mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
chore: Resolve master→3.x sync conflict (#41236)
This commit is contained in:
commit
bfd2d4db74
2
.github/test-metrics/e2e-impact-map.json
vendored
2
.github/test-metrics/e2e-impact-map.json
vendored
File diff suppressed because one or more lines are too long
24
OWNERS
24
OWNERS
|
|
@ -85,7 +85,6 @@ packages/cli/src/security-audit/ @n8n-io/catalysts
|
|||
packages/cli/src/modules/workflow-index/ @n8n-io/catalysts
|
||||
packages/cli/src/modules/breaking-changes/ @n8n-io/catalysts
|
||||
packages/cli/src/workflows/publication/ @n8n-io/catalysts required
|
||||
packages/cli/src/modules/otel/ @n8n-io/ligo
|
||||
|
||||
packages/cli/src/auth/ @n8n-io/iam
|
||||
packages/cli/src/credentials/ @n8n-io/iam
|
||||
|
|
@ -119,7 +118,7 @@ packages/cli/src/modules/data-table/ @n8n-io/knowledge
|
|||
packages/cli/src/evaluation.ee/ @n8n-io/ai-trust
|
||||
packages/cli/src/chat/ @n8n-io/ai
|
||||
packages/cli/src/tool-generation/ @n8n-io/ai
|
||||
packages/cli/src/modules/workflow-builder/ @n8n-io/ai-assistant
|
||||
packages/modules/workflow-builder/ @n8n-io/ai-assistant
|
||||
packages/cli/src/modules/mcp/ @n8n-io/context
|
||||
packages/modules/quick-connect/ @n8n-io/ai
|
||||
packages/cli/src/modules/chat-hub/ @n8n-io/ai-assistant
|
||||
|
|
@ -254,6 +253,27 @@ packages/@n8n/inbound-auth/ @n8n-io/iam
|
|||
|
||||
packages/modules/type-availability-policies/ @n8n-io/governance-and-observability
|
||||
packages/cli/src/modules/type-availability-policies/ @n8n-io/governance-and-observability
|
||||
packages/modules/otel/ @n8n-io/governance-and-observability
|
||||
packages/cli/src/modules/otel/ @n8n-io/governance-and-observability
|
||||
packages/cli/src/public-api/v1/controllers/otel.public.controller.ts @n8n-io/governance-and-observability
|
||||
packages/cli/src/public-api/v1/shared/otel.mapper.ts @n8n-io/governance-and-observability
|
||||
packages/cli/src/public-api/v1/controllers/node-type-policies.public.controller.ts @n8n-io/governance-and-observability
|
||||
packages/cli/src/public-api/v1/controllers/credential-type-policies.public.controller.ts @n8n-io/governance-and-observability
|
||||
packages/cli/src/public-api/v1/handlers/node-type-policies/ @n8n-io/governance-and-observability
|
||||
packages/cli/src/public-api/v1/handlers/credential-type-policies/ @n8n-io/governance-and-observability
|
||||
packages/cli/src/modules/policy-infrastructure/ @n8n-io/governance-and-observability
|
||||
packages/cli/src/policy/ @n8n-io/governance-and-observability
|
||||
packages/@n8n/decorators/src/policy-check/ @n8n-io/governance-and-observability
|
||||
packages/@n8n/api-types/src/dto/otel/ @n8n-io/governance-and-observability
|
||||
packages/@n8n/api-types/src/dto/type-availability-policies/ @n8n-io/governance-and-observability
|
||||
packages/@n8n/api-types/src/node-type-availability.ts @n8n-io/governance-and-observability
|
||||
packages/@n8n/api-types/src/credential-type-availability.ts @n8n-io/governance-and-observability
|
||||
packages/@n8n/api-types/src/schemas/policy-check-failure.schema.ts @n8n-io/governance-and-observability
|
||||
packages/@n8n/api-types/src/schemas/policy-violation.schema.ts @n8n-io/governance-and-observability
|
||||
packages/@n8n/telemetry/src/events/policies.ts @n8n-io/governance-and-observability
|
||||
packages/@n8n/telemetry/src/events/type-availability-policies.ts @n8n-io/governance-and-observability
|
||||
packages/workflow/src/errors/policy-refusal.ts @n8n-io/governance-and-observability
|
||||
packages/frontend/editor-ui/src/app/composables/usePolicyViolationToast.ts @n8n-io/governance-and-observability
|
||||
|
||||
# CI/CD
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -237,6 +237,68 @@ describe('parseObservationLogMarkdown', () => {
|
|||
]);
|
||||
expect(parsed.skippedLines).toEqual(['not a bullet']);
|
||||
});
|
||||
|
||||
it('appends indented unmarked detail lines to the entry above', () => {
|
||||
const parsed = parseObservationLogMarkdown(
|
||||
[
|
||||
"* CRITICAL (15:09) User's invoice rules:",
|
||||
' * Count only invoices with status paid or pending.',
|
||||
' - Status void means cancelled.',
|
||||
'* IMPORTANT (15:10) Open questions for the user',
|
||||
' * INFO (15:10) Setup is blocked on two answers.',
|
||||
' 1. Which bridge do they use?',
|
||||
' 2. Which webhook URL?',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
expect(parsed.entries).toEqual([
|
||||
{
|
||||
marker: 'critical',
|
||||
text: "User's invoice rules: Count only invoices with status paid or pending. Status void means cancelled.",
|
||||
parentIndex: null,
|
||||
},
|
||||
{ marker: 'important', text: 'Open questions for the user', parentIndex: null },
|
||||
{
|
||||
marker: 'info',
|
||||
text: 'Setup is blocked on two answers. 1. Which bridge do they use? 2. Which webhook URL?',
|
||||
parentIndex: 1,
|
||||
},
|
||||
]);
|
||||
expect(parsed.skippedLines).toEqual([]);
|
||||
});
|
||||
|
||||
it('joins a detail line with a semicolon when the entry has no closing punctuation', () => {
|
||||
const parsed = parseObservationLogMarkdown(
|
||||
['* INFO (10:02) Published version 90f4e3d6', ' * Current draft fbda5a15'].join('\n'),
|
||||
);
|
||||
|
||||
expect(parsed.entries).toEqual([
|
||||
{
|
||||
marker: 'info',
|
||||
text: 'Published version 90f4e3d6; Current draft fbda5a15',
|
||||
parentIndex: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps detail lines that start with an uppercase word', () => {
|
||||
const parsed = parseObservationLogMarkdown(
|
||||
[
|
||||
'* INFO (10:02) Endpoints checked:',
|
||||
' * GET /orders returns 200.',
|
||||
' * API key rotated.',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
expect(parsed.entries).toEqual([
|
||||
{
|
||||
marker: 'info',
|
||||
text: 'Endpoints checked: GET /orders returns 200. API key rotated.',
|
||||
parentIndex: null,
|
||||
},
|
||||
]);
|
||||
expect(parsed.skippedLines).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderObserverTranscript', () => {
|
||||
|
|
@ -520,6 +582,43 @@ describe('runObservationLogObserver', () => {
|
|||
},
|
||||
);
|
||||
|
||||
it('writes observations with indented detail lists and advances the cursor', async () => {
|
||||
const store = new InMemoryMemory();
|
||||
await store.saveThread({ id: 'thread-1', resourceId: 'user-1' });
|
||||
await store.saveMessages({
|
||||
threadId: 'thread-1',
|
||||
resourceId: 'user-1',
|
||||
messages: [message('m1', 'user', 'Remember my rules.', new Date(2026, 4, 12, 14, 30))],
|
||||
});
|
||||
|
||||
const result = await runObservationLogObserver({
|
||||
memory: store,
|
||||
observationScopeId: 'thread-1',
|
||||
observationLogTailLimit: 20,
|
||||
tokenCounter: () => 10,
|
||||
now: new Date(2026, 4, 12, 14, 31),
|
||||
observe: async () =>
|
||||
await Promise.resolve(
|
||||
[
|
||||
'* CRITICAL (14:31) User rules:',
|
||||
' * Count paid invoices.',
|
||||
' * Skip void ones.',
|
||||
].join('\n'),
|
||||
),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'ran',
|
||||
observationsWritten: 1,
|
||||
cursorAdvanced: true,
|
||||
skippedLines: [],
|
||||
});
|
||||
expect(await store.getActiveObservationLog({ observationScopeId: 'thread-1' })).toMatchObject([
|
||||
{ marker: 'critical', text: 'User rules: Count paid invoices. Skip void ones.' },
|
||||
]);
|
||||
expect(await store.getCursor('thread-1')).toMatchObject({ lastObservedMessageId: 'm1' });
|
||||
});
|
||||
|
||||
it('writes parsed observations and advances the cursor after observing', async () => {
|
||||
const store = new InMemoryMemory();
|
||||
const parentText = 'User needs the current request remembered.';
|
||||
|
|
@ -610,6 +709,12 @@ describe('runObservationLogObserver', () => {
|
|||
'```\nNO_OBSERVATIONS\n```',
|
||||
'NO_OBSERVATIONS\n* CRITICAL Keep this fact.',
|
||||
'* CRITICAL Keep this fact.\nUnformatted detail.',
|
||||
' Indented detail before any entry.\n* CRITICAL Keep this fact.',
|
||||
'* CRITICAL Keep this fact.\n * COMPLETED (14:31) Unknown marker.',
|
||||
'* CRITICAL Keep this fact.\n * COMPLETED Deploy is set for noon.',
|
||||
'* CRITICAL Keep this fact.\n * INFO: Marker with a colon.',
|
||||
'* CRITICAL Keep this fact.\n ```\n code\n ```',
|
||||
'* CRITICAL Keep this fact.\n NO_OBSERVATIONS',
|
||||
'* INFO ',
|
||||
'```\n* CRITICAL Keep this fact.\n```',
|
||||
])('rejects an invalid response without changing memory: %j', async (output) => {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ A valid response has exactly one of these forms:
|
|||
|
||||
Use only one indentation level. A child attaches to the preceding top-level bullet in this response. Never omit the \`*\` prefix.
|
||||
|
||||
Every line is one observation with its own marker. To add details to an observation, write each detail as a marked child, or put the details on the same line separated by semicolons. Never write a plain sub-list, a numbered list, or any line without a marker.
|
||||
|
||||
2. Exactly this marker when there is nothing durable to record:
|
||||
|
||||
NO_OBSERVATIONS
|
||||
|
|
@ -90,6 +92,16 @@ GOOD:
|
|||
* CRITICAL (14:30) User approved the data migration. Deployment requires separate approval.
|
||||
* IMPORTANT (14:31) Agent plans to start the migration; completion is not verified.
|
||||
|
||||
Details go on marked lines
|
||||
|
||||
BAD:
|
||||
* CRITICAL (15:09) User's invoice rules:
|
||||
* Count only invoices with status paid or pending.
|
||||
- Status void means cancelled.
|
||||
|
||||
GOOD:
|
||||
* CRITICAL (15:09) User's invoice rules: count only invoices with status paid or pending; status void means cancelled.
|
||||
|
||||
Keep verification evidence together
|
||||
|
||||
Transcript:
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ const MARKER_BY_TOKEN: Record<string, ObservationLogMarker> = {
|
|||
|
||||
const BULLET_PATTERN =
|
||||
/^(\s*)[*-]\s+(CRITICAL|IMPORTANT|INFO|COMPLETION)(?:\s+\(\d{2}:\d{2}\))?\s+(.+)$/iu;
|
||||
/**
|
||||
* Indented lines that are malformed structure, not detail: a fence, the sentinel, a timed
|
||||
* unknown marker, or a misspelled marker. Other uppercase words (API, GET) stay detail.
|
||||
*/
|
||||
const NON_DETAIL_PATTERN =
|
||||
/^(?:```|NO_OBSERVATIONS$|(?:[*-]\s+)?(?:[A-Z_]+\s+\(\d{2}:\d{2}\)|(?:COMPLET|CRITIC|IMPORTAN|INFO)[A-Z]*\b))/u;
|
||||
const DEFAULT_MAX_SERIALIZED_CHARS = 2_000;
|
||||
const DEFAULT_MAX_STRING_CHARS = 500;
|
||||
const DEFAULT_MAX_ARRAY_ITEMS = 20;
|
||||
|
|
@ -119,6 +125,13 @@ export function parseObservationLogMarkdown(markdown: string): ParseObservationL
|
|||
|
||||
const match = BULLET_PATTERN.exec(rawLine);
|
||||
if (!match) {
|
||||
// Models often add an indented detail list under an observation. Keep
|
||||
// that detail on the entry above instead of rejecting the response.
|
||||
const previous = entries.at(-1);
|
||||
if (previous && /^\s/.test(rawLine) && !NON_DETAIL_PATTERN.test(trimmed)) {
|
||||
previous.text = appendObservationDetail(previous.text, trimmed);
|
||||
continue;
|
||||
}
|
||||
skippedLines.push(rawLine);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -141,6 +154,12 @@ export function parseObservationLogMarkdown(markdown: string): ParseObservationL
|
|||
return { entries, skippedLines };
|
||||
}
|
||||
|
||||
/** Joins a detail line onto one line, so the rendered log keeps one entry per line. */
|
||||
function appendObservationDetail(text: string, detailLine: string): string {
|
||||
const detail = detailLine.replace(/^[*-]\s+/, '');
|
||||
return /[:;.!?]$/.test(text) ? `${text} ${detail}` : `${text}; ${detail}`;
|
||||
}
|
||||
|
||||
export function renderObserverTranscript(
|
||||
messages: AgentDbMessage[],
|
||||
options: RenderObserverTranscriptOptions = {},
|
||||
|
|
|
|||
|
|
@ -63,4 +63,72 @@ describe('n8nDefaultFailedAttemptHandler', () => {
|
|||
const error = new Error('Some random error');
|
||||
expect(() => n8nDefaultFailedAttemptHandler(error)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not retry a 413', () => {
|
||||
const error = new MockHttpError('Too large', 413);
|
||||
expect(() => n8nDefaultFailedAttemptHandler(error)).toThrow(error);
|
||||
});
|
||||
|
||||
describe('429', () => {
|
||||
it.each([
|
||||
[
|
||||
'top-level code',
|
||||
Object.assign(new Error('quota'), { status: 429, code: 'insufficient_quota' }),
|
||||
],
|
||||
[
|
||||
'nested code',
|
||||
Object.assign(new Error('quota'), { status: 429, error: { code: 'insufficient_quota' } }),
|
||||
],
|
||||
])('should throw when a 429 carries the insufficient_quota code (%s)', (_, error) => {
|
||||
expect(() => n8nDefaultFailedAttemptHandler(error)).toThrow(error);
|
||||
expect(error).not.toHaveProperty('retryAfterMs');
|
||||
});
|
||||
|
||||
it('should throw when a 429 message says the quota is exhausted', () => {
|
||||
const error = Object.assign(new Error('Your credit balance is too low'), { status: 429 });
|
||||
expect(() => n8nDefaultFailedAttemptHandler(error)).toThrow(error);
|
||||
expect(error).not.toHaveProperty('retryAfterMs');
|
||||
});
|
||||
|
||||
it('should read Retry-After from the message when there is no header', () => {
|
||||
const error = Object.assign(new Error('Rate limited. Please try again in 20 seconds.'), {
|
||||
status: 429,
|
||||
});
|
||||
expect(() => n8nDefaultFailedAttemptHandler(error)).not.toThrow();
|
||||
expect(error).toHaveProperty('retryAfterMs', 20000);
|
||||
});
|
||||
|
||||
it('should not throw for a 429 without a Retry-After header', () => {
|
||||
const error = new MockHttpError('Rate limited', 429);
|
||||
expect(() => n8nDefaultFailedAttemptHandler(error)).not.toThrow();
|
||||
expect(error).not.toHaveProperty('retryAfterMs');
|
||||
});
|
||||
|
||||
it('should pass Retry-After to the retry loop', () => {
|
||||
const error = Object.assign(new Error('Rate limited'), {
|
||||
status: 429,
|
||||
headers: new Headers({ 'retry-after': '5' }),
|
||||
});
|
||||
expect(() => n8nDefaultFailedAttemptHandler(error)).not.toThrow();
|
||||
expect(error).toHaveProperty('retryAfterMs', 5000);
|
||||
});
|
||||
|
||||
it('should pass a Retry-After of exactly a minute to the retry loop', () => {
|
||||
const error = Object.assign(new Error('Rate limited'), {
|
||||
status: 429,
|
||||
headers: new Headers({ 'retry-after': '60' }),
|
||||
});
|
||||
expect(() => n8nDefaultFailedAttemptHandler(error)).not.toThrow();
|
||||
expect(error).toHaveProperty('retryAfterMs', 60000);
|
||||
});
|
||||
|
||||
it('should throw when Retry-After is longer than a minute', () => {
|
||||
const error = Object.assign(new Error('Rate limited'), {
|
||||
status: 429,
|
||||
headers: new Headers({ 'retry-after': '120' }),
|
||||
});
|
||||
expect(() => n8nDefaultFailedAttemptHandler(error)).toThrow(error);
|
||||
expect(error).not.toHaveProperty('retryAfterMs');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { AsyncCaller } from '@langchain/core/utils/async_caller';
|
||||
import type { ISupplyDataFunctions } from 'n8n-workflow';
|
||||
import { NodeApiError, UnexpectedError } from 'n8n-workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
|
@ -9,17 +10,17 @@ describe('makeN8nLlmFailedAttemptHandler', () => {
|
|||
getNode: vi.fn(),
|
||||
});
|
||||
|
||||
it('should throw a wrapped error, when NO custom handler is provided', () => {
|
||||
it('should not throw a retryable error, when NO custom handler is provided', () => {
|
||||
const handler = makeN8nLlmFailedAttemptHandler(ctx);
|
||||
|
||||
expect(() => handler(new Error('Test error'))).toThrow(NodeApiError);
|
||||
expect(() => handler(new Error('Test error'))).not.toThrow();
|
||||
});
|
||||
|
||||
it('should wrapped error when custom handler is provided', () => {
|
||||
it('should not throw a retryable error when custom handler is provided', () => {
|
||||
const customHandler = vi.fn();
|
||||
const handler = makeN8nLlmFailedAttemptHandler(ctx, customHandler);
|
||||
|
||||
expect(() => handler(new Error('Test error'))).toThrow(NodeApiError);
|
||||
expect(() => handler(new Error('Test error'))).not.toThrow();
|
||||
expect(customHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -33,33 +34,78 @@ describe('makeN8nLlmFailedAttemptHandler', () => {
|
|||
expect(customHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not throw if retries are left', () => {
|
||||
const customHandler = vi.fn();
|
||||
const handler = makeN8nLlmFailedAttemptHandler(ctx, customHandler);
|
||||
it('should throw NodeApiError when the default handler rejects and the custom one does not', () => {
|
||||
const handler = makeN8nLlmFailedAttemptHandler(ctx, vi.fn());
|
||||
|
||||
const error = new Error('Test error');
|
||||
(error as any).retriesLeft = 1;
|
||||
|
||||
expect(() => handler(error)).not.toThrow();
|
||||
expect(() => handler(Object.assign(new Error('Bad request'), { status: 400 }))).toThrow(
|
||||
NodeApiError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NodeApiError if no retries are left', () => {
|
||||
const handler = makeN8nLlmFailedAttemptHandler(ctx);
|
||||
describe('through AsyncCaller', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
const error = new Error('Test error');
|
||||
(error as any).retriesLeft = 0;
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
expect(() => handler(error)).toThrow(NodeApiError);
|
||||
});
|
||||
const rateLimited = () => Object.assign(new Error('429'), { status: 429 });
|
||||
|
||||
it('should throw NodeApiError if no retries are left with custom handler', () => {
|
||||
const customHandler = vi.fn();
|
||||
const handler = makeN8nLlmFailedAttemptHandler(ctx, customHandler);
|
||||
it('should retry through AsyncCaller up to maxRetries', async () => {
|
||||
const caller = new AsyncCaller({
|
||||
maxRetries: 2,
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(ctx),
|
||||
});
|
||||
const callable = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(rateLimited())
|
||||
.mockRejectedValueOnce(rateLimited())
|
||||
.mockResolvedValueOnce('ok');
|
||||
|
||||
const error = new Error('Test error');
|
||||
(error as any).retriesLeft = 0;
|
||||
const pending = caller.call(callable);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(() => handler(error)).toThrow(NodeApiError);
|
||||
expect(customHandler).toHaveBeenCalled();
|
||||
await expect(pending).resolves.toBe('ok');
|
||||
expect(callable).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should surface the last error when retries are spent', async () => {
|
||||
const caller = new AsyncCaller({
|
||||
maxRetries: 2,
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(ctx),
|
||||
});
|
||||
const error = rateLimited();
|
||||
const callable = vi.fn().mockRejectedValue(error);
|
||||
|
||||
const pending = caller.call(callable);
|
||||
// Attach the rejection handler before the timers drain so the rejection is not unhandled
|
||||
const result = expect(pending).rejects.toBe(error);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
await result;
|
||||
expect(callable).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should honour retryAfterMs in the delay', async () => {
|
||||
const caller = new AsyncCaller({
|
||||
maxRetries: 2,
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(ctx),
|
||||
});
|
||||
const error = Object.assign(new Error('429'), {
|
||||
status: 429,
|
||||
headers: new Headers({ 'retry-after': '5' }),
|
||||
});
|
||||
const callable = vi.fn().mockRejectedValueOnce(error).mockResolvedValueOnce('ok');
|
||||
|
||||
const pending = caller.call(callable);
|
||||
await vi.advanceTimersByTimeAsync(4999);
|
||||
expect(callable).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(callable).toHaveBeenCalledTimes(2);
|
||||
await expect(pending).resolves.toBe('ok');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -73,6 +73,21 @@ describe('N8nPdfLoader', () => {
|
|||
expect(docs[2].metadata.loc).toEqual({ pageNumber: 3 });
|
||||
});
|
||||
|
||||
it('returns custom document info entries as a plain object', async () => {
|
||||
mockGetText.mockResolvedValue({ pages: [{ num: 1, text: 'Body' }], text: 'Body', total: 1 });
|
||||
mockGetInfo.mockResolvedValue({
|
||||
info: { Title: 'Sample', Custom: new Map([['Department', 'Finance']]) },
|
||||
metadata: undefined,
|
||||
});
|
||||
|
||||
const docs = await new N8nPdfLoader(makeBlob()).load();
|
||||
|
||||
expect(docs[0].metadata.pdf.info).toEqual({
|
||||
Title: 'Sample',
|
||||
Custom: { Department: 'Finance' },
|
||||
});
|
||||
});
|
||||
|
||||
it('concatenates pages into a single Document when splitPages is false', async () => {
|
||||
mockGetText.mockResolvedValue({
|
||||
pages: [
|
||||
|
|
|
|||
|
|
@ -83,4 +83,15 @@ describe('N8nBinaryLoader with real PDF data', () => {
|
|||
PDFParse.setWorker(originalWorker);
|
||||
}
|
||||
});
|
||||
|
||||
it('releases a real PDF after parsing', async () => {
|
||||
const parser = new PDFParse({
|
||||
data: readFileSync(
|
||||
join(__dirname, '../../../../../nodes-base/nodes/ReadPdf/test/sample.pdf'),
|
||||
),
|
||||
});
|
||||
|
||||
await expect(parser.getText()).resolves.toMatchObject({ total: 1 });
|
||||
await expect(parser.destroy()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { classifyRateLimitError } from '@langchain/core/utils/async_caller';
|
||||
|
||||
const STATUS_NO_RETRY = [
|
||||
400, // Bad Request
|
||||
401, // Unauthorized
|
||||
|
|
@ -8,12 +10,16 @@ const STATUS_NO_RETRY = [
|
|||
406, // Not Acceptable
|
||||
407, // Proxy Authentication Required
|
||||
409, // Conflict
|
||||
413, // Payload Too Large
|
||||
];
|
||||
|
||||
/**
|
||||
* This function is used as a default handler for failed attempts in all LLMs.
|
||||
* It is based on a default handler from the langchain core package.
|
||||
* It throws an error when it encounters a known error that should not be retried.
|
||||
* A 429 is classified by `classifyRateLimitError`: it is not retried when the code or message
|
||||
* signals an exhausted quota, or when Retry-After is over 60 s. Other 429s are retried, and a
|
||||
* Retry-After within that limit is copied to `retryAfterMs` so the retry loop waits that long.
|
||||
* @param error
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
@ -38,4 +44,13 @@ export const n8nDefaultFailedAttemptHandler = (error: any) => {
|
|||
if (status && STATUS_NO_RETRY.includes(+status)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const rateLimit = classifyRateLimitError(error);
|
||||
if (rateLimit) {
|
||||
if (rateLimit.action === 'stop') throw error;
|
||||
if (rateLimit.action === 'capacity' && rateLimit.retryAfterMs !== undefined) throw error;
|
||||
if (rateLimit.retryAfterMs !== undefined) {
|
||||
Object.assign(error, { retryAfterMs: rateLimit.retryAfterMs });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { n8nDefaultFailedAttemptHandler } from './n8nDefaultFailedAttemptHandler
|
|||
/**
|
||||
* This function returns a custom failed attempt handler for using with LangChain models.
|
||||
* It first tries to use a custom handler passed as an argument, and if that doesn't throw an error, it uses the default handler.
|
||||
* It always wraps the error in a NodeApiError.
|
||||
* It throws an error ONLY if there are no retries left.
|
||||
* It throws a NodeApiError only when a handler rejects the error.
|
||||
* Otherwise it returns, and AsyncCaller retries with backoff.
|
||||
*/
|
||||
export const makeN8nLlmFailedAttemptHandler = (
|
||||
ctx: ISupplyDataFunctions,
|
||||
|
|
@ -29,18 +29,5 @@ export const makeN8nLlmFailedAttemptHandler = (
|
|||
|
||||
throw apiError;
|
||||
}
|
||||
|
||||
// If no error was thrown, check if it is the last retry
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
if (error?.retriesLeft > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there are no retries left, throw the error wrapped in a NodeApiError
|
||||
const apiError = new NodeApiError(ctx.getNode(), error as JsonObject, {
|
||||
functionality: 'configuration-node',
|
||||
});
|
||||
|
||||
throw apiError;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export class N8nPdfLoader extends BufferLoader {
|
|||
});
|
||||
|
||||
const pdfMeta = {
|
||||
info: info?.info,
|
||||
info: toPlainInfo(info?.info),
|
||||
metadata: info?.metadata,
|
||||
totalPages: result.total,
|
||||
};
|
||||
|
|
@ -83,3 +83,10 @@ export class N8nPdfLoader extends BufferLoader {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** pdfjs-dist returns custom info entries as a Map, which JSON serializes as an empty object. */
|
||||
function toPlainInfo(info: unknown): unknown {
|
||||
if (typeof info !== 'object' || info === null || !('Custom' in info)) return info;
|
||||
if (!(info.Custom instanceof Map)) return info;
|
||||
return { ...info, Custom: Object.fromEntries(info.Custom) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
connectRequiredSubnodeInputs,
|
||||
describeAddedSubnodeConnection,
|
||||
parseWorkflowCodeToBuilder,
|
||||
resolveNodePorts,
|
||||
validateWorkflow,
|
||||
workflow,
|
||||
} from '@n8n/workflow-sdk';
|
||||
|
|
@ -308,7 +309,14 @@ export class ParseValidateHandler {
|
|||
);
|
||||
|
||||
// Convert to JSON with Dagre layout matching the FE's tidy-up
|
||||
const workflowJson: WorkflowJSON = builder.toJSON({ tidyUp: true, existingGroupIdsByName });
|
||||
const nodePorts = this.nodeTypesProvider
|
||||
? await resolveNodePorts(json, this.nodeTypesProvider)
|
||||
: undefined;
|
||||
const workflowJson: WorkflowJSON = builder.toJSON({
|
||||
tidyUp: true,
|
||||
existingGroupIdsByName,
|
||||
nodePorts,
|
||||
});
|
||||
// A fresh serialization, so the links added above have to be re-added.
|
||||
for (const link of this.connectRequiredInputs(workflowJson)) {
|
||||
allWarnings.push(describeAddedSubnodeConnection(link));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import type { INodeType, INodeTypeDescription, INodeTypes } from 'n8n-workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { ParseValidateHandler } from '../parse-validate-handler';
|
||||
|
||||
describe('ParseValidateHandler layout', () => {
|
||||
it('keeps a free-floating sticky clear of a Switch with unwired outputs', async () => {
|
||||
const nodeType = (
|
||||
name: string,
|
||||
description: Partial<INodeTypeDescription> = {},
|
||||
): INodeType => ({
|
||||
description: {
|
||||
name,
|
||||
displayName: name,
|
||||
description: '',
|
||||
version: 1,
|
||||
defaults: { name },
|
||||
group: [],
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
...description,
|
||||
},
|
||||
});
|
||||
const nodeTypes: Record<string, INodeType> = {
|
||||
'n8n-nodes-base.manualTrigger': nodeType('n8n-nodes-base.manualTrigger', {
|
||||
inputs: [],
|
||||
group: ['trigger'],
|
||||
}),
|
||||
'n8n-nodes-base.switch': nodeType('n8n-nodes-base.switch', {
|
||||
version: 3.2,
|
||||
outputs: '={{ Array($parameter.numberOutputs).fill("main") }}',
|
||||
properties: [{ displayName: 'Outputs', name: 'numberOutputs', type: 'number', default: 4 }],
|
||||
}),
|
||||
'n8n-nodes-base.noOp': nodeType('n8n-nodes-base.noOp'),
|
||||
'n8n-nodes-base.stickyNote': nodeType('n8n-nodes-base.stickyNote', {
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
}),
|
||||
};
|
||||
const nodeTypesProvider = mock<INodeTypes>({
|
||||
getByNameAndVersion: (name) => nodeTypes[name],
|
||||
});
|
||||
const handler = new ParseValidateHandler({ nodeTypesProvider, generatePinData: false });
|
||||
|
||||
const { workflow } = await handler.parseAndValidate(`
|
||||
const start = trigger({
|
||||
type: 'n8n-nodes-base.manualTrigger', version: 1, config: { name: 'Start' }
|
||||
});
|
||||
const router = node({
|
||||
type: 'n8n-nodes-base.switch', version: 3.2,
|
||||
config: { name: 'Switch', parameters: { mode: 'expression', numberOutputs: 8 } }
|
||||
});
|
||||
const done = node({
|
||||
type: 'n8n-nodes-base.noOp', version: 1, config: { name: 'Done' }
|
||||
});
|
||||
workflow('wf', 'Test')
|
||||
.add(start.to(router.onCase(0, done)))
|
||||
.add(sticky('## Workflow notes\\nRun this workflow manually.', { name: 'Notes' }));
|
||||
`);
|
||||
|
||||
expect(workflow.connections.Switch.main).toEqual([[{ node: 'Done', type: 'main', index: 0 }]]);
|
||||
const note = workflow.nodes.find(({ name }) => name === 'Notes');
|
||||
expect(note).toBeDefined();
|
||||
if (!note) throw new Error('Notes not found');
|
||||
const [noteX, noteY] = note.position;
|
||||
const overlappingNodes = workflow.nodes
|
||||
.filter(({ name }) => name !== 'Notes')
|
||||
.filter(({ name, position: [x, y] }) => {
|
||||
// Eight main outputs make the Switch 288 pixels tall on the canvas.
|
||||
const height = name === 'Switch' ? 288 : 96;
|
||||
return x < noteX + 240 && noteX < x + 96 && y < noteY + 160 && noteY < y + height;
|
||||
})
|
||||
.map(({ name }) => name);
|
||||
|
||||
expect(overlappingNodes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -18,6 +18,7 @@ import { ParseValidateHandler } from '../parse-validate-handler';
|
|||
// Mock the workflow-sdk module
|
||||
vi.mock('@n8n/workflow-sdk', () => ({
|
||||
parseWorkflowCodeToBuilder: vi.fn(),
|
||||
resolveNodePorts: vi.fn(),
|
||||
validateWorkflow: vi.fn(),
|
||||
workflow: { fromJSON: vi.fn() },
|
||||
stripImportStatements: vi.fn((code: string) => code),
|
||||
|
|
@ -388,6 +389,7 @@ describe('ParseValidateHandler', () => {
|
|||
|
||||
expect(mockBuilder.toJSON).toHaveBeenCalledWith({
|
||||
tidyUp: true,
|
||||
nodePorts: undefined,
|
||||
existingGroupIdsByName: new Map([
|
||||
['Ingestion', 'group-random-1'],
|
||||
['Processing', 'group-random-2'],
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ describe('WorkflowBuilderAgent', () => {
|
|||
mockCheckpointer = mock<MemorySaver>();
|
||||
mockCheckpointer.getTuple = vi.fn();
|
||||
mockCheckpointer.put = vi.fn();
|
||||
mockCheckpointer.putWrites = vi.fn().mockResolvedValue(undefined);
|
||||
mockCheckpointer.list = vi.fn();
|
||||
|
||||
parsedNodeTypes = [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
import type { BaseChannel, BinaryOperator } from '@langchain/langgraph';
|
||||
import { BinaryOperatorAggregate } from '@langchain/langgraph';
|
||||
|
||||
import type { TelemetryValidationStatus } from '../validation/types';
|
||||
import { WorkflowState } from '../workflow-state';
|
||||
|
||||
/** Returns the reducer of a state field. The state spec types each field as a plain channel. */
|
||||
function getReducer<Value, Update>(channel: BaseChannel<Value, Update>) {
|
||||
if (!(channel instanceof BinaryOperatorAggregate)) {
|
||||
throw new Error('Expected a state field with a reducer');
|
||||
}
|
||||
return channel.operator as BinaryOperator<Value, Update>;
|
||||
}
|
||||
|
||||
describe('WorkflowState.validationHistory reducer', () => {
|
||||
// Helper to create TelemetryValidationStatus avoiding ESLint naming-convention warnings
|
||||
const createValidationStatus = (
|
||||
|
|
@ -14,7 +25,7 @@ describe('WorkflowState.validationHistory reducer', () => {
|
|||
};
|
||||
|
||||
it('should append new validation history to existing history', () => {
|
||||
const reducer = WorkflowState.spec.validationHistory.operator;
|
||||
const reducer = getReducer(WorkflowState.spec.validationHistory);
|
||||
|
||||
const existingHistory: TelemetryValidationStatus[] = [
|
||||
createValidationStatus([
|
||||
|
|
@ -45,7 +56,7 @@ describe('WorkflowState.validationHistory reducer', () => {
|
|||
});
|
||||
|
||||
it('should handle empty existing history with new updates', () => {
|
||||
const reducer = WorkflowState.spec.validationHistory.operator;
|
||||
const reducer = getReducer(WorkflowState.spec.validationHistory);
|
||||
|
||||
const newHistory: TelemetryValidationStatus[] = [
|
||||
createValidationStatus([
|
||||
|
|
@ -65,7 +76,7 @@ describe('WorkflowState.validationHistory reducer', () => {
|
|||
x: TelemetryValidationStatus[],
|
||||
y: TelemetryValidationStatus[] | undefined | null,
|
||||
) => TelemetryValidationStatus[];
|
||||
const reducer = WorkflowState.spec.validationHistory.operator as ReducerFn;
|
||||
const reducer = getReducer(WorkflowState.spec.validationHistory) as ReducerFn;
|
||||
|
||||
let history: TelemetryValidationStatus[] = [];
|
||||
|
||||
|
|
@ -102,7 +113,7 @@ describe('WorkflowState.validationHistory reducer', () => {
|
|||
|
||||
describe('WorkflowState.techniqueCategories reducer', () => {
|
||||
it('should append new technique categories to existing categories', () => {
|
||||
const reducer = WorkflowState.spec.techniqueCategories.operator;
|
||||
const reducer = getReducer(WorkflowState.spec.techniqueCategories);
|
||||
|
||||
const existingCategories = ['scraping', 'data-transformation'];
|
||||
const newCategories = ['notifications', 'scheduling'];
|
||||
|
|
@ -115,7 +126,7 @@ describe('WorkflowState.techniqueCategories reducer', () => {
|
|||
|
||||
it('should return existing categories when update is undefined', () => {
|
||||
type ReducerFn = (x: string[], y: string[] | undefined | null) => string[];
|
||||
const reducer = WorkflowState.spec.techniqueCategories.operator as ReducerFn;
|
||||
const reducer = getReducer(WorkflowState.spec.techniqueCategories) as ReducerFn;
|
||||
|
||||
const existingCategories = ['api-integration', 'webhook'];
|
||||
|
||||
|
|
@ -126,7 +137,7 @@ describe('WorkflowState.techniqueCategories reducer', () => {
|
|||
});
|
||||
|
||||
it('should handle empty existing categories with new updates', () => {
|
||||
const reducer = WorkflowState.spec.techniqueCategories.operator;
|
||||
const reducer = getReducer(WorkflowState.spec.techniqueCategories);
|
||||
|
||||
const newCategories = ['email-automation', 'file-processing'];
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
import { skillNameKey } from '../skill-names';
|
||||
|
||||
describe('skillNameKey', () => {
|
||||
it.each([
|
||||
['Brand voice', 'brand-voice'],
|
||||
['Brand voice', 'brand_voice'],
|
||||
['Brand voice', ' BRAND voice '],
|
||||
['Brand voice', 'brand -_ voice'],
|
||||
])('treats "%s" and "%s" as the same name', (left, right) => {
|
||||
expect(skillNameKey(left)).toBe(skillNameKey(right));
|
||||
});
|
||||
|
||||
it('keeps other characters apart', () => {
|
||||
expect(skillNameKey('Brand voice')).not.toBe(skillNameKey('Brand voices'));
|
||||
expect(skillNameKey('brand.voice')).not.toBe(skillNameKey('brand voice'));
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@ import {
|
|||
CreateSkillDto,
|
||||
ListSkillsQueryDto,
|
||||
SaveSkillDto,
|
||||
SkillNewNameQueryDto,
|
||||
SKILLS_MAX_PAGE_SIZE,
|
||||
} from '../skills.schema';
|
||||
|
||||
|
|
@ -81,3 +82,15 @@ describe('SaveSkillDto', () => {
|
|||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('SkillNewNameQueryDto', () => {
|
||||
it('trims the requested name', () => {
|
||||
expect(SkillNewNameQueryDto.parse({ scope: 'instance', name: ' New skill ' }).name).toBe(
|
||||
'New skill',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a name of only spaces', () => {
|
||||
expect(SkillNewNameQueryDto.safeParse({ scope: 'instance', name: ' ' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -63,8 +63,10 @@ const agentSkillReferencesSchema = z
|
|||
* config as `{ type: 'skill', id }` refs (mirroring tasks), so it is
|
||||
* intentionally not part of the body.
|
||||
*/
|
||||
export const AGENT_SKILL_NAME_MAX_LENGTH = 128;
|
||||
|
||||
export const agentSkillShape = {
|
||||
name: z.string().min(1).max(128),
|
||||
name: z.string().min(1).max(AGENT_SKILL_NAME_MAX_LENGTH),
|
||||
description: z.string().min(1).max(512),
|
||||
// Measured in characters like the name, description and reference limits,
|
||||
// so the editor can show the user the same count the server enforces.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export * from './agent-node-tool-operations';
|
|||
export * from './agent-personalisation';
|
||||
export * from './agent-skill.schema';
|
||||
export * from './skills.schema';
|
||||
export * from './skill-names';
|
||||
export * from './child-trace';
|
||||
export type * from './background-job';
|
||||
export * from './inline-agent-config.schema';
|
||||
|
|
|
|||
11
packages/@n8n/api-types/src/agents/skill-names.ts
Normal file
11
packages/@n8n/api-types/src/agents/skill-names.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* The rule for two skill names that are hard to tell apart inside one agent: case,
|
||||
* surrounding spaces, and runs of spaces, hyphens and underscores do not count.
|
||||
*/
|
||||
export function skillNameKey(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_-]+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
|
@ -1,16 +1,15 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { agentSkillSchema, agentSkillShape } from './agent-skill.schema';
|
||||
import {
|
||||
AGENT_SKILL_NAME_MAX_LENGTH,
|
||||
agentSkillSchema,
|
||||
agentSkillShape,
|
||||
} from './agent-skill.schema';
|
||||
import type { AgentSkill } from './types';
|
||||
import type { ProjectType } from '../schemas/project.schema';
|
||||
import { paginationSchema } from '../dto/pagination/pagination.dto';
|
||||
import { Z } from '../zod-class';
|
||||
|
||||
/**
|
||||
* PostHog flag for the rollout of the skills hub, read for the instance once at startup.
|
||||
* Off: agents keep their inline skills and the skills routes answer 404.
|
||||
*/
|
||||
export const SKILLS_FLAG = '125_context_skills';
|
||||
|
||||
export const SKILLS_MAX_PAGE_SIZE = 100;
|
||||
|
||||
/** Optional on purpose: without `take` the caller gets every visible skill. */
|
||||
|
|
@ -30,6 +29,14 @@ export type SkillScope = z.infer<typeof skillScopeSchema>;
|
|||
|
||||
export type SkillSource = 'ui' | 'agent';
|
||||
|
||||
/** The user who created a skill, as the settings list names them. */
|
||||
export type SkillUserDto = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
};
|
||||
|
||||
/** One row of the skills list. Name and description come from the latest version. */
|
||||
export type SkillListItem = {
|
||||
id: string;
|
||||
|
|
@ -39,10 +46,14 @@ export type SkillListItem = {
|
|||
/** The project of a project skill, or the project of the agent of an agent skill. */
|
||||
projectId: string | null;
|
||||
projectName: string | null;
|
||||
/** Lets the editor name another member's personal project, which it does not list. */
|
||||
projectType: ProjectType | null;
|
||||
/** Set for an agent skill: the only agent that can use it. */
|
||||
agentId: string | null;
|
||||
agentName: string | null;
|
||||
source: SkillSource;
|
||||
/** Null when the creator is unknown or the user no longer exists. */
|
||||
createdBy: SkillUserDto | null;
|
||||
/** Number of the latest version, the one following agents run. */
|
||||
latestVersion: number;
|
||||
/** Distinct agents that use the skill: draft refs plus published pins. */
|
||||
|
|
@ -106,6 +117,25 @@ export class ListSkillsQueryDto extends Z.class({
|
|||
attachableTo: z.string().max(36).optional(),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* Asks for a name that no skill in the scope has yet, as `/workflows/new` does for a
|
||||
* workflow: the requested name, or the same name with the first free number after it.
|
||||
*/
|
||||
export class SkillNewNameQueryDto extends Z.class({
|
||||
scope: skillScopeSchema,
|
||||
/** Required for a project skill, absent otherwise. */
|
||||
projectId: z.string().max(36).optional(),
|
||||
/** Required for an agent skill, absent otherwise. */
|
||||
agentId: z.string().max(36).optional(),
|
||||
/** Trimmed first, so a name of only spaces is refused instead of suggested as "". */
|
||||
name: z.string().trim().min(1).max(AGENT_SKILL_NAME_MAX_LENGTH),
|
||||
}) {}
|
||||
|
||||
export type SkillNewNameResponse = { name: string };
|
||||
|
||||
/** The 409 message for a skill name that its scope already holds. The editor matches it. */
|
||||
export const SKILL_NAME_TAKEN_MESSAGE = 'That name is taken, choose another';
|
||||
|
||||
export class CreateSkillDto extends Z.class({
|
||||
scope: skillScopeSchema,
|
||||
/** Required for a project skill, absent otherwise. */
|
||||
|
|
@ -126,4 +156,11 @@ export class SaveSkillDto extends Z.class({
|
|||
instructions: agentSkillShape.instructions.optional(),
|
||||
references: agentSkillShape.references.optional(),
|
||||
baseSkillHash: z.string().min(1),
|
||||
/**
|
||||
* Moves the skill to the instance or to a project. Refused with 409 while an agent
|
||||
* uses it. An agent skill is made from inside its agent, so `agent` is not a choice.
|
||||
*/
|
||||
scope: z.enum(['project', 'instance']).optional(),
|
||||
/** Required when `scope` is `project`, absent otherwise. */
|
||||
projectId: z.string().max(36).optional(),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -734,6 +734,18 @@ export {
|
|||
type PromotionProviderType,
|
||||
type PromotionSshKeyType,
|
||||
} from './promotions/promotion-provider.dto';
|
||||
export {
|
||||
ListPromotionBranchesQueryDto,
|
||||
ListPromotionRepositoriesQueryDto,
|
||||
MAX_PROMOTION_DISCOVERY_ITEMS_PER_PAGE,
|
||||
PromotionBranchListPublicDto,
|
||||
PromotionRepositoryListPublicDto,
|
||||
promotionBranchSchema,
|
||||
promotionRepositoryIdParamSchema,
|
||||
promotionRepositorySchema,
|
||||
type PromotionBranch,
|
||||
type PromotionRepository,
|
||||
} from './promotions/promotion-repository.dto';
|
||||
export {
|
||||
PromotionApplyConfigPublicDto,
|
||||
PromotionPromoteConfigPublicDto,
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ describe('ImportResultDto', () => {
|
|||
workflows: { 'wf-1': 'wf-1' },
|
||||
credentials: { 'cred-1': 'cred-1' },
|
||||
},
|
||||
credentials: { matched: ['cred-1'], stubbed: [], seeded: [] },
|
||||
credentials: { matched: ['cred-1'], stubbed: [], seeded: [], incomplete: [] },
|
||||
dataTables: { matched: 1, created: 0, updated: 0 },
|
||||
variables: { matched: [], missing: [], created: [], stubbed: [], updated: [] },
|
||||
tags: { matched: [], created: [], renamed: [], reconciled: [], skipped: [] },
|
||||
|
|
@ -91,7 +91,7 @@ describe('ImportResultDto', () => {
|
|||
folders: [],
|
||||
projects: [],
|
||||
bindings: { workflows: {}, credentials: {} },
|
||||
credentials: { matched: [], stubbed: [], seeded: [] },
|
||||
credentials: { matched: [], stubbed: [], seeded: [], incomplete: [] },
|
||||
dataTables: { matched: 0, created: 0, updated: 0 },
|
||||
variables: { matched: [], missing: [], created: [], stubbed: [], updated: [] },
|
||||
tags: { matched: [], created: [], renamed: [], reconciled: [], skipped: [] },
|
||||
|
|
|
|||
|
|
@ -25,10 +25,11 @@ export const importPackageRequestFieldDocs = {
|
|||
'creates empty credential placeholders in the target project for missing references. ' +
|
||||
'`create-with-values` also creates them, seeded from the expression data bundled in the ' +
|
||||
'package (see the export option `credentialExportPolicy`); a reference the package ' +
|
||||
'carries no data for falls back to an empty stub. Seeded credentials are listed under ' +
|
||||
'`credentials.seeded` and only allow workflow publishing when the package confirms ' +
|
||||
'that all source data was retained. Partial or unverified data blocks publishing ' +
|
||||
'with `stub-credential`. ' +
|
||||
'carries no data for falls back to an empty stub. A credential created with package data ' +
|
||||
'is listed under `credentials.seeded` when the package confirms that all source data ' +
|
||||
'was retained. ' +
|
||||
'Otherwise, also when the package has no completeness flag, it is listed under ' +
|
||||
'`credentials.incomplete` and blocks workflow publishing with `incomplete-credential`. ' +
|
||||
'`must-preexist` requires every referenced credential to already exist.',
|
||||
},
|
||||
bindings: {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const workflowPublishingOutcomeSchema = z.object({
|
|||
state: z.enum(['published', 'unpublished', 'unchanged', 'blocked', 'failed']).openapi({
|
||||
description:
|
||||
'`blocked` means the imported version could not be published and no version is active ' +
|
||||
'(for example because the workflow uses an empty or partially seeded credential, or uses a node type this ' +
|
||||
'(for example because the workflow uses an empty or incomplete credential, or uses a node type this ' +
|
||||
'instance does not have). When a prior published version remains active, `state` is ' +
|
||||
'`unchanged` with `skippedPublishReason` instead. `failed` means publish or unpublish was ' +
|
||||
'attempted but did not succeed.',
|
||||
|
|
@ -25,7 +25,7 @@ const workflowPublishingOutcomeSchema = z.object({
|
|||
'Present when `state` is `failed`. Explains why publish or unpublish could not be applied.',
|
||||
}),
|
||||
blockedReason: z
|
||||
.enum(['stub-credential', 'missing-node-type'])
|
||||
.enum(['stub-credential', 'incomplete-credential', 'missing-node-type'])
|
||||
.optional()
|
||||
.openapi({
|
||||
description:
|
||||
|
|
@ -33,13 +33,13 @@ const workflowPublishingOutcomeSchema = z.object({
|
|||
'version is active.',
|
||||
}),
|
||||
skippedPublishReason: z
|
||||
.enum(['stub-credential', 'missing-node-type'])
|
||||
.enum(['stub-credential', 'incomplete-credential', 'missing-node-type'])
|
||||
.optional()
|
||||
.openapi({
|
||||
description:
|
||||
'Present when `state` is `unchanged` but the policy wanted to publish the imported ' +
|
||||
'version: a prior published version is still active (for example after an update with ' +
|
||||
'incomplete credentials).',
|
||||
'empty or incomplete credentials).',
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -146,14 +146,21 @@ const importCredentialSummarySchema = z
|
|||
}),
|
||||
seeded: z.array(z.string()).openapi({
|
||||
description:
|
||||
'Source credential ids for which credentials were created with bundled expression values ' +
|
||||
'in the target project. Partial or unverified bundled data still blocks workflow publishing.',
|
||||
'Source credential ids for which credentials were created in the target project with ' +
|
||||
'all their source data from the bundled expression values.',
|
||||
}),
|
||||
incomplete: z.array(z.string()).openapi({
|
||||
description:
|
||||
'Source credential ids for which credentials were created in the target project with ' +
|
||||
'bundled expression values, but the package does not confirm that it carries all source ' +
|
||||
'data. These credentials need input. The import does not publish the imported version ' +
|
||||
'of workflows that use them (`incomplete-credential`).',
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
description:
|
||||
'Source credential ids grouped by whether they matched an existing credential or were ' +
|
||||
'created as empty stubs or seeded credentials. Full source→target id mapping is in `bindings.credentials`.',
|
||||
'created as empty stubs, seeded credentials, or incomplete credentials. Full source→target id mapping is in `bindings.credentials`.',
|
||||
});
|
||||
|
||||
const importDataTableSummarySchema = z.object({
|
||||
|
|
@ -239,8 +246,8 @@ const serializedBindingsSchema = z
|
|||
description:
|
||||
'Credential id from `requirements.credentials` in the package manifest → resolved ' +
|
||||
'target credential id on the target instance. Covers both a matched existing ' +
|
||||
'credential and a credential created as a stub under ' +
|
||||
'`credentialMissingMode=create-stub`.',
|
||||
'credential and a credential that the import created under ' +
|
||||
'`credentialMissingMode=create-stub` or `create-with-values`.',
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
import {
|
||||
ListPromotionBranchesQueryDto,
|
||||
ListPromotionRepositoriesQueryDto,
|
||||
PromotionBranchListPublicDto,
|
||||
PromotionRepositoryListPublicDto,
|
||||
promotionRepositoryIdParamSchema,
|
||||
} from '../promotion-repository.dto';
|
||||
|
||||
describe.each([ListPromotionRepositoriesQueryDto, ListPromotionBranchesQueryDto])('%s', (Dto) => {
|
||||
it('uses standard pagination defaults and bounds and trims search', () => {
|
||||
expect(Dto.parse({})).toEqual({ limit: 100 });
|
||||
expect(Dto.parse({ limit: '250', search: ' platform ' })).toEqual({
|
||||
limit: 250,
|
||||
search: 'platform',
|
||||
});
|
||||
expect(Dto.parse({ limit: '1000' })).toEqual({ limit: 250 });
|
||||
expect(Dto.parse({ limit: '2', cursor: 'cursor' })).toEqual({
|
||||
limit: 2,
|
||||
cursor: 'cursor',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ limit: '-1' },
|
||||
{ limit: 'invalid' },
|
||||
{ offset: '1' },
|
||||
{ search: 'a'.repeat(256) },
|
||||
{ cursor: 'a'.repeat(2049) },
|
||||
])('rejects invalid query input: %j', (input) => {
|
||||
expect(Dto.safeParse(input).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('discovery responses', () => {
|
||||
it('returns repository identity and target fields without extra fields', () => {
|
||||
const repository = {
|
||||
id: '42',
|
||||
name: 'Workflows',
|
||||
fullPath: 'platform/workflows',
|
||||
remoteUrl: 'https://gitlab.example.com/platform/workflows.git',
|
||||
defaultBranch: null,
|
||||
};
|
||||
expect(
|
||||
PromotionRepositoryListPublicDto.parse({
|
||||
data: [{ ...repository, accessToken: 'not-public' }],
|
||||
nextCursor: null,
|
||||
}),
|
||||
).toEqual({ data: [repository], nextCursor: null });
|
||||
});
|
||||
|
||||
it('returns branch names and default flags without commit details', () => {
|
||||
expect(
|
||||
PromotionBranchListPublicDto.parse({
|
||||
data: [{ name: 'main', isDefault: true, commit: { id: 'not-public' } }],
|
||||
nextCursor: null,
|
||||
}),
|
||||
).toEqual({ data: [{ name: 'main', isDefault: true }], nextCursor: null });
|
||||
});
|
||||
|
||||
it.each(['.', '..', '', 'a'.repeat(257)])('rejects invalid repository IDs: %s', (id) => {
|
||||
expect(promotionRepositoryIdParamSchema.safeParse(id).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -27,15 +27,22 @@ export type PromotionConnectionScope = z.infer<typeof promotionConnectionScopeSc
|
|||
*/
|
||||
const remoteUrlSchema = z.string().trim().min(1);
|
||||
|
||||
/**
|
||||
* Plain Git points at a repository with its full SSH or HTTP(S) remote URL. A new
|
||||
* provider type adds its own target here and needs no remote URL.
|
||||
*/
|
||||
/** Git host targets store a path internally. The API exposes the resolved clone URL. */
|
||||
export const promotionConnectionTargetSchema = z
|
||||
.object({ schemaVersion: z.literal(1), remoteUrl: remoteUrlSchema })
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
remoteUrl: remoteUrlSchema,
|
||||
})
|
||||
.strict();
|
||||
export type PromotionConnectionTarget = z.infer<typeof promotionConnectionTargetSchema>;
|
||||
|
||||
const promotionConnectionTargetPublicSchema = promotionConnectionTargetSchema.extend({
|
||||
repositoryPath: z.string().optional().openapi({
|
||||
description:
|
||||
'The stored repository path of a GitLab target. URL-encode it before you use it as repositoryId to list branches. Requests do not accept it.',
|
||||
}),
|
||||
});
|
||||
|
||||
export class CreatePromotionConnectionDto extends Z.class(
|
||||
{
|
||||
name: promotionDisplayNameSchema,
|
||||
|
|
@ -126,7 +133,7 @@ export const promotionConnectionPublicSchema = z.object({
|
|||
id: n8nIdSchema,
|
||||
name: z.string(),
|
||||
scope: promotionConnectionScopeSchema,
|
||||
target: promotionConnectionTargetSchema,
|
||||
target: promotionConnectionTargetPublicSchema,
|
||||
provider: promotionProviderSummarySchema,
|
||||
configs: promotionConnectionConfigsPublicSchema,
|
||||
createdAt: z.string().datetime(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import '../../openapi-extend';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
import { publicApiPaginationSchema } from '../pagination/pagination.dto';
|
||||
|
||||
/** GitLab accepts at most 100 items per request. */
|
||||
export const MAX_PROMOTION_DISCOVERY_ITEMS_PER_PAGE = 100;
|
||||
|
||||
const discoveryQueryShape = {
|
||||
limit: publicApiPaginationSchema.limit,
|
||||
cursor: z.string().max(2048).optional(),
|
||||
search: z.string().trim().max(255).optional(),
|
||||
};
|
||||
|
||||
export class ListPromotionRepositoriesQueryDto extends Z.class(discoveryQueryShape, {
|
||||
strict: true,
|
||||
}) {}
|
||||
|
||||
export class ListPromotionBranchesQueryDto extends Z.class(discoveryQueryShape, {
|
||||
strict: true,
|
||||
}) {}
|
||||
|
||||
export const promotionRepositoryIdParamSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(256)
|
||||
.refine((id) => id !== '.' && id !== '..', { message: 'Invalid repository ID' })
|
||||
.openapi({
|
||||
param: {
|
||||
description:
|
||||
'The URL-encoded fullPath of the repository. A GitLab project ID is also accepted.',
|
||||
},
|
||||
});
|
||||
|
||||
export const promotionRepositorySchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
fullPath: z.string(),
|
||||
remoteUrl: z.string(),
|
||||
defaultBranch: z.string().nullable(),
|
||||
});
|
||||
export type PromotionRepository = z.infer<typeof promotionRepositorySchema>;
|
||||
|
||||
export class PromotionRepositoryListPublicDto extends Z.class({
|
||||
data: z.array(promotionRepositorySchema),
|
||||
nextCursor: z.string().nullable(),
|
||||
}) {}
|
||||
|
||||
export const promotionBranchSchema = z.object({
|
||||
name: z.string(),
|
||||
isDefault: z.boolean(),
|
||||
});
|
||||
export type PromotionBranch = z.infer<typeof promotionBranchSchema>;
|
||||
|
||||
export class PromotionBranchListPublicDto extends Z.class({
|
||||
data: z.array(promotionBranchSchema),
|
||||
nextCursor: z.string().nullable(),
|
||||
}) {}
|
||||
|
|
@ -453,7 +453,7 @@ export type FrontendModuleSettings = {
|
|||
knowledgeBaseEnabled: boolean;
|
||||
/** Whether the AI Assistant proxy is available to the agents module. */
|
||||
proxyEnabled: boolean;
|
||||
/** Whether agents use the skills hub (PostHog flag `125_context_skills`, read at startup). */
|
||||
/** Whether agents use the skills hub (`N8N_AGENTS_SKILLS_HUB_ENABLED`, read at startup). */
|
||||
skillsHubEnabled?: boolean;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -981,3 +981,12 @@ export { compareExecutionListItems } from './dto/executions/compare-execution-li
|
|||
export type * from './workflow-suggestions';
|
||||
export * from './self-healing-results';
|
||||
export * from './model-priority';
|
||||
|
||||
export {
|
||||
RsaPublicJwkSchema,
|
||||
EcPublicJwkSchema,
|
||||
PublicJwkSchema,
|
||||
type PublicJwk,
|
||||
JwksResponseSchema,
|
||||
type JwksResponse,
|
||||
} from './schemas/jwks.schema';
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import { buildDispatcher, createDispatcherTransport } from '../undici/transport'
|
|||
// response well past a tight `headersTimeout`: with the knob applied the request
|
||||
// aborts, and with undici's default (5 min) the same slow response succeeds.
|
||||
|
||||
const RESPONSE_DELAY_MS = 1000;
|
||||
// Undici's coarse timer can add nearly one second to short timeouts.
|
||||
const RESPONSE_DELAY_MS = 2000;
|
||||
const TIGHT_TIMEOUT_MS = 50;
|
||||
|
||||
describe('transport timeouts', () => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export {
|
|||
} from '../proxy/proxy-resolution';
|
||||
export { installGlobalProxyAgent } from './http-proxy';
|
||||
export { configureGlobalAxiosDefaults } from './axios/config';
|
||||
export { isFormDataInstance, tryParseUrl } from './axios/utils';
|
||||
export { buildTargetUrl, isFormDataInstance, tryParseUrl } from './axios/utils';
|
||||
export { removeEmptyBody } from './axios/request';
|
||||
export { parseIncomingMessage } from './parse-incoming-message';
|
||||
export { binaryToBuffer, streamToBuffer } from './binary-buffer';
|
||||
|
|
|
|||
|
|
@ -84,7 +84,8 @@ Two things do not carry over on purpose:
|
|||
uses belongs to that module.
|
||||
- Keep the file layout of `cli` (`errors/`, `services/`). A move with `git mv`
|
||||
keeps the history readable.
|
||||
- Add each new export to `src/index.ts`. Consumers import from the package
|
||||
root only.
|
||||
- Add shared exports to `src/index.ts`. Use `@n8n/backend-services/auth` for
|
||||
auth registries and provider proxies. This entry point does not load the
|
||||
cache, Redis, or database-backed services. Keep internal file paths private.
|
||||
- Keep `oxlint.config.mts` and `eslint.config.mjs` in step. A ratchet
|
||||
allowlist for a moved TypeORM leak goes into both.
|
||||
|
|
|
|||
|
|
@ -19,6 +19,18 @@
|
|||
"main": "dist/index.js",
|
||||
"module": "src/index.ts",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"module": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./auth": {
|
||||
"types": "./dist/auth.d.ts",
|
||||
"module": "./src/auth.ts",
|
||||
"default": "./dist/auth.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
],
|
||||
|
|
@ -32,10 +44,12 @@
|
|||
"@n8n/decorators": "workspace:*",
|
||||
"@n8n/di": "workspace:*",
|
||||
"@n8n/errors": "workspace:*",
|
||||
"@n8n/inbound-auth": "workspace:*",
|
||||
"@n8n/permissions": "workspace:*",
|
||||
"@n8n/utils": "workspace:*",
|
||||
"cache-manager": "catalog:",
|
||||
"ioredis": "5.3.2",
|
||||
"jose": "^6.2.9",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"n8n-core": "workspace:*",
|
||||
"n8n-workflow": "workspace:*"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import * as auth from '../auth';
|
||||
|
||||
vi.mock('../services/cache/cache.service', () => {
|
||||
throw new Error('The auth entry point must not load cache services');
|
||||
});
|
||||
|
||||
vi.mock('../services/redis-client.service', () => {
|
||||
throw new Error('The auth entry point must not load Redis services');
|
||||
});
|
||||
|
||||
vi.mock('@n8n/db', () => {
|
||||
throw new Error('The auth entry point must not load persistence services');
|
||||
});
|
||||
|
||||
describe('auth entry point', () => {
|
||||
it('loads auth registries and proxies without cache, Redis, or persistence services', () => {
|
||||
for (const service of [
|
||||
auth.AuthStrategyRegistry,
|
||||
auth.ProtectedResourceRegistry,
|
||||
auth.OAuthTokenVerifierProxy,
|
||||
auth.OAuth2FlowProxy,
|
||||
auth.RoleMappingProxyService,
|
||||
auth.JwksRegistry,
|
||||
auth.OAuthJweServiceProxy,
|
||||
]) {
|
||||
expect(service).toBeTypeOf('function');
|
||||
}
|
||||
});
|
||||
});
|
||||
34
packages/@n8n/backend-services/src/auth.ts
Normal file
34
packages/@n8n/backend-services/src/auth.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export { AuthStrategyRegistry } from './services/auth-strategy.registry';
|
||||
export { type AuthStrategyOptions, type AuthStrategy } from './services/auth-strategy.types';
|
||||
export {
|
||||
type ProtectedResource,
|
||||
type ProtectedResourceResolver,
|
||||
ProtectedResourceRegistry,
|
||||
} from './services/protected-resource.registry';
|
||||
export {
|
||||
type AuthFailureReason,
|
||||
type Mcpauth_type,
|
||||
type McpResolvedAuthType,
|
||||
type McpCallerAuth,
|
||||
type TelemetryAuthContext,
|
||||
type UserWithContext,
|
||||
type OAuthTokenVerifier,
|
||||
OAuthTokenVerifierProxy,
|
||||
} from './services/oauth-token-verifier-proxy.service';
|
||||
export { type N8nOAuth2Flow, OAuth2FlowProxy } from './services/oauth2-flow-proxy.service';
|
||||
export {
|
||||
type RoleResolverContext,
|
||||
type RoleMappingConfig,
|
||||
type ResolvedInstanceRole,
|
||||
type ResolvedProjectRole,
|
||||
type ResolvedRoles,
|
||||
type ProjectInfo,
|
||||
type RoleMappingProvider,
|
||||
RoleMappingProxyService,
|
||||
} from './services/role-mapping-proxy.service';
|
||||
export { type JwksProvider, JwksRegistry } from './jwks/jwks.registry';
|
||||
export {
|
||||
type DcrJweFields,
|
||||
type OAuthJweHandler,
|
||||
OAuthJweServiceProxy,
|
||||
} from './oauth/oauth-jwe-service.proxy';
|
||||
|
|
@ -5,8 +5,8 @@ import type {
|
|||
ExecutionRedaction,
|
||||
ExecutionRedactionOptions,
|
||||
RedactableExecution,
|
||||
} from '@/executions/execution-redaction';
|
||||
import { ExecutionRedactionServiceProxy } from '@/executions/execution-redaction-proxy.service';
|
||||
} from '../execution-redaction';
|
||||
import { ExecutionRedactionServiceProxy } from '../execution-redaction-proxy.service';
|
||||
|
||||
describe('ExecutionRedactionServiceProxy', () => {
|
||||
let proxy: ExecutionRedactionServiceProxy;
|
||||
|
|
@ -7,6 +7,7 @@ export {
|
|||
} from './credentials/credentials-finder.service';
|
||||
export { UncacheableValueError } from './errors/cache-errors/uncacheable-value.error';
|
||||
export { CacheService } from './services/cache/cache.service';
|
||||
export type { Hash, MaybeHash } from './services/cache/cache.types';
|
||||
export { RedisClientService } from './services/redis-client.service';
|
||||
export type { RedisClientType } from './services/redis.types';
|
||||
export { RoleCacheService, type RoleLoader } from './services/role-cache.service';
|
||||
|
|
@ -68,3 +69,40 @@ export {
|
|||
API_KEY_PURPOSES,
|
||||
LEGACY_UNBOUND_PURPOSES,
|
||||
} from './services/token-purposes';
|
||||
|
||||
export {
|
||||
WorkflowPublishedDataService,
|
||||
type PublishedWorkflowData,
|
||||
type PublishedWorkflowDataForExecution,
|
||||
} from './workflows/workflow-published-data.service';
|
||||
|
||||
export {
|
||||
RuntimeCredentialProxyService,
|
||||
type RuntimeCredentialProvider,
|
||||
} from './services/runtime-credential-proxy.service';
|
||||
export {
|
||||
InstanceRegistryProxyService,
|
||||
type InstanceRegistryProvider,
|
||||
} from './services/instance-registry-proxy.service';
|
||||
export { ExecutingUserIdentifierProxy } from './credentials/executing-user-identifier-proxy';
|
||||
export type { IExecutingUserIdentifier } from './credentials/executing-user-identifier.interface';
|
||||
export { ExecutionRedactionServiceProxy } from './executions/execution-redaction-proxy.service';
|
||||
export {
|
||||
isRedactableExecution,
|
||||
type ExecutionRedactionOptions,
|
||||
type ExecutionRedaction,
|
||||
type RedactableExecution,
|
||||
} from './executions/execution-redaction';
|
||||
export {
|
||||
WorkflowMutationHooksProxy,
|
||||
type WorkflowMutationHooks,
|
||||
} from './workflows/workflow-mutation-hooks-proxy.service';
|
||||
export {
|
||||
WorkflowPublishGuardProxy,
|
||||
type WorkflowPublishGuard,
|
||||
} from './workflows/workflow-publish-guard-proxy.service';
|
||||
export {
|
||||
RestrictedNodeTypesProviderProxy,
|
||||
type RestrictedNodeTypesProvider,
|
||||
} from './workflows/restricted-node-types-provider-proxy.service';
|
||||
export * from './auth';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { UserError } from 'n8n-workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { OAuthJweServiceProxy, type OAuthJweHandler } from '@/oauth/oauth-jwe-service.proxy';
|
||||
import { OAuthJweServiceProxy, type OAuthJweHandler } from '../oauth-jwe-service.proxy';
|
||||
|
||||
describe('OAuthJweServiceProxy', () => {
|
||||
describe('decryptOAuth2TokenData', () => {
|
||||
|
|
@ -20,7 +20,7 @@ export interface OAuthJweHandler {
|
|||
}
|
||||
|
||||
/**
|
||||
* CLI-side proxy for the OAuth2 JWE module. Lives outside the `oauth-jwe`
|
||||
* Shared proxy for the OAuth2 JWE module. Lives outside the `oauth-jwe`
|
||||
* module so non-module callers (the OAuth2 callback controller, the OAuth
|
||||
* service for dynamic client registration) and the execution engine
|
||||
* (via `additionalData['oauth-jwe']`) consume it without importing module
|
||||
|
|
@ -3,7 +3,7 @@ import type { IRunExecutionData } from 'n8n-workflow';
|
|||
import {
|
||||
RuntimeCredentialProxyService,
|
||||
type RuntimeCredentialProvider,
|
||||
} from '@/services/runtime-credential-proxy.service';
|
||||
} from '../runtime-credential-proxy.service';
|
||||
|
||||
const runExecutionData = {} as IRunExecutionData;
|
||||
|
||||
|
|
@ -2,11 +2,11 @@ import type { Logger } from '@n8n/backend-common';
|
|||
import type { WorkflowPublishedVersionRepository, WorkflowPublishedVersion } from '@n8n/db';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { CacheService } from '@n8n/backend-services';
|
||||
import type { CacheService } from '../../services/cache/cache.service';
|
||||
import {
|
||||
WorkflowPublishedDataService,
|
||||
type PublishedWorkflowDataForExecution,
|
||||
} from '@/workflows/workflow-published-data.service';
|
||||
} from '../workflow-published-data.service';
|
||||
|
||||
describe('WorkflowPublishedDataService', () => {
|
||||
const logger = mock<Logger>();
|
||||
|
|
@ -8,7 +8,7 @@ import {
|
|||
import { Service } from '@n8n/di';
|
||||
import { ensureError } from '@n8n/utils/errors/ensure-error';
|
||||
|
||||
import { CacheService } from '@n8n/backend-services';
|
||||
import { CacheService } from '../services/cache/cache.service';
|
||||
|
||||
export type { PublishedWorkflowDataForExecution };
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ n8n-cli package import --file=export.n8np --workflow-conflict-policy=fail --bind
|
|||
| `--folder-conflict-policy` | What to do when a package folder already exists in the target project. **Defaults to whatever `--project-conflict-policy` is**, so you state the intent once; for a workflow package, which defines no projects, it defaults to `merge`. `merge` reuses the existing folder and merges the package's children into it; `fail` rejects the import; `overwrite` additionally removes workflows the package does not contain, at the project root and in package-defined folders (see `--overwrite-deletion-policy`), and is rejected unless `--project-conflict-policy` is also `overwrite`. Folders the package does not define are removed too, but only once nothing is left inside them, so target-only content is never swept up. Requires a folders-enabled license when the package contains folders, and the `workflow:delete` and `folder:delete` scopes for `overwrite`. |
|
||||
| `--overwrite-deletion-policy` | How `--folder-conflict-policy=overwrite` removes a workflow the package does not contain: `archive` (instance default) archives it, keeping it and its execution history recoverable; `hard-delete` archives it — the step that unpublishes it — then deletes the workflow and its executions permanently. Each entry in `removedWorkflows` reports what actually happened in its `deletion` field, so a `hard-delete` whose row cannot be dropped yet (unpublishing defers trigger teardown) shows as `archived` rather than failing the import. Ignored unless the folder conflict policy is `overwrite`. |
|
||||
| `--credential-matching-mode` | How credential references are matched on the target instance: `id-only` (default, match by id), `name-and-type` (match by exact name and type), or `type-only` (match by type). For `name-and-type` and `type-only`, candidates are ranked by scope — owned by the target project, then shared into it, then global — and ties within a scope use the most recently updated credential. |
|
||||
| `--credential-missing-mode` | What to do when a referenced credential cannot be resolved. `create-stub` (instance default) creates empty placeholder credentials in the target project; `create-with-values` also creates them, seeded from the expression data bundled in the package (a reference the package carries no data for falls back to an empty stub); `must-preexist` requires every referenced credential to already exist. |
|
||||
| `--credential-missing-mode` | What to do when a referenced credential cannot be resolved. `create-stub` (instance default) creates empty placeholder credentials in the target project; `create-with-values` also creates them, seeded from the expression data bundled in the package (a reference the package carries no data for falls back to an empty stub). A credential created with package data is listed under `credentials.seeded` when the package confirms that it carries all source data, and under `credentials.incomplete` otherwise. The import does not publish the imported version of a workflow that uses an incomplete credential (`incomplete-credential`). An active earlier version stays active. `must-preexist` requires every referenced credential to already exist. |
|
||||
| `--data-table-matching-mode` | How data tables referenced by the package's workflows are matched on the target instance: `by-id` (default and only mode) matches the target-project table with the same id — imported tables keep their source id — and never falls back to name matching. |
|
||||
| `--data-table-missing-mode` | What to do when a referenced data table is absent in the target project. `create` (instance default) creates it from the package schema — keeping the source id, with no rows; `must-preexist` requires it to already exist; `do-nothing` skips creation. Under the `keep-existing` and `fail` schema conflict policies, matched tables are schema-validated (all package columns present with the same name and type), even under `do-nothing`. Under `overwrite-non-destructive`, a matched table blocks the import only when a change removes or retypes a column. The import never imports table rows. Matched tables change only under `--data-table-schema-conflict-policy=overwrite` or `overwrite-non-destructive`, which keep their rows. |
|
||||
| `--data-table-schema-conflict-policy` | How strictly a matched data table's schema is compared. Under `keep-existing` and `fail`, every package column must exist on the matched target table with the same name and type. A missing column or a type mismatch rejects the import. `keep-existing` (instance default) ignores additional columns the target table has of its own; `fail` is the strict drift-detection choice and rejects those too. `keep-existing` and `fail` never alter the matched target table. `overwrite` changes the matched target table to match the package. It can remove or retype columns. The values in those columns are removed. Rows and other columns are kept. When the package has column ids, a column that is only renamed keeps its values. The changes apply to all workflows that use the table. `overwrite-non-destructive` makes the same changes as `overwrite`, but rejects the import and writes nothing when a change removes or retypes a column. A target-only column counts as removed. |
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
|
||||
import type { ClientOAuth2Options, ClientOAuth2RequestObject } from './client-oauth2';
|
||||
import { ERROR_RESPONSES } from './constants';
|
||||
|
||||
|
|
@ -23,11 +25,22 @@ export class AuthError extends Error {
|
|||
message: string,
|
||||
readonly body: any,
|
||||
readonly code = 'EAUTH',
|
||||
/** True when `message` is the text of an `error` object in the server response. */
|
||||
readonly fromErrorObject = false,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
const UNREADABLE_ERROR_MESSAGE = 'The OAuth2 server rejected the request';
|
||||
|
||||
/** Some servers (e.g. Meta Graph API) send `error` as an object with its own `message`. */
|
||||
function getErrorObjectText(value: unknown): string | undefined {
|
||||
return isRecord(value) && typeof value.message === 'string' && value.message
|
||||
? value.message
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull an authentication error from the response data.
|
||||
*/
|
||||
|
|
@ -35,14 +48,15 @@ export function getAuthError(body: {
|
|||
error: string;
|
||||
error_description?: string;
|
||||
}): Error | undefined {
|
||||
const message: string | undefined =
|
||||
ERROR_RESPONSES[body.error] ?? body.error_description ?? body.error;
|
||||
const message: unknown = ERROR_RESPONSES[body.error] ?? body.error_description ?? body.error;
|
||||
|
||||
if (message) {
|
||||
return new AuthError(message, body);
|
||||
}
|
||||
if (!message) return undefined;
|
||||
if (typeof message === 'string') return new AuthError(message, body);
|
||||
|
||||
return undefined;
|
||||
const errorObjectText = getErrorObjectText(message);
|
||||
return errorObjectText
|
||||
? new AuthError(errorObjectText, body, undefined, true)
|
||||
: new AuthError(UNREADABLE_ERROR_MESSAGE, body);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -221,6 +221,64 @@ describe('ClientOAuth2', () => {
|
|||
expect(result).toBeInstanceOf(AuthError);
|
||||
expect(result.message).toEqual(ERROR_RESPONSES.access_denied);
|
||||
expect(result.body).toEqual({ error: 'access_denied' });
|
||||
expect(result.fromErrorObject).toBe(false);
|
||||
});
|
||||
|
||||
it('should use the nested message when the error field is an object', async () => {
|
||||
const body = {
|
||||
error: {
|
||||
message: "Can't load URL: The domain of this URL isn't included in the app's domains.",
|
||||
type: 'OAuthException',
|
||||
code: 191,
|
||||
fbtrace_id: 'AbCdEf123',
|
||||
},
|
||||
};
|
||||
mockTokenResponse({
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const result = await makeTokenCall().catch((err) => err);
|
||||
expect(result).toBeInstanceOf(AuthError);
|
||||
expect(result.message).toBe(body.error.message);
|
||||
expect(result.body).toEqual(body);
|
||||
expect(result.fromErrorObject).toBe(true);
|
||||
});
|
||||
|
||||
it('should use a fixed message when the error object has no string message', async () => {
|
||||
const body = { error: { code: 42, message: { text: 'nested' } } };
|
||||
mockTokenResponse({
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const result = await makeTokenCall().catch((err) => err);
|
||||
expect(result).toBeInstanceOf(AuthError);
|
||||
expect(result.message).toBe('The OAuth2 server rejected the request');
|
||||
expect(result.body).toEqual(body);
|
||||
expect(result.fromErrorObject).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an unknown error code', { error: 'custom_error', error_description: 'Server description' }],
|
||||
['no error field', { error_description: 'Server description' }],
|
||||
[
|
||||
'an error object',
|
||||
{ error: { message: 'Nested' }, error_description: 'Server description' },
|
||||
],
|
||||
])('should use error_description when the body has %s', async (_, body) => {
|
||||
mockTokenResponse({
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const result = await makeTokenCall().catch((err) => err);
|
||||
expect(result).toBeInstanceOf(AuthError);
|
||||
expect(result.message).toBe('Server description');
|
||||
expect(result.fromErrorObject).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject 3xx responses with response errors', async () => {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ describe('SchedulerConfig', () => {
|
|||
expect(scheduler.retentionSeconds).toBe(24 * 60 * 60);
|
||||
expect(scheduler.failedRetentionSeconds).toBe(7 * 24 * 60 * 60);
|
||||
expect(scheduler.retentionIntervalSeconds).toBe(60 * 60);
|
||||
expect(scheduler.minIntervalSeconds).toBe(0);
|
||||
expect(scheduler.jitterRatio).toBe(0.1);
|
||||
expect(scheduler.materializationTimeoutSeconds).toBe(60);
|
||||
expect(scheduler.executorTimeoutSeconds).toBe(60);
|
||||
|
|
@ -74,7 +73,6 @@ describe('SchedulerConfig', () => {
|
|||
vi.stubEnv('N8N_SCHEDULER_RETENTION', '43200');
|
||||
vi.stubEnv('N8N_SCHEDULER_FAILED_RETENTION', '86400');
|
||||
vi.stubEnv('N8N_SCHEDULER_RETENTION_INTERVAL', '600');
|
||||
vi.stubEnv('N8N_SCHEDULER_MIN_INTERVAL', '15');
|
||||
vi.stubEnv('N8N_SCHEDULER_JITTER_RATIO', '0.25');
|
||||
vi.stubEnv('N8N_SCHEDULER_MATERIALIZATION_TIMEOUT', '30');
|
||||
vi.stubEnv('N8N_SCHEDULER_EXECUTOR_TIMEOUT', '15');
|
||||
|
|
@ -97,7 +95,6 @@ describe('SchedulerConfig', () => {
|
|||
expect(scheduler.retentionSeconds).toBe(43200);
|
||||
expect(scheduler.failedRetentionSeconds).toBe(86400);
|
||||
expect(scheduler.retentionIntervalSeconds).toBe(600);
|
||||
expect(scheduler.minIntervalSeconds).toBe(15);
|
||||
expect(scheduler.jitterRatio).toBe(0.25);
|
||||
expect(scheduler.materializationTimeoutSeconds).toBe(30);
|
||||
expect(scheduler.executorTimeoutSeconds).toBe(15);
|
||||
|
|
@ -128,13 +125,5 @@ describe('SchedulerConfig', () => {
|
|||
|
||||
expect(scheduler.taskTimeoutSeconds).toBe(300);
|
||||
});
|
||||
|
||||
it('should allow disabling the min-interval clamp with 0', () => {
|
||||
vi.stubEnv('N8N_SCHEDULER_MIN_INTERVAL', '0');
|
||||
|
||||
const { scheduler } = Container.get(GlobalConfig);
|
||||
|
||||
expect(scheduler.minIntervalSeconds).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -66,6 +66,15 @@ export class AgentsConfig {
|
|||
@Env('N8N_AGENTS_TRACING_RECORD_OUTPUTS')
|
||||
tracingRecordOutputs: boolean = true;
|
||||
|
||||
/**
|
||||
* Whether agents keep their skills in the skills hub instead of inline on the agent.
|
||||
* Read once when the agents module starts; a change needs a restart. Set the same value
|
||||
* on every main and worker. With it on, each main copies inline skills into the hub at
|
||||
* startup, once per agent.
|
||||
*/
|
||||
@Env('N8N_AGENTS_SKILLS_HUB_ENABLED')
|
||||
skillsHubEnabled: boolean = false;
|
||||
|
||||
/**
|
||||
* Comma-separated list of agent sub-feature modules to enable. Each entry
|
||||
* gates a specific frontend/runtime capability inside the agents module.
|
||||
|
|
|
|||
|
|
@ -4,12 +4,6 @@ import { z } from 'zod';
|
|||
import { Config, Env } from '../decorators';
|
||||
import { positiveIntSchema } from '../schemas';
|
||||
|
||||
/**
|
||||
* Allows 0 (the clamp disabled) in addition to any positive value. Used for a
|
||||
* setting where 0 is a meaningful "off", not just a smaller number.
|
||||
*/
|
||||
const nonNegativeIntSchema = z.number({ coerce: true }).int().nonnegative();
|
||||
|
||||
/**
|
||||
* A jitter ratio: a fraction in [0, 1). 0 disables jitter; 1 or above would
|
||||
* allow a zero or negative delay between ticks, so they are rejected.
|
||||
|
|
@ -233,18 +227,6 @@ export class SchedulerConfig {
|
|||
@Env('N8N_SCHEDULER_JITTER_RATIO', jitterRatioSchema)
|
||||
jitterRatio: number = 0.1;
|
||||
|
||||
/**
|
||||
* The smallest gap, in seconds, allowed between consecutive runs of the same
|
||||
* schedule. A schedule set to run more often than this is slowed down to this
|
||||
* gap. Defaults to 0, which disables the limit and honours whatever interval
|
||||
* each schedule specifies.
|
||||
*
|
||||
* Set it to put a floor on how frequently any schedule can run, for example to
|
||||
* stop a misconfigured every-second schedule from overloading the instance.
|
||||
*/
|
||||
@Env('N8N_SCHEDULER_MIN_INTERVAL', nonNegativeIntSchema)
|
||||
minIntervalSeconds: number = 0;
|
||||
|
||||
/**
|
||||
* How a Schedule Trigger node's "every N seconds/minutes" schedules run under
|
||||
* the durable scheduler. Defaults to `new`.
|
||||
|
|
|
|||
|
|
@ -513,7 +513,6 @@ describe('GlobalConfig', () => {
|
|||
retentionIntervalSeconds: 3600,
|
||||
retentionTimeoutSeconds: 300,
|
||||
jitterRatio: 0.1,
|
||||
minIntervalSeconds: 0,
|
||||
maxConcurrentPasses: 10,
|
||||
triggerNodeMode: 'new',
|
||||
enabledForPollTriggers: false,
|
||||
|
|
@ -739,6 +738,7 @@ describe('GlobalConfig', () => {
|
|||
tracingEnabled: true,
|
||||
tracingRecordInputs: true,
|
||||
tracingRecordOutputs: true,
|
||||
skillsHubEnabled: false,
|
||||
modules: [],
|
||||
backgroundTasksEnabled: false,
|
||||
sandboxEnabled: false,
|
||||
|
|
@ -797,6 +797,15 @@ describe('GlobalConfig', () => {
|
|||
expect(config.agents.tracingRecordOutputs).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse N8N_AGENTS_SKILLS_HUB_ENABLED from env variables', () => {
|
||||
process.env = {
|
||||
N8N_AGENTS_SKILLS_HUB_ENABLED: 'true',
|
||||
};
|
||||
const config = Container.get(GlobalConfig);
|
||||
|
||||
expect(config.agents.skillsHubEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse N8N_AGENTS_AI_SANDBOX_SNAPSHOT from env variables', () => {
|
||||
process.env = {
|
||||
N8N_AGENTS_AI_SANDBOX_SNAPSHOT: 'n8n/agent-knowledge:1.2.3',
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ describe('toImapFlowOptions', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not enable IMAP4rev2', () => {
|
||||
expect(toImapFlowOptions(CREDENTIALS)).toMatchObject({ disableIMAP4rev2: true });
|
||||
});
|
||||
|
||||
it('names the server for TLS so the certificate can be checked against it', () => {
|
||||
expect(toImapFlowOptions(CREDENTIALS).tls).toEqual({ servername: 'imap.test.com' });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ export function toImapFlowOptions(options: ImapConnectionOptions): ImapFlowOptio
|
|||
secure: options.secure,
|
||||
// Matches what the trigger has always done; a plain connection stays plain.
|
||||
doSTARTTLS: false,
|
||||
// Some servers (e.g. STRATO) accept IMAP4rev2 and then answer every SEARCH as empty.
|
||||
// ENABLE can't be undone, and we need nothing from rev2.
|
||||
disableIMAP4rev2: true,
|
||||
auth: { user: options.user, pass: options.password },
|
||||
connectionTimeout: authTimeout,
|
||||
greetingTimeout: authTimeout,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ export class FakeImapServer {
|
|||
/** Messages the mailbox reports on SELECT. */
|
||||
exists = 0;
|
||||
|
||||
/**
|
||||
* Advertise IMAP4rev2 and, once a client enables it, answer every SEARCH with an ESEARCH
|
||||
* that leaves out ALL, as STRATO's RZimapd does. The mailbox holds unseen UID 1.
|
||||
*/
|
||||
brokenRev2Search = false;
|
||||
|
||||
constructor(secure: boolean) {
|
||||
const handler = (socket: net.Socket) => {
|
||||
this.sockets.add(socket);
|
||||
|
|
@ -33,6 +39,7 @@ export class FakeImapServer {
|
|||
socket.on('error', () => {});
|
||||
|
||||
let idleTag: string | undefined;
|
||||
let rev2 = false;
|
||||
const write = (line: string) => {
|
||||
if (!this.silent) socket.write(line + CRLF);
|
||||
};
|
||||
|
|
@ -54,11 +61,23 @@ export class FakeImapServer {
|
|||
continue;
|
||||
}
|
||||
|
||||
const [tag, command] = line.split(' ');
|
||||
const [tag, command, subcommand] = line.split(' ');
|
||||
const verb = (command ?? '').toUpperCase();
|
||||
const uidVerb = verb === 'UID' ? (subcommand ?? '').toUpperCase() : undefined;
|
||||
|
||||
if (verb === 'CAPABILITY') {
|
||||
write('* CAPABILITY IMAP4rev1 IDLE');
|
||||
write(
|
||||
this.brokenRev2Search
|
||||
? '* CAPABILITY IMAP4rev1 IMAP4rev2 ENABLE IDLE'
|
||||
: '* CAPABILITY IMAP4rev1 IDLE',
|
||||
);
|
||||
} else if (verb === 'ENABLE') {
|
||||
rev2 = this.brokenRev2Search && /IMAP4rev2/i.test(line);
|
||||
if (rev2) write('* ENABLED IMAP4rev2');
|
||||
} else if (uidVerb === 'SEARCH') {
|
||||
write(rev2 ? `* ESEARCH (TAG "${tag}") UID` : '* SEARCH 1');
|
||||
} else if (uidVerb === 'FETCH') {
|
||||
write('* 1 FETCH (UID 1 FLAGS ())');
|
||||
} else if (verb === 'LIST') {
|
||||
write('* LIST (\\Noselect) "/" ""');
|
||||
} else if (verb === 'SELECT' || verb === 'EXAMINE') {
|
||||
|
|
|
|||
32
packages/@n8n/imap/test/imap4rev2.test.ts
Normal file
32
packages/@n8n/imap/test/imap4rev2.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { ImapSimple } from '../src';
|
||||
import { FakeImapServer } from './fake-imap-server';
|
||||
|
||||
describe('server with a broken IMAP4rev2 mode', () => {
|
||||
let server: FakeImapServer;
|
||||
let port: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
server = new FakeImapServer(false);
|
||||
server.brokenRev2Search = true;
|
||||
server.exists = 1;
|
||||
port = await server.listen();
|
||||
});
|
||||
|
||||
afterEach(async () => await server.close());
|
||||
|
||||
it('finds unseen mail', async () => {
|
||||
const connection = await ImapSimple.connect({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
secure: false,
|
||||
user: 'user',
|
||||
password: 'password',
|
||||
});
|
||||
await connection.openBox('INBOX');
|
||||
|
||||
const found = await connection.search({ seen: false }, { uid: true, flags: true });
|
||||
connection.end();
|
||||
|
||||
expect(found.map((message) => message.uid)).toEqual([1]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { ProtectedResource } from '../contracts';
|
||||
import { extractBearer, inboundFrom } from '../inbound';
|
||||
import { extractBearer, inboundFrom, inboundRequestOf } from '../inbound';
|
||||
import type { Inbound, Verified } from '../pipeline';
|
||||
|
||||
const resourceUrl = 'https://n8n.example/mcp-server/http';
|
||||
|
|
@ -74,6 +74,25 @@ describe('inboundFrom', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('inboundRequestOf', () => {
|
||||
it('takes method, original URL, headers and ip from an Express-shaped request', () => {
|
||||
const headers = { authorization: 'Bearer t', 'x-forwarded-for': '198.51.100.1' };
|
||||
|
||||
expect(
|
||||
inboundRequestOf({
|
||||
method: 'GET',
|
||||
originalUrl: '/api/v1/workflows?limit=1',
|
||||
headers,
|
||||
ip: '203.0.113.7',
|
||||
}),
|
||||
).toEqual({ method: 'GET', url: '/api/v1/workflows?limit=1', headers, ip: '203.0.113.7' });
|
||||
});
|
||||
|
||||
it('stamps an empty ip when the request has none', () => {
|
||||
expect(inboundRequestOf({ method: 'GET', originalUrl: '/', headers: {} }).ip).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBearer', () => {
|
||||
it('returns Extracted with the bearer credential for a valid Authorization header', () => {
|
||||
const inbound = inboundWith({ authorization: 'Bearer abc.def.ghi' });
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import type { AuthPrincipal, SecurityContext } from '@n8n/permissions';
|
||||
import type { OAuthResourceGrant } from 'n8n-workflow';
|
||||
|
||||
import type { Credential, Extracted, Inbound, Result, Verified } from './pipeline';
|
||||
import type { Credential, Extracted, Inbound, RejectReason, Result, Verified } from './pipeline';
|
||||
import type { AuthorizationServerMetadata, Jwk, TrustedSource } from './trusted-source';
|
||||
import type { SurfaceId } from './trusted-source-config';
|
||||
|
||||
/** What a protected resource reads from the user. The `User` entity satisfies it. */
|
||||
export type ResourceUser = AuthPrincipal & { id: string; createdAt: Date };
|
||||
/** What a resource's gate reads from the caller. A `Principal` and the `User` entity both satisfy it. */
|
||||
export type ResourcePrincipal = AuthPrincipal & { id: string };
|
||||
|
||||
/** What the consent screen reads from the user. The `User` entity satisfies it. */
|
||||
export type ResourceUser = ResourcePrincipal & { createdAt: Date };
|
||||
|
||||
export interface ConsentUiHints {
|
||||
/** design-system icon name (updatedIconSet) for the consent logo tile; omit to fall back to the generic mcp icon */
|
||||
|
|
@ -89,7 +92,7 @@ export interface ProtectedResource {
|
|||
isAvailable?(): Promise<boolean>;
|
||||
|
||||
/** Whether `user` may access this resource at all. */
|
||||
authorize(user: ResourceUser): Promise<boolean>;
|
||||
authorize(user: ResourcePrincipal): Promise<boolean>;
|
||||
|
||||
/** The grant sealed into a run, so the worker re-takes the same decision without the resource. */
|
||||
getGrant?(): OAuthResourceGrant;
|
||||
|
|
@ -106,6 +109,51 @@ export abstract class AuthenticationPipeline {
|
|||
abstract run(extracted: Extracted): Promise<Result<SecurityContext>>;
|
||||
}
|
||||
|
||||
/** Why the caller was turned away: the pipeline's vocabulary, plus the resource's own refusal. */
|
||||
export type RefusalReason = RejectReason | 'insufficient_scope';
|
||||
|
||||
export type Admitted = {
|
||||
ok: true;
|
||||
context: SecurityContext;
|
||||
};
|
||||
|
||||
export type Refused = {
|
||||
ok: false;
|
||||
reason: RefusalReason;
|
||||
/** The resource as the pipeline saw it. Enough to build the challenge, without the request. */
|
||||
advertised: AdvertisedResource;
|
||||
/** Present when the pipeline accepted the token and the resource then refused the subject. */
|
||||
context?: SecurityContext;
|
||||
};
|
||||
|
||||
export type AdmissionResult = Admitted | Refused;
|
||||
|
||||
/**
|
||||
* What every surface does between taking a credential off the wire and acting as the caller:
|
||||
* stamp the inbound with the resource, run the pipeline, ask the resource to authorize the
|
||||
* subject, and build the 401 challenge when any of that fails.
|
||||
*
|
||||
* A surface keeps what is its own: where the credential travels (`Authorization`, a cookie,
|
||||
* a custom header, a token it just exchanged) and how it answers a refusal.
|
||||
*/
|
||||
export abstract class ResourceAdmission {
|
||||
/** Admits an extracted credential, for a surface that inspects it before the pipeline runs. */
|
||||
abstract admit(resource: ProtectedResource, extracted: Extracted): Promise<AdmissionResult>;
|
||||
|
||||
/**
|
||||
* Admits a bearer the surface already took out of its own carrier. An empty token is
|
||||
* refused with `no-credential`, so the surface can still answer with the challenge.
|
||||
*/
|
||||
abstract admitBearer(
|
||||
resource: ProtectedResource,
|
||||
request: Omit<Inbound['request'], 'headers'>,
|
||||
token: string,
|
||||
): Promise<AdmissionResult>;
|
||||
|
||||
/** The `WWW-Authenticate` value for a refusal: the advertised challenge, then the RFC 6750 error. */
|
||||
abstract challenge(refused: Refused): Promise<string>;
|
||||
}
|
||||
|
||||
/** The dispatcher. Owns what every method shares: driver lookup and surface acceptance. */
|
||||
export abstract class AuthenticationService {
|
||||
abstract authenticate(extracted: Extracted): Promise<Result<Verified>>;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,16 @@ export function inboundFrom(resource: ProtectedResource, request: Inbound['reque
|
|||
};
|
||||
}
|
||||
|
||||
/** The request as the pipeline wants it, from an Express-shaped request. */
|
||||
export function inboundRequestOf(req: {
|
||||
method: string;
|
||||
originalUrl: string;
|
||||
headers: Inbound['request']['headers'];
|
||||
ip?: string;
|
||||
}): Inbound['request'] {
|
||||
return { method: req.method, url: req.originalUrl, headers: req.headers, ip: req.ip ?? '' };
|
||||
}
|
||||
|
||||
// RFC 9110: the scheme is case-insensitive; the token is the rest, without surrounding blanks.
|
||||
const BEARER = /^bearer\s+(\S+)\s*$/i;
|
||||
|
||||
|
|
|
|||
|
|
@ -135,6 +135,23 @@ last. A matching manifest is trusted, and setup reads no other bundle file.
|
|||
Each cached sandbox entry keeps the result, so later runs do not read the
|
||||
manifest again.
|
||||
|
||||
The runtime skills bundle contains every bundled skill, whatever the prompt
|
||||
profile, skill flags or enabled modules. The image, the snapshot and the
|
||||
setup write the same set, so a sandbox from an image or snapshot of the same
|
||||
n8n version reuses it. A bundle from an older image or snapshot has a
|
||||
different hash, so setup writes it again one time. `load_skill` serves only
|
||||
the skills that the profile, the flags and the enabled modules allow.
|
||||
|
||||
With `N8N_LOG_LEVEL=debug`, these messages show the bundle file traffic:
|
||||
|
||||
| Message | Content |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `Checked prebaked workspace bundle` | Decision (`reused`, `cached`, `stale`, `missing`, `invalid`) and hashes |
|
||||
| `Wrote workspace bundle` | Bundle hash, file count, bytes, and duration |
|
||||
| `Read workspace file` | Path, transport, result, bytes, and duration |
|
||||
| `Wrote workspace file` | Path, transport, bytes, and duration |
|
||||
| `Wrote workspace files` | File count, bytes, and duration of a batch write |
|
||||
|
||||
When `N8N_INSTANCE_AI_SANDBOX_LINK_SDK` is enabled for local development,
|
||||
Instance AI packs and installs the local `@n8n/utils`, `@n8n/errors`,
|
||||
`n8n-workflow`, and `@n8n/workflow-sdk` packages. Build those packages before
|
||||
|
|
|
|||
|
|
@ -316,19 +316,6 @@ export async function loadPrebakedKnowledgeBaseBundle(
|
|||
schemaVersion: KNOWLEDGE_BASE_MANIFEST_SCHEMA_VERSION,
|
||||
resourceLabel: KNOWLEDGE_BASE_FILE_LABEL,
|
||||
logger: options.logger,
|
||||
invalidManifestLogMessage: 'Ignoring invalid prebaked knowledge base manifest',
|
||||
staleManifestLogMessage: 'Ignoring stale prebaked knowledge base manifest',
|
||||
staleManifestLogKeys: {
|
||||
expected: 'expectedContentHash',
|
||||
actual: 'actualContentHash',
|
||||
},
|
||||
successLogMessage: 'Using prebaked knowledge base from workspace',
|
||||
successLogContext: (loadedBundle) => ({
|
||||
root: options.root,
|
||||
knowledgeBaseRoot: loadedBundle.rootDir,
|
||||
contentHash: loadedBundle.contentHash,
|
||||
fileCount: loadedBundle.files.size,
|
||||
}),
|
||||
buildBundle: () => bundle,
|
||||
});
|
||||
}
|
||||
|
|
@ -352,13 +339,6 @@ export async function materializeKnowledgeBaseIntoWorkspace(
|
|||
loadPrebaked: async () => await loadPrebakedKnowledgeBaseBundle(options),
|
||||
buildBundle: async () => await buildKnowledgeBaseWorkspaceBundle(options),
|
||||
bundleHash: (bundle) => bundle.contentHash,
|
||||
materializedLogMessage: 'Materialized knowledge base into workspace',
|
||||
materializedLogContext: (bundle) => ({
|
||||
root: options.root,
|
||||
knowledgeBaseRoot: bundle.rootDir,
|
||||
contentHash: bundle.contentHash,
|
||||
fileCount: bundle.files.size,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,12 @@ import {
|
|||
createLazyWorkspaceRuntimeSkillSource,
|
||||
materializeRuntimeSkillsIntoWorkspace,
|
||||
} from '../materialize-runtime-skills';
|
||||
import { INSTANCE_AI_SKILLS_DIR, loadInstanceAiRuntimeSkillSource } from '../runtime-skills';
|
||||
import { INSTANCE_AI_PROMPT_PROFILES } from '../../prompts/prompt-profiles';
|
||||
import {
|
||||
INSTANCE_AI_SKILLS_DIR,
|
||||
loadInstanceAiPromptSkills,
|
||||
loadInstanceAiRuntimeSkillSource,
|
||||
} from '../runtime-skills';
|
||||
|
||||
/** Extract the text body from the content-block form of a load_skill success result. */
|
||||
function skillLoadText(output: unknown): string {
|
||||
|
|
@ -355,6 +360,131 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => {
|
|||
expect(skillLoadText(result)).toContain('[Skill: "data-table-manager"]');
|
||||
});
|
||||
|
||||
describe('with an unfiltered bundle source', () => {
|
||||
const root = '/home/daytona/workspace';
|
||||
const hiddenSkillIds = ['progressive-building', 'instance-awareness'];
|
||||
|
||||
function createSources() {
|
||||
const bundleSource = loadInstanceAiRuntimeSkillSource();
|
||||
const source = filterRuntimeSkillSource(bundleSource, hiddenSkillIds);
|
||||
return { bundleSource, source };
|
||||
}
|
||||
|
||||
it('reuses a baked bundle of all skills and serves only the filtered skills', async () => {
|
||||
const { bundleSource, source } = createSources();
|
||||
const { workspace, writes, readFile, writeFile } = createMockWorkspace();
|
||||
const baked = await buildRuntimeSkillWorkspaceBundle({
|
||||
source: bundleSource,
|
||||
root,
|
||||
logger: mockLogger,
|
||||
});
|
||||
if (!baked) throw new Error('Expected runtime skill bundle');
|
||||
for (const [path, content] of baked.files) writes.set(path, content);
|
||||
|
||||
const runtimeSource = createLazyWorkspaceRuntimeSkillSource({
|
||||
logger: mockLogger,
|
||||
source,
|
||||
bundleSource,
|
||||
workspace,
|
||||
});
|
||||
await runtimeSource.prepare?.();
|
||||
|
||||
expect(readFile).toHaveBeenCalledTimes(1);
|
||||
expect(readFile).toHaveBeenCalledWith(baked.manifestPath, expect.anything());
|
||||
expect(writeFile).not.toHaveBeenCalled();
|
||||
const registeredIds = runtimeSource.registry.skills.map(({ id }) => id);
|
||||
for (const hiddenId of hiddenSkillIds) {
|
||||
expect(registeredIds).not.toContain(hiddenId);
|
||||
await expect(runtimeSource.loadSkill(hiddenId)).resolves.toBeNull();
|
||||
}
|
||||
const text = skillLoadText(
|
||||
await createSkillLoadTool(runtimeSource).handler?.({ skillId: 'data-table-manager' }, {}),
|
||||
);
|
||||
expect(text).toContain(`${root}/${SANDBOX_RUNTIME_SKILLS_DIR}/data-table-manager/SKILL.md`);
|
||||
});
|
||||
|
||||
it.each(INSTANCE_AI_PROMPT_PROFILES.map((profile) => [profile.version, profile] as const))(
|
||||
'reuses the baked bundle for the %s prompt profile',
|
||||
async (_version, profile) => {
|
||||
const bundleSource = loadInstanceAiRuntimeSkillSource();
|
||||
const { source } = await loadInstanceAiPromptSkills(profile);
|
||||
const { workspace, writes, writeFile } = createMockWorkspace();
|
||||
const baked = await buildRuntimeSkillWorkspaceBundle({
|
||||
source: bundleSource,
|
||||
root,
|
||||
logger: mockLogger,
|
||||
});
|
||||
if (!baked) throw new Error('Expected runtime skill bundle');
|
||||
for (const [path, content] of baked.files) writes.set(path, content);
|
||||
|
||||
const runtimeSource = createLazyWorkspaceRuntimeSkillSource({
|
||||
logger: mockLogger,
|
||||
source,
|
||||
bundleSource,
|
||||
workspace,
|
||||
});
|
||||
await runtimeSource.prepare?.();
|
||||
|
||||
expect(writeFile).not.toHaveBeenCalled();
|
||||
// Served skills keep the profile's composed instructions, with the sandbox
|
||||
// paths filled in.
|
||||
const skillsRoot = `${root}/${SANDBOX_RUNTIME_SKILLS_DIR}`;
|
||||
for (const entry of source.registry.skills) {
|
||||
const served = await runtimeSource.loadSkill(entry.id);
|
||||
const original = await source.loadSkill(entry.id);
|
||||
if (!served || !original) throw new Error(`Expected skill "${entry.id}"`);
|
||||
expect(served.description).toBe(original.description);
|
||||
expect(served.instructions).toBe(
|
||||
original.instructions
|
||||
.replaceAll('$' + '{N8N_SKILL_DIR}', served.directory ?? '')
|
||||
.replaceAll('$' + '{N8N_SKILLS_DIR}', skillsRoot)
|
||||
.replaceAll('$' + '{N8N_WORKSPACE_DIR}', root),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('writes all skills when the sandbox has no bundle', async () => {
|
||||
const { bundleSource, source } = createSources();
|
||||
const { workspace, writes } = createMockWorkspace();
|
||||
|
||||
const runtimeSource = createLazyWorkspaceRuntimeSkillSource({
|
||||
logger: mockLogger,
|
||||
source,
|
||||
bundleSource,
|
||||
workspace,
|
||||
});
|
||||
await runtimeSource.prepare?.();
|
||||
|
||||
const skillsRoot = `${root}/${SANDBOX_RUNTIME_SKILLS_DIR}`;
|
||||
expect(writes.has(`${skillsRoot}/progressive-building/SKILL.md`)).toBe(true);
|
||||
const manifest = jsonParse<{ skillsHash: string }>(
|
||||
writes.get(`${skillsRoot}/${RUNTIME_SKILL_MANIFEST_FILE}`) ?? '{}',
|
||||
);
|
||||
expect(manifest.skillsHash).toBe(bundleSource.registry.skillsHash);
|
||||
expect(manifest.skillsHash).not.toBe(source.registry.skillsHash);
|
||||
});
|
||||
|
||||
it('gives each served skill a path that exists in the bundle', async () => {
|
||||
const { bundleSource, source } = createSources();
|
||||
const { workspace, writes } = createMockWorkspace();
|
||||
|
||||
const runtimeSource = createLazyWorkspaceRuntimeSkillSource({
|
||||
logger: mockLogger,
|
||||
source,
|
||||
bundleSource,
|
||||
workspace,
|
||||
});
|
||||
await runtimeSource.prepare?.();
|
||||
|
||||
expect(runtimeSource.registry.skills.length).toBe(source.registry.skills.length);
|
||||
for (const entry of runtimeSource.registry.skills) {
|
||||
expect(entry.path).toBeDefined();
|
||||
expect(writes.has(entry.path ?? '')).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to live materialization when the prebaked manifest is stale', async () => {
|
||||
const source = loadInstanceAiRuntimeSkillSource();
|
||||
const { workspace, writes, writeFile } = createMockWorkspace();
|
||||
|
|
|
|||
|
|
@ -69,7 +69,14 @@ interface BuildRuntimeSkillWorkspaceBundleOptions {
|
|||
}
|
||||
|
||||
interface MaterializeRuntimeSkillsOptions {
|
||||
/** Skills that `load_skill` serves. */
|
||||
source: RuntimeSkillSource;
|
||||
/**
|
||||
* Skills that the sandbox stores. Defaults to `source`. Pass the unfiltered
|
||||
* skill set so the bundle stays the same for all prompt profiles and skill
|
||||
* flags, and matches the image and snapshot bake.
|
||||
*/
|
||||
bundleSource?: RuntimeSkillSource;
|
||||
workspace: Workspace;
|
||||
verifiedBundles?: VerifiedWorkspaceBundles;
|
||||
root: string;
|
||||
|
|
@ -79,6 +86,8 @@ interface MaterializeRuntimeSkillsOptions {
|
|||
|
||||
interface LazyWorkspaceRuntimeSkillSourceOptions {
|
||||
source: RuntimeSkillSource;
|
||||
/** Skills that the sandbox stores. See {@link MaterializeRuntimeSkillsOptions.bundleSource}. */
|
||||
bundleSource?: RuntimeSkillSource;
|
||||
workspace: Workspace | undefined;
|
||||
/** Resolves the verified bundles of the sandbox behind `workspace`. */
|
||||
resolveVerifiedBundles?: () => Promise<VerifiedWorkspaceBundles | undefined>;
|
||||
|
|
@ -410,21 +419,6 @@ export async function loadPrebakedRuntimeSkillsBundle({
|
|||
schemaVersion: RUNTIME_SKILL_MANIFEST_SCHEMA_VERSION,
|
||||
resourceLabel: RUNTIME_SKILL_FILE_LABEL,
|
||||
logger,
|
||||
invalidManifestLogMessage: 'Ignoring invalid prebaked runtime skills manifest',
|
||||
staleManifestLogMessage: 'Ignoring stale prebaked runtime skills manifest',
|
||||
staleManifestLogKeys: {
|
||||
expected: 'expectedSkillsHash',
|
||||
actual: 'actualSkillsHash',
|
||||
},
|
||||
successLogMessage: 'Using prebaked runtime skills from workspace',
|
||||
successLogContext: (bundle) => ({
|
||||
root,
|
||||
workspaceRoot,
|
||||
skillsRoot: bundle.rootDir,
|
||||
registryPath: bundle.registryPath,
|
||||
skillsHash: bundle.skillsHash,
|
||||
count: bundle.skills.length,
|
||||
}),
|
||||
buildBundle: async () =>
|
||||
await buildRuntimeSkillWorkspaceBundle({
|
||||
source,
|
||||
|
|
@ -564,11 +558,11 @@ export async function buildRuntimeSkillWorkspaceBundle({
|
|||
const registryPath = posixJoin(skillsRoot, SANDBOX_RUNTIME_SKILL_REGISTRY_FILE);
|
||||
files.set(registryPath, stringifyWorkspaceJson(registry));
|
||||
|
||||
const manifestPath = posixJoin(skillsRoot, RUNTIME_SKILL_MANIFEST_FILE);
|
||||
const manifest: RuntimeSkillWorkspaceManifest = {
|
||||
schemaVersion: RUNTIME_SKILL_MANIFEST_SCHEMA_VERSION,
|
||||
skillsHash: source.registry.skillsHash,
|
||||
};
|
||||
const manifestPath = posixJoin(skillsRoot, RUNTIME_SKILL_MANIFEST_FILE);
|
||||
files.set(manifestPath, stringifyWorkspaceJson(manifest));
|
||||
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
|
|
@ -597,6 +591,7 @@ export async function buildRuntimeSkillWorkspaceBundle({
|
|||
|
||||
export async function materializeRuntimeSkillsIntoWorkspace({
|
||||
source,
|
||||
bundleSource = source,
|
||||
workspace,
|
||||
verifiedBundles,
|
||||
root,
|
||||
|
|
@ -605,7 +600,11 @@ export async function materializeRuntimeSkillsIntoWorkspace({
|
|||
return await traceSandboxOperation(
|
||||
'sync-skills',
|
||||
{
|
||||
inputs: { skillsHash: source.registry.skillsHash, skillCount: source.registry.skills.length },
|
||||
inputs: {
|
||||
skillsHash: bundleSource.registry.skillsHash,
|
||||
skillCount: source.registry.skills.length,
|
||||
bundleSkillCount: bundleSource.registry.skills.length,
|
||||
},
|
||||
processResult: (bundle) => ({
|
||||
outputs: { skillCount: bundle?.skills.length ?? 0, fileCount: bundle?.files.size ?? 0 },
|
||||
}),
|
||||
|
|
@ -613,42 +612,63 @@ export async function materializeRuntimeSkillsIntoWorkspace({
|
|||
async () => {
|
||||
if (source.registry.skills.length === 0) return undefined;
|
||||
|
||||
return await materializeWorkspaceBundle({
|
||||
const bundle = await materializeWorkspaceBundle({
|
||||
workspace,
|
||||
verifiedBundles,
|
||||
resourceLabel: RUNTIME_SKILL_FILE_LABEL,
|
||||
logger,
|
||||
loadPrebaked: async () =>
|
||||
await loadPrebakedRuntimeSkillsBundle({
|
||||
source,
|
||||
source: bundleSource,
|
||||
workspace,
|
||||
verifiedBundles,
|
||||
root,
|
||||
logger,
|
||||
}),
|
||||
buildBundle: async () => {
|
||||
const bundle = await buildRuntimeSkillWorkspaceBundle({ source, root, logger });
|
||||
if (!bundle) {
|
||||
const built = await buildRuntimeSkillWorkspaceBundle({
|
||||
source: bundleSource,
|
||||
root,
|
||||
logger,
|
||||
});
|
||||
if (!built) {
|
||||
throw new Error('Expected runtime skill bundle after registry validation');
|
||||
}
|
||||
return bundle;
|
||||
return built;
|
||||
},
|
||||
bundleHash: (bundle) => bundle.skillsHash,
|
||||
materializedLogMessage: 'Materialized runtime skills into workspace',
|
||||
materializedLogContext: (bundle) => ({
|
||||
root,
|
||||
skillsRoot: bundle.rootDir,
|
||||
registryPath: bundle.registryPath,
|
||||
skillsHash: bundle.skillsHash,
|
||||
count: bundle.skills.length,
|
||||
}),
|
||||
bundleHash: (built) => built.skillsHash,
|
||||
});
|
||||
return bundleSource === source ? bundle : runtimeSkillsFromBundle(bundle, source, root);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve `source` from a bundle that stores more skills. Skills keep the paths
|
||||
* of the bundle layout, and skills that `source` hides cannot be loaded.
|
||||
*/
|
||||
function runtimeSkillsFromBundle(
|
||||
bundle: RuntimeSkillWorkspaceBundle,
|
||||
source: RuntimeSkillSource,
|
||||
workspaceRoot: string,
|
||||
): RuntimeSkillWorkspaceBundle {
|
||||
const skillIds = new Set(source.registry.skills.map(({ id }) => id));
|
||||
return {
|
||||
...bundle,
|
||||
skills: bundle.skills.filter(({ id }) => skillIds.has(id)),
|
||||
source: createMaterializedRuntimeSkillSource(
|
||||
source,
|
||||
materializedRegistry(source.registry, bundle.skills),
|
||||
bundle.skills,
|
||||
workspaceRoot,
|
||||
bundle.rootDir,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function createLazyWorkspaceRuntimeSkillSource({
|
||||
source,
|
||||
bundleSource,
|
||||
workspace,
|
||||
resolveVerifiedBundles,
|
||||
logger,
|
||||
|
|
@ -681,7 +701,14 @@ export function createLazyWorkspaceRuntimeSkillSource({
|
|||
materializePromise ??= (async () => {
|
||||
const root = await getWorkspaceRoot(runtimeWorkspace);
|
||||
const verifiedBundles = await resolveVerifiedBundles?.();
|
||||
const options = { source, workspace: runtimeWorkspace, verifiedBundles, root, logger };
|
||||
const options = {
|
||||
source,
|
||||
bundleSource,
|
||||
workspace: runtimeWorkspace,
|
||||
verifiedBundles,
|
||||
root,
|
||||
logger,
|
||||
};
|
||||
const result = await materializeRuntimeSkillsIntoWorkspace(options);
|
||||
if (result) {
|
||||
materialized = result;
|
||||
|
|
|
|||
|
|
@ -69,11 +69,6 @@ describe('loadPrebakedWorkspaceBundle', () => {
|
|||
hashField: 'contentHash',
|
||||
schemaVersion: 1,
|
||||
resourceLabel: 'Test bundle file',
|
||||
invalidManifestLogMessage: 'invalid',
|
||||
staleManifestLogMessage: 'stale',
|
||||
staleManifestLogKeys: { expected: 'expectedHash', actual: 'actualHash' },
|
||||
successLogMessage: 'success',
|
||||
successLogContext: () => ({ root: ROOT }),
|
||||
buildBundle: () => bundle,
|
||||
});
|
||||
|
||||
|
|
@ -93,11 +88,6 @@ describe('loadPrebakedWorkspaceBundle', () => {
|
|||
hashField: 'contentHash',
|
||||
schemaVersion: 1,
|
||||
resourceLabel: 'Test bundle file',
|
||||
invalidManifestLogMessage: 'invalid',
|
||||
staleManifestLogMessage: 'stale',
|
||||
staleManifestLogKeys: { expected: 'expectedHash', actual: 'actualHash' },
|
||||
successLogMessage: 'success',
|
||||
successLogContext: () => ({ root: ROOT }),
|
||||
buildBundle: () => bundle,
|
||||
});
|
||||
|
||||
|
|
@ -122,11 +112,6 @@ describe('loadPrebakedWorkspaceBundle', () => {
|
|||
hashField: 'contentHash',
|
||||
schemaVersion: 1,
|
||||
resourceLabel: 'Test bundle file',
|
||||
invalidManifestLogMessage: 'invalid',
|
||||
staleManifestLogMessage: 'stale',
|
||||
staleManifestLogKeys: { expected: 'expectedHash', actual: 'actualHash' },
|
||||
successLogMessage: 'success',
|
||||
successLogContext: () => ({ root: ROOT }),
|
||||
buildBundle: () => bundle,
|
||||
});
|
||||
|
||||
|
|
@ -158,8 +143,6 @@ describe('materializeWorkspaceBundle', () => {
|
|||
loadPrebaked: async () => await Promise.resolve(undefined),
|
||||
buildBundle: () => bundle,
|
||||
bundleHash: (built) => built.contentHash,
|
||||
materializedLogMessage: 'materialized',
|
||||
materializedLogContext: () => ({ root: ROOT }),
|
||||
});
|
||||
|
||||
expect(result).toBe(bundle);
|
||||
|
|
@ -188,8 +171,6 @@ describe('materializeWorkspaceBundle', () => {
|
|||
loadPrebaked: async () => await Promise.resolve(undefined),
|
||||
buildBundle: () => bundle,
|
||||
bundleHash: (built) => built.contentHash,
|
||||
materializedLogMessage: 'materialized',
|
||||
materializedLogContext: () => ({ root: ROOT }),
|
||||
});
|
||||
|
||||
expect(writeOrder.at(-1)).toBe(manifestPath);
|
||||
|
|
@ -216,17 +197,10 @@ describe('materializeWorkspaceBundle', () => {
|
|||
hashField: 'contentHash',
|
||||
schemaVersion: 1,
|
||||
resourceLabel: 'Test bundle file',
|
||||
invalidManifestLogMessage: 'invalid',
|
||||
staleManifestLogMessage: 'stale',
|
||||
staleManifestLogKeys: { expected: 'expectedHash', actual: 'actualHash' },
|
||||
successLogMessage: 'success',
|
||||
successLogContext: () => ({ root: ROOT }),
|
||||
buildBundle: () => bundle,
|
||||
}),
|
||||
buildBundle: () => bundle,
|
||||
bundleHash: (built) => built.contentHash,
|
||||
materializedLogMessage: 'materialized',
|
||||
materializedLogContext: () => ({ root: ROOT }),
|
||||
});
|
||||
|
||||
expect(result).toBe(bundle);
|
||||
|
|
@ -274,17 +248,10 @@ describe('prebaked bundle checks with verified bundles', () => {
|
|||
hashField: 'contentHash',
|
||||
schemaVersion: 1,
|
||||
resourceLabel: 'Test bundle file',
|
||||
invalidManifestLogMessage: 'invalid',
|
||||
staleManifestLogMessage: 'stale',
|
||||
staleManifestLogKeys: { expected: 'expectedHash', actual: 'actualHash' },
|
||||
successLogMessage: 'success',
|
||||
successLogContext: () => ({ root: ROOT }),
|
||||
buildBundle: () => bundle,
|
||||
}),
|
||||
buildBundle: () => bundle,
|
||||
bundleHash: (built) => built.contentHash,
|
||||
materializedLogMessage: 'materialized',
|
||||
materializedLogContext: () => ({ root: ROOT }),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -346,6 +313,65 @@ describe('prebaked bundle checks with verified bundles', () => {
|
|||
expect(verifiedBundles.get(manifestPath)).toBe(bundle.contentHash);
|
||||
});
|
||||
|
||||
it('logs the decision of a stale manifest', async () => {
|
||||
const { workspace } = createSandboxWorkspace(
|
||||
new Map([
|
||||
[
|
||||
manifestPath,
|
||||
stringifyWorkspaceJson({
|
||||
schemaVersion: 1,
|
||||
contentHash: 'stale',
|
||||
files: ['a.txt', 'old.txt'],
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
|
||||
await loadPrebakedWorkspaceBundle({
|
||||
logger,
|
||||
workspace,
|
||||
manifestPath,
|
||||
expectedHash: bundle.contentHash,
|
||||
hashField: 'contentHash',
|
||||
schemaVersion: 1,
|
||||
resourceLabel: 'Test bundle file',
|
||||
buildBundle: () => bundle,
|
||||
});
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith('Checked prebaked workspace bundle', {
|
||||
resource: 'Test bundle file',
|
||||
manifestPath,
|
||||
decision: 'stale',
|
||||
expectedHash: bundle.contentHash,
|
||||
actualHash: 'stale',
|
||||
});
|
||||
});
|
||||
|
||||
it('logs the written bundle', async () => {
|
||||
const { workspace } = createSandboxWorkspace(new Map());
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
|
||||
await materializeWorkspaceBundle({
|
||||
logger,
|
||||
workspace,
|
||||
resourceLabel: 'Test bundle file',
|
||||
loadPrebaked: async () => await Promise.resolve(undefined),
|
||||
buildBundle: () => bundle,
|
||||
bundleHash: (built) => built.contentHash,
|
||||
});
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'Wrote workspace bundle',
|
||||
expect.objectContaining({
|
||||
resource: 'Test bundle file',
|
||||
manifestPath,
|
||||
hash: bundle.contentHash,
|
||||
fileCount: bundle.files.size,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not record the bundle when a write fails', async () => {
|
||||
const { workspace, writeFile, executeCommand } = createSandboxWorkspace(new Map());
|
||||
writeFile.mockRejectedValue(new Error('disk full'));
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { OperationalError } from 'n8n-workflow';
|
||||
|
||||
import { isQuotaExhaustedError } from '../../stream/map-chunk';
|
||||
import type { WorkspaceFileTarget } from '../workspace-files';
|
||||
import { readWorkspaceFile, writeWorkspaceFile, writeWorkspaceFileMap } from '../workspace-files';
|
||||
|
|
@ -194,6 +196,89 @@ describe('workspace-files', () => {
|
|||
expect(writes.get('/tmp/c.txt')).toBe('gamma');
|
||||
});
|
||||
|
||||
it('logs each read with its transport and result', async () => {
|
||||
const { target } = createWorkspaceTarget(new Map([['/tmp/found.json', '{}']]));
|
||||
target.filesystem = { writeFile: vi.fn(async () => {}) };
|
||||
const logger = createLogger();
|
||||
const options = { logger, resourceLabel: 'Test file' };
|
||||
|
||||
await readWorkspaceFile(target, '/tmp/found.json', options);
|
||||
await readWorkspaceFile(target, '/tmp/missing.json', options);
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'Read workspace file',
|
||||
expect.objectContaining({
|
||||
resource: 'Test file',
|
||||
path: '/tmp/found.json',
|
||||
transport: 'command',
|
||||
found: true,
|
||||
bytes: 2,
|
||||
}),
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'Read workspace file',
|
||||
expect.objectContaining({ path: '/tmp/missing.json', transport: 'command', found: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs the miss reason of a filesystem read', async () => {
|
||||
const readFile = vi.fn(async () => await Promise.reject(new Error('ENOENT')));
|
||||
const target: WorkspaceFileTarget = {
|
||||
filesystem: { readFile, writeFile: vi.fn(async () => {}) },
|
||||
};
|
||||
const logger = createLogger();
|
||||
|
||||
await readWorkspaceFile(target, '/tmp/manifest.json', { logger });
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'Read workspace file',
|
||||
expect.objectContaining({
|
||||
transport: 'filesystem',
|
||||
found: false,
|
||||
missReason: expect.stringContaining('ENOENT'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs each write and a summary for batch writes', async () => {
|
||||
const { target } = createWorkspaceTarget(new Map());
|
||||
const logger = createLogger();
|
||||
const options = { logger, resourceLabel: 'Test file' };
|
||||
|
||||
await writeWorkspaceFileMap(
|
||||
target,
|
||||
new Map([
|
||||
['/tmp/b.txt', 'beta'],
|
||||
['/tmp/c.txt', 'gamma'],
|
||||
]),
|
||||
options,
|
||||
);
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'Wrote workspace file',
|
||||
expect.objectContaining({ path: '/tmp/b.txt', transport: 'filesystem', bytes: 4 }),
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'Wrote workspace files',
|
||||
expect.objectContaining({ resource: 'Test file', fileCount: 2, bytes: 9 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs a write that used the command fallback', async () => {
|
||||
const { target } = createWorkspaceTarget(new Map());
|
||||
target.filesystem = {
|
||||
writeFile: vi.fn(async () => await Promise.reject(new Error('permission denied'))),
|
||||
};
|
||||
const logger = createLogger();
|
||||
|
||||
await writeWorkspaceFile(target, '/tmp/a.txt', 'alpha', { logger, retryBackoffBaseMs: 1 });
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'Wrote workspace file',
|
||||
expect.objectContaining({ path: '/tmp/a.txt', transport: 'command-fallback' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('retries filesystem writes on transient upstream errors', async () => {
|
||||
const { target, writes } = createWorkspaceTarget(new Map());
|
||||
const writeFile = vi
|
||||
|
|
@ -266,6 +351,20 @@ describe('workspace-files', () => {
|
|||
expect(executeCommand).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('reports a failed write as an operational error at error level', async () => {
|
||||
const writeFile = vi.fn(async () => await Promise.reject(new Error('permission denied')));
|
||||
const executeCommand = vi.fn(async () => await Promise.reject(new Error('sandbox gone')));
|
||||
const target: WorkspaceFileTarget = { filesystem: { writeFile }, sandbox: { executeCommand } };
|
||||
|
||||
const error = await writeWorkspaceFile(target, '/tmp/a.txt', 'alpha', {
|
||||
logger: createLogger(),
|
||||
retryBackoffBaseMs: 1,
|
||||
}).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toBeInstanceOf(OperationalError);
|
||||
expect(error).toMatchObject({ level: 'error' });
|
||||
});
|
||||
|
||||
it('keeps a classifiable primary quota error as cause when both write paths fail', async () => {
|
||||
const writeError = Object.assign(new Error('Have reached end of quota'), {
|
||||
statusCode: 403,
|
||||
|
|
@ -382,6 +481,9 @@ describe('workspace-files', () => {
|
|||
'Knowledge base file filesystem write failed; used command fallback',
|
||||
expect.objectContaining({ path: '/tmp/a.txt' }),
|
||||
);
|
||||
expect(logger.debug).not.toHaveBeenCalled();
|
||||
expect(logger.debug).not.toHaveBeenCalledWith(
|
||||
'Knowledge base file filesystem write failed; used command fallback',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,14 +24,6 @@ export interface LoadPrebakedWorkspaceBundleOptions<TBundle> {
|
|||
schemaVersion: number;
|
||||
resourceLabel: string;
|
||||
logger: Logger;
|
||||
invalidManifestLogMessage: string;
|
||||
staleManifestLogMessage: string;
|
||||
staleManifestLogKeys: {
|
||||
expected: string;
|
||||
actual: string;
|
||||
};
|
||||
successLogMessage: string;
|
||||
successLogContext: (bundle: TBundle) => Record<string, unknown>;
|
||||
buildBundle: () => Promise<TBundle | undefined> | TBundle | undefined;
|
||||
}
|
||||
|
||||
|
|
@ -47,22 +39,21 @@ export async function loadPrebakedWorkspaceBundle<TBundle extends { files: Map<s
|
|||
let decision = 'reused';
|
||||
let actualHash: string | undefined;
|
||||
const { verifiedBundles } = options;
|
||||
return await traceSandboxOperation(
|
||||
const bundle = await traceSandboxOperation(
|
||||
'check-bundle',
|
||||
{
|
||||
kind: 'batch',
|
||||
inputs: { manifestPath: options.manifestPath, expectedHash: options.expectedHash },
|
||||
processResult: (bundle) => ({
|
||||
outputs: { decision, actualHash, fileCount: bundle?.files.size ?? 0 },
|
||||
processResult: (result) => ({
|
||||
outputs: { decision, actualHash, fileCount: result?.files.size ?? 0 },
|
||||
}),
|
||||
},
|
||||
async () => {
|
||||
if (verifiedBundles?.get(options.manifestPath) === options.expectedHash) {
|
||||
decision = 'cached';
|
||||
actualHash = options.expectedHash;
|
||||
const bundle = await options.buildBundle();
|
||||
if (!bundle) decision = 'empty';
|
||||
return bundle;
|
||||
const cachedBundle = await options.buildBundle();
|
||||
if (!cachedBundle) decision = 'empty';
|
||||
return cachedBundle;
|
||||
}
|
||||
|
||||
const manifestRaw = await readWorkspaceFile(options.workspace, options.manifestPath, {
|
||||
|
|
@ -80,34 +71,33 @@ export async function loadPrebakedWorkspaceBundle<TBundle extends { files: Map<s
|
|||
});
|
||||
if (!manifest) {
|
||||
decision = 'invalid';
|
||||
options.logger.debug(options.invalidManifestLogMessage, {
|
||||
manifestPath: options.manifestPath,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
actualHash = manifest.hash;
|
||||
if (manifest.hash !== options.expectedHash) {
|
||||
decision = 'stale';
|
||||
options.logger.debug(options.staleManifestLogMessage, {
|
||||
manifestPath: options.manifestPath,
|
||||
[options.staleManifestLogKeys.expected]: options.expectedHash,
|
||||
[options.staleManifestLogKeys.actual]: manifest.hash,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const bundle = await options.buildBundle();
|
||||
if (!bundle) {
|
||||
const prebakedBundle = await options.buildBundle();
|
||||
if (!prebakedBundle) {
|
||||
decision = 'empty';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
verifiedBundles?.set(options.manifestPath, options.expectedHash);
|
||||
options.logger.debug(options.successLogMessage, options.successLogContext(bundle));
|
||||
return bundle;
|
||||
return prebakedBundle;
|
||||
},
|
||||
);
|
||||
options.logger.debug('Checked prebaked workspace bundle', {
|
||||
resource: options.resourceLabel,
|
||||
manifestPath: options.manifestPath,
|
||||
decision,
|
||||
expectedHash: options.expectedHash,
|
||||
...(actualHash !== undefined ? { actualHash } : {}),
|
||||
});
|
||||
return bundle;
|
||||
}
|
||||
|
||||
export interface MaterializeWorkspaceBundleOptions<
|
||||
|
|
@ -121,8 +111,6 @@ export interface MaterializeWorkspaceBundleOptions<
|
|||
buildBundle: () => Promise<TBundle> | TBundle;
|
||||
/** Hash that the bundle manifest records. */
|
||||
bundleHash: (bundle: TBundle) => string;
|
||||
materializedLogMessage: string;
|
||||
materializedLogContext: (bundle: TBundle) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Materialize a workspace bundle, skipping writes when a valid prebaked manifest exists. */
|
||||
|
|
@ -154,6 +142,7 @@ export async function materializeWorkspaceBundle<
|
|||
return prebaked;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const bundle = await options.buildBundle();
|
||||
const payloadFiles = new Map(bundle.files);
|
||||
payloadFiles.delete(bundle.manifestPath);
|
||||
|
|
@ -171,8 +160,19 @@ export async function materializeWorkspaceBundle<
|
|||
});
|
||||
}
|
||||
|
||||
options.verifiedBundles?.set(bundle.manifestPath, options.bundleHash(bundle));
|
||||
options.logger.debug(options.materializedLogMessage, options.materializedLogContext(bundle));
|
||||
const hash = options.bundleHash(bundle);
|
||||
options.verifiedBundles?.set(bundle.manifestPath, hash);
|
||||
options.logger.debug('Wrote workspace bundle', {
|
||||
resource: options.resourceLabel,
|
||||
manifestPath: bundle.manifestPath,
|
||||
hash,
|
||||
fileCount: bundle.files.size,
|
||||
bytes: [...bundle.files.values()].reduce(
|
||||
(sum, content) => sum + sandboxFileBytes(content),
|
||||
0,
|
||||
),
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return bundle;
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { isAbortError } from '@n8n/agents';
|
||||
import { OperationalError } from 'n8n-workflow';
|
||||
|
||||
import { formatErrorForLog } from '../error-formatting';
|
||||
import type { Logger } from '../logger';
|
||||
|
|
@ -69,59 +70,88 @@ export async function readWorkspaceFile(
|
|||
}),
|
||||
},
|
||||
async () => {
|
||||
const filesystem = workspace.filesystem;
|
||||
const readFile = filesystem?.readFile;
|
||||
if (filesystem && readFile) {
|
||||
try {
|
||||
return decodeWorkspaceFileContent(
|
||||
await retryTransientSandboxIo(
|
||||
// .call preserves the provider's `this` binding (e.g. LazyRuntimeFilesystem).
|
||||
async () =>
|
||||
await readFile.call(filesystem, filePath, {
|
||||
encoding: 'utf-8',
|
||||
abortSignal: options?.abortSignal,
|
||||
}),
|
||||
filePath,
|
||||
options,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
// A provider failure is not a missing file — surface it instead of reporting null.
|
||||
if (isTransientSandboxIoError(error)) {
|
||||
throw new Error(
|
||||
`Failed to read ${resourceLabel(options).toLowerCase()} "${filePath}": ${formatErrorForLog(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
options?.logger.debug(`${resourceLabel(options)} filesystem read missed`, {
|
||||
path: filePath,
|
||||
error: formatErrorForLog(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!workspace.sandbox) return null;
|
||||
|
||||
try {
|
||||
return await readFileViaSandbox(workspace, filePath, options);
|
||||
} catch (error) {
|
||||
if (isTransientSandboxIoError(error)) {
|
||||
throw new Error(
|
||||
`Failed to read ${resourceLabel(options).toLowerCase()} "${filePath}": ${formatErrorForLog(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
options?.logger.debug(`${resourceLabel(options)} command read missed`, {
|
||||
path: filePath,
|
||||
error: formatErrorForLog(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
const read = await readWorkspaceFileContent(workspace, filePath, options);
|
||||
options?.logger.debug('Read workspace file', {
|
||||
resource: resourceLabel(options),
|
||||
path: filePath,
|
||||
transport: read.transport,
|
||||
found: read.content !== null,
|
||||
bytes: read.content === null ? 0 : sandboxFileBytes(read.content),
|
||||
durationMs: Date.now() - startedAt,
|
||||
...(read.missReason ? { missReason: read.missReason } : {}),
|
||||
});
|
||||
return read.content;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
type WorkspaceFileTransport = 'filesystem' | 'command' | 'command-fallback' | 'none';
|
||||
|
||||
interface WorkspaceFileRead {
|
||||
content: string | null;
|
||||
transport: WorkspaceFileTransport;
|
||||
missReason?: string;
|
||||
}
|
||||
|
||||
async function readWorkspaceFileContent(
|
||||
workspace: WorkspaceFileTarget,
|
||||
filePath: string,
|
||||
options?: WorkspaceFileOptions,
|
||||
): Promise<WorkspaceFileRead> {
|
||||
const filesystem = workspace.filesystem;
|
||||
const readFile = filesystem?.readFile;
|
||||
if (filesystem && readFile) {
|
||||
try {
|
||||
const content = decodeWorkspaceFileContent(
|
||||
await retryTransientSandboxIo(
|
||||
// .call preserves the provider's `this` binding (e.g. LazyRuntimeFilesystem).
|
||||
async () =>
|
||||
await readFile.call(filesystem, filePath, {
|
||||
encoding: 'utf-8',
|
||||
abortSignal: options?.abortSignal,
|
||||
}),
|
||||
filePath,
|
||||
options,
|
||||
),
|
||||
);
|
||||
return { content, transport: 'filesystem' };
|
||||
} catch (error) {
|
||||
// A provider failure is not a missing file — surface it instead of reporting null.
|
||||
if (isTransientSandboxIoError(error)) {
|
||||
throw new OperationalError(
|
||||
`Failed to read ${resourceLabel(options).toLowerCase()} "${filePath}": ${formatErrorForLog(error)}`,
|
||||
{
|
||||
cause: error,
|
||||
level: 'error',
|
||||
},
|
||||
);
|
||||
}
|
||||
return { content: null, transport: 'filesystem', missReason: formatErrorForLog(error) };
|
||||
}
|
||||
}
|
||||
|
||||
if (!workspace.sandbox) return { content: null, transport: 'none' };
|
||||
|
||||
try {
|
||||
return {
|
||||
content: await readFileViaSandbox(workspace, filePath, options),
|
||||
transport: 'command',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isTransientSandboxIoError(error)) {
|
||||
throw new OperationalError(
|
||||
`Failed to read ${resourceLabel(options).toLowerCase()} "${filePath}": ${formatErrorForLog(error)}`,
|
||||
{
|
||||
cause: error,
|
||||
level: 'error',
|
||||
},
|
||||
);
|
||||
}
|
||||
return { content: null, transport: 'command', missReason: formatErrorForLog(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a UTF-8 workspace file. Tries the workspace filesystem first with a
|
||||
* sandbox command fallback when the provider supports both.
|
||||
|
|
@ -136,79 +166,106 @@ export async function writeWorkspaceFile(
|
|||
'write-file',
|
||||
{ kind: 'io', inputs: { path: filePath, bytes: sandboxFileBytes(content) } },
|
||||
async () => {
|
||||
const label = resourceLabel(options);
|
||||
|
||||
const filesystem = workspace.filesystem;
|
||||
if (filesystem) {
|
||||
try {
|
||||
await retryTransientSandboxIo(
|
||||
async () =>
|
||||
await filesystem.writeFile(filePath, content, {
|
||||
recursive: true,
|
||||
abortSignal: options?.abortSignal,
|
||||
}),
|
||||
filePath,
|
||||
options,
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) throw error;
|
||||
try {
|
||||
await traceSandboxOperation(
|
||||
'file-command-fallback',
|
||||
{ inputs: { path: filePath } },
|
||||
async () => await writeFileViaSandbox(workspace, filePath, content, options),
|
||||
);
|
||||
options?.logger.warn(`${label} filesystem write failed; used command fallback`, {
|
||||
path: filePath,
|
||||
error: formatErrorForLog(error),
|
||||
});
|
||||
return;
|
||||
} catch (fallbackError) {
|
||||
if (isAbortError(fallbackError)) throw fallbackError;
|
||||
// Preserve whichever path carries quota metadata so callers can
|
||||
// classify the combined failure correctly.
|
||||
throw new Error(
|
||||
`Failed to write ${label.toLowerCase()} "${filePath}": ${formatErrorForLog(error)}; command fallback failed: ${formatErrorForLog(fallbackError)}`,
|
||||
{ cause: selectWriteFailureCause(error, fallbackError) },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await writeFileViaSandbox(workspace, filePath, content, options);
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) throw error;
|
||||
throw new Error(
|
||||
`Failed to write ${label.toLowerCase()} "${filePath}": ${formatErrorForLog(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
const transport = await writeWorkspaceFileContent(workspace, filePath, content, options);
|
||||
options?.logger.debug('Wrote workspace file', {
|
||||
resource: resourceLabel(options),
|
||||
path: filePath,
|
||||
transport,
|
||||
bytes: sandboxFileBytes(content),
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function writeWorkspaceFileContent(
|
||||
workspace: WorkspaceFileTarget,
|
||||
filePath: string,
|
||||
content: string,
|
||||
options?: WorkspaceFileOptions,
|
||||
): Promise<WorkspaceFileTransport> {
|
||||
const label = resourceLabel(options);
|
||||
|
||||
const filesystem = workspace.filesystem;
|
||||
if (filesystem) {
|
||||
try {
|
||||
await retryTransientSandboxIo(
|
||||
async () =>
|
||||
await filesystem.writeFile(filePath, content, {
|
||||
recursive: true,
|
||||
abortSignal: options?.abortSignal,
|
||||
}),
|
||||
filePath,
|
||||
options,
|
||||
);
|
||||
return 'filesystem';
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) throw error;
|
||||
try {
|
||||
await traceSandboxOperation(
|
||||
'file-command-fallback',
|
||||
{ inputs: { path: filePath } },
|
||||
async () => await writeFileViaSandbox(workspace, filePath, content, options),
|
||||
);
|
||||
options?.logger.warn(`${label} filesystem write failed; used command fallback`, {
|
||||
path: filePath,
|
||||
error: formatErrorForLog(error),
|
||||
});
|
||||
return 'command-fallback';
|
||||
} catch (fallbackError) {
|
||||
if (isAbortError(fallbackError)) throw fallbackError;
|
||||
// Preserve whichever path carries quota metadata so callers can
|
||||
// classify the combined failure correctly.
|
||||
throw new OperationalError(
|
||||
`Failed to write ${label.toLowerCase()} "${filePath}": ${formatErrorForLog(error)}; command fallback failed: ${formatErrorForLog(fallbackError)}`,
|
||||
{
|
||||
cause: selectWriteFailureCause(error, fallbackError),
|
||||
level: 'error',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await writeFileViaSandbox(workspace, filePath, content, options);
|
||||
return 'command';
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) throw error;
|
||||
throw new OperationalError(
|
||||
`Failed to write ${label.toLowerCase()} "${filePath}": ${formatErrorForLog(error)}`,
|
||||
{
|
||||
cause: error,
|
||||
level: 'error',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Write several UTF-8 workspace files in parallel. */
|
||||
export async function writeWorkspaceFileMap(
|
||||
workspace: WorkspaceFileTarget,
|
||||
files: Map<string, string>,
|
||||
options?: WorkspaceFileOptions,
|
||||
): Promise<void> {
|
||||
const bytes = [...files.values()].reduce((sum, content) => sum + sandboxFileBytes(content), 0);
|
||||
return await traceSandboxOperation(
|
||||
'write-files',
|
||||
{
|
||||
kind: 'batch',
|
||||
inputs: {
|
||||
fileCount: files.size,
|
||||
bytes: [...files.values()].reduce((sum, content) => sum + sandboxFileBytes(content), 0),
|
||||
},
|
||||
},
|
||||
{ kind: 'batch', inputs: { fileCount: files.size, bytes } },
|
||||
async () => {
|
||||
const startedAt = Date.now();
|
||||
await Promise.all(
|
||||
Array.from(files, async ([filePath, content]) => {
|
||||
await writeWorkspaceFile(workspace, filePath, content, options);
|
||||
}),
|
||||
);
|
||||
options?.logger.debug('Wrote workspace files', {
|
||||
resource: resourceLabel(options),
|
||||
fileCount: files.size,
|
||||
bytes,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,57 @@
|
|||
import { createServer } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { buildAllowedHosts, isAuthorized, startHttpTransport } from '../server';
|
||||
|
||||
type Started = Awaited<ReturnType<typeof startHttpTransport>>;
|
||||
|
||||
async function boot(authToken: string): Promise<{
|
||||
/** Bind an ephemeral port, note it, release it. Sessions can only initialize on a concrete port:
|
||||
* `buildAllowedHosts` bakes the port into the allowed Host list, so booting on port 0 produces
|
||||
* `127.0.0.1:0` and the SDK's DNS-rebinding check then rejects the real Host header. */
|
||||
async function freePort(): Promise<number> {
|
||||
const probe = createServer();
|
||||
await new Promise<void>((resolve) => probe.listen(0, '127.0.0.1', () => resolve()));
|
||||
const { port } = probe.address() as AddressInfo;
|
||||
await new Promise<void>((resolve) => probe.close(() => resolve()));
|
||||
return port;
|
||||
}
|
||||
|
||||
async function boot(
|
||||
authToken: string,
|
||||
sessionOpts: { sessionIdleTtlMs?: number; sessionSweepIntervalMs?: number } = {},
|
||||
): Promise<{
|
||||
started: Started;
|
||||
baseUrl: string;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const started = await startHttpTransport({
|
||||
config: {},
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
authToken,
|
||||
});
|
||||
const address = started.httpServer.address() as AddressInfo;
|
||||
return {
|
||||
started,
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
close: async () => {
|
||||
await new Promise<void>((resolve) => started.httpServer.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
// There is an unavoidable gap between releasing the probed port and re-binding it, so another
|
||||
// process can win the race. `startHttpTransport` now rejects on a failed bind rather than
|
||||
// hanging, which makes that losable race simply retryable here.
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const port = await freePort();
|
||||
try {
|
||||
const started = await startHttpTransport({
|
||||
config: {},
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
authToken,
|
||||
...sessionOpts,
|
||||
});
|
||||
return {
|
||||
started,
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
close: async () => {
|
||||
await new Promise<void>((resolve) => started.httpServer.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE') throw error;
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
describe('isAuthorized', () => {
|
||||
|
|
@ -76,6 +105,25 @@ describe('HTTP transport', () => {
|
|||
expect(address.address).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('enables TCP keepalive on client sockets', async () => {
|
||||
const keepAliveCalls: Array<[boolean, number | undefined]> = [];
|
||||
booted.started.httpServer.once('connection', (socket) => {
|
||||
const originalSetKeepAlive = socket.setKeepAlive.bind(socket);
|
||||
socket.setKeepAlive = ((enable: boolean, initialDelay?: number) => {
|
||||
keepAliveCalls.push([enable, initialDelay]);
|
||||
return originalSetKeepAlive(enable, initialDelay);
|
||||
}) as typeof socket.setKeepAlive;
|
||||
});
|
||||
|
||||
await fetch(booted.baseUrl + '/mcp', {
|
||||
method: 'POST',
|
||||
headers: { authorization: 'Bearer ' + token },
|
||||
body: '{}',
|
||||
});
|
||||
|
||||
expect(keepAliveCalls).toContainEqual([true, 60_000]);
|
||||
});
|
||||
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
const res = await fetch(`${booted.baseUrl}/mcp`, { method: 'POST', body: '{}' });
|
||||
|
||||
|
|
@ -117,6 +165,169 @@ describe('HTTP transport', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('idle session eviction', () => {
|
||||
const token = 'test-token-1234';
|
||||
|
||||
/** Opens a session and returns its id. Requires a real (non-ephemeral) port so the SDK's
|
||||
* DNS-rebinding Host check passes and initialization actually completes. */
|
||||
async function openSession(baseUrl: string, id: number): Promise<string> {
|
||||
const res = await fetch(`${baseUrl}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-06-18',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test-client', version: '1.0.0' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const sessionId = res.headers.get('mcp-session-id');
|
||||
if (!sessionId)
|
||||
throw new Error(`initialize did not return a session id (status ${res.status})`);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/** A follow-up request on an existing session -- the path that refreshes its activity stamp. */
|
||||
async function pingSession(baseUrl: string, sessionId: string, id: number) {
|
||||
await fetch(`${baseUrl}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream',
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name
|
||||
'mcp-session-id': sessionId,
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id, method: 'ping' }),
|
||||
});
|
||||
}
|
||||
|
||||
it('evicts sessions that go idle past the TTL, shutting down their browser connections', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'Date'] });
|
||||
const booted = await boot(token, { sessionIdleTtlMs: 50, sessionSweepIntervalMs: 10 });
|
||||
try {
|
||||
for (let i = 0; i < 3; i++) await openSession(booted.baseUrl, i);
|
||||
expect(booted.started.activeConnections.size).toBe(3);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
expect(booted.started.activeConnections.size).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
await booted.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a session alive while its client keeps making requests', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'Date'] });
|
||||
const ttl = 100;
|
||||
const booted = await boot(token, { sessionIdleTtlMs: ttl, sessionSweepIntervalMs: 10 });
|
||||
try {
|
||||
const sessionId = await openSession(booted.baseUrl, 1);
|
||||
expect(booted.started.activeConnections.size).toBe(1);
|
||||
|
||||
// Keep pinging for well over the TTL. Each ping must refresh the activity stamp,
|
||||
// so the session survives a span it would certainly have been evicted across
|
||||
// had it stayed idle.
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await vi.advanceTimersByTimeAsync(ttl / 2);
|
||||
await pingSession(booted.baseUrl, sessionId, 100 + i);
|
||||
}
|
||||
|
||||
expect(booted.started.activeConnections.size).toBe(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
await booted.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not evict a session while an SSE request is still open', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'Date'] });
|
||||
const booted = await boot(token, { sessionIdleTtlMs: 50, sessionSweepIntervalMs: 10 });
|
||||
const controller = new AbortController();
|
||||
let response: Response | undefined;
|
||||
try {
|
||||
const sessionId = await openSession(booted.baseUrl, 1);
|
||||
response = await fetch(`${booted.baseUrl}/mcp`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
accept: 'text/event-stream',
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name
|
||||
'mcp-session-id': sessionId,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name
|
||||
'mcp-protocol-version': '2025-06-18',
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
expect(booted.started.activeConnections.size).toBe(1);
|
||||
} finally {
|
||||
controller.abort();
|
||||
await response?.body?.cancel().catch(() => undefined);
|
||||
vi.useRealTimers();
|
||||
await booted.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('evicts a transport whose request never completes initialization', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'Date'] });
|
||||
const booted = await boot(token, { sessionIdleTtlMs: 50, sessionSweepIntervalMs: 10 });
|
||||
try {
|
||||
const response = await fetch(`${booted.baseUrl}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream',
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name
|
||||
'mcp-protocol-version': '2025-06-18',
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(booted.started.activeConnections.size).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(booted.started.activeConnections.size).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
await booted.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('startHttpTransport bind failures', () => {
|
||||
it('rejects instead of hanging when the port is already taken', async () => {
|
||||
const port = await freePort();
|
||||
const squatter = createServer();
|
||||
await new Promise<void>((resolve) => squatter.listen(port, '127.0.0.1', () => resolve()));
|
||||
|
||||
try {
|
||||
await expect(
|
||||
startHttpTransport({ config: {}, host: '127.0.0.1', port, authToken: 'token' }),
|
||||
).rejects.toMatchObject({ code: 'EADDRINUSE' });
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => squatter.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAllowedHosts', () => {
|
||||
it('returns the full loopback alias set when bound to a loopback address', () => {
|
||||
const hosts = buildAllowedHosts('127.0.0.1', 3100);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,15 @@ import type { Config, ToolDefinition } from './types';
|
|||
|
||||
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
|
||||
|
||||
/**
|
||||
* How long a session may go without a request before it is evicted, and how often to sweep.
|
||||
* A session only ends on `transport.onclose`, which the SDK fires on an explicit HTTP DELETE —
|
||||
* a client that simply goes away never sends one, so without this its BrowserConnection (and the
|
||||
* browser it holds) would be retained for the lifetime of the process.
|
||||
*/
|
||||
const SESSION_IDLE_TTL_MS = 30 * 60 * 1000;
|
||||
const SESSION_SWEEP_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
function registerTools(server: McpServer, tools: ToolDefinition[]) {
|
||||
for (const tool of tools) {
|
||||
server.registerTool(
|
||||
|
|
@ -79,16 +88,54 @@ export async function startHttpTransport(opts: {
|
|||
host: string;
|
||||
port: number;
|
||||
authToken: string;
|
||||
/** Overridable for tests; defaults to SESSION_IDLE_TTL_MS / SESSION_SWEEP_INTERVAL_MS. */
|
||||
sessionIdleTtlMs?: number;
|
||||
sessionSweepIntervalMs?: number;
|
||||
}): Promise<{
|
||||
httpServer: ReturnType<typeof createServer>;
|
||||
activeConnections: Set<BrowserConnection>;
|
||||
}> {
|
||||
const { config, host, port, authToken } = opts;
|
||||
const {
|
||||
config,
|
||||
host,
|
||||
port,
|
||||
authToken,
|
||||
sessionIdleTtlMs = SESSION_IDLE_TTL_MS,
|
||||
sessionSweepIntervalMs = SESSION_SWEEP_INTERVAL_MS,
|
||||
} = opts;
|
||||
const sessions = new Map<string, StreamableHTTPServerTransport>();
|
||||
const activeConnections = new Set<BrowserConnection>();
|
||||
// Keyed by transport rather than by session id: a connection is created (and its browser
|
||||
// reserved) as soon as a request arrives, which is BEFORE `onsessioninitialized` assigns an
|
||||
// id -- one that never finishes initializing has no session id at all, and would otherwise
|
||||
// never become reapable.
|
||||
const lastActivityAt = new Map<StreamableHTTPServerTransport, number>();
|
||||
const inFlightRequests = new Map<StreamableHTTPServerTransport, number>();
|
||||
|
||||
const trackRequest = (transport: StreamableHTTPServerTransport, res: ServerResponse) => {
|
||||
lastActivityAt.set(transport, Date.now());
|
||||
inFlightRequests.set(transport, (inFlightRequests.get(transport) ?? 0) + 1);
|
||||
|
||||
const onResponseClose = () => {
|
||||
const inFlight = inFlightRequests.get(transport);
|
||||
if (inFlight === undefined) return;
|
||||
if (inFlight === 1) inFlightRequests.delete(transport);
|
||||
else inFlightRequests.set(transport, inFlight - 1);
|
||||
|
||||
// A long-running request counts as activity when it completes. Do not let the
|
||||
// next sweep evict it immediately just because it started before the TTL.
|
||||
if (lastActivityAt.has(transport)) lastActivityAt.set(transport, Date.now());
|
||||
};
|
||||
|
||||
res.once('close', onResponseClose);
|
||||
};
|
||||
const allowedHosts = buildAllowedHosts(host, port);
|
||||
|
||||
const httpServer = createServer((req, res) => {
|
||||
// Let the OS detect clients that disappear without closing the TCP connection. An open
|
||||
// SSE response otherwise stays in-flight forever and pins its browser session.
|
||||
req.socket.setKeepAlive(true, 60_000);
|
||||
|
||||
// Reject any request that carries an Origin header. The transport has no
|
||||
// browser clients; the only way an Origin header appears is from a
|
||||
// fetch/XHR initiated by web content, which we never want to serve.
|
||||
|
|
@ -107,6 +154,7 @@ export async function startHttpTransport(opts: {
|
|||
const existingTransport = sessionId ? sessions.get(sessionId) : undefined;
|
||||
|
||||
if (existingTransport) {
|
||||
trackRequest(existingTransport, res);
|
||||
void existingTransport.handleRequest(req, res);
|
||||
return;
|
||||
}
|
||||
|
|
@ -128,15 +176,25 @@ export async function startHttpTransport(opts: {
|
|||
enableDnsRebindingProtection: allowedHosts !== undefined,
|
||||
allowedHosts,
|
||||
onsessioninitialized: (id) => {
|
||||
// A sweep can fire while this request is still in flight and evict the transport
|
||||
// before the SDK gets here. `lastActivityAt` doubles as the liveness registry
|
||||
// (onclose removes the entry), so a missing entry means "already torn down" —
|
||||
// registering the session now would resurrect a closed transport into `sessions`
|
||||
// with no activity tracking behind it, i.e. an entry nothing can ever reap.
|
||||
if (!lastActivityAt.has(transport)) return;
|
||||
sessions.set(id, transport);
|
||||
},
|
||||
});
|
||||
lastActivityAt.set(transport, Date.now());
|
||||
|
||||
transport.onclose = () => {
|
||||
if (transport.sessionId) sessions.delete(transport.sessionId);
|
||||
lastActivityAt.delete(transport);
|
||||
inFlightRequests.delete(transport);
|
||||
activeConnections.delete(connection);
|
||||
void connection.shutdown();
|
||||
};
|
||||
trackRequest(transport, res);
|
||||
|
||||
const mcpServer = new McpServer({ name: 'n8n-browser', version: '1.0.0' });
|
||||
registerTools(mcpServer, tools);
|
||||
|
|
@ -146,8 +204,33 @@ export async function startHttpTransport(opts: {
|
|||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
// Evict sessions no client has touched for a while. `unref` so this timer never keeps the
|
||||
// process (or a test run) alive on its own.
|
||||
const sweeper = setInterval(() => {
|
||||
const cutoff = Date.now() - sessionIdleTtlMs;
|
||||
for (const [transport, seenAt] of [...lastActivityAt]) {
|
||||
if ((inFlightRequests.get(transport) ?? 0) > 0) continue;
|
||||
if (seenAt > cutoff) continue;
|
||||
// `transport.close()` fires onclose, which removes the session, drops the connection
|
||||
// from activeConnections and shuts the browser down -- same teardown path as an
|
||||
// explicit client DELETE, so the cleanup logic lives in exactly one place.
|
||||
void transport.close();
|
||||
}
|
||||
}, sessionSweepIntervalMs);
|
||||
sweeper.unref();
|
||||
httpServer.on('close', () => clearInterval(sweeper));
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// Without an `error` handler a failed bind (EADDRINUSE, EACCES on a privileged port…)
|
||||
// never settles this promise, so the caller hangs forever instead of reporting why the
|
||||
// server did not come up.
|
||||
const onError = (error: Error) => {
|
||||
clearInterval(sweeper);
|
||||
reject(error);
|
||||
};
|
||||
httpServer.once('error', onError);
|
||||
httpServer.listen(port, host, () => {
|
||||
httpServer.removeListener('error', onError);
|
||||
console.debug(`n8n-browser MCP server listening on http://${host}:${port}`);
|
||||
resolve();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -190,9 +190,12 @@ describe('EmbeddingsDatabricks', () => {
|
|||
await node.supplyData.call(ctx, 0);
|
||||
|
||||
const [, databricksHandler] = mockedMakeN8nLlmFailedAttemptHandler.mock.calls[0];
|
||||
expect(() => databricksHandler?.(Object.assign(new Error('429 x'), { status: 429 }))).toThrow(
|
||||
'Databricks rate limit reached for system.ai.gte-large-en',
|
||||
);
|
||||
const rateLimited = Object.assign(new Error('429 x'), { status: 429 });
|
||||
expect(() => databricksHandler?.(rateLimited)).not.toThrow();
|
||||
expect(rateLimited).toMatchObject({
|
||||
message: 'Databricks rate limit reached for system.ai.gte-large-en',
|
||||
description: expect.stringMatching(/throttling requests to this model service/),
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject non-https hosts', async () => {
|
||||
|
|
|
|||
|
|
@ -174,9 +174,12 @@ describe('LmChatDatabricks', () => {
|
|||
await node.supplyData.call(ctx, 0);
|
||||
|
||||
const [, databricksHandler] = mockedMakeN8nLlmFailedAttemptHandler.mock.calls[0];
|
||||
expect(() => databricksHandler?.(Object.assign(new Error('429 x'), { status: 429 }))).toThrow(
|
||||
'Databricks rate limit reached for my-chat-endpoint',
|
||||
);
|
||||
const rateLimited = Object.assign(new Error('429 x'), { status: 429 });
|
||||
expect(() => databricksHandler?.(rateLimited)).not.toThrow();
|
||||
expect(rateLimited).toMatchObject({
|
||||
message: 'Databricks rate limit reached for my-chat-endpoint',
|
||||
description: expect.stringMatching(/throttling requests to this model service/),
|
||||
});
|
||||
});
|
||||
|
||||
it('should send the bearer and the partner User-Agent on every request', async () => {
|
||||
|
|
|
|||
|
|
@ -665,6 +665,7 @@ export class McpServer {
|
|||
return MessageFormatter.formatToolResult(
|
||||
result,
|
||||
MessageFormatter.isErrorResult(result),
|
||||
requestedTool?.metadata?.isFromToolkit === true,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -673,7 +674,11 @@ export class McpServer {
|
|||
messageId: extra.requestId?.toString(),
|
||||
});
|
||||
|
||||
return MessageFormatter.formatToolResult(result, MessageFormatter.isErrorResult(result));
|
||||
return MessageFormatter.formatToolResult(
|
||||
result,
|
||||
MessageFormatter.isErrorResult(result),
|
||||
requestedTool?.metadata?.isFromToolkit === true,
|
||||
);
|
||||
} catch (error) {
|
||||
const errorObject = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.error(`Error while executing Tool ${toolName}: ${errorObject.message}`, {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,25 @@ import type { CredentialCheckResult } from 'n8n-workflow';
|
|||
import { isToolErrorResult } from '@utils/tool-error-result';
|
||||
|
||||
import type { McpToolResult } from './types';
|
||||
import { isMcpContentArray, type McpContentBlock } from '../../shared/utils';
|
||||
|
||||
/**
|
||||
* Parse a tool result string that may be a JSON-serialized MCP content array.
|
||||
*
|
||||
* Proxy mode passes the tool result through `DynamicStructuredTool.invoke`,
|
||||
* which serializes the tool's return value to a string before it leaves the
|
||||
* tool. The raw content array therefore arrives here as JSON text. Malformed
|
||||
* JSON and JSON that is not an MCP content array return `null` so the caller
|
||||
* wraps the original string.
|
||||
*/
|
||||
function parseMcpContentArrayString(value: string): McpContentBlock[] | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return isMcpContentArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of presenting a single missing credential's connection URL to the
|
||||
|
|
@ -17,25 +36,34 @@ export interface CredentialGateElicitationOutcome {
|
|||
}
|
||||
|
||||
export class MessageFormatter {
|
||||
static formatToolResult(result: unknown, isError = false): McpToolResult {
|
||||
let content: McpToolResult['content'];
|
||||
static formatToolResult(result: unknown, isError = false, isFromMcpTool = false): McpToolResult {
|
||||
let content: McpContentBlock[];
|
||||
|
||||
if (typeof result === 'object' && result !== null) {
|
||||
content = [{ type: 'text', text: JSON.stringify(result) }];
|
||||
if (isFromMcpTool && isMcpContentArray(result)) {
|
||||
content = result; // pass-through
|
||||
} else if (typeof result === 'object' && result !== null) {
|
||||
content = [{ type: 'text' as const, text: JSON.stringify(result) }];
|
||||
} else if (typeof result === 'string') {
|
||||
content = [{ type: 'text', text: result }];
|
||||
// Proxy mode hands us a JSON string, so parse before wrapping. A failed
|
||||
// parse or a non-MCP value falls back to the original string, never the
|
||||
// parsed value.
|
||||
const parsedContent = isFromMcpTool ? parseMcpContentArrayString(result) : null;
|
||||
content = parsedContent ?? [{ type: 'text' as const, text: result }];
|
||||
} else if (result === null || result === undefined) {
|
||||
content = [{ type: 'text', text: String(result) }];
|
||||
content = [{ type: 'text' as const, text: String(result) }];
|
||||
} else if (
|
||||
typeof result === 'number' ||
|
||||
typeof result === 'boolean' ||
|
||||
typeof result === 'bigint'
|
||||
) {
|
||||
content = [{ type: 'text', text: result.toString() }];
|
||||
content = [{ type: 'text' as const, text: result.toString() }];
|
||||
} else {
|
||||
// Remaining types: symbol, function - convert to string representation
|
||||
content = [
|
||||
{ type: 'text', text: String(result as symbol | ((...args: unknown[]) => unknown)) },
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: String(result),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +77,7 @@ export class MessageFormatter {
|
|||
static formatError(error: Error): McpToolResult {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: `${error.name}: ${error.message}` }],
|
||||
content: [{ type: 'text' as const, text: `${error.name}: ${error.message}` }],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +105,7 @@ export class MessageFormatter {
|
|||
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: 'text', text }],
|
||||
content: [{ type: 'text' as const, text }],
|
||||
credentialGate: result,
|
||||
};
|
||||
}
|
||||
|
|
@ -108,7 +136,7 @@ export class MessageFormatter {
|
|||
);
|
||||
}
|
||||
|
||||
const content = [{ type: 'text', text: lines.join('\n') }];
|
||||
const content = [{ type: 'text' as const, text: lines.join('\n') }];
|
||||
// Omit `isError` when nothing is left to connect, matching formatToolResult.
|
||||
return skipped.length > 0 ? { isError: true, content } : { content };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,116 +1,431 @@
|
|||
import type { CredentialCheckResult } from 'n8n-workflow';
|
||||
|
||||
import { MessageFormatter } from '../MessageFormatter';
|
||||
import type { ContentBlock } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
function getTextContent(blocks: ContentBlock[]): string {
|
||||
return (blocks[0] as { type: 'text'; text: string }).text;
|
||||
}
|
||||
|
||||
describe('MessageFormatter', () => {
|
||||
describe('formatToolResult', () => {
|
||||
it('should format object result as JSON string in content array', () => {
|
||||
const result = { data: 'value', count: 42 };
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [{ type: 'text', text: '{"data":"value","count":42}' }],
|
||||
describe('MCP content array pass-through', () => {
|
||||
it('should pass through single text content block', () => {
|
||||
const result = [{ type: 'text', text: 'Hello world' }];
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: [{ type: 'text', text: 'Hello world' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through mixed text and image content blocks', () => {
|
||||
const result = [
|
||||
{ type: 'text', text: 'Image description' },
|
||||
{ type: 'image', data: 'base64data', mimeType: 'image/png' },
|
||||
];
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: result,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through with isError=true', () => {
|
||||
const result = [{ type: 'text', text: 'Error occurred' }];
|
||||
expect(MessageFormatter.formatToolResult(result, true, true)).toEqual({
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: 'Error occurred' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through audio content block', () => {
|
||||
const result = [{ type: 'audio', data: 'base64audio', mimeType: 'audio/mpeg' }];
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: result,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through resource content block', () => {
|
||||
const result = [
|
||||
{ type: 'resource', resource: { uri: 'file:///test.txt', text: 'content' } },
|
||||
];
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: result,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through resource content block with blob payload', () => {
|
||||
const result = [
|
||||
{ type: 'resource', resource: { uri: 'file:///test.bin', blob: 'base64data' } },
|
||||
];
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: result,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through resource content block containing text and blob', () => {
|
||||
const result = [
|
||||
{
|
||||
type: 'resource',
|
||||
resource: { uri: 'file:///test.txt', text: 'content', blob: 'base64data' },
|
||||
},
|
||||
];
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: result,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through resource_link content block', () => {
|
||||
const result = [
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'https://example.com/resource',
|
||||
name: 'Example resource',
|
||||
},
|
||||
];
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should format string result directly without double-quoting', () => {
|
||||
expect(MessageFormatter.formatToolResult('hello world')).toEqual({
|
||||
content: [{ type: 'text', text: 'hello world' }],
|
||||
describe('MCP content array gating', () => {
|
||||
it('should wrap an MCP content array when isFromMcpTool is false (default)', () => {
|
||||
const result = [{ type: 'text', text: 'Hello world' }];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [{ type: 'text', text: JSON.stringify(result) }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap an MCP content array when isFromMcpTool is explicitly false', () => {
|
||||
const result = [{ type: 'text', text: 'Hello world' }];
|
||||
expect(MessageFormatter.formatToolResult(result, false, false)).toEqual({
|
||||
content: [{ type: 'text', text: JSON.stringify(result) }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap an MCP content array from a non-MCP tool even with mixed blocks', () => {
|
||||
const result = [
|
||||
{ type: 'text', text: 'Image description' },
|
||||
{ type: 'image', data: 'base64data', mimeType: 'image/png' },
|
||||
];
|
||||
expect(MessageFormatter.formatToolResult(result, false, false)).toEqual({
|
||||
content: [{ type: 'text', text: JSON.stringify(result) }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through an MCP content array when isFromMcpTool is true', () => {
|
||||
const result = [{ type: 'text', text: 'Hello world' }];
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: [{ type: 'text', text: 'Hello world' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should format number as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(42)).toEqual({
|
||||
content: [{ type: 'text', text: '42' }],
|
||||
describe('stringified MCP content array gating', () => {
|
||||
it('should wrap a stringified MCP content array when isFromMcpTool is false (default)', () => {
|
||||
const result = '[{"type":"text","text":"{\\"success\\":true}"}]';
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [{ type: 'text', text: result }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap a stringified MCP content array when isFromMcpTool is explicitly false', () => {
|
||||
const result = '[{"type":"text","text":"{\\"success\\":true}"}]';
|
||||
expect(MessageFormatter.formatToolResult(result, false, false)).toEqual({
|
||||
content: [{ type: 'text', text: result }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap a stringified MCP content array from a non-MCP tool even with mixed blocks', () => {
|
||||
const parsedContent = [
|
||||
{ type: 'text', text: 'Image description' },
|
||||
{ type: 'image', data: 'base64data', mimeType: 'image/png' },
|
||||
];
|
||||
const raw = JSON.stringify(parsedContent);
|
||||
expect(MessageFormatter.formatToolResult(raw, false, false)).toEqual({
|
||||
content: [{ type: 'text', text: raw }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through a stringified MCP content array when isFromMcpTool is true', () => {
|
||||
const result = '[{"type":"text","text":"{\\"success\\":true}"}]';
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: [{ type: 'text', text: '{"success":true}' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should format zero as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(0)).toEqual({
|
||||
content: [{ type: 'text', text: '0' }],
|
||||
describe('stringified MCP content array pass-through', () => {
|
||||
it('should pass through a JSON string containing a single text content block', () => {
|
||||
const result = JSON.stringify([{ type: 'text', text: 'Hello world' }]);
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: [{ type: 'text', text: 'Hello world' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through the exact payload from the reviewer manual test', () => {
|
||||
const result = '[{"type":"text","text":"{\\"success\\":true}"}]';
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: [{ type: 'text', text: '{"success":true}' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through a JSON string containing mixed text and image content blocks', () => {
|
||||
const parsedContent = [
|
||||
{ type: 'text', text: 'Image description' },
|
||||
{ type: 'image', data: 'base64data', mimeType: 'image/png' },
|
||||
];
|
||||
expect(
|
||||
MessageFormatter.formatToolResult(JSON.stringify(parsedContent), false, true),
|
||||
).toEqual({
|
||||
content: parsedContent,
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap the original string when it is not valid JSON', () => {
|
||||
expect(MessageFormatter.formatToolResult('not json', false, true)).toEqual({
|
||||
content: [{ type: 'text', text: 'not json' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap the original string when valid JSON is not an MCP content array', () => {
|
||||
expect(MessageFormatter.formatToolResult('[1,2,3]', false, true)).toEqual({
|
||||
content: [{ type: 'text', text: '[1,2,3]' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap the original string when valid JSON is a plain object', () => {
|
||||
expect(MessageFormatter.formatToolResult('{"a":1}', false, true)).toEqual({
|
||||
content: [{ type: 'text', text: '{"a":1}' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap the original string when one content block is invalid', () => {
|
||||
const result = JSON.stringify([
|
||||
{ type: 'text', text: 'valid' },
|
||||
{ type: 'invalid', text: 'not valid' },
|
||||
]);
|
||||
expect(MessageFormatter.formatToolResult(result, false, true)).toEqual({
|
||||
content: [{ type: 'text', text: result }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve isError alongside a stringified MCP content array', () => {
|
||||
const result = '[{"type":"text","text":"Error occurred"}]';
|
||||
expect(MessageFormatter.formatToolResult(result, true, true)).toEqual({
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: 'Error occurred' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should format negative number as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(-123)).toEqual({
|
||||
content: [{ type: 'text', text: '-123' }],
|
||||
describe('fallback to stringify + wrap for invalid MCP content arrays', () => {
|
||||
it('should stringify array with invalid type', () => {
|
||||
const result = [{ type: 'invalid', text: 'hello' }];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [{ type: 'text', text: '[{"type":"invalid","text":"hello"}]' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should stringify array with text block missing text', () => {
|
||||
const result = [{ type: 'text' }];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [{ type: 'text', text: '[{"type":"text"}]' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should stringify array with image block missing data', () => {
|
||||
const result = [{ type: 'image', mimeType: 'image/png' }];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [{ type: 'text', text: '[{"type":"image","mimeType":"image/png"}]' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should stringify array with image block missing mimeType', () => {
|
||||
const result = [{ type: 'image', data: 'base64data' }];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [{ type: 'text', text: '[{"type":"image","data":"base64data"}]' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should stringify array with resource block missing nested uri', () => {
|
||||
const result = [{ type: 'resource', resource: { text: 'content' } }];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '[{"type":"resource","resource":{"text":"content"}}]',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should stringify array with resource block missing text or blob', () => {
|
||||
const result = [{ type: 'resource', resource: { uri: 'file:///test.txt' } }];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '[{"type":"resource","resource":{"uri":"file:///test.txt"}}]',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should stringify array with resource_link block missing uri', () => {
|
||||
const result = [{ type: 'resource_link', name: 'Example resource' }];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '[{"type":"resource_link","name":"Example resource"}]',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should stringify array with resource_link block missing name', () => {
|
||||
const result = [{ type: 'resource_link', uri: 'https://example.com/resource' }];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '[{"type":"resource_link","uri":"https://example.com/resource"}]',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should stringify array with mixed valid and invalid blocks', () => {
|
||||
const result = [
|
||||
{ type: 'text', text: 'valid' },
|
||||
{ type: 'invalid', text: 'not valid' },
|
||||
];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '[{"type":"text","text":"valid"},{"type":"invalid","text":"not valid"}]',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should stringify empty array', () => {
|
||||
const result: unknown[] = [];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [{ type: 'text', text: '[]' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should format float as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(3.14159)).toEqual({
|
||||
content: [{ type: 'text', text: '3.14159' }],
|
||||
describe('existing behavior unchanged for non-MCP arrays', () => {
|
||||
it('should format object result as JSON string in content array', () => {
|
||||
const result = { data: 'value', count: 42 };
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [{ type: 'text', text: '{"data":"value","count":42}' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should format boolean true as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(true)).toEqual({
|
||||
content: [{ type: 'text', text: 'true' }],
|
||||
it('should format string result directly without double-quoting', () => {
|
||||
expect(MessageFormatter.formatToolResult('hello world')).toEqual({
|
||||
content: [{ type: 'text', text: 'hello world' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should format boolean false as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(false)).toEqual({
|
||||
content: [{ type: 'text', text: 'false' }],
|
||||
it('should format number as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(42)).toEqual({
|
||||
content: [{ type: 'text', text: '42' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should format null as JSON string "null"', () => {
|
||||
expect(MessageFormatter.formatToolResult(null)).toEqual({
|
||||
content: [{ type: 'text', text: 'null' }],
|
||||
it('should format zero as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(0)).toEqual({
|
||||
content: [{ type: 'text', text: '0' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should format undefined as string "undefined"', () => {
|
||||
expect(MessageFormatter.formatToolResult(undefined)).toEqual({
|
||||
content: [{ type: 'text', text: 'undefined' }],
|
||||
it('should format negative number as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(-123)).toEqual({
|
||||
content: [{ type: 'text', text: '-123' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nested objects correctly', () => {
|
||||
const result = { outer: { inner: { deep: 'value' } } };
|
||||
const formatted = MessageFormatter.formatToolResult(result);
|
||||
expect(formatted.content[0].text).toBe(JSON.stringify(result));
|
||||
});
|
||||
|
||||
it('should handle arrays', () => {
|
||||
const result = [1, 2, 3];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [{ type: 'text', text: '[1,2,3]' }],
|
||||
it('should format float as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(3.14159)).toEqual({
|
||||
content: [{ type: 'text', text: '3.14159' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
expect(MessageFormatter.formatToolResult([])).toEqual({
|
||||
content: [{ type: 'text', text: '[]' }],
|
||||
it('should format boolean true as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(true)).toEqual({
|
||||
content: [{ type: 'text', text: 'true' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
expect(MessageFormatter.formatToolResult({})).toEqual({
|
||||
content: [{ type: 'text', text: '{}' }],
|
||||
it('should format boolean false as string', () => {
|
||||
expect(MessageFormatter.formatToolResult(false)).toEqual({
|
||||
content: [{ type: 'text', text: 'false' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle array of objects', () => {
|
||||
const result = [{ id: 1 }, { id: 2 }];
|
||||
const formatted = MessageFormatter.formatToolResult(result);
|
||||
expect(formatted.content[0].text).toBe(JSON.stringify(result));
|
||||
});
|
||||
|
||||
it('should handle object with special characters in values', () => {
|
||||
const result = { message: 'Hello "world" with\nnewline' };
|
||||
const formatted = MessageFormatter.formatToolResult(result);
|
||||
expect(formatted.content[0].text).toBe(JSON.stringify(result));
|
||||
});
|
||||
|
||||
it('should handle empty string result', () => {
|
||||
expect(MessageFormatter.formatToolResult('')).toEqual({
|
||||
content: [{ type: 'text', text: '' }],
|
||||
it('should format null as JSON string "null"', () => {
|
||||
expect(MessageFormatter.formatToolResult(null)).toEqual({
|
||||
content: [{ type: 'text', text: 'null' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle string with unicode characters', () => {
|
||||
expect(MessageFormatter.formatToolResult('Hello')).toEqual({
|
||||
content: [{ type: 'text', text: 'Hello' }],
|
||||
it('should format undefined as string "undefined"', () => {
|
||||
expect(MessageFormatter.formatToolResult(undefined)).toEqual({
|
||||
content: [{ type: 'text', text: 'undefined' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nested objects correctly', () => {
|
||||
const result = { outer: { inner: { deep: 'value' } } };
|
||||
const formatted = MessageFormatter.formatToolResult(result);
|
||||
expect(getTextContent(formatted.content)).toBe(JSON.stringify(result));
|
||||
});
|
||||
|
||||
it('should handle arrays', () => {
|
||||
const result = [1, 2, 3];
|
||||
expect(MessageFormatter.formatToolResult(result)).toEqual({
|
||||
content: [{ type: 'text', text: '[1,2,3]' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
expect(MessageFormatter.formatToolResult([])).toEqual({
|
||||
content: [{ type: 'text', text: '[]' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
expect(MessageFormatter.formatToolResult({})).toEqual({
|
||||
content: [{ type: 'text', text: '{}' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle array of objects', () => {
|
||||
const result = [{ id: 1 }, { id: 2 }];
|
||||
const formatted = MessageFormatter.formatToolResult(result);
|
||||
expect(getTextContent(formatted.content)).toBe(JSON.stringify(result));
|
||||
});
|
||||
|
||||
it('should handle object with special characters in values', () => {
|
||||
const result = { message: 'Hello "world" with\nnewline' };
|
||||
const formatted = MessageFormatter.formatToolResult(result);
|
||||
expect(getTextContent(formatted.content)).toBe(JSON.stringify(result));
|
||||
});
|
||||
|
||||
it('should handle empty string result', () => {
|
||||
expect(MessageFormatter.formatToolResult('')).toEqual({
|
||||
content: [{ type: 'text', text: '' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle string with unicode characters', () => {
|
||||
expect(MessageFormatter.formatToolResult('Hello')).toEqual({
|
||||
content: [{ type: 'text', text: 'Hello' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -183,7 +498,7 @@ describe('MessageFormatter', () => {
|
|||
it('should set isError when flag is true', () => {
|
||||
const result = MessageFormatter.formatToolResult('There was an error: "Unauthorized"', true);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toBe('There was an error: "Unauthorized"');
|
||||
expect(getTextContent(result.content)).toBe('There was an error: "Unauthorized"');
|
||||
});
|
||||
|
||||
it('should not set isError when flag is false', () => {
|
||||
|
|
@ -200,7 +515,7 @@ describe('MessageFormatter', () => {
|
|||
const errorObj = { error: { message: 'Bad request', name: 'NodeApiError' } };
|
||||
const result = MessageFormatter.formatToolResult(errorObj, true);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toBe(JSON.stringify(errorObj));
|
||||
expect(getTextContent(result.content)).toBe(JSON.stringify(errorObj));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -211,7 +526,7 @@ describe('MessageFormatter', () => {
|
|||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].type).toBe('text');
|
||||
expect(result.content[0].text).toContain('Error: Something went wrong');
|
||||
expect(getTextContent(result.content)).toContain('Error: Something went wrong');
|
||||
});
|
||||
|
||||
it('should handle error with empty message', () => {
|
||||
|
|
@ -219,7 +534,7 @@ describe('MessageFormatter', () => {
|
|||
const result = MessageFormatter.formatError(error);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('Error: ');
|
||||
expect(getTextContent(result.content)).toContain('Error: ');
|
||||
});
|
||||
|
||||
it('should handle error with special characters in message', () => {
|
||||
|
|
@ -227,7 +542,7 @@ describe('MessageFormatter', () => {
|
|||
const result = MessageFormatter.formatError(error);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('Error: Failed: "invalid" <value>');
|
||||
expect(getTextContent(result.content)).toContain('Error: Failed: "invalid" <value>');
|
||||
});
|
||||
|
||||
it('should handle error with newlines in message', () => {
|
||||
|
|
@ -235,7 +550,7 @@ describe('MessageFormatter', () => {
|
|||
const result = MessageFormatter.formatError(error);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('Error: Line 1\nLine 2');
|
||||
expect(getTextContent(result.content)).toContain('Error: Line 1\nLine 2');
|
||||
});
|
||||
|
||||
it('should handle TypeError', () => {
|
||||
|
|
@ -243,7 +558,9 @@ describe('MessageFormatter', () => {
|
|||
const result = MessageFormatter.formatError(error);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('TypeError: Cannot read property of undefined');
|
||||
expect(getTextContent(result.content)).toContain(
|
||||
'TypeError: Cannot read property of undefined',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle custom error subclass', () => {
|
||||
|
|
@ -257,7 +574,7 @@ describe('MessageFormatter', () => {
|
|||
const result = MessageFormatter.formatError(error);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('CustomError: Custom error message');
|
||||
expect(getTextContent(result.content)).toContain('CustomError: Custom error message');
|
||||
});
|
||||
|
||||
it('should not include stack trace in the response', () => {
|
||||
|
|
@ -266,8 +583,8 @@ describe('MessageFormatter', () => {
|
|||
'Error: Test error\n at Context.<anonymous> (test.ts:1:1)\n at /internal/path/node.js:100:5';
|
||||
const result = MessageFormatter.formatError(error);
|
||||
|
||||
expect(result.content[0].text).toBe('Error: Test error');
|
||||
expect(result.content[0].text).not.toContain('internal/path');
|
||||
expect(getTextContent(result.content)).toBe('Error: Test error');
|
||||
expect(getTextContent(result.content)).not.toContain('internal/path');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -290,9 +607,9 @@ describe('MessageFormatter', () => {
|
|||
const result = MessageFormatter.formatCredentialGate(gateResult);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('My Slack (slackOAuth2Api)');
|
||||
expect(getTextContent(result.content)).toContain('My Slack (slackOAuth2Api)');
|
||||
// The URL is emitted raw on its own line (not wrapped in prose).
|
||||
expect(result.content[0].text.split('\n')).toContain(
|
||||
expect(getTextContent(result.content).split('\n')).toContain(
|
||||
'https://n8n.test/rest/credentials/cred-1/authorize?resolverId=n8n',
|
||||
);
|
||||
// The structured field carries the full result (raw URLs) for programmatic clients.
|
||||
|
|
@ -321,7 +638,7 @@ describe('MessageFormatter', () => {
|
|||
],
|
||||
};
|
||||
|
||||
const text = MessageFormatter.formatCredentialGate(gateResult).content[0].text;
|
||||
const text = getTextContent(MessageFormatter.formatCredentialGate(gateResult).content);
|
||||
|
||||
expect(text).toContain('Missing Cred (notionOAuth2Api)');
|
||||
expect(text).not.toContain('Connected Cred');
|
||||
|
|
@ -340,7 +657,7 @@ describe('MessageFormatter', () => {
|
|||
],
|
||||
};
|
||||
|
||||
const text = MessageFormatter.formatCredentialGate(gateResult).content[0].text;
|
||||
const text = getTextContent(MessageFormatter.formatCredentialGate(gateResult).content);
|
||||
|
||||
expect(text).toContain('No URL Cred (httpHeaderAuth): not connected');
|
||||
});
|
||||
|
|
@ -353,10 +670,10 @@ describe('MessageFormatter', () => {
|
|||
]);
|
||||
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(result.content[0].text).toContain('retry the request');
|
||||
expect(result.content[0].text).toContain('My Slack (slackOAuth2Api)');
|
||||
expect(getTextContent(result.content)).toContain('retry the request');
|
||||
expect(getTextContent(result.content)).toContain('My Slack (slackOAuth2Api)');
|
||||
// The raw URL is never relayed as text on the elicitation path.
|
||||
expect(result.content[0].text).not.toContain('http');
|
||||
expect(getTextContent(result.content)).not.toContain('http');
|
||||
});
|
||||
|
||||
it('should flag an error and list credentials that were declined or cancelled', () => {
|
||||
|
|
@ -367,10 +684,10 @@ describe('MessageFormatter', () => {
|
|||
]);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('My Slack (slackOAuth2Api)');
|
||||
expect(result.content[0].text).toContain('still need to be connected');
|
||||
expect(result.content[0].text).toContain('My Notion (notionOAuth2Api)');
|
||||
expect(result.content[0].text).toContain('My GitHub (githubOAuth2Api)');
|
||||
expect(getTextContent(result.content)).toContain('My Slack (slackOAuth2Api)');
|
||||
expect(getTextContent(result.content)).toContain('still need to be connected');
|
||||
expect(getTextContent(result.content)).toContain('My Notion (notionOAuth2Api)');
|
||||
expect(getTextContent(result.content)).toContain('My GitHub (githubOAuth2Api)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user