Compare commits

...

27 Commits

Author SHA1 Message Date
n8n-assistant[bot]
602f737443
🚀 Release 2.35.5 (#36682)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-08-20 07:16:45 +00:00
n8n-assistant[bot]
af53a1a71c
fix(core): Only init the expression engine for commands that evaluate expressions (backport to release-candidate/2.35.x) (#36648)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Danny Martini <danny@n8n.io>
2026-08-19 16:30:11 +00:00
n8n-assistant[bot]
17926a0e8b
fix(core): Release test-webhook isolate only after teardown completes (backport to release-candidate/2.35.x) (#36632)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Danny Martini <danny@n8n.io>
2026-08-19 15:06:22 +00:00
n8n-assistant[bot]
f97d74fc03
fix(editor): Restore Edit Fields type selector styles (backport to release-candidate/2.35.x) (#36628)
Co-authored-by: Rob Hough <robhough180@gmail.com>
2026-08-19 14:54:25 +00:00
n8n-assistant[bot]
738fa9b1f9
fix(editor): Restore theme outline shadows (no-changelog) (backport to release-candidate/2.35.x) (#36621)
Co-authored-by: Rob Hough <robhough180@gmail.com>
2026-08-19 14:06:48 +00:00
n8n-assistant[bot]
7ee5295ed5
fix(core): Avoid restarting task runners that are only slow (backport to release-candidate/2.35.x) (#36610)
Co-authored-by: Lorent Lempereur <looorent@users.noreply.github.com>
2026-08-19 13:03:13 +00:00
n8n-assistant[bot]
c6c3c4f42f
fix: Keep Kafka patch in Docker image build (no-changelog) (backport to release-candidate/2.35.x) (#36574)
Co-authored-by: Riqwan Thamir <rmthamir@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 08:08:21 +00:00
n8n-assistant[bot]
d2ce3c084c
🚀 Release 2.35.4 (#36563)
Co-authored-by: Matsuuu <16068444+Matsuuu@users.noreply.github.com>
2026-08-19 08:42:02 +03:00
n8n-assistant[bot]
00aad642c2
chore: Bundle/2.x (backport to release-candidate/2.35.x) (#36561)
Co-authored-by: n8n-assistant[bot] <100856346+n8n-assistant[bot]@users.noreply.github.com>
Co-authored-by: Matsu <matias.huhta@n8n.io>
Co-authored-by: Sam Wooler <swooler592@gmail.com>
Co-authored-by: Dimitri Lavrenük <20122620+dlavrenuek@users.noreply.github.com>
Co-authored-by: Bernhard Wittmann <bernhard.wittmann@n8n.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Lorent Lempereur <looorent@users.noreply.github.com>
Co-authored-by: yehorkardash <yehor.kardash@n8n.io>
Co-authored-by: Danny Martini <danny@n8n.io>
Co-authored-by: mfsiega <93014743+mfsiega@users.noreply.github.com>
Co-authored-by: phyllis-noester <102315132+phyllis-noester@users.noreply.github.com>
Co-authored-by: n8n-assistant[bot] <n8n-assistant[bot]@users.noreply.github.com>
Co-authored-by: Emilia <100027345+sovietspaceship@users.noreply.github.com>
Co-authored-by: Robin Braumann <50590409+bjorger@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-19 08:33:35 +03:00
n8n-assistant[bot]
fa781db207
fix(API): Return a request schema for decorator routes in /discover (backport to release-candidate/2.35.x) (#36497)
Co-authored-by: Uddish Verma <uddish.verma@n8n.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 10:39:15 +00:00
n8n-assistant[bot]
7f7f0a1964
feat(core): Add AI Assistant setup funnel telemetry (backport to release-candidate/2.35.x) (#36464)
Co-authored-by: oleg <me@olegivaniv.com>
2026-08-18 05:38:20 +00:00
n8n-assistant[bot]
a8b2716344
fix(Google Ads Node): Select the renamed video views metric on API v25 (backport to release-candidate/2.35.x) (#36414)
Co-authored-by: Nickolas Steel <138252345+DentalRx@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 10:19:23 +00:00
n8n-assistant[bot]
d8e3ee6c7e
feat: Show scopes for managed OAuth credentials with N8N_MANAGED_OAUTH_SHOW_SCOPES (backport to release-candidate/2.35.x) (#36397)
Co-authored-by: yehorkardash <yehor.kardash@n8n.io>
Co-authored-by: Jon <jonathan.bennetts@gmail.com>
2026-08-17 08:49:47 +00:00
n8n-assistant[bot]
0dccd14a44
🚀 Release 2.35.3 (#36288)
Co-authored-by: mfsiega <93014743+mfsiega@users.noreply.github.com>
2026-08-14 09:39:19 +00:00
n8n-assistant[bot]
a29cabc294
feat(core): Skip update approval for workflows created in the same Instance AI session (backport to release-candidate/2.35.x) (#36106)
Co-authored-by: Riqwan Thamir <rmthamir@gmail.com>
2026-08-14 09:09:26 +00:00
n8n-assistant[bot]
69140c0610
fix(core): Bound workflow publication outbox record processing with an abort deadline (backport to release-candidate/2.35.x) (#36286)
Co-authored-by: mfsiega <93014743+mfsiega@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 09:01:23 +00:00
n8n-assistant[bot]
75780ec66f
fix(Google Ads Node): Migrate from sunset v21 API to v25 (backport to release-candidate/2.35.x) (#36259) 2026-08-13 19:26:34 +00:00
n8n-assistant[bot]
e58a68016b
fix(core): Allow details field in continueErrorOutput mode (backport to release-candidate/2.35.x) (#36228)
Co-authored-by: yehorkardash <yehor.kardash@n8n.io>
2026-08-13 15:49:42 +00:00
n8n-assistant[bot]
66ba995eed
fix(editor): Update ready-to-run demo templates to a current OpenAI model (backport to release-candidate/2.35.x) (#36201)
Co-authored-by: Jaakko Husso <jaakko@n8n.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 10:25:05 +00:00
n8n-assistant[bot]
0eba018529
fix(Microsoft Teams Node): Restore Group.ReadWrite.All OAuth2 scope (backport to release-candidate/2.35.x) (#36186)
Co-authored-by: Ilfat Mindubaev <ilfat.mindubaev@n8n.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:27:14 +00:00
n8n-assistant[bot]
3c078f45c2
🚀 Release 2.35.2 (#36181)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-08-13 07:17:25 +00:00
n8n-assistant[bot]
07e3927d0c
fix(core): Report the real activation mode for triggers activated via the publication outbox (backport to release-candidate/2.35.x) (#36151)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 14:08:29 +00:00
n8n-assistant[bot]
e80209024e
🚀 Release 2.35.1 (#36102)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-08-12 07:19:18 +00:00
n8n-assistant[bot]
b81e52d76d
fix(core): Accept Always allow scope on data-tables resume (backport to release-candidate/2.35.x) (#36080)
Co-authored-by: Riqwan Thamir <rmthamir@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 18:52:43 +00:00
n8n-assistant[bot]
2f6059f686
feat(core): Add activation-capped AI Assistant trial variant (no-changelog) (backport to release-candidate/2.35.x) (#36051)
Co-authored-by: Jaakko Husso <jaakko@n8n.io>
2026-08-11 15:42:59 +00:00
n8n-assistant[bot]
2a53043249
chore: chore: Remove stale seq-queue license override from SBOM config (backport to release-candidate/2.35.x) (#36022)
Co-authored-by: n8n-cat-bot[bot] <283985454+n8n-cat-bot[bot]@users.noreply.github.com>
Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 09:02:13 +00:00
n8n-assistant[bot]
2e2ad77114
fix(core): Apply TLS options per hop when requests go through a proxy (backport to release-candidate/2.35.x) (#36016)
Co-authored-by: Lorent Lempereur <looorent@users.noreply.github.com>
2026-08-11 08:28:53 +00:00
273 changed files with 10659 additions and 1475 deletions

View File

@ -1,3 +1,63 @@
## [2.35.5](https://github.com/n8n-io/n8n/compare/n8n@2.35.4...n8n@2.35.5) (2026-08-20)
### Bug Fixes
* **core:** Avoid restarting task runners that are only slow ([#36610](https://github.com/n8n-io/n8n/issues/36610)) ([7ee5295](https://github.com/n8n-io/n8n/commit/7ee5295ed58ccf2b7921ac22a6fc89a315b11bfe))
* **core:** Only init the expression engine for commands that evaluate expressions ([#36648](https://github.com/n8n-io/n8n/issues/36648)) ([af53a1a](https://github.com/n8n-io/n8n/commit/af53a1a71c8ec3428ef130e524bcc645a82d4ddf))
* **core:** Release test-webhook isolate only after teardown completes ([#36632](https://github.com/n8n-io/n8n/issues/36632)) ([17926a0](https://github.com/n8n-io/n8n/commit/17926a0e8b665bc99a71ef8b7baa35ec85494c65))
* **editor:** Restore Edit Fields type selector styles ([#36628](https://github.com/n8n-io/n8n/issues/36628)) ([f97d74f](https://github.com/n8n-io/n8n/commit/f97d74fc039da374eaa2b4e701b19f8c0a1e8ad9))
## [2.35.4](https://github.com/n8n-io/n8n/compare/n8n@2.35.3...n8n@2.35.4) (2026-08-19)
### Bug Fixes
* **API:** Return a request schema for decorator routes in /discover ([#36497](https://github.com/n8n-io/n8n/issues/36497)) ([fa781db](https://github.com/n8n-io/n8n/commit/fa781db2079eff0db5675fb2cd63558629db3dd7))
* **Google Ads Node:** Select the renamed video views metric on API v25 ([#36414](https://github.com/n8n-io/n8n/issues/36414)) ([a8b2716](https://github.com/n8n-io/n8n/commit/a8b2716344fa5c3dacccc524bcdfce04df3ed7e7))
### Features
* **core:** Add AI Assistant setup funnel telemetry ([#36464](https://github.com/n8n-io/n8n/issues/36464)) ([7f7f0a1](https://github.com/n8n-io/n8n/commit/7f7f0a19642bb9e8d0938c98daba0e3bb7ecf7cf))
* Show scopes for managed OAuth credentials with N8N_MANAGED_OAUTH_SHOW_SCOPES ([#36397](https://github.com/n8n-io/n8n/issues/36397)) ([d8e3ee6](https://github.com/n8n-io/n8n/commit/d8e3ee6c7ea7bdbfa395a40687ca0d70037c98bf))
## [2.35.3](https://github.com/n8n-io/n8n/compare/n8n@2.35.2...n8n@2.35.3) (2026-08-14)
### Bug Fixes
* **core:** Allow `details` field in `continueErrorOutput` mode ([#36228](https://github.com/n8n-io/n8n/issues/36228)) ([e58a680](https://github.com/n8n-io/n8n/commit/e58a68016b881f29b2509f6c9d1ba093edf7b83b))
* **core:** Bound workflow publication outbox record processing with an abort deadline ([#36286](https://github.com/n8n-io/n8n/issues/36286)) ([69140c0](https://github.com/n8n-io/n8n/commit/69140c0610754ef3a8da66d23fb2f8b00e1d256d))
* **editor:** Update ready-to-run demo templates to a current OpenAI model ([#36201](https://github.com/n8n-io/n8n/issues/36201)) ([66ba995](https://github.com/n8n-io/n8n/commit/66ba995eed583cf0eb782dcba8e0872bc3b270be))
* **Google Ads Node:** Migrate from sunset v21 API to v25 ([#36259](https://github.com/n8n-io/n8n/issues/36259)) ([75780ec](https://github.com/n8n-io/n8n/commit/75780ec66f08e5de10327cc2bd445dd4fd4ae56c))
* **Microsoft Teams Node:** Restore Group.ReadWrite.All OAuth2 scope ([#36186](https://github.com/n8n-io/n8n/issues/36186)) ([0eba018](https://github.com/n8n-io/n8n/commit/0eba018529593012d5d21b2723c3a3accc02bac6))
### Features
* **core:** Skip update approval for workflows created in the same Instance AI session ([#36106](https://github.com/n8n-io/n8n/issues/36106)) ([a29cabc](https://github.com/n8n-io/n8n/commit/a29cabc2946fcf758b1cc5c2e143bd3d779c620c))
## [2.35.2](https://github.com/n8n-io/n8n/compare/n8n@2.35.1...n8n@2.35.2) (2026-08-13)
### Bug Fixes
* **core:** Report the real activation mode for triggers activated via the publication outbox ([#36151](https://github.com/n8n-io/n8n/issues/36151)) ([07e3927](https://github.com/n8n-io/n8n/commit/07e3927d0ced53aa1572d278933c424a1bc265ee))
## [2.35.1](https://github.com/n8n-io/n8n/compare/n8n@2.35.0...n8n@2.35.1) (2026-08-12)
### Bug Fixes
* **core:** Accept Always allow scope on data-tables resume ([#36080](https://github.com/n8n-io/n8n/issues/36080)) ([b81e52d](https://github.com/n8n-io/n8n/commit/b81e52d76d05ab90bf77fcd201b5e29e4e05ea37))
* **core:** Apply TLS options per hop when requests go through a proxy ([#36016](https://github.com/n8n-io/n8n/issues/36016)) ([2e2ad77](https://github.com/n8n-io/n8n/commit/2e2ad771142487b105d2e5590ba70528c69b750b))
# [2.35.0](https://github.com/n8n-io/n8n/compare/n8n@2.34.0...n8n@2.35.0) (2026-08-11)

View File

@ -122,7 +122,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [public.workflow_dependency](public.workflow_dependency.md) | 9 | | BASE TABLE |
| [public.workflow_entity](public.workflow_entity.md) | 20 | | BASE TABLE |
| [public.workflow_history](public.workflow_history.md) | 11 | | BASE TABLE |
| [public.workflow_publication_outbox](public.workflow_publication_outbox.md) | 7 | | BASE TABLE |
| [public.workflow_publication_outbox](public.workflow_publication_outbox.md) | 8 | | BASE TABLE |
| [public.workflow_publication_trigger_status](public.workflow_publication_trigger_status.md) | 8 | | BASE TABLE |
| [public.workflow_publish_history](public.workflow_publish_history.md) | 6 | | BASE TABLE |
| [public.workflow_published_version](public.workflow_published_version.md) | 4 | | BASE TABLE |
@ -1478,6 +1478,7 @@ erDiagram
text errorMessage
integer id
varchar_36_ publishedVersionId
varchar_20_ reason
varchar_20_ status
timestamp_3__with_time_zone updatedAt
varchar_36_ workflowId

View File

@ -8,6 +8,7 @@
| errorMessage | text | | true | | | Error details for surfacing failed publications to the user. |
| id | integer | | false | | | |
| publishedVersionId | varchar(36) | | false | | | References workflow_history.versionId. |
| reason | varchar(20) | 'publish'::character varying | false | | | Why this publication was enqueued: user publish/unpublish, leader startup pass, leadership takeover, or reconciler drift-heal |
| status | varchar(20) | | false | | | |
| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| workflowId | varchar(36) | | false | | | References workflow_entity.id. |
@ -16,11 +17,13 @@
| Name | Type | Definition |
| ---- | ---- | ---------- |
| CHK_workflow_publication_outbox_reason | CHECK | CHECK (((reason)::text = ANY ((ARRAY['publish'::character varying, 'startup'::character varying, 'leadership-takeover'::character varying, 'reconcile'::character varying])::text[]))) |
| CHK_workflow_publication_outbox_status | CHECK | CHECK (((status)::text = ANY ((ARRAY['pending'::character varying, 'in_progress'::character varying, 'completed'::character varying, 'partial_success'::character varying, 'failed'::character varying])::text[]))) |
| PK_b3e2eeee36a4bd044d56468d311 | PRIMARY KEY | PRIMARY KEY (id) |
| workflow_publication_outbox_createdAt_not_null | n | NOT NULL "createdAt" |
| workflow_publication_outbox_id_not_null | n | NOT NULL id |
| workflow_publication_outbox_publishedVersionId_not_null | n | NOT NULL "publishedVersionId" |
| workflow_publication_outbox_reason_not_null | n | NOT NULL reason |
| workflow_publication_outbox_status_not_null | n | NOT NULL status |
| workflow_publication_outbox_updatedAt_not_null | n | NOT NULL "updatedAt" |
| workflow_publication_outbox_workflowId_not_null | n | NOT NULL "workflowId" |
@ -43,6 +46,7 @@ erDiagram
text errorMessage
integer id
varchar_36_ publishedVersionId
varchar_20_ reason
varchar_20_ status
timestamp_3__with_time_zone updatedAt
varchar_36_ workflowId

View File

@ -122,7 +122,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
| [workflow_dependency](workflow_dependency.md) | 9 | | table |
| [workflow_entity](workflow_entity.md) | 20 | | table |
| [workflow_history](workflow_history.md) | 11 | | table |
| [workflow_publication_outbox](workflow_publication_outbox.md) | 7 | | table |
| [workflow_publication_outbox](workflow_publication_outbox.md) | 8 | | table |
| [workflow_publication_trigger_status](workflow_publication_trigger_status.md) | 8 | | table |
| [workflow_publish_history](workflow_publish_history.md) | 6 | | table |
| [workflow_published_version](workflow_published_version.md) | 4 | | table |
@ -1467,6 +1467,7 @@ erDiagram
TEXT errorMessage
INTEGER id
varchar_36_ publishedVersionId
varchar_20_ reason
varchar_20_ status
datetime_3_ updatedAt
varchar_36_ workflowId

View File

@ -6,7 +6,7 @@
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "workflow_publication_outbox" ("id" integer PRIMARY KEY NOT NULL, "workflowId" varchar(36) NOT NULL, "publishedVersionId" varchar(36) NOT NULL, "status" varchar(20) NOT NULL, "errorMessage" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_workflow_publication_outbox_status" CHECK ("status" IN ('pending', 'in_progress', 'completed', 'partial_success', 'failed')))
CREATE TABLE "workflow_publication_outbox" ("id" integer PRIMARY KEY NOT NULL, "workflowId" varchar(36) NOT NULL, "publishedVersionId" varchar(36) NOT NULL, "status" varchar(20) NOT NULL, "errorMessage" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "reason" varchar(20) NOT NULL DEFAULT ('publish'), CONSTRAINT "CHK_workflow_publication_outbox_status" CHECK (("status" IN ('pending', 'in_progress', 'completed', 'partial_success', 'failed'))), CONSTRAINT "CHK_workflow_publication_outbox_reason" CHECK ("reason" IN ('publish', 'startup', 'leadership-takeover', 'reconcile')))
```
</details>
@ -19,6 +19,7 @@ CREATE TABLE "workflow_publication_outbox" ("id" integer PRIMARY KEY NOT NULL, "
| errorMessage | TEXT | | true | | | |
| id | INTEGER | | false | | | |
| publishedVersionId | varchar(36) | | false | | | |
| reason | varchar(20) | 'publish' | false | | | |
| status | varchar(20) | | false | | | |
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| workflowId | varchar(36) | | false | | | |
@ -27,7 +28,8 @@ CREATE TABLE "workflow_publication_outbox" ("id" integer PRIMARY KEY NOT NULL, "
| Name | Type | Definition |
| ---- | ---- | ---------- |
| - | CHECK | CHECK ("status" IN ('pending', 'in_progress', 'completed', 'partial_success', 'failed')) |
| - | CHECK | CHECK (("status" IN ('pending', 'in_progress', 'completed', 'partial_success', 'failed'))) |
| - | CHECK | CHECK ("reason" IN ('publish', 'startup', 'leadership-takeover', 'reconcile')) |
| id | PRIMARY KEY | PRIMARY KEY (id) |
## Indexes
@ -47,6 +49,7 @@ erDiagram
TEXT errorMessage
INTEGER id
varchar_36_ publishedVersionId
varchar_20_ reason
varchar_20_ status
datetime_3_ updatedAt
varchar_36_ workflowId

View File

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "2.35.0",
"version": "2.35.5",
"private": true,
"engines": {
"node": ">=22.22",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/agents",
"version": "0.20.0",
"version": "0.20.4",
"description": "AI agent SDK for n8n's code-first execution engine",
"engines": {
"node": ">=22.22"

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/ai-node-sdk",
"version": "0.25.0",
"version": "0.25.4",
"description": "SDK for building AI nodes in n8n",
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/ai-utilities",
"version": "0.28.0",
"version": "0.28.4",
"description": "Utilities for building AI nodes in n8n",
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/ai-workflow-builder",
"version": "1.35.0",
"version": "1.35.4",
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/api-types",
"version": "1.35.0",
"version": "1.35.4",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -352,6 +352,8 @@ export type FrontendModuleSettings = {
sandboxUnavailableReason: string | null;
/** When true, orchestrator LLM step / workflow code debug is captured (`N8N_INSTANCE_AI_RUN_DEBUG_ENABLED`). */
runDebugEnabled: boolean;
/** Whether this instance is in the activation-capped trial cohort (`N8N_INSTANCE_AI_ACTIVATION_CAPPED`). Optional. */
activationCapped?: boolean;
};
/**

View File

@ -286,6 +286,8 @@ export {
export {
buildRunWorkflowSessionGrantKey,
buildUpdateWorkflowSessionGrantKey,
buildDataTablesSessionGrantKey,
buildFetchUrlGrantKey,
FETCH_URL_ALLOW_ALL_GRANT_KEY,
WEB_SEARCH_GRANT_KEY,
@ -381,6 +383,7 @@ export {
instanceAiEvalSeedAgentSchema,
findUnbackedSeedWorkflowTools,
applyBranchReadOnlyOverrides,
deriveInstanceAiSetupState,
INSTANCE_AI_THREAD_SOURCES,
INSTANCE_AI_THREAD_SOURCE_FALLBACK,
} from './schemas/instance-ai.schema';
@ -392,6 +395,11 @@ export type {
InstanceAiCatalogProvider,
InstanceAiCatalogModel,
InstanceAiModelCatalogResponse,
InstanceAiCredits,
InstanceAiComponentSource,
InstanceAiWebSearchSource,
InstanceAiSetupState,
InstanceAiSetupStateInput,
} from './schemas/instance-ai.schema';
export type {

View File

@ -1,4 +1,4 @@
import type { ToolCategory } from '../schemas/instance-ai.schema';
import type { InstanceAiCredits, ToolCategory } from '../schemas/instance-ai.schema';
export type InstanceAiPushMessage =
| {
@ -20,9 +20,7 @@ export type InstanceAiPushMessage =
}
| {
type: 'updateInstanceAiCredits';
data: {
creditsQuota: number;
creditsClaimed: number;
data: InstanceAiCredits & {
// Present only on a per-message claim that carries the acting thread's
// running total (decimal). Grouped so the thread id and its total always
// travel together — never one without the other.

View File

@ -0,0 +1,115 @@
import { deriveInstanceAiSetupState, type InstanceAiSetupStateInput } from '../instance-ai.schema';
const unconfigured: InstanceAiSetupStateInput = {
modelEnvConfigured: false,
modelCredentialId: null,
modelName: null,
sandboxEnabled: false,
sandboxEnvConfigured: false,
sandboxProvider: 'n8n-sandbox',
daytonaCredentialId: null,
n8nSandboxCredentialId: null,
searchCredentialId: null,
searchEnvConfigured: false,
searchDisabled: false,
};
describe('deriveInstanceAiSetupState', () => {
it('reports everything as unconfigured and setup incomplete by default', () => {
expect(deriveInstanceAiSetupState(unconfigured)).toEqual({
modelSource: 'none',
sandboxSource: 'none',
sandboxType: null,
sandboxCredentialId: null,
webSearchSource: 'none',
setupCompleted: false,
});
});
it('reports UI sources and completion when every component has a credential', () => {
expect(
deriveInstanceAiSetupState({
...unconfigured,
modelCredentialId: 'model-cred',
modelName: 'gpt-4',
sandboxEnabled: true,
sandboxProvider: 'daytona',
daytonaCredentialId: 'day-cred',
searchCredentialId: 'search-cred',
}),
).toEqual({
modelSource: 'ui',
sandboxSource: 'ui',
sandboxType: 'daytona',
sandboxCredentialId: 'day-cred',
webSearchSource: 'ui',
setupCompleted: true,
});
});
it('reports env sources and completion for an env-var-only setup', () => {
expect(
deriveInstanceAiSetupState({
...unconfigured,
modelEnvConfigured: true,
sandboxEnabled: true,
sandboxEnvConfigured: true,
searchEnvConfigured: true,
}),
).toEqual({
modelSource: 'env',
sandboxSource: 'env',
sandboxType: 'n8n-sandbox',
sandboxCredentialId: null,
webSearchSource: 'env',
setupCompleted: true,
});
});
it('does not count a model credential without a model name', () => {
const state = deriveInstanceAiSetupState({
...unconfigured,
modelCredentialId: 'model-cred',
});
expect(state.modelSource).toBe('none');
expect(state.setupCompleted).toBe(false);
});
it('does not count a sandbox credential for the unselected provider', () => {
const state = deriveInstanceAiSetupState({
...unconfigured,
sandboxEnabled: true,
sandboxProvider: 'daytona',
n8nSandboxCredentialId: 'sandbox-cred',
});
expect(state.sandboxSource).toBe('none');
expect(state.sandboxCredentialId).toBeNull();
});
it('does not count a disabled sandbox as configured', () => {
const state = deriveInstanceAiSetupState({
...unconfigured,
sandboxEnabled: false,
daytonaCredentialId: 'day-cred',
sandboxProvider: 'daytona',
});
expect(state.sandboxSource).toBe('none');
expect(state.sandboxType).toBeNull();
});
it('treats an explicit web search opt-out as a decision that completes setup', () => {
const state = deriveInstanceAiSetupState({
...unconfigured,
modelEnvConfigured: true,
sandboxEnabled: true,
sandboxEnvConfigured: true,
searchDisabled: true,
});
expect(state.webSearchSource).toBe('disabled');
expect(state.setupCompleted).toBe(true);
});
});

View File

@ -1,6 +1,8 @@
import {
AI_GATEWAY_MANAGED_TAG,
applyBranchReadOnlyOverrides,
buildDataTablesSessionGrantKey,
buildUpdateWorkflowSessionGrantKey,
buildFetchUrlGrantKey,
DEFAULT_INSTANCE_AI_PERMISSIONS,
errorPayloadSchema,
@ -431,6 +433,19 @@ describe('instance-ai launch schema', () => {
});
});
describe('data-tables session grant keys', () => {
it('builds action-scoped keys matching the frontend always-allow format', () => {
expect(buildDataTablesSessionGrantKey('create')).toBe('data-tables:create');
expect(buildDataTablesSessionGrantKey('insert-rows')).toBe('data-tables:insert-rows');
});
});
describe('workflow update session grant keys', () => {
it('builds per-workflow keys matching the frontend always-allow format', () => {
expect(buildUpdateWorkflowSessionGrantKey('wf-1')).toBe('workflows:update:wf-1');
});
});
describe('domain-access grant keys', () => {
it('builds and parses per-host grant keys round-trip', () => {
const key = buildFetchUrlGrantKey('example.com');

View File

@ -16,6 +16,20 @@ import { TimeZoneSchema } from './timezone.schema';
*/
export const UNLIMITED_CREDITS = -1;
/**
* The instance's AI Assistant credit standing, as reported by `GET /instance-ai/credits`, by the
* `updateInstanceAiCredits` push and by the internal callers that pass it around.
*
* `creditsQuota` is {@link UNLIMITED_CREDITS} when credits are not metered — either the proxy is
* disabled, or the amounts are deliberately withheld from a cohort that must not see a balance.
*/
export type InstanceAiCredits = {
creditsQuota: number;
creditsClaimed: number;
/** Whether the pool has been locked by the activation cap. */
quotaLocked?: boolean;
};
/**
* Transient setup-state tag for an AI Gateway managed credential selection.
* Handlers convert this tag to the Ai Gateway managed credential shape.
@ -38,6 +52,27 @@ export function buildRunWorkflowSessionGrantKey(workflowId: string): string {
return `executions:run:${workflowId}`;
}
/**
* Builds the thread-level grant key for updating a specific workflow without HITL.
*
* Written automatically when the agent creates a workflow in this thread, so follow-up
* edits to that same artifact (same run or later runs in the session) skip the update
* approval prompt. Foreign workflows still require approval unless the admin policy is
* `always_allow`.
*/
export function buildUpdateWorkflowSessionGrantKey(workflowId: string): string {
return `workflows:update:${workflowId}`;
}
/**
* Builds the thread-level "always allow" grant key for a data-tables action
* (e.g. `create`, `insert-rows`). Must match the frontend key
* `${toolName}:${action}` so UI auto-approve and persisted grants stay aligned.
*/
export function buildDataTablesSessionGrantKey(action: string): string {
return `data-tables:${action}`;
}
// --- Domain-access grants ("always allow" for web access) ---
// These keys mirror the research tool's action names (`fetch-url`, `web-search`) the same
// way `executions:run:<id>` mirrors the executions `run` action, so a persisted grant row
@ -585,7 +620,12 @@ export const confirmationRequestPayloadSchema = z.object({
.array(workflowSetupNodeSchema)
.optional()
.describe('Per-node setup cards for workflow credential/parameter configuration'),
workflowId: z.string().optional().describe('Workflow ID for setup-workflow tool'),
workflowId: z
.string()
.optional()
.describe(
'Workflow ID for setup cards and per-workflow edit approvals (build-workflow / workflows update)',
),
resourceDecision: gatewayConfirmationRequiredPayloadSchema
.optional()
.describe('Gateway resource-access decision data (inputType=resource-decision)'),
@ -1558,6 +1598,79 @@ export interface InstanceAiAdminSettingsResponse {
browserUseEnabled: boolean;
}
export type InstanceAiComponentSource = 'ui' | 'env' | 'none';
export type InstanceAiWebSearchSource = InstanceAiComponentSource | 'disabled';
export type InstanceAiSetupStateInput = Pick<
InstanceAiAdminSettingsResponse,
| 'modelEnvConfigured'
| 'modelCredentialId'
| 'modelName'
| 'sandboxEnabled'
| 'sandboxEnvConfigured'
| 'sandboxProvider'
| 'daytonaCredentialId'
| 'n8nSandboxCredentialId'
| 'searchCredentialId'
| 'searchEnvConfigured'
| 'searchDisabled'
>;
export interface InstanceAiSetupState {
modelSource: InstanceAiComponentSource;
sandboxSource: InstanceAiComponentSource;
sandboxType: InstanceAiSandboxProvider | null;
/** Credential assigned for the selected sandbox provider; a credential for the other provider does not count. */
sandboxCredentialId: string | null;
webSearchSource: InstanceAiWebSearchSource;
/** Model and sandbox configured, and web search decided (configured or explicitly disabled). */
setupCompleted: boolean;
}
/**
* How each AI Assistant setup component is configured, derived from the admin
* settings response. Single source of truth for the setup gate and the setup
* telemetry snapshot, on both backend and frontend. The response already
* resolves precedence (credential ids are null when env config wins), so env
* before ui here does not shadow a UI selection.
*/
export function deriveInstanceAiSetupState(
settings: InstanceAiSetupStateInput,
): InstanceAiSetupState {
const modelSource: InstanceAiComponentSource = settings.modelEnvConfigured
? 'env'
: settings.modelCredentialId && settings.modelName
? 'ui'
: 'none';
const sandboxCredentialId =
settings.sandboxProvider === 'daytona'
? settings.daytonaCredentialId
: settings.n8nSandboxCredentialId;
const sandboxSource: InstanceAiComponentSource = !settings.sandboxEnabled
? 'none'
: settings.sandboxEnvConfigured
? 'env'
: sandboxCredentialId
? 'ui'
: 'none';
const webSearchSource: InstanceAiWebSearchSource = settings.searchCredentialId
? 'ui'
: settings.searchEnvConfigured
? 'env'
: settings.searchDisabled
? 'disabled'
: 'none';
return {
modelSource,
sandboxSource,
sandboxType: sandboxSource === 'none' ? null : settings.sandboxProvider,
sandboxCredentialId,
webSearchSource,
setupCompleted:
modelSource !== 'none' && sandboxSource !== 'none' && webSearchSource !== 'none',
};
}
/**
* Inline provider-connection payload: the credential type plus its field
* values. `null` clears the connection (and falls back to env config).

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-common",
"version": "1.35.0",
"version": "1.35.4",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-network",
"version": "1.9.0",
"version": "1.9.4",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -0,0 +1,78 @@
import type { HttpsProxyAgent } from 'https-proxy-agent';
import type http from 'node:http';
import { makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
import { installProxyConnectionGuard } from '../connection-guards';
type ConnectOpts = Parameters<HttpsProxyAgent<string>['connect']>[1];
describe('installProxyConnectionGuard', () => {
function guarded(connectOpts: { host?: string | null; hostname?: string | null }) {
const connect = vi.fn().mockResolvedValue('SOCKET');
const agent = { connectOpts, connect };
return { agent, connect };
}
it.each([
['host', { host: 'proxy.internal' }],
['hostname', { hostname: 'proxy.internal' }],
])(
'reads the proxy host from `%s` and delegates when the policy allows it',
async (_label, connectOpts) => {
const bridge = makeSsrfBridge();
const { agent, connect } = guarded(connectOpts);
installProxyConnectionGuard(agent, bridge);
const req = {} as http.ClientRequest;
const opts = {} as ConnectOpts;
await expect(agent.connect(req, opts)).resolves.toBe('SOCKET');
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('proxy.internal');
expect(connect).toHaveBeenCalledWith(req, opts);
},
);
it('rejects the connection when the policy denies the proxy host', async () => {
const error = new Error('blocked');
const bridge = makeSsrfBridge({
validateConnectionHost: vi.fn().mockReturnValue({ ok: false, error }),
});
const { agent, connect } = guarded({ host: '169.254.169.254' });
installProxyConnectionGuard(agent, bridge);
await expect(agent.connect({} as http.ClientRequest, {} as ConnectOpts)).rejects.toBe(error);
expect(connect).not.toHaveBeenCalled();
});
it('validates the proxy host on every connection, not once at installation', async () => {
const bridge = makeSsrfBridge();
const { agent } = guarded({ host: 'proxy.internal' });
installProxyConnectionGuard(agent, bridge);
await agent.connect({} as http.ClientRequest, {} as ConnectOpts);
agent.connectOpts.host = 'other-proxy.internal';
await agent.connect({} as http.ClientRequest, {} as ConnectOpts);
expect(bridge.validateConnectionHost).toHaveBeenNthCalledWith(1, 'proxy.internal');
expect(bridge.validateConnectionHost).toHaveBeenNthCalledWith(2, 'other-proxy.internal');
});
it.each([
['missing', {}],
['null', { host: null, hostname: null }],
['empty', { host: '' }],
['empty while `hostname` is set', { host: '', hostname: 'proxy.internal' }],
])('rejects the connection when the proxy host is %s', async (_label, connectOpts) => {
const bridge = makeSsrfBridge();
const { agent, connect } = guarded(connectOpts);
installProxyConnectionGuard(agent, bridge);
await expect(agent.connect({} as http.ClientRequest, {} as ConnectOpts)).rejects.toThrow(
'Cannot determine the host for this connection',
);
expect(connect).not.toHaveBeenCalled();
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
});
});

View File

@ -1,24 +1,47 @@
import http from 'node:http';
import type { LookupFunction } from 'node:net';
import type { MockInstance } from 'vitest';
import { makeLookupFn } from '../../ssrf/__tests__/mock-ssrf-bridge';
import { EnvProxyHttpAgent } from '../env-proxy-http-agent';
// Routing/caching is covered in env-proxy-router.test.ts; here we only assert
// the agent's wiring: delegate to the resolved proxy agent, else dispatch
// directly via `super.addRequest`. The proxy agent is mocked and `getProxyForUrl`
// drives which branch runs, so nothing hits the network.
const { getProxyForUrl, proxyAddRequest } = vi.hoisted(() => ({
const { getProxyForUrl, proxyAddRequest, proxyConnect, proxyAgents } = vi.hoisted(() => ({
getProxyForUrl: vi.fn<(url: string) => string>(),
proxyAddRequest: vi.fn(),
proxyConnect: vi.fn(),
proxyAgents: [] as unknown[],
}));
vi.mock('proxy-from-env', () => ({ getProxyForUrl }));
vi.mock('http-proxy-agent', () => ({
HttpProxyAgent: class {
connectOpts: { host: string };
addRequest = proxyAddRequest;
constructor(
proxyUrl: string,
readonly agentOptions?: { lookup?: unknown },
) {
this.connectOpts = { host: new URL(proxyUrl).hostname };
proxyAgents.push(this);
}
async connect(...args: unknown[]): Promise<unknown> {
return await proxyConnect(...args);
}
},
}));
type CreatedProxyAgent = {
agentOptions?: { lookup?: unknown };
connect: (req: unknown, opts: unknown) => Promise<unknown>;
};
const req = {} as http.ClientRequest;
const options = (o: Partial<http.RequestOptions>): http.RequestOptions => o as http.RequestOptions;
@ -28,6 +51,9 @@ describe('EnvProxyHttpAgent', () => {
beforeEach(() => {
getProxyForUrl.mockReset();
proxyAddRequest.mockReset();
proxyConnect.mockReset();
proxyConnect.mockResolvedValue('SOCKET');
proxyAgents.length = 0;
// `super.addRequest` is the only path that would open a real socket.
// `addRequest` is an internal Agent method untyped on the public types.
superAddRequest = vi
@ -57,4 +83,37 @@ describe('EnvProxyHttpAgent', () => {
expect(proxyAddRequest).not.toHaveBeenCalled();
expect(superAddRequest).toHaveBeenCalledWith(req, opts);
});
describe('SSRF scope of the resolved proxy agent', () => {
beforeEach(() => {
getProxyForUrl.mockReturnValue('http://proxy.internal:3128');
});
function resolvedProxyAgent(lookup?: LookupFunction): CreatedProxyAgent {
new EnvProxyHttpAgent(lookup).addRequest(req, options({ host: 'a.example', port: 80 }));
return proxyAgents[0] as CreatedProxyAgent;
}
it.each([
['a lookup is given', () => makeLookupFn()],
['no lookup is given', () => undefined],
])('opens the connection to the proxy unchecked when %s', async (_label, makeLookup) => {
const lookup = makeLookup();
const proxyAgent = resolvedProxyAgent(lookup);
await expect(proxyAgent.connect(req, {})).resolves.toBe('SOCKET');
expect(proxyAgent.agentOptions?.lookup).toBeUndefined();
});
it('keeps the secure lookup on its own direct pool', () => {
const lookupFn = makeLookupFn();
const agent = new EnvProxyHttpAgent(lookupFn) as unknown as {
options: { lookup?: unknown };
};
expect(agent.options.lookup).toBe(lookupFn);
});
});
});

View File

@ -1,25 +1,48 @@
import type http from 'node:http';
import https from 'node:https';
import type { LookupFunction } from 'node:net';
import type { MockInstance } from 'vitest';
import { makeLookupFn } from '../../ssrf/__tests__/mock-ssrf-bridge';
import { EnvProxyHttpsAgent } from '../env-proxy-https-agent';
// Routing/caching is covered in env-proxy-router.test.ts; here we only assert
// the agent's wiring: delegate to the resolved proxy agent, else dispatch
// directly via `super.addRequest`. The proxy agent is mocked and `getProxyForUrl`
// drives which branch runs, so nothing hits the network.
const { getProxyForUrl, proxyAddRequest } = vi.hoisted(() => ({
const { getProxyForUrl, proxyAddRequest, proxyConnect, proxyAgents } = vi.hoisted(() => ({
getProxyForUrl: vi.fn<(url: string) => string>(),
proxyAddRequest: vi.fn(),
proxyConnect: vi.fn(),
proxyAgents: [] as unknown[],
}));
vi.mock('proxy-from-env', () => ({ getProxyForUrl }));
vi.mock('https-proxy-agent', () => ({
HttpsProxyAgent: class {
connectOpts: { host: string };
addRequest = proxyAddRequest;
constructor(
proxyUrl: string,
readonly agentOptions?: { lookup?: unknown },
) {
this.connectOpts = { host: new URL(proxyUrl).hostname };
proxyAgents.push(this);
}
async connect(...args: unknown[]): Promise<unknown> {
return await proxyConnect(...args);
}
},
}));
type CreatedProxyAgent = {
agentOptions?: { lookup?: unknown };
connect: (req: unknown, opts: unknown) => Promise<unknown>;
};
const req = {} as http.ClientRequest;
const options = (o: Partial<https.RequestOptions>): https.RequestOptions =>
o as https.RequestOptions;
@ -30,6 +53,9 @@ describe('EnvProxyHttpsAgent', () => {
beforeEach(() => {
getProxyForUrl.mockReset();
proxyAddRequest.mockReset();
proxyConnect.mockReset();
proxyConnect.mockResolvedValue('SOCKET');
proxyAgents.length = 0;
// `super.addRequest` is the only path that would open a real socket.
// `addRequest` is an internal Agent method untyped on the public types.
superAddRequest = vi
@ -59,4 +85,37 @@ describe('EnvProxyHttpsAgent', () => {
expect(proxyAddRequest).not.toHaveBeenCalled();
expect(superAddRequest).toHaveBeenCalledWith(req, opts);
});
describe('SSRF scope of the resolved proxy agent', () => {
beforeEach(() => {
getProxyForUrl.mockReturnValue('http://proxy.internal:3128');
});
function resolvedProxyAgent(lookup?: LookupFunction): CreatedProxyAgent {
new EnvProxyHttpsAgent(lookup).addRequest(req, options({ host: 'a.example', port: 443 }));
return proxyAgents[0] as CreatedProxyAgent;
}
it.each([
['a lookup is given', () => makeLookupFn()],
['no lookup is given', () => undefined],
])('opens the connection to the proxy unchecked when %s', async (_label, makeLookup) => {
const lookup = makeLookup();
const proxyAgent = resolvedProxyAgent(lookup);
await expect(proxyAgent.connect(req, {})).resolves.toBe('SOCKET');
expect(proxyAgent.agentOptions?.lookup).toBeUndefined();
});
it('keeps the secure lookup on its own direct pool', () => {
const lookupFn = makeLookupFn();
const agent = new EnvProxyHttpsAgent(lookupFn) as unknown as {
options: { lookup?: unknown };
};
expect(agent.options.lookup).toBe(lookupFn);
});
});
});

View File

@ -1,13 +1,21 @@
import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import dns from 'node:dns';
import http from 'node:http';
import https from 'node:https';
import type { LookupFunction } from 'node:net';
import type { SsrfBridge } from '../../ssrf';
import { makeLookupFn, makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
import {
makeDenyingLookup,
makeLookupFn,
makeSsrfBridge,
} from '../../ssrf/__tests__/mock-ssrf-bridge';
import { installConnectionGuard, UnknownConnectionHostError } from '../connection-guards';
import { EnvProxyHttpAgent } from '../env-proxy-http-agent';
import { EnvProxyHttpsAgent } from '../env-proxy-https-agent';
import { buildNodeAgents, installConnectionGuard } from '../node-agents';
import { type LocalServer, startServer } from '../local-server';
import { buildNodeAgents, type ProxyUrl } from '../node-agents';
// HttpsProxyAgent stores `lookup` in `connectOpts` rather than `options`
// (unlike http.Agent and HttpProxyAgent which use `options`).
@ -68,6 +76,22 @@ describe('buildNodeAgents', () => {
expect(getAgentOptions(httpAgent).keepAlive).toBe(true);
});
it('proxy: explicit URL → keeps the target TLS options off the proxy connection', () => {
const agents = buildNodeAgents('https://proxy.internal:3128', 'disabled', {
servername: 'target.example.com',
ca: 'TARGET_CA',
rejectUnauthorized: false,
});
for (const agent of [agents.httpAgent, agents.httpsAgent]) {
const { connectOpts } = agent as unknown as { connectOpts: Record<string, unknown> };
expect(connectOpts).toMatchObject({ host: 'proxy.internal', port: 3128 });
expect(connectOpts).not.toHaveProperty('servername');
expect(connectOpts).not.toHaveProperty('ca');
expect(connectOpts).not.toHaveProperty('rejectUnauthorized');
}
});
});
describe('rejects a caller-provided lookup (managed by the SSRF policy)', () => {
@ -87,39 +111,25 @@ describe('buildNodeAgents', () => {
});
});
describe('SSRF lookup placement (direct connections only)', () => {
it('proxy: false → injects the secure lookup on both agents', () => {
describe('SSRF lookup placement', () => {
const modes: Array<[string, false | 'env' | ProxyUrl]> = [
['proxy: false', false],
['proxy: env', 'env'],
['proxy: explicit URL', 'http://proxy.internal:3128'],
];
it.each(modes)('%s → injects the secure lookup on both agents', (_label, proxy) => {
const lookupFn = makeLookupFn();
const bridge = makeSsrfBridge({ createSecureLookup: vi.fn().mockReturnValue(lookupFn) });
const { httpAgent, httpsAgent } = buildNodeAgents(false, bridge);
const { httpAgent, httpsAgent } = buildNodeAgents(proxy, bridge);
expect(getAgentLookup(httpAgent)).toBe(lookupFn);
expect(getAgentLookup(httpsAgent)).toBe(lookupFn);
});
it('proxy: env → injects the secure lookup for the direct path', () => {
const lookupFn = makeLookupFn();
const bridge = makeSsrfBridge({ createSecureLookup: vi.fn().mockReturnValue(lookupFn) });
const { httpAgent, httpsAgent } = buildNodeAgents('env', bridge);
expect(getAgentLookup(httpAgent)).toBe(lookupFn);
expect(getAgentLookup(httpsAgent)).toBe(lookupFn);
});
it('proxy: explicit URL → does NOT inject the lookup (proxy validates the target)', () => {
const lookupFn = makeLookupFn();
const bridge = makeSsrfBridge({ createSecureLookup: vi.fn().mockReturnValue(lookupFn) });
const { httpAgent, httpsAgent } = buildNodeAgents('http://proxy.internal:3128', bridge);
expect(getAgentLookup(httpAgent)).toBeUndefined();
expect(getAgentLookup(httpsAgent)).toBeUndefined();
});
it('ssrf disabled → no lookup on the direct path', () => {
const { httpAgent, httpsAgent } = buildNodeAgents('env', 'disabled');
it.each(modes)('%s with SSRF disabled → no lookup on either agent', (_label, proxy) => {
const { httpAgent, httpsAgent } = buildNodeAgents(proxy, 'disabled');
expect(getAgentLookup(httpAgent)).toBeUndefined();
expect(getAgentLookup(httpsAgent)).toBeUndefined();
@ -169,6 +179,24 @@ describe('buildNodeAgents', () => {
expect(socket).toBe('SOCKET');
});
it.each([
['missing', {}],
['null', { host: null, hostname: null }],
['empty', { host: '' }],
['empty while `hostname` is set', { host: '', hostname: 'target.example' }],
])('rejects the connection when the host is %s', (_label, options) => {
const bridge = makeSsrfBridge();
const { createConnection, original } = guarded(bridge);
const onCreate = vi.fn();
const result = createConnection(options, onCreate);
expect(onCreate).toHaveBeenCalledWith(expect.any(UnknownConnectionHostError));
expect(original).not.toHaveBeenCalled();
expect(result).toBeUndefined();
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
});
it('passes the raw host through to the bridge (normalization is the service’s job)', () => {
const bridge = makeSsrfBridge();
const { createConnection } = guarded(bridge);
@ -198,4 +226,107 @@ describe('buildNodeAgents', () => {
},
);
});
describe('proxy host validation (explicit proxy URL)', () => {
let proxyServer: LocalServer;
let proxyByIp: ProxyUrl;
let proxyByHostname: ProxyUrl;
const realLookup = () => dns.lookup as unknown as LookupFunction;
async function getThroughAgent(agent: http.Agent): Promise<string> {
return await new Promise((resolve, reject) => {
const req = http.get('http://proxied-target.invalid/x', { agent, timeout: 3000 }, (res) => {
let data = '';
res.on('data', (chunk) => (data += String(chunk)));
res.on('end', () => resolve(data));
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('timeout'));
});
});
}
beforeEach(async () => {
proxyServer = await startServer((_req, res) => {
res.setHeader('Content-Type', 'text/plain');
res.end('proxied');
});
const { port } = new URL(proxyServer.url);
proxyByIp = `http://127.0.0.1:${port}`;
proxyByHostname = `http://localhost:${port}`;
});
afterEach(async () => {
await proxyServer.close();
});
it('rejects a proxy host the SSRF policy denies, without reaching the proxy', async () => {
const error = new Error(
'The request was blocked because it resolves to a restricted IP address',
);
const bridge = makeSsrfBridge({
createSecureLookup: realLookup,
validateConnectionHost: vi.fn().mockReturnValue({ ok: false, error }),
});
const { httpAgent } = buildNodeAgents(proxyByIp, bridge);
await expect(getThroughAgent(httpAgent)).rejects.toThrow(error.message);
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('127.0.0.1');
expect(proxyServer.captured).toEqual([]);
});
it.each(['httpAgent', 'httpsAgent'] as const)(
'validates the proxy host before the %s opens its connection',
async (which) => {
const error = new Error('blocked');
const bridge = makeSsrfBridge({
validateConnectionHost: vi.fn().mockReturnValue({ ok: false, error }),
});
const agents = buildNodeAgents('http://proxy.internal:3128', bridge);
const agent = agents[which] as unknown as {
connect: (req: unknown, opts: unknown) => Promise<unknown>;
};
await expect(agent.connect({}, {})).rejects.toBe(error);
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('proxy.internal');
},
);
it('rejects a proxy hostname that resolves to a restricted address', async () => {
const error = new Error(
'The request was blocked because it resolves to a restricted IP address',
);
const lookup = makeDenyingLookup(error);
const bridge = makeSsrfBridge({ createSecureLookup: () => lookup });
const { httpAgent } = buildNodeAgents(proxyByHostname, bridge);
await expect(getThroughAgent(httpAgent)).rejects.toThrow(error.message);
expect(lookup).toHaveBeenCalledWith('localhost', expect.any(Object), expect.any(Function));
expect(proxyServer.captured).toEqual([]);
});
it('connects through a proxy host the SSRF policy allows', async () => {
const bridge = makeSsrfBridge({ createSecureLookup: realLookup });
const { httpAgent } = buildNodeAgents(proxyByIp, bridge);
await expect(getThroughAgent(httpAgent)).resolves.toBe('proxied');
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('127.0.0.1');
expect(proxyServer.captured).toEqual(['http://proxied-target.invalid/x']);
});
it('connects through the proxy unchanged when SSRF protection is disabled', async () => {
const { httpAgent } = buildNodeAgents(proxyByHostname, 'disabled');
await expect(getThroughAgent(httpAgent)).resolves.toBe('proxied');
expect(proxyServer.captured).toEqual(['http://proxied-target.invalid/x']);
});
});
});

View File

@ -1,8 +1,8 @@
import type { Logger } from '@n8n/backend-common';
import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import http from 'node:http';
import https from 'node:https';
import type http from 'node:http';
import type https from 'node:https';
import { mock } from 'vitest-mock-extended';
import type { SsrfProtectionService } from '../../ssrf';
@ -30,15 +30,9 @@ function getAgentLookup(agent: http.Agent | https.Agent): unknown {
// ---------------------------------------------------------------------------
describe('getNodeAgent', () => {
it('proxy: false → plain http/https.Agent (no proxy class)', () => {
const { httpAgent, httpsAgent } = makeFacade().transport({ proxy: false }).getNodeAgent();
expect(httpAgent).toBeInstanceOf(http.Agent);
expect(httpsAgent).toBeInstanceOf(https.Agent);
expect(httpAgent).not.toBeInstanceOf(HttpProxyAgent);
expect(httpsAgent).not.toBeInstanceOf(HttpsProxyAgent);
});
// Proxy-mode → agent-class matrix is owned by `node-agents.test.ts`
// (`buildNodeAgents` → `describe('agent classes per proxy mode')`); this is a
// representative case proving the facade forwards to it correctly.
it('proxy: explicit URL → HttpProxyAgent / HttpsProxyAgent', () => {
const { httpAgent, httpsAgent } = makeFacade()
.transport({ proxy: 'http://proxy.internal:3128' })
@ -48,13 +42,6 @@ describe('getNodeAgent', () => {
expect(httpsAgent).toBeInstanceOf(HttpsProxyAgent);
});
it('proxy: env → custom env-routing agents (http/https.Agent subclasses)', () => {
const { httpAgent, httpsAgent } = makeFacade().transport({ proxy: 'env' }).getNodeAgent();
expect(httpAgent).toBeInstanceOf(http.Agent);
expect(httpsAgent).toBeInstanceOf(https.Agent);
});
it('returns the same agent instances on repeated calls', () => {
const client = makeFacade().transport();
const a1 = client.getNodeAgent();
@ -79,62 +66,29 @@ describe('getNodeAgent', () => {
// ---------------------------------------------------------------------------
describe('getNodeAgent SSRF lookup injection', () => {
describe('proxy: false', () => {
it('injects createSecureLookup when SSRF is enabled', () => {
const lookupFn = makeLookupFn();
const bridge = makeSsrfBridge({
createSecureLookup: vi.fn().mockReturnValue(lookupFn),
});
const { httpAgent, httpsAgent } = makeFacade()
.transport({ ssrf: bridge, proxy: false })
.getNodeAgent();
expect(bridge.createSecureLookup).toHaveBeenCalledTimes(1);
expect(getAgentLookup(httpAgent)).toBe(lookupFn);
expect(getAgentLookup(httpsAgent)).toBe(lookupFn);
// The full proxy-mode matrix is owned by `node-agents.test.ts`
// (`buildNodeAgents` → `describe('SSRF lookup placement')`); this is a
// representative case proving the facade forwards to it correctly.
it('the agents carry the lookup created by the SSRF bridge', () => {
const lookupFn = makeLookupFn();
const bridge = makeSsrfBridge({
createSecureLookup: vi.fn().mockReturnValue(lookupFn),
});
it('does NOT inject lookup when SSRF is disabled', () => {
const { httpAgent, httpsAgent } = makeFacade()
.transport({ ssrf: 'disabled', proxy: false })
.getNodeAgent();
const { httpAgent, httpsAgent } = makeFacade()
.transport({ ssrf: bridge, proxy: false })
.getNodeAgent();
expect(getAgentLookup(httpAgent)).toBeUndefined();
expect(getAgentLookup(httpsAgent)).toBeUndefined();
});
expect(getAgentLookup(httpAgent)).toBe(lookupFn);
expect(getAgentLookup(httpsAgent)).toBe(lookupFn);
});
describe('proxy: explicit URL', () => {
it('does NOT inject lookup behind an explicit proxy (proxy validates the target)', () => {
const lookupFn = makeLookupFn();
const bridge = makeSsrfBridge({
createSecureLookup: vi.fn().mockReturnValue(lookupFn),
});
const { httpAgent, httpsAgent } = makeFacade()
.transport({ ssrf: bridge, proxy: 'http://proxy.internal:3128' })
.getNodeAgent();
it('no lookup when SSRF is disabled', () => {
const { httpAgent, httpsAgent } = makeFacade()
.transport({ ssrf: 'disabled', proxy: false })
.getNodeAgent();
// SSRF lookup is applied to direct connections only. Behind a proxy it
// would resolve the proxy host, not the final target, so it is omitted.
expect(getAgentLookup(httpAgent)).toBeUndefined();
expect(getAgentLookup(httpsAgent)).toBeUndefined();
});
});
describe('proxy: env', () => {
it('injects createSecureLookup when SSRF is enabled', () => {
const lookupFn = makeLookupFn();
const bridge = makeSsrfBridge({
createSecureLookup: vi.fn().mockReturnValue(lookupFn),
});
const { httpAgent, httpsAgent } = makeFacade()
.transport({ ssrf: bridge, proxy: 'env' })
.getNodeAgent();
expect(bridge.createSecureLookup).toHaveBeenCalledTimes(1);
// EnvProxy* agents inherit from http/https.Agent and pass lookup to super()
expect(getAgentLookup(httpAgent)).toBe(lookupFn);
expect(getAgentLookup(httpsAgent)).toBe(lookupFn);
});
expect(getAgentLookup(httpAgent)).toBeUndefined();
expect(getAgentLookup(httpsAgent)).toBeUndefined();
});
});

View File

@ -5,13 +5,18 @@ import type { Dispatcher } from 'undici';
import { mock } from 'vitest-mock-extended';
import type { SsrfBridge, SsrfProtectionService } from '../../ssrf';
import { makeLookupFn, makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
import {
makeDenyingLookup,
makeSsrfBridge,
useCleanProxyEnv,
} from '../../ssrf/__tests__/mock-ssrf-bridge';
import { type LocalServer, startServer } from '../local-server';
import { OutboundHttp } from '../outbound-http';
import { createSsrfInterceptor } from '../undici/transport';
// SSRF enforcement lives in a single place: the dispatcher interceptor.
// This file proves it at two levels:
// SSRF enforcement on the dispatch path lives in the dispatcher interceptor
// (an explicit proxy URI is additionally checked when the dispatcher is built).
// This file proves the interceptor at two levels:
// (a) a direct unit test of `createSsrfInterceptor`, and
// (b) end-to-end tests against a real local server (no mocked `fetch`), so the
// interceptor actually runs and we assert that a 30x cannot smuggle a
@ -49,6 +54,8 @@ function makeOpts(path: string, origin?: string) {
return { path, origin } as unknown as Dispatcher.DispatchOptions;
}
useCleanProxyEnv();
describe('createSsrfInterceptor', () => {
it('validates the reconstructed target URL and dispatches when allowed', async () => {
const bridge = makeSsrfBridge();
@ -89,7 +96,7 @@ describe('createSsrfInterceptor', () => {
expect(bridge.validateUrl).not.toHaveBeenCalled();
expect(innerDispatch).not.toHaveBeenCalled();
expect(handler.onResponseError).toHaveBeenCalled();
expect(handler.onResponseError).toHaveBeenCalledWith(null, expect.any(TypeError));
});
it('falls back to onError when onResponseError is unavailable', async () => {
@ -309,14 +316,123 @@ describe('connect-time secure lookup (DNS rebinding)', () => {
await expect(fetchFn('http://rebind.example/')).rejects.toThrow();
});
it('does not derive a connect-time lookup behind an explicit proxy', () => {
const createSecureLookup = vi.fn(makeLookupFn);
const bridge = makeSsrfBridge({ createSecureLookup });
it('resolves the proxy host through the secure lookup behind an explicit proxy', async () => {
const denied = new Error('blocked: restricted IP address');
const lookupSpy = makeDenyingLookup(denied);
const bridge = makeSsrfBridge({ createSecureLookup: () => lookupSpy });
const fetchFn = makeTransport({
ssrf: bridge,
proxy: 'http://proxy.internal:3128',
}).asCustomFetch();
// Forces the lazy dispatcher to build. The proxy resolves the target, so
// the secure lookup must not be consulted on our side.
makeTransport({ ssrf: bridge, proxy: 'http://proxy.invalid:3128' }).getDispatcher();
const rejection = await fetchFn('http://target.invalid/x').catch((e: unknown) => e);
expect(createSecureLookup).not.toHaveBeenCalled();
expect(lookupSpy).toHaveBeenCalledWith('proxy.internal', expect.anything(), expect.anything());
expect(rootCauseMessage(rejection)).toBe(denied.message);
});
});
describe('proxy host validation', () => {
const realLookup = () => dns.lookup as unknown as LookupFunction;
function denyingBridge(error: Error) {
return makeSsrfBridge({
createSecureLookup: realLookup,
validateConnectionHost: vi.fn().mockReturnValue({ ok: false, error }),
});
}
function bridgeDenyingProxyHost(deniedHostname: string) {
const error = new Error('The proxy host is not permitted by policy');
const bridge = makeSsrfBridge({
createSecureLookup: realLookup,
validateUrl: vi.fn(async (url: string | URL) => {
const hostname = typeof url === 'string' ? new URL(url).hostname : url.hostname;
return await Promise.resolve(
hostname === deniedHostname
? { ok: false as const, error }
: { ok: true as const, result: undefined },
);
}),
});
return { bridge, error };
}
it('rejects an explicit proxy host the policy denies', () => {
const error = new Error('The proxy host is not permitted by policy');
const bridge = denyingBridge(error);
expect(() =>
makeTransport({ ssrf: bridge, proxy: 'http://127.0.0.1:3128' }).getDispatcher(),
).toThrow(error.message);
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('127.0.0.1');
});
it('surfaces the rejection of an explicit proxy host through fetch', async () => {
const error = new Error('The proxy host is not permitted by policy');
const fetchFn = makeTransport({
ssrf: denyingBridge(error),
proxy: 'http://127.0.0.1:3128',
}).asCustomFetch();
await expect(fetchFn('http://target.invalid/x')).rejects.toThrow(error.message);
});
// The environment's proxies describe the deployment, so the policy that decides
// which targets a workflow may reach does not decide them.
it.each([
['HTTP_PROXY', 'http://target.invalid/x'],
['HTTPS_PROXY', 'https://target.invalid/x'],
] as const)('leaves a proxy configured through %s unchecked', async (envKey, target) => {
process.env[envKey] = 'http://127.0.0.1:3128';
const lookupSpy = makeDenyingLookup(new Error('blocked: restricted IP address'));
const { bridge } = bridgeDenyingProxyHost('127.0.0.1');
bridge.createSecureLookup = () => lookupSpy;
const transport = makeTransport({ ssrf: bridge, proxy: 'env' });
expect(() => transport.getDispatcher()).not.toThrow();
await transport
.asCustomFetch()(target)
.catch(() => undefined);
const validated = vi.mocked(bridge.validateUrl).mock.calls.map(([url]) => String(url));
expect(validated).toEqual([target]);
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
expect(lookupSpy).not.toHaveBeenCalled();
});
it('leaves an explicit proxy unchecked when SSRF protection is disabled', () => {
const bridge = denyingBridge(new Error('The proxy host is not permitted by policy'));
expect(() =>
makeTransport({ ssrf: 'disabled', proxy: 'http://127.0.0.1:3128' }).getDispatcher(),
).not.toThrow();
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
});
it('serves a target the environment exempts from the direct path', async () => {
const server = await startServer((_req, res) => {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('ok');
});
process.env.HTTP_PROXY = 'http://proxy.internal:3128';
process.env.NO_PROXY = '127.0.0.1';
const { bridge } = bridgeDenyingProxyHost('proxy.internal');
const fetchFn = makeTransport({ ssrf: bridge, proxy: 'env' }).asCustomFetch();
try {
const res = await fetchFn(`${server.url}/x`);
expect(res.status).toBe(200);
} finally {
await server.close();
}
});
it('leaves the dispatcher untouched when no proxy is configured in the environment', () => {
const bridge = makeSsrfBridge({ createSecureLookup: realLookup });
expect(() => makeTransport({ ssrf: bridge, proxy: 'env' }).getDispatcher()).not.toThrow();
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
});
});

View File

@ -4,7 +4,7 @@ import dns from 'node:dns';
import http from 'node:http';
import type { LookupFunction } from 'node:net';
import { makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
import { makeSsrfBridge, useCleanProxyEnv } from '../../ssrf/__tests__/mock-ssrf-bridge';
import { getBeforeRedirectFn, setAxiosAgents } from '../axios/utils';
import { type LocalServer, startServer } from '../local-server';
import { buildNodeAgents } from '../node-agents';
@ -31,9 +31,10 @@ async function httpGetWithAgent(url: string, agent: http.Agent): Promise<string>
}
describe('outbound transport integration', () => {
useCleanProxyEnv();
let target: LocalServer;
let proxy: LocalServer;
const ORIGINAL_ENV = { ...process.env };
beforeAll(async () => {
target = await startServer((req, res) => {
@ -58,18 +59,10 @@ describe('outbound transport integration', () => {
});
beforeEach(() => {
delete process.env.HTTP_PROXY;
delete process.env.HTTPS_PROXY;
delete process.env.NO_PROXY;
delete process.env.ALL_PROXY;
target.clear();
proxy.clear();
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
describe('setAxiosAgents routing', () => {
it('routes through an explicit custom proxy', async () => {
const config: AxiosRequestConfig = { url: `${target.url}/x`, method: 'GET', proxy: false };
@ -120,7 +113,7 @@ describe('outbound transport integration', () => {
});
});
describe('SSRF secure lookup is applied to direct connections only', () => {
describe('SSRF secure lookup placement', () => {
it('invokes the secure lookup for a direct (hostname) connection', async () => {
const lookupSpy = vi.fn((hostname: string, options: dns.LookupOptions, onResult: unknown) =>
dns.lookup(hostname, options, onResult as never),
@ -142,8 +135,17 @@ describe('outbound transport integration', () => {
expect(lookupSpy).toHaveBeenCalledWith('localhost', expect.anything(), expect.anything());
});
it('does NOT invoke the secure lookup when routed through a proxy', async () => {
process.env.HTTP_PROXY = proxy.url;
// An explicit proxy URL can come from a request, so its host is resolved
// through the policy; the environment's is part of the deployment and is not.
it.each([
['leaves the env proxy host to the platform resolver', true],
['invokes the secure lookup for an explicit proxy host', false],
])('%s', async (_case, fromEnv) => {
const { port } = new URL(proxy.url);
const proxyUrl = `http://localhost:${port}`;
if (fromEnv) {
process.env.HTTP_PROXY = proxyUrl;
}
const lookupSpy = vi.fn((hostname: string, options: dns.LookupOptions, onResult: unknown) =>
dns.lookup(hostname, options, onResult as never),
);
@ -156,14 +158,21 @@ describe('outbound transport integration', () => {
method: 'GET',
proxy: false,
};
setAxiosAgents(config, undefined, undefined, bridge);
setAxiosAgents(config, undefined, fromEnv ? undefined : proxyUrl, bridge);
const res = await axios<{ message: string }>(config);
expect(res.data.message).toBe('proxied');
// The proxy host is an IP (no lookup) and the proxy resolves the final
// target, so the secure lookup is never consulted on our side.
expect(lookupSpy).not.toHaveBeenCalled();
if (fromEnv) {
expect(lookupSpy).not.toHaveBeenCalled();
} else {
expect(lookupSpy).toHaveBeenCalledWith('localhost', expect.anything(), expect.anything());
}
expect(lookupSpy).not.toHaveBeenCalledWith(
'proxied-target.invalid',
expect.anything(),
expect.anything(),
);
});
});

View File

@ -165,6 +165,16 @@ describe('buildAxiosConfigFromLegacyRequest', () => {
expect((axiosOptions.httpsAgent as HttpsAgent).options.servername).toEqual('example.de');
});
// SNI takes a hostname, never an IP literal (RFC 6066 §3).
test.each([
['IPv4', 'https://185.90.154.8/foo'],
['IPv6', 'https://[2001:db8::1]/foo'],
])('should set no SNI when the host is an %s literal', async (_label, url) => {
const axiosOptions = await buildAxiosConfigFromLegacyRequest({ url });
expect((axiosOptions.httpsAgent as HttpsAgent).options.servername).toBeUndefined();
});
describe('should set SSL certificates', () => {
const agentOptions: SecureContextOptions = {
ca: TEST_CA_CERT,

View File

@ -1,8 +1,10 @@
import type { Logger } from '@n8n/backend-common';
import dns from 'node:dns';
import type { LookupFunction } from 'node:net';
import { mock } from 'vitest-mock-extended';
import type { SsrfBridge } from '../../../ssrf';
import { makeSsrfBridge } from '../../../ssrf/__tests__/mock-ssrf-bridge';
import { makeSsrfBridge, useCleanProxyEnv } from '../../../ssrf/__tests__/mock-ssrf-bridge';
import { executeLegacyRequest } from '../../legacy-request';
import { type LocalServer, startServer } from '../../local-server';
import { configureGlobalAxiosDefaults } from '../config';
@ -16,8 +18,6 @@ import { httpRequest } from '../request';
configureGlobalAxiosDefaults();
const PROXY_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY'] as const;
async function startRedirectServer(): Promise<LocalServer> {
let serverUrl = '';
const server = await startServer((req, res) => {
@ -67,13 +67,11 @@ function makeBridge(blockedPath: string): { bridge: SsrfBridge; error: Error } {
}
describe('httpRequest manual redirect following with SSRF + proxy', () => {
useCleanProxyEnv();
let server: LocalServer;
const savedEnv: Record<string, string | undefined> = {};
beforeEach(async () => {
for (const key of PROXY_ENV_KEYS) {
savedEnv[key] = process.env[key];
}
// A proxy is configured (engages the manual follower), but loopback is
// exempt so the request connects directly to the local server.
process.env.HTTP_PROXY = 'http://127.0.0.1:1';
@ -84,13 +82,6 @@ describe('httpRequest manual redirect following with SSRF + proxy', () => {
});
afterEach(async () => {
for (const key of PROXY_ENV_KEYS) {
if (savedEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = savedEnv[key];
}
}
await server.close();
});
@ -218,6 +209,177 @@ describe('httpRequest manual redirect following with SSRF + proxy', () => {
});
});
describe('proxy host validation across redirect hops', () => {
let proxyServer: LocalServer;
const START_URL = 'http://redirect-target.invalid/start';
const INTERNAL_URL = 'http://redirect-target.invalid/internal';
beforeEach(async () => {
proxyServer = await startServer((req, res) => {
if (req.url === START_URL) {
res.writeHead(302, { Location: INTERNAL_URL });
res.end();
return;
}
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('reached:proxied');
});
});
afterEach(async () => {
await proxyServer.close();
});
const proxyConfig = () => ({
host: 'localhost',
port: Number(new URL(proxyServer.url).port),
protocol: 'http',
});
it('follows the redirect through a proxy the policy allows', async () => {
const bridge = makeSsrfBridge({
createSecureLookup: () => dns.lookup as unknown as LookupFunction,
});
const response = await httpRequest(
{ method: 'GET', url: START_URL, proxy: proxyConfig() },
bridge,
);
expect(response).toBe('reached:proxied');
expect(proxyServer.captured).toEqual([START_URL, INTERNAL_URL]);
});
it('blocks the redirect hop when the proxy host is no longer allowed', async () => {
const denied = new Error(
'The request was blocked because it resolves to a restricted IP address',
);
const lookup = ((hostname: string, options: dns.LookupOptions, onResult: unknown) => {
if (proxyServer.captured.length > 0) {
(onResult as (error: Error | null, address?: unknown, family?: number) => void)(
denied,
options.all ? [] : '',
undefined,
);
return;
}
dns.lookup(hostname, options, onResult as never);
}) as unknown as LookupFunction;
const bridge = makeSsrfBridge({ createSecureLookup: () => lookup });
await expect(
httpRequest({ method: 'GET', url: START_URL, proxy: proxyConfig() }, bridge),
).rejects.toThrow(denied.message);
expect(proxyServer.captured).toEqual([START_URL]);
});
});
describe('proxy host validation before the request is sent', () => {
let proxyServer: LocalServer;
const TARGET_URL = 'http://proxied-target.invalid/x';
beforeEach(async () => {
proxyServer = await startServer((_req, res) => {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('reached:proxied');
});
});
afterEach(async () => {
await proxyServer.close();
});
const proxyUrl = () => `http://localhost:${new URL(proxyServer.url).port}`;
const proxyConfig = () => ({
host: 'localhost',
port: Number(new URL(proxyServer.url).port),
protocol: 'http',
});
function bridgeDenying(hostname: string, error: Error): SsrfBridge {
return makeSsrfBridge({
createSecureLookup: () => dns.lookup as unknown as LookupFunction,
validateUrl: vi.fn(async (url: string | URL) => {
const host = typeof url === 'string' ? new URL(url).hostname : url.hostname;
return await Promise.resolve(
host === hostname
? { ok: false as const, error }
: { ok: true as const, result: undefined },
);
}),
});
}
const requestPaths = [
[
'httpRequest',
async (bridge: SsrfBridge) =>
await httpRequest({ method: 'GET', url: TARGET_URL, proxy: proxyConfig() }, bridge),
],
[
'executeLegacyRequest',
async (bridge: SsrfBridge) =>
await executeLegacyRequest(
{ uri: TARGET_URL, proxy: proxyUrl() },
bridge,
mock<Logger>(),
),
],
] as const;
it.each(requestPaths)(
'blocks %s when the policy denies the proxy host',
async (_name, send) => {
const error = new Error('The proxy host is not permitted by policy');
await expect(send(bridgeDenying('localhost', error))).rejects.toBe(error);
expect(proxyServer.captured).toEqual([]);
},
);
it.each(requestPaths)('sends %s when the policy allows the proxy host', async (_name, send) => {
const bridge = bridgeDenying('never-matches.invalid', new Error('unused'));
expect(await send(bridge)).toBe('reached:proxied');
expect(bridge.validateUrl).toHaveBeenCalledWith(
expect.objectContaining({ hostname: 'localhost' }),
);
});
it('reports the target URL denial before checking the proxy host', async () => {
const targetError = new Error('The target host is not permitted by policy');
const bridge = makeSsrfBridge({
createSecureLookup: () => dns.lookup as unknown as LookupFunction,
validateUrl: vi.fn(async (url: string | URL) => {
const host = typeof url === 'string' ? new URL(url).hostname : url.hostname;
return await Promise.resolve({
ok: false as const,
error: host === 'localhost' ? new Error('proxy denied') : targetError,
});
}),
});
await expect(
httpRequest({ method: 'GET', url: TARGET_URL, proxy: proxyConfig() }, bridge),
).rejects.toBe(targetError);
});
it('sends the request through the proxy when SSRF protection is disabled', async () => {
const response = await httpRequest({
method: 'GET',
url: TARGET_URL,
proxy: proxyConfig(),
});
expect(response).toBe('reached:proxied');
expect(proxyServer.captured).toEqual([TARGET_URL]);
});
});
describe('credential headers across redirects', () => {
let origin: LocalServer;
let crossOrigin: LocalServer;

View File

@ -24,6 +24,7 @@ import {
searchForHeader,
setAxiosAgents,
tryParseUrl,
validateProxySsrf,
} from '../utils';
// Agent construction is owned by `buildNodeAgents` (./factory).
@ -431,6 +432,66 @@ describe('getUrlFromProxyConfig', () => {
});
});
describe('validateProxySsrf', () => {
const denyingBridge = (deniedHostname: string, error: Error) =>
makeSsrfBridge({
validateUrl: vi.fn(async (url: string | URL) => {
const hostname = typeof url === 'string' ? new URL(url).hostname : url.hostname;
return await Promise.resolve(
hostname === deniedHostname
? { ok: false as const, error }
: { ok: true as const, result: undefined },
);
}),
});
it.each([
['a proxy URL string', 'http://proxy.example.com:8080'],
['a proxy config object', { host: 'proxy.example.com', port: 8080 }],
])('throws when the policy denies the host of %s', async (_label, proxyConfig) => {
const error = new Error('The proxy host is not permitted by policy');
await expect(
validateProxySsrf(proxyConfig, denyingBridge('proxy.example.com', error)),
).rejects.toBe(error);
});
it('validates the URL composed from a proxy config object', async () => {
const bridge = makeSsrfBridge();
await validateProxySsrf(
{
protocol: 'https',
host: 'proxy.example.com',
port: 9443,
auth: { username: 'user', password: 'pass' },
},
bridge,
);
expect(bridge.validateUrl).toHaveBeenCalledWith(
expect.objectContaining({ href: 'https://user:pass@proxy.example.com:9443/' }),
);
});
it.each([
['no proxy is configured', undefined],
['the proxy config has no host', { host: '', port: 8080 }],
['the proxy scheme is not supported', 'socks5://proxy.example.com:1080'],
['the proxy value is not a URL', 'not-a-url'],
])('does not consult the policy when %s', async (_label, proxyConfig) => {
const bridge = makeSsrfBridge();
await validateProxySsrf(proxyConfig, bridge);
expect(bridge.validateUrl).not.toHaveBeenCalled();
});
it('does not consult the policy when no bridge is provided', async () => {
await expect(validateProxySsrf('http://proxy.example.com:8080')).resolves.toBeUndefined();
});
});
describe('buildTargetUrl', () => {
it('should return url as-is when no baseURL', () => {
expect(buildTargetUrl('https://example.com/path')).toBe('https://example.com/path');
@ -701,4 +762,14 @@ describe('buildAgentOptions', () => {
expect(options.keepAlive).toBe(true);
expect(options.servername).toBe('api.example.com');
});
// SNI takes a hostname, never an IP literal (RFC 6066 §3).
it.each([
['IPv4', 'https://185.90.154.8/v1'],
['IPv6', 'https://[2001:db8::1]/v1'],
])('sets no servername when the host is an %s literal', (_label, url) => {
const options = buildAgentOptions({ method: 'GET', url });
expect(options.servername).toBeUndefined();
});
});

View File

@ -21,6 +21,7 @@ import {
resolveLegacyRequestTarget,
searchForHeader,
setAxiosAgents,
sniFor,
} from './utils';
import type { SsrfBridge } from '../../ssrf';
@ -32,10 +33,10 @@ import type { SsrfBridge } from '../../ssrf';
* @deprecated Backs the deprecated `request` helpers.
*/
export function buildLegacyAgentOptions(requestObject: IRequestOptions): AgentOptions {
const host = getHostFromRequestObject(requestObject);
const servername = sniFor(getHostFromRequestObject(requestObject));
const agentOptions: AgentOptions = { ...requestObject.agentOptions };
if (host) {
agentOptions.servername = host;
if (servername) {
agentOptions.servername = servername;
}
if (requestObject.rejectUnauthorized === false) {
agentOptions.rejectUnauthorized = false;

View File

@ -11,6 +11,7 @@ import {
isProxyPotentiallyActive,
isRedirectStatus,
resolveProxyOption,
sniFor,
throwIfDomainNotAllowed,
tryParseUrl,
validateUrlSsrf,
@ -120,12 +121,11 @@ function buildRedirectHopAgents(
nextUrl: string,
policy: SsrfRedirectPolicy,
): Pick<AxiosRequestConfig, 'httpAgent' | 'httpsAgent'> {
const host = tryParseUrl(nextUrl)?.hostname;
const customProxyUrl = policy.proxyConfig ? getUrlFromProxyConfig(policy.proxyConfig) : null;
const proxy = resolveProxyOption(customProxyUrl);
return buildNodeAgents(proxy, policy.ssrf, {
...policy.agentOptions,
...(host ? { servername: host } : {}),
servername: sniFor(tryParseUrl(nextUrl)?.hostname),
});
}

View File

@ -20,6 +20,7 @@ import {
searchForHeader,
setAxiosAgents,
throwIfDomainNotAllowed,
validateProxySsrf,
validateUrlSsrf,
} from './utils';
import type { SsrfBridge } from '../../ssrf';
@ -187,6 +188,7 @@ export async function httpRequest(
const url = buildTargetUrl(requestOptions.url, requestOptions.baseURL) ?? requestOptions.url;
await validateUrlSsrf(url, ssrfBridge);
await validateProxySsrf(requestOptions.proxy, ssrfBridge);
const axiosRequest = convertN8nRequestToAxios(requestOptions, ssrfBridge);
if (

View File

@ -11,6 +11,7 @@ import {
type IRequestOptions,
type IgnoreStatusErrorConfig,
} from 'n8n-workflow';
import net from 'node:net';
import { hasProxyEnvironmentVariables } from '../../proxy/proxy-resolution';
import type { SsrfBridge } from '../../ssrf';
@ -57,6 +58,24 @@ export function searchForHeader(config: AxiosRequestConfig, headerName: string)
return headerNames.find((thisHeader) => thisHeader.toLowerCase() === headerName);
}
/**
* The SNI to announce when connecting to `host`.
*
* @returns the host, or `undefined` when it carries no name to announce: SNI takes a
* hostname and never an IP literal (RFC 6066 §3), and Node ignores IP values.
*/
export function sniFor(host: string | null | undefined): string | undefined {
if (!host) {
return undefined;
}
// A URL hostname keeps the brackets of an IPv6 literal; `net.isIP` expects it bare.
const unbracketed = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
if (net.isIP(unbracketed)) {
return undefined;
}
return host;
}
/** Extracts the hostname from a request object's URL or URI. */
export const getHostFromRequestObject = (
requestObject: Partial<{
@ -115,13 +134,11 @@ export const getBeforeRedirectFn =
const redirectAgentOptions: AgentOptions = {
...agentOptions,
servername: redirectedRequest.hostname,
servername: sniFor(redirectedRequest.hostname as string | undefined),
};
const customProxyUrl = proxyConfig ? getUrlFromProxyConfig(proxyConfig) : null;
const proxy = resolveProxyOption(customProxyUrl);
// SSRF lookup is applied to direct connections only; behind a proxy the
// proxy validates the final target.
const targetUrl = redirectedRequest.href;
const { httpAgent, httpsAgent } = buildNodeAgents(proxy, ssrf, redirectAgentOptions);
@ -248,10 +265,10 @@ export function isFormDataInstance(data: unknown): data is FormData {
* Shared by the axios config builder and the manual redirect follower so both derive agents the same way.
*/
export function buildAgentOptions(n8nRequest: IHttpRequestOptions): AgentOptions {
const host = getHostFromRequestObject(n8nRequest);
const servername = sniFor(getHostFromRequestObject(n8nRequest));
const agentOptions: AgentOptions = { ...n8nRequest.agentOptions };
if (host) {
agentOptions.servername = host;
if (servername) {
agentOptions.servername = servername;
}
if (n8nRequest.skipSslCertificateValidation === true) {
agentOptions.rejectUnauthorized = false;
@ -378,8 +395,6 @@ export function setAxiosAgents(
const customProxyUrl = proxyConfig ? getUrlFromProxyConfig(proxyConfig) : null;
const proxy = resolveProxyOption(customProxyUrl);
// SSRF lookup is applied to direct connections only; behind a proxy the
// proxy validates the final target.
const { httpAgent, httpsAgent } = buildNodeAgents(proxy, ssrf, agentOptions);
config.httpAgent = httpAgent;
config.httpsAgent = httpsAgent;
@ -401,6 +416,16 @@ export async function validateUrlSsrf(
}
}
export async function validateProxySsrf(
proxyConfig: IHttpRequestOptions['proxy'] | string | undefined,
ssrfBridge?: SsrfBridge,
): Promise<void> {
const proxyUrl = getUrlFromProxyConfig(proxyConfig);
if (!isSupportedProxyUrl(proxyUrl)) return;
await validateUrlSsrf(proxyUrl, ssrfBridge);
}
/**
* Resolves the raw target of a legacy request object, i.e. the value the axios
* config carries as its `url`.

View File

@ -0,0 +1,85 @@
import type { HttpsProxyAgent } from 'https-proxy-agent';
import { UnexpectedError } from 'n8n-workflow';
import type http from 'node:http';
import type net from 'node:net';
import type { SsrfBridge } from '../ssrf';
/** Per-hop connect options of a proxy agent, as `agent-base` hands them to `connect()`. */
type ProxyConnectOpts = Parameters<HttpsProxyAgent<string>['connect']>[1];
export class UnknownConnectionHostError extends UnexpectedError {
constructor() {
super('Cannot determine the host for this connection');
}
}
/** Subset of an agent's connection options we read to find the target host. */
type ConnectionOptions = { host?: string | null; hostname?: string | null };
/**
* Runtime-only `createConnection` method of Node's http(s) agents.
*/
type CreateConnection = (
options: ConnectionOptions,
onConnect?: (error: Error | null, stream?: unknown) => void,
) => unknown;
interface ProxyConnectionAgent {
connectOpts: ConnectionOptions;
connect(req: http.ClientRequest, opts: ProxyConnectOpts): Promise<net.Socket>;
}
function connectionHostError(ssrf: SsrfBridge, options: ConnectionOptions): Error | undefined {
const host = options.host ?? options.hostname ?? undefined;
if (typeof host !== 'string' || host.length === 0) {
return new UnknownConnectionHostError();
}
const result = ssrf.validateConnectionHost(host);
return result.ok ? undefined : result.error;
}
/**
* Validates the proxy's own host before the socket to it opens.
*
* `agent-base` opens that socket inside `connect()`, from `connectOpts`.
* The agent's `createConnection` receives the target's options and never sees the proxy.
* This guard decides IP literals only.
* A hostname proxy relies on the secure lookup in the agent's options, which validates
* the address that hostname resolves to.
*/
export function installProxyConnectionGuard(agent: ProxyConnectionAgent, ssrf: SsrfBridge): void {
const connect = agent.connect.bind(agent);
agent.connect = async (req, opts) => {
const error = connectionHostError(ssrf, agent.connectOpts);
if (error) {
throw error;
}
return await connect(req, opts);
};
}
/**
* Wraps an agent's `createConnection` to validate the connection target before the socket opens.
*
* Node invokes the custom `lookup` to resolve hostnames only.
* An IP-literal target therefore reaches the socket unresolved, and needs this check
* at connection time.
*/
export function installConnectionGuard(
target: { createConnection: CreateConnection },
ssrf: SsrfBridge,
): void {
const createConnection = target.createConnection.bind(target);
target.createConnection = (options, onConnect) => {
const error = connectionHostError(ssrf, options);
if (error) {
if (onConnect) {
onConnect(error);
return undefined;
}
throw error;
}
return createConnection(options, onConnect);
};
}

View File

@ -1,9 +1,10 @@
import { HttpProxyAgent } from 'http-proxy-agent';
import type { HttpProxyAgent } from 'http-proxy-agent';
import http from 'node:http';
import type { LookupFunction } from 'node:net';
import { EnvProxyRouter } from './env-proxy-router';
import type { NodeAgentOptions } from './node-agents';
import { createProxiedHttpAgent } from '../proxy/proxied-agents';
type HttpAddRequestArgs = Parameters<HttpProxyAgent<string>['addRequest']>;
type HttpProxyClientReq = HttpAddRequestArgs[0];
@ -12,8 +13,10 @@ type HttpProxyReqOpts = HttpAddRequestArgs[1];
/**
* `http.Agent` that delegates per-request env-proxy routing and caching to a shared {@link EnvProxyRouter}.
*
* The optional SSRF `lookup` is applied to the direct path only
* (behind a proxy it would resolve the proxy host, so the proxy validates the target).
* The optional SSRF `lookup` is applied to the direct path only.
* A proxy named by the environment belongs to the deployment rather than to a request.
* The policy that decides which targets a workflow may reach does not decide such a
* proxy (see `buildNodeAgents`).
*
* Also backs `installGlobalProxyAgent` (http-proxy.ts), keeping a single env-proxy agent implementation.
*/
@ -22,10 +25,8 @@ export class EnvProxyHttpAgent extends http.Agent {
constructor(lookup?: LookupFunction, agentOptions?: NodeAgentOptions) {
super({ ...agentOptions, lookup });
this.router = new EnvProxyRouter(
'http',
80,
(proxyUrl) => new HttpProxyAgent(proxyUrl, { ...agentOptions }),
this.router = new EnvProxyRouter('http', 80, (proxyUrl) =>
createProxiedHttpAgent(proxyUrl, agentOptions),
);
}

View File

@ -1,10 +1,11 @@
import { HttpsProxyAgent } from 'https-proxy-agent';
import type { HttpsProxyAgent } from 'https-proxy-agent';
import type http from 'node:http';
import https from 'node:https';
import type { LookupFunction } from 'node:net';
import { EnvProxyRouter } from './env-proxy-router';
import type { NodeAgentOptions } from './node-agents';
import { createProxiedHttpsAgent } from '../proxy/proxied-agents';
type HttpsProxyReqOpts = Parameters<HttpsProxyAgent<string>['addRequest']>[1];
@ -16,10 +17,8 @@ export class EnvProxyHttpsAgent extends https.Agent {
constructor(lookup?: LookupFunction, agentOptions?: NodeAgentOptions) {
super({ ...agentOptions, lookup });
this.router = new EnvProxyRouter(
'https',
443,
(proxyUrl) => new HttpsProxyAgent(proxyUrl, { ...agentOptions }),
this.router = new EnvProxyRouter('https', 443, (proxyUrl) =>
createProxiedHttpsAgent(proxyUrl, agentOptions),
);
}

View File

@ -15,7 +15,12 @@ import type { SsrfBridge } from '../ssrf';
import { invokeAxios } from './axios/invoke';
import { buildAxiosConfigFromLegacyRequest, buildLegacyAgentOptions } from './axios/legacy';
import { followSsrfRedirects, shouldFollowRedirectsManually } from './axios/redirect';
import { resolveLegacyRequestUrl, throwIfDomainNotAllowed, validateUrlSsrf } from './axios/utils';
import {
resolveLegacyRequestUrl,
throwIfDomainNotAllowed,
validateProxySsrf,
validateUrlSsrf,
} from './axios/utils';
import { binaryToString } from './binary-string';
import { parseIncomingMessage } from './parse-incoming-message';
@ -56,6 +61,7 @@ export async function executeLegacyRequest(
const url = resolveLegacyRequestUrl(requestObject);
await validateUrlSsrf(url, ssrfBridge);
await validateProxySsrf(requestObject.proxy, ssrfBridge);
axiosConfig = Object.assign(
axiosConfig,

View File

@ -1,12 +1,12 @@
import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { UnexpectedError } from 'n8n-workflow';
import http from 'node:http';
import https from 'node:https';
import type { LookupFunction } from 'node:net';
import { installConnectionGuard, installProxyConnectionGuard } from './connection-guards';
import { EnvProxyHttpAgent } from './env-proxy-http-agent';
import { EnvProxyHttpsAgent } from './env-proxy-https-agent';
import { createProxiedHttpAgent, createProxiedHttpsAgent } from '../proxy/proxied-agents';
import type { SsrfBridge } from '../ssrf';
/**
@ -55,14 +55,34 @@ export type NodeAgentOptions = https.AgentOptions;
* undici factory (`undici/factory.ts`), the axios transport layer
* (`axios/utils.ts`) and the global proxy agents (`http-proxy.ts`).
*
* SSRF lookup is injected only for **direct** connections. Behind a proxy the
* lookup resolves the proxy host, not the final target, so it is omitted there
* and the proxy validates the final target.
* The SSRF policy covers every host this builder opens a socket to, with one
* exception: a proxy named by the environment.
* `HTTP_PROXY` / `HTTPS_PROXY` describe the deployment rather than a request, and
* such a proxy commonly sits on a private address the policy blocks for targets.
* Setting them already requires control of the process.
* An explicit {@link ProxyUrl} can come from a request, so the policy decides its
* host like any other.
*
* The lookup resolves the target on a direct connection, and the proxy's own host
* behind an explicit proxy.
* Behind any proxy the final target never reaches these agents, so validating it
* belongs to the caller:
* - the axios entry points (`httpRequest`, `executeLegacyRequest`) check it before
* the request, and on each hop with the manual redirect follower
* - callers taking the agents on their own (`HttpTransport.getNodeAgent`) get
* neither check, and validate the targets they send through them
*
* A pre-request check does not pin an address to the socket, so a target the proxy
* resolves stays open to a rebind between the check and the connection.
*
* The `lookup` is owned by this builder: it is derived from the SSRF policy and
* always overrides anything in `agentOptions`. Passing `agentOptions.lookup`
* therefore has no effect and is rejected to avoid a false sense of control over
* DNS resolution.
*
* `agentOptions` describes the connection to the **target**. Behind a proxy its TLS
* options travel with the tunnelled session rather than the proxy handshake
* (see `proxy/proxied-agents.ts`).
*/
export function buildNodeAgents(
proxy: ProxyOption,
@ -98,24 +118,18 @@ export function buildNodeAgents(
);
}
// Explicit proxy URL. No direct path, so no SSRF lookup is injected.
return {
httpAgent: new HttpProxyAgent(proxy as string, { ...agentOptions }),
httpsAgent: new HttpsProxyAgent(proxy as string, { ...agentOptions }),
// Explicit proxy URL. No direct path, so the lookup only guards the proxy host.
const agents = {
httpAgent: createProxiedHttpAgent(proxy, { ...agentOptions, lookup }),
httpsAgent: createProxiedHttpsAgent(proxy, { ...agentOptions, lookup }),
};
if (ssrf !== 'disabled') {
installProxyConnectionGuard(agents.httpAgent, ssrf);
installProxyConnectionGuard(agents.httpsAgent, ssrf);
}
return agents;
}
/** Subset of an agent's connection options we read to find the target host. */
type ConnectionOptions = { host?: string | null; hostname?: string | null };
/**
* Runtime-only `createConnection` method of Node's http(s) agents.
*/
type CreateConnection = (
options: ConnectionOptions,
onConnect?: (error: Error | null, stream?: unknown) => void,
) => unknown;
/**
* Installs {@link installConnectionGuard} on a direct-path agent pair when SSRF protection is active.
*/
@ -129,28 +143,3 @@ function applyConnectionGuard(
}
return agents;
}
/**
* Wraps an agent's `createConnection` to validate the connection target before the socket opens.
* Node invokes the custom `lookup` only to resolve hostnames, so it also requires a check at connection time.
*/
export function installConnectionGuard(
target: { createConnection: CreateConnection },
ssrf: SsrfBridge,
): void {
const createConnection = target.createConnection.bind(target);
target.createConnection = (options, onConnect) => {
const host = options.host ?? options.hostname ?? undefined;
if (typeof host === 'string') {
const result = ssrf.validateConnectionHost(host);
if (!result.ok) {
if (onConnect) {
onConnect(result.error);
return undefined;
}
throw result.error;
}
}
return createConnection(options, onConnect);
};
}

View File

@ -139,8 +139,13 @@ export interface HttpRequestClient {
* SSRF coverage is identical for `asCustomFetch()` and `getDispatcher()` (same
* underlying dispatcher): every dispatched request — initial and each redirect
* hop — is validated, and direct connections also carry a connect-time secure
* DNS lookup that defeats DNS-rebinding (TOCTOU). `getNodeAgent()` enforces the
* same connect-time secure lookup for direct connections.
* DNS lookup that defeats DNS-rebinding (TOCTOU). `getNodeAgent()` carries that
* same lookup, applied to whichever host its socket opens to: the target on a
* direct connection, the proxy behind an explicit one.
* It does **not** validate the targets sent through it.
* Behind any proxy the proxy resolves the final target, so no client-side
* connect-time check of that target is possible.
* Callers taking the agents validate those targets themselves (see `buildNodeAgents`).
*/
export interface HttpTransport {
asCustomFetch(): CustomFetch;

View File

@ -108,11 +108,53 @@ function buildDispatcherFromProxy(
return new Agent({ ...agentOptions, ...secureConnect(ssrf) });
}
if (proxy === 'env') {
// The environment's proxies are part of the deployment, not of a request,
// so the policy does not decide them (see `buildNodeAgents`).
return new EnvHttpProxyAgent({ ...agentOptions, ...secureConnect(ssrf) });
}
// Explicit proxy URL: no direct path, so no connect-time lookup is injected —
// the proxy resolves the target. Mirrors `buildNodeAgents`.
return new ProxyAgent({ uri: proxy, ...agentOptions });
assertProxyHostAllowed(ssrf, proxy);
return new ProxyAgent({
uri: proxy,
...agentOptions,
...secureProxyConnect(ssrf),
});
}
/**
* A connect-time secure DNS lookup for the socket opened to a proxy.
*
* undici builds the connector for the proxy socket from `proxyTls`.
* A caller's `connect` does not reach it: `ProxyAgent` overwrites that with its own
* tunnel handshake.
*/
function secureProxyConnect(ssrf: SsrfOption) {
return ssrf === 'disabled' ? {} : { proxyTls: { lookup: ssrf.createSecureLookup() } };
}
/**
* Catches an IP-literal proxy host, which no lookup sees.
* Applies to an explicit proxy URI, the one form that can come from a request.
*/
function assertProxyHostAllowed(ssrf: SsrfOption, proxyUri: string): void {
if (ssrf === 'disabled') {
return;
}
const hostname = proxyHostname(proxyUri);
if (hostname === undefined) {
return;
}
const result = ssrf.validateConnectionHost(hostname);
if (!result.ok) {
throw result.error;
}
}
function proxyHostname(uri: string): string | undefined {
try {
return new URL(uri).hostname;
} catch {
return undefined;
}
}
/**
@ -174,8 +216,11 @@ function lazyValue<T>(factory: () => T): () => T {
* `fetch` re-dispatches through this dispatcher for each redirect hop, so this
* validates the initial request **and** every redirect target (both hostname
* and direct-IP targets), unlike a connect-time DNS lookup which never fires for
* IP-literal targets. Validation runs against the request target, never the
* proxy, so it is proxy-agnostic.
* IP-literal targets.
*
* This interceptor does not validate a proxy's own host.
* That host is fixed for the dispatcher rather than per request, so
* {@link buildDispatcherFromProxy} decides it where the dispatcher is built.
*/
export function createSsrfInterceptor(bridge: SsrfBridge): Dispatcher.DispatcherComposeInterceptor {
return (dispatch) => (opts, handler) => {

View File

@ -0,0 +1,123 @@
import { HttpsProxyAgent } from 'https-proxy-agent';
import type http from 'node:http';
import type https from 'node:https';
import type net from 'node:net';
import type { MockInstance } from 'vitest';
import { createProxiedHttpAgent, createProxiedHttpsAgent } from '../proxied-agents';
const PROXY_URL = 'https://proxy.internal:3128';
const TARGET_TLS: https.AgentOptions = {
servername: 'target.example.com',
ca: 'TARGET_CA',
cert: 'TARGET_CERT',
key: 'TARGET_KEY',
passphrase: 'TARGET_PASSPHRASE',
};
type ConnectOpts = Parameters<HttpsProxyAgent<string>['connect']>[1];
const targetRequestOpts = {
host: 'target.example.com',
port: 443,
secureEndpoint: true,
} as ConnectOpts;
describe('createProxiedHttpsAgent', () => {
it('keeps the target SNI and trust material off the connection to the proxy', () => {
const agent = createProxiedHttpsAgent(PROXY_URL, TARGET_TLS);
expect(agent.connectOpts).toMatchObject({ host: 'proxy.internal', port: 3128 });
expect(agent.connectOpts).not.toHaveProperty('servername');
expect(agent.connectOpts).not.toHaveProperty('ca');
expect(agent.connectOpts).not.toHaveProperty('cert');
expect(agent.connectOpts).not.toHaveProperty('key');
expect(agent.connectOpts).not.toHaveProperty('passphrase');
});
it('verifies the proxy certificate even when the request opts out of verification', () => {
const agent = createProxiedHttpsAgent(PROXY_URL, {
...TARGET_TLS,
rejectUnauthorized: false,
secureOptions: 4,
});
expect(agent.connectOpts).not.toHaveProperty('rejectUnauthorized');
expect(agent.connectOpts).not.toHaveProperty('secureOptions');
});
it('keeps a target TLS policy from reshaping the proxy handshake', () => {
const agent = createProxiedHttpsAgent(PROXY_URL, {
ciphers: 'TARGET_CIPHERS',
minVersion: 'TLSv1.1',
checkServerIdentity: () => undefined,
});
expect(agent.connectOpts).not.toHaveProperty('ciphers');
expect(agent.connectOpts).not.toHaveProperty('minVersion');
expect(agent.connectOpts).not.toHaveProperty('checkServerIdentity');
});
it('forwards pool and socket options to the connection to the proxy', () => {
const agent = createProxiedHttpsAgent(PROXY_URL, { keepAlive: true, timeout: 5000 });
expect(agent.connectOpts).toMatchObject({ keepAlive: true, timeout: 5000 });
});
describe('tunnelled session', () => {
let parentConnect: MockInstance<HttpsProxyAgent<string>['connect']>;
beforeEach(() => {
parentConnect = vi
.spyOn(HttpsProxyAgent.prototype, 'connect')
.mockResolvedValue({} as net.Socket);
});
afterEach(() => parentConnect.mockRestore());
it('carries the target trust material', async () => {
const agent = createProxiedHttpsAgent(PROXY_URL, {
...TARGET_TLS,
rejectUnauthorized: false,
});
await agent.connect({} as http.ClientRequest, targetRequestOpts);
expect(parentConnect).toHaveBeenCalledWith(
{},
expect.objectContaining({
host: 'target.example.com',
port: 443,
secureEndpoint: true,
ca: 'TARGET_CA',
cert: 'TARGET_CERT',
key: 'TARGET_KEY',
passphrase: 'TARGET_PASSPHRASE',
rejectUnauthorized: false,
}),
);
});
it('leaves the SNI to be derived from the host of each hop', async () => {
const agent = createProxiedHttpsAgent(PROXY_URL, TARGET_TLS);
await agent.connect({} as http.ClientRequest, targetRequestOpts);
expect(parentConnect.mock.calls[0][1]).not.toHaveProperty('servername');
});
});
});
describe('createProxiedHttpAgent', () => {
it('keeps the target SNI and trust material off the connection to the proxy', () => {
const agent = createProxiedHttpAgent(PROXY_URL, TARGET_TLS);
expect(agent.connectOpts).toMatchObject({ host: 'proxy.internal', port: 3128 });
expect(agent.connectOpts).not.toHaveProperty('servername');
expect(agent.connectOpts).not.toHaveProperty('ca');
expect(agent.connectOpts).not.toHaveProperty('cert');
expect(agent.connectOpts).not.toHaveProperty('key');
expect(agent.connectOpts).not.toHaveProperty('passphrase');
});
});

View File

@ -0,0 +1,96 @@
import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import type http from 'node:http';
import type https from 'node:https';
import type net from 'node:net';
/** Per-hop connect options of a proxy agent, as `agent-base` hands them to `connect()`. */
type ProxyConnectOpts = Parameters<HttpsProxyAgent<string>['connect']>[1];
/**
* How a socket is opened and pooled, as opposed to how the peer on the other end is
* authenticated. Every TLS option in a request describes the target, including the
* caller's willingness to accept a certificate it cannot verify, so a proxy borrows
* these and nothing else: it is a different peer, and its certificate is always
* verified against the proxy hostname. A private proxy CA is trusted through the
* process trust store (`NODE_EXTRA_CA_CERTS`), not through a per-request option.
*/
const CONNECTION_OPTION_KEYS = [
'keepAlive',
'keepAliveMsecs',
'keepAliveInitialDelay',
'maxSockets',
'maxTotalSockets',
'maxFreeSockets',
'scheduling',
'timeout',
'noDelay',
'family',
'hints',
'localAddress',
'localPort',
'lookup',
'autoSelectFamily',
'autoSelectFamilyAttemptTimeout',
] as const satisfies ReadonlyArray<keyof https.AgentOptions>;
function pickKeys(agentOptions: https.AgentOptions, keys: readonly string[]): https.AgentOptions {
return Object.fromEntries(
Object.entries(agentOptions).filter(([key]) => keys.includes(key)),
) as https.AgentOptions;
}
function omitKeys(agentOptions: https.AgentOptions, keys: readonly string[]): https.AgentOptions {
return Object.fromEntries(
Object.entries(agentOptions).filter(([key]) => !keys.includes(key)),
) as https.AgentOptions;
}
function forProxyConnection(agentOptions: https.AgentOptions = {}): https.AgentOptions {
return pickKeys(agentOptions, CONNECTION_OPTION_KEYS);
}
function forTunnelledConnection(agentOptions: https.AgentOptions = {}): https.AgentOptions {
return omitKeys(agentOptions, ['servername']);
}
class TunnellingHttpsProxyAgent extends HttpsProxyAgent<string> {
private readonly tunnelOptions: https.AgentOptions;
constructor(proxyUrl: string, agentOptions?: https.AgentOptions) {
super(proxyUrl, forProxyConnection(agentOptions));
this.tunnelOptions = forTunnelledConnection(agentOptions);
}
async connect(req: http.ClientRequest, opts: ProxyConnectOpts): Promise<net.Socket> {
return await super.connect(req, { ...opts, ...this.tunnelOptions });
}
}
/**
* Creates the agent routing plain-HTTP targets through `proxyUrl`.
* Such a target has no TLS session of its own, so its TLS options are dropped.
*
* @param agentOptions options describing the connection to the **target**; only its
* connection-management options reach the proxy
*/
export function createProxiedHttpAgent(
proxyUrl: string,
agentOptions?: https.AgentOptions,
): HttpProxyAgent<string> {
return new HttpProxyAgent(proxyUrl, forProxyConnection(agentOptions));
}
/**
* Creates the agent tunnelling HTTPS targets through `proxyUrl`, applying the
* target's TLS options to the tunnelled session rather than to the proxy handshake.
*
* @param agentOptions options describing the connection to the **target**; only its
* connection-management options reach the proxy
*/
export function createProxiedHttpsAgent(
proxyUrl: string,
agentOptions?: https.AgentOptions,
): HttpsProxyAgent<string> {
return new TunnellingHttpsProxyAgent(proxyUrl, agentOptions);
}

View File

@ -1,9 +1,9 @@
import http from 'http';
import { HttpProxyAgent } from 'http-proxy-agent';
import https from 'https';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { getProxyForUrl } from 'proxy-from-env';
import { createProxiedHttpAgent, createProxiedHttpsAgent } from './proxied-agents';
/**
* Resolves the proxy URL configured via environment variables
* (HTTP_PROXY, HTTPS_PROXY, NO_PROXY, etc.) for a given target URL.
@ -40,7 +40,7 @@ export function createHttpProxyAgent(
const proxyUrl = customProxyUrl ?? getProxyForUrl(targetUrl);
if (proxyUrl) {
return new HttpProxyAgent(proxyUrl, options);
return createProxiedHttpAgent(proxyUrl, options);
}
return new http.Agent(options);
@ -62,7 +62,7 @@ export function createHttpsProxyAgent(
const proxyUrl = customProxyUrl ?? getProxyForUrl(targetUrl);
if (proxyUrl) {
return new HttpsProxyAgent(proxyUrl, options);
return createProxiedHttpsAgent(proxyUrl, options);
}
return new https.Agent(options);

View File

@ -1,5 +1,6 @@
import type dns from 'node:dns';
import type { LookupFunction } from 'node:net';
import { vi } from 'vitest';
import { afterEach, beforeEach, vi, type Mock } from 'vitest';
import type { SsrfBridge } from '..';
@ -7,6 +8,50 @@ export function makeLookupFn(): LookupFunction {
return vi.fn() as unknown as LookupFunction;
}
/** A `lookup` that fails every resolution with `error`. */
export function makeDenyingLookup(error: Error): LookupFunction & Mock {
return vi.fn((_hostname: string, options: dns.LookupOptions, onResult: unknown) => {
(onResult as (error: Error | null, address?: unknown, family?: number) => void)(
error,
options.all ? [] : '',
undefined,
);
}) as unknown as LookupFunction & Mock;
}
const PROXY_ENV_KEYS = [
'HTTP_PROXY',
'http_proxy',
'HTTPS_PROXY',
'https_proxy',
'NO_PROXY',
'no_proxy',
'ALL_PROXY',
'all_proxy',
] as const;
/** Clears the proxy environment around each test and restores it afterwards. */
export function useCleanProxyEnv(): void {
const savedEnv: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of PROXY_ENV_KEYS) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of PROXY_ENV_KEYS) {
if (savedEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = savedEnv[key];
}
}
});
}
export function makeSsrfBridge(overrides?: Partial<SsrfBridge>): SsrfBridge {
return {
validateUrl: vi.fn().mockResolvedValue({ ok: true, result: undefined }),

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-test-utils",
"version": "1.35.0",
"version": "1.35.5",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/blob-storage",
"version": "1.3.0",
"version": "1.3.4",
"description": "Blob storage layer for n8n, with filesystem, S3, and Azure Blob backends",
"scripts": {
"clean": "rimraf dist .turbo",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/chat-hub",
"version": "1.28.0",
"version": "1.28.4",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/client-oauth2",
"version": "1.17.0",
"version": "1.17.4",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -296,7 +296,7 @@ describe('ClientOAuth2', () => {
expect(httpsAgent.options.rejectUnauthorized).toBe(false);
});
it('should route through an https proxy agent with relaxed TLS when HTTPS_PROXY is set', async () => {
it('should route through an https proxy agent when HTTPS_PROXY is set', async () => {
process.env.HTTPS_PROXY = 'http://fake-proxy.example';
const axiosSpy = vi.spyOn(axios, 'request').mockResolvedValue({
@ -313,7 +313,9 @@ describe('ClientOAuth2', () => {
const requestConfig = axiosSpy.mock.calls[0][0];
const httpsAgent = requestConfig.httpsAgent as HttpsProxyAgent<string>;
expect(httpsAgent).toBeInstanceOf(HttpsProxyAgent);
expect(httpsAgent.connectOpts.rejectUnauthorized).toBe(false);
// 'Ignore SSL issues' relaxes the tunnelled session to the target; the
// proxy is a different peer and keeps its certificate verified.
expect(httpsAgent.connectOpts.rejectUnauthorized).toBeUndefined();
// The ignore-SSL branch must keep axios's own proxy handling disabled
// so routing stays with our agent, not double-proxied.
expect(requestConfig.proxy).toBe(false);
@ -518,7 +520,7 @@ describe('ClientOAuth2', () => {
expect(ssrfBridge.validateUrl).toHaveBeenCalledWith(new URL(config.accessTokenUri));
});
it('should relax TLS through the proxy agent without a lookup when ignoreSSLIssues is set', async () => {
it('should route through the proxy agent without a lookup when ignoreSSLIssues is set', async () => {
process.env.HTTPS_PROXY = 'http://fake-proxy.example';
const ssrfBridge = makeSsrfBridge();
const axiosSpy = proxiedTokenResponse();
@ -527,7 +529,7 @@ describe('ClientOAuth2', () => {
const httpsAgent = axiosSpy.mock.calls[0][0].httpsAgent as HttpsProxyAgent<string>;
expect(httpsAgent).toBeInstanceOf(HttpsProxyAgent);
expect(httpsAgent.connectOpts.rejectUnauthorized).toBe(false);
expect(httpsAgent.connectOpts.rejectUnauthorized).toBeUndefined();
expect(httpsAgent.connectOpts.lookup).toBeUndefined();
});

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/config",
"version": "2.33.0",
"version": "2.33.3",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -30,6 +30,14 @@ class CredentialsOverwrite {
*/
@Env('N8N_SKIP_CREDENTIAL_OVERWRITE')
skipTypes: CommaSeparatedStringArray<string> = [];
/**
* Comma-separated list of managed OAuth credential types whose scope fields
* remain user-configurable.
* @example `N8N_MANAGED_OAUTH_SHOW_SCOPES=googleOAuth2Api`
*/
@Env('N8N_MANAGED_OAUTH_SHOW_SCOPES')
showScopes: CommaSeparatedStringArray<string> = [];
}
@Config

View File

@ -193,4 +193,17 @@ export class InstanceAiConfig {
*/
@Env('N8N_INSTANCE_AI_MCP_CONNECTIONS_ENABLED')
mcpConnectionsEnabled: boolean = false;
/**
* Activation-capped trial variant for n8n cloud experiment.
* Set by the cloud dashboard at deploy time on one signup-experiment cohort only.
*/
@Env('N8N_INSTANCE_AI_ACTIVATION_CAPPED')
activationCapped: boolean = false;
/**
* How many assistant messages the instance must have sent before {@link activationCapped} locking may apply.
*/
@Env('N8N_INSTANCE_AI_ACTIVATION_LOCK_MESSAGE_THRESHOLD')
activationLockMessageThreshold: number = 1;
}

View File

@ -7,6 +7,9 @@ import { positiveIntSchema } from '../schemas';
const callerPolicySchema = z.enum(['any', 'none', 'workflowsFromAList', 'workflowsFromSameOwner']);
type CallerPolicy = z.infer<typeof callerPolicySchema>;
// Bounded so lease-derived timeouts stay far below Node's max timer delay (~24.8 days).
const outboxLeaseSecondsSchema = positiveIntSchema.max(Time.days.toSeconds);
@Config
export class WorkflowsConfig {
/** Default name suggested when creating a new workflow. */
@ -34,8 +37,9 @@ export class WorkflowsConfig {
publicationOutboxPollIntervalMs: number = 15 * Time.seconds.toMilliseconds;
/** Seconds after which an `in_progress` workflow publication outbox record
* is considered stale (its leader likely died) and may be reclaimed by a poll cycle. */
@Env('N8N_WORKFLOW_PUBLICATION_OUTBOX_LEASE_SECONDS')
* is considered stale (its leader likely died) and may be reclaimed by a poll cycle.
* Must be at most one day. */
@Env('N8N_WORKFLOW_PUBLICATION_OUTBOX_LEASE_SECONDS', outboxLeaseSecondsSchema)
publicationOutboxLeaseSeconds: number = 2 * Time.minutes.toSeconds;
/** Number of workflow publication outbox records the leader processes in parallel per drain. */

View File

@ -135,6 +135,7 @@ describe('GlobalConfig', () => {
endpoint: '',
endpointAuthToken: '',
persistence: false,
showScopes: [],
skipTypes: [],
},
},
@ -359,6 +360,8 @@ describe('GlobalConfig', () => {
thinkingEnabled: true,
durableLog: true,
mcpConnectionsEnabled: false,
activationCapped: false,
activationLockMessageThreshold: 1,
},
queue: {
health: {
@ -740,6 +743,18 @@ describe('GlobalConfig', () => {
expect(config.agents.sandboxSnapshot).toBe('n8n/agent-knowledge:1.2.3');
});
it('should parse N8N_MANAGED_OAUTH_SHOW_SCOPES from env variables', () => {
process.env = {
N8N_MANAGED_OAUTH_SHOW_SCOPES: 'googleOAuth2Api,microsoftOAuth2Api',
};
const config = Container.get(GlobalConfig);
expect(config.credentials.overwrite.showScopes).toEqual([
'googleOAuth2Api',
'microsoftOAuth2Api',
]);
});
it('should use values from env variables when defined', () => {
process.env = {
DB_POSTGRESDB_HOST: 'some-host',
@ -976,6 +991,24 @@ describe('GlobalConfig', () => {
const config = Container.get(GlobalConfig);
expect(config.workflows.workflowPublicationConcurrency).toBe(3);
});
it('should reject an outbox lease above one day and fall back to the default', () => {
process.env = { N8N_WORKFLOW_PUBLICATION_OUTBOX_LEASE_SECONDS: `${100 * 24 * 60 * 60}` };
const config = Container.get(GlobalConfig);
expect(config.workflows.publicationOutboxLeaseSeconds).toBe(120);
expect(consoleWarnMock).toHaveBeenCalledWith(
expect.stringContaining('N8N_WORKFLOW_PUBLICATION_OUTBOX_LEASE_SECONDS'),
);
});
it('should reject an outbox lease of 0 and fall back to the default', () => {
process.env = { N8N_WORKFLOW_PUBLICATION_OUTBOX_LEASE_SECONDS: '0' };
const config = Container.get(GlobalConfig);
expect(config.workflows.publicationOutboxLeaseSeconds).toBe(120);
expect(consoleWarnMock).toHaveBeenCalledWith(
expect.stringContaining('N8N_WORKFLOW_PUBLICATION_OUTBOX_LEASE_SECONDS'),
);
});
});
it('should clamp password min length to valid range', () => {

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/create-node",
"version": "0.43.0",
"version": "0.43.4",
"description": "Official CLI to create new community nodes for n8n",
"bin": {
"create-node": "bin/create-node.cjs"

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/db",
"version": "1.35.0",
"version": "1.35.5",
"scripts": {
"clean": "rimraf dist .turbo .tmp-schema-docs",
"gen:migration-index": "node scripts/generate-migration-index.mjs",

View File

@ -64,6 +64,7 @@ import {
UNPUBLISH_VERSION_SENTINEL,
WorkflowPublicationOutbox,
WorkflowPublicationOutboxStatus,
WorkflowPublicationReason,
} from './workflow-publication-outbox';
import {
WorkflowPublicationTriggerStatus,
@ -142,6 +143,7 @@ export {
WorkflowHistory,
WorkflowPublicationOutbox,
WorkflowPublicationOutboxStatus,
WorkflowPublicationReason,
UNPUBLISH_VERSION_SENTINEL,
WorkflowPublicationTriggerStatus,
type WorkflowPublicationTriggerStatusType,

View File

@ -21,6 +21,25 @@ export const WorkflowPublicationOutboxStatus = {
export type WorkflowPublicationOutboxStatus =
(typeof WorkflowPublicationOutboxStatus)[keyof typeof WorkflowPublicationOutboxStatus];
/**
* Why a publication record was enqueued. The applier translates it into the
* `WorkflowActivateMode` reported to trigger nodes, so e.g. the n8n Trigger's
* "Instance Started" event fires only for the leader's startup pass.
*/
export const WorkflowPublicationReason = {
/** A user published or unpublished the workflow. */
Publish: 'publish',
/** The leader's initial pass after the process booted. */
Startup: 'startup',
/** A running instance became leader without restarting. */
LeadershipTakeover: 'leadership-takeover',
/** A periodic reconciliation pass healing detected drift. */
Reconcile: 'reconcile',
} as const;
export type WorkflowPublicationReason =
(typeof WorkflowPublicationReason)[keyof typeof WorkflowPublicationReason];
@Entity({ name: 'workflow_publication_outbox' })
@Index('IDX_workflow_publication_outbox_active_workflow_status', ['workflowId', 'status'], {
unique: true,
@ -39,6 +58,9 @@ export class WorkflowPublicationOutbox extends WithTimestamps {
@Column({ type: 'varchar', length: 20 })
status: WorkflowPublicationOutboxStatus;
@Column({ type: 'varchar', length: 20, default: 'publish' })
reason: WorkflowPublicationReason;
@Column({ type: 'text', nullable: true })
errorMessage: string | null;
}

View File

@ -0,0 +1,37 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const tableName = 'workflow_publication_outbox';
const columnName = 'reason';
/**
* Adds `reason` so each publication record carries why it was enqueued: a user
* publish/unpublish, the leader's startup pass, a leadership takeover, or the
* reconciler healing drift. The applier translates it into the activation mode
* reported to trigger nodes (e.g. the n8n Trigger's "Instance Started" event
* fires only for `startup`).
*
* NOT NULL with a `publish` default so rows inserted by pre-upgrade instances
* during a rolling deploy keep today's behavior (activation mode `update`).
*/
export class AddReasonToWorkflowPublicationOutbox1786519946974 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns(
tableName,
[
column(columnName)
.varchar(20)
.notNull.default("'publish'")
.withEnumCheck(['publish', 'startup', 'leadership-takeover', 'reconcile'])
.comment(
'Why this publication was enqueued: user publish/unpublish, leader startup pass, leadership takeover, or reconciler drift-heal',
),
],
{ recreatesOnSqlite: true },
);
}
async down({ schemaBuilder: { dropColumns, dropEnumCheck } }: MigrationContext) {
await dropEnumCheck(tableName, columnName, { recreatesOnSqlite: true });
await dropColumns(tableName, [columnName], { recreatesOnSqlite: true });
}
}

View File

@ -30,6 +30,24 @@ export class SettingsRepository extends BaseRepository<Settings> {
await this.managerFor(ctx).upsert(Settings, { key, value, loadOnStartup }, ['key']);
}
/**
* One-time claim of a key: sets the value only when the key was never
* claimed before, and reports whether this call was the claimant. Concurrent
* claimants (multi-main boots, parallel saves) resolve to exactly one winner
* because only one conditional update can flip the empty marker.
*/
async claimKey(key: string, value: string): Promise<boolean> {
await this.manager
.createQueryBuilder()
.insert()
.into(Settings)
.values({ key, value: '', loadOnStartup: false })
.orIgnore()
.execute();
const result = await this.manager.update(Settings, { key, value: '' }, { value });
return (result.affected ?? 0) > 0;
}
async findByKeys(keys: string[]): Promise<Settings[]> {
return await this.findBy({ key: In(keys) });
}

View File

@ -8,11 +8,12 @@ import {
UNPUBLISH_VERSION_SENTINEL,
WorkflowPublicationOutbox,
WorkflowPublicationOutboxStatus as Status,
type WorkflowPublicationReason,
} from '../entities/workflow-publication-outbox';
import { isUniqueConstraintError } from '../utils/is-unique-constraint-error';
/** Sqlite bound-variable budget per statement; safely under every build's cap. */
const SQLITE_ENQUEUE_CHUNK_SIZE = 999;
/** Sqlite bound-variable budget per statement (ids + the reason); safely under every build's cap. */
const SQLITE_ENQUEUE_CHUNK_SIZE = 998;
@Service()
export class WorkflowPublicationOutboxRepository extends Repository<WorkflowPublicationOutbox> {
@ -47,23 +48,33 @@ export class WorkflowPublicationOutboxRepository extends Repository<WorkflowPubl
*
* Pass `trx` to run the UPSERT inside an existing transaction, e.g. to make
* the enqueue atomic with a `workflow_entity` update.
*
* The conflict path also supersedes `reason`: a fresher enqueue's intent
* wins (e.g. a user publish replacing a pending startup record).
*/
async enqueue(
workflowId: string,
publishedVersionId: string,
reason: WorkflowPublicationReason,
trx?: EntityManager,
): Promise<void> {
if (this.globalConfig.database.type === 'postgresdb') {
await this.enqueueWithPostgresUpsert(workflowId, publishedVersionId, trx ?? this.manager);
await this.enqueueWithPostgresUpsert(
workflowId,
publishedVersionId,
reason,
trx ?? this.manager,
);
return;
}
await this.enqueueWithSqliteUpsert(workflowId, publishedVersionId, trx ?? this.manager);
await this.enqueueWithSqliteUpsert(workflowId, publishedVersionId, reason, trx ?? this.manager);
}
private async enqueueWithPostgresUpsert(
workflowId: string,
publishedVersionId: string,
reason: WorkflowPublicationReason,
trx: EntityManager,
): Promise<void> {
const tableName = this.getTableName('workflow_publication_outbox');
@ -71,27 +82,28 @@ export class WorkflowPublicationOutboxRepository extends Repository<WorkflowPubl
// `createdAt`/`updatedAt` carry DB-level defaults, so the insert omits
// them; the conflict path bumps `updatedAt` explicitly.
await trx.query(
`INSERT INTO ${tableName} ("workflowId", "publishedVersionId", "status")
VALUES ($1, $2, '${Status.Pending}')
`INSERT INTO ${tableName} ("workflowId", "publishedVersionId", "status", "reason")
VALUES ($1, $2, '${Status.Pending}', $3)
ON CONFLICT ("workflowId", "status") WHERE "status" IN ('${Status.Pending}', '${Status.InProgress}')
DO UPDATE SET "publishedVersionId" = EXCLUDED."publishedVersionId", "updatedAt" = CURRENT_TIMESTAMP(3)`,
[workflowId, publishedVersionId],
DO UPDATE SET "publishedVersionId" = EXCLUDED."publishedVersionId", "reason" = EXCLUDED."reason", "updatedAt" = CURRENT_TIMESTAMP(3)`,
[workflowId, publishedVersionId, reason],
);
}
private async enqueueWithSqliteUpsert(
workflowId: string,
publishedVersionId: string,
reason: WorkflowPublicationReason,
trx: EntityManager,
): Promise<void> {
const tableName = this.getTableName('workflow_publication_outbox');
await trx.query(
`INSERT INTO ${tableName} ("workflowId", "publishedVersionId", "status")
VALUES (?, ?, '${Status.Pending}')
`INSERT INTO ${tableName} ("workflowId", "publishedVersionId", "status", "reason")
VALUES (?, ?, '${Status.Pending}', ?)
ON CONFLICT ("workflowId", "status") WHERE "status" IN ('${Status.Pending}', '${Status.InProgress}')
DO UPDATE SET "publishedVersionId" = excluded."publishedVersionId", "updatedAt" = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')`,
[workflowId, publishedVersionId],
DO UPDATE SET "publishedVersionId" = excluded."publishedVersionId", "reason" = excluded."reason", "updatedAt" = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')`,
[workflowId, publishedVersionId, reason],
);
}
@ -104,18 +116,24 @@ export class WorkflowPublicationOutboxRepository extends Repository<WorkflowPubl
* an unpublish record that clears their stale trigger-status rows. Idempotent
* via the same partial-unique-index upsert as {@link enqueue}.
*/
async enqueueByWorkflowIds(workflowIds: string[]): Promise<void> {
async enqueueByWorkflowIds(
workflowIds: string[],
reason: WorkflowPublicationReason,
): Promise<void> {
if (workflowIds.length === 0) return;
if (this.globalConfig.database.type === 'postgresdb') {
await this.enqueueByWorkflowIdsWithPostgresUpsert(workflowIds);
await this.enqueueByWorkflowIdsWithPostgresUpsert(workflowIds, reason);
return;
}
await this.enqueueByWorkflowIdsWithSqliteUpsert(workflowIds);
await this.enqueueByWorkflowIdsWithSqliteUpsert(workflowIds, reason);
}
private async enqueueByWorkflowIdsWithPostgresUpsert(workflowIds: string[]): Promise<void> {
private async enqueueByWorkflowIdsWithPostgresUpsert(
workflowIds: string[],
reason: WorkflowPublicationReason,
): Promise<void> {
const outboxTableName = this.getTableName('workflow_publication_outbox');
const workflowTableName = this.getTableName('workflow_entity');
@ -131,17 +149,20 @@ export class WorkflowPublicationOutboxRepository extends Repository<WorkflowPubl
// earlier snapshot). Such a record is at least as fresh as this statement's
// snapshot — overwriting it could roll the workflow back to a stale version.
await this.query(
`INSERT INTO ${outboxTableName} ("workflowId", "publishedVersionId", "status")
SELECT w."id", COALESCE(w."activeVersionId", '${UNPUBLISH_VERSION_SENTINEL}'), '${Status.Pending}'
`INSERT INTO ${outboxTableName} ("workflowId", "publishedVersionId", "status", "reason")
SELECT w."id", COALESCE(w."activeVersionId", '${UNPUBLISH_VERSION_SENTINEL}'), '${Status.Pending}', $2
FROM ${workflowTableName} w
WHERE w."id" = ANY($1)
ON CONFLICT ("workflowId", "status") WHERE "status" IN ('${Status.Pending}', '${Status.InProgress}')
DO NOTHING`,
[workflowIds],
[workflowIds, reason],
);
}
private async enqueueByWorkflowIdsWithSqliteUpsert(workflowIds: string[]): Promise<void> {
private async enqueueByWorkflowIdsWithSqliteUpsert(
workflowIds: string[],
reason: WorkflowPublicationReason,
): Promise<void> {
const outboxTableName = this.getTableName('workflow_publication_outbox');
const workflowTableName = this.getTableName('workflow_entity');
@ -154,13 +175,13 @@ export class WorkflowPublicationOutboxRepository extends Repository<WorkflowPubl
const placeholders = chunk.map(() => '?').join(', ');
await this.query(
`INSERT INTO ${outboxTableName} ("workflowId", "publishedVersionId", "status")
SELECT w."id", COALESCE(w."activeVersionId", '${UNPUBLISH_VERSION_SENTINEL}'), '${Status.Pending}'
`INSERT INTO ${outboxTableName} ("workflowId", "publishedVersionId", "status", "reason")
SELECT w."id", COALESCE(w."activeVersionId", '${UNPUBLISH_VERSION_SENTINEL}'), '${Status.Pending}', ?
FROM ${workflowTableName} w
WHERE w."id" IN (${placeholders})
ON CONFLICT ("workflowId", "status") WHERE "status" IN ('${Status.Pending}', '${Status.InProgress}')
DO NOTHING`,
chunk,
[reason, ...chunk],
);
}
}

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/decorators",
"version": "1.35.0",
"version": "1.35.4",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/engine",
"version": "0.15.0",
"version": "0.15.3",
"description": "n8n workflow execution engine (v2)",
"scripts": {
"clean": "rimraf dist .turbo compiled",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/expression-runtime",
"version": "0.26.0",
"version": "0.26.1",
"description": "Secure, isolated expression evaluation runtime for n8n",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",

View File

@ -277,7 +277,7 @@ export class IsolatedVmBridge implements RuntimeBridge {
}
/**
* Create an ivm.Reference callback for getting value/metadata at a path.
* Create an ivm.Callback for getting value/metadata at a path.
*
* Used by createDeepLazyProxy when accessing properties. Returns metadata
* markers for arrays and objects, or the primitive value directly.
@ -291,8 +291,8 @@ export class IsolatedVmBridge implements RuntimeBridge {
* @param data - Current workflow data to use for callback responses
* @private
*/
private createGetValueAtPathRef(data: WorkflowData): ivm.Reference {
return new (getIvm().Reference)((path: string[]) => {
private createGetValueAtPathRef(data: WorkflowData): ivm.Callback {
return new (getIvm().Callback)((path: string[]) => {
try {
// Navigate to value
// Special-case: paths starting with ['$item', index] call data.$item(index)
@ -361,15 +361,15 @@ export class IsolatedVmBridge implements RuntimeBridge {
}
/**
* Create an ivm.Reference callback for getting array elements at an index.
* Create an ivm.Callback for getting array elements at an index.
*
* Used by array proxy when accessing numeric indices.
*
* @param data - Current workflow data to use for callback responses
* @private
*/
private createGetArrayElementRef(data: WorkflowData): ivm.Reference {
return new (getIvm().Reference)((path: string[], index: number) => {
private createGetArrayElementRef(data: WorkflowData): ivm.Callback {
return new (getIvm().Callback)((path: string[], index: number) => {
try {
// Navigate to array
// Special-case: paths starting with ['$item', index] call data.$item(index)
@ -462,11 +462,17 @@ export class IsolatedVmBridge implements RuntimeBridge {
* in this switch; the `type` field selects a static branch in source,
* not a property lookup on a runtime object.
*
* Return-value note: handlers must return plain, structured-clone-able
* data. Results cross into the isolate through an ivm.Callback, which copies
* them via the structured-clone algorithm — return JSON-shaped values, not
* isolated-vm objects (`Reference`/`ExternalCopy`) or other non-cloneable
* values.
*
* @param data - Current workflow data
* @private
*/
private createCallHostRef(data: WorkflowData): ivm.Reference {
return new (getIvm().Reference)((rawMsg: unknown) => {
private createCallHostRef(data: WorkflowData): ivm.Callback {
return new (getIvm().Callback)((rawMsg: unknown) => {
try {
const msg = bridgeMessageSchema.parse(rawMsg);
switch (msg.type) {
@ -695,7 +701,7 @@ export class IsolatedVmBridge implements RuntimeBridge {
* Execute JavaScript code in the isolated context.
*
* Flow:
* 1. Create three ivm.Reference callbacks scoped to the current data:
* 1. Create three ivm.Callback instances scoped to the current data:
* `getValueAtPath`, `getArrayElement`, `callHost`.
* 2. Use evalClosureSync to run the code in a closure where `$0`/`$1`/`$2`
* are the callback references — no global mutable state.
@ -715,6 +721,10 @@ export class IsolatedVmBridge implements RuntimeBridge {
throw new Error('Bridge not initialized. Call initialize() first.');
}
// Host callbacks are ivm.Callback instances: inside the isolate they
// arrive as plain functions with structured-clone marshaling, so the
// runtime invokes them directly. Callbacks are GC-managed; there is no
// release() to call in `finally`.
const getValueAtPath = this.createGetValueAtPathRef(data);
const getArrayElement = this.createGetArrayElementRef(data);
const callHost = this.createCallHostRef(data);
@ -796,10 +806,6 @@ try {
);
}
throw new Error(`Expression evaluation failed: ${errorMessage}`);
} finally {
getValueAtPath.release();
getArrayElement.release();
callHost.release();
}
}

View File

@ -1,9 +1,7 @@
import { buildContext } from '../context';
function makeRef(impl: (args: unknown[]) => unknown) {
return {
applySync: (_thisArg: unknown, args: unknown[]) => impl(args),
};
return (...args: unknown[]) => impl(args);
}
describe('buildContext proxy', () => {

View File

@ -5,8 +5,6 @@ import { createDeepLazyProxy, isLazyProxy, getProxyPath } from '../lazy-proxy';
// Helpers
// ---------------------------------------------------------------------------
const ivmCallOpts = { arguments: { copy: true }, result: { copy: true } };
function mockApplySync(returnValue: unknown = undefined) {
return vi.fn().mockReturnValue(returnValue);
}
@ -22,8 +20,8 @@ function createMockCallbacks(
const getArrayElement = overrides.getArrayElement ?? mockApplySync();
const callbacks = {
getValueAtPath: { applySync: getValueAtPath },
getArrayElement: { applySync: getArrayElement },
getValueAtPath,
getArrayElement,
};
return { getValueAtPath, getArrayElement, callbacks };
@ -231,7 +229,7 @@ describe('createDeepLazyProxy', () => {
const p = proxy(['data']);
mocks.getArrayElement.mockReturnValue('val');
p.list[3];
expect(mocks.getArrayElement).toHaveBeenCalledWith(null, [['data', 'list'], 3], ivmCallOpts);
expect(mocks.getArrayElement).toHaveBeenCalledWith(['data', 'list'], 3);
});
it('returns undefined for non-numeric non-length properties', () => {
@ -272,15 +270,15 @@ describe('createDeepLazyProxy', () => {
// Each level triggers __getValueAtPath and creates a nested proxy
// a -> returns object metadata
const a = p.a;
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(null, [['a']], ivmCallOpts);
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(['a']);
// a.b -> returns object metadata
const b = a.b;
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(null, [['a', 'b']], ivmCallOpts);
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(['a', 'b']);
// a.b.c -> returns object metadata
const c = b.c;
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(null, [['a', 'b', 'c']], ivmCallOpts);
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(['a', 'b', 'c']);
expect(getProxyPath(c)).toEqual(['a', 'b', 'c']);
});
@ -303,7 +301,7 @@ describe('createDeepLazyProxy', () => {
mocks.getValueAtPath.mockReturnValue('val');
const p = proxy(['$json']);
p.user;
expect(mocks.getValueAtPath).toHaveBeenCalledWith(null, [['$json', 'user']], ivmCallOpts);
expect(mocks.getValueAtPath).toHaveBeenCalledWith(['$json', 'user']);
});
it('nested proxies inherit full path', () => {
@ -315,11 +313,7 @@ describe('createDeepLazyProxy', () => {
// Accessing a property on the nested proxy should build the full path
mocks.getValueAtPath.mockReturnValue('Alice');
user.name;
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(
null,
[['$json', 'user', 'name']],
ivmCallOpts,
);
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(['$json', 'user', 'name']);
});
});
@ -369,7 +363,7 @@ describe('createDeepLazyProxy', () => {
mocks.getValueAtPath.mockReturnValue('val');
const p = proxy(['$json']);
'foo' in p;
expect(mocks.getValueAtPath).toHaveBeenCalledWith(null, [['$json', 'foo']], ivmCallOpts);
expect(mocks.getValueAtPath).toHaveBeenCalledWith(['$json', 'foo']);
});
});
@ -461,7 +455,7 @@ describe('createDeepLazyProxy', () => {
const p = arrayProxy(['arr'], 3);
mocks.getArrayElement.mockReturnValue('first');
expect(p[0]).toBe('first');
expect(mocks.getArrayElement).toHaveBeenCalledWith(null, [['arr'], 0], ivmCallOpts);
expect(mocks.getArrayElement).toHaveBeenCalledWith(['arr'], 0);
});
it('nested array element returns an array-shaped child proxy', () => {

View File

@ -52,30 +52,23 @@ type InputRpcType = (typeof INPUT_RPC_TYPES)[keyof typeof INPUT_RPC_TYPES];
// ============================================================================
/**
* The subset of `ivm.Reference` shape the in-isolate runtime relies on.
* Declared locally rather than importing from `isolated-vm` because this
* module is bundled into the isolate IIFE, where the native module is
* unavailable. The host wires real `ivm.Reference` instances which
* structurally satisfy this interface.
* A bridge callback as seen from inside the isolate: a plain function.
* The host wires these as `ivm.Callback` instances, which arrive in the
* isolate as ordinary functions whose arguments and return value are copied
* via the structured-clone algorithm.
*/
interface BridgeCallback {
applySync(
thisArg: unknown,
args: unknown[],
options?: { arguments?: { copy?: boolean }; result?: { copy?: boolean } },
): unknown;
}
type BridgeCallback = (...args: unknown[]) => unknown;
/**
* Bridge callbacks the in-isolate runtime can invoke synchronously via
* `ivm.Reference.applySync`.
* Bridge callbacks the in-isolate runtime invokes synchronously as plain
* functions (host-side `ivm.Callback` instances).
*
* - `getValueAtPath`, `getArrayElement`: data-access primitives used by the
* lazy-proxy system. Hot path; one ivm.Reference each for minimum overhead.
* lazy-proxy system. Hot path; one ivm.Callback each for minimum overhead.
* - `callHost`: typed-RPC dispatcher. The in-isolate runtime constructs
* an envelope (e.g. `{ type: 'getNodeFirst', nodeName, ... }`) and the
* host-side dispatcher validates it with zod before routing to a handler.
* A single ivm.Reference covers every typed operation; new operations
* A single ivm.Callback covers every typed operation; new operations
* are new schemas in `bridge/bridge-messages.ts` + new cases in the
* dispatcher switch. The name reflects what this is: a synchronous
* host RPC, not a postMessage-style async send.
@ -198,11 +191,7 @@ export function buildContext(
const lazyProxy = createDeepLazyProxy(['$', nodeName], undefined, callbacks);
const sendNodeMethod = (type: NodeRpcType) => {
return (branchIndex?: number, runIndex?: number) => {
const result = callbacks.callHost.applySync(
null,
[{ type, nodeName, branchIndex, runIndex }],
{ arguments: { copy: true }, result: { copy: true } },
);
const result = callbacks.callHost({ type, nodeName, branchIndex, runIndex });
throwIfErrorSentinel(result);
return result;
};
@ -217,18 +206,12 @@ export function buildContext(
type: 'getNodePairedItem' | 'getNodeItemMatching',
itemIndex?: number,
) => {
const result = callbacks.callHost.applySync(null, [{ type, nodeName, itemIndex }], {
arguments: { copy: true },
result: { copy: true },
});
const result = callbacks.callHost({ type, nodeName, itemIndex });
throwIfErrorSentinel(result);
return result;
};
const sendGetNodeItem = () => {
const result = callbacks.callHost.applySync(null, [{ type: 'getNodeItem', nodeName }], {
arguments: { copy: true },
result: { copy: true },
});
const result = callbacks.callHost({ type: 'getNodeItem', nodeName });
throwIfErrorSentinel(result);
return result;
};
@ -271,10 +254,7 @@ export function buildContext(
const lazyInputProxy = createDeepLazyProxy(['$input'], undefined, callbacks);
const sendInputMethod = (type: InputRpcType) => {
return () => {
const result = callbacks.callHost.applySync(null, [{ type }], {
arguments: { copy: true },
result: { copy: true },
});
const result = callbacks.callHost({ type });
throwIfErrorSentinel(result);
return result;
};
@ -295,11 +275,7 @@ export function buildContext(
// args, and the host's `WorkflowDataProxy.$items` applies its own
// defaults when fields are undefined.
target.$items = (nodeName?: string, outputIndex?: number, runIndex?: number) => {
const result = callbacks.callHost.applySync(
null,
[{ type: 'getItems', nodeName, outputIndex, runIndex }],
{ arguments: { copy: true }, result: { copy: true } },
);
const result = callbacks.callHost({ type: 'getItems', nodeName, outputIndex, runIndex });
throwIfErrorSentinel(result);
return result;
};
@ -316,11 +292,13 @@ export function buildContext(
valueType?: string,
defaultValue?: unknown,
) => {
const result = callbacks.callHost.applySync(
null,
[{ type: 'fromAi', name, description, valueType, defaultValue }],
{ arguments: { copy: true }, result: { copy: true } },
);
const result = callbacks.callHost({
type: 'fromAi',
name,
description,
valueType,
defaultValue,
});
throwIfErrorSentinel(result);
return result;
};
@ -333,11 +311,7 @@ export function buildContext(
// Under the VM engine this re-enters the bridge on a fresh evaluation;
// the legacy engine handles it inline.
target.$evaluateExpression = (expression: string, itemIndex?: number) => {
const result = callbacks.callHost.applySync(
null,
[{ type: 'evaluateExpression', expression, itemIndex }],
{ arguments: { copy: true }, result: { copy: true } },
);
const result = callbacks.callHost({ type: 'evaluateExpression', expression, itemIndex });
throwIfErrorSentinel(result);
return result;
};
@ -352,18 +326,12 @@ export function buildContext(
incomingSourceData: unknown,
initialPairedItem: unknown,
) => {
const result = callbacks.callHost.applySync(
null,
[
{
type: 'getPairedItem',
destinationNodeName,
incomingSourceData,
initialPairedItem,
},
],
{ arguments: { copy: true }, result: { copy: true } },
);
const result = callbacks.callHost({
type: 'getPairedItem',
destinationNodeName,
incomingSourceData,
initialPairedItem,
});
throwIfErrorSentinel(result);
return result;
};
@ -374,7 +342,7 @@ export function buildContext(
// so each key is fetched at most once per evaluation.
// -------------------------------------------------------------------------
// Track keys we've already probed so we never call applySync twice
// Track keys we've already probed so we never call getValueAtPath twice
// for the same key — even if the host returned undefined.
const probedKeys = new Set<string>();
@ -383,10 +351,7 @@ export function buildContext(
let value: unknown;
try {
value = callbacks.getValueAtPath.applySync(null, [[key]], {
arguments: { copy: true },
result: { copy: true },
});
value = callbacks.getValueAtPath([key]);
} catch {
// Don't mark as probed — the throw may be transient
// (e.g. host data not yet available) and a retry should be allowed.

View File

@ -106,7 +106,7 @@ export type ProxyMeta = { kind: 'object'; keys?: string[] } | { kind: 'array'; l
*
* @param basePath - Current path in object tree (e.g., ['$json', 'user'])
* @param meta - Optional shape descriptor (object/array + known keys/length)
* @param callbacks - ivm.Reference callbacks for cross-isolate communication
* @param callbacks - ivm.Callback functions for cross-isolate communication
* @returns Proxy object with lazy loading behavior
*/
export function createDeepLazyProxy(
@ -133,10 +133,7 @@ export function createDeepLazyProxy(
function resolveObjectKeys(): string[] {
if (objectKeys) return objectKeys;
if (fetchedKeys) return fetchedKeys;
const value = getValueAtPath.applySync(null, [basePath], {
arguments: { copy: true },
result: { copy: true },
});
const value = getValueAtPath(basePath);
throwIfErrorSentinel(value);
if (isObjectMetadata(value)) {
fetchedKeys = value.__keys;
@ -252,10 +249,7 @@ export function createDeepLazyProxy(
if (isArray) {
const idx = isInArrayBounds(prop);
if (idx === undefined) return undefined;
const element = getArrayElement.applySync(null, [basePath, idx], {
arguments: { copy: true },
result: { copy: true },
});
const element = getArrayElement(basePath, idx);
// Primitives (and null) skip `materializeChild`'s metadata checks.
if (element === null || typeof element !== 'object') {
targetObj[prop] = element;
@ -269,12 +263,8 @@ export function createDeepLazyProxy(
// materializeChild rebuilds it only inside metadata branches.
const path = [...basePath, prop];
// Call back to host to get metadata/value
// Note: getValueAtPath is an ivm.Reference passed via callbacks
const value = getValueAtPath.applySync(null, [path], {
arguments: { copy: true },
result: { copy: true },
});
// Call back to host to get metadata/value.
const value = getValueAtPath(path);
targetObj[prop] = materializeChild(basePath, prop, value);
return targetObj[prop];
@ -301,10 +291,7 @@ export function createDeepLazyProxy(
// Build path and check existence via callback
const path = [...basePath, prop];
const value = getValueAtPath.applySync(null, [path], {
arguments: { copy: true },
result: { copy: true },
});
const value = getValueAtPath(path);
// Handle errors serialized by host-side callbacks — reconstruct and throw
// so the isolate's outer try-catch can serialize them back via __reportError

View File

@ -137,10 +137,10 @@ export interface NodeProxy {
* type.
*
* Return types are `unknown` rather than `INodeExecutionData` / `[]`:
* results cross the isolate boundary via `applySync({ result: { copy: true } })`,
* which structured-clones the value and erases nominal types. The handlers
* pass the clone through verbatim, so a precise return type would be
* misleading. Matches the `NodeProxy` return type for the same reason.
* `ivm.Callback` structured-clones results as they pass between isolates,
* which erases nominal types. The handlers pass the clone through verbatim,
* so a precise return type would be misleading. Matches the `NodeProxy`
* return type for the same reason.
*/
export interface InputProxy {
first?: () => unknown;

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/instance-ai",
"version": "1.20.0",
"version": "1.20.4",
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json",

View File

@ -1,5 +1,6 @@
import type { InstanceAiPermissions } from '@n8n/api-types';
import type { Mock } from 'vitest';
import type { z } from 'zod';
import { executeTool } from '../../__tests__/tool-test-utils';
import type { InstanceAiContext } from '../../types';
@ -40,8 +41,8 @@ function suspendCtx(suspendFn: Mock) {
return { resumeData: undefined, suspend: suspendFn } as never;
}
function resumeCtx(approved: boolean) {
return { resumeData: { approved } } as never;
function resumeCtx(approved: boolean, scope?: 'once' | 'session') {
return { resumeData: { approved, ...(scope ? { scope } : {}) } } as never;
}
function noSuspendCtx() {
@ -63,6 +64,21 @@ describe('data-tables tool', () => {
expect(tool.description).toContain('load_skill');
expect(tool.description).toContain('what data tables do I have?');
});
// The SDK validates resume payloads against this schema (converted to JSON
// schema with additionalProperties: false) and replaces resume data with the
// parse result — an undeclared `scope` is rejected or silently stripped, so
// the "Always allow" grant would never reach the handler.
it('resume schema declares scope so the SDK preserves it on resume', () => {
const context = createMockContext();
const tool = createDataTablesTool(context);
const parsed: unknown = (tool.resumeSchema as z.ZodTypeAny).parse({
approved: true,
scope: 'session',
});
expect(parsed).toEqual({ approved: true, scope: 'session' });
});
});
// ── list ────────────────────────────────────────────────────────────────
@ -372,6 +388,48 @@ describe('data-tables tool', () => {
expect(result).toEqual({ table });
});
it('should accept scope=session on resume and persist a session grant', async () => {
const table = { id: 'dt-new', name: 'Contacts' };
const grantSessionToolApproval = vi.fn().mockResolvedValue(undefined);
const context = createMockContext({ permissions: {}, grantSessionToolApproval });
(context.dataTableService.create as Mock).mockResolvedValue(table);
const tool = createDataTablesTool(context);
const result = await executeTool(tool, createInput as never, resumeCtx(true, 'session'));
expect(result).toEqual({ table });
expect(grantSessionToolApproval).toHaveBeenCalledWith('data-tables:create');
});
it('should not persist a session grant when resume has no scope', async () => {
const table = { id: 'dt-new', name: 'Contacts' };
const grantSessionToolApproval = vi.fn().mockResolvedValue(undefined);
const context = createMockContext({ permissions: {}, grantSessionToolApproval });
(context.dataTableService.create as Mock).mockResolvedValue(table);
const tool = createDataTablesTool(context);
await executeTool(tool, createInput as never, resumeCtx(true));
expect(grantSessionToolApproval).not.toHaveBeenCalled();
});
it('should skip HITL when a session grant already exists', async () => {
const table = { id: 'dt-new', name: 'Contacts' };
const context = createMockContext({
permissions: {},
sessionApprovedToolKeys: new Set(['data-tables:create']),
});
(context.dataTableService.create as Mock).mockResolvedValue(table);
const suspendFn = vi.fn();
const tool = createDataTablesTool(context);
const result = await executeTool(tool, createInput as never, suspendCtx(suspendFn));
expect(suspendFn).not.toHaveBeenCalled();
expect(context.dataTableService.create).toHaveBeenCalled();
expect(result).toEqual({ table });
});
it('should return denied when user denies on resume', async () => {
const context = createMockContext({ permissions: {} });

View File

@ -760,6 +760,144 @@ describe('workflows tool', () => {
});
});
describe('update action', () => {
const workflowPayload = { name: 'Updated WF', nodes: [], connections: {} };
it('should suspend for approval before updating a foreign workflow', async () => {
const context = createMockContext({
permissions: { updateWorkflow: 'require_approval' },
});
(context.workflowService.get as Mock).mockResolvedValue({
id: 'wf1',
name: 'Foreign WF',
});
const suspend = vi.fn();
await executeTool(
createWorkflowsTool(context, 'full'),
{ action: 'update', workflowId: 'wf1', workflow: workflowPayload },
{ suspend } as never,
);
expect(suspend).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Update workflow "Foreign WF" (ID: wf1)?',
severity: 'warning',
workflowId: 'wf1',
}),
);
expect(context.workflowService.updateFromWorkflowJSON).not.toHaveBeenCalled();
});
it('should update without approval when the workflow was created in this run', async () => {
const context = createMockContext({
permissions: { updateWorkflow: 'require_approval' },
aiCreatedWorkflowIds: new Set(['wf1']),
});
(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({
id: 'wf1',
versionId: 'v2',
});
const suspend = vi.fn();
const result = await executeTool(
createWorkflowsTool(context, 'full'),
{ action: 'update', workflowId: 'wf1', workflow: workflowPayload },
{ suspend } as never,
);
expect(result).toEqual({ success: true, workflowId: 'wf1' });
expect(suspend).not.toHaveBeenCalled();
expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenCalledWith(
'wf1',
workflowPayload,
);
});
it('should update without approval when the workflow has a session ownership grant', async () => {
const context = createMockContext({
permissions: { updateWorkflow: 'require_approval' },
sessionApprovedToolKeys: new Set(['workflows:update:wf1']),
});
(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({
id: 'wf1',
versionId: 'v2',
});
const suspend = vi.fn();
const result = await executeTool(
createWorkflowsTool(context, 'full'),
{ action: 'update', workflowId: 'wf1', workflow: workflowPayload },
{ suspend } as never,
);
expect(result).toEqual({ success: true, workflowId: 'wf1' });
expect(suspend).not.toHaveBeenCalled();
expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenCalled();
});
it('should still block updates when admin policy denies them for owned workflows', async () => {
const context = createMockContext({
permissions: { updateWorkflow: 'blocked' },
aiCreatedWorkflowIds: new Set(['wf1']),
});
const result = await executeTool(
createWorkflowsTool(context, 'full'),
{ action: 'update', workflowId: 'wf1', workflow: workflowPayload },
{} as never,
);
expect(result).toEqual({
success: false,
denied: true,
reason: 'Action blocked by admin',
});
expect(context.workflowService.updateFromWorkflowJSON).not.toHaveBeenCalled();
});
it('should persist a session update grant when resumed with scope=session', async () => {
const grantSessionToolApproval = vi.fn().mockResolvedValue(undefined);
const context = createMockContext({
permissions: { updateWorkflow: 'require_approval' },
grantSessionToolApproval,
});
(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({
id: 'wf1',
versionId: 'v2',
});
const result = await executeTool(
createWorkflowsTool(context, 'full'),
{ action: 'update', workflowId: 'wf1', workflow: workflowPayload },
{ resumeData: { approved: true, scope: 'session' } } as never,
);
expect(result).toEqual({ success: true, workflowId: 'wf1' });
expect(grantSessionToolApproval).toHaveBeenCalledWith('workflows:update:wf1');
});
it('should not persist a grant for a one-time update approval', async () => {
const grantSessionToolApproval = vi.fn().mockResolvedValue(undefined);
const context = createMockContext({
permissions: { updateWorkflow: 'require_approval' },
grantSessionToolApproval,
});
(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({
id: 'wf1',
versionId: 'v2',
});
await executeTool(
createWorkflowsTool(context, 'full'),
{ action: 'update', workflowId: 'wf1', workflow: workflowPayload },
{ resumeData: { approved: true } } as never,
);
expect(grantSessionToolApproval).not.toHaveBeenCalled();
});
});
describe('delete action', () => {
it('should return denied when permission is blocked', async () => {
const context = createMockContext({

View File

@ -3,7 +3,10 @@
* add-column, delete-column, rename-column, insert-rows, update-rows, delete-rows.
*/
import { Tool } from '@n8n/agents';
import { instanceAiConfirmationSeveritySchema } from '@n8n/api-types';
import {
buildDataTablesSessionGrantKey,
instanceAiConfirmationSeveritySchema,
} from '@n8n/api-types';
import { nanoid } from 'nanoid';
import { z } from 'zod';
@ -49,6 +52,8 @@ const confirmationSuspendSchema = z.object({
const confirmationResumeSchema = z.object({
approved: z.boolean(),
/** `'session'` — user chose "always allow"; persist a thread-level grant. */
scope: z.enum(['once', 'session']).optional(),
});
type ResumeData = z.infer<typeof confirmationResumeSchema>;
@ -58,6 +63,20 @@ interface ConfirmationToolContext {
suspend: (payload: z.infer<typeof confirmationSuspendSchema>) => Promise<never>;
}
function hasSessionGrant(context: InstanceAiContext, action: string): boolean {
return context.sessionApprovedToolKeys?.has(buildDataTablesSessionGrantKey(action)) === true;
}
async function persistSessionGrantIfRequested(
context: InstanceAiContext,
action: string,
resumeData: ResumeData | undefined,
): Promise<void> {
if (resumeData?.approved && resumeData.scope === 'session') {
await context.grantSessionToolApproval?.(buildDataTablesSessionGrantKey(action));
}
}
/**
* Check if an error (or its cause chain) is a DataTableNameConflictError.
* The error class lives in packages/cli so we can't import it directly —
@ -337,7 +356,8 @@ async function handleCreate(
return { denied: true, reason: 'Action blocked by admin' };
}
const needsApproval = context.permissions?.createDataTable !== 'always_allow';
const needsApproval =
context.permissions?.createDataTable !== 'always_allow' && !hasSessionGrant(context, 'create');
// State 1: First call — suspend for confirmation (unless always_allow)
if (needsApproval && (resumeData === undefined || resumeData === null)) {
@ -359,6 +379,8 @@ async function handleCreate(
return { denied: true, reason: 'User denied the action' };
}
await persistSessionGrantIfRequested(context, 'create', resumeData);
// State 3: Approved or always_allow — execute
try {
const table = await context.dataTableService.create(input.name, input.columns, {
@ -389,7 +411,8 @@ async function handleDelete(
return { success: false, denied: true, reason: 'Action blocked by admin' };
}
const needsApproval = context.permissions?.deleteDataTable !== 'always_allow';
const needsApproval =
context.permissions?.deleteDataTable !== 'always_allow' && !hasSessionGrant(context, 'delete');
// State 1: First call — suspend for confirmation (unless always_allow)
if (needsApproval && (resumeData === undefined || resumeData === null)) {
@ -405,6 +428,8 @@ async function handleDelete(
return { success: false, denied: true, reason: 'User denied the action' };
}
await persistSessionGrantIfRequested(context, 'delete', resumeData);
// State 3: Approved or always_allow — execute
await context.dataTableService.delete(input.dataTableId, { projectId: input.projectId });
return { success: true };
@ -421,7 +446,9 @@ async function handleAddColumn(
return { denied: true, reason: 'Action blocked by admin' };
}
const needsApproval = context.permissions?.mutateDataTableSchema !== 'always_allow';
const needsApproval =
context.permissions?.mutateDataTableSchema !== 'always_allow' &&
!hasSessionGrant(context, 'add-column');
// State 1: First call — suspend for confirmation (unless always_allow)
if (needsApproval && (resumeData === undefined || resumeData === null)) {
@ -437,6 +464,8 @@ async function handleAddColumn(
return { denied: true, reason: 'User denied the action' };
}
await persistSessionGrantIfRequested(context, 'add-column', resumeData);
// State 3: Approved or always_allow — execute
const column = await context.dataTableService.addColumn(
input.dataTableId,
@ -457,7 +486,9 @@ async function handleDeleteColumn(
return { success: false, denied: true, reason: 'Action blocked by admin' };
}
const needsApproval = context.permissions?.mutateDataTableSchema !== 'always_allow';
const needsApproval =
context.permissions?.mutateDataTableSchema !== 'always_allow' &&
!hasSessionGrant(context, 'delete-column');
// State 1: First call — suspend for confirmation (unless always_allow)
if (needsApproval && (resumeData === undefined || resumeData === null)) {
@ -473,6 +504,8 @@ async function handleDeleteColumn(
return { success: false, denied: true, reason: 'User denied the action' };
}
await persistSessionGrantIfRequested(context, 'delete-column', resumeData);
// State 3: Approved or always_allow — execute
await context.dataTableService.deleteColumn(input.dataTableId, input.columnId, {
projectId: input.projectId,
@ -491,7 +524,9 @@ async function handleRenameColumn(
return { success: false, denied: true, reason: 'Action blocked by admin' };
}
const needsApproval = context.permissions?.mutateDataTableSchema !== 'always_allow';
const needsApproval =
context.permissions?.mutateDataTableSchema !== 'always_allow' &&
!hasSessionGrant(context, 'rename-column');
// State 1: First call — suspend for confirmation (unless always_allow)
if (needsApproval && (resumeData === undefined || resumeData === null)) {
@ -507,6 +542,8 @@ async function handleRenameColumn(
return { success: false, denied: true, reason: 'User denied the action' };
}
await persistSessionGrantIfRequested(context, 'rename-column', resumeData);
// State 3: Approved or always_allow — execute
await context.dataTableService.renameColumn(input.dataTableId, input.columnId, input.newName, {
projectId: input.projectId,
@ -525,7 +562,9 @@ async function handleInsertRows(
return { denied: true, reason: 'Action blocked by admin' };
}
const needsApproval = context.permissions?.mutateDataTableRows !== 'always_allow';
const needsApproval =
context.permissions?.mutateDataTableRows !== 'always_allow' &&
!hasSessionGrant(context, 'insert-rows');
// State 1: First call — suspend for confirmation (unless always_allow)
if (needsApproval && (resumeData === undefined || resumeData === null)) {
@ -541,6 +580,8 @@ async function handleInsertRows(
return { denied: true, reason: 'User denied the action' };
}
await persistSessionGrantIfRequested(context, 'insert-rows', resumeData);
// State 3: Approved or always_allow — execute
return await context.dataTableService.insertRows(input.dataTableId, input.rows, {
projectId: input.projectId,
@ -558,7 +599,9 @@ async function handleUpdateRows(
return { denied: true, reason: 'Action blocked by admin' };
}
const needsApproval = context.permissions?.mutateDataTableRows !== 'always_allow';
const needsApproval =
context.permissions?.mutateDataTableRows !== 'always_allow' &&
!hasSessionGrant(context, 'update-rows');
// State 1: First call — suspend for confirmation (unless always_allow)
if (needsApproval && (resumeData === undefined || resumeData === null)) {
@ -574,6 +617,8 @@ async function handleUpdateRows(
return { denied: true, reason: 'User denied the action' };
}
await persistSessionGrantIfRequested(context, 'update-rows', resumeData);
// State 3: Approved or always_allow — execute
return await context.dataTableService.updateRows(input.dataTableId, input.filter, input.data, {
projectId: input.projectId,
@ -591,7 +636,9 @@ async function handleDeleteRows(
return { success: false, denied: true, reason: 'Action blocked by admin' };
}
const needsApproval = context.permissions?.mutateDataTableRows !== 'always_allow';
const needsApproval =
context.permissions?.mutateDataTableRows !== 'always_allow' &&
!hasSessionGrant(context, 'delete-rows');
// State 1: First call — suspend for confirmation (unless always_allow)
if (needsApproval && (resumeData === undefined || resumeData === null)) {
@ -616,6 +663,8 @@ async function handleDeleteRows(
return { success: false, denied: true, reason: 'User denied the action' };
}
await persistSessionGrantIfRequested(context, 'delete-rows', resumeData);
// State 3: Approved or always_allow — execute
const result = await context.dataTableService.deleteRows(input.dataTableId, input.filter, {
projectId: input.projectId,

View File

@ -31,6 +31,10 @@ import {
summarizeWorkflowStructure,
} from './workflows/summarize-workflow';
import { validateWorkflowConfig } from './workflows/validate-workflow.service';
import {
grantSessionWorkflowUpdate,
canSkipWorkflowUpdateHitl,
} from './workflows/workflow-build-context';
import { refreshWorkflowSourceFileBindingFromWorkflow } from './workflows/workflow-file-bindings';
import { getReferencedWorkflowIds } from './workflows/workflow-json-utils';
@ -205,16 +209,22 @@ const updateVersionAction = z.object({
// ── Suspend / resume schemas ────────────────────────────────────────────────
const confirmationSuspendSchema = setupSuspendSchema.pick({
requestId: true,
message: true,
severity: true,
});
const confirmationSuspendSchema = setupSuspendSchema
.pick({
requestId: true,
message: true,
severity: true,
workflowId: true,
})
.partial({ workflowId: true });
const suspendSchema = z.union([setupSuspendSchema, confirmationSuspendSchema]);
// Resume: union of standard confirmation (approved) and setup-specific fields.
const resumeSchema = setupResumeSchema;
// Resume: setup-specific fields plus optional session scope for generic approvals
// (e.g. update "always allow" → persist `workflows:update:<id>`).
const resumeSchema = setupResumeSchema.extend({
scope: z.enum(['once', 'session']).optional(),
});
interface WorkflowToolContext {
resumeData: z.infer<typeof resumeSchema> | undefined;
@ -893,7 +903,10 @@ async function handleUpdate(
return { success: false, denied: true, reason: 'Action blocked by admin' };
}
const needsApproval = context.permissions?.updateWorkflow !== 'always_allow';
// Skip HITL for session-created or always-allowed workflows; others still need approval.
const needsApproval =
context.permissions?.updateWorkflow !== 'always_allow' &&
!canSkipWorkflowUpdateHitl(context, input.workflowId);
if (needsApproval && (resumeData === undefined || resumeData === null)) {
const workflowName = await resolveWorkflowName(context, input.workflowId);
@ -901,6 +914,9 @@ async function handleUpdate(
requestId: nanoid(),
message: `Update workflow "${workflowName}" (ID: ${input.workflowId})?`,
severity: 'warning' as const,
// Carried on the confirmation so the UI can scope "always allow" per workflow
// even if tool-call args are incomplete on resume.
workflowId: input.workflowId,
});
}
@ -908,6 +924,11 @@ async function handleUpdate(
return { success: false, denied: true, reason: 'User denied the action' };
}
// "Always allow" — persist so later edits of this workflow skip HITL.
if (resumeData?.approved && resumeData.scope === 'session') {
await grantSessionWorkflowUpdate(context, input.workflowId);
}
if (!isWorkflowJson(input.workflow)) {
return {
success: false,

View File

@ -488,9 +488,11 @@ describe('createBuildWorkflowTool', () => {
});
it('updates a workflow created earlier in the run without requesting approval', async () => {
const grantSessionToolApproval = vi.fn().mockResolvedValue(undefined);
const { context, filePath } = makeContext({
source: 'workflow source',
overrides: {
grantSessionToolApproval,
permissions: {
createWorkflow: 'always_allow',
updateWorkflow: 'require_approval',
@ -507,6 +509,31 @@ describe('createBuildWorkflowTool', () => {
expect(created).toMatchObject({ success: true, workflowId: 'wf-1' });
expect(updated).toMatchObject({ success: true, workflowId: 'wf-1' });
expect(context.aiCreatedWorkflowIds).toEqual(new Set(['wf-1']));
expect(grantSessionToolApproval).toHaveBeenCalledWith('workflows:update:wf-1');
expect(suspend).not.toHaveBeenCalled();
expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenCalledTimes(1);
});
it('updates a workflow with a session ownership grant without requesting approval', async () => {
const { context, filePath } = makeContext({
source: 'workflow source',
overrides: {
sessionApprovedToolKeys: new Set(['workflows:update:wf-session']),
permissions: {
createWorkflow: 'always_allow',
updateWorkflow: 'require_approval',
} as InstanceAiContext['permissions'],
},
});
const suspend = vi.fn();
const result = await executeTool<BuildToolOutput>(
createBuildWorkflowTool(context),
{ filePath, workflowId: 'wf-session' },
{ suspend },
);
expect(result).toMatchObject({ success: true, workflowId: 'wf-session' });
expect(suspend).not.toHaveBeenCalled();
expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenCalledTimes(1);
});
@ -533,12 +560,60 @@ describe('createBuildWorkflowTool', () => {
expect.objectContaining({
message: 'Edit Target workflow (ID: wf-existing)?',
severity: 'warning',
workflowId: 'wf-existing',
}),
);
expect(compileWorkflowSource).not.toHaveBeenCalled();
expect(context.workflowService.updateFromWorkflowJSON).not.toHaveBeenCalled();
});
it('persists a session update grant when edit approval resumes with scope=session', async () => {
const grantSessionToolApproval = vi.fn().mockResolvedValue(undefined);
const { context, filePath } = makeContext({
source: 'workflow source',
overrides: {
grantSessionToolApproval,
permissions: {
createWorkflow: 'always_allow',
updateWorkflow: 'require_approval',
} as InstanceAiContext['permissions'],
},
});
const result = await executeTool<BuildToolOutput>(
createBuildWorkflowTool(context),
{ filePath, workflowId: 'wf-existing' },
{ resumeData: { approved: true, scope: 'session' } },
);
expect(result).toMatchObject({ success: true, workflowId: 'wf-existing' });
expect(grantSessionToolApproval).toHaveBeenCalledWith('workflows:update:wf-existing');
expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenCalledTimes(1);
});
it('does not persist a grant for a one-time edit approval', async () => {
const grantSessionToolApproval = vi.fn().mockResolvedValue(undefined);
const { context, filePath } = makeContext({
source: 'workflow source',
overrides: {
grantSessionToolApproval,
permissions: {
createWorkflow: 'always_allow',
updateWorkflow: 'require_approval',
} as InstanceAiContext['permissions'],
},
});
await executeTool<BuildToolOutput>(
createBuildWorkflowTool(context),
{ filePath, workflowId: 'wf-existing' },
{ resumeData: { approved: true } },
);
expect(grantSessionToolApproval).not.toHaveBeenCalled();
expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenCalledTimes(1);
});
it('blocks updates to workflows created earlier in the run when admin policy denies them', async () => {
const { context, filePath } = makeContext({
source: 'workflow source',

View File

@ -20,8 +20,11 @@ import {
combineWarnings,
formatWarning,
getBuildFailureTrackingKey,
grantSessionWorkflowUpdate,
isApprovedBuildContext,
canSkipWorkflowUpdateHitl,
markSourceBuildFailed,
recordSessionOwnedWorkflow,
resolveBuildIdentifiers,
resolveWorkflowName,
sourceResponseBase,
@ -77,10 +80,14 @@ const confirmationSuspendSchema = z.object({
requestId: z.string(),
message: z.string(),
severity: instanceAiConfirmationSeveritySchema,
/** Resolved target workflow — used by the UI for per-workflow always-allow keys. */
workflowId: z.string(),
});
const confirmationResumeSchema = z.object({
approved: z.boolean(),
/** `'session'` — user chose "always allow"; persist a per-workflow update grant. */
scope: z.enum(['once', 'session']).optional(),
});
interface BuildCtx {
@ -421,13 +428,12 @@ export function createBuildWorkflowTool(context: InstanceAiContext) {
};
}
const isOwnInFlightWorkflow =
targetWorkflowId !== undefined &&
(context.aiCreatedWorkflowIds?.has(targetWorkflowId) ?? false);
const canSkipUpdateHitl =
targetWorkflowId !== undefined && canSkipWorkflowUpdateHitl(context, targetWorkflowId);
if (
targetWorkflowId &&
!isOwnInFlightWorkflow &&
!canSkipUpdateHitl &&
!isApprovedBuildContext(context) &&
context.permissions?.updateWorkflow !== 'always_allow'
) {
@ -494,8 +500,13 @@ export function createBuildWorkflowTool(context: InstanceAiContext) {
requestId: nanoid(),
message: `Edit ${workflowName} (ID: ${targetWorkflowId})?`,
severity: 'warning',
workflowId: targetWorkflowId,
});
}
// "Always allow" — persist so later edits of this workflow skip HITL.
if (ctx.resumeData.approved && ctx.resumeData.scope === 'session') {
await grantSessionWorkflowUpdate(context, targetWorkflowId);
}
}
// Persist inline source first so the workspace file stays canonical for later repairs.
@ -968,7 +979,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) {
...(projectId ? { projectId } : {}),
markAsAiTemporary: true,
});
(context.aiCreatedWorkflowIds ??= new Set<string>()).add(created.id);
await recordSessionOwnedWorkflow(context, created.id);
return await createSuccessResponse(created, 'create');
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';

View File

@ -1,3 +1,4 @@
import { buildUpdateWorkflowSessionGrantKey } from '@n8n/api-types';
import { nanoid } from 'nanoid';
import {
@ -12,6 +13,43 @@ export function isApprovedBuildContext(context: InstanceAiContext): boolean {
return Boolean(buildContext?.plannedTaskService ?? buildContext?.allowPostPlanWorkflowCreate);
}
/**
* True when update HITL can be skipped for this workflow in this session:
* created earlier in the current run (`aiCreatedWorkflowIds`), or covered by a
* `workflows:update:<id>` thread grant (written on create, or when the user
* chose "always allow" for an edit — including foreign workflows). Untrusted
* workflows without a grant still require approval.
*/
export function canSkipWorkflowUpdateHitl(context: InstanceAiContext, workflowId: string): boolean {
if (context.aiCreatedWorkflowIds?.has(workflowId) === true) return true;
const grantKey = buildUpdateWorkflowSessionGrantKey(workflowId);
return context.sessionApprovedToolKeys?.has(grantKey) === true;
}
/**
* Persist a thread grant so later update HITL for this workflow is skipped
* (same run and later runs in the thread). Used when the user chooses
* "always allow" on an edit confirmation.
*/
export async function grantSessionWorkflowUpdate(
context: InstanceAiContext,
workflowId: string,
): Promise<void> {
await context.grantSessionToolApproval?.(buildUpdateWorkflowSessionGrantKey(workflowId));
}
/**
* Mark a newly created workflow as owned by this session: in-memory for the
* current run, and as a persisted thread grant so later runs skip update HITL.
*/
export async function recordSessionOwnedWorkflow(
context: InstanceAiContext,
workflowId: string,
): Promise<void> {
(context.aiCreatedWorkflowIds ??= new Set<string>()).add(workflowId);
await grantSessionWorkflowUpdate(context, workflowId);
}
export async function resolveWorkflowName(
context: InstanceAiContext,
workflowId: string,

View File

@ -1111,14 +1111,12 @@ export interface InstanceAiContext {
/** Records workflow code snapshots for the run debug buffer (dev tooling). */
recordWorkflowCodeSnapshot?: (snapshot: WorkflowCodeSnapshotInput) => void;
/**
* IDs of workflows the agent created during the **currently active plan
* cycle**. Populated by build-workflow on every successful create, and
* hydrated at run start from the persisted plan graph when — and only when —
* the plan is still `active` or
* `awaiting_replan`, so replan follow-up runs keep the bypass active but
* the window closes as soon as the plan settles. Consumed by the delete
* handler to skip the confirmation gate when the agent cleans up its own
* in-flight artifacts. Lazily initialized on first create.
* IDs of workflows the agent created during the **current run**. Populated by
* build-workflow on every successful create (via `recordSessionOwnedWorkflow`).
* Same-run update HITL bypasses consult this set. Cross-run bypass for
* the same thread uses the persisted `workflows:update:<id>` session grant
* written at create time — this in-memory set alone does not survive a new run.
* Lazily initialized on first create.
*/
aiCreatedWorkflowIds?: Set<string>;
/**

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/mcp-apps",
"version": "0.12.0",
"version": "0.12.4",
"description": "MCP Apps UI resources and server helpers for n8n",
"main": "dist/server/index.js",
"types": "dist/server/index.d.ts",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/node-cli",
"version": "0.44.0",
"version": "0.44.4",
"description": "Official CLI for developing community nodes for n8n",
"bin": {
"n8n-node": "bin/n8n-node.mjs"

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/node-engine-compatibility",
"version": "0.3.0",
"version": "0.3.4",
"description": "Adapts v1 nodes and workflows to the Engine 2.0 execution model",
"scripts": {
"clean": "rimraf dist .turbo",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/n8n-nodes-langchain",
"version": "2.35.0",
"version": "2.35.4",
"description": "",
"main": "index.js",
"exports": {

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/scheduler",
"version": "1.4.0",
"version": "1.4.3",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/task-runner",
"version": "2.35.0",
"version": "2.35.4",
"scripts": {
"clean": "rimraf dist .turbo",
"start": "node dist/start.js",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/telemetry",
"version": "1.3.0",
"version": "1.3.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -2,6 +2,36 @@ import { z } from 'zod/v4';
import { defineTelemetryEvents } from '../define';
/**
* How each AI Assistant setup component is configured. Source (who set it) and
* type/provider (what it is) are separate properties on purpose: an env-var
* Daytona sandbox reports sandbox_source 'env' and sandbox_type 'daytona',
* so neither dimension shadows the other.
*/
const setupSnapshotProps = {
model_source: z
.enum(['ui', 'env', 'none'])
.describe('Whether the model is configured via a UI credential, env vars, or not at all'),
model_provider: z
.string()
.nullable()
.describe("Model provider, e.g. 'anthropic'. Null when not configured or not derivable"),
model_name: z
.string()
.nullable()
.describe(
'Selected model name. Null when not configured; on the page-view event also null when the name is env-managed, which the emitting frontend cannot resolve — "AI Assistant setup completed" carries the resolved name',
),
sandbox_source: z.enum(['ui', 'env', 'none']),
sandbox_type: z.enum(['n8n-sandbox', 'daytona']).nullable(),
web_search_source: z
.enum(['ui', 'env', 'disabled', 'none'])
.describe(
"'disabled' means the admin explicitly turned web search off, which counts as decided",
),
web_search_provider: z.enum(['brave', 'searxng']).nullable(),
};
export const INSTANCE_AI_TELEMETRY = defineTelemetryEvents({
BUILDER_SPECCED_TEMPLATED_CRED: {
name: 'Builder specced templated cred',
@ -33,4 +63,98 @@ export const INSTANCE_AI_TELEMETRY = defineTelemetryEvents({
.describe('No longer model-suppliable; expected absent — presence flags a regression'),
}),
},
USER_VIEWED_AI_ASSISTANT_SETUP_PAGE: {
name: 'User viewed AI Assistant setup page',
description:
'The user landed on a self-hosted AI Assistant setup surface: the onboarding takeover on /assistant, or the settings page on /settings/assistant. Carries the configuration snapshot at view time, so joined with "AI Assistant setup completed" it measures setup drop-off. Not emitted on cloud or proxy deployments, where setup is managed.',
properties: z.object({
page: z
.enum(['onboarding', 'settings'])
.describe('Which setup surface: the first-run onboarding wizard or the settings page'),
...setupSnapshotProps,
}),
},
USER_CONFIGURED_AI_ASSISTANT_MODEL: {
name: 'User configured AI Assistant model',
description:
'An admin saved an AI Assistant model connection (PUT /instance-ai/settings), covering the first connect, later changes, and same-provider key rotations: first connects are rows where previous_provider is absent. The event marks a saved configuration, not a verified one — the setup wizard verifies before saving, but a direct API save can skip verification. Env-var model config never emits this; it is visible on "Instance started" and on the snapshot events instead.',
properties: z.object({
provider: z
.string()
.describe("Model provider derived from the credential type, e.g. 'anthropic'"),
model: z.string(),
previous_provider: z
.string()
.optional()
.describe(
'Absent when nothing was configured before — an absent value marks a first connect',
),
previous_model: z.string().optional(),
}),
},
USER_CONFIGURED_AI_ASSISTANT_SANDBOX: {
name: 'User configured AI Assistant sandbox',
description:
'An admin saved an AI Assistant sandbox connection (PUT /instance-ai/settings), covering the first connect, later changes, and same-provider key rotations: first connects are rows where previous_sandbox_type is absent. Env-var sandbox config never emits this.',
properties: z.object({
sandbox_type: z.enum(['n8n-sandbox', 'daytona']),
previous_sandbox_type: z
.enum(['n8n-sandbox', 'daytona'])
.optional()
.describe(
'Absent when nothing was configured before — an absent value marks a first connect',
),
}),
},
USER_CONFIGURED_AI_ASSISTANT_WEB_SEARCH: {
name: 'User configured AI Assistant web search',
description:
'An admin saved an AI Assistant web search connection (PUT /instance-ai/settings), covering the first connect, later changes, and same-provider key rotations: first connects are rows where previous_provider is absent. Explicitly disabling web search does not emit this; that decision is visible as web_search_source "disabled" on the snapshot events.',
properties: z.object({
provider: z.enum(['brave', 'searxng']),
previous_provider: z
.enum(['brave', 'searxng'])
.optional()
.describe(
'Absent when nothing was configured before — an absent value marks a first connect',
),
}),
},
AI_ASSISTANT_CONNECTION_FAILED: {
name: 'AI Assistant connection failed',
description:
'A setup verification call (POST /instance-ai/settings/verify/*) failed for a model, sandbox, or web search connection. One event covers all three components; the component property tells them apart. Fires once per failed verify attempt, including retries.',
properties: z.object({
component: z.enum(['model', 'sandbox', 'web_search']),
provider: z
.string()
.nullable()
.describe(
'Provider being verified, when known — model provider, sandbox type, or search provider',
),
failure: z
.enum([
'unauthorized',
'forbidden',
'quota_exceeded',
'rate_limited',
'timeout',
'unreachable',
'invalid_response',
'provider_error',
])
.describe('Classified failure, same taxonomy the verify response returns to the UI'),
error_message: z
.string()
.describe(
'Sanitized provider error: URL queries stripped, length capped, never key values',
),
}),
},
AI_ASSISTANT_SETUP_COMPLETED: {
name: 'AI Assistant setup completed',
description:
'A self-hosted instance reached a complete AI Assistant setup for the first time: model configured, sandbox configured, and web search decided (configured or explicitly disabled) — the same predicate that unlocks the assistant UI. Fires at most once per instance, guarded by a persisted settings key, regardless of how the last piece was set: emitted from the settings save path, with a boot-time check so an env-var finish is also counted. No "User" prefix because the last piece can land via env vars with no acting user.',
properties: z.object({ ...setupSnapshotProps }),
},
});

View File

@ -64,8 +64,20 @@ export const INSTANCE_TELEMETRY = defineTelemetryEvents({
sandbox_provider: z.string(),
search_brave_set: z.boolean(),
search_searxng_set: z.boolean(),
model_env_set: z
.boolean()
.describe(
'Model connection env vars are set (API key or base URL). UI-credential model state is on the "AI Assistant setup completed" and "User viewed AI Assistant setup page" events instead',
),
model_id: z
.string()
.describe(
"Configured model identifier incl. provider, e.g. 'anthropic/claude-sonnet-4'",
),
})
.describe('Which sandbox and search providers are configured, never key values'),
.describe(
'Which model, sandbox and search providers are configured via env vars, never key values',
),
metrics: z.object({
metrics_enabled: z.boolean(),
metrics_category_default: z.boolean(),

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/tournament",
"version": "1.9.0",
"version": "1.9.1",
"description": "Output compatible rewrite of riot tmpl",
"main": "dist/index.js",
"module": "src/index.ts",

View File

@ -3,7 +3,10 @@ import type {
ExpressionKind,
PatternKind,
PropertyKind,
SpreadElementKind,
SpreadPropertyKind,
StatementKind,
SwitchCaseKind,
VariableDeclaratorKind,
} from 'ast-types/lib/gen/kinds';
@ -23,4 +26,7 @@ export type ParentKind =
| PropertyKind
| PatternKind
| VariableDeclaratorKind
| CatchClauseKind;
| CatchClauseKind
| SpreadElementKind
| SpreadPropertyKind
| SwitchCaseKind;

View File

@ -80,6 +80,10 @@ const customPatches: Partial<Record<ParentKind['type'], CustomPatcher>> = {
}
},
Property(path, parent: namedTypes.Property, dataNode) {
if (parent.computed && parent.key === path.node) {
polyfillVar(path, dataNode);
return;
}
if (path.node !== parent.value) {
return;
}
@ -117,6 +121,36 @@ const customPatches: Partial<Record<ParentKind['type'], CustomPatcher>> = {
polyfillVar(path, dataNode);
}
},
SpreadElement(path, parent: namedTypes.SpreadElement, dataNode) {
if (parent.argument === path.node) {
polyfillVar(path, dataNode);
}
},
SpreadProperty(path, parent: namedTypes.SpreadProperty, dataNode) {
if (parent.argument === path.node) {
polyfillVar(path, dataNode);
}
},
MethodDefinition(path, parent: namedTypes.MethodDefinition, dataNode) {
if (parent.computed && parent.key === path.node) {
polyfillVar(path, dataNode);
}
},
SwitchCase(path, parent: namedTypes.SwitchCase, dataNode) {
if (parent.test === path.node) {
polyfillVar(path, dataNode);
}
},
ClassDeclaration(path, parent: namedTypes.ClassDeclaration, dataNode) {
if (parent.superClass === path.node) {
polyfillVar(path, dataNode);
}
},
ClassExpression(path, parent: namedTypes.ClassExpression, dataNode) {
if (parent.superClass === path.node) {
polyfillVar(path, dataNode);
}
},
};
export const jsVariablePolyfill = (
@ -143,6 +177,12 @@ export const jsVariablePolyfill = (
case 'OptionalMemberExpression':
case 'VariableDeclarator':
case 'ArrowFunctionExpression':
case 'SpreadElement':
case 'SpreadProperty':
case 'MethodDefinition':
case 'SwitchCase':
case 'ClassDeclaration':
case 'ClassExpression':
if (!customPatches[parent.type]) {
throw new Error(`Couldn't find custom patcher for parent type: ${parent.type}`);
}
@ -211,12 +251,9 @@ export const jsVariablePolyfill = (
case 'RestElement':
case 'ArrayPattern':
case 'ObjectPattern':
case 'ClassExpression':
case 'RecordExpression':
case 'V8IntrinsicIdentifier':
case 'TopicReference':
case 'MethodDefinition':
case 'ClassDeclaration':
case 'ClassProperty':
case 'StaticBlock':
case 'ClassBody':
@ -314,6 +351,7 @@ export const jsVariablePolyfill = (
// This is a simple type guard that guarantees we haven't missed
// a case. It'll result in a type error at compile time.
assertNever(parent);
polyfillVar(path, dataNode);
break;
}
},

View File

@ -0,0 +1,107 @@
import { Tournament } from '../src/index';
const evaluator = new Tournament((e) => {
throw e;
});
/**
* Every identifier that is a free read has to be resolved through the data
* context. One that is skipped resolves against the host scope instead, which
* is how `process` and friends become reachable from an expression.
*/
describe('jsVariablePolyfill', () => {
describe('free reads resolve through the data context', () => {
it.each([
['object spread', '{{ ({...value}).a }}'],
['nested object spread', '{{ ({...({...value})}).a }}'],
['computed object key', '{{ ({[key]: 1}).a }}'],
['computed class field', '{{ (() => { class X { [key] = 1; } return new X().a; })() }}'],
[
'computed class method',
'{{ (() => { class X { [key]() { return 1; } } return new X().a(); })() }}',
],
['switch case', '{{ (() => { switch (1) { case one: return 1; } })() }}'],
])('%s', (_, expression) => {
expect(evaluator.execute(expression, { value: { a: 1 }, key: 'a', one: 1 })).toBe(1);
});
it('array spread', () => {
expect(evaluator.execute('{{ [...value].length }}', { value: [1, 2, 3] })).toBe(3);
});
it('call argument spread', () => {
expect(evaluator.execute('{{ Math.max(...value) }}', { value: [1, 5, 3] })).toBe(5);
});
it('base class', () => {
class Base {
greet() {
return 'hello';
}
}
expect(
evaluator.execute('{{ (() => { class X extends Base {} return new X().greet(); })() }}', {
Base,
}),
).toBe('hello');
});
});
describe('unresolved free reads do not fall through to the host scope', () => {
it.each([
['object spread', '{{ ({...process}) }}'],
['nested object spread', '{{ ({...({...process})}) }}'],
['spread inside a function', '{{ (() => ({...process}))() }}'],
])('%s', (_, expression) => {
expect(evaluator.execute(expression, {})).toEqual({});
});
it.each([
['array spread', '{{ [...process] }}'],
['call argument spread', '{{ ((a) => a)(...process) }}'],
])('%s', (_, expression) => {
expect(() => evaluator.execute(expression, {})).toThrow(/is not iterable/);
});
it('base class', () => {
expect(() => evaluator.execute('{{ (() => { class X extends Buffer {} })() }}', {})).toThrow(
/is not a constructor or null/,
);
});
it('switch case', () => {
expect(
evaluator.execute(
'{{ (() => { switch (1) { case process: return "host"; } return "safe"; })() }}',
{},
),
).toBe('safe');
});
it.each([
['computed object key', '{{ Object.keys({[process]: 1})[0] }}'],
[
'computed class method key',
'{{ (() => { class X { [process]() {} } return Object.getOwnPropertyNames(X.prototype)[1]; })() }}',
],
])('%s', (_, expression) => {
expect(evaluator.execute(expression, { Object })).toBe('undefined');
});
});
describe('bindings are left alone', () => {
it.each([
['rest property', '{{ (() => { const {...rest} = value; return rest.a; })() }}'],
['rest element', '{{ (() => { const [...rest] = [1]; return rest[0]; })() }}'],
['rest parameter', '{{ ((...rest) => rest[0])(1) }}'],
[
'local shadowing a host global',
'{{ (() => { const process = value; return {...process}.a; })() }}',
],
['parameter shadowing a host global', '{{ ((process) => ({...process}).a)(value) }}'],
])('%s', (_, expression) => {
expect(evaluator.execute(expression, { value: { a: 1 } })).toBe(1);
});
});
});

View File

@ -1,7 +1,7 @@
{
"name": "@n8n/utils",
"type": "module",
"version": "1.43.0",
"version": "1.43.1",
"files": [
"dist"
],

View File

@ -30,24 +30,33 @@ describe('sleep', () => {
expect(onResolve).toHaveBeenCalled();
});
it('should reject immediately if abort signal is already aborted', async () => {
it('should reject with the abort reason if abort signal is already aborted', async () => {
const abortController = new AbortController();
abortController.abort();
const reason = new Error('deadline');
abortController.abort(reason);
await expect(sleep(1000, abortController.signal)).rejects.toThrow('Aborted');
await expect(sleep(1000, abortController.signal)).rejects.toBe(reason);
});
it('should reject when abort signal is triggered during sleep', async () => {
it('should reject with the abort reason when abort signal is triggered during sleep', async () => {
const abortController = new AbortController();
const reason = new Error('deadline');
const onSettled = vi.fn();
const promise = sleep(1000, abortController.signal).catch(onSettled);
await vi.advanceTimersByTimeAsync(50);
abortController.abort();
abortController.abort(reason);
await promise;
expect(onSettled).toHaveBeenCalledWith(expect.objectContaining({ message: 'Aborted' }));
expect(onSettled).toHaveBeenCalledWith(reason);
});
it('should reject with the default abort reason when none is given', async () => {
const abortController = new AbortController();
abortController.abort();
await expect(sleep(1000, abortController.signal)).rejects.toThrow('This operation was aborted');
});
it('should clean up timeout when aborted during sleep', async () => {
@ -59,10 +68,22 @@ describe('sleep', () => {
await vi.advanceTimersByTimeAsync(50);
abortController.abort();
await expect(sleepPromise).rejects.toThrow('Aborted');
await expect(sleepPromise).rejects.toThrow('This operation was aborted');
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});
it('should remove the abort listener when the sleep completes', async () => {
const abortController = new AbortController();
const removeListenerSpy = vi.spyOn(abortController.signal, 'removeEventListener');
const sleepPromise = sleep(100, abortController.signal);
await vi.advanceTimersByTimeAsync(100);
await sleepPromise;
expect(removeListenerSpy).toHaveBeenCalledWith('abort', expect.any(Function));
});
});

View File

@ -1,25 +1,29 @@
import { ensureError } from './errors/ensure-error';
async function sleepWithAbort(ms: number, abortSignal: AbortSignal): Promise<void> {
return await new Promise((resolve, reject) => {
if (abortSignal.aborted) {
reject(new Error('Aborted'));
reject(ensureError(abortSignal.reason));
return;
}
const timeout = setTimeout(resolve, ms);
const onAbort = () => {
clearTimeout(timeout);
reject(ensureError(abortSignal.reason));
};
abortSignal.addEventListener(
'abort',
() => {
clearTimeout(timeout);
reject(new Error('Aborted'));
},
{ once: true },
);
const timeout = setTimeout(() => {
abortSignal.removeEventListener('abort', onAbort);
resolve();
}, ms);
abortSignal.addEventListener('abort', onAbort, { once: true });
});
}
/**
* Resolves after `ms` milliseconds, or rejects early if `abortSignal` is aborted.
* Resolves after `ms` milliseconds, or rejects with the signal's abort reason
* if `abortSignal` fires first.
*/
export async function sleep(ms: number, abortSignal?: AbortSignal): Promise<void> {
if (!abortSignal) {

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/workflow-sdk",
"version": "0.28.0",
"version": "0.28.3",
"description": "TypeScript SDK for programmatically creating n8n workflows",
"exports": {
".": {

View File

@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "2.35.0",
"version": "2.35.5",
"description": "n8n Workflow Automation Tool",
"main": "dist/index",
"types": "dist/index.d.ts",

View File

@ -110,3 +110,7 @@ test('should start a task runner', async () => {
expect(taskRunnerModule.start).toHaveBeenCalledTimes(1);
});
test('execute:batch needs the expression engine', () => {
expect(new ExecuteBatch().needsExpressionEngine).toBe(true);
});

View File

@ -1,4 +1,4 @@
import { LicenseState } from '@n8n/backend-common';
import { LicenseState, Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import type { User, WorkflowEntity, Project } from '@n8n/db';
@ -10,7 +10,9 @@ import {
DeploymentKeyRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { ErrorReporter } from 'n8n-core';
import type { IRun } from 'n8n-workflow';
import { Expression } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import { ActiveExecutions } from '@/active-executions';
@ -41,7 +43,7 @@ const loadNodesAndCredentials = mockInstance(LoadNodesAndCredentials);
const shutdownService = mockInstance(ShutdownService);
const deprecationService = mockInstance(DeprecationService);
mockInstance(MessageEventBus);
mockInstance(ExpressionObservabilityProvider);
const expressionObservability = mockInstance(ExpressionObservabilityProvider);
const posthogClient = mockInstance(PostHogClient);
const telemetryEventRelay = mockInstance(TelemetryEventRelay);
const externalHooks = mockInstance(ExternalHooks);
@ -50,6 +52,8 @@ mockInstance(LicenseState);
mockInstance(CommunityPackagesService);
mockInstance(WorkflowFailureNotificationEventRelay);
const logger = mockInstance(Logger);
const errorReporter = mockInstance(ErrorReporter);
const dbConnection = mockInstance(DbConnection);
dbConnection.init.mockResolvedValue(undefined);
dbConnection.migrate.mockResolvedValue(undefined);
@ -60,6 +64,26 @@ const deploymentKeyRepository = mockInstance(DeploymentKeyRepository);
deploymentKeyRepository.findActiveByType.mockResolvedValue(null);
deploymentKeyRepository.insertOrIgnore.mockResolvedValue(undefined);
// minimal command for exercising BaseCommand.init():
// Execute.init() chains singletons that cannot init twice per process
class ReadOnlyCommand extends BaseCommand {}
// default config for every test, so none depends on what an earlier test leaked
// into the container; tests that need different values set their own
beforeEach(() => {
Container.set(
GlobalConfig,
mock<GlobalConfig>({
taskRunners: {},
nodes: {},
expressionEngine: { engine: 'legacy' },
// must be numeric: the SIGTERM/SIGINT handlers registered by init() compute
// a setTimeout delay from it, and a mock proxy yields NaN at pool teardown
generic: { gracefulShutdownTimeout: 30 },
}),
);
});
test('should start a task runner', async () => {
// arrange
@ -85,14 +109,6 @@ test('should start a task runner', async () => {
workflowRunner.run.mockResolvedValue('123');
activeExecutions.getPostExecutePromise.mockResolvedValue(run);
Container.set(
GlobalConfig,
mock<GlobalConfig>({
taskRunners: {},
nodes: {},
}),
);
const cmd = new Execute();
// @ts-expect-error Protected property
cmd.flags = { id: '123' };
@ -113,8 +129,6 @@ test('should not seed the instance identity and should tolerate deployment key r
deploymentKeyRepository.insertOrIgnore.mockClear();
deploymentKeyRepository.findActiveByType.mockRejectedValueOnce(new Error('permission denied'));
// minimal command: Execute.init() chains singletons that cannot init twice per process
class ReadOnlyCommand extends BaseCommand {}
const cmd = new ReadOnlyCommand();
// act
@ -125,3 +139,118 @@ test('should not seed the instance identity and should tolerate deployment key r
expect(deploymentKeyRepository.insertOrIgnore).not.toHaveBeenCalled();
});
test('should not init the expression engine for commands that do not need it', async () => {
// arrange
const initSpy = vi.spyOn(Expression, 'initExpressionEngine').mockResolvedValue(undefined);
const setEngineSpy = vi.spyOn(Expression, 'setExpressionEngine').mockReturnValue(undefined);
Container.set(
GlobalConfig,
mock<GlobalConfig>({
taskRunners: {},
nodes: {},
expressionEngine: { engine: 'vm' },
generic: { gracefulShutdownTimeout: 30 },
}),
);
const cmd = new ReadOnlyCommand();
// act
await cmd.init();
// assert
expect(initSpy).not.toHaveBeenCalled();
// the configured engine is still recorded, so evaluating an expression without
// an initialized engine throws instead of silently using the legacy engine
expect(setEngineSpy).toHaveBeenCalledWith('vm');
});
test('should exit with a crash when expression engine init fails', async () => {
// arrange
const initSpy = vi
.spyOn(Expression, 'initExpressionEngine')
.mockRejectedValue(new Error('isolated-vm failed to load'));
const exitSpy = vi
// @ts-expect-error Protected method
.spyOn(BaseCommand.prototype, 'exitWithCrash')
.mockResolvedValue(undefined);
Container.set(
GlobalConfig,
mock<GlobalConfig>({
taskRunners: {},
nodes: {},
expressionEngine: {
engine: 'vm',
poolSize: 4,
maxCodeCacheSize: 1024,
bridgeTimeout: 5000,
bridgeMemoryLimit: 128,
idleTimeout: 30,
},
generic: { gracefulShutdownTimeout: 30 },
}),
);
class ExpressionCommand extends BaseCommand {
override needsExpressionEngine = true;
}
const cmd = new ExpressionCommand();
// act
await cmd.init();
// assert
expect(initSpy).toHaveBeenCalledTimes(1);
expect(initSpy).toHaveBeenCalledWith({
engine: 'vm',
poolSize: 4,
maxCodeCacheSize: 1024,
bridgeTimeout: 5000,
bridgeMemoryLimit: 128,
idleTimeoutMs: 30_000, // the config value is in seconds
observability: expressionObservability,
});
expect(exitSpy).toHaveBeenCalledWith(expect.stringContaining('isolated-vm'), expect.any(Error));
});
test('exitWithCrash logs the crash message to the console', async () => {
// arrange
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
vi.useFakeTimers();
const cmd = new ReadOnlyCommand();
// @ts-expect-error Protected property, set directly to avoid another init() traversal
cmd.errorReporter = errorReporter;
const cause = new Error('isolated-vm failed to load');
// act
try {
// @ts-expect-error Protected method
const promise: Promise<void> = cmd.exitWithCrash('something broke', cause);
await vi.advanceTimersByTimeAsync(2000);
await promise;
} finally {
vi.useRealTimers();
}
// assert
// the error reporter may only reach Sentry, so the message must also hit the console
expect(logger.error).toHaveBeenCalledWith('something broke', { error: cause });
expect(exitSpy).toHaveBeenCalledWith(1);
});
test('execute needs the expression engine', () => {
expect(new Execute().needsExpressionEngine).toBe(true);
});

View File

@ -363,3 +363,7 @@ describe('Start - AuthRolesService initialization', () => {
});
});
});
test('start needs the expression engine', () => {
expect(new Start().needsExpressionEngine).toBe(true);
});

Some files were not shown because too many files have changed in this diff Show More