From 1bb166dbaa70996da3b3b2c41e0c5a456af06850 Mon Sep 17 00:00:00 2001 From: Matsu Date: Tue, 22 Sep 2026 12:48:49 +0000 Subject: [PATCH 01/29] chore: Enforce extraneous dependencies in Oxlint (no-changelog) (#39281) --- packages/@n8n/oxlint-config/package.json | 5 +++ .../@n8n/oxlint-config/src/configs/base.ts | 28 ++++++++++++ .../@n8n/oxlint-config/src/import-x-alias.ts | 19 ++++++++ packages/cli/eslint.guardrails.config.mjs | 44 ------------------- packages/cli/oxlint.config.mts | 3 +- packages/cli/package.json | 6 +-- pnpm-lock.yaml | 3 ++ .../lint-parity/OXLINT_MIGRATION_BLOCKERS.md | 16 +++---- scripts/lint-parity/oxlint-gap.json | 7 --- scripts/lint-parity/oxlint-parity.mjs | 7 ++- 10 files changed, 71 insertions(+), 67 deletions(-) create mode 100644 packages/@n8n/oxlint-config/src/import-x-alias.ts delete mode 100644 packages/cli/eslint.guardrails.config.mjs diff --git a/packages/@n8n/oxlint-config/package.json b/packages/@n8n/oxlint-config/package.json index fa85f0c6d3d..0d8b33ed50e 100644 --- a/packages/@n8n/oxlint-config/package.json +++ b/packages/@n8n/oxlint-config/package.json @@ -19,6 +19,10 @@ "./nodes": { "types": "./dist/configs/nodes.d.ts", "default": "./dist/configs/nodes.js" + }, + "./import-x-alias": { + "types": "./dist/import-x-alias.d.ts", + "default": "./dist/import-x-alias.js" } }, "scripts": { @@ -41,6 +45,7 @@ "dependencies": { "@n8n/eslint-config": "workspace:*", "@stylistic/eslint-plugin": "^5.0.0", + "eslint-plugin-import-x": "^4.15.2", "eslint-plugin-lodash": "^8.0.0", "eslint-plugin-unused-imports": "^4.1.4", "oxlint": "catalog:" diff --git a/packages/@n8n/oxlint-config/src/configs/base.ts b/packages/@n8n/oxlint-config/src/configs/base.ts index 027c73efdfb..c4bb482b5d3 100644 --- a/packages/@n8n/oxlint-config/src/configs/base.ts +++ b/packages/@n8n/oxlint-config/src/configs/base.ts @@ -19,6 +19,7 @@ export const baseConfig = defineConfig({ plugins: ['typescript', 'import', 'unicorn', 'oxc'], jsPlugins: [ '@n8n/eslint-config/plugin', + '@n8n/oxlint-config/import-x-alias', '@stylistic/eslint-plugin', 'eslint-plugin-unused-imports', 'eslint-plugin-lodash', @@ -173,6 +174,26 @@ export const baseConfig = defineConfig({ // ---------------------------------- 'import/no-cycle': ['error', { ignoreExternal: false, maxDepth: 3 }], 'import/no-duplicates': 'error', + 'import-x-alias/no-extraneous-dependencies': [ + 'error', + { + devDependencies: [ + '**/test/**', + '**/__tests__/**', + '**/*.test.ts', + '**/*.test.utils.ts', + '**/*.spec.ts', + '**/integration-tests/**', + '**/test-utils/**', + '**/*.config.ts', + '**/*.config.js', + '**/scripts/*.ts', + '**/scripts/*.js', + '**/*.stories.ts', + ], + optionalDependencies: false, + }, + ], // ---------------------------------- // unicorn @@ -209,6 +230,13 @@ export const baseConfig = defineConfig({ 'n8n-local-rules/no-useless-catch-throw': 'error', }, overrides: [ + { + files: ['eslint.config.mjs', 'eslint.*.config.mjs', 'oxlint.config.mts'], + jsPlugins: ['@n8n/oxlint-config/import-x-alias'], + rules: { + 'import-x-alias/no-extraneous-dependencies': 'off', + }, + }, { files: ['test/**/*.ts', '**/__tests__/*.ts', '**/*.test.ts'], // An override that names a jsPlugin rule must re-declare the plugin. diff --git a/packages/@n8n/oxlint-config/src/import-x-alias.ts b/packages/@n8n/oxlint-config/src/import-x-alias.ts new file mode 100644 index 00000000000..89d7bc6155d --- /dev/null +++ b/packages/@n8n/oxlint-config/src/import-x-alias.ts @@ -0,0 +1,19 @@ +import importXPlugin from 'eslint-plugin-import-x'; + +interface OxlintJsPlugin { + meta: { + name: string; + version?: string; + }; + rules: Record; +} + +const importXAlias: OxlintJsPlugin = { + ...importXPlugin, + meta: { + ...importXPlugin.meta, + name: 'import-x-alias', + }, +}; + +export default importXAlias; diff --git a/packages/cli/eslint.guardrails.config.mjs b/packages/cli/eslint.guardrails.config.mjs deleted file mode 100644 index 805beea2253..00000000000 --- a/packages/cli/eslint.guardrails.config.mjs +++ /dev/null @@ -1,44 +0,0 @@ -import { defineConfig, globalIgnores } from 'eslint/config'; -import { functionalGuardrailsConfig } from '@n8n/eslint-config/functional-guardrails'; -import { localRulesPlugin } from '@n8n/eslint-config/plugin'; -import cliConfig from './eslint.config.mjs'; - -const directivePluginConfigs = cliConfig.flatMap(({ plugins }) => { - const externalPlugins = Object.fromEntries( - Object.entries(plugins ?? {}).filter(([name]) => name !== 'n8n-local-rules'), - ); - - return Object.keys(externalPlugins).length === 0 ? [] : [{ plugins: externalPlugins }]; -}); - -const dependencyRuleConfigs = cliConfig.flatMap(({ files, ignores, settings, rules }) => { - const rule = rules?.['import-x/no-extraneous-dependencies']; - if (!rule) return []; - - return [ - { - ...(files ? { files } : {}), - ...(ignores ? { ignores } : {}), - ...(settings ? { settings } : {}), - rules: { 'import-x/no-extraneous-dependencies': rule }, - }, - ]; -}); - -export default defineConfig( - globalIgnores([ - 'dist/**', - 'coverage/**', - 'eslint.config.mjs', - 'eslint.guardrails.config.mjs', - 'oxlint.config.mts', - 'scripts/**/*.mjs', - 'vitest.*.ts', - ]), - { - plugins: { 'n8n-local-rules': localRulesPlugin }, - }, - functionalGuardrailsConfig, - ...directivePluginConfigs, - ...dependencyRuleConfigs, -); diff --git a/packages/cli/oxlint.config.mts b/packages/cli/oxlint.config.mts index 2159eb59688..eaba2e4b5b5 100644 --- a/packages/cli/oxlint.config.mts +++ b/packages/cli/oxlint.config.mts @@ -44,8 +44,7 @@ export default defineConfig({ rules: { 'n8n-local-rules/no-dynamic-import-template': 'error', 'n8n-local-rules/misplaced-n8n-typeorm-import': 'error', - // Ratchets: the allowlists below only shrink, so an inline disable is the one way to add a - // violation. The narrow ESLint guardrail config owns no-unsealed-workflow-entity-write. + // Ratchets: the allowlists below only shrink, so an inline disable is the one way to add a violation. 'n8n-local-rules/no-guardrail-disable': [ 'error', { diff --git a/packages/cli/package.json b/packages/cli/package.json index 915d5bfad5a..5f30d63e272 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -17,10 +17,8 @@ "dev:webhook": "concurrently -k -n \"TypeScript,Node\" -c \"yellow.bold,cyan.bold\" \"pnpm run watch\" \"nodemon webhook\"", "format": "biome format --write .", "format:check": "biome ci .", - "lint": "pnpm run lint:base && pnpm run lint:guardrails", - "lint:base": "oxlint --type-aware . --quiet", - "lint:fix": "oxlint --type-aware . --fix && pnpm run lint:guardrails", - "lint:guardrails": "cross-env NODE_OPTIONS=\"--max-old-space-size=6144\" eslint --config eslint.guardrails.config.mjs . --quiet", + "lint": "oxlint --type-aware . --quiet", + "lint:fix": "oxlint --type-aware . --fix", "recording:list": "node scripts/agent-integration-recordings.mjs --list", "recording:export": "node scripts/agent-integration-recordings.mjs", "start": "node ../../scripts/os-normalize.mjs --dir bin n8n", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85e9e831406..8545e8d233f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3689,6 +3689,9 @@ importers: '@stylistic/eslint-plugin': specifier: ^5.0.0 version: 5.0.0(eslint@9.29.0(jiti@2.6.1)(supports-color@8.1.1)) + eslint-plugin-import-x: + specifier: ^4.15.2 + version: 4.15.2(@typescript-eslint/utils@8.70.0(@typescript/typescript6@6.0.2)(eslint@9.29.0(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1))(eslint-import-resolver-node@0.3.9(supports-color@8.1.1))(eslint@9.29.0(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1) eslint-plugin-lodash: specifier: ^8.0.0 version: 8.0.0(eslint@9.29.0(jiti@2.6.1)(supports-color@8.1.1)) diff --git a/scripts/lint-parity/OXLINT_MIGRATION_BLOCKERS.md b/scripts/lint-parity/OXLINT_MIGRATION_BLOCKERS.md index d5153713d81..e920cb139a9 100644 --- a/scripts/lint-parity/OXLINT_MIGRATION_BLOCKERS.md +++ b/scripts/lint-parity/OXLINT_MIGRATION_BLOCKERS.md @@ -1,6 +1,6 @@ # Oxlint migration blockers -Last reviewed: 2026-09-18 +Last reviewed: 2026-09-22 Oxlint version: 1.78.0 This file is the working backlog for the ESLint to Oxlint migration. It focuses on rules that block an Oxlint-only lint command for many packages. `oxlint-gap.json` is the machine-readable source of truth for shared-layer parity. @@ -21,7 +21,6 @@ These rules come from the shared base or backend layers. Resolve these first bec | Priority | Rule | Blocker | Suggested next action | | --- | --- | --- | --- | -| P0 | `import-x/no-extraneous-dependencies` | Oxlint reserves the `import-x` plugin name. The JS plugin bridge has not been validated with an alias. | Test `eslint-plugin-import-x` under an alias. If that is unstable, implement a native package-manifest check. | | P1 | `no-restricted-syntax` | Oxlint has no AST-selector rule. The base layer uses it for the raw-enum restriction. | Add a purpose-built syntax rule for each active selector. Start with the raw-enum rule. | | P1 | `typescript/consistent-type-imports` | Oxlint does not honor the `emitDecoratorMetadata` exception. Its fixer can remove imports that DI needs at runtime. | Wait for upstream support or add a tested shim that skips decorated files. Do not enable the current fixer. | | P1 | `typescript/prefer-optional-chain` | The native nursery rule changes guarded undeclared globals incorrectly. | Re-test after the rule leaves nursery or after the upstream fixer issue is resolved. | @@ -150,13 +149,12 @@ The main exceptions are: ## Work order -1. Resolve or isolate `import-x/no-extraneous-dependencies`. -2. Replace active `no-restricted-syntax` selectors with focused rules. -3. Resolve decorator-safe `consistent-type-imports` behavior. -4. Audit and reduce `naming-convention` package overrides. -5. Re-test the two nursery rules. -6. Run a JS-plugin bridge spike for `n8n-nodes-base`. -7. Keep a template-only ESLint pass for Vue until native template support exists. +1. Replace active `no-restricted-syntax` selectors with focused rules. +2. Resolve decorator-safe `consistent-type-imports` behavior. +3. Audit and reduce `naming-convention` package overrides. +4. Re-test the two nursery rules. +5. Run a JS-plugin bridge spike for `n8n-nodes-base`. +6. Keep a template-only ESLint pass for Vue until native template support exists. ## Validation commands diff --git a/scripts/lint-parity/oxlint-gap.json b/scripts/lint-parity/oxlint-gap.json index 8f4faeafbc1..75656598d12 100644 --- a/scripts/lint-parity/oxlint-gap.json +++ b/scripts/lint-parity/oxlint-gap.json @@ -21,13 +21,6 @@ "impact": "Near zero.", "replacement": "No action unless non-module source becomes supported." }, - "import-x/no-extraneous-dependencies": { - "layer": "base", - "native": null, - "reason": "`import-x` is a reserved plugin name in oxlint, so `eslint-plugin-import-x` cannot load as a jsPlugin under its own name (`Plugin name 'import-x' is reserved`). An alias would permanently change the rule ids, and these rules are the slowest in ESLint and unvalidated through the alpha bridge.", - "impact": "A package can import a dependency it does not declare. Left out of the spike deliberately.", - "replacement": "Validate eslint-plugin-import-x with an alias, or replace the dependency check with a native package-manifest rule." - }, "typescript/prefer-optional-chain": { "layer": "base", "native": "typescript/prefer-optional-chain", diff --git a/scripts/lint-parity/oxlint-parity.mjs b/scripts/lint-parity/oxlint-parity.mjs index 03f948c1820..380b2a94f02 100644 --- a/scripts/lint-parity/oxlint-parity.mjs +++ b/scripts/lint-parity/oxlint-parity.mjs @@ -61,6 +61,7 @@ const SCOPE_BY_NAMESPACE = { /** Namespaces oxlint can only reach through a jsPlugin, so ids stay as ESLint wrote them. */ const JS_PLUGIN_NAMESPACES = new Set([ '@stylistic', + 'import-x-alias', 'lodash', 'unused-imports', 'n8n-local-rules', @@ -77,13 +78,17 @@ function oxlintRuleIds() { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, }); - const start = out.stdout.indexOf('['); + const arrayLine = out.stdout.lastIndexOf('\n['); + const start = arrayLine === -1 ? out.stdout.indexOf('[') : arrayLine + 1; if (start === -1) throw new Error(`oxlint --rules failed: ${(out.stderr || out.stdout).trim()}`); return new Set(JSON.parse(out.stdout.slice(start)).map((r) => `${r.scope}/${r.value}`)); } /** ESLint id -> the oxlint id we would write in a config, or null if oxlint has no such rule. */ function toOxlintId(eslintId, native) { + if (eslintId === 'import-x/no-extraneous-dependencies') { + return 'import-x-alias/no-extraneous-dependencies'; + } const [ns, name] = splitId(eslintId); if (JS_PLUGIN_NAMESPACES.has(ns)) return eslintId; if (ns === '') return native.has(`eslint/${name}`) ? name : null; From 6433934db70b10073026235263c35687c5d3f239 Mon Sep 17 00:00:00 2001 From: Sam Wooler Date: Tue, 22 Sep 2026 12:49:05 +0000 Subject: [PATCH 02/29] feat(API): Migrate GET /users endpoints to the decorator pattern (#38599) Co-authored-by: Claude Opus 5 --- packages/@n8n/api-types/package.json | 2 + packages/@n8n/api-types/src/dto/index.ts | 3 + .../user/__tests__/get-user-query.dto.test.ts | 21 ++ .../__tests__/list-users-query.dto.test.ts | 35 +++ .../user/__tests__/user-public.dto.test.ts | 39 +++ .../src/dto/user/get-user-query.dto.ts | 6 + .../src/dto/user/list-users-query.dto.ts | 12 + .../api-types/src/dto/user/user-public.dto.ts | 36 +++ packages/@n8n/api-types/src/index.ts | 1 + .../schemas/public-api-path-params.schema.ts | 7 + packages/cli/package.json | 4 +- packages/cli/src/public-api/types.ts | 9 - .../__tests__/users.public.controller.test.ts | 128 ++++++++++ .../src/public-api/v1/controllers/index.ts | 1 + .../v1/controllers/users.public.controller.ts | 123 +++++++++ .../users/spec/paths/getUser.generated.yml | 90 +++++++ .../users/spec/paths/getUsers.generated.yml | 104 ++++++++ .../v1/handlers/users/spec/paths/users.id.yml | 20 -- .../v1/handlers/users/spec/paths/users.yml | 30 --- .../spec/schemas/parameters/includeRole.yml | 8 - .../v1/handlers/users/spec/schemas/user.yml | 47 ---- .../handlers/users/spec/schemas/userList.yml | 11 - .../v1/handlers/users/users.handler.ee.ts | 65 ----- .../v1/handlers/users/users.mapper.ts | 32 --- .../v1/openapi.decorator-routes.generated.yml | 6 + .../v1/shared/spec/parameters/_index.yml | 2 - .../v1/shared/spec/schemas/_index.yml | 4 - .../integration/public-api/users.ee.test.ts | 38 +-- .../test/integration/public-api/users.test.ts | 238 +++++------------- .../nodes/N8n/n8n-api-coverage.json | 2 +- pnpm-lock.yaml | 11 +- pnpm-workspace.yaml | 4 +- 32 files changed, 714 insertions(+), 425 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/user/__tests__/get-user-query.dto.test.ts create mode 100644 packages/@n8n/api-types/src/dto/user/__tests__/list-users-query.dto.test.ts create mode 100644 packages/@n8n/api-types/src/dto/user/__tests__/user-public.dto.test.ts create mode 100644 packages/@n8n/api-types/src/dto/user/get-user-query.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/user/list-users-query.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/user/user-public.dto.ts create mode 100644 packages/cli/src/public-api/v1/controllers/__tests__/users.public.controller.test.ts create mode 100644 packages/cli/src/public-api/v1/controllers/users.public.controller.ts create mode 100644 packages/cli/src/public-api/v1/handlers/users/spec/paths/getUser.generated.yml create mode 100644 packages/cli/src/public-api/v1/handlers/users/spec/paths/getUsers.generated.yml delete mode 100644 packages/cli/src/public-api/v1/handlers/users/spec/schemas/parameters/includeRole.yml delete mode 100644 packages/cli/src/public-api/v1/handlers/users/spec/schemas/user.yml delete mode 100644 packages/cli/src/public-api/v1/handlers/users/spec/schemas/userList.yml delete mode 100644 packages/cli/src/public-api/v1/handlers/users/users.mapper.ts diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index a7850a52b5f..9a89f5667e1 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -25,6 +25,7 @@ "devDependencies": { "@n8n/typescript-config": "workspace:*", "@n8n/vitest-config": "workspace:*", + "@types/validator": "catalog:", "@vitest/coverage-v8": "catalog:", "typescript": "catalog:typescript", "vite": "catalog:", @@ -36,6 +37,7 @@ "@asteasolutions/zod-to-openapi": "catalog:", "@n8n_io/ai-assistant-sdk": "catalog:", "n8n-workflow": "workspace:*", + "validator": "catalog:", "xss": "catalog:", "@n8n/permissions": "workspace:*" }, diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index e56970dcdbb..c5c6b642baa 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -111,6 +111,9 @@ export { UserUpdateRequestDto } from './user/user-update-request.dto'; export { ChangeEmailRequestDto } from './user/change-email-request.dto'; export { ResolveChangeEmailTokenQueryDto } from './user/resolve-change-email-token-query.dto'; export { ConfirmEmailChangeRequestDto } from './user/confirm-email-change-request.dto'; +export { ListUsersQueryDto } from './user/list-users-query.dto'; +export { GetUserQueryDto } from './user/get-user-query.dto'; +export { UserPublicDto, UserListPublicDto, userPublicSchema } from './user/user-public.dto'; export { CommunityRegisteredRequestDto } from './license/community-registered-request.dto'; diff --git a/packages/@n8n/api-types/src/dto/user/__tests__/get-user-query.dto.test.ts b/packages/@n8n/api-types/src/dto/user/__tests__/get-user-query.dto.test.ts new file mode 100644 index 00000000000..dbe6aaecc76 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/user/__tests__/get-user-query.dto.test.ts @@ -0,0 +1,21 @@ +import { GetUserQueryDto } from '../get-user-query.dto'; + +describe('GetUserQueryDto', () => { + test('defaults includeRole to false', () => { + const result = GetUserQueryDto.safeParse({}); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.includeRole).toBe(false); + } + }); + + test('parses includeRole from the query string', () => { + const result = GetUserQueryDto.safeParse({ includeRole: 'true' }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.includeRole).toBe(true); + } + }); +}); diff --git a/packages/@n8n/api-types/src/dto/user/__tests__/list-users-query.dto.test.ts b/packages/@n8n/api-types/src/dto/user/__tests__/list-users-query.dto.test.ts new file mode 100644 index 00000000000..a441e5cd5df --- /dev/null +++ b/packages/@n8n/api-types/src/dto/user/__tests__/list-users-query.dto.test.ts @@ -0,0 +1,35 @@ +import { ListUsersQueryDto } from '../list-users-query.dto'; + +describe('ListUsersQueryDto', () => { + test('accepts an empty query and applies defaults', () => { + const result = ListUsersQueryDto.safeParse({}); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(100); + expect(result.data.includeRole).toBe(false); + expect(result.data.cursor).toBeUndefined(); + expect(result.data.projectId).toBeUndefined(); + } + }); + + test('parses includeRole and projectId from the query string', () => { + const result = ListUsersQueryDto.safeParse({ includeRole: 'true', projectId: 'project-1' }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.includeRole).toBe(true); + expect(result.data.projectId).toBe('project-1'); + } + }); + + test('accepts limit and cursor and drops offset', () => { + const result = ListUsersQueryDto.safeParse({ limit: '5', cursor: 'abc', offset: '3' }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toMatchObject({ limit: 5, cursor: 'abc' }); + expect(result.data).not.toHaveProperty('offset'); + } + }); +}); diff --git a/packages/@n8n/api-types/src/dto/user/__tests__/user-public.dto.test.ts b/packages/@n8n/api-types/src/dto/user/__tests__/user-public.dto.test.ts new file mode 100644 index 00000000000..bac2785f8b5 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/user/__tests__/user-public.dto.test.ts @@ -0,0 +1,39 @@ +import { UserPublicDto } from '../user-public.dto'; + +describe('UserPublicDto', () => { + const baseUser = { + id: 'user-id', + email: 'member@example.com', + firstName: null, + lastName: null, + isPending: true, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + mfaEnabled: false, + }; + + // A shell user (invited, not yet accepted) has no name yet. + test('allows a null firstName and lastName', () => { + const result = UserPublicDto.safeParse(baseUser); + + expect(result.success).toBe(true); + }); + + test('role is absent unless the caller asks for it', () => { + const result = UserPublicDto.safeParse(baseUser); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).not.toHaveProperty('role'); + } + }); + + test('accepts a role when included', () => { + const result = UserPublicDto.safeParse({ ...baseUser, role: 'global:owner' }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.role).toBe('global:owner'); + } + }); +}); diff --git a/packages/@n8n/api-types/src/dto/user/get-user-query.dto.ts b/packages/@n8n/api-types/src/dto/user/get-user-query.dto.ts new file mode 100644 index 00000000000..7d2ce274349 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/user/get-user-query.dto.ts @@ -0,0 +1,6 @@ +import { Z } from '../../zod-class'; +import { booleanFromString } from '../../schemas/boolean-from-string'; + +export class GetUserQueryDto extends Z.class({ + includeRole: booleanFromString.optional().default('false'), +}) {} diff --git a/packages/@n8n/api-types/src/dto/user/list-users-query.dto.ts b/packages/@n8n/api-types/src/dto/user/list-users-query.dto.ts new file mode 100644 index 00000000000..8f27d348133 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/user/list-users-query.dto.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { Z } from '../../zod-class'; +import { booleanFromString } from '../../schemas/boolean-from-string'; +import { publicApiPaginationSchema } from '../pagination/pagination.dto'; + +export class ListUsersQueryDto extends Z.class({ + limit: publicApiPaginationSchema.limit, + cursor: z.string().optional(), + includeRole: booleanFromString.optional().default('false'), + projectId: z.string().optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/user/user-public.dto.ts b/packages/@n8n/api-types/src/dto/user/user-public.dto.ts new file mode 100644 index 00000000000..eb5ff17ed9a --- /dev/null +++ b/packages/@n8n/api-types/src/dto/user/user-public.dto.ts @@ -0,0 +1,36 @@ +import '../../openapi-extend'; +import { z } from 'zod'; + +import { Z } from '../../zod-class'; + +/** The user as the Public API publishes it. `role` is present only when the caller asks for it. */ +export const userPublicSchema = z.object({ + id: z.string().openapi({ readOnly: true, example: '123e4567-e89b-12d3-a456-426614174000' }), + email: z.string().email().nullable().openapi({ example: 'john.doe@company.com' }), + firstName: z.string().nullable().openapi({ readOnly: true, example: 'john' }), + lastName: z.string().nullable().openapi({ readOnly: true, example: 'Doe' }), + isPending: z.boolean().openapi({ + readOnly: true, + description: + 'Whether the user finished setting up their account in response to the invitation (true) or not (false).', + }), + createdAt: z.string().datetime().openapi({ readOnly: true }), + updatedAt: z.string().datetime().openapi({ readOnly: true }), + mfaEnabled: z.boolean().openapi({ + readOnly: true, + description: 'Whether the user has multi-factor authentication (MFA/2FA) enabled.', + example: false, + }), + role: z.string().optional().openapi({ readOnly: true, example: 'global:owner' }), +}); + +export class UserPublicDto extends Z.class(userPublicSchema.shape) {} + +export class UserListPublicDto extends Z.class({ + data: z.array(userPublicSchema), + nextCursor: z.string().nullable().openapi({ + description: + 'Paginate through users by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first page of the collection.', + example: 'MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA', + }), +}) {} diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 5931319a1c6..e630eacba3c 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -165,6 +165,7 @@ export { roleSlugParamSchema, tagIdParamSchema, userIdParamSchema, + userIdentifierParamSchema, variableIdParamSchema, workflowIdParamSchema, workflowVersionIdParamSchema, diff --git a/packages/@n8n/api-types/src/schemas/public-api-path-params.schema.ts b/packages/@n8n/api-types/src/schemas/public-api-path-params.schema.ts index 04376c00c57..f0466c72efa 100644 --- a/packages/@n8n/api-types/src/schemas/public-api-path-params.schema.ts +++ b/packages/@n8n/api-types/src/schemas/public-api-path-params.schema.ts @@ -1,4 +1,5 @@ import '../openapi-extend'; +import validator from 'validator'; import { z } from 'zod'; import { promotionDirectionSchema } from '../dto/promotions/promotion-config.dto'; @@ -23,6 +24,12 @@ export const projectIdParamSchema = stringIdParamSchema('The ID of the project.' export const tagIdParamSchema = stringIdParamSchema('The ID of the tag.'); export const folderIdParamSchema = stringIdParamSchema('The ID of the folder.'); export const userIdParamSchema = stringIdParamSchema('The ID of the user.'); +export const userIdentifierParamSchema = z + .string() + .refine((value) => validator.isUUID(value) || validator.isEmail(value), { + message: 'must be a valid ID or email', + }) + .openapi({ param: { description: 'The ID or email of the user.' } }); export const roleSlugParamSchema = stringIdParamSchema('The slug of the role.'); export const roleMappingRuleIdParamSchema = stringIdParamSchema('The ID of the role mapping rule.'); export const promotionConnectionIdParamSchema = stringIdParamSchema( diff --git a/packages/cli/package.json b/packages/cli/package.json index 5f30d63e272..13b3553eddb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -100,7 +100,7 @@ "@types/swagger-ui-express": "^4.1.8", "@types/turndown": "catalog:", "@types/uuid": "catalog:", - "@types/validator": "^13.7.0", + "@types/validator": "catalog:", "@types/ws": "catalog:", "@types/xml2js": "catalog:", "@types/yargs-parser": "21.0.0", @@ -277,7 +277,7 @@ "tar": "catalog:", "turndown": "catalog:", "uuid": "catalog:", - "validator": "13.15.22", + "validator": "catalog:", "ws": "8.20.1", "xml2js": "catalog:", "xmllint-wasm": "3.0.1", diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index 54b113f0aa6..18614fcf9f0 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -104,13 +104,6 @@ export declare namespace UserRequest { { transferId?: string; includeRole: boolean } >; - export type Get = AuthenticatedRequest< - { id: string; email: string }, - {}, - {}, - { limit?: number; offset?: number; cursor?: string; includeRole?: boolean } - >; - export type Reinvite = AuthenticatedRequest<{ id: string }>; export type Update = AuthlessRequest< @@ -125,8 +118,6 @@ export declare namespace UserRequest { >; } -export type OperationID = 'getUsers' | 'getUser'; - type PaginationBase = { limit: number }; export type PaginationOffsetDecoded = PaginationBase & { offset: number }; diff --git a/packages/cli/src/public-api/v1/controllers/__tests__/users.public.controller.test.ts b/packages/cli/src/public-api/v1/controllers/__tests__/users.public.controller.test.ts new file mode 100644 index 00000000000..1055103c237 --- /dev/null +++ b/packages/cli/src/public-api/v1/controllers/__tests__/users.public.controller.test.ts @@ -0,0 +1,128 @@ +import type { GetUserQueryDto, ListUsersQueryDto } from '@n8n/api-types'; +import type { AuthenticatedRequest, User } from '@n8n/db'; +import type { Response } from 'express'; +import { mock } from 'vitest-mock-extended'; + +import type { EventService } from '@/events/event.service'; +import type { ProjectService } from '@/services/project.service.ee'; +import type { UserService } from '@/services/user.service'; + +import { UsersPublicController } from '../users.public.controller'; + +describe('UsersPublicController', () => { + let controller: UsersPublicController; + const userService = mock(); + const projectService = mock(); + const eventService = mock(); + + const caller = mock({ user: mock({ id: 'caller-id' }) }); + + const buildUser = (overrides: Partial = {}): User => + mock({ + id: 'user-id', + email: 'member@example.com', + firstName: 'Nathan', + lastName: 'Doe', + isPending: false, + mfaEnabled: false, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-02T00:00:00.000Z'), + role: { slug: 'global:member' }, + password: 'secret-hash', + mfaSecret: 'secret-mfa-key', + disabled: false, + ...overrides, + }); + + beforeEach(() => { + vi.clearAllMocks(); + controller = new UsersPublicController(userService, projectService, eventService); + }); + + describe('getUsers', () => { + it('never leaks sensitive user fields', async () => { + const users = [buildUser()]; + userService.getUsersAndCount.mockResolvedValue({ users, count: 1 }); + + const result = await controller.getUsers( + caller, + mock(), + mock({ + limit: 100, + cursor: undefined, + includeRole: false, + projectId: undefined, + }), + ); + + expect(result.data[0]).toMatchObject({ + id: 'user-id', + email: 'member@example.com', + mfaEnabled: false, + }); + expect(result.data[0]).not.toHaveProperty('role'); + expect(result.data[0]).not.toHaveProperty('password'); + expect(result.data[0]).not.toHaveProperty('mfaSecret'); + expect(result.data[0]).not.toHaveProperty('disabled'); + }); + + it('emits user-retrieved-all-users telemetry', async () => { + userService.getUsersAndCount.mockResolvedValue({ users: [buildUser()], count: 1 }); + + await controller.getUsers( + caller, + mock(), + mock({ + limit: 100, + cursor: undefined, + includeRole: false, + projectId: undefined, + }), + ); + + expect(eventService.emit).toHaveBeenCalledWith('user-retrieved-all-users', { + userId: caller.user.id, + publicApi: true, + }); + }); + }); + + describe('getUser', () => { + it('never leaks sensitive user fields', async () => { + userService.getUser.mockResolvedValue(buildUser()); + + const result = await controller.getUser( + caller, + mock(), + 'user-id', + mock({ includeRole: false }), + ); + + expect(result).toMatchObject({ + id: 'user-id', + email: 'member@example.com', + mfaEnabled: false, + }); + expect(result).not.toHaveProperty('role'); + expect(result).not.toHaveProperty('password'); + expect(result).not.toHaveProperty('mfaSecret'); + expect(result).not.toHaveProperty('disabled'); + }); + + it('emits user-retrieved-user telemetry', async () => { + userService.getUser.mockResolvedValue(buildUser()); + + await controller.getUser( + caller, + mock(), + 'user-id', + mock({ includeRole: false }), + ); + + expect(eventService.emit).toHaveBeenCalledWith('user-retrieved-user', { + userId: caller.user.id, + publicApi: true, + }); + }); + }); +}); diff --git a/packages/cli/src/public-api/v1/controllers/index.ts b/packages/cli/src/public-api/v1/controllers/index.ts index 33c4cb8fd09..978c30b160d 100644 --- a/packages/cli/src/public-api/v1/controllers/index.ts +++ b/packages/cli/src/public-api/v1/controllers/index.ts @@ -14,5 +14,6 @@ import './role-mapping-rules.public.controller'; import './roles.public.controller'; import './source-control.public.controller'; import './tags.public.controller'; +import './users.public.controller'; import './variables.public.controller'; import './workflows.public.controller'; diff --git a/packages/cli/src/public-api/v1/controllers/users.public.controller.ts b/packages/cli/src/public-api/v1/controllers/users.public.controller.ts new file mode 100644 index 00000000000..4d16d96a0f2 --- /dev/null +++ b/packages/cli/src/public-api/v1/controllers/users.public.controller.ts @@ -0,0 +1,123 @@ +import { + GetUserQueryDto, + ListUsersQueryDto, + UserListPublicDto, + UserPublicDto, + userIdentifierParamSchema, +} from '@n8n/api-types'; +import type { AuthenticatedRequest, User } from '@n8n/db'; +import { + ApiDescription, + ApiErrorResponse, + ApiKeyScope, + ApiResponse, + ApiSummary, + ApiTags, + Get, + Param, + PublicApiController, + Query, + RequiresUserQuota, +} from '@n8n/decorators'; +import type { Response } from 'express'; +import pick from 'lodash/pick'; + +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { EventService } from '@/events/event.service'; +import { + encodeNextCursor, + resolveOffsetPagination, +} from '@/public-api/v1/shared/services/pagination.service'; +import { ProjectService } from '@/services/project.service.ee'; +import { UserService } from '@/services/user.service'; + +const tags = ['User']; + +const userProperties = ['id', 'email', 'firstName', 'lastName', 'isPending', 'mfaEnabled'] as const; + +function toPublicApiUser(user: User, options?: { includeRole: boolean }): UserPublicDto { + const publicApiUser = { + ...pick(user, ...userProperties), + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + }; + + return options?.includeRole ? { ...publicApiUser, role: user.role?.slug } : publicApiUser; +} + +@PublicApiController('/users') +export class UsersPublicController { + constructor( + private readonly userService: UserService, + private readonly projectService: ProjectService, + private readonly eventService: EventService, + ) {} + + @Get('/') + @ApiKeyScope('user:list') + @RequiresUserQuota() + @ApiSummary('Retrieve all users') + @ApiDescription('Retrieve all users from your instance. Only available for the instance owner.') + @ApiTags(tags) + @ApiResponse(200, UserListPublicDto) + @ApiErrorResponse(404, { description: 'The given `projectId` does not exist.' }) + async getUsers( + req: AuthenticatedRequest, + _res: Response, + @Query query: ListUsersQueryDto, + ): Promise { + const { includeRole, projectId } = query; + const { offset, limit } = resolveOffsetPagination(query); + + await this.userService.assertGetUsersAccess(req.user, projectId); + + const userIdsInProject = projectId + ? await this.projectService.findUserIdsByProjectId(projectId) + : undefined; + + const { users, count } = await this.userService.getUsersAndCount({ + ids: userIdsInProject, + limit, + offset, + }); + + this.eventService.emit('user-retrieved-all-users', { + userId: req.user.id, + publicApi: true, + }); + + return { + data: users.map((user) => toPublicApiUser(user, { includeRole })), + nextCursor: encodeNextCursor({ offset, limit, numberOfTotalRecords: count }), + }; + } + + @Get('/:userId') + @ApiKeyScope('user:read') + @RequiresUserQuota() + @ApiSummary('Get user by ID/Email') + @ApiDescription('Retrieve a user from your instance. Only available for the instance owner.') + @ApiTags(tags) + @ApiResponse(200, UserPublicDto) + @ApiErrorResponse(404, { description: 'No user with that ID or email.' }) + async getUser( + req: AuthenticatedRequest, + _res: Response, + @Param('userId', userIdentifierParamSchema) userId: string, + @Query query: GetUserQueryDto, + ): Promise { + const { includeRole } = query; + + const user = await this.userService.getUser(userId); + if (!user) { + throw new NotFoundError(`Could not find user with id: ${userId}`); + } + + this.eventService.emit('user-retrieved-user', { + userId: req.user.id, + publicApi: true, + }); + + return toPublicApiUser(user, { includeRole }); + } +} diff --git a/packages/cli/src/public-api/v1/handlers/users/spec/paths/getUser.generated.yml b/packages/cli/src/public-api/v1/handlers/users/spec/paths/getUser.generated.yml new file mode 100644 index 00000000000..5ba9b6ebe32 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/users/spec/paths/getUser.generated.yml @@ -0,0 +1,90 @@ +operationId: getUser +tags: + - User +summary: Get user by ID/Email +description: Retrieve a user from your instance. Only available for the instance owner. +x-required-scope: user:read +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + required: true + description: The ID or email of the user. + name: userId + in: path + - schema: + type: string + enum: + - 'true' + - 'false' + default: 'false' + required: false + name: includeRole + in: query +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + id: + type: string + readOnly: true + example: 123e4567-e89b-12d3-a456-426614174000 + email: + type: string + nullable: true + format: email + example: john.doe@company.com + firstName: + type: string + nullable: true + readOnly: true + example: john + lastName: + type: string + nullable: true + readOnly: true + example: Doe + isPending: + type: boolean + description: Whether the user finished setting up their account in response to the invitation (true) or not (false). + readOnly: true + createdAt: + type: string + format: date-time + readOnly: true + updatedAt: + type: string + format: date-time + readOnly: true + mfaEnabled: + type: boolean + description: Whether the user has multi-factor authentication (MFA/2FA) enabled. + readOnly: true + example: false + role: + type: string + readOnly: true + example: global:owner + required: + - id + - email + - firstName + - lastName + - isPending + - createdAt + - updatedAt + - mfaEnabled + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + description: No user with that ID or email. diff --git a/packages/cli/src/public-api/v1/handlers/users/spec/paths/getUsers.generated.yml b/packages/cli/src/public-api/v1/handlers/users/spec/paths/getUsers.generated.yml new file mode 100644 index 00000000000..26e6277e1d6 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/users/spec/paths/getUsers.generated.yml @@ -0,0 +1,104 @@ +operationId: getUsers +tags: + - User +summary: Retrieve all users +description: Retrieve all users from your instance. Only available for the instance owner. +x-required-scope: user:list +parameters: + - $ref: ../../../../shared/spec/parameters/limit.yml + - $ref: ../../../../shared/spec/parameters/cursor.yml + - schema: + type: string + enum: + - 'true' + - 'false' + default: 'false' + required: false + name: includeRole + in: query + - schema: + type: string + required: false + name: projectId + in: query +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + id: + type: string + readOnly: true + example: 123e4567-e89b-12d3-a456-426614174000 + email: + type: string + nullable: true + format: email + example: john.doe@company.com + firstName: + type: string + nullable: true + readOnly: true + example: john + lastName: + type: string + nullable: true + readOnly: true + example: Doe + isPending: + type: boolean + description: Whether the user finished setting up their account in response to the invitation (true) or not (false). + readOnly: true + createdAt: + type: string + format: date-time + readOnly: true + updatedAt: + type: string + format: date-time + readOnly: true + mfaEnabled: + type: boolean + description: Whether the user has multi-factor authentication (MFA/2FA) enabled. + readOnly: true + example: false + role: + type: string + readOnly: true + example: global:owner + required: + - id + - email + - firstName + - lastName + - isPending + - createdAt + - updatedAt + - mfaEnabled + nextCursor: + type: string + nullable: true + description: Paginate through users by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first page of the collection. + example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA + required: + - data + - nextCursor + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + description: The given `projectId` does not exist. diff --git a/packages/cli/src/public-api/v1/handlers/users/spec/paths/users.id.yml b/packages/cli/src/public-api/v1/handlers/users/spec/paths/users.id.yml index 69f8d45b1e4..a2e7e989452 100644 --- a/packages/cli/src/public-api/v1/handlers/users/spec/paths/users.id.yml +++ b/packages/cli/src/public-api/v1/handlers/users/spec/paths/users.id.yml @@ -1,23 +1,3 @@ -get: - x-eov-operation-id: getUser - x-required-scope: user:read - x-eov-operation-handler: v1/handlers/users/users.handler.ee - tags: - - User - summary: Get user by ID/Email - description: Retrieve a user from your instance. Only available for the instance owner. - parameters: - - $ref: '../schemas/parameters/userIdentifier.yml' - - $ref: '../schemas/parameters/includeRole.yml' - responses: - '200': - description: Operation successful. - content: - application/json: - schema: - $ref: '../schemas/user.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' delete: x-eov-operation-id: deleteUser x-required-scope: user:delete diff --git a/packages/cli/src/public-api/v1/handlers/users/spec/paths/users.yml b/packages/cli/src/public-api/v1/handlers/users/spec/paths/users.yml index 09ef93f416b..5d2543b78fc 100644 --- a/packages/cli/src/public-api/v1/handlers/users/spec/paths/users.yml +++ b/packages/cli/src/public-api/v1/handlers/users/spec/paths/users.yml @@ -1,33 +1,3 @@ -get: - x-eov-operation-id: getUsers - x-required-scope: user:list - x-eov-operation-handler: v1/handlers/users/users.handler.ee - tags: - - User - summary: Retrieve all users - description: Retrieve all users from your instance. Only available for the instance owner. - parameters: - - $ref: '../../../../shared/spec/parameters/limit.yml' - - $ref: '../../../../shared/spec/parameters/offset.yml' - - $ref: '../../../../shared/spec/parameters/cursor.yml' - - $ref: '../schemas/parameters/includeRole.yml' - - name: projectId - in: query - required: false - explode: false - allowReserved: true - schema: - type: string - example: VmwOO9HeTEj20kxM - responses: - '200': - description: Operation successful. - content: - application/json: - schema: - $ref: '../schemas/userList.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' post: x-eov-operation-id: createUser x-required-scope: user:create diff --git a/packages/cli/src/public-api/v1/handlers/users/spec/schemas/parameters/includeRole.yml b/packages/cli/src/public-api/v1/handlers/users/spec/schemas/parameters/includeRole.yml deleted file mode 100644 index 5c560b8f671..00000000000 --- a/packages/cli/src/public-api/v1/handlers/users/spec/schemas/parameters/includeRole.yml +++ /dev/null @@ -1,8 +0,0 @@ -name: includeRole -in: query -description: Whether to include the user's role or not. -required: false -schema: - type: boolean - example: true - default: false diff --git a/packages/cli/src/public-api/v1/handlers/users/spec/schemas/user.yml b/packages/cli/src/public-api/v1/handlers/users/spec/schemas/user.yml deleted file mode 100644 index bc67f236d33..00000000000 --- a/packages/cli/src/public-api/v1/handlers/users/spec/schemas/user.yml +++ /dev/null @@ -1,47 +0,0 @@ -required: - - email -type: object -properties: - id: - type: string - readOnly: true - example: 123e4567-e89b-12d3-a456-426614174000 - email: - type: string - format: email - example: john.doe@company.com - firstName: - maxLength: 32 - type: string - description: User's first name - readOnly: true - example: john - lastName: - maxLength: 32 - type: string - description: User's last name - readOnly: true - example: Doe - isPending: - type: boolean - description: Whether the user finished setting up their account in response to the invitation (true) or not (false). - readOnly: true - createdAt: - type: string - description: Time the user was created. - format: date-time - readOnly: true - updatedAt: - type: string - description: Last time the user was updated. - format: date-time - readOnly: true - role: - type: string - example: global:owner - readOnly: true - mfaEnabled: - type: boolean - description: Whether the user has multi-factor authentication (MFA/2FA) enabled. - readOnly: true - example: false diff --git a/packages/cli/src/public-api/v1/handlers/users/spec/schemas/userList.yml b/packages/cli/src/public-api/v1/handlers/users/spec/schemas/userList.yml deleted file mode 100644 index 212b26d64ac..00000000000 --- a/packages/cli/src/public-api/v1/handlers/users/spec/schemas/userList.yml +++ /dev/null @@ -1,11 +0,0 @@ -type: object -properties: - data: - type: array - items: - $ref: './user.yml' - nextCursor: - type: string - description: Paginate through users by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection. - nullable: true - example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA diff --git a/packages/cli/src/public-api/v1/handlers/users/users.handler.ee.ts b/packages/cli/src/public-api/v1/handlers/users/users.handler.ee.ts index 8ab3cf18a1a..4df4d6759f2 100644 --- a/packages/cli/src/public-api/v1/handlers/users/users.handler.ee.ts +++ b/packages/cli/src/public-api/v1/handlers/users/users.handler.ee.ts @@ -2,21 +2,14 @@ import { InviteUsersRequestDto, RoleChangeRequestDto } from '@n8n/api-types'; import { type AuthenticatedRequest } from '@n8n/db'; import { Container } from '@n8n/di'; -import { toPublicApiUser } from './users.mapper'; import type { PublicAPIEndpoint } from '../../shared/handler.types'; import { apiKeyHasScopeWithGlobalScopeFallback, isLicensed, - validCursor, - validLicenseWithUserQuota, } from '../../shared/middlewares/global.middleware'; -import { encodeNextCursor } from '../../shared/services/pagination.service'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { EventService } from '@/events/event.service'; import type { UserRequest } from '@/requests'; -import { ProjectService } from '@/services/project.service.ee'; import { UserService } from '@/services/user.service'; type Create = AuthenticatedRequest<{}, {}, InviteUsersRequestDto>; @@ -24,70 +17,12 @@ type Delete = UserRequest.Delete; type ChangeRole = AuthenticatedRequest<{ id: string }, {}, RoleChangeRequestDto, {}>; type UsersHandlers = { - getUser: PublicAPIEndpoint; - getUsers: PublicAPIEndpoint; createUser: PublicAPIEndpoint; deleteUser: PublicAPIEndpoint; changeRole: PublicAPIEndpoint; }; const usersHandlers: UsersHandlers = { - getUser: [ - validLicenseWithUserQuota, - apiKeyHasScopeWithGlobalScopeFallback({ scope: 'user:read' }), - async (req, res) => { - const { includeRole = false } = req.query; - const { id } = req.params; - - const user = await Container.get(UserService).getUser(id); - - if (!user) { - throw new NotFoundError(`Could not find user with id: ${id}`); - } - - Container.get(EventService).emit('user-retrieved-user', { - userId: req.user.id, - publicApi: true, - }); - - return res.json(toPublicApiUser(user, { includeRole })); - }, - ], - getUsers: [ - apiKeyHasScopeWithGlobalScopeFallback({ scope: 'user:list' }), - validLicenseWithUserQuota, - validCursor, - async (req, res) => { - const { offset = 0, limit = 100, includeRole = false, projectId } = req.query; - const userService = Container.get(UserService); - - await userService.assertGetUsersAccess(req.user, projectId); - - const userIdsInProject = projectId - ? await Container.get(ProjectService).findUserIdsByProjectId(projectId) - : undefined; - - const { users, count } = await userService.getUsersAndCount({ - ids: userIdsInProject, - limit, - offset, - }); - - Container.get(EventService).emit('user-retrieved-all-users', { - userId: req.user.id, - publicApi: true, - }); - - return res.json({ - data: toPublicApiUser(users, { includeRole }), - nextCursor: encodeNextCursor({ - offset, - limit, - numberOfTotalRecords: count, - }), - }); - }, - ], createUser: [ apiKeyHasScopeWithGlobalScopeFallback({ scope: 'user:create' }), async (req, res) => { diff --git a/packages/cli/src/public-api/v1/handlers/users/users.mapper.ts b/packages/cli/src/public-api/v1/handlers/users/users.mapper.ts deleted file mode 100644 index 4092a47ddce..00000000000 --- a/packages/cli/src/public-api/v1/handlers/users/users.mapper.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { User } from '@n8n/db'; -import pick from 'lodash/pick'; - -const userProperties = [ - 'id', - 'email', - 'firstName', - 'lastName', - 'createdAt', - 'updatedAt', - 'isPending', - 'mfaEnabled', -] as const; - -type PublicApiUser = Pick & { role?: string }; - -function mapToPublicApiUser(user: User, options?: { includeRole: boolean }): PublicApiUser { - const publicApiUser = pick(user, ...userProperties); - - return options?.includeRole ? { ...publicApiUser, role: user.role?.slug } : publicApiUser; -} - -export function toPublicApiUser(user: User, options?: { includeRole: boolean }): PublicApiUser; -export function toPublicApiUser(users: User[], options?: { includeRole: boolean }): PublicApiUser[]; -export function toPublicApiUser( - users: User[] | User, - options?: { includeRole: boolean }, -): PublicApiUser[] | PublicApiUser { - return Array.isArray(users) - ? users.map((user) => mapToPublicApiUser(user, options)) - : mapToPublicApiUser(users, options); -} diff --git a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml index d4474bc5964..6ccfc0af39a 100644 --- a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml +++ b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml @@ -207,6 +207,12 @@ paths: $ref: ./handlers/tags/spec/paths/updateTag.generated.yml delete: $ref: ./handlers/tags/spec/paths/deleteTag.generated.yml + /users: + get: + $ref: ./handlers/users/spec/paths/getUsers.generated.yml + /users/{userId}: + get: + $ref: ./handlers/users/spec/paths/getUser.generated.yml /variables: get: $ref: ./handlers/variables/spec/paths/getVariables.generated.yml diff --git a/packages/cli/src/public-api/v1/shared/spec/parameters/_index.yml b/packages/cli/src/public-api/v1/shared/spec/parameters/_index.yml index b84832bb163..2acf6e2928a 100644 --- a/packages/cli/src/public-api/v1/shared/spec/parameters/_index.yml +++ b/packages/cli/src/public-api/v1/shared/spec/parameters/_index.yml @@ -12,8 +12,6 @@ TestRunId: $ref: '../../../handlers/evaluations/spec/schemas/parameters/testRunId.yml' UserIdentifier: $ref: '../../../handlers/users/spec/schemas/parameters/userIdentifier.yml' -IncludeRole: - $ref: '../../../handlers/users/spec/schemas/parameters/includeRole.yml' dataTableId: $ref: '../../../handlers/data-tables/spec/schemas/parameters/dataTableId.yml' columnId: diff --git a/packages/cli/src/public-api/v1/shared/spec/schemas/_index.yml b/packages/cli/src/public-api/v1/shared/spec/schemas/_index.yml index 1dded081d8a..d52ee74b492 100644 --- a/packages/cli/src/public-api/v1/shared/spec/schemas/_index.yml +++ b/packages/cli/src/public-api/v1/shared/spec/schemas/_index.yml @@ -16,7 +16,3 @@ WorkflowList: $ref: './../../../handlers/workflows/spec/schemas/workflowList.yml' Audit: $ref: './../../../handlers/audit/spec/schemas/audit.yml' -UserList: - $ref: './../../../handlers/users/spec/schemas/userList.yml' -User: - $ref: './../../../handlers/users/spec/schemas/user.yml' diff --git a/packages/cli/test/integration/public-api/users.ee.test.ts b/packages/cli/test/integration/public-api/users.ee.test.ts index e98f4cbab33..15f50847621 100644 --- a/packages/cli/test/integration/public-api/users.ee.test.ts +++ b/packages/cli/test/integration/public-api/users.ee.test.ts @@ -1,14 +1,9 @@ -import { - createTeamProject, - linkUserToProject, - testDb, - mockInstance, -} from '@n8n/backend-test-utils'; +import { createTeamProject, linkUserToProject, testDb } from '@n8n/backend-test-utils'; import { GLOBAL_MEMBER_ROLE, type User } from '@n8n/db'; import { v4 as uuid } from 'uuid'; import validator from 'validator'; -import { License } from '@/license'; +import { USER_QUOTA_FORBIDDEN_MESSAGE } from '@/public-api/constants'; import { createMember, @@ -20,10 +15,6 @@ import { import type { SuperAgentTest } from '../shared/types'; import * as utils from '../shared/utils/'; -mockInstance(License, { - getUsersLimit: vi.fn().mockReturnValue(-1), -}); - const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); beforeEach(async () => { @@ -210,7 +201,11 @@ describe('With license unlimited quota:users', () => { test('should return 404 for non-existing id ', async () => { const owner = await createOwnerWithApiKey(); const authOwnerAgent = testServer.publicApiAgentFor(owner); - await authOwnerAgent.get(`/users/${uuid()}`).expect(404); + const missingId = uuid(); + + const response = await authOwnerAgent.get(`/users/${missingId}`).expect(404); + + expect(response.body).toStrictEqual({ message: `Could not find user with id: ${missingId}` }); }); test('should return a pending user', async () => { @@ -291,17 +286,30 @@ describe('With license without quota:users', () => { let authOwnerAgent: SuperAgentTest; beforeEach(async () => { - mockInstance(License, { getUsersLimit: vi.fn().mockReturnValue(null) }); + testServer.license.setQuota('quota:users', 0); const owner = await createOwnerWithApiKey(); authOwnerAgent = testServer.publicApiAgentFor(owner); }); test('GET /users should fail due to invalid license', async () => { - await authOwnerAgent.get('/users').expect(403); + const response = await authOwnerAgent.get('/users').expect(403); + + expect(response.body).toHaveProperty('message', USER_QUOTA_FORBIDDEN_MESSAGE); }); test('GET /users/:id should fail due to invalid license', async () => { - await authOwnerAgent.get(`/users/${uuid()}`).expect(403); + const response = await authOwnerAgent.get(`/users/${uuid()}`).expect(403); + + expect(response.body).toHaveProperty('message', USER_QUOTA_FORBIDDEN_MESSAGE); + }); + + test('GET /users/:id answers the generic Forbidden message when scope and license both fail', async () => { + const member = await createMemberWithApiKey(); + + const response = await testServer.publicApiAgentFor(member).get(`/users/${member.id}`); + + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); }); }); diff --git a/packages/cli/test/integration/public-api/users.test.ts b/packages/cli/test/integration/public-api/users.test.ts index a9b7a21ce16..1b0a0dd5878 100644 --- a/packages/cli/test/integration/public-api/users.test.ts +++ b/packages/cli/test/integration/public-api/users.test.ts @@ -26,21 +26,62 @@ describe('Users in Public API', () => { describe('GET /users', () => { it('if not authenticated, should reject', async () => { - /** - * Act - */ const response = await testServer.publicApiAgentWithApiKey('').get('/users'); - /** - * Assert - */ expect(response.status).toBe(401); }); + it('if missing scope, should reject', async () => { + const memberWithoutScope = await createMemberWithApiKey({ scopes: ['user:read'] }); + + const response = await testServer.publicApiAgentFor(memberWithoutScope).get('/users'); + + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + + it('with a non-numeric limit, should reject', async () => { + const owner = await createOwnerWithApiKey(); + + const response = await testServer + .publicApiAgentFor(owner) + .get('/users') + .query({ limit: 'abc' }); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + message: 'request/query/limit Param `limit` must be a valid integer', + }); + }); + + it('with an invalid includeRole, should reject', async () => { + const owner = await createOwnerWithApiKey(); + + const response = await testServer + .publicApiAgentFor(owner) + .get('/users') + .query({ includeRole: 'not-a-boolean' }); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + message: + "request/query/includeRole Invalid enum value. Expected 'true' | 'false', received 'not-a-boolean'", + }); + }); + + it('should reject an invalid cursor', async () => { + const owner = await createOwnerWithApiKey(); + + const response = await testServer + .publicApiAgentFor(owner) + .get('/users') + .query({ cursor: 'not-a-cursor' }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('message', 'An invalid cursor was provided'); + }); + it('should return users with roles', async () => { - /** - * Arrange - */ const owner = await createOwnerWithApiKey(); const includeRole = true; @@ -48,17 +89,11 @@ describe('Users in Public API', () => { await createMember(); await createMember(); - /** - * Act - */ const response = await testServer .publicApiAgentFor(owner) .get('/users') .query({ includeRole }); - /** - * Assert - */ expect(response.status).toBe(200); const { data: users } = response.body; @@ -82,9 +117,6 @@ describe('Users in Public API', () => { }); it('should return mfaEnabled status for users', async () => { - /** - * Arrange - */ const owner = await createOwnerWithApiKey(); const memberWithMfa = await createMember(); // Manually enable MFA for this member @@ -94,14 +126,8 @@ describe('Users in Public API', () => { const memberWithoutMfa = await createMember(); - /** - * Act - */ const response = await testServer.publicApiAgentFor(owner).get('/users'); - /** - * Assert - */ expect(response.status).toBe(200); const { data: users } = response.body; @@ -115,41 +141,23 @@ describe('Users in Public API', () => { describe('GET /users/:id', () => { it('if not authenticated, should reject', async () => { - /** - * Arrange - */ const member = await createMember(); - /** - * Act - */ const response = await testServer.publicApiAgentWithApiKey('').get(`/users/${member.id}`); - /** - * Assert - */ expect(response.status).toBe(401); }); it('should return a user with role', async () => { - /** - * Arrange - */ const owner = await createOwnerWithApiKey(); const member = await createMember(); const includeRole = true; - /** - * Act - */ const response = await testServer .publicApiAgentFor(owner) .get(`/users/${member.id}`) .query({ includeRole }); - /** - * Assert - */ expect(response.status).toBe(200); const returnedUser = response.body; @@ -165,9 +173,6 @@ describe('Users in Public API', () => { }); it('should return mfaEnabled status for a single user', async () => { - /** - * Arrange - */ const owner = await createOwnerWithApiKey(); const member = await createMember(); // Enable MFA for this member @@ -175,95 +180,64 @@ describe('Users in Public API', () => { const { Container } = await import('@n8n/di'); await Container.get(userRepository).update(member.id, { mfaEnabled: true }); - /** - * Act - */ const response = await testServer.publicApiAgentFor(owner).get(`/users/${member.id}`); - /** - * Assert - */ expect(response.status).toBe(200); const returnedUser = response.body; expect(returnedUser).toHaveProperty('mfaEnabled', true); }); + + it('if the identifier is neither a valid ID nor a valid email, should reject', async () => { + const owner = await createOwnerWithApiKey(); + + const response = await testServer.publicApiAgentFor(owner).get('/users/not-an-id'); + + expect(response.status).toBe(400); + expect(response.body).toStrictEqual({ + message: 'request/params/userId must be a valid ID or email', + }); + }); }); describe('POST /users', () => { it('if not authenticated, should reject', async () => { - /** - * Arrange - */ const payload = { email: 'test@test.com', role: 'global:admin' }; - /** - * Act - */ const response = await testServer.publicApiAgentWithApiKey('').post('/users').send(payload); - /** - * Assert - */ expect(response.status).toBe(401); }); it('if missing scope, should reject', async () => { - /** - * Arrange - */ testServer.license.enable('feat:advancedPermissions'); const member = await createMemberWithApiKey(); const payload = [{ email: 'test@test.com', role: 'global:admin' }]; - /** - * Act - */ const response = await testServer.publicApiAgentFor(member).post('/users').send(payload); - /** - * Assert - */ expect(response.status).toBe(403); expect(response.body).toHaveProperty('message', 'Forbidden'); }); it('should fail if role does not exist', async () => { - /** - * Arrange - */ testServer.license.enable('feat:advancedPermissions'); const owner = await createOwnerWithApiKey(); const payload = [{ email: 'test@test.com', role: 'non-existing-role' }]; - /** - * Act - */ const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload); - /** - * Assert - */ expect(response.status).toBe(400); expect(response.body).toHaveProperty('message', 'Role non-existing-role does not exist'); }); it('should create a user', async () => { - /** - * Arrange - */ testServer.license.enable('feat:advancedPermissions'); const owner = await createOwnerWithApiKey(); const payload = [{ email: 'test@test.com', role: 'global:admin' }]; - /** - * Act - */ const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload); - /** - * Assert - */ expect(response.status).toBe(201); expect(response.body).toHaveLength(1); @@ -285,83 +259,47 @@ describe('Users in Public API', () => { }); it('should create a user with an existing custom role', async () => { - /** - * Arrange - */ testServer.license.enable('feat:advancedPermissions'); const owner = await createOwnerWithApiKey(); const customRole = 'custom:role'; await createRole({ slug: customRole, displayName: 'Custom role', roleType: 'global' }); const payload = [{ email: 'test@test.com', role: customRole }]; - /** - * Act - */ const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload); - /** - * Assert - */ expect(response.status).toBe(201); }); }); describe('DELETE /users/:id', () => { it('if not authenticated, should reject', async () => { - /** - * Arrange - */ const member = await createMember(); - /** - * Act - */ const response = await testServer.publicApiAgentWithApiKey('').delete(`/users/${member.id}`); - /** - * Assert - */ expect(response.status).toBe(401); }); it('if missing scope, should reject', async () => { - /** - * Arrange - */ testServer.license.enable('feat:advancedPermissions'); const member = await createMemberWithApiKey(); const secondMember = await createMember(); - /** - * Act - */ const response = await testServer .publicApiAgentFor(member) .delete(`/users/${secondMember.id}`); - /** - * Assert - */ expect(response.status).toBe(403); expect(response.body).toHaveProperty('message', 'Forbidden'); }); it('should delete a user', async () => { - /** - * Arrange - */ testServer.license.enable('feat:advancedPermissions'); const owner = await createOwnerWithApiKey(); const member = await createMember(); - /** - * Act - */ const response = await testServer.publicApiAgentFor(owner).delete(`/users/${member.id}`); - /** - * Assert - */ expect(response.status).toBe(204); await expect(getUserById(member.id)).rejects.toThrow(); }); @@ -369,43 +307,25 @@ describe('Users in Public API', () => { describe('PATCH /users/:id/role', () => { it('if not authenticated, should reject', async () => { - /** - * Arrange - */ const member = await createMember(); - /** - * Act - */ const response = await testServer .publicApiAgentWithApiKey('') .patch(`/users/${member.id}/role`); - /** - * Assert - */ expect(response.status).toBe(401); }); it('if not licensed, should reject', async () => { - /** - * Arrange - */ const owner = await createOwnerWithApiKey(); const member = await createMember(); const payload = { newRoleName: 'global:admin' }; - /** - * Act - */ const response = await testServer .publicApiAgentFor(owner) .patch(`/users/${member.id}/role`) .send(payload); - /** - * Assert - */ expect(response.status).toBe(403); expect(response.body).toHaveProperty( 'message', @@ -414,81 +334,51 @@ describe('Users in Public API', () => { }); it('if missing scope, should reject', async () => { - /** - * Arrange - */ testServer.license.enable('feat:advancedPermissions'); const member = await createMemberWithApiKey(); const secondMember = await createMember(); const payload = { newRoleName: 'global:admin' }; - /** - * Act - */ const response = await testServer .publicApiAgentFor(member) .patch(`/users/${secondMember.id}/role`) .send(payload); - /** - * Assert - */ expect(response.status).toBe(403); expect(response.body).toHaveProperty('message', 'Forbidden'); }); it('should return a 400 on invalid payload', async () => { - /** - * Arrange - */ testServer.license.enable('feat:advancedPermissions'); const owner = await createOwnerWithApiKey(); const member = await createMember(); const payload = { newRoleName: 'invalid' }; - /** - * Act - */ const response = await testServer .publicApiAgentFor(owner) .patch(`/users/${member.id}/role`) .send(payload); - /** - * Assert - */ expect(response.status).toBe(400); }); it("should change a user's role", async () => { - /** - * Arrange - */ testServer.license.enable('feat:advancedPermissions'); const owner = await createOwnerWithApiKey(); const member = await createMember(); const payload = { newRoleName: 'global:admin' }; - /** - * Act - */ const response = await testServer .publicApiAgentFor(owner) .patch(`/users/${member.id}/role`) .send(payload); - /** - * Assert - */ expect(response.status).toBe(204); const storedUser = await getUserById(member.id); expect(storedUser.role.slug).toBe(payload.newRoleName); }); it('should change a user role to an existing custom role', async () => { - /** - * Arrange - */ testServer.license.enable('feat:advancedPermissions'); testServer.license.enable('feat:customRoles'); const owner = await createOwnerWithApiKey(); @@ -497,17 +387,11 @@ describe('Users in Public API', () => { await createRole({ slug: customRole, displayName: 'Custom role', roleType: 'global' }); const payload = { newRoleName: customRole }; - /** - * Act - */ const response = await testServer .publicApiAgentFor(owner) .patch(`/users/${member.id}/role`) .send(payload); - /** - * Assert - */ expect(response.status).toBe(204); const storedUser = await getUserById(member.id); expect(storedUser.role.slug).toBe(payload.newRoleName); diff --git a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json index 972361a4b14..f214a993bf9 100644 --- a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json +++ b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json @@ -211,7 +211,7 @@ "POST /users": { "status": "gap" }, - "GET /users/{id}": { + "GET /users/{userId}": { "status": "gap" }, "DELETE /users/{id}": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8545e8d233f..ea5c4b946f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -482,6 +482,9 @@ catalogs: '@types/uuid': specifier: ^11.0.0 version: 11.0.0 + '@types/validator': + specifier: ^13.7.0 + version: 13.7.10 '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -1560,6 +1563,9 @@ importers: n8n-workflow: specifier: workspace:* version: link:../../workflow + validator: + specifier: 13.15.26 + version: 13.15.26 xss: specifier: 'catalog:' version: 1.0.15 @@ -1570,6 +1576,9 @@ importers: '@n8n/vitest-config': specifier: workspace:* version: link:../vitest-config + '@types/validator': + specifier: 'catalog:' + version: 13.7.10 '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.9(@vitest/browser@4.1.10)(vitest@4.1.9) @@ -4827,7 +4836,7 @@ importers: specifier: 'catalog:' version: 11.0.0 '@types/validator': - specifier: ^13.7.0 + specifier: 'catalog:' version: 13.7.10 '@types/ws': specifier: 'catalog:' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5b48b104c52..38266db2b23 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -167,6 +167,7 @@ catalog: '@types/ssh2': 1.15.0 '@types/turndown': ^5.0.6 '@types/uuid': ^11.0.0 + '@types/validator': ^13.7.0 '@types/ws': ^8.18.1 '@types/xml2js': ^0.4.14 # Pinned exactly and kept together: these packages cross types with each other @@ -273,6 +274,7 @@ catalog: typescript: 6.0.2 typescript-eslint: 8.70.0 uuid: 11.1.1 + validator: 13.15.26 vite: ^8.0.2 unplugin-dts: ^1.1.0 vite-plugin-singlefile: 2.3.3 @@ -393,7 +395,7 @@ overrides: pdfjs-dist: 'catalog:' tmp: 0.2.7 nodemailer: 8.0.10 - validator: 13.15.26 + validator: 'catalog:' zod: 3.25.76 # Scoped to knip so the repo-wide zod 3 pin stays for everything else. 'knip>zod': catalog:zod-v4 From 40f6c9d25371a2c50174a989b4d4b0d7db91e725 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Tue, 22 Sep 2026 13:04:44 +0000 Subject: [PATCH 03/29] fix(core): Keep skill activation from invalidating the prompt cache (no-changelog) (#39231) Co-authored-by: Cursor --- ...oaded-skill-drives-a-domain-tool-call.json | 106 ++++++--- ...ollows-its-instructions-with-generate.json | 55 +++-- .../loads-and-follows-a-skill-in-stream.json | 57 +++-- .../runtime/__tests__/active-skills.test.ts | 219 +++++++++++++++++- .../agents/src/runtime/model/message-list.ts | 18 ++ .../src/runtime/skills/active-skills.ts | 203 ++++++++++++++-- .../agents/src/runtime/tools/tool-adapter.ts | 13 +- packages/@n8n/agents/src/skills/types.ts | 10 +- packages/@n8n/agents/src/types/sdk/message.ts | 7 + 9 files changed, 579 insertions(+), 109 deletions(-) diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loaded-skill-drives-a-domain-tool-call.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loaded-skill-drives-a-domain-tool-call.json index 7bc9463d6b5..171f11c0c03 100644 --- a/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loaded-skill-drives-a-domain-tool-call.json +++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loaded-skill-drives-a-domain-tool-call.json @@ -67,29 +67,31 @@ } }, "status": 200, - "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeuLqshPA7QoqLUtLRhcA\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01Krds9KGve8gJNTNa9jn6N7\",\"name\":\"load_skill\",\"input\":{\"skillId\":\"engineering-status\"},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1045,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":57,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", + "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfJCd2zmwW8c3i9MDr6fG\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I'll load the engineering-status skill first, then use it to format the status token.\"},{\"type\":\"tool_use\",\"id\":\"toolu_019R6LssCYfGrz8ddVcPvYQC\",\"name\":\"load_skill\",\"input\":{\"skillId\":\"engineering-status\"},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1045,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":76,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", "rawHeaders": { "anthropic-ratelimit-input-tokens-limit": "10000000", "anthropic-ratelimit-input-tokens-remaining": "9999000", - "anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:04Z", + "anthropic-ratelimit-input-tokens-reset": "2026-09-22T08:57:55Z", "anthropic-ratelimit-output-tokens-limit": "2000000", "anthropic-ratelimit-output-tokens-remaining": "2000000", - "anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:04Z", + "anthropic-ratelimit-output-tokens-reset": "2026-09-22T08:57:55Z", "anthropic-ratelimit-requests-limit": "20000", "anthropic-ratelimit-requests-remaining": "19999", - "anthropic-ratelimit-requests-reset": "2026-09-10T07:12:04Z", + "anthropic-ratelimit-requests-reset": "2026-09-22T08:57:55Z", "anthropic-ratelimit-tokens-limit": "12000000", "anthropic-ratelimit-tokens-remaining": "11999000", - "anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:04Z", + "anthropic-ratelimit-tokens-reset": "2026-09-22T08:57:55Z", "cf-cache-status": "DYNAMIC", - "cf-ray": "a38c9f485dd8bc7b-PRG", + "cf-ray": "a3f01ad7ea6cbbdd-FRA", + "connection": "keep-alive", "content-security-policy": "default-src 'none'; frame-ancestors 'none'", "content-type": "application/json", - "date": "Thu, 10 Sep 2026 07:12:04 GMT", - "request-id": "req_011CeuLqs6fxp1AkSuMSRWYb", + "date": "Tue, 22 Sep 2026 08:57:55 GMT", + "request-id": "req_011CfJCd2nsYW1GzkhCoPFhw", "server": "cloudflare", + "server-timing": "x-originResponse;dur=667", "strict-transport-security": "max-age=31536000; includeSubDomains; preload", - "traceresponse": "00-77c37e49f4c62f7623d8cc66c4f3a26a-6bf7325e3f3f69d3-01", + "traceresponse": "00-234e70eda24ff35d6c847bca1695098f-c9010c0bb2f634e5-01", "vary": "Accept-Encoding", "x-robots-tag": "none" }, @@ -105,7 +107,7 @@ "system": [ { "type": "text", - "text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Engineering status formatter\"\n description: \"Use for formatting engineering workflow status identifiers.\"\n id: \"engineering-status\"\n category: \"engineering\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"\", \"filePath\": \"\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies.\n\n\n\nUse these current skill versions when relevant to the task. Earlier tool results may describe older versions.\n\n[Skill: \"Engineering status formatter\"]\n[Skill category: \"engineering\"]\n[Skill hash: \"f62e1e0bd274\"]\n\nFor status-formatting requests, call the format_status tool with the exact status token from the user. Use the returned formatted value verbatim in the final answer and include STATUS_FORMATTED_BRAVO. Do not format the status manually.\n\n" + "text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Engineering status formatter\"\n description: \"Use for formatting engineering workflow status identifiers.\"\n id: \"engineering-status\"\n category: \"engineering\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"\", \"filePath\": \"\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies." } ], "messages": [ @@ -121,9 +123,13 @@ { "role": "assistant", "content": [ + { + "type": "text", + "text": "I'll load the engineering-status skill first, then use it to format the status token." + }, { "type": "tool_use", - "id": "toolu_01Krds9KGve8gJNTNa9jn6N7", + "id": "toolu_019R6LssCYfGrz8ddVcPvYQC", "name": "load_skill", "input": { "skillId": "engineering-status" @@ -139,8 +145,17 @@ "content": [ { "type": "tool_result", - "tool_use_id": "toolu_01Krds9KGve8gJNTNa9jn6N7", - "content": "{\"skillId\":\"engineering-status\",\"active\":true}" + "tool_use_id": "toolu_019R6LssCYfGrz8ddVcPvYQC", + "content": [ + { + "type": "text", + "text": "{\"skillId\":\"engineering-status\",\"active\":true}" + }, + { + "type": "text", + "text": "[Skill: \"Engineering status formatter\"]\n[Skill category: \"engineering\"]\n[Skill hash: \"f62e1e0bd274\"]\n\nFor status-formatting requests, call the format_status tool with the exact status token from the user. Use the returned formatted value verbatim in the final answer and include STATUS_FORMATTED_BRAVO. Do not format the status manually." + } + ] } ] } @@ -189,29 +204,30 @@ } }, "status": 200, - "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeuLqxFjGLPD3QE1zh7Ef\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Now I'll format the status token:\"},{\"type\":\"tool_use\",\"id\":\"toolu_01CKRsNPiekmX9TWf3ooQdHU\",\"name\":\"format_status\",\"input\":{\"status\":\"waiting_on_vendor_info\"},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1244,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":68,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", + "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfJCd6hmu4KWXr78SxvJK\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Now I'll format the status token using the format_status tool:\"},{\"type\":\"tool_use\",\"id\":\"toolu_011UF3b8wMRddXy2WYPGRYZe\",\"name\":\"format_status\",\"input\":{\"status\":\"waiting_on_vendor_info\"},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1230,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":74,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", "rawHeaders": { "anthropic-ratelimit-input-tokens-limit": "10000000", "anthropic-ratelimit-input-tokens-remaining": "9999000", - "anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:05Z", + "anthropic-ratelimit-input-tokens-reset": "2026-09-22T08:57:57Z", "anthropic-ratelimit-output-tokens-limit": "2000000", "anthropic-ratelimit-output-tokens-remaining": "2000000", - "anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:05Z", + "anthropic-ratelimit-output-tokens-reset": "2026-09-22T08:57:57Z", "anthropic-ratelimit-requests-limit": "20000", "anthropic-ratelimit-requests-remaining": "19999", - "anthropic-ratelimit-requests-reset": "2026-09-10T07:12:05Z", + "anthropic-ratelimit-requests-reset": "2026-09-22T08:57:56Z", "anthropic-ratelimit-tokens-limit": "12000000", "anthropic-ratelimit-tokens-remaining": "11999000", - "anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:05Z", + "anthropic-ratelimit-tokens-reset": "2026-09-22T08:57:57Z", "cf-cache-status": "DYNAMIC", - "cf-ray": "a38c9f4f38babc7b-PRG", + "cf-ray": "a3f01add2e2bbbdd-FRA", + "connection": "keep-alive", "content-security-policy": "default-src 'none'; frame-ancestors 'none'", "content-type": "application/json", - "date": "Thu, 10 Sep 2026 07:12:06 GMT", - "request-id": "req_011CeuLqwpw3fPBk6xt6nrrm", + "date": "Tue, 22 Sep 2026 08:57:57 GMT", + "request-id": "req_011CfJCd6Vcovu2SLcUWAPC4", "server": "cloudflare", "strict-transport-security": "max-age=31536000; includeSubDomains; preload", - "traceresponse": "00-d17795404368021a8e502b8755cc4b35-c750063766d5eb87-01", + "traceresponse": "00-e98096358e475d79d338f7d1cf690dbb-aa6a8d0521eacc76-01", "vary": "Accept-Encoding", "x-robots-tag": "none" }, @@ -227,7 +243,7 @@ "system": [ { "type": "text", - "text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Engineering status formatter\"\n description: \"Use for formatting engineering workflow status identifiers.\"\n id: \"engineering-status\"\n category: \"engineering\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"\", \"filePath\": \"\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies.\n\n\n\nUse these current skill versions when relevant to the task. Earlier tool results may describe older versions.\n\n[Skill: \"Engineering status formatter\"]\n[Skill category: \"engineering\"]\n[Skill hash: \"f62e1e0bd274\"]\n\nFor status-formatting requests, call the format_status tool with the exact status token from the user. Use the returned formatted value verbatim in the final answer and include STATUS_FORMATTED_BRAVO. Do not format the status manually.\n\n" + "text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Engineering status formatter\"\n description: \"Use for formatting engineering workflow status identifiers.\"\n id: \"engineering-status\"\n category: \"engineering\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"\", \"filePath\": \"\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies." } ], "messages": [ @@ -243,9 +259,13 @@ { "role": "assistant", "content": [ + { + "type": "text", + "text": "I'll load the engineering-status skill first, then use it to format the status token." + }, { "type": "tool_use", - "id": "toolu_01Krds9KGve8gJNTNa9jn6N7", + "id": "toolu_019R6LssCYfGrz8ddVcPvYQC", "name": "load_skill", "input": { "skillId": "engineering-status" @@ -261,8 +281,17 @@ "content": [ { "type": "tool_result", - "tool_use_id": "toolu_01Krds9KGve8gJNTNa9jn6N7", - "content": "{\"skillId\":\"engineering-status\",\"active\":true}" + "tool_use_id": "toolu_019R6LssCYfGrz8ddVcPvYQC", + "content": [ + { + "type": "text", + "text": "{\"skillId\":\"engineering-status\",\"active\":true}" + }, + { + "type": "text", + "text": "[Skill: \"Engineering status formatter\"]\n[Skill category: \"engineering\"]\n[Skill hash: \"f62e1e0bd274\"]\n\nFor status-formatting requests, call the format_status tool with the exact status token from the user. Use the returned formatted value verbatim in the final answer and include STATUS_FORMATTED_BRAVO. Do not format the status manually." + } + ] } ] }, @@ -271,11 +300,11 @@ "content": [ { "type": "text", - "text": "Now I'll format the status token:" + "text": "Now I'll format the status token using the format_status tool:" }, { "type": "tool_use", - "id": "toolu_01CKRsNPiekmX9TWf3ooQdHU", + "id": "toolu_011UF3b8wMRddXy2WYPGRYZe", "name": "format_status", "input": { "status": "waiting_on_vendor_info" @@ -291,7 +320,7 @@ "content": [ { "type": "tool_result", - "tool_use_id": "toolu_01CKRsNPiekmX9TWf3ooQdHU", + "tool_use_id": "toolu_011UF3b8wMRddXy2WYPGRYZe", "content": "{\"formatted\":\"STATUS_FORMATTED_BRAVO: WAITING_ON_VENDOR_INFO\"}" } ] @@ -341,29 +370,30 @@ } }, "status": 200, - "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeuLr3AQ92UTrYgeFRerd\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"The status token has been formatted successfully:\\n\\n**STATUS_FORMATTED_BRAVO: WAITING_ON_VENDOR_INFO**\"}],\"container\":null,\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1346,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":32,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", + "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfJCdCtqry5T2NJGAMCjV\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Perfect! The engineering-status skill has been loaded and the status token has been formatted.\\n\\n**Formatted Status:**\\nSTATUS_FORMATTED_BRAVO: WAITING_ON_VENDOR_INFO\"}],\"container\":null,\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1338,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":46,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", "rawHeaders": { "anthropic-ratelimit-input-tokens-limit": "10000000", "anthropic-ratelimit-input-tokens-remaining": "9999000", - "anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:06Z", + "anthropic-ratelimit-input-tokens-reset": "2026-09-22T08:57:57Z", "anthropic-ratelimit-output-tokens-limit": "2000000", "anthropic-ratelimit-output-tokens-remaining": "2000000", - "anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:06Z", + "anthropic-ratelimit-output-tokens-reset": "2026-09-22T08:57:58Z", "anthropic-ratelimit-requests-limit": "20000", "anthropic-ratelimit-requests-remaining": "19999", - "anthropic-ratelimit-requests-reset": "2026-09-10T07:12:06Z", + "anthropic-ratelimit-requests-reset": "2026-09-22T08:57:57Z", "anthropic-ratelimit-tokens-limit": "12000000", "anthropic-ratelimit-tokens-remaining": "11999000", - "anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:06Z", + "anthropic-ratelimit-tokens-reset": "2026-09-22T08:57:57Z", "cf-cache-status": "DYNAMIC", - "cf-ray": "a38c9f561a95bc7b-PRG", + "cf-ray": "a3f01ae61be4bbdd-FRA", + "connection": "keep-alive", "content-security-policy": "default-src 'none'; frame-ancestors 'none'", "content-type": "application/json", - "date": "Thu, 10 Sep 2026 07:12:07 GMT", - "request-id": "req_011CeuLr2Whz42pkAm7AzGhs", + "date": "Tue, 22 Sep 2026 08:57:58 GMT", + "request-id": "req_011CfJCdCcii1dW1VhpqXjuj", "server": "cloudflare", "strict-transport-security": "max-age=31536000; includeSubDomains; preload", - "traceresponse": "00-80319e2d05f81fe007810b9d0f60ed01-c7956b0a3a60554e-01", + "traceresponse": "00-4d1157be05f9b0ab551736730e6ba0e2-b17f94e742ef7628-01", "vary": "Accept-Encoding", "x-robots-tag": "none" }, diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loads-a-matching-skill-and-follows-its-instructions-with-generate.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loads-a-matching-skill-and-follows-its-instructions-with-generate.json index 4382b1f71e1..d473789a631 100644 --- a/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loads-a-matching-skill-and-follows-its-instructions-with-generate.json +++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loads-a-matching-skill-and-follows-its-instructions-with-generate.json @@ -51,29 +51,30 @@ } }, "status": 200, - "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeuLqj5DrEFutdjSD8ETs\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01Eq5aFLJhGriLQF4UBg7tyT\",\"name\":\"load_skill\",\"input\":{\"skillId\":\"summary-marker\"},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":957,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":57,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", + "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfJCcuKuoVBeB7Ake3FNv\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01Rhg9DwH7n3MzSh9iz6TFzh\",\"name\":\"load_skill\",\"input\":{\"skillId\":\"summary-marker\"},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":957,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":57,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", "rawHeaders": { "anthropic-ratelimit-input-tokens-limit": "10000000", "anthropic-ratelimit-input-tokens-remaining": "9999000", - "anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:02Z", + "anthropic-ratelimit-input-tokens-reset": "2026-09-22T08:57:53Z", "anthropic-ratelimit-output-tokens-limit": "2000000", "anthropic-ratelimit-output-tokens-remaining": "2000000", - "anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:02Z", + "anthropic-ratelimit-output-tokens-reset": "2026-09-22T08:57:54Z", "anthropic-ratelimit-requests-limit": "20000", "anthropic-ratelimit-requests-remaining": "19999", - "anthropic-ratelimit-requests-reset": "2026-09-10T07:12:02Z", + "anthropic-ratelimit-requests-reset": "2026-09-22T08:57:53Z", "anthropic-ratelimit-tokens-limit": "12000000", "anthropic-ratelimit-tokens-remaining": "11999000", - "anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:02Z", + "anthropic-ratelimit-tokens-reset": "2026-09-22T08:57:53Z", "cf-cache-status": "DYNAMIC", - "cf-ray": "a38c9f3bd9d2bc7b-PRG", + "cf-ray": "a3f01acbda36bbdd-FRA", + "connection": "keep-alive", "content-security-policy": "default-src 'none'; frame-ancestors 'none'", "content-type": "application/json", - "date": "Thu, 10 Sep 2026 07:12:02 GMT", - "request-id": "req_011CeuLqiWV7tDxhVGbXmda8", + "date": "Tue, 22 Sep 2026 08:57:54 GMT", + "request-id": "req_011CfJCcteyXnhh5BmvQdDTp", "server": "cloudflare", "strict-transport-security": "max-age=31536000; includeSubDomains; preload", - "traceresponse": "00-7cf7d7c3cfa9378a22214da3c62a1ea0-1ad2b8e7d42a1569-01", + "traceresponse": "00-66ff12c75cd9a23f50952244bb3cba2c-133f511c645be506-01", "vary": "Accept-Encoding", "x-robots-tag": "none" }, @@ -89,7 +90,7 @@ "system": [ { "type": "text", - "text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Summary marker\"\n description: \"Use for summarizing notes, updates, or short text while preserving a marker.\"\n id: \"summary-marker\"\n category: \"productivity\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"\", \"filePath\": \"\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies.\n\n\n\nUse these current skill versions when relevant to the task. Earlier tool results may describe older versions.\n\n[Skill: \"Summary marker\"]\n[Skill category: \"productivity\"]\n[Skill hash: \"e3433b77c9e3\"]\n\nEvery final answer after loading this skill must include the exact marker SKILL_MARKER_ALPHA. For summaries, be concise and keep the marker unchanged.\n\n" + "text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Summary marker\"\n description: \"Use for summarizing notes, updates, or short text while preserving a marker.\"\n id: \"summary-marker\"\n category: \"productivity\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"\", \"filePath\": \"\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies." } ], "messages": [ @@ -107,7 +108,7 @@ "content": [ { "type": "tool_use", - "id": "toolu_01Eq5aFLJhGriLQF4UBg7tyT", + "id": "toolu_01Rhg9DwH7n3MzSh9iz6TFzh", "name": "load_skill", "input": { "skillId": "summary-marker" @@ -123,8 +124,17 @@ "content": [ { "type": "tool_result", - "tool_use_id": "toolu_01Eq5aFLJhGriLQF4UBg7tyT", - "content": "{\"skillId\":\"summary-marker\",\"active\":true}" + "tool_use_id": "toolu_01Rhg9DwH7n3MzSh9iz6TFzh", + "content": [ + { + "type": "text", + "text": "{\"skillId\":\"summary-marker\",\"active\":true}" + }, + { + "type": "text", + "text": "[Skill: \"Summary marker\"]\n[Skill category: \"productivity\"]\n[Skill hash: \"e3433b77c9e3\"]\n\nEvery final answer after loading this skill must include the exact marker SKILL_MARKER_ALPHA. For summaries, be concise and keep the marker unchanged." + } + ] } ] } @@ -157,29 +167,30 @@ } }, "status": 200, - "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeuLqo1c45tLcGPFM5dEA\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"**Summary:** n8n agents maintain efficient workflow operations by keeping context compact, reducing overhead and improving performance.\\n\\nSKILL_MARKER_ALPHA\"}],\"container\":null,\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1139,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":34,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", + "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfJCcxfLB1kLZMwT9hmcx\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Based on the Summary marker skill instructions, here's the summary:\\n\\n**Summary:** n8n agents maintain workflow context efficiency by keeping data structures and variables compact, reducing memory overhead and improving performance during workflow execution.\\n\\nSKILL_MARKER_ALPHA\"}],\"container\":null,\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1106,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":55,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", "rawHeaders": { "anthropic-ratelimit-input-tokens-limit": "10000000", "anthropic-ratelimit-input-tokens-remaining": "9999000", - "anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:03Z", + "anthropic-ratelimit-input-tokens-reset": "2026-09-22T08:57:54Z", "anthropic-ratelimit-output-tokens-limit": "2000000", "anthropic-ratelimit-output-tokens-remaining": "2000000", - "anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:03Z", + "anthropic-ratelimit-output-tokens-reset": "2026-09-22T08:57:55Z", "anthropic-ratelimit-requests-limit": "20000", "anthropic-ratelimit-requests-remaining": "19999", - "anthropic-ratelimit-requests-reset": "2026-09-10T07:12:02Z", + "anthropic-ratelimit-requests-reset": "2026-09-22T08:57:54Z", "anthropic-ratelimit-tokens-limit": "12000000", "anthropic-ratelimit-tokens-remaining": "11999000", - "anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:03Z", + "anthropic-ratelimit-tokens-reset": "2026-09-22T08:57:54Z", "cf-cache-status": "DYNAMIC", - "cf-ray": "a38c9f417db9bc7b-PRG", + "cf-ray": "a3f01ad17e0abbdd-FRA", + "connection": "keep-alive", "content-security-policy": "default-src 'none'; frame-ancestors 'none'", "content-type": "application/json", - "date": "Thu, 10 Sep 2026 07:12:03 GMT", - "request-id": "req_011CeuLqnRP3vkdg9opi7gNC", + "date": "Tue, 22 Sep 2026 08:57:55 GMT", + "request-id": "req_011CfJCcxUAmJjy1fhrHrwCP", "server": "cloudflare", "strict-transport-security": "max-age=31536000; includeSubDomains; preload", - "traceresponse": "00-86beb3374d817d1d427aabf5c903ca34-f5a5fa52f1cb2789-01", + "traceresponse": "00-7063877d53ab0eae4686a22ce533a4ec-69260e5b962e00c7-01", "vary": "Accept-Encoding", "x-robots-tag": "none" }, diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loads-and-follows-a-skill-in-stream.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loads-and-follows-a-skill-in-stream.json index 481a5c37b3f..4aa66be67b9 100644 --- a/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loads-and-follows-a-skill-in-stream.json +++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/runtime-skills/loads-and-follows-a-skill-in-stream.json @@ -53,32 +53,34 @@ }, "status": 200, "response": [ - "1f8b0800000000000013c5554d8bdb3010bde757189d1348c2ba21be9585d2427ae852e8a12942b1a6896259f2ea23ec12f4df3b72e2f823d96d2f253e18346fa479ef69d0c00194cb9212ac655ba0d631e3469c39962547e25e2b2019e98164dcac497624a5e620312597cc7398ec9828fce461924ee6d3793a9b4e67982e783cc36ee974367b04bf328ba765fa8def3fa63f5e162bf3b9c09c7e250c182d6380592bb0ac8a6573ad1c9225d9cf5fa705130a0cc99497724cacd31535c0ac56bd9085670f2a875e90036e96b689f9468e509577d4e9021482cbf4010bb17c0734c7939dd08af633a60d8e307f0b6bf6c60250eda004c3244dcbebfc169ded86681813ed5d3734ff806ac01c440ed489e8048956716678745dfd061385d32d68849476941d5035dba0b3212449188de074fb6767e946eabcb8dd033752ea221c5e4e52bb78547adee7b496d45b68fa20ae3d76c222cfbf2f3f6ded53517eddad9ee71b2b166a85598a95719fd468a82d84947519d48d878668a99451eaa500170672570b3a7fadae4aa8ed4048426290bca51dbbd9b1f7b5d7293deda7484be974737becc44b72857e09bcd77ddd9e84843ed1bb9038ae892561e8d95da8c47bfec2d7b52f77a69225688b2fb1a1eecba364e67582bf8284ebc6be0b23306b125a36ef528aafecdf9e105d7508b50735d3e6a6aa1e483a2a7acf7ff7d5f9e721309828e1ff8d85c13b9e2ec2c0cb76e05ebbd8c5c8e512fe00c6a9d291c7070000" + "1f8b0800000000000013cd544d6fd34010bdf757587376243b6a5a756f10a440c501845490085a4dbdd364f17ad7ec47441bf9bfa3b5ebc63629f482826f3b6fc6f3dedbd9a11d69cf928a9cc30d71e7d1fa33811e59b2077f5f1330188190f667607ba88c20050c0a8541d06c8bb20cb3f3d96236cfe68b3ccb7248418af80fb7e1599e2fefae9762e5de5cac1e56e2cb03bef5b95c423ae9042958a362009d93cea38e6d0ba33d690fecebb7ee80529305a6835229386f6a6e099dd1a390a31f817441a3a0208f52b93e167a3952d7c1736f4ad20ed8d5e23c85028b2df1c2127a69341f67643d6e09c573585f1b1b50bda58a2c2abea87ecf3fa0f9768a362998e087a1f9450a8eec4e16c4bd8c4e40b44aa015d1757d47360ae71b32c0401bcf718752e1ad22689a2469cecea8bbfd4767f9ad3245797c068ea4b44d04fdeca40ef1a8f4b1ce1ba37870d4cf413c079ee5d757bbf2e6f2755d7d78f5193fdd6cdf7f9ccf439c048d55ac53060577a554aa6d53070f6cdf444b958a529f1a0869a9f0ada0f63ba8aaa5de4c64241083f09c7241cae39f95b72923e55de440a8bbb7efcee8a7e41aad97a8da2030809eeb98ef49b8ecd7d0b9dcfc0764de8935b0640dae65736232a1aad0decf0e9775623e15da92ec1a9a178d4f5c737f7bc3a61e509afcaddff947c58d401888192de1e1db7ff12a9eecf5e6df2de7c9365d5c0e6d3de2c431478718b445bf00ccc6474350070000" ], "rawHeaders": { "anthropic-ratelimit-input-tokens-limit": "10000000", "anthropic-ratelimit-input-tokens-remaining": "10000000", - "anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:07Z", + "anthropic-ratelimit-input-tokens-reset": "2026-09-22T08:57:58Z", "anthropic-ratelimit-output-tokens-limit": "2000000", "anthropic-ratelimit-output-tokens-remaining": "2000000", - "anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:07Z", + "anthropic-ratelimit-output-tokens-reset": "2026-09-22T08:57:58Z", "anthropic-ratelimit-requests-limit": "20000", "anthropic-ratelimit-requests-remaining": "19999", - "anthropic-ratelimit-requests-reset": "2026-09-10T07:12:07Z", + "anthropic-ratelimit-requests-reset": "2026-09-22T08:57:58Z", "anthropic-ratelimit-tokens-limit": "12000000", "anthropic-ratelimit-tokens-remaining": "12000000", - "anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:07Z", + "anthropic-ratelimit-tokens-reset": "2026-09-22T08:57:58Z", "cache-control": "no-cache", "cf-cache-status": "DYNAMIC", - "cf-ray": "a38c9f5c5a57bc7b-PRG", + "cf-ray": "a3f01aebf8c4bbdd-FRA", + "connection": "keep-alive", "content-encoding": "gzip", "content-security-policy": "default-src 'none'; frame-ancestors 'none'", "content-type": "text/event-stream; charset=utf-8", - "date": "Thu, 10 Sep 2026 07:12:08 GMT", - "request-id": "req_011CeuLr6og4ahrpyfnPec45", + "date": "Tue, 22 Sep 2026 08:57:59 GMT", + "request-id": "req_011CfJCdGeJvd8FiNh3bikfH", "server": "cloudflare", "strict-transport-security": "max-age=31536000; includeSubDomains; preload", - "traceresponse": "00-db154d560f530266545ecc9baf7015e2-c0470cb196446bc0-01", + "traceresponse": "00-5b8f08c4391de2116ab063ddaa4cdd99-24fa357e98008a81-01", + "transfer-encoding": "chunked", "vary": "Accept-Encoding", "x-robots-tag": "none" }, @@ -94,7 +96,7 @@ "system": [ { "type": "text", - "text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Summary marker\"\n description: \"Use for summarizing notes, updates, or short text while preserving a marker.\"\n id: \"summary-marker\"\n category: \"productivity\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"\", \"filePath\": \"\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies.\n\n\n\nUse these current skill versions when relevant to the task. Earlier tool results may describe older versions.\n\n[Skill: \"Summary marker\"]\n[Skill category: \"productivity\"]\n[Skill hash: \"e3433b77c9e3\"]\n\nEvery final answer after loading this skill must include the exact marker SKILL_MARKER_ALPHA. For summaries, be concise and keep the marker unchanged.\n\n" + "text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Summary marker\"\n description: \"Use for summarizing notes, updates, or short text while preserving a marker.\"\n id: \"summary-marker\"\n category: \"productivity\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"\", \"filePath\": \"\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies." } ], "messages": [ @@ -112,7 +114,7 @@ "content": [ { "type": "tool_use", - "id": "toolu_017ccT9FgsRkmMhLq2bsi7nL", + "id": "toolu_01J9vkV7BpmPAWaSVhLQ22uC", "name": "load_skill", "input": { "skillId": "summary-marker" @@ -128,8 +130,17 @@ "content": [ { "type": "tool_result", - "tool_use_id": "toolu_017ccT9FgsRkmMhLq2bsi7nL", - "content": "{\"skillId\":\"summary-marker\",\"active\":true}" + "tool_use_id": "toolu_01J9vkV7BpmPAWaSVhLQ22uC", + "content": [ + { + "type": "text", + "text": "{\"skillId\":\"summary-marker\",\"active\":true}" + }, + { + "type": "text", + "text": "[Skill: \"Summary marker\"]\n[Skill category: \"productivity\"]\n[Skill hash: \"e3433b77c9e3\"]\n\nEvery final answer after loading this skill must include the exact marker SKILL_MARKER_ALPHA. For summaries, be concise and keep the marker unchanged." + } + ] } ] } @@ -164,32 +175,34 @@ }, "status": 200, "response": [ - "1f8b0800000000000013c555cb8e1a3110bcf315968f082266b3ec616e681569a32551b428a76c6419bb99b1f0d8133f106885948fc817e64bd21e98c0f0489443122e305d6557755334b00213725281f7bc00e60377a12779e03979a1615303cd6907a483f699e62fb4b212345284e651c2b0e46a1987b7c3f1f0667433ce46a30ce94aa63b7cc14659760f71eaee65b40f73fbf1bd288a30dddc21a7ab840567752a70ef15ca9a242bac096896e69f3eef1eb832e0686ea2d603ea83ad9903eeade9943c7c896004748a12f0b0f66d2db6ed2853c7c0825d824130cb5edfa112172530815707650deb52462d8eb0bc86b5679302d42554e0b866e3ea9c7f40b3f214dd0ea88da1e30fbb01b7520258506912348d4a7227d3d4cd025c6a9c156011323630bec2aef91c27bbdd1242b6bd1eecbefefd68d95c5bb1bc1c820b944645c27ad7ea319e3add9f0bb04ec4e62da734e936af8378ad4c71a246682ad26b06317381ffda6043e918dc55bac67ed2f6f6fafdbdc17fae3c8b55c5dd26eff7c92c605e2aec9f602a4df0042a1548b05693c693fffef51b274bd8e0af5694dc285f918575043f4add1cf31b234a678d8d9e28f4e6b848f9f30419a47676a564e2a18c1e0655015900c839174b22a34bc84e6c0d2236b9fd4f3379f56c9ecdecf1ed74cade4d9e1edf3cb1c9f4c3c3e490a1b3289d86d4d6bf8bb1ad8f6c5db9b5dd7f171bed80f4a8b1ce42a260240bd119fa076be964c76dffe2a23ad92cb7d9f5191ffe0dcea77b8cd1c3c91f64c432c465060000" + "1f8b0800000000000013c598514fdb3010c7dff914d6bd4c4201356c4c53de109a36a09b26d8db98a2233e1aab8e9dda97b608e5bb4f4e5b9ab4c0b60792bc44b9bbe47ef78fedf84273329c8882bcc709a59ed1f18144c6443c023f940409749c106dae217984c24ad29040a6b1927494a39a56471f8e4e8f4e4627a7f1681443044a8667f8493a8ae3f3fbcb73399ebae5a74b9fcf5c7c15cfe4178876324104ceea6040ef956734216d660d9361487efd5e5da032e4203195d61178b665ea08bd351d93a7594526a38e5112a3d27e63ab36e52853569cb29d92f190c4f1e87d04196639a5992364654dda0d196dfc8e50bee4dbdc1b32509953410e757a5aecc76fbd71beebad23b01577f822f0e4e62aa3945550028254129d0caa9b7b72a1f0744216123096539ca3d278a709ea5aac8ffae080566360ad6f7aa76d367d7e243c13d2a492b45cd5dbf68772d7f7312d4360734a00ea76da5299c94e1e01c1082fa149d28cafa335211db495a58bf414b606fb6e1750bf26ca9b651617efb416a5b37325a961e81f81f355e6f6d13f85af8a02dd835828cebb388328221ccd2ae548b658fa0729d04dc925b7e6d61c1e6e487ac7b859bd9a648b30941e37ec080b6526022764d80b2a140bb6568b86c41f0f358f7fe6e4879ac0e868b0b1b1d6bd3d30fa6798902187bc9dacfd23dc3dac87e47008b60c22341fd0810894197a81f04f0b44d8a146629193a36681189a4c9939d4fde7b559b305f502cd80b3c391af34fbb05289cc1645655416e6ab400f75ff3852f9cc11ef6d7b865a3d8543cec909ced18805aad6241e84eadebaf09a4abdafd100122d29ab9a36aa1e8ae1b8dbb9f49435ecfa6eae2ec6e3f4dbd9f5d5e7ebf46cfce3ebd9bf7c6943affbb716ce962da01777ff9b5f00cf16d97142aba84e4f0e6464ca9533f01f9df94e9b5fbf61afbed35c7f3cdd5b25b7ff41f6656dfb605fc63f07b52da767110000" ], "rawHeaders": { "anthropic-ratelimit-input-tokens-limit": "10000000", "anthropic-ratelimit-input-tokens-remaining": "10000000", - "anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:08Z", + "anthropic-ratelimit-input-tokens-reset": "2026-09-22T08:57:59Z", "anthropic-ratelimit-output-tokens-limit": "2000000", "anthropic-ratelimit-output-tokens-remaining": "2000000", - "anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:08Z", + "anthropic-ratelimit-output-tokens-reset": "2026-09-22T08:57:59Z", "anthropic-ratelimit-requests-limit": "20000", "anthropic-ratelimit-requests-remaining": "19999", - "anthropic-ratelimit-requests-reset": "2026-09-10T07:12:08Z", + "anthropic-ratelimit-requests-reset": "2026-09-22T08:57:59Z", "anthropic-ratelimit-tokens-limit": "12000000", "anthropic-ratelimit-tokens-remaining": "12000000", - "anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:08Z", + "anthropic-ratelimit-tokens-reset": "2026-09-22T08:57:59Z", "cache-control": "no-cache", "cf-cache-status": "DYNAMIC", - "cf-ray": "a38c9f63ce57bc7b-PRG", + "cf-ray": "a3f01af1acffbbdd-FRA", + "connection": "keep-alive", "content-encoding": "gzip", "content-security-policy": "default-src 'none'; frame-ancestors 'none'", "content-type": "text/event-stream; charset=utf-8", - "date": "Thu, 10 Sep 2026 07:12:08 GMT", - "request-id": "req_011CeuLrBuGK4E7uxpMM6NMq", + "date": "Tue, 22 Sep 2026 08:57:59 GMT", + "request-id": "req_011CfJCdLVz3EfLeiVHjxihN", "server": "cloudflare", "strict-transport-security": "max-age=31536000; includeSubDomains; preload", - "traceresponse": "00-ae497abd3e1ae1a20e06ccaeba57c754-f8de5c4ed0f0dac4-01", + "traceresponse": "00-0dcb0fbb57a4e0ed3374476fe9b454e3-795c47a4b1e4f26c-01", + "transfer-encoding": "chunked", "vary": "Accept-Encoding", "x-robots-tag": "none" }, diff --git a/packages/@n8n/agents/src/runtime/__tests__/active-skills.test.ts b/packages/@n8n/agents/src/runtime/__tests__/active-skills.test.ts index 4a972d77cf6..7e357c0fe50 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/active-skills.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/active-skills.test.ts @@ -5,6 +5,19 @@ import { AgentMessageList } from '../model/message-list'; import { ActiveSkills } from '../skills/active-skills'; const scope = { threadId: 'thread-1', resourceId: 'user-1' }; + +/** + * Mark the list as observation-masked, so recovered skills render in the + * `` system block. Without a mask the block stays empty and + * skills ride on their still-visible activating tool result. + */ +function mask(list: AgentMessageList): AgentMessageList { + list.maskObservedMessages({ + lastObservedAt: new Date(Date.now() + 60_000), + lastObservedMessageId: 'observation-cursor', + }); + return list; +} const source = createRuntimeSkillSource([ { id: 'planning', @@ -87,12 +100,46 @@ describe('active skills', () => { await active.restore(list, scope); - expect(active.instructions()).toContain('Build one workflow.'); + // The load_skill result is still visible, so the body rides on it and the + // system block stays empty — no cached-prefix rewrite. + expect(active.instructions()).toBeUndefined(); + expect(JSON.stringify(active.modelMessages(list.forLlm('').messages, list))).toContain( + 'Build one workflow.', + ); await expect(memory.skillState.load({ ...scope, agentName: 'assistant' })).resolves.toEqual([ 'builder', ]); }); + it('recovers a skill into the system block once observation masks its load', async () => { + const memory = new InMemoryMemory(); + const list = new AgentMessageList(); + list.addHistory([ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolName: 'load_skill', + toolCallId: 'old-load', + input: { skillId: 'builder' }, + state: 'resolved', + output: { type: 'content', value: [{ type: 'text', text: 'Build one workflow.' }] }, + }, + ], + }, + ]); + const active = new ActiveSkills(source, 'assistant', memory.skillState); + await active.restore(list, scope); + + // Visible load → block empty. + expect(active.instructions()).toBeUndefined(); + + // Observation hides the load result → the skill moves into the block. + mask(list); + expect(active.instructions()).toContain('Build one workflow.'); + }); + it('persists checkpoint skills when they differ from stored state', async () => { const memory = new InMemoryMemory(); await memory.skillState.save({ ...scope, agentName: 'assistant' }, ['planning']); @@ -120,7 +167,15 @@ describe('active skills', () => { { type: 'tool-call', toolName: 'load_skill', - toolCallId: 'old-load', + toolCallId: 'builder-load', + input: { skillId: 'builder' }, + state: 'resolved', + output: { type: 'content', value: [{ type: 'text', text: 'Build one workflow.' }] }, + }, + { + type: 'tool-call', + toolName: 'load_skill', + toolCallId: 'planning-load', input: { skillId: 'planning' }, state: 'resolved', output: { type: 'content', value: [{ type: 'text', text: 'Create a task plan.' }] }, @@ -135,16 +190,132 @@ describe('active skills', () => { ); await active.restore(list, scope); - expect(active.instructions()).toContain('Build one workflow.'); - expect(active.instructions()).not.toContain('Create a task plan.'); - expect(JSON.stringify(active.modelMessages(list.forLlm('').messages, list))).not.toContain( - 'Create a task plan.', - ); + // Both loads are still visible, so the block stays empty; the enabled skill + // rides on its result while the disabled one is collapsed out of it. + expect(active.instructions()).toBeUndefined(); + const rendered = JSON.stringify(active.modelMessages(list.forLlm('').messages, list)); + expect(rendered).toContain('Build one workflow.'); + expect(rendered).not.toContain('Create a task plan.'); await expect(memory.skillState.load({ ...scope, agentName: 'assistant' })).resolves.toEqual([ 'builder', ]); }); + it('re-anchors a stamped tool activation on a later turn instead of the block', async () => { + const memory = new InMemoryMemory(); + await memory.skillState.save({ ...scope, agentName: 'assistant' }, ['builder']); + const list = new AgentMessageList(); + // Turn 1 activated the skill from a tool and stamped its result. On the + // next turn the body must ride on that result again, not the system block. + list.addHistory([ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolName: 'build_workflow', + toolCallId: 'build-1', + input: {}, + state: 'resolved', + output: { type: 'content', value: [{ type: 'text', text: 'built' }] }, + activatedSkillIds: ['builder'], + }, + ], + }, + ]); + const active = new ActiveSkills(source, 'assistant', memory.skillState); + await active.restore(list, scope); + + expect(active.instructions()).toBeUndefined(); + expect(JSON.stringify(active.modelMessages(list.forLlm('').messages, list))).toContain( + 'Build one workflow.', + ); + }); + + it('persists a tool activation stamp across serialization', async () => { + const memory = new InMemoryMemory(); + const active = new ActiveSkills(source, 'assistant', memory.skillState); + const list = new AgentMessageList(); + list.addResponse([ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolName: 'build_workflow', + toolCallId: 'build-1', + input: {}, + state: 'resolved', + output: { type: 'content', value: [{ type: 'text', text: 'built' }] }, + }, + ], + }, + ]); + await active.restore(list, scope); + await active.load('builder', { toolCallId: 'build-1' }); + + const roundTripped = AgentMessageList.deserialize(structuredClone(list.serialize())); + const stamped = roundTripped + .messages() + .flatMap((message) => ('content' in message ? message.content : [])) + .find((part) => part.type === 'tool-call' && part.toolCallId === 'build-1'); + expect(stamped).toMatchObject({ activatedSkillIds: ['builder'] }); + }); + + it('recovers a restored programmatic skill through the block when no stamp survives', async () => { + const memory = new InMemoryMemory(); + await memory.skillState.save({ ...scope, agentName: 'assistant' }, ['builder']); + const list = new AgentMessageList(); + // Legacy data: a prior programmatic activation left no stamp and no + // `load_skill` record. The skill must still reach the model via the block. + list.addHistory([ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolName: 'build_workflow', + toolCallId: 'build-1', + input: {}, + state: 'resolved', + output: { type: 'content', value: [{ type: 'text', text: 'built' }] }, + }, + ], + }, + ]); + const active = new ActiveSkills(source, 'assistant', memory.skillState); + await active.restore(list, scope); + + expect(active.instructions()).toContain('Build one workflow.'); + }); + + it('recovers a skill through the block when its activating tool call failed', async () => { + const memory = new InMemoryMemory(); + const active = new ActiveSkills(source, 'assistant', memory.skillState); + const list = new AgentMessageList(); + list.addResponse([ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolName: 'build_workflow', + toolCallId: 'build-fail', + input: {}, + state: 'rejected', + error: 'boom', + }, + ], + }, + ]); + await active.restore(list, scope); + // The tool activated the skill, then failed — its error result cannot + // carry the body, so the skill belongs in the block, not silently dropped. + await active.load('builder', { toolCallId: 'build-fail' }); + + expect(active.instructions()).toContain('Build one workflow.'); + }); + it('keeps threads, resources, and agents isolated', async () => { const memory = new InMemoryMemory(); const active = new ActiveSkills(source, 'assistant', memory.skillState); @@ -165,6 +336,40 @@ describe('active skills', () => { ).resolves.toBeUndefined(); }); + it('keeps the system block empty for an anchored activation until observation masks it', async () => { + const memory = new InMemoryMemory(); + const active = new ActiveSkills(source, 'assistant', memory.skillState); + const list = new AgentMessageList(); + list.addResponse([ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolName: 'build_workflow', + toolCallId: 'build-1', + input: {}, + state: 'resolved', + output: { type: 'content', value: [{ type: 'text', text: 'built' }] }, + }, + ], + }, + ]); + await active.restore(list, scope); + + // A programmatic activation anchored to the calling tool result stays out + // of the system prompt — the body rides on that visible result instead. + await active.load('builder', { toolCallId: 'build-1' }); + expect(active.instructions()).toBeUndefined(); + expect(JSON.stringify(active.modelMessages(list.forLlm('').messages, list))).toContain( + 'Build one workflow.', + ); + + // Only once observation hides the anchor does the skill move into the block. + mask(list); + expect(active.instructions()).toContain('Build one workflow.'); + }); + it('does not activate a missing skill or a failed load', async () => { const missing: RuntimeSkillSource = { ...source, loadSkill: vi.fn().mockResolvedValue(null) }; const active = new ActiveSkills(missing, 'assistant'); diff --git a/packages/@n8n/agents/src/runtime/model/message-list.ts b/packages/@n8n/agents/src/runtime/model/message-list.ts index 7741f935377..59b448a3346 100644 --- a/packages/@n8n/agents/src/runtime/model/message-list.ts +++ b/packages/@n8n/agents/src/runtime/model/message-list.ts @@ -295,6 +295,24 @@ export class AgentMessageList { block.suspension = suspension; } + /** + * Record that a tool programmatically activated a skill on its own result, so + * `ActiveSkills` can re-anchor the skill body there on a later turn instead + * of falling back to the `` system prompt. The stamp rides on + * the persisted message; it is metadata only and never reaches the model + * (`toAiMessages` ignores it). No-op when the tool call is unknown. + */ + stampActivatedSkill(toolCallId: string, skillId: string): void { + const host = this.findToolCallHost(toolCallId); + if (!host) return; + const block = this.findToolCallBlock(host, toolCallId); + if (!block) return; + const current = block.activatedSkillIds ?? []; + if (current.includes(skillId)) return; + block.activatedSkillIds = [...current, skillId]; + this.responseSet.add(host); + } + private findToolCallHost(toolCallId: string): AgentDbMessage | undefined { // Start from the last message and go backwards to find the host message for (let i = this.all.length - 1; i >= 0; i--) { diff --git a/packages/@n8n/agents/src/runtime/skills/active-skills.ts b/packages/@n8n/agents/src/runtime/skills/active-skills.ts index 61e5e0aae1d..6e292302bb1 100644 --- a/packages/@n8n/agents/src/runtime/skills/active-skills.ts +++ b/packages/@n8n/agents/src/runtime/skills/active-skills.ts @@ -1,4 +1,4 @@ -import type { ModelMessage } from 'ai'; +import type { ModelMessage, ToolResultPart } from 'ai'; import { formatActiveSkill } from '../../skills/tools'; import { @@ -11,7 +11,30 @@ import { import type { AgentPersistenceOptions } from '../../types/sdk/agent'; import type { AgentMessageList } from '../model/message-list'; -/** Keeps trusted skill instructions outside the conversation's compaction window. */ +/** + * Keeps trusted skill instructions available without invalidating the cached + * prompt prefix. + * + * A skill body rides on the tool result that activated it (the recorded result + * is collapsed first, so an obsolete persisted body is never replayed). The + * top-level system prompt does not mention the skill, so activating and + * carrying a skill never rewrites the cached prefix — within a run or across + * runs. + * + * The `` block of the system prompt is a recovery path only. + * Anthropic renders the prompt as `tools → system → messages`, so any edit to + * `system` invalidates the tool block and every message after it. The block + * therefore holds only the skills the anchored path cannot deliver on this + * call — their result masked by observation memory, their activating call + * failed, or the activation predates this window with no `load_skill` record: + * + * - Anchor still deliverable: the skill rides on its resolved tool result, so + * `system` is unchanged and the cached prefix stays warm. + * - Anchor gone or unusable: the skill moves into the block so its guidance is + * never lost. When observation memory replaced the history it already rewrote + * the prefix, so the move is free; otherwise it costs one rewrite, the price + * of not dropping an active skill. + */ export class ActiveSkills { private readonly loaded = new Map(); private list?: AgentMessageList; @@ -38,7 +61,11 @@ export class ActiveSkills { list.activeSkillIds === undefined && this.scope ? await this.store?.load(this.scope) : undefined; - const ids = list.activeSkillIds ?? stored ?? [...this.recordedLoads(list).values()]; + const ids = list.activeSkillIds ?? + stored ?? [ + ...this.recordedLoads(list).values(), + ...this.stampedActivations(list).map(([, skillId]) => skillId), + ]; const registered = new Set(this.source.registry.skills.map(({ id }) => id)); for (const id of new Set(ids)) { if (!registered.has(id)) continue; @@ -53,7 +80,10 @@ export class ActiveSkills { if (!storeIsCurrent && (ids.length > 0 || stored !== undefined)) await this.persist(); } - async load(skillId: string): Promise { + async load( + skillId: string, + anchor?: { toolCallId: string }, + ): Promise { if (!this.source.registry.skills.some(({ id }) => id === skillId)) return null; const skill = this.loaded.get(skillId) ?? (await this.source.loadSkill(skillId)); if (!skill) return null; @@ -61,6 +91,9 @@ export class ActiveSkills { if (!this.loaded.has(skillId)) { this.loaded.set(skillId, skill); this.list.activeSkillIds = [...this.loaded.keys()]; + // Stamp the activating tool result so the body re-anchors there on a + // later turn instead of falling back into the system prompt. + if (anchor) this.list.stampActivatedSkill(anchor.toolCallId, skillId); await this.persist(); } return skill; @@ -72,12 +105,19 @@ export class ActiveSkills { ]; } + /** + * The active skills the anchored path cannot deliver on this call, as one + * block for the top-level system prompt. A skill enters the block only when + * it has no successfully resolved, visible tool result to ride on, so a + * still-anchored skill never edits `system` or breaks a warm cache. + */ instructions(): string | undefined { - if (this.loaded.size === 0) return undefined; - const sections = [...this.loaded].flatMap(([id, skill]) => { - const entry = this.source.registry.skills.find((candidate) => candidate.id === id); - return entry ? [formatActiveSkill(skill, entry)] : []; + const { inBlock } = this.placement(); + const sections = inBlock.flatMap((id) => { + const text = this.formatSkill(id); + return text ? [text] : []; }); + if (sections.length === 0) return undefined; return [ '', 'Use these current skill versions when relevant to the task. Earlier tool results may describe older versions.', @@ -86,9 +126,23 @@ export class ActiveSkills { ].join('\n\n'); } - /** Old conversations can contain full skill bodies. Do not replay an obsolete version. */ + /** + * Per-call view of the conversation for the model. + * + * Collapses recorded `load_skill` results to `{ skillId, active }`: old + * conversations can contain full skill bodies, and an obsolete version must + * not be replayed. Skills anchored to a visible tool call then get their + * current body appended to that result, keeping them out of the system + * block for as long as observational memory keeps the result visible. + */ modelMessages(messages: ModelMessage[], list: AgentMessageList): ModelMessage[] { const loads = this.recordedLoads(list); + const { anchors } = this.placement(loads); + const appendices = new Map(); + for (const [skillId, toolCallId] of anchors) { + const text = this.formatSkill(skillId); + if (text) appendices.set(toolCallId, [...(appendices.get(toolCallId) ?? []), text]); + } return messages.map((message) => { if (message.role !== 'tool') return message; return { @@ -96,19 +150,105 @@ export class ActiveSkills { content: message.content.map((part) => { if (part.type !== 'tool-result') return part; const skillId = loads.get(part.toolCallId); - if (!skillId) return part; - return { - ...part, - output: { - type: 'json' as const, - value: { skillId, active: this.loaded.has(skillId) }, - }, - }; + const collapsed: ToolResultPart = skillId + ? { + ...part, + output: { + type: 'json' as const, + value: { skillId, active: this.loaded.has(skillId) }, + }, + } + : part; + const texts = appendices.get(part.toolCallId); + return texts ? appendToolResultText(collapsed, texts) : collapsed; }), }; }); } + /** + * Where each active skill is delivered on this call: anchored to a visible + * tool result, or in the system block. The anchor is the first visible + * recorded `load_skill` call for the skill, else this run's activating + * call. `instructions()` and `modelMessages()` share this so a skill is + * always delivered exactly once. + * + * An anchor must be a **successfully resolved** tool result: the skill body + * is appended to it (see `appendToolResultText`, which skips error outputs), + * so a pending, canceled, or failed call cannot deliver it. A skill with no + * such anchor — its result masked by observation memory, or its activating + * call failed, or the activation predates this window with no `load_skill` + * record — falls back to the `` block, which never drops it. + * The block only edits `system` when the anchored path cannot deliver, so an + * unchanged, still-anchored skill never rewrites the cached prefix. + */ + private placement(loads?: Map): { + anchors: Map; + inBlock: string[]; + } { + const anchors = new Map(); + if (this.list) { + const deliverable = this.deliverableToolResults(); + // `load_skill` results and tool results that stamped a programmatic + // activation are both valid anchors; the stamp is what survives a turn. + const sources: Array<[string, string]> = [ + ...(loads ?? this.recordedLoads(this.list)), + ...this.stampedActivations(this.list), + ]; + for (const [toolCallId, skillId] of sources) { + if (!anchors.has(skillId) && this.loaded.has(skillId) && deliverable.has(toolCallId)) { + anchors.set(skillId, toolCallId); + } + } + } + const inBlock = [...this.loaded.keys()].filter((id) => !anchors.has(id)); + return { anchors, inBlock }; + } + + /** + * `[toolCallId, skillId]` pairs for tool results that stamped a programmatic + * skill activation (see `stampActivatedSkill`). Unlike `load_skill` records, + * these carry activations a tool started itself, so a post-build skill + * re-anchors to its tool result on the next turn instead of the system block. + */ + private stampedActivations(list: AgentMessageList): Array<[string, string]> { + const pairs: Array<[string, string]> = []; + for (const message of list.messages()) { + if (!('content' in message)) continue; + for (const part of message.content) { + if (part.type === 'tool-call' && Array.isArray(part.activatedSkillIds)) { + for (const skillId of part.activatedSkillIds) pairs.push([part.toolCallId, skillId]); + } + } + } + return pairs; + } + + /** + * Tool-call ids whose result is visible and successfully resolved, so a skill + * body can ride on it. Pending, canceled, and failed calls are excluded — a + * skill anchored there would silently vanish, so it belongs in the block. + */ + private deliverableToolResults(): Set { + const ids = new Set(); + if (!this.list) return ids; + for (const message of this.list.llmVisibleMessages()) { + if (!('content' in message)) continue; + for (const part of message.content) { + if (part.type === 'tool-call' && part.state === 'resolved' && !part.canceled) { + ids.add(part.toolCallId); + } + } + } + return ids; + } + + private formatSkill(skillId: string): string | undefined { + const skill = this.loaded.get(skillId); + const entry = this.source.registry.skills.find((candidate) => candidate.id === skillId); + return skill && entry ? formatActiveSkill(skill, entry) : undefined; + } + private recordedLoads(list: AgentMessageList): Map { const loads = new Map(); for (const message of list.messages()) { @@ -154,3 +294,32 @@ export class ActiveSkills { await save; } } + +/** + * Append trusted text parts after a tool result's own output. Text and JSON + * outputs become content parts so the skill text is a separate block from the + * (possibly untrusted-wrapped) tool output. Error outputs are left alone. + */ +function appendToolResultText(part: ToolResultPart, texts: string[]): ToolResultPart { + const extra = texts.map((text) => ({ type: 'text' as const, text })); + const { output } = part; + switch (output.type) { + case 'content': + return { ...part, output: { type: 'content', value: [...output.value, ...extra] } }; + case 'text': + return { + ...part, + output: { type: 'content', value: [{ type: 'text', text: output.value }, ...extra] }, + }; + case 'json': + return { + ...part, + output: { + type: 'content', + value: [{ type: 'text', text: JSON.stringify(output.value) }, ...extra], + }, + }; + default: + return part; + } +} diff --git a/packages/@n8n/agents/src/runtime/tools/tool-adapter.ts b/packages/@n8n/agents/src/runtime/tools/tool-adapter.ts index 6197c7f8538..41fe09bac1f 100644 --- a/packages/@n8n/agents/src/runtime/tools/tool-adapter.ts +++ b/packages/@n8n/agents/src/runtime/tools/tool-adapter.ts @@ -117,6 +117,15 @@ export async function executeTool( throw new Error(`No handler found for tool "${builtTool.name}"`); } + // Anchor programmatic skill activations to the calling tool's result by + // default, so the skill body rides on that result (kept out of `system`) + // instead of forcing a full prompt-prefix rewrite. An explicit anchor wins. + const baseLoadSkill = executionContext.loadSkill; + const loadSkill: ToolExecutionContext['loadSkill'] = baseLoadSkill + ? async (skillId, anchor) => + await baseLoadSkill(skillId, anchor ?? (toolCallId ? { toolCallId } : undefined)) + : undefined; + if (builtTool.suspendSchema) { const isCancelled = isCancellation(resumeData); const ctx: InterruptibleToolContext = { @@ -139,7 +148,7 @@ export async function executeTool( toolCallId, toolName: builtTool.name, runId: executionContext.runId, - ...(executionContext.loadSkill ? { loadSkill: executionContext.loadSkill } : {}), + ...(loadSkill ? { loadSkill } : {}), persistence: executionContext.persistence, emitEvent: executionContext.emitEvent, abortSignal: executionContext.abortSignal, @@ -156,7 +165,7 @@ export async function executeTool( toolCallId, toolName: builtTool.name, runId: executionContext.runId, - ...(executionContext.loadSkill ? { loadSkill: executionContext.loadSkill } : {}), + ...(loadSkill ? { loadSkill } : {}), persistence: executionContext.persistence, emitEvent: executionContext.emitEvent, abortSignal: executionContext.abortSignal, diff --git a/packages/@n8n/agents/src/skills/types.ts b/packages/@n8n/agents/src/skills/types.ts index e8d872153e7..b1ce440105c 100644 --- a/packages/@n8n/agents/src/skills/types.ts +++ b/packages/@n8n/agents/src/skills/types.ts @@ -137,7 +137,15 @@ export interface RuntimeSkillContent extends RuntimeSkillIndexEntry { linkedFiles?: RuntimeSkillLinkedFiles; } -export type RuntimeSkillLoader = (skillId: string) => Promise; +export type RuntimeSkillLoader = ( + skillId: string, + /** + * Tool result the activation rides on. The skill body is appended to this + * result so the top-level system prompt stays byte-identical (no cache + * invalidation). Defaults to the calling tool's own result when omitted. + */ + anchor?: { toolCallId: string }, +) => Promise; export interface RuntimeSkillFileContent { skillId: string; diff --git a/packages/@n8n/agents/src/types/sdk/message.ts b/packages/@n8n/agents/src/types/sdk/message.ts index c8e6510c3d5..71eeaebd081 100644 --- a/packages/@n8n/agents/src/types/sdk/message.ts +++ b/packages/@n8n/agents/src/types/sdk/message.ts @@ -133,6 +133,13 @@ export type ContentToolCall = ContentMetadata & { input: JSONValue; providerExecuted?: boolean; + + /** + * Skills this tool activated programmatically (via `ctx.loadSkill`), so the + * skill body can re-anchor to this result on a later turn instead of falling + * back to the system prompt. Metadata only — never sent to the model. + */ + activatedSkillIds?: string[]; } & ( | { state: 'pending'; suspension?: ToolCallSuspensionInfo } | { state: 'resolved'; output: JSONValue; canceled?: boolean } From 4d8c29adbe04b81226e9158afe5eefbf5af61a06 Mon Sep 17 00:00:00 2001 From: Veljko Terzic Date: Tue, 22 Sep 2026 13:10:30 +0000 Subject: [PATCH 04/29] feat(core): Run the agent checkpoint pruning as a durable system task (no-changelog) (#39269) --- .../agent-checkpoint-pruning.task.test.ts | 2 +- .../agents/agent-checkpoint-pruning.task.ts | 2 +- ...heckpoint-pruning.task.integration.test.ts | 197 ++++++++++++++++++ 3 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 packages/cli/test/integration/agents/agent-checkpoint-pruning.task.integration.test.ts diff --git a/packages/cli/src/modules/agents/__tests__/agent-checkpoint-pruning.task.test.ts b/packages/cli/src/modules/agents/__tests__/agent-checkpoint-pruning.task.test.ts index f6d047dd26f..9a9f9e055c5 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-checkpoint-pruning.task.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-checkpoint-pruning.task.test.ts @@ -11,7 +11,7 @@ describe('AgentCheckpointPruningTask', () => { expect(task.name).toBe('agent-checkpoint-pruning'); expect(task.schedule).toEqual({ kind: 'interval', intervalSeconds: 3600 }); expect(task.effects).toBe('idempotent'); - expect(task.durable).toBe(false); + expect(task.durable).toBe(true); expect(task.runOnTakeover).toBe(true); expect(task.retryDelaySeconds).toBe(30); }); diff --git a/packages/cli/src/modules/agents/agent-checkpoint-pruning.task.ts b/packages/cli/src/modules/agents/agent-checkpoint-pruning.task.ts index 572ffa3bd1a..b6578f9039e 100644 --- a/packages/cli/src/modules/agents/agent-checkpoint-pruning.task.ts +++ b/packages/cli/src/modules/agents/agent-checkpoint-pruning.task.ts @@ -19,7 +19,7 @@ export class AgentCheckpointPruningTask implements SystemTask { readonly effects: SystemTaskEffects = 'idempotent'; - readonly durable = false; + readonly durable = true; readonly runOnTakeover = true; diff --git a/packages/cli/test/integration/agents/agent-checkpoint-pruning.task.integration.test.ts b/packages/cli/test/integration/agents/agent-checkpoint-pruning.task.integration.test.ts new file mode 100644 index 00000000000..e48b692b136 --- /dev/null +++ b/packages/cli/test/integration/agents/agent-checkpoint-pruning.task.integration.test.ts @@ -0,0 +1,197 @@ +import type { SerializableAgentState } from '@n8n/agents'; +import type { Logger } from '@n8n/backend-common'; +import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils'; +import { AgentsConfig } from '@n8n/config'; +import { Time } from '@n8n/constants'; +import { DbConnectionOptions } from '@n8n/db'; +import { Container } from '@n8n/di'; +import type { QueryRunner } from '@n8n/typeorm'; +import { DataSource, IsNull, Not } from '@n8n/typeorm'; +import { sleep } from '@n8n/utils/sleep'; +import { v4 as uuid } from 'uuid'; +import { mock } from 'vitest-mock-extended'; + +import { AgentCheckpointPruningTask } from '@/modules/agents/agent-checkpoint-pruning.task'; +import type { Agent } from '@/modules/agents/entities/agent.entity'; +import { N8NCheckpointStorage } from '@/modules/agents/integrations/n8n-checkpoint-storage'; +import { AgentCheckpointRepository } from '@/modules/agents/repositories/agent-checkpoint.repository'; +import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; + +const suspendedState: SerializableAgentState = { + status: 'suspended', + persistence: { threadId: 'thread-1', resourceId: 'resource-1' }, + messageList: { messages: [], historyIds: [], inputIds: [], responseIds: [] }, + pendingToolCalls: {}, +}; +const suspendedJson = JSON.stringify(suspendedState); +const runningJson = JSON.stringify({ ...suspendedState, status: 'running' }); + +const RESUMED = { expired: false, state: runningJson }; +const PRUNED = { expired: true, state: null }; + +const isPostgres = process.env.DB_TYPE === 'postgresdb'; + +describe('AgentCheckpointPruningTask', () => { + const logger = mock({ scoped: vi.fn().mockReturnThis() }); + let agentRepository: AgentRepository; + let checkpointRepository: AgentCheckpointRepository; + let config: AgentsConfig; + let storage: N8NCheckpointStorage; + let task: AgentCheckpointPruningTask; + let agentId: string; + let stale: Date; + + beforeAll(async () => { + await testModules.loadModules(['agents']); + await testDb.init(); + agentRepository = Container.get(AgentRepository); + checkpointRepository = Container.get(AgentCheckpointRepository); + config = Container.get(AgentsConfig); + storage = new N8NCheckpointStorage(checkpointRepository, logger, config); + task = new AgentCheckpointPruningTask(storage); + stale = new Date(Date.now() - (config.checkpointTtlSeconds + Time.hours.toSeconds) * 1000); + }); + + beforeEach(async () => { + logger.info.mockClear(); + const project = await createTeamProject(); + const agent = agentRepository.create({ + id: uuid(), + name: 'Test Agent', + projectId: project.id, + schema: { name: 'Test Agent', model: 'm', instructions: 'i' }, + integrations: [], + tools: {}, + skills: {}, + versionId: 'version-1', + activeVersionId: null, + } as Partial); + agentId = (await agentRepository.save(agent)).id; + }); + + afterEach(async () => { + await checkpointRepository.delete({}); + await agentRepository.delete({}); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + async function insertCheckpoints( + prefix: string, + count: number, + updatedAt: Date, + ): Promise { + const runIds = Array.from({ length: count }, (_, i) => `${prefix}-${i}`); + for (let start = 0; start < runIds.length; start += 500) { + await checkpointRepository.insert( + runIds.slice(start, start + 500).map((runId) => ({ + runId, + agentId, + state: suspendedJson, + expired: false, + createdAt: updatedAt, + updatedAt, + })), + ); + } + return runIds; + } + + async function counts(): Promise<{ expired: number; open: number }> { + return { + expired: await checkpointRepository.count({ where: { expired: true, state: IsNull() } }), + open: await checkpointRepository.count({ where: { expired: false, state: Not(IsNull()) } }), + }; + } + + function expiredTotal(): number { + return logger.info.mock.calls + .filter(([message]) => message === 'Marked stale agent checkpoints as expired') + .reduce((sum, [, meta]) => sum + (meta as { count: number }).count, 0); + } + + it('should expire each stale checkpoint once across repeated runs', async () => { + // Arrange: 3000 checkpoints past the TTL and 200 inside it. + await insertCheckpoints('stale', 3000, stale); + await insertCheckpoints('fresh', 200, new Date()); + + // Act: four prune runs at once. The pool may serialize them, so this is + // the idempotency check; the two-connection tests below cover the race. + await Promise.all(Array.from({ length: 4 }, async () => await task.run())); + + // Assert: the stale rows are expired, the fresh rows are untouched, and the + // logged counts sum to the stale rows, so no run expired a row twice. + expect(await counts()).toEqual({ expired: 3000, open: 200 }); + expect(expiredTotal()).toBe(3000); + }); + + // A second connection holds one side of the race open in a transaction, so the + // other side has to wait on the row lock like it would across two mains. + describe.skipIf(!isPostgres)('across two connections', () => { + let otherConnection: DataSource; + let otherRunner: QueryRunner; + let otherStorage: N8NCheckpointStorage; + + beforeAll(async () => { + otherConnection = new DataSource(Container.get(DbConnectionOptions).getOptions()); + await otherConnection.initialize(); + }); + + beforeEach(async () => { + otherRunner = otherConnection.createQueryRunner(); + await otherRunner.startTransaction(); + const repository = new AgentCheckpointRepository({ + manager: otherRunner.manager, + } as DataSource); + otherStorage = new N8NCheckpointStorage(repository, logger, config); + }); + + afterEach(async () => { + if (otherRunner.isTransactionActive) await otherRunner.rollbackTransaction(); + await otherRunner.release(); + }); + + afterAll(async () => { + await otherConnection.destroy(); + }); + + async function isPending(promise: Promise): Promise { + return await Promise.race([promise.then(() => false), sleep(250).then(() => true)]); + } + + it('should block a resume behind an uncommitted prune and then reject it', async () => { + // Arrange: the other connection prunes the row but does not commit. + const [runId] = await insertCheckpoints('stale', 1, stale); + await new AgentCheckpointPruningTask(otherStorage).run(); + + // Act: a resume on this connection waits on the row lock until the commit. + const claim = storage.claimForResume(runId, suspendedState, agentId); + expect(await isPending(claim)).toBe(true); + await otherRunner.commitTransaction(); + + // Assert: the resume sees the expired row and loses. + expect(await claim).toBe(false); + expect(await checkpointRepository.findByRunId(runId)).toMatchObject(PRUNED); + }); + + it('should block a prune behind an uncommitted resume and then skip the resumed row', async () => { + // Arrange: the other connection resumes one of two stale rows but does not commit. + const [resumed, untouched] = await insertCheckpoints('stale', 2, stale); + expect(await otherStorage.claimForResume(resumed, suspendedState, agentId)).toBe(true); + + // Act: a prune on this connection waits on the row lock until the commit. + const prune = task.run(); + expect(await isPending(prune)).toBe(true); + await otherRunner.commitTransaction(); + await prune; + + // Assert: the prune re-reads the row after the lock, sees the fresh + // `updatedAt`, and expires only the other row. + expect(expiredTotal()).toBe(1); + expect(await checkpointRepository.findByRunId(resumed)).toMatchObject(RESUMED); + expect(await checkpointRepository.findByRunId(untouched)).toMatchObject(PRUNED); + }); + }); +}); From fc68bb6a8f1bbfb521e2f3bbaf06ec0cf1bbc28c Mon Sep 17 00:00:00 2001 From: MHMD <45968454+dou-mhmd@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:22:12 +0000 Subject: [PATCH 05/29] feat(core): Add feature flag for granular credential sharing work (#39293) --- .../__tests__/credential-sharing.test.ts | 34 +++++++++++++++++++ .../cli/src/constants/credential-sharing.ts | 10 ++++++ 2 files changed, 44 insertions(+) create mode 100644 packages/cli/src/constants/__tests__/credential-sharing.test.ts create mode 100644 packages/cli/src/constants/credential-sharing.ts diff --git a/packages/cli/src/constants/__tests__/credential-sharing.test.ts b/packages/cli/src/constants/__tests__/credential-sharing.test.ts new file mode 100644 index 00000000000..079694939fd --- /dev/null +++ b/packages/cli/src/constants/__tests__/credential-sharing.test.ts @@ -0,0 +1,34 @@ +import { isCredSharingEnabled } from '@/constants/credential-sharing'; + +describe('isCredSharingEnabled', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('should return true when N8N_ENV_FEAT_CRED_SHARING is "true"', () => { + process.env.N8N_ENV_FEAT_CRED_SHARING = 'true'; + + expect(isCredSharingEnabled()).toBe(true); + }); + + it('should return false when N8N_ENV_FEAT_CRED_SHARING is unset', () => { + delete process.env.N8N_ENV_FEAT_CRED_SHARING; + + expect(isCredSharingEnabled()).toBe(false); + }); + + it.each(['false', 'TRUE', '1', ''])( + 'should return false when N8N_ENV_FEAT_CRED_SHARING is %p', + (value) => { + process.env.N8N_ENV_FEAT_CRED_SHARING = value; + + expect(isCredSharingEnabled()).toBe(false); + }, + ); +}); diff --git a/packages/cli/src/constants/credential-sharing.ts b/packages/cli/src/constants/credential-sharing.ts new file mode 100644 index 00000000000..46e3e8b5363 --- /dev/null +++ b/packages/cli/src/constants/credential-sharing.ts @@ -0,0 +1,10 @@ +import { isEnvFeatureEnabled } from '@n8n/backend-common'; + +/** + * More granular credential sharing (personal-space credentials usable in any + * project their owner works in, the can-see/can-use split, and related + * behavior) is opt-in while it lands on master in incremental pieces. + */ +export function isCredSharingEnabled(): boolean { + return isEnvFeatureEnabled('N8N_ENV_FEAT_CRED_SHARING'); +} From cae5062e3a15073cc7f94370d23e4ee0277b71d1 Mon Sep 17 00:00:00 2001 From: "n8n-cat-bot[bot]" <283985454+n8n-cat-bot[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:23:51 +0000 Subject: [PATCH 06/29] refactor(API): Migrate GET /discover to the decorator pattern (no-changelog) (#39162) Co-authored-by: n8n-cat-bot[bot] Co-authored-by: Uddish Verma Co-authored-by: Claude Fable 5.1 --- .../src/dto/discover/discover-public.dto.ts | 59 ++++++++ .../dto/discover/discover-public.openapi.ts | 28 ++++ packages/@n8n/api-types/src/dto/index.ts | 7 + packages/cli/eslint.config.mjs | 1 - .../controllers/discover.public.controller.ts | 44 ++++++ .../src/public-api/v1/controllers/index.ts | 1 + .../__tests__/discover.handler.test.ts | 127 ------------------ .../v1/handlers/discover/discover.handler.ts | 44 ------ .../v1/handlers/discover/discover.service.ts | 18 +-- .../handlers/discover/spec/paths/discover.yml | 97 ------------- .../spec/paths/getDiscover.generated.yml | 104 ++++++++++++++ .../v1/openapi.decorator-routes.generated.yml | 3 + packages/cli/src/public-api/v1/openapi.yml | 2 - .../integration/public-api/discover.test.ts | 60 +++++++++ 14 files changed, 309 insertions(+), 286 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/discover/discover-public.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/discover/discover-public.openapi.ts create mode 100644 packages/cli/src/public-api/v1/controllers/discover.public.controller.ts delete mode 100644 packages/cli/src/public-api/v1/handlers/discover/__tests__/discover.handler.test.ts delete mode 100644 packages/cli/src/public-api/v1/handlers/discover/discover.handler.ts delete mode 100644 packages/cli/src/public-api/v1/handlers/discover/spec/paths/discover.yml create mode 100644 packages/cli/src/public-api/v1/handlers/discover/spec/paths/getDiscover.generated.yml diff --git a/packages/@n8n/api-types/src/dto/discover/discover-public.dto.ts b/packages/@n8n/api-types/src/dto/discover/discover-public.dto.ts new file mode 100644 index 00000000000..06860cc0679 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/discover/discover-public.dto.ts @@ -0,0 +1,59 @@ +import '../../openapi-extend'; + +import { z } from 'zod'; + +import { discoverQueryFieldDocs, discoverResponseFieldDocs } from './discover-public.openapi'; +import { Z } from '../../zod-class'; + +const discoverEndpointPublicSchema = z.object({ + method: z.string(), + path: z.string(), + operationId: z.string(), + requestSchema: z + .record(z.string(), z.unknown()) + .optional() + .openapi(discoverResponseFieldDocs.requestSchema), +}); + +const discoverResourcePublicSchema = z.object({ + operations: z.array(z.string()), + endpoints: z.array(discoverEndpointPublicSchema), +}); + +const discoverFilterPublicSchema = z.object({ + description: z.string(), + values: z.array(z.string()), +}); + +export const discoverDataPublicSchema = z.object({ + scopes: z.array(z.string()).openapi(discoverResponseFieldDocs.scopes), + resources: z.record(z.string(), discoverResourcePublicSchema), + filters: z + .record(z.string(), discoverFilterPublicSchema) + .openapi(discoverResponseFieldDocs.filters), + specUrl: z.string().openapi(discoverResponseFieldDocs.specUrl), +}); + +export type DiscoverDataPublic = z.infer; + +export class DiscoverPublicDto extends Z.class({ + data: discoverDataPublicSchema, +}) {} + +const DISCOVER_INCLUDE_VALUES = ['schemas'] as const; + +// Strict on purpose: the legacy validator answered 400 to an unknown query parameter on this +// route, and a client that relies on that must keep getting it. The list DTOs are not strict. +export class DiscoverQueryPublicDto extends Z.class( + { + include: z + .enum(DISCOVER_INCLUDE_VALUES, { + message: `must be equal to one of the allowed values: ${DISCOVER_INCLUDE_VALUES.join(', ')}`, + }) + .optional() + .openapi(discoverQueryFieldDocs.include), + resource: z.string().optional().openapi(discoverQueryFieldDocs.resource), + operation: z.string().optional().openapi(discoverQueryFieldDocs.operation), + }, + { strict: true }, +) {} diff --git a/packages/@n8n/api-types/src/dto/discover/discover-public.openapi.ts b/packages/@n8n/api-types/src/dto/discover/discover-public.openapi.ts new file mode 100644 index 00000000000..2acdcf9c18b --- /dev/null +++ b/packages/@n8n/api-types/src/dto/discover/discover-public.openapi.ts @@ -0,0 +1,28 @@ +import type { ZodOpenAPIMetadata } from '@asteasolutions/zod-to-openapi'; + +export const discoverQueryFieldDocs = { + include: { + description: + 'Include additional data. Use "schemas" to inline request body schemas per endpoint, eliminating the need to fetch the full OpenAPI spec.', + }, + resource: { + description: 'Filter to a specific resource (e.g. "workflow", "tags", "credential").', + }, + operation: { + description: 'Filter to endpoints with a specific operation (e.g. "read", "create", "list").', + }, +} as const satisfies Record; + +export const discoverResponseFieldDocs = { + scopes: { description: "The API key's active scopes" }, + requestSchema: { + type: 'object', + description: + 'Request body schema (only present when include=schemas and the endpoint accepts a request body).', + }, + filters: { + description: + "Available query parameter filters. The values arrays reflect what the caller's scopes permit.", + }, + specUrl: { description: 'URL to the full OpenAPI specification' }, +} as const satisfies Record; diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index c5c6b642baa..7ac7bf47453 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -40,6 +40,13 @@ export { InstanceAiMcpUpdateConnectionRequestDto } from './instance-ai/instance- export { BinaryDataQueryDto } from './binary-data/binary-data-query.dto'; export { BinaryDataSignedQueryDto } from './binary-data/binary-data-signed-query.dto'; +export { + discoverDataPublicSchema, + DiscoverPublicDto, + DiscoverQueryPublicDto, +} from './discover/discover-public.dto'; +export type { DiscoverDataPublic } from './discover/discover-public.dto'; + export { BreakingChangeReportQueryDto } from './breaking-changes/breaking-change-report-query.dto'; export { LoginRequestDto } from './auth/login-request.dto'; diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index f925c739187..641e95f95dd 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -151,7 +151,6 @@ export default defineConfig( './src/public-api/v1/handlers/data-tables/data-tables.columns.handler.ts', './src/public-api/v1/handlers/data-tables/data-tables.handler.ts', './src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts', - './src/public-api/v1/handlers/discover/discover.handler.ts', './src/public-api/v1/handlers/evaluations/evaluations.handler.ts', './src/public-api/v1/handlers/folders/folders.handler.ts', './src/public-api/v1/handlers/ldap/ldap.handler.ts', diff --git a/packages/cli/src/public-api/v1/controllers/discover.public.controller.ts b/packages/cli/src/public-api/v1/controllers/discover.public.controller.ts new file mode 100644 index 00000000000..fe2c9a02d85 --- /dev/null +++ b/packages/cli/src/public-api/v1/controllers/discover.public.controller.ts @@ -0,0 +1,44 @@ +import { DiscoverPublicDto, DiscoverQueryPublicDto } from '@n8n/api-types'; +import type { AuthenticatedRequest } from '@n8n/db'; +import { + ApiDescription, + ApiResponse, + ApiSummary, + ApiTags, + Get, + PublicApiController, + Query, +} from '@n8n/decorators'; +import type { Response } from 'express'; + +import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error'; +import { buildDiscoverResponse } from '@/public-api/v1/handlers/discover/discover.service'; + +@PublicApiController('/discover') +export class DiscoverPublicController { + @Get('/') + @ApiSummary('Discover available API capabilities') + @ApiDescription( + "Returns a filtered capability map based on the caller's API key scopes. Each resource includes the operations and endpoints accessible to the authenticated API key. Use query parameters to narrow the response.", + ) + @ApiTags(['Discover']) + @ApiResponse(200, DiscoverPublicDto) + async getDiscover( + req: AuthenticatedRequest, + _res: Response, + @Query query: DiscoverQueryPublicDto, + ): Promise { + const scopes = req.tokenGrant?.apiKeyScopes; + if (!scopes) { + throw new UnauthenticatedError('Unauthorized'); + } + + return { + data: await buildDiscoverResponse(scopes, { + includeSchemas: query.include === 'schemas', + resource: query.resource, + operation: query.operation, + }), + }; + } +} diff --git a/packages/cli/src/public-api/v1/controllers/index.ts b/packages/cli/src/public-api/v1/controllers/index.ts index 978c30b160d..a40fe97e878 100644 --- a/packages/cli/src/public-api/v1/controllers/index.ts +++ b/packages/cli/src/public-api/v1/controllers/index.ts @@ -4,6 +4,7 @@ * scope-parity / discover run. */ import './credentials.public.controller'; +import './discover.public.controller'; import './executions.public.controller'; import './folders.public.controller'; import './insights.public.controller'; diff --git a/packages/cli/src/public-api/v1/handlers/discover/__tests__/discover.handler.test.ts b/packages/cli/src/public-api/v1/handlers/discover/__tests__/discover.handler.test.ts deleted file mode 100644 index 12b1b02a39a..00000000000 --- a/packages/cli/src/public-api/v1/handlers/discover/__tests__/discover.handler.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { AuthenticatedRequest } from '@n8n/db'; -import type { Response } from 'express'; - -import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error'; - -import * as discoverService from '../discover.service'; - -// Loaded dynamically (handler routes are arrays of middleware + handler) and -// typed loosely so the suite can invoke individual entries by index. -let handler: Record unknown>>; - -beforeAll(async () => { - handler = (await import('../discover.handler.js')) as unknown as typeof handler; -}); - -describe('Discover Handler', () => { - let mockResponse: Partial; - - beforeEach(() => { - vi.clearAllMocks(); - - mockResponse = { - json: vi.fn().mockReturnThis(), - }; - }); - - function makeRequest( - query: { include?: string; resource?: string; operation?: string } = {}, - apiKeyScopes?: string[], - ) { - return { - query, - tokenGrant: apiKeyScopes ? { apiKeyScopes } : undefined, - } as unknown as AuthenticatedRequest; - } - - it('should throw UnauthenticatedError when tokenGrant is missing', async () => { - const req = makeRequest(); - - const handlerFn = handler.getDiscover[0]; - let caught: unknown; - try { - await handlerFn(req, mockResponse as Response); - } catch (error) { - caught = error; - } - expect(caught).toBeInstanceOf(UnauthenticatedError); - expect(caught).toMatchObject({ message: 'Unauthorized', httpStatusCode: 401 }); - }); - - it('should return discover data when tokenGrant has scopes', async () => { - const scopes = ['tag:list', 'tag:create']; - - const mockDiscoverResponse = { - scopes, - resources: { - tags: { - operations: ['list', 'create'], - endpoints: [ - { method: 'GET', path: '/api/v1/tags', operationId: 'getTags' }, - { method: 'POST', path: '/api/v1/tags', operationId: 'createTag' }, - ], - }, - }, - filters: {}, - specUrl: '/api/v1/openapi.yml', - }; - - vi.spyOn(discoverService, 'buildDiscoverResponse').mockResolvedValue(mockDiscoverResponse); - - const req = makeRequest({}, scopes); - - const handlerFn = handler.getDiscover[0]; - await handlerFn(req, mockResponse); - - expect(mockResponse.json).toHaveBeenCalledWith({ data: mockDiscoverResponse }); - expect(discoverService.buildDiscoverResponse).toHaveBeenCalledWith(scopes, { - includeSchemas: false, - resource: undefined, - operation: undefined, - }); - }); - - it('should pass includeSchemas true when query include is schemas', async () => { - const scopes = ['tag:list']; - - vi.spyOn(discoverService, 'buildDiscoverResponse').mockResolvedValue({ - scopes, - resources: {}, - filters: {}, - specUrl: '/api/v1/openapi.yml', - }); - - const req = makeRequest({ include: 'schemas' }, scopes); - - const handlerFn = handler.getDiscover[0]; - await handlerFn(req, mockResponse); - - expect(discoverService.buildDiscoverResponse).toHaveBeenCalledWith(scopes, { - includeSchemas: true, - resource: undefined, - operation: undefined, - }); - }); - - it('should pass resource and operation query params to service', async () => { - const scopes = ['workflow:create']; - - vi.spyOn(discoverService, 'buildDiscoverResponse').mockResolvedValue({ - scopes, - resources: {}, - filters: {}, - specUrl: '/api/v1/openapi.yml', - }); - - const req = makeRequest({ resource: 'workflow', operation: 'create' }, scopes); - - const handlerFn = handler.getDiscover[0]; - await handlerFn(req, mockResponse); - - expect(discoverService.buildDiscoverResponse).toHaveBeenCalledWith(scopes, { - includeSchemas: false, - resource: 'workflow', - operation: 'create', - }); - }); -}); diff --git a/packages/cli/src/public-api/v1/handlers/discover/discover.handler.ts b/packages/cli/src/public-api/v1/handlers/discover/discover.handler.ts deleted file mode 100644 index 4c07e8dafdd..00000000000 --- a/packages/cli/src/public-api/v1/handlers/discover/discover.handler.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { AuthenticatedRequest } from '@n8n/db'; - -import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error'; - -import { buildDiscoverResponse } from './discover.service'; -import type { PublicAPIEndpoint } from '../../shared/handler.types'; - -type GetDiscoverRequest = AuthenticatedRequest< - {}, - {}, - {}, - { include?: string; resource?: string; operation?: string } ->; - -function firstString(value: unknown): string | undefined { - if (typeof value === 'string') return value; - if (Array.isArray(value) && typeof value[0] === 'string') return value[0]; - return undefined; -} - -type DiscoverHandlers = { - getDiscover: PublicAPIEndpoint; -}; - -const discoverHandlers: DiscoverHandlers = { - getDiscover: [ - async (req, res) => { - const scopes = req.tokenGrant?.apiKeyScopes; - if (!scopes) { - throw new UnauthenticatedError('Unauthorized'); - } - - const includeSchemas = req.query.include === 'schemas'; - const response = await buildDiscoverResponse(scopes, { - includeSchemas, - resource: firstString(req.query.resource), - operation: firstString(req.query.operation), - }); - return res.json({ data: response }); - }, - ], -}; - -export = discoverHandlers; diff --git a/packages/cli/src/public-api/v1/handlers/discover/discover.service.ts b/packages/cli/src/public-api/v1/handlers/discover/discover.service.ts index 8b8018b2740..c36d371e9db 100644 --- a/packages/cli/src/public-api/v1/handlers/discover/discover.service.ts +++ b/packages/cli/src/public-api/v1/handlers/discover/discover.service.ts @@ -1,4 +1,5 @@ import RefParser from '@apidevtools/json-schema-ref-parser'; +import type { DiscoverDataPublic } from '@n8n/api-types'; import type { ApiKeyScopeRequirement } from '@n8n/decorators'; import { isRecord } from '@n8n/utils/is-record'; import path from 'path'; @@ -37,19 +38,6 @@ interface ResourceInfo { endpoints: EndpointEntry[]; } -interface FilterInfo { - description: string; - example?: string; - values?: string[]; -} - -export interface DiscoverResponse { - scopes: readonly string[]; - resources: Record; - filters: Record; - specUrl: string; -} - export interface DiscoverOptions { includeSchemas?: boolean; resource?: string; @@ -158,7 +146,7 @@ function buildDecoratorEndpoints(): EndpointInfo[] { export async function buildDiscoverResponse( callerScopes: readonly string[], options?: DiscoverOptions, -): Promise { +): Promise { const allEndpoints = await parseEndpointsFromSpec(); const includeSchemas = options?.includeSchemas === true; @@ -239,7 +227,7 @@ export async function buildDiscoverResponse( const allOperations = [...new Set(Object.values(resources).flatMap((r) => r.operations))]; return { - scopes: callerScopes, + scopes: [...callerScopes], resources: filteredResources, filters: { resource: { diff --git a/packages/cli/src/public-api/v1/handlers/discover/spec/paths/discover.yml b/packages/cli/src/public-api/v1/handlers/discover/spec/paths/discover.yml deleted file mode 100644 index 6edac69b8a7..00000000000 --- a/packages/cli/src/public-api/v1/handlers/discover/spec/paths/discover.yml +++ /dev/null @@ -1,97 +0,0 @@ -get: - x-eov-operation-id: getDiscover - x-required-scope: none - x-eov-operation-handler: v1/handlers/discover/discover.handler - tags: - - Discover - summary: Discover available API capabilities - description: > - Returns a filtered capability map based on the caller's API key scopes. - Each resource includes the operations and endpoints accessible to the - authenticated API key. Use query parameters to narrow the response. - parameters: - - name: include - in: query - required: false - schema: - type: string - enum: - - schemas - description: > - Include additional data. Use "schemas" to inline request body schemas - per endpoint, eliminating the need to fetch the full OpenAPI spec. - - name: resource - in: query - required: false - schema: - type: string - description: > - Filter to a specific resource (e.g. "workflow", "tags", "credential"). - - name: operation - in: query - required: false - schema: - type: string - description: > - Filter to endpoints with a specific operation (e.g. "read", "create", "list"). - responses: - '200': - description: Discovery response with available resources and endpoints. - content: - application/json: - schema: - type: object - properties: - data: - type: object - properties: - scopes: - type: array - items: - type: string - description: The API key's active scopes - resources: - type: object - additionalProperties: - type: object - properties: - operations: - type: array - items: - type: string - endpoints: - type: array - items: - type: object - properties: - method: - type: string - path: - type: string - operationId: - type: string - requestSchema: - type: object - description: > - Request body schema (only present when - include=schemas and the endpoint accepts a - request body). - filters: - type: object - description: > - Available query parameter filters. The values arrays - reflect what the caller's scopes permit. - additionalProperties: - type: object - properties: - description: - type: string - values: - type: array - items: - type: string - specUrl: - type: string - description: URL to the full OpenAPI specification - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' diff --git a/packages/cli/src/public-api/v1/handlers/discover/spec/paths/getDiscover.generated.yml b/packages/cli/src/public-api/v1/handlers/discover/spec/paths/getDiscover.generated.yml new file mode 100644 index 00000000000..283f33487e3 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/discover/spec/paths/getDiscover.generated.yml @@ -0,0 +1,104 @@ +operationId: getDiscover +tags: + - Discover +summary: Discover available API capabilities +description: Returns a filtered capability map based on the caller's API key scopes. Each resource includes the operations and endpoints accessible to the authenticated API key. Use query parameters to narrow the response. +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + enum: + - schemas + description: Include additional data. Use "schemas" to inline request body schemas per endpoint, eliminating the need to fetch the full OpenAPI spec. + required: false + name: include + in: query + - schema: + type: string + description: Filter to a specific resource (e.g. "workflow", "tags", "credential"). + required: false + name: resource + in: query + - schema: + type: string + description: Filter to endpoints with a specific operation (e.g. "read", "create", "list"). + required: false + name: operation + in: query +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + scopes: + type: array + items: + type: string + description: The API key's active scopes + resources: + type: object + additionalProperties: + type: object + properties: + operations: + type: array + items: + type: string + endpoints: + type: array + items: + type: object + properties: + method: + type: string + path: + type: string + operationId: + type: string + requestSchema: + type: object + description: Request body schema (only present when include=schemas and the endpoint accepts a request body). + required: + - method + - path + - operationId + required: + - operations + - endpoints + filters: + type: object + additionalProperties: + type: object + properties: + description: + type: string + values: + type: array + items: + type: string + required: + - description + - values + description: Available query parameter filters. The values arrays reflect what the caller's scopes permit. + specUrl: + type: string + description: URL to the full OpenAPI specification + required: + - scopes + - resources + - filters + - specUrl + required: + - data + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml diff --git a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml index 6ccfc0af39a..59e978b5168 100644 --- a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml +++ b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml @@ -24,6 +24,9 @@ paths: /credentials/schema/{credentialTypeName}: get: $ref: ./handlers/credentials/spec/paths/getCredentialType.generated.yml + /discover: + get: + $ref: ./handlers/discover/spec/paths/getDiscover.generated.yml /executions: get: $ref: ./handlers/executions/spec/paths/getExecutions.generated.yml diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index 1700d395dcc..063a033e390 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -131,8 +131,6 @@ paths: $ref: './handlers/community-packages/spec/paths/community-packages.yml' /community-packages/{name}: $ref: './handlers/community-packages/spec/paths/community-packages.packageName.yml' - /discover: - $ref: './handlers/discover/spec/paths/discover.yml' /projects/{projectId}/folders: $ref: './handlers/folders/spec/paths/folders.yml' /projects/{projectId}/folders/{folderId}: diff --git a/packages/cli/test/integration/public-api/discover.test.ts b/packages/cli/test/integration/public-api/discover.test.ts index 7088e0f4b6f..8f29533f8bb 100644 --- a/packages/cli/test/integration/public-api/discover.test.ts +++ b/packages/cli/test/integration/public-api/discover.test.ts @@ -70,6 +70,24 @@ describe('GET /discover', () => { expect(response.body.data.specUrl).toBe('/api/v1/openapi.yml'); }); + test('should filter discover data by the API key scopes', async () => { + const scopedOwner = await createOwnerWithApiKey({ scopes: ['tag:list'] }); + const response = await testServer.publicApiAgentFor(scopedOwner).get('/discover'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.scopes).toEqual(['tag:list']); + expect( + response.body.data.resources.tags.endpoints.some( + (endpoint: { operationId: string }) => endpoint.operationId === 'getTags', + ), + ).toBe(true); + expect( + response.body.data.resources.tags.endpoints.some( + (endpoint: { operationId: string }) => endpoint.operationId === 'createTag', + ), + ).toBe(false); + }); + test('should return discover data for member', async () => { const response = await authMemberAgent.get('/discover'); @@ -150,4 +168,46 @@ describe('GET /discover', () => { expect(response.statusCode).toBe(200); expect(response.body.data.resources).toEqual({}); }); + + test('should keep only create endpoints with ?operation=create', async () => { + const response = await authOwnerAgent.get('/discover?operation=create'); + expect(response.statusCode).toBe(200); + + const resources = Object.values(response.body.data.resources) as Array<{ + operations: string[]; + }>; + expect(resources.length).toBeGreaterThan(0); + for (const resource of resources) { + expect(resource.operations.map((o) => o.toLowerCase())).toEqual(['create']); + } + }); + + // The legacy validator also answered 400 here: a repeated parameter arrives as an array, + // and the parameter is declared as a string. + test('should return 400 for a repeated query parameter', async () => { + const response = await authOwnerAgent.get('/discover?resource=tags&resource=workflow'); + + expect(response.statusCode).toBe(400); + expect(response.body).toEqual({ + message: 'request/query/resource Expected string, received array', + }); + }); + + test.each([ + [ + 'an invalid include value', + { include: 'invalid' }, + 'request/query/include must be equal to one of the allowed values: schemas', + ], + [ + 'an unknown query parameter', + { unknown: 'value' }, + "request/query Unrecognized key(s) in object: 'unknown'", + ], + ])('should return 400 for %s', async (_name, query, message) => { + const response = await authOwnerAgent.get('/discover').query(query); + + expect(response.statusCode).toBe(400); + expect(response.body).toEqual({ message }); + }); }); From b16b705f5d294f2b03d4d1d6bde4664edd5c7029 Mon Sep 17 00:00:00 2001 From: Yen Su <49794855+yens1@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:24:40 +0000 Subject: [PATCH 07/29] feat(Databricks Node): Add the hidden Databricks Vector Store sub-node (no-changelog) (#38990) --- .../DatabricksVectorStore.test.ts | 12 + .../DatabricksVectorStore.ts | 6 + .../VectorStoreDatabricks.node.test.ts | 474 ++++++++++++++++++ .../VectorStoreDatabricks.node.ts | 296 +++++++++++ packages/@n8n/nodes-langchain/package.json | 1 + .../utils/databricks/auth-fetch.ts | 66 +++ .../utils/databricks/constants.ts | 8 +- .../utils/databricks/gateway-config.ts | 55 +- .../utils/databricks/list-pages.ts | 48 ++ .../utils/databricks/model-services.ts | 38 +- .../utils/databricks/token-provider.ts | 3 +- 11 files changed, 928 insertions(+), 79 deletions(-) create mode 100644 packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/VectorStoreDatabricks.node.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/VectorStoreDatabricks.node.ts create mode 100644 packages/@n8n/nodes-langchain/utils/databricks/auth-fetch.ts create mode 100644 packages/@n8n/nodes-langchain/utils/databricks/list-pages.ts diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/DatabricksVectorStore.test.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/DatabricksVectorStore.test.ts index d1e8f9ebaed..cf2bf247779 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/DatabricksVectorStore.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/DatabricksVectorStore.test.ts @@ -460,6 +460,18 @@ describe('DatabricksVectorStore', () => { ).rejects.toThrow('Failed primary keys: row-7'); }); + it('rejects the primary key as the content column without a request', async () => { + const store = await createStore(directDescribe, { contentColumn: 'id' }); + + await expect(store.addDocuments([{ pageContent: 'hello', metadata: {} }])).rejects.toThrow( + 'primary key', + ); + expect(fetchMock).not.toHaveBeenCalledWith( + expect.stringContaining('upsert-data'), + expect.anything(), + ); + }); + it('rejects a managed Delta Sync index without a request', async () => { const store = await managedStore(); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/DatabricksVectorStore.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/DatabricksVectorStore.ts index 3554ad36023..897f9ebc6cb 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/DatabricksVectorStore.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/DatabricksVectorStore.ts @@ -311,6 +311,12 @@ export class DatabricksVectorStore extends VectorStore { ): Promise { const vectorColumn = this.assertDirectAccess(); const { primaryKey, name } = this.index; + // The row below would write the document text over its own primary key + if (this.contentColumn === primaryKey) { + throw new UserError( + `Column ${primaryKey} is the primary key of ${name}. Select another content column`, + ); + } const schemaColumns = new Set(this.index.schemaColumns ?? []); const ids = documents.map((doc, i) => options?.ids?.[i] ?? doc.id ?? randomUUID()); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/VectorStoreDatabricks.node.test.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/VectorStoreDatabricks.node.test.ts new file mode 100644 index 00000000000..3d64053c23d --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/VectorStoreDatabricks.node.test.ts @@ -0,0 +1,474 @@ +/* eslint-disable n8n-nodes-base/node-param-display-name-miscased */ +/* eslint-disable n8n-nodes-base/node-param-display-name-miscased-id */ +import type { Embeddings } from '@langchain/core/embeddings'; +import { proxyFetch } from '@n8n/ai-utilities'; +import { DATABRICKS_PARTNER_USER_AGENT } from 'n8n-nodes-base/dist/nodes/Databricks/constants'; +import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; +import type { + IDataObject, + IExecuteFunctions, + ILoadOptionsFunctions, + INode, + INodeProperties, + INodeType, + ISupplyDataFunctions, +} from 'n8n-workflow'; +import type { Mocked } from 'vitest'; +import { mock } from 'vitest-mock-extended'; + +import { getDatabricksTokenProvider } from '@utils/databricks/token-provider'; +import { DatabricksVectorStore } from './DatabricksVectorStore'; +import { VectorStoreDatabricks } from './VectorStoreDatabricks.node'; + +vi.mock('./DatabricksVectorStore', () => ({ + DatabricksVectorStore: { fromExistingIndex: vi.fn(), describeIndex: vi.fn() }, +})); +vi.mock('@utils/databricks/token-provider'); +vi.mock('@n8n/ai-utilities', async (importActual) => ({ + ...(await importActual()), + proxyFetch: vi.fn(), +})); + +const mockedFromExistingIndex = vi.mocked(DatabricksVectorStore.fromExistingIndex); +const mockedDescribeIndex = vi.mocked(DatabricksVectorStore.describeIndex); + +const mockCredential = { + host: 'https://ws.example.com/', + grantType: 'clientCredentials', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + allowedHttpRequestDomains: 'all', + allowedDomains: '', +}; + +const nodeDef: INode = { + id: '1', + name: 'Databricks Vector Store', + typeVersion: 1.3, + type: '@n8n/n8n-nodes-langchain.vectorStoreDatabricks', + position: [0, 0], + parameters: {}, +}; + +const baseParams = { + databricksIndex: { mode: 'list', value: 'cat.sch.idx' }, + contentColumn: 'text', + options: {}, +}; + +const indexInfo = { + name: 'cat.sch.idx', + primaryKey: 'id', + indexType: 'DELTA_SYNC' as const, + embedding: { kind: 'managed' as const, sourceColumn: 'text' }, + schemaColumns: ['id', 'text', 'source'], +}; + +describe('VectorStoreDatabricks', () => { + let node: VectorStoreDatabricks; + const methods = new VectorStoreDatabricks().methods as Required< + NonNullable + >; + let embeddings: ReturnType>; + + const setupContext = ( + params: IDataObject, + credentialOverrides: Partial = {}, + ) => { + const ctx = createMockExecuteFunction(params, nodeDef) as Mocked; + ctx.getCredentials = vi.fn().mockResolvedValue({ ...mockCredential, ...credentialOverrides }); + ctx.getInputData = vi.fn().mockReturnValue([{ json: {} }]); + ctx.getInputConnectionData = vi.fn().mockResolvedValue(embeddings); + ctx.logAiEvent = vi.fn(); + return ctx; + }; + + beforeEach(() => { + vi.resetAllMocks(); + node = new VectorStoreDatabricks(); + embeddings = mock(); + mockedDescribeIndex.mockResolvedValue(indexInfo); + vi.mocked(getDatabricksTokenProvider).mockReturnValue({ + getToken: vi.fn(async () => 'test-token'), + expiredStatus: 403, + }); + }); + + describe('description', () => { + it('is a hidden vector store node with the four default modes', () => { + expect(node.description).toMatchObject({ + name: 'vectorStoreDatabricks', + hidden: true, + credentials: [{ name: 'databricksOAuth2Api', required: true }], + }); + const mode = node.description.properties.find((p) => p.name === 'mode'); + expect(mode?.options?.map((option) => 'value' in option && option.value)).toEqual([ + 'load', + 'insert', + 'retrieve', + 'retrieve-as-tool', + ]); + }); + + it('declares the shared fields', () => { + const { properties } = node.description; + const index = properties.find((p) => p.name === 'databricksIndex'); + expect(index?.type).toBe('resourceLocator'); + expect(index?.modes?.[0].typeOptions?.searchListMethod).toBe('searchIndexes'); + + const contentColumn = properties.find((p) => p.name === 'contentColumn'); + expect(contentColumn?.typeOptions).toEqual({ + loadOptionsMethod: 'getIndexColumns', + loadOptionsDependsOn: ['databricksIndex.value'], + }); + + const options = properties.find((p) => p.name === 'options'); + const metadataColumns = (options?.options as INodeProperties[]).find( + (o) => o.name === 'metadataColumns', + ); + expect(metadataColumns?.type).toBe('multiOptions'); + expect(metadataColumns?.typeOptions?.loadOptionsMethod).toBe('getIndexColumns'); + expect(options?.displayOptions?.show?.mode).toEqual(['load', 'retrieve', 'retrieve-as-tool']); + }); + }); + + describe('supplyData in retrieve mode', () => { + it('creates the store from the credential host and the shared fields', async () => { + const store = {}; + mockedFromExistingIndex.mockResolvedValue(store as DatabricksVectorStore); + mockedDescribeIndex.mockResolvedValue(indexInfo); + const ctx = setupContext({ + ...baseParams, + mode: 'retrieve', + options: { + metadataColumns: ['source'], + metadata: { metadataValues: [{ name: 'source', value: 'hr' }] }, + }, + }); + + const result = await node.supplyData.call(ctx, 0); + + expect(mockedFromExistingIndex).toHaveBeenCalledWith(embeddings, { + fetch: expect.any(Function), + host: 'https://ws.example.com', + indexName: 'cat.sch.idx', + contentColumn: 'text', + metadataColumns: ['source'], + filter: { source: 'hr' }, + index: indexInfo, + }); + expect(mockedDescribeIndex).toHaveBeenCalledWith( + expect.any(Function), + 'https://ws.example.com', + 'cat.sch.idx', + ); + expect(result.response).toBeDefined(); + }); + + it('describes the index again after a failed describe in the same run', async () => { + mockedFromExistingIndex.mockResolvedValue({} as DatabricksVectorStore); + mockedDescribeIndex + .mockRejectedValueOnce(new Error('503 Service Unavailable')) + .mockResolvedValueOnce(indexInfo); + const ctx = setupContext({ ...baseParams, mode: 'retrieve' }); + + await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('503'); + await node.supplyData.call(ctx, 0); + + expect(mockedDescribeIndex).toHaveBeenCalledTimes(2); + expect(mockedFromExistingIndex).toHaveBeenCalledTimes(1); + expect(mockedFromExistingIndex).toHaveBeenCalledWith( + embeddings, + expect.objectContaining({ index: indexInfo }), + ); + }); + + it('opens the run again after a failed credential lookup in the same run', async () => { + mockedFromExistingIndex.mockResolvedValue({} as DatabricksVectorStore); + mockedDescribeIndex.mockResolvedValue(indexInfo); + const ctx = setupContext({ ...baseParams, mode: 'retrieve' }); + ctx.getCredentials = vi + .fn() + .mockRejectedValueOnce(new Error('credential store unavailable')) + .mockResolvedValue(mockCredential); + + await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('credential store unavailable'); + await node.supplyData.call(ctx, 0); + + expect(ctx.getCredentials).toHaveBeenCalledTimes(2); + expect(mockedFromExistingIndex).toHaveBeenCalledTimes(1); + }); + + it('rejects an http host before describing the index', async () => { + const ctx = setupContext( + { ...baseParams, mode: 'retrieve' }, + { host: 'http://ws.example.com' }, + ); + + await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('must use https'); + expect(mockedFromExistingIndex).not.toHaveBeenCalled(); + }); + + it('sends the bearer, the partner User-Agent and a cancellable signal through the shared pool', async () => { + mockedFromExistingIndex.mockResolvedValue({} as DatabricksVectorStore); + vi.mocked(proxyFetch).mockResolvedValue(new Response('{}')); + const cancel = new AbortController(); + const ctx = setupContext({ ...baseParams, mode: 'retrieve' }); + ctx.getExecutionCancelSignal = vi.fn().mockReturnValue(cancel.signal); + await node.supplyData.call(ctx, 0); + + const { fetch } = mockedFromExistingIndex.mock.calls[0][1]; + await fetch('https://ws.example.com/x'); + + const [{ input, init, timeoutOptions }] = vi.mocked(proxyFetch).mock.calls[0]; + expect(String(input)).toBe('https://ws.example.com/x'); + const headers = new Headers(init?.headers); + expect(headers.get('authorization')).toBe('Bearer test-token'); + expect(headers.get('user-agent')).toBe(DATABRICKS_PARTNER_USER_AGENT); + // No timeoutOptions: they would force a fresh undici Agent for every request + expect(timeoutOptions).toBeUndefined(); + expect(init?.signal).toBeInstanceOf(AbortSignal); + expect(init?.signal?.aborted).toBe(false); + cancel.abort(); + expect(init?.signal?.aborted).toBe(true); + }); + + it('aborts on a signal the caller already set', async () => { + mockedFromExistingIndex.mockResolvedValue({} as DatabricksVectorStore); + vi.mocked(proxyFetch).mockResolvedValue(new Response('{}')); + const ctx = setupContext({ ...baseParams, mode: 'retrieve' }); + ctx.getExecutionCancelSignal = vi.fn().mockReturnValue(new AbortController().signal); + await node.supplyData.call(ctx, 0); + + const { fetch } = mockedFromExistingIndex.mock.calls[0][1]; + const caller = new AbortController(); + await fetch('https://ws.example.com/x', { signal: caller.signal }); + + const signal = vi.mocked(proxyFetch).mock.calls[0][0].init?.signal; + expect(signal?.aborted).toBe(false); + caller.abort(); + expect(signal?.aborted).toBe(true); + }); + }); + + describe('execute in load mode', () => { + it('searches by text and never embeds the prompt', async () => { + const doc = { pageContent: 'hello', metadata: { source: 'hr' } }; + const store = { similaritySearchWithScore: vi.fn().mockResolvedValue([[doc, 0.9]]) }; + mockedFromExistingIndex.mockResolvedValue(store as unknown as DatabricksVectorStore); + const ctx = setupContext({ + ...baseParams, + mode: 'load', + prompt: 'what is up', + topK: 2, + }); + + const result = await node.execute.call(ctx); + + expect(store.similaritySearchWithScore).toHaveBeenCalledWith('what is up', 2, undefined); + expect(embeddings.embedQuery).not.toHaveBeenCalled(); + expect(result).toEqual([[{ json: { document: doc, score: 0.9 }, pairedItem: { item: 0 } }]]); + }); + + it('mints the token and describes the index once for every item of the run', async () => { + const store = { similaritySearchWithScore: vi.fn().mockResolvedValue([]) }; + mockedFromExistingIndex.mockResolvedValue(store as unknown as DatabricksVectorStore); + mockedDescribeIndex.mockResolvedValue(indexInfo); + const ctx = setupContext({ + ...baseParams, + mode: 'load', + prompt: 'what is up', + topK: 2, + }); + ctx.getInputData = vi.fn().mockReturnValue([{ json: {} }, { json: {} }]); + + await node.execute.call(ctx); + + expect(store.similaritySearchWithScore).toHaveBeenCalledTimes(2); + expect(mockedFromExistingIndex).toHaveBeenCalledTimes(2); + expect(mockedDescribeIndex).toHaveBeenCalledTimes(1); + expect(getDatabricksTokenProvider).toHaveBeenCalledTimes(1); + expect(ctx.getCredentials).toHaveBeenCalledTimes(1); + }); + }); + + describe('execute in insert mode', () => { + it('adds the loaded documents through the store', async () => { + const documents = [ + { pageContent: 'hello', metadata: { source: 'hr' } }, + { pageContent: 'world', metadata: {} }, + ]; + const store = { + addDocuments: vi.fn(async (docs: unknown[]) => docs.map((_, i) => String(i))), + }; + mockedFromExistingIndex.mockResolvedValue(store as unknown as DatabricksVectorStore); + const ctx = setupContext({ ...baseParams, mode: 'insert' }); + ctx.getInputConnectionData = vi + .fn() + .mockResolvedValueOnce(embeddings) + .mockResolvedValueOnce(documents); + ctx.getExecutionCancelSignal = vi.fn().mockReturnValue(undefined); + + const result = await node.execute.call(ctx); + + expect(mockedFromExistingIndex).toHaveBeenCalledWith( + embeddings, + expect.objectContaining({ indexName: 'cat.sch.idx', filter: undefined }), + ); + expect(store.addDocuments).toHaveBeenCalledWith(documents); + expect(result).toEqual([documents.map((doc) => ({ json: doc, pairedItem: { item: 0 } }))]); + }); + }); + + describe('searchIndexes', () => { + const pages: Record = { + endpoints: { endpoints: [{ name: 'ep1' }, { name: 'ep2' }] }, + 'ep1:': { vector_indexes: [{ name: 'cat.sch.zeta', index_type: 'DIRECT_ACCESS' }] }, + 'ep2:': { + vector_indexes: [{ name: 'cat.sch.beta', index_type: 'DELTA_SYNC' }], + next_page_token: 'p2', + }, + 'ep2:p2': { vector_indexes: [{ name: 'cat.sch.alpha', index_type: 'DELTA_SYNC' }] }, + }; + + let httpRequestWithAuthentication: ReturnType; + let ctx: ILoadOptionsFunctions; + + const setupSearchContext = (host: string) => { + httpRequestWithAuthentication = vi.fn( + async (_type: string, options: { url: string; qs: Record }) => + options.url.endsWith('/endpoints') + ? pages.endpoints + : pages[`${options.qs.endpoint_name}:${options.qs.page_token ?? ''}`], + ); + ctx = { + getCredentials: vi.fn().mockResolvedValue({ ...mockCredential, host }), + getNode: vi.fn().mockReturnValue(nodeDef), + helpers: { httpRequestWithAuthentication }, + } as unknown as ILoadOptionsFunctions; + }; + + it('lists every index of every endpoint, sorted, with the type and endpoint', async () => { + setupSearchContext('https://ws.example.com/'); + + const result = await methods.listSearch.searchIndexes.call(ctx); + + expect(result.results).toEqual([ + { name: 'cat.sch.alpha', value: 'cat.sch.alpha', description: 'DELTA_SYNC - ep2' }, + { name: 'cat.sch.beta', value: 'cat.sch.beta', description: 'DELTA_SYNC - ep2' }, + { name: 'cat.sch.zeta', value: 'cat.sch.zeta', description: 'DIRECT_ACCESS - ep1' }, + ]); + expect(httpRequestWithAuthentication).toHaveBeenCalledTimes(4); + for (const [type, options] of httpRequestWithAuthentication.mock.calls) { + expect(type).toBe('databricksOAuth2Api'); + expect(options.url).toMatch(/^https:\/\/ws\.example\.com\/api\/2\.0\/vector-search\//); + expect(options.headers).toMatchObject({ 'User-Agent': DATABRICKS_PARTNER_USER_AGENT }); + } + }); + + it('does not let the bearer follow a cross-origin redirect', async () => { + setupSearchContext('https://ws.example.com'); + + await methods.listSearch.searchIndexes.call(ctx); + + for (const [, options] of httpRequestWithAuthentication.mock.calls) { + expect(options.sendCredentialsOnCrossOriginRedirect).toBe(false); + } + }); + + it('applies the substring filter to the name', async () => { + setupSearchContext('https://ws.example.com'); + + const result = await methods.listSearch.searchIndexes.call(ctx, 'ZETA'); + + expect(result.results.map((r) => r.value)).toEqual(['cat.sch.zeta']); + }); + + it('stops after 50 pages when a page echoes its own token', async () => { + setupSearchContext('https://ws.example.com'); + httpRequestWithAuthentication.mockResolvedValue({ endpoints: [], next_page_token: 'loop' }); + + await expect(methods.listSearch.searchIndexes.call(ctx)).rejects.toThrow('50 pages'); + expect(httpRequestWithAuthentication).toHaveBeenCalledTimes(50); + }); + + it('rejects an http host before any request', async () => { + setupSearchContext('http://ws.example.com'); + + await expect(methods.listSearch.searchIndexes.call(ctx)).rejects.toThrow('must use https'); + expect(httpRequestWithAuthentication).not.toHaveBeenCalled(); + }); + }); + + describe('getIndexColumns', () => { + const setupLoadOptionsContext = (indexName: string) => { + const ctx = createMockExecuteFunction( + {}, + nodeDef, + ) as Mocked; + ctx.getCredentials = vi.fn().mockResolvedValue(mockCredential); + ctx.getCurrentNodeParameter = vi.fn().mockReturnValue(indexName); + return ctx; + }; + + it('lists the embedding source column first and hides the primary key', async () => { + mockedDescribeIndex.mockResolvedValue({ + name: 'cat.sch.idx', + primaryKey: 'id', + indexType: 'DELTA_SYNC', + embedding: { kind: 'managed', sourceColumn: 'text' }, + schemaColumns: ['id', 'text', 'source'], + }); + const ctx = setupLoadOptionsContext('cat.sch.idx'); + + const result = await methods.loadOptions.getIndexColumns.call(ctx); + + expect(mockedDescribeIndex).toHaveBeenCalledWith( + expect.any(Function), + 'https://ws.example.com', + 'cat.sch.idx', + ); + expect(ctx.getCurrentNodeParameter).toHaveBeenCalledWith('databricksIndex', { + extractValue: true, + }); + expect(result).toEqual([ + { name: 'text', value: 'text', description: 'Embedding source column' }, + { name: 'source', value: 'source' }, + ]); + }); + + it('hides the vector column of a Direct Access index', async () => { + mockedDescribeIndex.mockResolvedValue({ + name: 'cat.sch.idx', + primaryKey: 'id', + indexType: 'DIRECT_ACCESS', + embedding: { kind: 'self', vectorColumn: 'embedding' }, + schemaColumns: ['id', 'text', 'embedding'], + }); + const ctx = setupLoadOptionsContext('cat.sch.idx'); + + await expect(methods.loadOptions.getIndexColumns.call(ctx)).resolves.toEqual([ + { name: 'text', value: 'text' }, + ]); + }); + + it('returns no columns when the index schema is unknown', async () => { + mockedDescribeIndex.mockResolvedValue({ + name: 'cat.sch.idx', + primaryKey: 'id', + indexType: 'DELTA_SYNC', + embedding: { kind: 'self', vectorColumn: 'embedding' }, + }); + const ctx = setupLoadOptionsContext('cat.sch.idx'); + + await expect(methods.loadOptions.getIndexColumns.call(ctx)).resolves.toEqual([]); + }); + + it('returns no columns before an index is chosen', async () => { + const ctx = setupLoadOptionsContext(''); + + await expect(methods.loadOptions.getIndexColumns.call(ctx)).resolves.toEqual([]); + expect(mockedDescribeIndex).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/VectorStoreDatabricks.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/VectorStoreDatabricks.node.ts new file mode 100644 index 00000000000..22ebe399f71 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreDatabricks/VectorStoreDatabricks.node.ts @@ -0,0 +1,296 @@ +import type { Embeddings } from '@langchain/core/embeddings'; +import { createVectorStoreNode, proxyFetch } from '@n8n/ai-utilities'; +import { + assertParamIsArray, + assertParamIsString, + type IExecuteFunctions, + type ILoadOptionsFunctions, + type INodeListSearchResult, + type INodeProperties, + type INodePropertyOptions, + type ISupplyDataFunctions, +} from 'n8n-workflow'; + +import { + createDatabricksAuthFetch, + type DatabricksFetchContext, +} from '@utils/databricks/auth-fetch'; +import { assertHttpsHost } from '@utils/databricks/constants'; +import { listDatabricksPages } from '@utils/databricks/list-pages'; +import { + DATABRICKS_CREDENTIAL_TYPE, + type DatabricksOAuth2Credential, +} from '@utils/databricks/token-provider'; +import { DatabricksVectorStore, type DatabricksIndexInfo } from './DatabricksVectorStore'; + +const REQUEST_TIMEOUT_MS = 60_000; + +const databricksIndexRLC: INodeProperties = { + displayName: 'Index', + name: 'databricksIndex', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select an index...', + typeOptions: { + searchListMethod: 'searchIndexes', + searchable: true, + }, + }, + { + displayName: 'Name', + name: 'id', + type: 'string', + placeholder: 'catalog.schema.index', + validation: [ + { + type: 'regex', + properties: { + regex: '^[^.\\s]+\\.[^.\\s]+\\.[^.\\s]+$', + errorMessage: 'Use the full name: catalog.schema.index', + }, + }, + ], + }, + ], +}; + +const columnTypeOptions = { + loadOptionsMethod: 'getIndexColumns', + loadOptionsDependsOn: ['databricksIndex.value'], +}; + +const sharedFields: INodeProperties[] = [ + databricksIndexRLC, + { + displayName: 'Content Column', + name: 'contentColumn', + type: 'options', + default: '', + typeOptions: columnTypeOptions, + description: + 'Column that becomes the document text. Leave empty on a managed-embedding index to use its embedding source column. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + // Metadata Columns shapes the documents a read returns; the write path ignores it + displayOptions: { show: { mode: ['load', 'retrieve', 'retrieve-as-tool'] } }, + options: [ + { + displayName: 'Metadata Columns', + name: 'metadataColumns', + type: 'multiOptions', + default: [], + typeOptions: columnTypeOptions, + description: + 'Columns to return in document metadata. Defaults to all columns except the key, content and vector columns. Choose from the list, or specify IDs using an expression.', + }, + ], + }, +]; + +interface Run { + fetch: typeof fetch; + host: string; + /** Describe results of this run, keyed by index name */ + indexes: Map>; +} + +// The class owns its HTTP calls, so the runtime and the column dropdown share +// the token-refreshing fetch; the transport goes through the proxy-aware fetch +async function openRun(ctx: DatabricksFetchContext): Promise { + const credential = await ctx.getCredentials( + DATABRICKS_CREDENTIAL_TYPE, + ); + assertHttpsHost(ctx, credential.host); + const host = credential.host.replace(/\/$/, ''); + const egressFilter = ctx.helpers.getSecureEgressFilter(); + // ILoadOptionsFunctions has no cancel signal + const cancelSignal = + 'getExecutionCancelSignal' in ctx ? ctx.getExecutionCancelSignal() : undefined; + const { fetch } = createDatabricksAuthFetch(ctx, credential, { + endpointUrl: host, + egressFilter, + baseFetch: async (input, init) => { + // A whole-request deadline on the signal keeps the shared undici pool; per-request + // timeoutOptions would build a new Agent for every hop. The responses are small JSON + // bodies, so the deadline covers headers and body alike + const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS)]; + if (init?.signal) signals.push(init.signal); + if (cancelSignal) signals.push(cancelSignal); + return await proxyFetch({ + input, + init: { ...init, signal: AbortSignal.any(signals) }, + egressFilter, + }); + }, + }); + return { fetch, host, indexes: new Map() }; +} + +// One context object serves every item of an execute call and every tool call of a +// supplyData closure, so the token mint and the describe GETs happen once per run +const runs = new WeakMap>(); + +async function databricksFetch(ctx: DatabricksFetchContext): Promise { + let run = runs.get(ctx); + if (!run) { + // Evict a rejected run so a failed credential lookup does not poison the rest of the run + run = openRun(ctx).catch((error) => { + runs.delete(ctx); + throw error; + }); + runs.set(ctx, run); + } + return await run; +} + +async function createStore( + ctx: IExecuteFunctions | ISupplyDataFunctions, + embeddings: Embeddings, + itemIndex: number, + filter?: Record, +): Promise { + const { fetch, host, indexes } = await databricksFetch(ctx); + const node = ctx.getNode(); + const indexName = ctx.getNodeParameter('databricksIndex', itemIndex, '', { extractValue: true }); + assertParamIsString('databricksIndex', indexName, node); + const contentColumn = ctx.getNodeParameter('contentColumn', itemIndex, ''); + assertParamIsString('contentColumn', contentColumn, node); + const metadataColumns = ctx.getNodeParameter('options.metadataColumns', itemIndex, []); + assertParamIsArray( + 'metadataColumns', + metadataColumns, + (value): value is string => typeof value === 'string', + node, + ); + + let index = indexes.get(indexName); + if (!index) { + // Evict a rejected describe so one transient failure does not poison the rest of the run + index = DatabricksVectorStore.describeIndex(fetch, host, indexName).catch((error) => { + indexes.delete(indexName); + throw error; + }); + indexes.set(indexName, index); + } + + return await DatabricksVectorStore.fromExistingIndex(embeddings, { + fetch, + host, + indexName, + contentColumn, + metadataColumns, + filter, + index: await index, + }); +} + +interface ListPage { + endpoints?: Array<{ name: string }>; + vector_indexes?: Array<{ name: string; index_type?: string }>; + next_page_token?: string; +} + +async function searchIndexes( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + const credentials = await this.getCredentials( + DATABRICKS_CREDENTIAL_TYPE, + ); + assertHttpsHost(this, credentials.host); + const host = credentials.host.replace(/\/$/, ''); + + const endpoints = await listDatabricksPages( + this, + `${host}/api/2.0/vector-search/endpoints`, + {}, + (page: ListPage) => page.endpoints, + ); + const results = ( + await Promise.all( + endpoints.map(async (endpoint) => { + const indexes = await listDatabricksPages( + this, + `${host}/api/2.0/vector-search/indexes`, + { endpoint_name: endpoint.name }, + (page: ListPage) => page.vector_indexes, + ); + // The type tells the user which indexes accept inserts + return indexes.map((index) => ({ + name: index.name, + value: index.name, + description: `${index.index_type} - ${endpoint.name}`, + })); + }), + ) + ) + .flat() + .sort((a, b) => a.name.localeCompare(b.name)); + + const filterLower = filter?.toLowerCase(); + return { + results: filterLower + ? results.filter((result) => result.name.toLowerCase().includes(filterLower)) + : results, + }; +} + +async function getIndexColumns(this: ILoadOptionsFunctions): Promise { + const indexName = this.getCurrentNodeParameter('databricksIndex', { extractValue: true }); + if (typeof indexName !== 'string' || indexName === '') return []; + + const { fetch, host } = await databricksFetch(this); + const info = await DatabricksVectorStore.describeIndex(fetch, host, indexName); + const { embedding } = info; + const source = embedding.kind === 'managed' ? embedding.sourceColumn : undefined; + const vector = embedding.kind === 'self' ? embedding.vectorColumn : undefined; + const columns = (info.schemaColumns ?? []) + .filter((column) => column !== info.primaryKey && column !== source && column !== vector) + .map((column) => ({ name: column, value: column })); + return source + ? [{ name: source, value: source, description: 'Embedding source column' }, ...columns] + : columns; +} + +export class VectorStoreDatabricks extends createVectorStoreNode({ + meta: { + displayName: 'Databricks Vector Store', + name: 'vectorStoreDatabricks', + description: 'Work with your data in Databricks Vector Search', + icon: { + light: 'file:../../shared/icons/databricks.svg', + dark: 'file:../../shared/icons/databricks.dark.svg', + }, + docsUrl: + 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstoredatabricks/', + credentials: [ + { + name: 'databricksOAuth2Api', + required: true, + }, + ], + }, + hidden: true, + // The index decides between query_text and query_vector inside the class + searchByText: true, + methods: { listSearch: { searchIndexes }, loadOptions: { getIndexColumns } }, + sharedFields, + async getVectorStoreClient(context, filter, embeddings, itemIndex) { + return await createStore(context, embeddings, itemIndex, filter); + }, + async populateVectorStore(context, embeddings, documents, itemIndex) { + const store = await createStore(context, embeddings, itemIndex); + await store.addDocuments(documents); + }, +}) {} diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 472ad885a21..f6195124164 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -183,6 +183,7 @@ "dist/nodes/trigger/ChatTrigger/ChatTrigger.node.js", "dist/nodes/trigger/ChatTrigger/Chat.node.js", "dist/nodes/vector_store/VectorStoreAzureAISearch/VectorStoreAzureAISearch.node.js", + "dist/nodes/vector_store/VectorStoreDatabricks/VectorStoreDatabricks.node.js", "dist/nodes/vector_store/VectorStoreInMemory/VectorStoreInMemory.node.js", "dist/nodes/vector_store/VectorStoreInMemoryInsert/VectorStoreInMemoryInsert.node.js", "dist/nodes/vector_store/VectorStoreInMemoryLoad/VectorStoreInMemoryLoad.node.js", diff --git a/packages/@n8n/nodes-langchain/utils/databricks/auth-fetch.ts b/packages/@n8n/nodes-langchain/utils/databricks/auth-fetch.ts new file mode 100644 index 00000000000..4b7a92d7f6d --- /dev/null +++ b/packages/@n8n/nodes-langchain/utils/databricks/auth-fetch.ts @@ -0,0 +1,66 @@ +import { createRefreshingAuthFetch } from '@n8n/ai-utilities'; +import { + assertUrlAllowed, + getCredentialAllowedDomains, + type IExecuteFunctions, + type ILoadOptionsFunctions, + type ISupplyDataFunctions, + type NodeEgressFilter, +} from 'n8n-workflow'; + +import type { RefreshingTokenSource } from '../oauth2-token-provider'; +import { databricksAuthHeaders } from './constants'; +import type { DatabricksOAuth2Credential } from './token-provider'; +import { getDatabricksTokenProvider } from './token-provider'; + +export type DatabricksFetchContext = + | IExecuteFunctions + | ISupplyDataFunctions + | ILoadOptionsFunctions; + +/** + * The token-refreshing fetch every Databricks AI node sends its requests through. + * The caller owns the transport (`baseFetch`) and the egress filter; the token + * source comes back so the caller can read `expiredStatus`. + */ +export function createDatabricksAuthFetch( + ctx: DatabricksFetchContext, + credential: DatabricksOAuth2Credential, + options: { endpointUrl: string; egressFilter: NodeEgressFilter; baseFetch: typeof fetch }, +): { fetch: typeof fetch; tokenSource: RefreshingTokenSource } { + const { endpointUrl, egressFilter } = options; + const node = ctx.getNode(); + // endpointUrl derives from the credential's own host, so credentialOwnedSurface joins it to the allowlist + const allowedDomains = getCredentialAllowedDomains({ + node, + credentialData: credential, + credentialOwnedSurface: true, + nodeEndpointUrl: endpointUrl, + }); + + const tokenSource = getDatabricksTokenProvider(ctx, credential, egressFilter); + const { refreshAfterRejection } = tokenSource; + // The caller builds its own transport, so it never reaches the + // request helpers: `resolveHeaders` runs the expiry clock before every + // request, and `refreshHeaders` covers the rejection the clock missed - + // revoked server-side, or clock skew + const authFetch = createRefreshingAuthFetch({ + baseFetch: options.baseFetch, + expiredStatus: tokenSource.expiredStatus, + resolveHeaders: async () => databricksAuthHeaders(await tokenSource.getToken()), + ...(refreshAfterRejection && { + refreshHeaders: async () => { + const refreshed = await refreshAfterRejection(); + return refreshed ? databricksAuthHeaders(refreshed) : null; + }, + }), + assertAllowedUrl: async (hopUrl) => { + assertUrlAllowed({ url: hopUrl, allowedDomains, node }); + if (!egressFilter) return; + const result = await egressFilter.validateUrl(hopUrl); + if (!result.ok) throw result.error; + }, + }); + + return { fetch: authFetch, tokenSource }; +} diff --git a/packages/@n8n/nodes-langchain/utils/databricks/constants.ts b/packages/@n8n/nodes-langchain/utils/databricks/constants.ts index d99fa4bfa85..8de196ee060 100644 --- a/packages/@n8n/nodes-langchain/utils/databricks/constants.ts +++ b/packages/@n8n/nodes-langchain/utils/databricks/constants.ts @@ -1,6 +1,5 @@ import { DATABRICKS_PARTNER_USER_AGENT } from 'n8n-nodes-base/dist/nodes/Databricks/constants'; -import type { ILoadOptionsFunctions, ISupplyDataFunctions } from 'n8n-workflow'; -import { NodeOperationError } from 'n8n-workflow'; +import { NodeOperationError, type INode } from 'n8n-workflow'; /** * The bearer plus the partner User-Agent, set on every request and every retry. @@ -16,10 +15,7 @@ export const databricksAuthHeaders = (token: string): HeadersInit => ({ * Every request carries a secret (bearer token, or the client secret on the * mint path), so an http host would ship it in cleartext. */ -export function assertHttpsHost( - ctx: ILoadOptionsFunctions | ISupplyDataFunctions, - host: string, -): void { +export function assertHttpsHost(ctx: { getNode(): INode }, host: string): void { if (!URL.canParse(host) || new URL(host).protocol !== 'https:') { throw new NodeOperationError(ctx.getNode(), 'Databricks host must use https'); } diff --git a/packages/@n8n/nodes-langchain/utils/databricks/gateway-config.ts b/packages/@n8n/nodes-langchain/utils/databricks/gateway-config.ts index ae14d23c4e8..fb90606b195 100644 --- a/packages/@n8n/nodes-langchain/utils/databricks/gateway-config.ts +++ b/packages/@n8n/nodes-langchain/utils/databricks/gateway-config.ts @@ -1,15 +1,12 @@ import type { ClientOptions } from '@langchain/openai'; -import { createRefreshingAuthFetch, getProxyAgent } from '@n8n/ai-utilities'; -import { - assertUrlAllowed, - getCredentialAllowedDomains, - type ISupplyDataFunctions, -} from 'n8n-workflow'; +import { getProxyAgent } from '@n8n/ai-utilities'; +import type { ISupplyDataFunctions } from 'n8n-workflow'; -import { assertHttpsHost, databricksAuthHeaders, gatewayBaseUrl } from './constants'; -import { wrapDatabricksErrorFetch } from './error-handling'; import type { RefreshingTokenSource } from '../oauth2-token-provider'; -import { getDatabricksTokenProvider, type DatabricksOAuth2Credential } from './token-provider'; +import { createDatabricksAuthFetch } from './auth-fetch'; +import { assertHttpsHost, gatewayBaseUrl } from './constants'; +import { wrapDatabricksErrorFetch } from './error-handling'; +import type { DatabricksOAuth2Credential } from './token-provider'; /** * Builds the OpenAI-client transport for the Unity AI Gateway: the gateway @@ -28,44 +25,16 @@ export function createDatabricksGatewayConfig( assertHttpsHost(ctx, credential.host); const baseURL = gatewayBaseUrl(credential.host); - const node = ctx.getNode(); - // baseURL derives from the credential's own host, so credentialOwnedSurface joins it to the allowlist - const allowedDomains = getCredentialAllowedDomains({ - node, - credentialData: credential, - credentialOwnedSurface: true, - nodeEndpointUrl: baseURL, - }); - const egressFilter = ctx.helpers.getSecureEgressFilter(); - const tokenSource = getDatabricksTokenProvider(ctx, credential, egressFilter); - const { refreshAfterRejection } = tokenSource; + const { fetch: authFetch, tokenSource } = createDatabricksAuthFetch(ctx, credential, { + endpointUrl: baseURL, + egressFilter, + baseFetch: fetch, + }); const configuration: ClientOptions = { baseURL, - // The model client builds its own transport, so it never reaches the - // request helpers: `resolveHeaders` runs the expiry clock before every - // request, and `refreshHeaders` covers the rejection the clock missed - - // revoked server-side, or clock skew - fetch: wrapDatabricksErrorFetch( - createRefreshingAuthFetch({ - baseFetch: fetch, - expiredStatus: tokenSource.expiredStatus, - resolveHeaders: async () => databricksAuthHeaders(await tokenSource.getToken()), - ...(refreshAfterRejection && { - refreshHeaders: async () => { - const refreshed = await refreshAfterRejection(); - return refreshed ? databricksAuthHeaders(refreshed) : null; - }, - }), - assertAllowedUrl: async (hopUrl) => { - assertUrlAllowed({ url: hopUrl, allowedDomains, node }); - if (!egressFilter) return; - const result = await egressFilter.validateUrl(hopUrl); - if (!result.ok) throw result.error; - }, - }), - ), + fetch: wrapDatabricksErrorFetch(authFetch), fetchOptions: { dispatcher: getProxyAgent( baseURL, diff --git a/packages/@n8n/nodes-langchain/utils/databricks/list-pages.ts b/packages/@n8n/nodes-langchain/utils/databricks/list-pages.ts new file mode 100644 index 00000000000..87ad1a62d74 --- /dev/null +++ b/packages/@n8n/nodes-langchain/utils/databricks/list-pages.ts @@ -0,0 +1,48 @@ +import { DATABRICKS_PARTNER_USER_AGENT } from 'n8n-nodes-base/dist/nodes/Databricks/constants'; +import { NodeOperationError, type ILoadOptionsFunctions } from 'n8n-workflow'; + +import { DATABRICKS_CREDENTIAL_TYPE } from './token-provider'; + +const MAX_PAGES = 50; + +interface Page { + next_page_token?: string; +} + +/** + * Follows `next_page_token` through a Databricks list endpoint and concatenates + * what `pick` keeps from each page. Every picker pages through this one function + * so the request shape, and the redirect guard, cannot drift between them. + */ +export async function listDatabricksPages

( + ctx: ILoadOptionsFunctions, + url: string, + qs: Record, + pick: (page: P) => T[] | undefined, +): Promise { + let items: T[] = []; + let pageToken: string | undefined; + let pages = 0; + do { + // Guard against a host or proxy that echoes the same next_page_token back + if (++pages > MAX_PAGES) { + throw new NodeOperationError(ctx.getNode(), `Databricks list exceeded ${MAX_PAGES} pages`); + } + const page: P = await ctx.helpers.httpRequestWithAuthentication.call( + ctx, + DATABRICKS_CREDENTIAL_TYPE, + { + method: 'GET', + url, + qs: { ...qs, page_token: pageToken }, + headers: { Accept: 'application/json', 'User-Agent': DATABRICKS_PARTNER_USER_AGENT }, + json: true, + // The bearer must not follow a redirect off the workspace host + sendCredentialsOnCrossOriginRedirect: false, + }, + ); + items = items.concat(pick(page) ?? []); + pageToken = page.next_page_token; + } while (pageToken); + return items; +} diff --git a/packages/@n8n/nodes-langchain/utils/databricks/model-services.ts b/packages/@n8n/nodes-langchain/utils/databricks/model-services.ts index 669b5077e69..77e9ed93cb7 100644 --- a/packages/@n8n/nodes-langchain/utils/databricks/model-services.ts +++ b/packages/@n8n/nodes-langchain/utils/databricks/model-services.ts @@ -1,4 +1,3 @@ -import { DATABRICKS_PARTNER_USER_AGENT } from 'n8n-nodes-base/dist/nodes/Databricks/constants'; import { NodeApiError, NodeOperationError, @@ -7,6 +6,7 @@ import { } from 'n8n-workflow'; import { assertHttpsHost } from './constants'; +import { listDatabricksPages } from './list-pages'; import { DATABRICKS_CREDENTIAL_TYPE, type DatabricksOAuth2Credential } from './token-provider'; interface ModelService { @@ -57,34 +57,14 @@ export function makeModelSearch(capability: ModelCapability) { assertHttpsHost(this, credentials.host); const host = credentials.host.replace(/\/$/, ''); - const listModelServices = async (parent?: string): Promise => { - let services: ModelService[] = []; - let pageToken: string | undefined; - let pages = 0; - do { - // Guard against a host or proxy that echoes the same next_page_token back - if (++pages > 50) { - throw new NodeOperationError(this.getNode(), 'Model service list exceeded 50 pages'); - } - const page: ModelServicesResponse = await this.helpers.httpRequestWithAuthentication.call( - this, - DATABRICKS_CREDENTIAL_TYPE, - { - method: 'GET', - url: `${host}/api/2.1/unity-catalog/model-services`, - // FULL view is needed for supported_api_types - qs: { view: 'FULL', parent, page_token: pageToken }, - headers: { Accept: 'application/json', 'User-Agent': DATABRICKS_PARTNER_USER_AGENT }, - json: true, - // The bearer must not follow a redirect off the workspace host - sendCredentialsOnCrossOriginRedirect: false, - }, - ); - services = services.concat(page.model_services ?? []); - pageToken = page.next_page_token; - } while (pageToken); - return services; - }; + const listModelServices = async (parent?: string): Promise => + await listDatabricksPages( + this, + `${host}/api/2.1/unity-catalog/model-services`, + // FULL view is needed for supported_api_types + { view: 'FULL', parent }, + (page) => page.model_services, + ); let services: ModelService[]; try { diff --git a/packages/@n8n/nodes-langchain/utils/databricks/token-provider.ts b/packages/@n8n/nodes-langchain/utils/databricks/token-provider.ts index 8bb43fd87fe..e40dc9b6062 100644 --- a/packages/@n8n/nodes-langchain/utils/databricks/token-provider.ts +++ b/packages/@n8n/nodes-langchain/utils/databricks/token-provider.ts @@ -1,6 +1,7 @@ import { ClientOAuth2 } from '@n8n/client-oauth2'; import { DATABRICKS_PARTNER_USER_AGENT } from 'n8n-nodes-base/dist/nodes/Databricks/constants'; import type { + IExecuteFunctions, INode, ISupplyDataFunctions, ILoadOptionsFunctions, @@ -29,7 +30,7 @@ export type DatabricksOAuth2Credential = { * one-time-use refresh token, so it has to go through core to persist the rotation. */ export function getDatabricksTokenProvider( - ctx: ISupplyDataFunctions | ILoadOptionsFunctions, + ctx: IExecuteFunctions | ISupplyDataFunctions | ILoadOptionsFunctions, credential: DatabricksOAuth2Credential, egressFilter?: NodeEgressFilter, ): RefreshingTokenSource { From b6b4e542515d32e395e8f854da06cc86610c7d1e Mon Sep 17 00:00:00 2001 From: Rob Hough Date: Tue, 22 Sep 2026 13:51:01 +0000 Subject: [PATCH 08/29] feat(editor): Use test label and icon for agent preview (#38939) --- packages/frontend/@n8n/i18n/src/locales/en.json | 6 +++--- .../agents/__tests__/AgentBuilderEditorColumn.spec.ts | 7 +++++++ .../features/agents/__tests__/AgentBuilderHeader.test.ts | 9 ++++----- .../features/agents/__tests__/AgentBuilderView.test.ts | 4 ++-- .../agents/components/AgentBuilderEditorColumn.vue | 1 + .../features/agents/components/AgentBuilderHeader.vue | 1 - .../features/agents/components/AgentPreviewButton.vue | 6 +++--- 7 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 186ff7456c5..dac13ff5a40 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -8411,9 +8411,9 @@ "agents.builder.header.saving": "Saving…", "agents.builder.header.saved": "Saved", "agents.builder.header.editWithAi": "Edit with n8n Assistant", - "agents.builder.preview.button": "Preview", + "agents.builder.preview.button": "Test agent", "agents.builder.preview.showChat": "Show chat", - "agents.builder.preview.disabledTooltip": "Can't preview agent yet", + "agents.builder.preview.disabledTooltip": "Can't test agent yet", "agents.builder.preview.issue.agent.instructions.missingRequired": "Add instructions describing what this agent should do.", "agents.builder.preview.issue.agent.model.missingRequired": "Select a model before you preview this agent.", "agents.builder.preview.issue.agent.model.invalidValue": "Select a valid model before you preview this agent.", @@ -8480,7 +8480,7 @@ "agents.builder.validation.issue.mcpServer.incompatibleCredential": "The credential type doesn't match the server's authentication method", "agents.builder.preview.sendError": "Couldn't send the preview message. Try again.", "agents.builder.preview.viewSession": "View session trace", - "agents.builder.preview.close.ariaLabel": "Close preview", + "agents.builder.preview.close.ariaLabel": "Hide test", "agents.builder.executions.count": "{count} session | {count} sessions", "agents.builder.settings.description": "Configure memory, sub-agent routing, and advanced runtime settings.", "agents.builder.raw.description": "Agent JSON configuration", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderEditorColumn.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderEditorColumn.spec.ts index 4a80b4b78c4..22ef99b0593 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderEditorColumn.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderEditorColumn.spec.ts @@ -58,6 +58,13 @@ vi.mock('@n8n/design-system', () => ({ '

', props: ['modelValue', 'options'], }, + N8nToggle: { + name: 'N8nToggle', + template: + '