Compare commits

...

28 Commits

Author SHA1 Message Date
n8n-assistant[bot]
f5da43d99f
🚀 Release 2.41.6 (#40134)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-10-02 07:15:42 +00:00
n8n-assistant[bot]
d719c878a0
fix(core): Keep the task runner running on an unhandled promise rejection (backport to release-candidate/2.41.x) (#40092)
Co-authored-by: Emilia <100027345+sovietspaceship@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:45:20 +00:00
n8n-assistant[bot]
a9c858b4d9
🚀 Release 2.41.5 (#40038)
Co-authored-by: Matsuuu <16068444+Matsuuu@users.noreply.github.com>
2026-10-01 07:52:06 +00:00
n8n-assistant[bot]
925aa38f60
chore: Bundle/2.x (backport to release-candidate/2.41.x) (#40033)
Co-authored-by: n8n-assistant[bot] <100856346+n8n-assistant[bot]@users.noreply.github.com>
Co-authored-by: Matsu <matias.huhta@n8n.io>
2026-10-01 10:34:38 +03:00
n8n-assistant[bot]
3d79333ced
fix(core): Ask before building a picked onboarding suggestion (no-changelog) (backport to release-candidate/2.41.x) (#39956)
Co-authored-by: Raúl Gómez Morales <raul00gm@gmail.com>
2026-09-30 08:02:26 +00:00
n8n-assistant[bot]
063ba759a7
🚀 Release 2.41.4 (#39940)
Co-authored-by: Matsuuu <16068444+Matsuuu@users.noreply.github.com>
2026-09-30 05:50:41 +00:00
n8n-assistant[bot]
e137e9741f
chore: Bundle/2.x (backport to release-candidate/2.41.x) (#39938)
Co-authored-by: n8n-assistant[bot] <100856346+n8n-assistant[bot]@users.noreply.github.com>
Co-authored-by: Matsu <matias.huhta@n8n.io>
2026-09-30 05:31:05 +00:00
n8n-assistant[bot]
1d655e3e19
fix(editor): Show remaining credits on Assistant banner for Cloud UBB (backport to release-candidate/2.41.x) (#39912)
Co-authored-by: Oğuz Eroğlu <oguz.eroglu@n8n.io>
2026-09-29 14:58:08 +00:00
n8n-assistant[bot]
a578ee7730
fix(editor): Correct the n8n Assistant onboarding greeting animations and actions (no-changelog) (backport to release-candidate/2.41.x) (#39868)
Co-authored-by: Raúl Gómez Morales <raul00gm@gmail.com>
2026-09-29 10:59:12 +00:00
n8n-assistant[bot]
dc84b24c3b
feat(editor): Route assistant credit CTAs to top-up for UBB accounts (backport to release-candidate/2.41.x) (#39854)
Co-authored-by: Oğuz Eroğlu <oguz.eroglu@n8n.io>
2026-09-29 09:28:59 +00:00
n8n-assistant[bot]
940f65e55e
fix(API): Return an execution when its stored trace context is incomplete (backport to release-candidate/2.41.x) (#39827)
Co-authored-by: Ali Elkhateeb <ali.elkhateeb@n8n.io>
2026-09-29 07:25:39 +00:00
n8n-assistant[bot]
596d8aad39
feat(core): Update the n8n Assistant onboarding copy (no-changelog) (backport to release-candidate/2.41.x) (#39782)
Co-authored-by: Raúl Gómez Morales <raul00gm@gmail.com>
2026-09-28 14:52:02 +00:00
n8n-assistant[bot]
afafb025ff
feat: Add an n8n Assistant onboarding thread for new Cloud signups (backport to release-candidate/2.41.x) (#39771)
Co-authored-by: Raúl Gómez Morales <raul00gm@gmail.com>
2026-09-28 14:26:30 +00:00
n8n-assistant[bot]
fe3c5bceb7
fix(core): Count a database ping as successful when its reply arrives during event-loop lag (backport to release-candidate/2.41.x) (#39757)
Co-authored-by: Lorent Lempereur <looorent@users.noreply.github.com>
2026-09-28 12:34:20 +00:00
n8n-assistant[bot]
af4fa9bbe3
fix(core): Store queue job results only for executions this process enqueued (backport to release-candidate/2.41.x) (#39725)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
2026-09-28 09:44:52 +00:00
n8n-assistant[bot]
7f7a8ac25b
🚀 Release 2.41.3 (#39574)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-09-25 07:15:53 +00:00
n8n-assistant[bot]
29afe517b3
fix(core): Propagate project span attributes to node spans (backport to release-candidate/2.41.x) (#39456)
Co-authored-by: Theodore Nastos <15264814+theonastos@users.noreply.github.com>
2026-09-24 08:05:59 +00:00
n8n-assistant[bot]
2db9ab8dce
🚀 Release 2.41.2 (#39447)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-09-24 07:16:20 +00:00
n8n-assistant[bot]
56f9e97b8e
fix(core): Retry instance reports that cross the UTC midnight boundary (backport to release-candidate/2.41.x) (#39433)
Co-authored-by: Dmitrii <dmitrii.kulikov@n8n.io>
2026-09-23 21:28:35 +00:00
n8n-assistant[bot]
2de7d47c2e
fix(core): Keep OpenTelemetry export working after a restart when Sentry is enabled (backport to release-candidate/2.41.x) (#39367)
Co-authored-by: ocdejong <52361003+ocdejong@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 16:02:14 +02:00
n8n-assistant[bot]
b4e38b3cc7
feat(core): Track n8n Assistant workflow setup (no-changelog) (backport to release-candidate/2.41.x) (#39376)
Co-authored-by: oleg <me@olegivaniv.com>
2026-09-23 13:12:43 +00:00
n8n-assistant[bot]
665bcd9af7
fix(editor): Improve n8n Assistant async credential setup (no-changelog) (backport to release-candidate/2.41.x) (#39391)
Co-authored-by: oleg <me@olegivaniv.com>
2026-09-23 14:51:46 +02:00
n8n-assistant[bot]
07f2d3675a
fix(core): Record live n8n Assistant test runs as verification evidence (backport to release-candidate/2.41.x) (#39383)
Co-authored-by: Jaakko Husso <jaakko@n8n.io>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:30:55 +00:00
n8n-assistant[bot]
4c5ecc22fc
feat(core): Authenticate instance reports with the license certificate (backport to release-candidate/2.41.x) (#39380)
Co-authored-by: Konstantin Tieber <46342664+konstantintieber@users.noreply.github.com>
2026-09-23 11:24:49 +00:00
n8n-assistant[bot]
397e4f2eb7
🚀 Release 2.41.1 (#39351)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-09-23 07:14:32 +00:00
n8n-assistant[bot]
d8c49e2a3e
feat(core): Enable Agents by default (backport to release-candidate/2.41.x) (#39328)
Co-authored-by: Michael Drury <me@michaeldrury.co.uk>
2026-09-22 19:15:52 +00:00
n8n-assistant[bot]
69022dc863
fix(core): Keep skill activation from invalidating the prompt cache (no-changelog) (backport to release-candidate/2.41.x) (#39295)
Co-authored-by: Riqwan Thamir <rmthamir@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 13:41:43 +00:00
n8n-assistant[bot]
b3196fb520
fix(core): Restore pubsub subscriptions after Redis reconnect and detect half-open subscriber connections (backport to release-candidate/2.41.x) (#39241)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
2026-09-22 09:14:54 +00:00
436 changed files with 17164 additions and 1915 deletions

View File

@ -43,7 +43,8 @@ frontend, and extensible node-based workflow engine.
comments keep the historical `n8nConnect` / `n8n credits` / AI Gateway names
- **Shared utilities:** before you hand-roll a utility (`isRecord`, secret or
PII redaction, JSON extraction from LLM output, Zod to JSON Schema, model-id
parsing, …), you MUST check the shared packages for an existing
parsing, AI client HTTP transport and response-size limiting, …), you MUST
check the shared packages for an existing
implementation and use it: `@n8n/utils` (generic helpers, redaction),
`@n8n/ai-utilities` (AI- and LLM-specific helpers) and `n8n-workflow`
(workflow graph and traversal). A new shared helper usually belongs in one of

View File

@ -1,3 +1,67 @@
## [2.41.6](https://github.com/n8n-io/n8n/compare/n8n@2.41.5...n8n@2.41.6) (2026-10-02)
### Bug Fixes
* **core:** Keep the task runner running on an unhandled promise rejection ([#40092](https://github.com/n8n-io/n8n/issues/40092)) ([d719c87](https://github.com/n8n-io/n8n/commit/d719c878a0965101650665200176a835224ed3bf))
## [2.41.5](https://github.com/n8n-io/n8n/compare/n8n@2.41.4...n8n@2.41.5) (2026-10-01)
## [2.41.4](https://github.com/n8n-io/n8n/compare/n8n@2.41.3...n8n@2.41.4) (2026-09-30)
### Bug Fixes
* **API:** Return an execution when its stored trace context is incomplete ([#39827](https://github.com/n8n-io/n8n/issues/39827)) ([940f65e](https://github.com/n8n-io/n8n/commit/940f65e55e234b91773e4628f8ac15506f7e644f))
* **core:** Count a database ping as successful when its reply arrives during event-loop lag ([#39757](https://github.com/n8n-io/n8n/issues/39757)) ([fe3c5bc](https://github.com/n8n-io/n8n/commit/fe3c5bceb7c2c955863427e2f3f17a8b56eb7d8c))
* **core:** Store queue job results only for executions this process enqueued ([#39725](https://github.com/n8n-io/n8n/issues/39725)) ([af4fa9b](https://github.com/n8n-io/n8n/commit/af4fa9bbe341e86235510fcd4106009bff2e27e4))
* **editor:** Show remaining credits on Assistant banner for Cloud UBB ([#39912](https://github.com/n8n-io/n8n/issues/39912)) ([1d655e3](https://github.com/n8n-io/n8n/commit/1d655e3e194d699471d2c6db0095b7d3445da5ea))
### Features
* Add an n8n Assistant onboarding thread for new Cloud signups ([#39771](https://github.com/n8n-io/n8n/issues/39771)) ([afafb02](https://github.com/n8n-io/n8n/commit/afafb025ff363e24e9e484d9d78f316f25054604))
* **editor:** Route assistant credit CTAs to top-up for UBB accounts ([#39854](https://github.com/n8n-io/n8n/issues/39854)) ([dc84b24](https://github.com/n8n-io/n8n/commit/dc84b24c3b82f3af166d2a99e498638d3840aef3))
## [2.41.3](https://github.com/n8n-io/n8n/compare/n8n@2.41.2...n8n@2.41.3) (2026-09-25)
### Bug Fixes
* **core:** Propagate project span attributes to node spans ([#39456](https://github.com/n8n-io/n8n/issues/39456)) ([29afe51](https://github.com/n8n-io/n8n/commit/29afe517b3a6954a6070c13e26dde9e9d648bfe3))
## [2.41.2](https://github.com/n8n-io/n8n/compare/n8n@2.41.1...n8n@2.41.2) (2026-09-24)
### Bug Fixes
* **core:** Keep OpenTelemetry export working after a restart when Sentry is enabled ([#39367](https://github.com/n8n-io/n8n/issues/39367)) ([2de7d47](https://github.com/n8n-io/n8n/commit/2de7d47c2e079bd4474984b11e038877ac5fd7cf))
* **core:** Record live n8n Assistant test runs as verification evidence ([#39383](https://github.com/n8n-io/n8n/issues/39383)) ([07f2d36](https://github.com/n8n-io/n8n/commit/07f2d3675a31fbdc2b82d3e5c205669102d9bb2c))
* **core:** Retry instance reports that cross the UTC midnight boundary ([#39433](https://github.com/n8n-io/n8n/issues/39433)) ([56f9e97](https://github.com/n8n-io/n8n/commit/56f9e97b8e026d072e98c0eea6ce17f3c6076b4c))
### Features
* **core:** Authenticate instance reports with the license certificate ([#39380](https://github.com/n8n-io/n8n/issues/39380)) ([4c5ecc2](https://github.com/n8n-io/n8n/commit/4c5ecc22fce7990ae4ff9ba44bfb99f3c202b61d))
## [2.41.1](https://github.com/n8n-io/n8n/compare/n8n@2.41.0...n8n@2.41.1) (2026-09-23)
### Bug Fixes
* **core:** Restore pubsub subscriptions after Redis reconnect and detect half-open subscriber connections ([#39241](https://github.com/n8n-io/n8n/issues/39241)) ([b3196fb](https://github.com/n8n-io/n8n/commit/b3196fb520d4011ed66fdfcceb8da5da2b2e819d))
### Features
* **core:** Enable Agents by default ([#39328](https://github.com/n8n-io/n8n/issues/39328)) ([d8c49e2](https://github.com/n8n-io/n8n/commit/d8c49e2a3e85344e3d651bcf26dea90fa3707d2c))
# [2.41.0](https://github.com/n8n-io/n8n/compare/n8n@2.40.0...n8n@2.41.0) (2026-09-22)

View File

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "2.41.0",
"version": "2.41.6",
"private": true,
"engines": {
"node": ">=24.0.0",

View File

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

View File

@ -67,29 +67,31 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeuLqshPA7QoqLUtLRhcA\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01Krds9KGve8gJNTNa9jn6N7\",\"name\":\"load_skill\",\"input\":{\"skillId\":\"engineering-status\"},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1045,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":57,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfJCd2zmwW8c3i9MDr6fG\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I'll load the engineering-status skill first, then use it to format the status token.\"},{\"type\":\"tool_use\",\"id\":\"toolu_019R6LssCYfGrz8ddVcPvYQC\",\"name\":\"load_skill\",\"input\":{\"skillId\":\"engineering-status\"},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1045,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":76,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:04Z",
"anthropic-ratelimit-input-tokens-reset": "2026-09-22T08:57:55Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:04Z",
"anthropic-ratelimit-output-tokens-reset": "2026-09-22T08:57:55Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-09-10T07:12:04Z",
"anthropic-ratelimit-requests-reset": "2026-09-22T08:57:55Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:04Z",
"anthropic-ratelimit-tokens-reset": "2026-09-22T08:57:55Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a38c9f485dd8bc7b-PRG",
"cf-ray": "a3f01ad7ea6cbbdd-FRA",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Thu, 10 Sep 2026 07:12:04 GMT",
"request-id": "req_011CeuLqs6fxp1AkSuMSRWYb",
"date": "Tue, 22 Sep 2026 08:57:55 GMT",
"request-id": "req_011CfJCd2nsYW1GzkhCoPFhw",
"server": "cloudflare",
"server-timing": "x-originResponse;dur=667",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-77c37e49f4c62f7623d8cc66c4f3a26a-6bf7325e3f3f69d3-01",
"traceresponse": "00-234e70eda24ff35d6c847bca1695098f-c9010c0bb2f634e5-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -105,7 +107,7 @@
"system": [
{
"type": "text",
"text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Engineering status formatter\"\n description: \"Use for formatting engineering workflow status identifiers.\"\n id: \"engineering-status\"\n category: \"engineering\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"<id>\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"<id>\", \"filePath\": \"<relative path>\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies.\n\n<active_skills>\n\nUse these current skill versions when relevant to the task. Earlier tool results may describe older versions.\n\n[Skill: \"Engineering status formatter\"]\n[Skill category: \"engineering\"]\n[Skill hash: \"f62e1e0bd274\"]\n\nFor status-formatting requests, call the format_status tool with the exact status token from the user. Use the returned formatted value verbatim in the final answer and include STATUS_FORMATTED_BRAVO. Do not format the status manually.\n\n</active_skills>"
"text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Engineering status formatter\"\n description: \"Use for formatting engineering workflow status identifiers.\"\n id: \"engineering-status\"\n category: \"engineering\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"<id>\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"<id>\", \"filePath\": \"<relative path>\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies."
}
],
"messages": [
@ -121,9 +123,13 @@
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "I'll load the engineering-status skill first, then use it to format the status token."
},
{
"type": "tool_use",
"id": "toolu_01Krds9KGve8gJNTNa9jn6N7",
"id": "toolu_019R6LssCYfGrz8ddVcPvYQC",
"name": "load_skill",
"input": {
"skillId": "engineering-status"
@ -139,8 +145,17 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01Krds9KGve8gJNTNa9jn6N7",
"content": "{\"skillId\":\"engineering-status\",\"active\":true}"
"tool_use_id": "toolu_019R6LssCYfGrz8ddVcPvYQC",
"content": [
{
"type": "text",
"text": "{\"skillId\":\"engineering-status\",\"active\":true}"
},
{
"type": "text",
"text": "[Skill: \"Engineering status formatter\"]\n[Skill category: \"engineering\"]\n[Skill hash: \"f62e1e0bd274\"]\n\nFor status-formatting requests, call the format_status tool with the exact status token from the user. Use the returned formatted value verbatim in the final answer and include STATUS_FORMATTED_BRAVO. Do not format the status manually."
}
]
}
]
}
@ -189,29 +204,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeuLqxFjGLPD3QE1zh7Ef\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Now I'll format the status token:\"},{\"type\":\"tool_use\",\"id\":\"toolu_01CKRsNPiekmX9TWf3ooQdHU\",\"name\":\"format_status\",\"input\":{\"status\":\"waiting_on_vendor_info\"},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1244,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":68,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfJCd6hmu4KWXr78SxvJK\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Now I'll format the status token using the format_status tool:\"},{\"type\":\"tool_use\",\"id\":\"toolu_011UF3b8wMRddXy2WYPGRYZe\",\"name\":\"format_status\",\"input\":{\"status\":\"waiting_on_vendor_info\"},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1230,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":74,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:05Z",
"anthropic-ratelimit-input-tokens-reset": "2026-09-22T08:57:57Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:05Z",
"anthropic-ratelimit-output-tokens-reset": "2026-09-22T08:57:57Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-09-10T07:12:05Z",
"anthropic-ratelimit-requests-reset": "2026-09-22T08:57:56Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:05Z",
"anthropic-ratelimit-tokens-reset": "2026-09-22T08:57:57Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a38c9f4f38babc7b-PRG",
"cf-ray": "a3f01add2e2bbbdd-FRA",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Thu, 10 Sep 2026 07:12:06 GMT",
"request-id": "req_011CeuLqwpw3fPBk6xt6nrrm",
"date": "Tue, 22 Sep 2026 08:57:57 GMT",
"request-id": "req_011CfJCd6Vcovu2SLcUWAPC4",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-d17795404368021a8e502b8755cc4b35-c750063766d5eb87-01",
"traceresponse": "00-e98096358e475d79d338f7d1cf690dbb-aa6a8d0521eacc76-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -227,7 +243,7 @@
"system": [
{
"type": "text",
"text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Engineering status formatter\"\n description: \"Use for formatting engineering workflow status identifiers.\"\n id: \"engineering-status\"\n category: \"engineering\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"<id>\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"<id>\", \"filePath\": \"<relative path>\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies.\n\n<active_skills>\n\nUse these current skill versions when relevant to the task. Earlier tool results may describe older versions.\n\n[Skill: \"Engineering status formatter\"]\n[Skill category: \"engineering\"]\n[Skill hash: \"f62e1e0bd274\"]\n\nFor status-formatting requests, call the format_status tool with the exact status token from the user. Use the returned formatted value verbatim in the final answer and include STATUS_FORMATTED_BRAVO. Do not format the status manually.\n\n</active_skills>"
"text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Engineering status formatter\"\n description: \"Use for formatting engineering workflow status identifiers.\"\n id: \"engineering-status\"\n category: \"engineering\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"<id>\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"<id>\", \"filePath\": \"<relative path>\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies."
}
],
"messages": [
@ -243,9 +259,13 @@
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "I'll load the engineering-status skill first, then use it to format the status token."
},
{
"type": "tool_use",
"id": "toolu_01Krds9KGve8gJNTNa9jn6N7",
"id": "toolu_019R6LssCYfGrz8ddVcPvYQC",
"name": "load_skill",
"input": {
"skillId": "engineering-status"
@ -261,8 +281,17 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01Krds9KGve8gJNTNa9jn6N7",
"content": "{\"skillId\":\"engineering-status\",\"active\":true}"
"tool_use_id": "toolu_019R6LssCYfGrz8ddVcPvYQC",
"content": [
{
"type": "text",
"text": "{\"skillId\":\"engineering-status\",\"active\":true}"
},
{
"type": "text",
"text": "[Skill: \"Engineering status formatter\"]\n[Skill category: \"engineering\"]\n[Skill hash: \"f62e1e0bd274\"]\n\nFor status-formatting requests, call the format_status tool with the exact status token from the user. Use the returned formatted value verbatim in the final answer and include STATUS_FORMATTED_BRAVO. Do not format the status manually."
}
]
}
]
},
@ -271,11 +300,11 @@
"content": [
{
"type": "text",
"text": "Now I'll format the status token:"
"text": "Now I'll format the status token using the format_status tool:"
},
{
"type": "tool_use",
"id": "toolu_01CKRsNPiekmX9TWf3ooQdHU",
"id": "toolu_011UF3b8wMRddXy2WYPGRYZe",
"name": "format_status",
"input": {
"status": "waiting_on_vendor_info"
@ -291,7 +320,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01CKRsNPiekmX9TWf3ooQdHU",
"tool_use_id": "toolu_011UF3b8wMRddXy2WYPGRYZe",
"content": "{\"formatted\":\"STATUS_FORMATTED_BRAVO: WAITING_ON_VENDOR_INFO\"}"
}
]
@ -341,29 +370,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeuLr3AQ92UTrYgeFRerd\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"The status token has been formatted successfully:\\n\\n**STATUS_FORMATTED_BRAVO: WAITING_ON_VENDOR_INFO**\"}],\"container\":null,\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1346,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":32,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfJCdCtqry5T2NJGAMCjV\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Perfect! The engineering-status skill has been loaded and the status token has been formatted.\\n\\n**Formatted Status:**\\nSTATUS_FORMATTED_BRAVO: WAITING_ON_VENDOR_INFO\"}],\"container\":null,\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1338,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":46,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:06Z",
"anthropic-ratelimit-input-tokens-reset": "2026-09-22T08:57:57Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:06Z",
"anthropic-ratelimit-output-tokens-reset": "2026-09-22T08:57:58Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-09-10T07:12:06Z",
"anthropic-ratelimit-requests-reset": "2026-09-22T08:57:57Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:06Z",
"anthropic-ratelimit-tokens-reset": "2026-09-22T08:57:57Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a38c9f561a95bc7b-PRG",
"cf-ray": "a3f01ae61be4bbdd-FRA",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Thu, 10 Sep 2026 07:12:07 GMT",
"request-id": "req_011CeuLr2Whz42pkAm7AzGhs",
"date": "Tue, 22 Sep 2026 08:57:58 GMT",
"request-id": "req_011CfJCdCcii1dW1VhpqXjuj",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-80319e2d05f81fe007810b9d0f60ed01-c7956b0a3a60554e-01",
"traceresponse": "00-4d1157be05f9b0ab551736730e6ba0e2-b17f94e742ef7628-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},

View File

@ -51,29 +51,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeuLqj5DrEFutdjSD8ETs\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01Eq5aFLJhGriLQF4UBg7tyT\",\"name\":\"load_skill\",\"input\":{\"skillId\":\"summary-marker\"},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":957,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":57,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfJCcuKuoVBeB7Ake3FNv\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01Rhg9DwH7n3MzSh9iz6TFzh\",\"name\":\"load_skill\",\"input\":{\"skillId\":\"summary-marker\"},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":957,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":57,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:02Z",
"anthropic-ratelimit-input-tokens-reset": "2026-09-22T08:57:53Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:02Z",
"anthropic-ratelimit-output-tokens-reset": "2026-09-22T08:57:54Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-09-10T07:12:02Z",
"anthropic-ratelimit-requests-reset": "2026-09-22T08:57:53Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:02Z",
"anthropic-ratelimit-tokens-reset": "2026-09-22T08:57:53Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a38c9f3bd9d2bc7b-PRG",
"cf-ray": "a3f01acbda36bbdd-FRA",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Thu, 10 Sep 2026 07:12:02 GMT",
"request-id": "req_011CeuLqiWV7tDxhVGbXmda8",
"date": "Tue, 22 Sep 2026 08:57:54 GMT",
"request-id": "req_011CfJCcteyXnhh5BmvQdDTp",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-7cf7d7c3cfa9378a22214da3c62a1ea0-1ad2b8e7d42a1569-01",
"traceresponse": "00-66ff12c75cd9a23f50952244bb3cba2c-133f511c645be506-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -89,7 +90,7 @@
"system": [
{
"type": "text",
"text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Summary marker\"\n description: \"Use for summarizing notes, updates, or short text while preserving a marker.\"\n id: \"summary-marker\"\n category: \"productivity\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"<id>\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"<id>\", \"filePath\": \"<relative path>\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies.\n\n<active_skills>\n\nUse these current skill versions when relevant to the task. Earlier tool results may describe older versions.\n\n[Skill: \"Summary marker\"]\n[Skill category: \"productivity\"]\n[Skill hash: \"e3433b77c9e3\"]\n\nEvery final answer after loading this skill must include the exact marker SKILL_MARKER_ALPHA. For summaries, be concise and keep the marker unchanged.\n\n</active_skills>"
"text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Summary marker\"\n description: \"Use for summarizing notes, updates, or short text while preserving a marker.\"\n id: \"summary-marker\"\n category: \"productivity\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"<id>\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"<id>\", \"filePath\": \"<relative path>\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies."
}
],
"messages": [
@ -107,7 +108,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_01Eq5aFLJhGriLQF4UBg7tyT",
"id": "toolu_01Rhg9DwH7n3MzSh9iz6TFzh",
"name": "load_skill",
"input": {
"skillId": "summary-marker"
@ -123,8 +124,17 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01Eq5aFLJhGriLQF4UBg7tyT",
"content": "{\"skillId\":\"summary-marker\",\"active\":true}"
"tool_use_id": "toolu_01Rhg9DwH7n3MzSh9iz6TFzh",
"content": [
{
"type": "text",
"text": "{\"skillId\":\"summary-marker\",\"active\":true}"
},
{
"type": "text",
"text": "[Skill: \"Summary marker\"]\n[Skill category: \"productivity\"]\n[Skill hash: \"e3433b77c9e3\"]\n\nEvery final answer after loading this skill must include the exact marker SKILL_MARKER_ALPHA. For summaries, be concise and keep the marker unchanged."
}
]
}
]
}
@ -157,29 +167,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeuLqo1c45tLcGPFM5dEA\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"**Summary:** n8n agents maintain efficient workflow operations by keeping context compact, reducing overhead and improving performance.\\n\\nSKILL_MARKER_ALPHA\"}],\"container\":null,\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1139,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":34,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfJCcxfLB1kLZMwT9hmcx\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Based on the Summary marker skill instructions, here's the summary:\\n\\n**Summary:** n8n agents maintain workflow context efficiency by keeping data structures and variables compact, reducing memory overhead and improving performance during workflow execution.\\n\\nSKILL_MARKER_ALPHA\"}],\"container\":null,\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1106,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":55,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:03Z",
"anthropic-ratelimit-input-tokens-reset": "2026-09-22T08:57:54Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:03Z",
"anthropic-ratelimit-output-tokens-reset": "2026-09-22T08:57:55Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-09-10T07:12:02Z",
"anthropic-ratelimit-requests-reset": "2026-09-22T08:57:54Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:03Z",
"anthropic-ratelimit-tokens-reset": "2026-09-22T08:57:54Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a38c9f417db9bc7b-PRG",
"cf-ray": "a3f01ad17e0abbdd-FRA",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Thu, 10 Sep 2026 07:12:03 GMT",
"request-id": "req_011CeuLqnRP3vkdg9opi7gNC",
"date": "Tue, 22 Sep 2026 08:57:55 GMT",
"request-id": "req_011CfJCcxUAmJjy1fhrHrwCP",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-86beb3374d817d1d427aabf5c903ca34-f5a5fa52f1cb2789-01",
"traceresponse": "00-7063877d53ab0eae4686a22ce533a4ec-69260e5b962e00c7-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},

View File

@ -53,32 +53,34 @@
},
"status": 200,
"response": [
"1f8b0800000000000013c5554d8bdb3010bde757189d1348c2ba21be9585d2427ae852e8a12942b1a6896259f2ea23ec12f4df3b72e2f823d96d2f253e18346fa479ef69d0c00194cb9212ac655ba0d631e3469c39962547e25e2b2019e98164dcac497624a5e620312597cc7398ec9828fce461924ee6d3793a9b4e67982e783cc36ee974367b04bf328ba765fa8def3fa63f5e162bf3b9c09c7e250c182d6380592bb0ac8a6573ad1c9225d9cf5fa705130a0cc99497724cacd31535c0ac56bd9085670f2a875e90036e96b689f9468e509577d4e9021482cbf4010bb17c0734c7939dd08af633a60d8e307f0b6bf6c60250eda004c3244dcbebfc169ded86681813ed5d3734ff806ac01c440ed489e8048956716678745dfd061385d32d68849476941d5035dba0b3212449188de074fb6767e946eabcb8dd033752ea221c5e4e52bb78547adee7b496d45b68fa20ae3d76c222cfbf2f3f6ded53517eddad9ee71b2b166a85598a95719fd468a82d84947519d48d878668a99451eaa500170672570b3a7fadae4aa8ed4048426290bca51dbbd9b1f7b5d7293deda7484be974737becc44b72857e09bcd77ddd9e84843ed1bb9038ae892561e8d95da8c47bfec2d7b52f77a69225688b2fb1a1eecba364e67582bf8284ebc6be0b23306b125a36ef528aafecdf9e105d7508b50735d3e6a6aa1e483a2a7acf7ff7d5f9e721309828e1ff8d85c13b9e2ec2c0cb76e05ebbd8c5c8e512fe00c6a9d291c7070000"
"1f8b0800000000000013cd544d6fd34010bdf757587376243b6a5a756f10a440c501845490085a4dbdd364f17ad7ec47441bf9bfa3b5ebc63629f482826f3b6fc6f3dedbd9a11d69cf928a9cc30d71e7d1fa33811e59b2077f5f1330188190f667607ba88c20050c0a8541d06c8bb20cb3f3d96236cfe68b3ccb7248418af80fb7e1599e2fefae9762e5de5cac1e56e2cb03bef5b95c423ae9042958a362009d93cea38e6d0ba33d690fecebb7ee80529305a6835229386f6a6e099dd1a390a31f817441a3a0208f52b93e167a3952d7c1736f4ad20ed8d5e23c85028b2df1c2127a69341f67643d6e09c573585f1b1b50bda58a2c2abea87ecf3fa0f9768a362998e087a1f9450a8eec4e16c4bd8c4e40b44aa015d1757d47360ae71b32c0401bcf718752e1ad22689a2469cecea8bbfd4767f9ad3245797c068ea4b44d04fdeca40ef1a8f4b1ce1ba37870d4cf413c079ee5d757bbf2e6f2755d7d78f5193fdd6cdf7f9ccf439c048d55ac53060577a554aa6d53070f6cdf444b958a529f1a0869a9f0ada0f63ba8aaa5de4c64241083f09c7241cae39f95b72923e55de440a8bbb7efcee8a7e41aad97a8da2030809eeb98ef49b8ecd7d0b9dcfc0764de8935b0640dae65736232a1aad0decf0e9775623e15da92ec1a9a178d4f5c737f7bc3a61e509afcaddff947c58d401888192de1e1db7ff12a9eecf5e6df2de7c9365d5c0e6d3de2c431478718b445bf00ccc6474350070000"
],
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "10000000",
"anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:07Z",
"anthropic-ratelimit-input-tokens-reset": "2026-09-22T08:57:58Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:07Z",
"anthropic-ratelimit-output-tokens-reset": "2026-09-22T08:57:58Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-09-10T07:12:07Z",
"anthropic-ratelimit-requests-reset": "2026-09-22T08:57:58Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "12000000",
"anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:07Z",
"anthropic-ratelimit-tokens-reset": "2026-09-22T08:57:58Z",
"cache-control": "no-cache",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a38c9f5c5a57bc7b-PRG",
"cf-ray": "a3f01aebf8c4bbdd-FRA",
"connection": "keep-alive",
"content-encoding": "gzip",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "text/event-stream; charset=utf-8",
"date": "Thu, 10 Sep 2026 07:12:08 GMT",
"request-id": "req_011CeuLr6og4ahrpyfnPec45",
"date": "Tue, 22 Sep 2026 08:57:59 GMT",
"request-id": "req_011CfJCdGeJvd8FiNh3bikfH",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-db154d560f530266545ecc9baf7015e2-c0470cb196446bc0-01",
"traceresponse": "00-5b8f08c4391de2116ab063ddaa4cdd99-24fa357e98008a81-01",
"transfer-encoding": "chunked",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -94,7 +96,7 @@
"system": [
{
"type": "text",
"text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Summary marker\"\n description: \"Use for summarizing notes, updates, or short text while preserving a marker.\"\n id: \"summary-marker\"\n category: \"productivity\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"<id>\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"<id>\", \"filePath\": \"<relative path>\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies.\n\n<active_skills>\n\nUse these current skill versions when relevant to the task. Earlier tool results may describe older versions.\n\n[Skill: \"Summary marker\"]\n[Skill category: \"productivity\"]\n[Skill hash: \"e3433b77c9e3\"]\n\nEvery final answer after loading this skill must include the exact marker SKILL_MARKER_ALPHA. For summaries, be concise and keep the marker unchanged.\n\n</active_skills>"
"text": "Skill loading protocol:\nSkills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request.\n\nAvailable skills:\n- name: \"Summary marker\"\n description: \"Use for summarizing notes, updates, or short text while preserving a marker.\"\n id: \"summary-marker\"\n category: \"productivity\"\n\nWhen deciding whether to load a skill:\n- Match the user's request against the skill name and description.\n- If one skill clearly matches, call load_skill once with `{ \"skillId\": \"<id>\" }`, then follow the returned instructions.\n- If a loaded skill references a supporting file, call load_skill with `{ \"skillId\": \"<id>\", \"filePath\": \"<relative path>\" }`.\n- If the relevant skill was already loaded for this request, do not call load_skill again.\n- If no skill clearly matches, do not call load_skill.\n- Do not load a skill just because it is listed here.\n\nYou are testing runtime skill loading. Use the skill catalog in your instructions to pick a matching skillId. Call load_skill with the exact matching skillId before answering when a skill applies. After loading a skill, strictly follow its returned instructions. Do not answer from memory when a relevant skill applies."
}
],
"messages": [
@ -112,7 +114,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_017ccT9FgsRkmMhLq2bsi7nL",
"id": "toolu_01J9vkV7BpmPAWaSVhLQ22uC",
"name": "load_skill",
"input": {
"skillId": "summary-marker"
@ -128,8 +130,17 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_017ccT9FgsRkmMhLq2bsi7nL",
"content": "{\"skillId\":\"summary-marker\",\"active\":true}"
"tool_use_id": "toolu_01J9vkV7BpmPAWaSVhLQ22uC",
"content": [
{
"type": "text",
"text": "{\"skillId\":\"summary-marker\",\"active\":true}"
},
{
"type": "text",
"text": "[Skill: \"Summary marker\"]\n[Skill category: \"productivity\"]\n[Skill hash: \"e3433b77c9e3\"]\n\nEvery final answer after loading this skill must include the exact marker SKILL_MARKER_ALPHA. For summaries, be concise and keep the marker unchanged."
}
]
}
]
}
@ -164,32 +175,34 @@
},
"status": 200,
"response": [
"1f8b0800000000000013c555cb8e1a3110bcf315968f082266b3ec616e681569a32551b428a76c6419bb99b1f0d8133f106885948fc817e64bd21e98c0f0489443122e305d6557755334b00213725281f7bc00e60377a12779e03979a1615303cd6907a483f699e62fb4b212345284e651c2b0e46a1987b7c3f1f0667433ce46a30ce94aa63b7cc14659760f71eaee65b40f73fbf1bd288a30dddc21a7ab840567752a70ef15ca9a242bac096896e69f3eef1eb832e0686ea2d603ea83ad9903eeade9943c7c896004748a12f0b0f66d2db6ed2853c7c0825d824130cb5edfa112172530815707650deb52462d8eb0bc86b5679302d42554e0b866e3ea9c7f40b3f214dd0ea88da1e30fbb01b7520258506912348d4a7227d3d4cd025c6a9c156011323630bec2aef91c27bbdd1242b6bd1eecbefefd68d95c5bb1bc1c820b944645c27ad7ea319e3add9f0bb04ec4e62da734e936af8378ad4c71a246682ad26b06317381ffda6043e918dc55bac67ed2f6f6fafdbdc17fae3c8b55c5dd26eff7c92c605e2aec9f602a4df0042a1548b05693c693fffef51b274bd8e0af5694dc285f918575043f4add1cf31b234a678d8d9e28f4e6b848f9f30419a47676a564e2a18c1e0655015900c839174b22a34bc84e6c0d2236b9fd4f3379f56c9ecdecf1ed74cade4d9e1edf3cb1c9f4c3c3e490a1b3289d86d4d6bf8bb1ad8f6c5db9b5dd7f171bed80f4a8b1ce42a260240bd119fa076be964c76dffe2a23ad92cb7d9f5191ffe0dcea77b8cd1c3c91f64c432c465060000"
"1f8b0800000000000013c598514fdb3010c7dff914d6bd4c4201356c4c53de109a36a09b26d8db98a2233e1aab8e9dda97b608e5bb4f4e5b9ab4c0b60792bc44b9bbe47ef78fedf84273329c8882bcc709a59ed1f18144c6443c023f940409749c106dae217984c24ad29040a6b1927494a39a56471f8e4e8f4e4627a7f1681443044a8667f8493a8ae3f3fbcb73399ebae5a74b9fcf5c7c15cfe4178876324104ceea6040ef956734216d660d9361487efd5e5da032e4203195d61178b665ea08bd351d93a7594526a38e5112a3d27e63ab36e52853569cb29d92f190c4f1e87d04196639a5992364654dda0d196dfc8e50bee4dbdc1b32509953410e757a5aecc76fbd71beebad23b01577f822f0e4e62aa3945550028254129d0caa9b7b72a1f0744216123096539ca3d278a709ea5aac8ffae080566360ad6f7aa76d367d7e243c13d2a492b45cd5dbf68772d7f7312d4360734a00ea76da5299c94e1e01c1082fa149d28cafa335211db495a58bf414b606fb6e1750bf26ca9b651617efb416a5b37325a961e81f81f355e6f6d13f85af8a02dd835828cebb388328221ccd2ae548b658fa0729d04dc925b7e6d61c1e6e487ac7b859bd9a648b30941e37ec080b6526022764d80b2a140bb6568b86c41f0f358f7fe6e4879ac0e868b0b1b1d6bd3d30fa6798902187bc9dacfd23dc3dac87e47008b60c22341fd0810894197a81f04f0b44d8a146629193a36681189a4c9939d4fde7b559b305f502cd80b3c391af34fbb05289cc1645655416e6ab400f75ff3852f9cc11ef6d7b865a3d8543cec909ced18805aad6241e84eadebaf09a4abdafd100122d29ab9a36aa1e8ae1b8dbb9f49435ecfa6eae2ec6e3f4dbd9f5d5e7ebf46cfce3ebd9bf7c6943affbb716ce962da01777ff9b5f00cf16d97142aba84e4f0e6464ca9533f01f9df94e9b5fbf61afbed35c7f3cdd5b25b7ff41f6656dfb605fc63f07b52da767110000"
],
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "10000000",
"anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:08Z",
"anthropic-ratelimit-input-tokens-reset": "2026-09-22T08:57:59Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:08Z",
"anthropic-ratelimit-output-tokens-reset": "2026-09-22T08:57:59Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-09-10T07:12:08Z",
"anthropic-ratelimit-requests-reset": "2026-09-22T08:57:59Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "12000000",
"anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:08Z",
"anthropic-ratelimit-tokens-reset": "2026-09-22T08:57:59Z",
"cache-control": "no-cache",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a38c9f63ce57bc7b-PRG",
"cf-ray": "a3f01af1acffbbdd-FRA",
"connection": "keep-alive",
"content-encoding": "gzip",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "text/event-stream; charset=utf-8",
"date": "Thu, 10 Sep 2026 07:12:08 GMT",
"request-id": "req_011CeuLrBuGK4E7uxpMM6NMq",
"date": "Tue, 22 Sep 2026 08:57:59 GMT",
"request-id": "req_011CfJCdLVz3EfLeiVHjxihN",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-ae497abd3e1ae1a20e06ccaeba57c754-f8de5c4ed0f0dac4-01",
"traceresponse": "00-0dcb0fbb57a4e0ed3374476fe9b454e3-795c47a4b1e4f26c-01",
"transfer-encoding": "chunked",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},

View File

@ -5,6 +5,19 @@ import { AgentMessageList } from '../model/message-list';
import { ActiveSkills } from '../skills/active-skills';
const scope = { threadId: 'thread-1', resourceId: 'user-1' };
/**
* Mark the list as observation-masked, so recovered skills render in the
* `<active_skills>` system block. Without a mask the block stays empty and
* skills ride on their still-visible activating tool result.
*/
function mask(list: AgentMessageList): AgentMessageList {
list.maskObservedMessages({
lastObservedAt: new Date(Date.now() + 60_000),
lastObservedMessageId: 'observation-cursor',
});
return list;
}
const source = createRuntimeSkillSource([
{
id: 'planning',
@ -87,12 +100,46 @@ describe('active skills', () => {
await active.restore(list, scope);
expect(active.instructions()).toContain('Build one workflow.');
// The load_skill result is still visible, so the body rides on it and the
// system block stays empty — no cached-prefix rewrite.
expect(active.instructions()).toBeUndefined();
expect(JSON.stringify(active.modelMessages(list.forLlm('').messages, list))).toContain(
'Build one workflow.',
);
await expect(memory.skillState.load({ ...scope, agentName: 'assistant' })).resolves.toEqual([
'builder',
]);
});
it('recovers a skill into the system block once observation masks its load', async () => {
const memory = new InMemoryMemory();
const list = new AgentMessageList();
list.addHistory([
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolName: 'load_skill',
toolCallId: 'old-load',
input: { skillId: 'builder' },
state: 'resolved',
output: { type: 'content', value: [{ type: 'text', text: 'Build one workflow.' }] },
},
],
},
]);
const active = new ActiveSkills(source, 'assistant', memory.skillState);
await active.restore(list, scope);
// Visible load → block empty.
expect(active.instructions()).toBeUndefined();
// Observation hides the load result → the skill moves into the block.
mask(list);
expect(active.instructions()).toContain('Build one workflow.');
});
it('persists checkpoint skills when they differ from stored state', async () => {
const memory = new InMemoryMemory();
await memory.skillState.save({ ...scope, agentName: 'assistant' }, ['planning']);
@ -120,7 +167,15 @@ describe('active skills', () => {
{
type: 'tool-call',
toolName: 'load_skill',
toolCallId: 'old-load',
toolCallId: 'builder-load',
input: { skillId: 'builder' },
state: 'resolved',
output: { type: 'content', value: [{ type: 'text', text: 'Build one workflow.' }] },
},
{
type: 'tool-call',
toolName: 'load_skill',
toolCallId: 'planning-load',
input: { skillId: 'planning' },
state: 'resolved',
output: { type: 'content', value: [{ type: 'text', text: 'Create a task plan.' }] },
@ -135,16 +190,132 @@ describe('active skills', () => {
);
await active.restore(list, scope);
expect(active.instructions()).toContain('Build one workflow.');
expect(active.instructions()).not.toContain('Create a task plan.');
expect(JSON.stringify(active.modelMessages(list.forLlm('').messages, list))).not.toContain(
'Create a task plan.',
);
// Both loads are still visible, so the block stays empty; the enabled skill
// rides on its result while the disabled one is collapsed out of it.
expect(active.instructions()).toBeUndefined();
const rendered = JSON.stringify(active.modelMessages(list.forLlm('').messages, list));
expect(rendered).toContain('Build one workflow.');
expect(rendered).not.toContain('Create a task plan.');
await expect(memory.skillState.load({ ...scope, agentName: 'assistant' })).resolves.toEqual([
'builder',
]);
});
it('re-anchors a stamped tool activation on a later turn instead of the block', async () => {
const memory = new InMemoryMemory();
await memory.skillState.save({ ...scope, agentName: 'assistant' }, ['builder']);
const list = new AgentMessageList();
// Turn 1 activated the skill from a tool and stamped its result. On the
// next turn the body must ride on that result again, not the system block.
list.addHistory([
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolName: 'build_workflow',
toolCallId: 'build-1',
input: {},
state: 'resolved',
output: { type: 'content', value: [{ type: 'text', text: 'built' }] },
activatedSkillIds: ['builder'],
},
],
},
]);
const active = new ActiveSkills(source, 'assistant', memory.skillState);
await active.restore(list, scope);
expect(active.instructions()).toBeUndefined();
expect(JSON.stringify(active.modelMessages(list.forLlm('').messages, list))).toContain(
'Build one workflow.',
);
});
it('persists a tool activation stamp across serialization', async () => {
const memory = new InMemoryMemory();
const active = new ActiveSkills(source, 'assistant', memory.skillState);
const list = new AgentMessageList();
list.addResponse([
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolName: 'build_workflow',
toolCallId: 'build-1',
input: {},
state: 'resolved',
output: { type: 'content', value: [{ type: 'text', text: 'built' }] },
},
],
},
]);
await active.restore(list, scope);
await active.load('builder', { toolCallId: 'build-1' });
const roundTripped = AgentMessageList.deserialize(structuredClone(list.serialize()));
const stamped = roundTripped
.messages()
.flatMap((message) => ('content' in message ? message.content : []))
.find((part) => part.type === 'tool-call' && part.toolCallId === 'build-1');
expect(stamped).toMatchObject({ activatedSkillIds: ['builder'] });
});
it('recovers a restored programmatic skill through the block when no stamp survives', async () => {
const memory = new InMemoryMemory();
await memory.skillState.save({ ...scope, agentName: 'assistant' }, ['builder']);
const list = new AgentMessageList();
// Legacy data: a prior programmatic activation left no stamp and no
// `load_skill` record. The skill must still reach the model via the block.
list.addHistory([
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolName: 'build_workflow',
toolCallId: 'build-1',
input: {},
state: 'resolved',
output: { type: 'content', value: [{ type: 'text', text: 'built' }] },
},
],
},
]);
const active = new ActiveSkills(source, 'assistant', memory.skillState);
await active.restore(list, scope);
expect(active.instructions()).toContain('Build one workflow.');
});
it('recovers a skill through the block when its activating tool call failed', async () => {
const memory = new InMemoryMemory();
const active = new ActiveSkills(source, 'assistant', memory.skillState);
const list = new AgentMessageList();
list.addResponse([
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolName: 'build_workflow',
toolCallId: 'build-fail',
input: {},
state: 'rejected',
error: 'boom',
},
],
},
]);
await active.restore(list, scope);
// The tool activated the skill, then failed — its error result cannot
// carry the body, so the skill belongs in the block, not silently dropped.
await active.load('builder', { toolCallId: 'build-fail' });
expect(active.instructions()).toContain('Build one workflow.');
});
it('keeps threads, resources, and agents isolated', async () => {
const memory = new InMemoryMemory();
const active = new ActiveSkills(source, 'assistant', memory.skillState);
@ -165,6 +336,40 @@ describe('active skills', () => {
).resolves.toBeUndefined();
});
it('keeps the system block empty for an anchored activation until observation masks it', async () => {
const memory = new InMemoryMemory();
const active = new ActiveSkills(source, 'assistant', memory.skillState);
const list = new AgentMessageList();
list.addResponse([
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolName: 'build_workflow',
toolCallId: 'build-1',
input: {},
state: 'resolved',
output: { type: 'content', value: [{ type: 'text', text: 'built' }] },
},
],
},
]);
await active.restore(list, scope);
// A programmatic activation anchored to the calling tool result stays out
// of the system prompt — the body rides on that visible result instead.
await active.load('builder', { toolCallId: 'build-1' });
expect(active.instructions()).toBeUndefined();
expect(JSON.stringify(active.modelMessages(list.forLlm('').messages, list))).toContain(
'Build one workflow.',
);
// Only once observation hides the anchor does the skill move into the block.
mask(list);
expect(active.instructions()).toContain('Build one workflow.');
});
it('does not activate a missing skill or a failed load', async () => {
const missing: RuntimeSkillSource = { ...source, loadSkill: vi.fn().mockResolvedValue(null) };
const active = new ActiveSkills(missing, 'assistant');

View File

@ -295,6 +295,24 @@ export class AgentMessageList {
block.suspension = suspension;
}
/**
* Record that a tool programmatically activated a skill on its own result, so
* `ActiveSkills` can re-anchor the skill body there on a later turn instead
* of falling back to the `<active_skills>` system prompt. The stamp rides on
* the persisted message; it is metadata only and never reaches the model
* (`toAiMessages` ignores it). No-op when the tool call is unknown.
*/
stampActivatedSkill(toolCallId: string, skillId: string): void {
const host = this.findToolCallHost(toolCallId);
if (!host) return;
const block = this.findToolCallBlock(host, toolCallId);
if (!block) return;
const current = block.activatedSkillIds ?? [];
if (current.includes(skillId)) return;
block.activatedSkillIds = [...current, skillId];
this.responseSet.add(host);
}
private findToolCallHost(toolCallId: string): AgentDbMessage | undefined {
// Start from the last message and go backwards to find the host message
for (let i = this.all.length - 1; i >= 0; i--) {

View File

@ -1,4 +1,4 @@
import type { ModelMessage } from 'ai';
import type { ModelMessage, ToolResultPart } from 'ai';
import { formatActiveSkill } from '../../skills/tools';
import {
@ -11,7 +11,30 @@ import {
import type { AgentPersistenceOptions } from '../../types/sdk/agent';
import type { AgentMessageList } from '../model/message-list';
/** Keeps trusted skill instructions outside the conversation's compaction window. */
/**
* Keeps trusted skill instructions available without invalidating the cached
* prompt prefix.
*
* A skill body rides on the tool result that activated it (the recorded result
* is collapsed first, so an obsolete persisted body is never replayed). The
* top-level system prompt does not mention the skill, so activating and
* carrying a skill never rewrites the cached prefix — within a run or across
* runs.
*
* The `<active_skills>` block of the system prompt is a recovery path only.
* Anthropic renders the prompt as `tools → system → messages`, so any edit to
* `system` invalidates the tool block and every message after it. The block
* therefore holds only the skills the anchored path cannot deliver on this
* call — their result masked by observation memory, their activating call
* failed, or the activation predates this window with no `load_skill` record:
*
* - Anchor still deliverable: the skill rides on its resolved tool result, so
* `system` is unchanged and the cached prefix stays warm.
* - Anchor gone or unusable: the skill moves into the block so its guidance is
* never lost. When observation memory replaced the history it already rewrote
* the prefix, so the move is free; otherwise it costs one rewrite, the price
* of not dropping an active skill.
*/
export class ActiveSkills {
private readonly loaded = new Map<string, RuntimeSkillContent>();
private list?: AgentMessageList;
@ -38,7 +61,11 @@ export class ActiveSkills {
list.activeSkillIds === undefined && this.scope
? await this.store?.load(this.scope)
: undefined;
const ids = list.activeSkillIds ?? stored ?? [...this.recordedLoads(list).values()];
const ids = list.activeSkillIds ??
stored ?? [
...this.recordedLoads(list).values(),
...this.stampedActivations(list).map(([, skillId]) => skillId),
];
const registered = new Set(this.source.registry.skills.map(({ id }) => id));
for (const id of new Set(ids)) {
if (!registered.has(id)) continue;
@ -53,7 +80,10 @@ export class ActiveSkills {
if (!storeIsCurrent && (ids.length > 0 || stored !== undefined)) await this.persist();
}
async load(skillId: string): Promise<RuntimeSkillContent | null> {
async load(
skillId: string,
anchor?: { toolCallId: string },
): Promise<RuntimeSkillContent | null> {
if (!this.source.registry.skills.some(({ id }) => id === skillId)) return null;
const skill = this.loaded.get(skillId) ?? (await this.source.loadSkill(skillId));
if (!skill) return null;
@ -61,6 +91,9 @@ export class ActiveSkills {
if (!this.loaded.has(skillId)) {
this.loaded.set(skillId, skill);
this.list.activeSkillIds = [...this.loaded.keys()];
// Stamp the activating tool result so the body re-anchors there on a
// later turn instead of falling back into the system prompt.
if (anchor) this.list.stampActivatedSkill(anchor.toolCallId, skillId);
await this.persist();
}
return skill;
@ -72,12 +105,19 @@ export class ActiveSkills {
];
}
/**
* The active skills the anchored path cannot deliver on this call, as one
* block for the top-level system prompt. A skill enters the block only when
* it has no successfully resolved, visible tool result to ride on, so a
* still-anchored skill never edits `system` or breaks a warm cache.
*/
instructions(): string | undefined {
if (this.loaded.size === 0) return undefined;
const sections = [...this.loaded].flatMap(([id, skill]) => {
const entry = this.source.registry.skills.find((candidate) => candidate.id === id);
return entry ? [formatActiveSkill(skill, entry)] : [];
const { inBlock } = this.placement();
const sections = inBlock.flatMap((id) => {
const text = this.formatSkill(id);
return text ? [text] : [];
});
if (sections.length === 0) return undefined;
return [
'<active_skills>',
'Use these current skill versions when relevant to the task. Earlier tool results may describe older versions.',
@ -86,9 +126,23 @@ export class ActiveSkills {
].join('\n\n');
}
/** Old conversations can contain full skill bodies. Do not replay an obsolete version. */
/**
* Per-call view of the conversation for the model.
*
* Collapses recorded `load_skill` results to `{ skillId, active }`: old
* conversations can contain full skill bodies, and an obsolete version must
* not be replayed. Skills anchored to a visible tool call then get their
* current body appended to that result, keeping them out of the system
* block for as long as observational memory keeps the result visible.
*/
modelMessages(messages: ModelMessage[], list: AgentMessageList): ModelMessage[] {
const loads = this.recordedLoads(list);
const { anchors } = this.placement(loads);
const appendices = new Map<string, string[]>();
for (const [skillId, toolCallId] of anchors) {
const text = this.formatSkill(skillId);
if (text) appendices.set(toolCallId, [...(appendices.get(toolCallId) ?? []), text]);
}
return messages.map((message) => {
if (message.role !== 'tool') return message;
return {
@ -96,19 +150,105 @@ export class ActiveSkills {
content: message.content.map((part) => {
if (part.type !== 'tool-result') return part;
const skillId = loads.get(part.toolCallId);
if (!skillId) return part;
return {
...part,
output: {
type: 'json' as const,
value: { skillId, active: this.loaded.has(skillId) },
},
};
const collapsed: ToolResultPart = skillId
? {
...part,
output: {
type: 'json' as const,
value: { skillId, active: this.loaded.has(skillId) },
},
}
: part;
const texts = appendices.get(part.toolCallId);
return texts ? appendToolResultText(collapsed, texts) : collapsed;
}),
};
});
}
/**
* Where each active skill is delivered on this call: anchored to a visible
* tool result, or in the system block. The anchor is the first visible
* recorded `load_skill` call for the skill, else this run's activating
* call. `instructions()` and `modelMessages()` share this so a skill is
* always delivered exactly once.
*
* An anchor must be a **successfully resolved** tool result: the skill body
* is appended to it (see `appendToolResultText`, which skips error outputs),
* so a pending, canceled, or failed call cannot deliver it. A skill with no
* such anchor — its result masked by observation memory, or its activating
* call failed, or the activation predates this window with no `load_skill`
* record — falls back to the `<active_skills>` block, which never drops it.
* The block only edits `system` when the anchored path cannot deliver, so an
* unchanged, still-anchored skill never rewrites the cached prefix.
*/
private placement(loads?: Map<string, string>): {
anchors: Map<string, string>;
inBlock: string[];
} {
const anchors = new Map<string, string>();
if (this.list) {
const deliverable = this.deliverableToolResults();
// `load_skill` results and tool results that stamped a programmatic
// activation are both valid anchors; the stamp is what survives a turn.
const sources: Array<[string, string]> = [
...(loads ?? this.recordedLoads(this.list)),
...this.stampedActivations(this.list),
];
for (const [toolCallId, skillId] of sources) {
if (!anchors.has(skillId) && this.loaded.has(skillId) && deliverable.has(toolCallId)) {
anchors.set(skillId, toolCallId);
}
}
}
const inBlock = [...this.loaded.keys()].filter((id) => !anchors.has(id));
return { anchors, inBlock };
}
/**
* `[toolCallId, skillId]` pairs for tool results that stamped a programmatic
* skill activation (see `stampActivatedSkill`). Unlike `load_skill` records,
* these carry activations a tool started itself, so a post-build skill
* re-anchors to its tool result on the next turn instead of the system block.
*/
private stampedActivations(list: AgentMessageList): Array<[string, string]> {
const pairs: Array<[string, string]> = [];
for (const message of list.messages()) {
if (!('content' in message)) continue;
for (const part of message.content) {
if (part.type === 'tool-call' && Array.isArray(part.activatedSkillIds)) {
for (const skillId of part.activatedSkillIds) pairs.push([part.toolCallId, skillId]);
}
}
}
return pairs;
}
/**
* Tool-call ids whose result is visible and successfully resolved, so a skill
* body can ride on it. Pending, canceled, and failed calls are excluded — a
* skill anchored there would silently vanish, so it belongs in the block.
*/
private deliverableToolResults(): Set<string> {
const ids = new Set<string>();
if (!this.list) return ids;
for (const message of this.list.llmVisibleMessages()) {
if (!('content' in message)) continue;
for (const part of message.content) {
if (part.type === 'tool-call' && part.state === 'resolved' && !part.canceled) {
ids.add(part.toolCallId);
}
}
}
return ids;
}
private formatSkill(skillId: string): string | undefined {
const skill = this.loaded.get(skillId);
const entry = this.source.registry.skills.find((candidate) => candidate.id === skillId);
return skill && entry ? formatActiveSkill(skill, entry) : undefined;
}
private recordedLoads(list: AgentMessageList): Map<string, string> {
const loads = new Map<string, string>();
for (const message of list.messages()) {
@ -154,3 +294,32 @@ export class ActiveSkills {
await save;
}
}
/**
* Append trusted text parts after a tool result's own output. Text and JSON
* outputs become content parts so the skill text is a separate block from the
* (possibly untrusted-wrapped) tool output. Error outputs are left alone.
*/
function appendToolResultText(part: ToolResultPart, texts: string[]): ToolResultPart {
const extra = texts.map((text) => ({ type: 'text' as const, text }));
const { output } = part;
switch (output.type) {
case 'content':
return { ...part, output: { type: 'content', value: [...output.value, ...extra] } };
case 'text':
return {
...part,
output: { type: 'content', value: [{ type: 'text', text: output.value }, ...extra] },
};
case 'json':
return {
...part,
output: {
type: 'content',
value: [{ type: 'text', text: JSON.stringify(output.value) }, ...extra],
},
};
default:
return part;
}
}

View File

@ -117,6 +117,15 @@ export async function executeTool(
throw new Error(`No handler found for tool "${builtTool.name}"`);
}
// Anchor programmatic skill activations to the calling tool's result by
// default, so the skill body rides on that result (kept out of `system`)
// instead of forcing a full prompt-prefix rewrite. An explicit anchor wins.
const baseLoadSkill = executionContext.loadSkill;
const loadSkill: ToolExecutionContext['loadSkill'] = baseLoadSkill
? async (skillId, anchor) =>
await baseLoadSkill(skillId, anchor ?? (toolCallId ? { toolCallId } : undefined))
: undefined;
if (builtTool.suspendSchema) {
const isCancelled = isCancellation(resumeData);
const ctx: InterruptibleToolContext = {
@ -139,7 +148,7 @@ export async function executeTool(
toolCallId,
toolName: builtTool.name,
runId: executionContext.runId,
...(executionContext.loadSkill ? { loadSkill: executionContext.loadSkill } : {}),
...(loadSkill ? { loadSkill } : {}),
persistence: executionContext.persistence,
emitEvent: executionContext.emitEvent,
abortSignal: executionContext.abortSignal,
@ -156,7 +165,7 @@ export async function executeTool(
toolCallId,
toolName: builtTool.name,
runId: executionContext.runId,
...(executionContext.loadSkill ? { loadSkill: executionContext.loadSkill } : {}),
...(loadSkill ? { loadSkill } : {}),
persistence: executionContext.persistence,
emitEvent: executionContext.emitEvent,
abortSignal: executionContext.abortSignal,

View File

@ -137,7 +137,15 @@ export interface RuntimeSkillContent extends RuntimeSkillIndexEntry {
linkedFiles?: RuntimeSkillLinkedFiles;
}
export type RuntimeSkillLoader = (skillId: string) => Promise<RuntimeSkillContent | null>;
export type RuntimeSkillLoader = (
skillId: string,
/**
* Tool result the activation rides on. The skill body is appended to this
* result so the top-level system prompt stays byte-identical (no cache
* invalidation). Defaults to the calling tool's own result when omitted.
*/
anchor?: { toolCallId: string },
) => Promise<RuntimeSkillContent | null>;
export interface RuntimeSkillFileContent {
skillId: string;

View File

@ -133,6 +133,13 @@ export type ContentToolCall = ContentMetadata & {
input: JSONValue;
providerExecuted?: boolean;
/**
* Skills this tool activated programmatically (via `ctx.loadSkill`), so the
* skill body can re-anchor to this result on a later turn instead of falling
* back to the system prompt. Metadata only — never sent to the model.
*/
activatedSkillIds?: string[];
} & (
| { state: 'pending'; suspension?: ToolCallSuspensionInfo }
| { state: 'resolved'; output: JSONValue; canceled?: boolean }

View File

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

View File

@ -23,3 +23,26 @@ model configuration, and model output.
- Put generic helpers and generic secret or PII redaction in `@n8n/utils`.
- Put workflow graph and traversal utilities in `n8n-workflow`.
- Keep domain logic in the package that owns that domain.
## Bounding AI response sizes
Every outbound AI client must bound the size of the response it buffers. A
hostile or misbehaving endpoint can stream an unbounded or highly compressed
body and exhaust the heap. The `http-proxy-agent` subpath applies the cap; it
counts decoded bytes, so a compressed body cannot expand past the limit. The
default is 100 MB, set through `N8N_AI_MAX_RESPONSE_SIZE` (0 disables it).
The general rule: never hand the SDK the raw transport. Give it the wrapper
that matches how the SDK takes one.
- The SDK accepts a `fetch`: pass `aiClientFetch`.
- The SDK builds requests itself (Ollama, Mistral): call `proxyFetch`.
- The SDK returns a Node stream (AWS Bedrock): build the client with
`createBedrockRuntimeClient`, which installs a response-limited request
handler for the stream.
Get `egressFilter` for `proxyFetch` from `this.helpers.getSecureEgressFilter()`.
Some SDK wrappers expose no such seam (Cohere, Google Gemini, Google Vertex,
HuggingFace). Do not hand-roll a limiter for these. Flag the gap for a
maintainer instead.

View File

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

View File

@ -20,6 +20,7 @@ vi.mock('@langchain/openai', () => ({
vi.mock('src/utils/http-proxy-agent', () => ({
getProxyAgent: vi.fn().mockReturnValue({ __agent: true }),
aiClientFetch: vi.fn(),
}));
vi.mock('src/utils/n8n-llm-tracing', () => ({
@ -41,6 +42,7 @@ vi.mock('src/adapters/langchain-chat-model', () => ({
const ChatOpenAI = vi.mocked(openai.ChatOpenAI);
const LangchainChatModelAdapter = vi.mocked(langchainChatModelAdapter.LangchainChatModelAdapter);
const getProxyAgent = vi.mocked(httpProxyAgent.getProxyAgent);
const aiClientFetch = vi.mocked(httpProxyAgent.aiClientFetch);
const makeN8nLlmFailedAttemptHandler = vi.mocked(
failedAttemptHandler.makeN8nLlmFailedAttemptHandler,
);
@ -82,6 +84,7 @@ describe('supplyModel', () => {
model: 'gpt-4',
apiKey: 'test-key',
configuration: expect.objectContaining({
fetch: aiClientFetch,
baseURL: 'https://api.openai.com',
}),
onFailedAttempt: expect.any(Function),

View File

@ -13,6 +13,25 @@ import {
type EgressFilter,
} from 'src/utils/http-proxy-agent';
// The module reads `N8N_AI_MAX_RESPONSE_SIZE` at import time. Clear it before
// the imports evaluate (vi.hoisted runs first) so the cap is the 100 MB
// default here, whatever the runner shell has set. Capture the original value
// and restore it after the file so the deletion does not leak to other test
// files that share the worker.
const originalMaxResponseSize = vi.hoisted(() => {
const value = process.env.N8N_AI_MAX_RESPONSE_SIZE;
delete process.env.N8N_AI_MAX_RESPONSE_SIZE;
return value;
});
afterAll(() => {
if (originalMaxResponseSize === undefined) {
delete process.env.N8N_AI_MAX_RESPONSE_SIZE;
} else {
process.env.N8N_AI_MAX_RESPONSE_SIZE = originalMaxResponseSize;
}
});
vi.mock('@n8n/backend-network/transport', () => ({
buildDispatcher: vi.fn((proxy: unknown, ssrf: unknown, options: unknown) => ({
type: 'Dispatcher',
@ -39,6 +58,15 @@ const DEFAULT_BUILD_OPTIONS = {
timeouts: { headersTimeout: 3600000, bodyTimeout: 3600000 },
};
// proxyFetch forwards the AI response-size limit to dispatchedFetch as the 4th
// argument. The env var is cleared above, so the cap is the 100 MB default;
// asserting the exact value also catches a regression that neutralizes the
// limit (0 / negative / Infinity, which the transport treats as disabled).
const EXPECTED_RESPONSE_SIZE_LIMIT = expect.objectContaining({
maxBytes: 100 * 1024 * 1024,
createError: expect.any(Function),
});
describe('getProxyAgent', () => {
// Store original environment variables
const originalEnv = { ...process.env };
@ -336,6 +364,7 @@ describe('proxyFetch', () => {
expect.objectContaining({ type: 'Dispatcher', proxy: false }),
url,
undefined,
EXPECTED_RESPONSE_SIZE_LIMIT,
);
});
@ -356,6 +385,7 @@ describe('proxyFetch', () => {
expect.objectContaining({ ssrf: egressFilter }),
'https://api.openai.com/v1',
undefined,
EXPECTED_RESPONSE_SIZE_LIMIT,
);
});
@ -374,6 +404,7 @@ describe('proxyFetch', () => {
expect.objectContaining({ type: 'Dispatcher' }),
url,
undefined,
EXPECTED_RESPONSE_SIZE_LIMIT,
);
});
@ -387,14 +418,24 @@ describe('proxyFetch', () => {
await proxyFetch({ input: url, init, egressFilter: passthroughFilter });
expect(mockDispatchedFetch).toHaveBeenCalledWith(expect.anything(), url, init);
expect(mockDispatchedFetch).toHaveBeenCalledWith(
expect.anything(),
url,
init,
EXPECTED_RESPONSE_SIZE_LIMIT,
);
});
it('should handle URL objects', async () => {
const url = new URL('https://api.openai.com/v1');
await proxyFetch({ input: url, egressFilter: passthroughFilter });
expect(mockDispatchedFetch).toHaveBeenCalledWith(expect.anything(), url, undefined);
expect(mockDispatchedFetch).toHaveBeenCalledWith(
expect.anything(),
url,
undefined,
EXPECTED_RESPONSE_SIZE_LIMIT,
);
});
it('should handle Request objects', async () => {
@ -414,6 +455,7 @@ describe('proxyFetch', () => {
method: 'POST',
headers: [['content-type', 'application/json']],
}),
EXPECTED_RESPONSE_SIZE_LIMIT,
);
});
});
@ -429,6 +471,7 @@ describe('proxyFetch', () => {
expect.objectContaining({ type: 'Dispatcher', proxy: 'env' }),
url,
undefined,
EXPECTED_RESPONSE_SIZE_LIMIT,
);
});
@ -447,6 +490,7 @@ describe('proxyFetch', () => {
expect.objectContaining({ type: 'Dispatcher', proxy: 'env' }),
url,
init,
EXPECTED_RESPONSE_SIZE_LIMIT,
);
});
@ -461,6 +505,7 @@ describe('proxyFetch', () => {
expect.objectContaining({ type: 'Dispatcher', proxy: false }),
url,
undefined,
EXPECTED_RESPONSE_SIZE_LIMIT,
);
});

View File

@ -0,0 +1,118 @@
import { Readable } from 'node:stream';
// `vi.resetModules` gives the reloaded module its own `OperationalError` class,
// so its identity differs from a top-level import. Match on the class name,
// which survives the reset.
// The response-size cap is read from the environment when the module loads, so
// each test reloads the module with the value it needs. Global `fetch` is
// mocked, so no request leaves the process and the proxy environment is
// irrelevant here.
const originalFetch = global.fetch;
async function loadModule(maxResponseSize: string) {
vi.resetModules();
vi.stubEnv('N8N_AI_MAX_RESPONSE_SIZE', maxResponseSize);
return await import('../../utils/http-proxy-agent.js');
}
afterEach(() => {
global.fetch = originalFetch;
vi.unstubAllEnvs();
});
describe('aiClientFetch', () => {
it('passes a response within the cap', async () => {
global.fetch = vi.fn(async () => new Response(new Uint8Array(5)));
const { aiClientFetch } = await loadModule('1000');
const response = await aiClientFetch('https://provider.test');
await expect(response.arrayBuffer()).resolves.toHaveProperty('byteLength', 5);
});
it('aborts a response over the cap', async () => {
global.fetch = vi.fn(async () => new Response(new Uint8Array(100)));
const { aiClientFetch } = await loadModule('10');
await expect(
aiClientFetch('https://provider.test').then(
async (response: Response) => await response.text(),
),
).rejects.toThrow(/exceeded the maximum allowed size/);
});
it('throws an OperationalError when the cap is exceeded', async () => {
global.fetch = vi.fn(async () => new Response(new Uint8Array(100)));
const { aiClientFetch } = await loadModule('10');
const error = await aiClientFetch('https://provider.test')
.then(async (response: Response) => await response.text())
.catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(Error);
expect((error as Error).constructor.name).toBe('OperationalError');
});
it('does not cap the response when the value is 0', async () => {
global.fetch = vi.fn(async () => new Response(new Uint8Array(100)));
const { aiClientFetch } = await loadModule('0');
const response = await aiClientFetch('https://provider.test');
await expect(response.arrayBuffer()).resolves.toHaveProperty('byteLength', 100);
});
it('falls back to the default cap when the value is malformed', async () => {
global.fetch = vi.fn(async () => new Response(new Uint8Array(100)));
const { aiClientFetch } = await loadModule('not-a-number');
// The default cap is 100 MB, so a 100-byte response passes.
const response = await aiClientFetch('https://provider.test');
await expect(response.arrayBuffer()).resolves.toHaveProperty('byteLength', 100);
});
});
describe('limitAiResponseStream', () => {
async function totalBytes(stream: Readable): Promise<number> {
let total = 0;
for await (const chunk of stream) total += (chunk as Buffer).length;
return total;
}
it('passes a stream within the cap', async () => {
const { limitAiResponseStream } = await loadModule('1000');
const limited = limitAiResponseStream(Readable.from([Buffer.alloc(5)]));
await expect(totalBytes(limited)).resolves.toBe(5);
});
it('errors a stream over the cap with an OperationalError', async () => {
const { limitAiResponseStream } = await loadModule('10');
// Feed the oversize payload as small chunks so the limiter must stop
// mid-stream; assert it never forwards more than the cap before erroring.
const chunks = Array.from({ length: 20 }, () => Buffer.alloc(5));
const limited = limitAiResponseStream(Readable.from(chunks));
let yielded = 0;
let error: unknown;
try {
for await (const chunk of limited) yielded += (chunk as Buffer).length;
} catch (caught) {
error = caught;
}
expect(yielded).toBeLessThanOrEqual(10);
expect(error).toBeInstanceOf(Error);
expect((error as Error).constructor.name).toBe('OperationalError');
});
it('returns the same stream when the value is 0', async () => {
const { limitAiResponseStream } = await loadModule('0');
const source = Readable.from([Buffer.alloc(100)]);
expect(limitAiResponseStream(source)).toBe(source);
});
});

View File

@ -53,6 +53,7 @@ export { encodingForModel, getEncoding } from './utils/tokenizer/tiktoken';
export { makeN8nLlmFailedAttemptHandler } from './utils/failed-attempt-handler/n8nLlmFailedAttemptHandler';
export {
getProxyAgent,
aiClientFetch,
getNodeProxyAgent,
proxyFetch,
type AgentTimeoutOptions,

View File

@ -7,7 +7,7 @@ import { BaseChatModel } from '../chat-model/base';
import type { ChatModel } from '../types/chat-model';
import type { OpenAIModelOptions } from '../types/openai';
import { makeN8nLlmFailedAttemptHandler } from '../utils/failed-attempt-handler/n8nLlmFailedAttemptHandler';
import { getProxyAgent } from '../utils/http-proxy-agent';
import { getProxyAgent, aiClientFetch } from '../utils/http-proxy-agent';
import { N8nLlmTracing } from '../utils/n8n-llm-tracing';
export type OpenAiModel = OpenAIModelOptions & {
@ -21,6 +21,7 @@ function isOpenAiModel(model: SupplyModelOptions): model is OpenAiModel {
function getOpenAiModel(ctx: ISupplyDataFunctions, model: OpenAiModel) {
const clientConfiguration: ClientOptions = {
fetch: aiClientFetch,
baseURL: model.baseUrl,
};

View File

@ -23,10 +23,14 @@ import {
import {
buildDispatcher,
dispatchedFetch,
limitResponseBody,
type ResponseSizeLimit,
type TransportSsrfPolicy,
} from '@n8n/backend-network/transport';
import type { AgentOptions } from 'node:https';
import { pipeline, type Readable, Transform } from 'node:stream';
import type { Dispatcher } from 'undici';
import { OperationalError } from 'n8n-workflow';
/**
* Options for configuring HTTP agent timeouts.
@ -45,6 +49,40 @@ export type EgressFilter = TransportSsrfPolicy;
// Configurable via N8N_AI_TIMEOUT_MAX environment variable to support custom timeout requirements
const DEFAULT_TIMEOUT = parseInt(process.env.N8N_AI_TIMEOUT_MAX ?? '3600000', 10);
// Bound the response body every AI client may buffer, so a hostile or
// misbehaving endpoint cannot stream an unbounded body until the heap runs
// out. The count is of decoded bytes, so a compressed body cannot expand past
// the cap. Real completions are far below this 100 MB default.
const DEFAULT_MAX_RESPONSE_SIZE = 100 * 1024 * 1024;
/**
* Resolve the cap from `N8N_AI_MAX_RESPONSE_SIZE` (in bytes). Set it to 0 to
* disable the cap; a blank, malformed or negative value falls back to the
* default.
*/
function resolveMaxResponseSize(): number {
const configured = process.env.N8N_AI_MAX_RESPONSE_SIZE?.trim();
if (!configured) return DEFAULT_MAX_RESPONSE_SIZE;
const parsed = Number(configured);
const isValid = Number.isSafeInteger(parsed) && parsed >= 0;
return isValid ? parsed : DEFAULT_MAX_RESPONSE_SIZE;
}
const MAX_RESPONSE_SIZE = resolveMaxResponseSize();
function responseSizeError(maxBytes: number): OperationalError {
return new OperationalError(
`Response body exceeded the maximum allowed size of ${maxBytes} bytes`,
);
}
/** The AI response-size limit, in the shape `@n8n/backend-network` consumes. */
const responseSizeLimit: ResponseSizeLimit = {
maxBytes: MAX_RESPONSE_SIZE,
createError: responseSizeError,
};
/**
* Stand-in target used when the real target URL is unknown in advance (e.g. when providing a proxy agent to ChatAwsBedrock).
* It only decides whether an env-proxied dispatcher is built at all: that dispatcher re-resolves
@ -151,16 +189,32 @@ export async function proxyFetch({
// fetch only recognizes its own Request class and stringifies any other, so
// a Request (the Mistral SDK builds one with the global class) is passed
// as url + init instead.
if (!isRequest) return await dispatchedFetch(dispatcher, input, init);
if (!isRequest) return await dispatchedFetch(dispatcher, input, init, responseSizeLimit);
return await dispatchedFetch(dispatcher, targetUrl, {
method: input.method,
headers: [...input.headers],
body: input.body === null ? undefined : await input.arrayBuffer(),
signal: input.signal,
redirect: input.redirect,
...init,
});
return await dispatchedFetch(
dispatcher,
targetUrl,
{
method: input.method,
headers: [...input.headers],
body: input.body === null ? undefined : await input.arrayBuffer(),
signal: input.signal,
redirect: input.redirect,
...init,
},
responseSizeLimit,
);
}
/**
* A drop-in for an SDK's `fetch` option that applies n8n's AI transport
* policy. Today that policy bounds the decoded response body; wrap the SDK's
* fetch through this instead of using the SDK default. It stays global `fetch`
* (with the dispatcher passed through `fetchOptions`), so HTTP mocks that patch
* the global undici still see the request.
*/
export async function aiClientFetch(...args: Parameters<typeof fetch>): Promise<Response> {
return limitResponseBody(await fetch(...args), responseSizeLimit);
}
/**
@ -180,3 +234,27 @@ export function getNodeProxyAgent(targetUrl?: string, agentOptions?: AgentOption
return createHttpsProxyAgent(targetUrl ?? PROXY_FALLBACK_TARGET, proxyUrl, agentOptions);
}
/**
* A Node `Transform` that errors once the bytes it has passed through exceed
* the cap. Its own function so it can be unit-tested in isolation. This is the
* Node-stream counterpart of `@n8n/backend-network`'s web-stream limiter, for
* SDKs (AWS Bedrock) that hand back a Node `Readable` instead of a fetch body.
*/
export function createResponseStreamLimit(maxBytes: number): Transform {
let received = 0;
return new Transform({
transform(chunk: Buffer, _encoding, done) {
received += chunk.length;
done(received > maxBytes ? responseSizeError(maxBytes) : null, chunk);
},
});
}
/** Bound a response body that arrives as a Node `Readable`. */
export function limitAiResponseStream(body: Readable): Readable {
if (MAX_RESPONSE_SIZE === 0) return body;
return pipeline(body, createResponseStreamLimit(MAX_RESPONSE_SIZE), () => {
// The SDK reads errors off the returned stream; pipeline closes the source too.
});
}

View File

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

View File

@ -3,7 +3,7 @@ import type { BaseChatModel } from '@langchain/core/language_models/chat_models'
import type { MemorySaver } from '@langchain/langgraph';
import { GraphRecursionError } from '@langchain/langgraph';
import type { Logger } from '@n8n/backend-common';
import type { INodeTypeDescription } from 'n8n-workflow';
import type { INodeTypeDescription, IWorkflowBase } from 'n8n-workflow';
import { OperationalError, UserError } from 'n8n-workflow';
import type { Mock, MockedClass, MockedFunction } from 'vitest';
import { mock } from 'vitest-mock-extended';
@ -52,6 +52,7 @@ import { MAX_AI_BUILDER_PROMPT_LENGTH } from '@/constants';
import { ValidationError } from '@/errors';
import type { PlanInterruptValue, PlanOutput } from '@/types/planning';
import type { StreamOutput } from '@/types/streaming';
import type { SimpleWorkflow } from '@/types/workflow';
import { createStreamProcessor } from '@/utils/stream-processor';
import {
WorkflowBuilderAgent,
@ -121,6 +122,74 @@ describe('WorkflowBuilderAgent', () => {
agent = new WorkflowBuilderAgent(config);
});
describe('workflow entry point', () => {
// getDefaultWorkflowJSON is the only producer of the workflowJSON the graph is started
// with, so it is the choke point where a stored workflow is made safe to key by node
// name. Exercised directly: the call sites below it sit behind the LangGraph stream.
function getDefaultWorkflowJSON(payload: ChatPayload): SimpleWorkflow {
return (
agent as unknown as {
getDefaultWorkflowJSON: (p: ChatPayload) => SimpleWorkflow;
}
).getDefaultWorkflowJSON(payload);
}
function payloadWith(currentWorkflow: unknown): ChatPayload {
return {
id: '1',
message: 'hi',
workflowContext: { currentWorkflow: currentWorkflow as IWorkflowBase },
};
}
it('should pass an ordinary workflow through unchanged', () => {
const currentWorkflow = {
name: 'w',
nodes: [{ id: '1', name: 'Manual' }],
connections: { Manual: { main: [[{ node: 'Sink', type: 'main', index: 0 }]] } },
};
const result = getDefaultWorkflowJSON(payloadWith(currentWorkflow));
expect(result).toBe(currentWorkflow);
});
it('should return an empty workflow when no workflow is supplied', () => {
expect(getDefaultWorkflowJSON({ id: '1', message: 'hi' })).toEqual({
nodes: [],
connections: {},
name: '',
});
});
it('should drop a node whose name cannot be an object key before the graph starts', () => {
const result = getDefaultWorkflowJSON(
payloadWith({
name: 'w',
nodes: [
{ id: '1', name: '__proto__' },
{ id: '2', name: 'Keep' },
],
connections: {},
}),
);
expect(result.nodes.map((n) => n.name)).toEqual(['Keep']);
});
it('should drop a connection field that cannot be an object key before the graph starts', () => {
const result = getDefaultWorkflowJSON(
payloadWith(
JSON.parse(
'{"name":"w","nodes":[],"connections":{"A":{"main":[[{"node":["__proto__"],"type":"main","index":0}]]}}}',
),
),
);
expect(result.connections.A?.main?.[0]).toEqual([]);
});
});
describe('chat method', () => {
let mockPayload: ChatPayload;

View File

@ -5,7 +5,7 @@ import type {
IConnection,
NodeConnectionType,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeHelpers } from 'n8n-workflow';
import { isUsableObjectKey, NodeConnectionTypes, NodeHelpers } from 'n8n-workflow';
import type {
ConnectionValidationResult,
@ -137,6 +137,13 @@ export function createConnection(
sourceOutputIndex: number = 0,
targetInputIndex: number = 0,
): IConnections {
// Connection maps are keyed by node name and connection type. A key that names an
// inherited object property resolves to a slot that is not the map's own, so refuse
// it instead of writing through it.
if (!isUsableObjectKey(sourceNodeName) || !isUsableObjectKey(connectionType)) {
return connections;
}
// Ensure source node exists in connections
if (!connections[sourceNodeName]) {
connections[sourceNodeName] = {};
@ -194,6 +201,10 @@ export function removeConnection(
sourceOutputIndex?: number,
targetInputIndex?: number,
): IConnections {
if (!isUsableObjectKey(sourceNodeName) || !isUsableObjectKey(connectionType)) {
return connections;
}
if (!connections[sourceNodeName]?.[connectionType]) {
return connections;
}

View File

@ -1001,4 +1001,74 @@ describe('connection.utils', () => {
expect(NodeHelpers.nodeHasOutputType(returnExprNode, 'ai_tool')).toBe(true);
});
});
describe('reserved connection keys', () => {
const plantedKeys = ['main', 'ai_tool'];
afterEach(() => {
for (const key of plantedKeys) {
delete (Object.prototype as Record<string, unknown>)[key];
}
});
it('createConnection should refuse a source name that is not a usable object key', () => {
const connections: IConnections = {};
const result = createConnection(connections, '__proto__', 'Sink', NodeConnectionTypes.Main);
expect(({} as Record<string, unknown>).main).toBeUndefined();
expect(Object.keys(result)).toEqual([]);
});
it('createConnection should refuse a connection type that is not a usable object key', () => {
const connections: IConnections = {};
const result = createConnection(
connections,
'Source',
'Sink',
'constructor' as NodeConnectionType,
);
expect(Object.keys(result)).toEqual([]);
});
it('createConnection should still create a connection for usable keys', () => {
const connections: IConnections = {};
const result = createConnection(connections, 'Source', 'Sink', NodeConnectionTypes.Main);
expect(result.Source?.main?.[0]).toEqual([{ node: 'Sink', type: 'main', index: 0 }]);
});
it('removeConnection should not write through a reserved source name to a shared slot', () => {
// Start from a shared slot that already holds a connection-shaped value, so the
// unguarded lookup finds something to read, rewrite and store back. One entry is
// kept by the filter, which keeps the unguarded path away from its delete branch.
const seeded = [[{ node: 'Other', type: 'ai_tool', index: 0 }]];
(Object.prototype as Record<string, unknown>).ai_tool = seeded;
const connections: IConnections = {};
const result = removeConnection(connections, '__proto__', 'Sink', 'ai_tool');
// Identity, not deep equality: the unguarded path stores back a filtered copy
// whose contents happen to match.
expect(({} as Record<string, unknown>).ai_tool).toBe(seeded);
expect(seeded).toEqual([[{ node: 'Other', type: 'ai_tool', index: 0 }]]);
expect(Object.keys(result)).toEqual([]);
});
it('removeConnection should refuse a connection type that resolves through the prototype', () => {
const connections: IConnections = {
Source: { main: [[{ node: 'Sink', type: 'main', index: 0 }]] },
};
// 'constructor' reads back a function rather than undefined, so the unguarded
// path gets past its early return and then calls an array method on it.
expect(() => removeConnection(connections, 'Source', 'Sink', 'constructor')).not.toThrow();
expect(Object.keys(connections.Source)).toEqual(['main']);
});
});
});

View File

@ -1,5 +1,5 @@
import type { INode, IConnections, INodeTypes } from 'n8n-workflow';
import { Workflow } from 'n8n-workflow';
import { isUsableObjectKey, Workflow } from 'n8n-workflow';
import type { SimpleWorkflow, WorkflowOperation } from '../types/workflow';
@ -63,10 +63,14 @@ function applyRemoveNodeOperation(
// Copy connections, excluding those from/to removed nodes (using node names)
for (const [sourceName, nodeConnections] of Object.entries(workflow.connections)) {
if (!isUsableObjectKey(sourceName)) continue;
if (!nodeNamesToRemove.has(sourceName)) {
cleanedConnections[sourceName] = {};
for (const [connectionType, outputs] of Object.entries(nodeConnections)) {
if (!isUsableObjectKey(connectionType)) continue;
if (Array.isArray(outputs)) {
cleanedConnections[sourceName][connectionType] = outputs.map((outputConnections) => {
if (Array.isArray(outputConnections)) {
@ -132,6 +136,36 @@ function applyUpdateNodeOperation(
};
}
/**
* Keep only the connection types that can be own keys of a plain object.
*/
function pickSafeConnectionTypes(nodeConnections: IConnections[string]): IConnections[string] {
return Object.fromEntries(
Object.entries(nodeConnections).filter(([connectionType]) => isUsableObjectKey(connectionType)),
);
}
/**
* Keep only the connection entries whose node-name and connection-type keys can be
* own keys of a plain object. Used where a whole connection map arrives from outside
* and is stored as-is.
*/
function pickSafeConnections(connections: IConnections): IConnections {
const safe: IConnections = {};
for (const [sourceName, nodeConnections] of Object.entries(connections)) {
if (!isUsableObjectKey(sourceName)) continue;
const safeTypes = pickSafeConnectionTypes(nodeConnections);
if (Object.keys(safeTypes).length > 0) {
safe[sourceName] = safeTypes;
}
}
return safe;
}
/**
* Handle 'setConnections' operation - replace all connections
*/
@ -143,7 +177,7 @@ function applySetConnectionsOperation(
return {
...workflow,
connections: operation.connections,
connections: pickSafeConnections(operation.connections),
};
}
@ -160,11 +194,17 @@ function applyMergeConnectionsOperation(
// Merge connections additively
for (const [sourceId, nodeConnections] of Object.entries(operation.connections)) {
// A node name that is not usable as an own key of the map cannot take part in a
// connection, so drop the entry rather than resolve it against the prototype chain.
if (!isUsableObjectKey(sourceId)) continue;
if (!connections[sourceId]) {
connections[sourceId] = nodeConnections;
connections[sourceId] = pickSafeConnectionTypes(nodeConnections);
} else {
// Merge connections for this source node
for (const [connectionType, newOutputs] of Object.entries(nodeConnections)) {
if (!isUsableObjectKey(connectionType)) continue;
if (!connections[sourceId][connectionType]) {
connections[sourceId][connectionType] = newOutputs;
} else {
@ -221,6 +261,10 @@ function applyRemoveConnectionOperation(
const { sourceNode, targetNode, connectionType, sourceOutputIndex, targetInputIndex } = operation;
if (!isUsableObjectKey(sourceNode) || !isUsableObjectKey(connectionType)) {
return workflow;
}
const connections = { ...workflow.connections };
// Check if source node has connections

View File

@ -1128,4 +1128,196 @@ describe('operations-processor', () => {
expect(result.workflowOperations).toBeNull();
});
});
describe('reserved connection keys', () => {
const plantedKeys = ['main', 'shell', 'ai_tool'];
afterEach(() => {
for (const key of plantedKeys) {
delete (Object.prototype as Record<string, unknown>)[key];
}
});
it('should build a fixture whose reserved key is an own property', () => {
// Guards the tests below: a plain object literal would set the prototype instead
// of adding the key, which would make the cases vacuous.
const connections = { ['__proto__']: { main: [[]] } } as unknown as IConnections;
expect(Object.prototype.hasOwnProperty.call(connections, '__proto__')).toBe(true);
});
describe('mergeConnections', () => {
it('should ignore a source key that is not a usable object key', () => {
const workflow = createWorkflow([createNode({ id: 'node1', name: 'Sink' })]);
const operations: WorkflowOperation[] = [
{
type: 'mergeConnections',
connections: {
['__proto__']: { main: [[{ node: 'Sink', type: 'main', index: 0 }]] },
} as unknown as IConnections,
},
];
const result = applyOperations(workflow, operations);
expect(({} as Record<string, unknown>).main).toBeUndefined();
expect(Object.keys(result.connections)).toEqual([]);
});
it('should ignore a connection type that is not a usable object key', () => {
const workflow: SimpleWorkflow = {
name: 'Test Workflow',
nodes: [createNode({ id: 'node1', name: 'Source' })],
connections: { Source: { main: [[]] } },
};
const operations: WorkflowOperation[] = [
{
type: 'mergeConnections',
connections: {
Source: { ['__proto__']: [[{ node: 'Sink', type: 'main', index: 0 }]] },
} as unknown as IConnections,
},
];
const result = applyOperations(workflow, operations);
// Object.keys alone cannot see this: the unguarded write lands on the entry's
// prototype, so assert the prototype itself is intact.
expect(Object.getPrototypeOf(result.connections.Source!)).toBe(Object.prototype);
expect(Object.keys(result.connections.Source!)).toEqual(['main']);
});
it('should still merge connections for usable keys', () => {
const workflow: SimpleWorkflow = {
name: 'Test Workflow',
nodes: [createNode({ id: 'node1', name: 'Source' })],
connections: { Source: { main: [[{ node: 'A', type: 'main', index: 0 }]] } },
};
const operations: WorkflowOperation[] = [
{
type: 'mergeConnections',
connections: {
['__proto__']: { main: [[{ node: 'Ignored', type: 'main', index: 0 }]] },
Source: { main: [[{ node: 'B', type: 'main', index: 0 }]] },
} as unknown as IConnections,
},
];
const result = applyOperations(workflow, operations);
expect(result.connections.Source?.main?.[0]).toEqual([
{ node: 'A', type: 'main', index: 0 },
{ node: 'B', type: 'main', index: 0 },
]);
expect(result.connections).not.toHaveProperty('Ignored');
});
});
it('mergeConnections should filter types on a first-time source entry', () => {
const workflow = createWorkflow([createNode({ id: 'node1', name: 'Source' })]);
const operations: WorkflowOperation[] = [
{
type: 'mergeConnections',
connections: JSON.parse(
'{"Source":{"__proto__":[[]],"main":[[{"node":"Sink","type":"main","index":0}]]}}',
) as IConnections,
},
];
const result = applyOperations(workflow, operations);
expect(Object.keys(result.connections.Source!)).toEqual(['main']);
expect(Object.getPrototypeOf(result.connections.Source!)).toBe(Object.prototype);
});
describe('removeNode', () => {
it('should not reparent the rebuilt connections map', () => {
const workflow: SimpleWorkflow = {
name: 'Test Workflow',
nodes: [createNode({ id: 'node1', name: 'Keep' }), createNode({ id: 'node2' })],
connections: JSON.parse(
'{"__proto__":{"shell":[[{"node":"Keep","type":"main","index":0}]]},"Keep":{"main":[[]]}}',
) as IConnections,
};
const operations: WorkflowOperation[] = [{ type: 'removeNode', nodeIds: ['node2'] }];
const result = applyOperations(workflow, operations);
expect(Object.getPrototypeOf(result.connections)).toBe(Object.prototype);
expect(Object.keys(result.connections)).toEqual(['Keep']);
expect(({} as Record<string, unknown>).shell).toBeUndefined();
});
});
describe('removeConnection', () => {
it('should not write through a reserved source node to a shared slot', () => {
// Start from a shared slot that already holds a connection-shaped value, so the
// unguarded lookup finds something to rewrite and then delete.
(Object.prototype as Record<string, unknown>).main = [
[{ node: 'Sink', type: 'main', index: 0 }],
];
const workflow = createWorkflow([createNode({ id: 'node1', name: 'Sink' })]);
const operations: WorkflowOperation[] = [
{
type: 'removeConnection',
sourceNode: '__proto__',
targetNode: 'Sink',
connectionType: 'main',
sourceOutputIndex: 0,
targetInputIndex: 0,
},
];
const result = applyOperations(workflow, operations);
expect(({} as Record<string, unknown>).main).toEqual([
[{ node: 'Sink', type: 'main', index: 0 }],
]);
expect(result.connections).toEqual({});
});
it('should leave the workflow unchanged for a reserved connection type', () => {
const workflow: SimpleWorkflow = {
name: 'Test Workflow',
nodes: [createNode({ id: 'node1', name: 'Source' })],
connections: { Source: { main: [[{ node: 'Sink', type: 'main', index: 0 }]] } },
};
const operations: WorkflowOperation[] = [
{
type: 'removeConnection',
sourceNode: 'Source',
targetNode: 'Sink',
connectionType: 'constructor',
sourceOutputIndex: 0,
targetInputIndex: 0,
},
];
const result = applyOperations(workflow, operations);
expect(result.connections.Source?.main?.[0]).toHaveLength(1);
});
});
describe('setConnections', () => {
it('should keep only entries whose keys are usable object keys', () => {
const workflow = createWorkflow([createNode({ id: 'node1', name: 'Source' })]);
const operations: WorkflowOperation[] = [
{
type: 'setConnections',
connections: JSON.parse(
'{"__proto__":{"ai_tool":[[]]},"Source":{"main":[[{"node":"Sink","type":"main","index":0}]],"constructor":[[]]}}',
) as IConnections,
},
];
const result = applyOperations(workflow, operations);
expect(Object.keys(result.connections)).toEqual(['Source']);
expect(Object.keys(result.connections.Source!)).toEqual(['main']);
expect(({} as Record<string, unknown>).ai_tool).toBeUndefined();
});
});
});
});

View File

@ -0,0 +1,215 @@
import type { IConnection, IConnections, INode, NodeConnectionType } from 'n8n-workflow';
import { mapConnectionsByDestination } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types/workflow';
import { sanitizeWorkflowForBuilder } from '../workflow-sanitization';
/**
* A persisted workflow reaches the builder as parsed JSON, so a reserved key arrives as an
* own property of the connections map. An object literal cannot express that (`__proto__`
* in a literal sets the prototype instead), so build these fixtures with JSON.parse.
*/
function parseConnections(json: string): IConnections {
return JSON.parse(json) as IConnections;
}
function createNode(name: string): INode {
return {
id: `id-${name}`,
name,
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
}
function createWorkflow(nodes: INode[], connections: IConnections = {}): SimpleWorkflow {
return { name: 'Test Workflow', nodes, connections };
}
describe('sanitizeWorkflowForBuilder', () => {
const reservedKeys = ['main', 'ai_languageModel', 'shell'];
afterEach(() => {
for (const key of reservedKeys) {
delete (Object.prototype as Record<string, unknown>)[key];
}
});
it('should return the same workflow when every name and connection key is usable', () => {
const workflow = createWorkflow([createNode('Manual'), createNode('Sink')], {
Manual: { main: [[{ node: 'Sink', type: 'main', index: 0 }]] },
});
const result = sanitizeWorkflowForBuilder(workflow);
// Unchanged input is returned as-is, so the common case allocates nothing.
expect(result).toBe(workflow);
});
it('should drop a node whose name is not a usable object key', () => {
const workflow = createWorkflow([createNode('__proto__'), createNode('Sink')]);
const result = sanitizeWorkflowForBuilder(workflow);
expect(result.nodes.map((n) => n.name)).toEqual(['Sink']);
});
it('should drop connections that start from a node with an unusable name', () => {
const workflow = createWorkflow(
[createNode('__proto__'), createNode('Sink')],
parseConnections('{"__proto__":{"main":[[{"node":"Sink","type":"main","index":0}]]}}'),
);
const result = sanitizeWorkflowForBuilder(workflow);
expect(Object.keys(result.connections)).toEqual([]);
});
it('should drop connections that point at a node with an unusable name', () => {
const workflow = createWorkflow([createNode('Manual'), createNode('__proto__')], {
Manual: {
main: [
[
{ node: '__proto__', type: 'main', index: 0 },
{ node: 'Sink', type: 'main', index: 0 },
],
],
},
});
const result = sanitizeWorkflowForBuilder(workflow);
expect(result.connections.Manual?.main?.[0]).toEqual([
{ node: 'Sink', type: 'main', index: 0 },
]);
});
it('should drop a connection type that is not a usable object key', () => {
const workflow = createWorkflow(
[createNode('Manual'), createNode('Sink')],
parseConnections(
'{"Manual":{"__proto__":[[{"node":"Sink","type":"main","index":0}]],"main":[[{"node":"Sink","type":"main","index":0}]]}}',
),
);
const result = sanitizeWorkflowForBuilder(workflow);
expect(Object.keys(result.connections.Manual!)).toEqual(['main']);
});
it('should leave the shared object prototype untouched for a reserved source key', () => {
const workflow = createWorkflow(
[createNode('Sink')],
parseConnections('{"__proto__":{"shell":[[{"node":"Sink","type":"main","index":0}]]}}'),
);
sanitizeWorkflowForBuilder(workflow);
expect(({} as Record<string, unknown>).shell).toBeUndefined();
});
it('should drop a connection whose target name has no node entry', () => {
// A stored payload can name a target that is absent from `nodes`, so filtering by the
// set of known unsafe node names is not enough on its own.
const workflow = createWorkflow([createNode('Manual')], {
Manual: {
main: [
[
{ node: '__proto__', type: 'main', index: 0 },
{ node: 'Sink', type: 'main', index: 0 },
],
],
},
});
const result = sanitizeWorkflowForBuilder(workflow);
expect(result.connections.Manual?.main?.[0]).toEqual([
{ node: 'Sink', type: 'main', index: 0 },
]);
});
it('should drop a connection whose type field is not a usable object key', () => {
// Traversal helpers key the inverted graph by this field as well as by `node`.
const workflow = createWorkflow([createNode('Manual'), createNode('Sink')], {
Manual: {
main: [
[
{ node: 'Sink', type: '__proto__' as NodeConnectionType, index: 0 },
{ node: 'Sink', type: 'main', index: 0 },
],
],
},
});
const result = sanitizeWorkflowForBuilder(workflow);
expect(result.connections.Manual?.main?.[0]).toEqual([
{ node: 'Sink', type: 'main', index: 0 },
]);
});
// A request body is JSON, so these fields are only strings by convention. The key check
// has to reject a non-string outright: bracket access would coerce it back to the very
// name being guarded against ( obj[['__proto__']] writes to obj['__proto__'] ).
const coercibleKeys: Array<[string, unknown]> = [
['a string', '__proto__'],
['an array', ['__proto__']],
['a nested array', [['__proto__']]],
['an object with toString', { toString: (): string => '__proto__' }],
];
it.each(coercibleKeys)('should drop a connection whose target is %s', (_label, node) => {
const workflow = createWorkflow([createNode('Manual')], {
Manual: {
main: [[{ node, type: 'main', index: 0 } as unknown as IConnection]],
},
});
const result = sanitizeWorkflowForBuilder(workflow);
expect(result.connections.Manual?.main?.[0]).toEqual([]);
expect(({} as Record<string, unknown>).main).toBeUndefined();
});
it.each(coercibleKeys)('should drop a node whose name is %s', (_label, name) => {
const workflow = createWorkflow([
{ ...createNode('placeholder'), name } as unknown as INode,
createNode('Keep'),
]);
const result = sanitizeWorkflowForBuilder(workflow);
expect(result.nodes.map((n) => n.name)).toEqual(['Keep']);
});
it('should leave the inverted graph intact for a non-string target', () => {
// End to end on the choke-point property: whatever the sanitizer returns must be
// safe to invert, which is where a surviving field would be used as a key.
const workflow = JSON.parse(
'{"name":"w","nodes":[],"connections":{"A":{"main":[[{"node":["__proto__"],"type":"main","index":0}]]}}}',
) as SimpleWorkflow;
const result = sanitizeWorkflowForBuilder(workflow);
expect(result.connections.A?.main?.[0]).toEqual([]);
expect(Object.getPrototypeOf(mapConnectionsByDestination(result.connections))).toBe(
Object.prototype,
);
});
it('should keep the workflow name and unrelated nodes', () => {
const workflow = createWorkflow([createNode('__proto__'), createNode('Keep')], {
Keep: { main: [[]] },
});
const result = sanitizeWorkflowForBuilder(workflow);
expect(result.name).toBe('Test Workflow');
expect(result.nodes.map((n) => n.name)).toEqual(['Keep']);
expect(result.connections.Keep).toBeDefined();
});
});

View File

@ -0,0 +1,72 @@
import type { IConnection, IConnections } from 'n8n-workflow';
import { isUsableObjectKey } from 'n8n-workflow';
import type { SimpleWorkflow } from '../types/workflow';
/**
* A connection can only be kept if both of its node-name and connection-type fields can be
* own keys of an object. Traversal helpers invert the graph and key the result by these two
* fields, so an unusable value there is as bad as an unusable key in the stored map — and a
* stored payload can name a target that has no node entry at all.
*/
function isUsableConnection(connection: IConnection): boolean {
return isUsableObjectKey(connection.node) && isUsableObjectKey(connection.type);
}
/**
* Remove nodes whose name cannot be an own key of a plain object, together with every
* connection that references them or that is itself keyed by such a name.
*
* The builder keys its connection maps by node name. A name such as `__proto__` resolves
* to an inherited slot instead of an own key, so a node with that name can never take part
* in a valid graph. The write paths guard their own keys; this runs once when a stored
* workflow enters builder state, so no later step has to reason about the name at all.
*/
export function sanitizeWorkflowForBuilder(workflow: SimpleWorkflow): SimpleWorkflow {
const nodes = workflow.nodes ?? [];
// Set when anything is dropped, so an untouched workflow is returned as-is.
let changed = false;
const keptNodes = nodes.filter((node) => {
if (isUsableObjectKey(node.name)) return true;
changed = true;
return false;
});
const connections: IConnections = {};
for (const [sourceName, nodeConnections] of Object.entries(workflow.connections ?? {})) {
if (!isUsableObjectKey(sourceName)) {
changed = true;
continue;
}
const keptTypes: IConnections[string] = {};
for (const [connectionType, outputs] of Object.entries(nodeConnections)) {
if (!isUsableObjectKey(connectionType) || !Array.isArray(outputs)) {
changed = true;
continue;
}
keptTypes[connectionType] = outputs.map((outputConnections) => {
if (!Array.isArray(outputConnections)) return outputConnections;
const kept = outputConnections.filter(isUsableConnection);
if (kept.length !== outputConnections.length) changed = true;
return kept;
});
}
connections[sourceName] = keptTypes;
}
if (!changed) return workflow;
return {
...workflow,
nodes: keptNodes,
connections,
};
}

View File

@ -602,4 +602,120 @@ describe('autoFixConnections', () => {
expect(result.fixed).toHaveLength(1);
});
});
describe('reserved node names', () => {
afterEach(() => {
delete (Object.prototype as Record<string, unknown>).ai_languageModel;
});
it('should not connect a sub-node whose name is not a usable object key', () => {
// Auto-fix keys the connection map by source node name, so a name that cannot be
// an own key of that map must not produce a connection.
const workflow = createWorkflow([
createNode({ name: 'AI Agent', type: '@n8n/n8n-nodes-langchain.agent' }),
createNode({ name: '__proto__', type: '@n8n/n8n-nodes-langchain.lmChatOpenAi' }),
]);
const violations: ProgrammaticViolation[] = [
{
name: 'node-missing-required-input',
type: 'critical',
description:
'Node AI Agent (@n8n/n8n-nodes-langchain.agent) is missing required input of type ai_languageModel',
pointsDeducted: 50,
metadata: {
nodeName: 'AI Agent',
nodeType: '@n8n/n8n-nodes-langchain.agent',
missingType: 'ai_languageModel',
},
},
{
name: 'sub-node-not-connected',
type: 'critical',
description:
'Sub-node __proto__ (@n8n/n8n-nodes-langchain.lmChatOpenAi) provides ai_languageModel but is not connected to a root node.',
pointsDeducted: 50,
metadata: {
nodeName: '__proto__',
nodeType: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
outputType: 'ai_languageModel',
},
},
];
const result = autoFixConnections(workflow, mockNodeTypes, violations);
expect(({} as Record<string, unknown>).ai_languageModel).toBeUndefined();
expect(Object.keys(result.updatedConnections)).toEqual([]);
// createConnection refuses the key and returns the map unchanged, so the result must
// not report a fix that was never applied either.
expect(result.fixed).toEqual([]);
expect(result.unfixable.length).toBeGreaterThan(0);
});
// The name only has to be a string by convention, and bracket access would coerce a
// non-string back to the name being guarded against.
it.each([
['a string', '__proto__'],
['an array', ['__proto__']],
['an object with toString', { toString: (): string => '__proto__' }],
])('should not connect a sub-node whose name is %s', (_label, subNodeName) => {
const workflow = createWorkflow([
createNode({ name: 'AI Agent', type: '@n8n/n8n-nodes-langchain.agent' }),
{
...createNode({
name: 'placeholder',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
}),
name: subNodeName as string,
},
]);
const violations: ProgrammaticViolation[] = [
{
name: 'node-missing-required-input',
type: 'critical',
description: 'Node AI Agent is missing required input of type ai_languageModel',
pointsDeducted: 50,
metadata: {
nodeName: 'AI Agent',
nodeType: '@n8n/n8n-nodes-langchain.agent',
missingType: 'ai_languageModel',
},
},
];
const result = autoFixConnections(workflow, mockNodeTypes, violations);
expect(({} as Record<string, unknown>).ai_languageModel).toBeUndefined();
expect(Object.keys(result.updatedConnections)).toEqual([]);
expect(result.fixed).toEqual([]);
});
it('should still connect a sub-node with a usable name', () => {
const workflow = createWorkflow([
createNode({ name: 'AI Agent', type: '@n8n/n8n-nodes-langchain.agent' }),
createNode({ name: 'OpenAI Model', type: '@n8n/n8n-nodes-langchain.lmChatOpenAi' }),
]);
const violations: ProgrammaticViolation[] = [
{
name: 'node-missing-required-input',
type: 'critical',
description:
'Node AI Agent (@n8n/n8n-nodes-langchain.agent) is missing required input of type ai_languageModel',
pointsDeducted: 50,
metadata: {
nodeName: 'AI Agent',
nodeType: '@n8n/n8n-nodes-langchain.agent',
missingType: 'ai_languageModel',
},
},
];
const result = autoFixConnections(workflow, mockNodeTypes, violations);
expect(result.updatedConnections['OpenAI Model']?.ai_languageModel).toBeDefined();
});
});
});

View File

@ -1,4 +1,5 @@
import type { INodeTypeDescription, NodeConnectionType, IConnections } from 'n8n-workflow';
import { isUsableObjectKey } from 'n8n-workflow';
import { createConnection } from '@/tools/utils/connection.utils';
import type { SimpleWorkflow } from '@/types';
@ -141,6 +142,11 @@ function findCandidateSubNodes(
const candidates: string[] = [];
for (const [subNodeName, outputs] of ctx.subNodeOutputs) {
// The source name becomes a key of the connection map, so a name that cannot be an
// own key is not a candidate. Dropping it here lets the caller report honestly
// instead of counting a connection that the write path would refuse.
if (!isUsableObjectKey(subNodeName)) continue;
if (outputs.has(missingType)) {
// Check if this sub-node is already connected to THIS specific target
const alreadyConnectedToTarget = connectionExistsBetweenNodes(
@ -174,6 +180,17 @@ function processMissingInputViolation(ctx: AutoFixContext, violation: Programmat
// Only auto-fix AI connections (ai_*)
if (!missingType.startsWith('ai_')) return;
if (!isUsableObjectKey(nodeName)) {
ctx.result.unfixable.push({
nodeName: String(nodeName),
missingInputType: missingType as NodeConnectionType,
// String(): the check rejects a value that need not be a string at all.
reason: `Node name "${String(nodeName)}" cannot be used in a connection`,
candidateCount: 0,
});
return;
}
const candidates = findCandidateSubNodes(ctx, nodeName, missingType as NodeConnectionType);
if (candidates.length === 1) {
@ -222,6 +239,9 @@ function findTargetCandidates(
for (const node of ctx.workflow.nodes) {
if (node.disabled || node.name === subNodeName) continue;
// The target name is stored as a connection's `node`, which traversal helpers use
// as a key when they invert the graph.
if (!isUsableObjectKey(node.name)) continue;
const nodeType = getNodeTypeForNode(node, ctx.nodeTypeMap, ctx.nodeTypesByName);
if (!nodeType) continue;
@ -269,6 +289,16 @@ function processDisconnectedSubNodeViolation(
if (ctx.connectedSubNodes.has(subNodeName)) return;
if (!isUsableObjectKey(subNodeName)) {
ctx.result.unfixable.push({
nodeName: String(subNodeName),
missingInputType: outputType as NodeConnectionType,
reason: `Node name "${String(subNodeName)}" cannot be used in a connection`,
candidateCount: 0,
});
return;
}
const nowConnected = findExistingOutgoingConnection(
ctx.result.updatedConnections,
subNodeName,

View File

@ -40,6 +40,7 @@ import type { HITLInterruptValue, PlanOutput } from './types/planning';
import type { SimpleWorkflow } from './types/workflow';
import { sanitizeLlmErrorMessage } from './utils/error-sanitizer';
import { createStreamProcessor, type StreamEvent } from './utils/stream-processor';
import { sanitizeWorkflowForBuilder } from './utils/workflow-sanitization';
import type { WorkflowState } from './workflow-state';
const PROMPT_IS_TOO_LARGE_ERROR =
@ -202,12 +203,13 @@ export class WorkflowBuilderAgent {
}
private getDefaultWorkflowJSON(payload: ChatPayload): SimpleWorkflow {
return (
(payload.workflowContext?.currentWorkflow as SimpleWorkflow) ?? {
nodes: [],
connections: {},
}
);
const currentWorkflow = payload.workflowContext?.currentWorkflow as SimpleWorkflow | undefined;
if (!currentWorkflow) {
return { nodes: [], connections: {}, name: '' };
}
return sanitizeWorkflowForBuilder(currentWorkflow);
}
async *chat(

View File

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

View File

@ -0,0 +1,29 @@
import { CommunityPackageRequestDto } from '../community-package-request.dto';
describe('CommunityPackageRequestDto', () => {
it.each([
{ name: 'n8n-nodes-test' },
{ name: 'n8n-nodes-test', version: '1.0.0' },
{ name: '@scope/n8n-nodes-test@1.0.0' },
])('accepts %o', (request) => {
expect(CommunityPackageRequestDto.safeParse(request).success).toBe(true);
});
it.each([
{ name: 'missing name', request: {} },
{ name: 'empty name', request: { name: ' ' } },
{ name: 'empty version', request: { name: 'n8n-nodes-test', version: '' } },
])('rejects $name', ({ request }) => {
expect(CommunityPackageRequestDto.safeParse(request).success).toBe(false);
});
it('drops unknown fields', () => {
const result = CommunityPackageRequestDto.safeParse({
name: 'n8n-nodes-test',
checksum: 'sha512-abc',
verify: true,
});
expect(result.success).toBe(true);
expect(result.success && result.data).toEqual({ name: 'n8n-nodes-test' });
});
});

View File

@ -0,0 +1,9 @@
import { z } from 'zod';
import { Z } from '../../zod-class';
/** Body of `POST` and `PATCH /community-packages`. */
export class CommunityPackageRequestDto extends Z.class({
name: z.string().trim().min(1),
version: z.string().trim().min(1).optional(),
}) {}

View File

@ -403,6 +403,7 @@ export {
IMPORT_PACKAGE_REQUEST_FORM_FIELDS,
} from './packages/import-package-request.dto';
export { ExportPackageRequestDto } from './packages/export-package-request.dto';
export { CommunityPackageRequestDto } from './community-packages/community-package-request.dto';
export * from './evaluations';

View File

@ -60,6 +60,19 @@ describe('SAML preference DTOs', () => {
relayState: 'https://example.com/relay',
},
},
{
name: 'email verification settings',
request: {
mapping: {
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
userPrincipalName: 'johndoe',
emailVerified: 'http://schemas.example.com/claims/emailverified',
},
emailVerifiedRequired: true,
},
},
])('should validate $name', ({ request }) => {
const result = SamlPreferences.safeParse(request);
expect(result.success).toBe(true);
@ -119,6 +132,13 @@ describe('SAML preference DTOs', () => {
},
expectedErrorPath: ['mapping', 'email'],
},
{
name: 'invalid emailVerifiedRequired',
request: {
emailVerifiedRequired: 'yes',
},
expectedErrorPath: ['emailVerifiedRequired'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = SamlPreferences.safeParse(request);
@ -153,6 +173,7 @@ describe('SAML preference DTOs', () => {
expect(result.data?.authnRequestsSigned).toBe(false);
expect(result.data?.wantAssertionsSigned).toBe(true);
expect(result.data?.wantMessageSigned).toBe(true);
expect(result.data?.emailVerifiedRequired).toBeUndefined();
expect(result.data?.acsBinding).toBe('post');
expect(result.data?.signatureConfig).toEqual({
prefix: 'ds',
@ -174,6 +195,7 @@ describe('SAML preference DTOs', () => {
firstName: 'John',
lastName: 'Doe',
userPrincipalName: 'johndoe',
emailVerified: '',
n8nInstanceRole: '',
n8nProjectRoles: [] as string[],
},
@ -186,6 +208,7 @@ describe('SAML preference DTOs', () => {
authnRequestsSigned: false,
wantAssertionsSigned: true,
wantMessageSigned: true,
emailVerifiedRequired: false,
signingPrivateKey: '',
signingCertificate: '',
acsBinding: 'post' as const,

View File

@ -31,6 +31,11 @@ export class SamlPreferencesAttributeMapping extends Z.class({
lastName: z.string(),
/** SAML attribute mapped to the user's principal name. */
userPrincipalName: z.string(),
/**
* SAML attribute that states whether the identity provider verified the user's email.
* Accepted values are `true` and `false`, case-insensitive.
*/
emailVerified: z.string().optional(),
/** SAML attribute mapped to the n8n instance role. */
n8nInstanceRole: z.string().optional(),
/** Each element in the array is formatted like "<projectId>:<role>" */
@ -54,6 +59,12 @@ export class SamlPreferences extends Z.class({
authnRequestsSigned: z.boolean().default(false),
wantAssertionsSigned: z.boolean().default(true),
wantMessageSigned: z.boolean().default(true),
/**
* Whether the identity provider must assert a verified email before a login is
* linked to an existing user by email. When disabled, only an explicit negative
* assertion is rejected. Optional so that stored settings and the UI stay valid.
*/
emailVerifiedRequired: z.boolean().optional(),
/** PEM-encoded private key for signing SAML AuthnRequests. Stored encrypted at rest. */
signingPrivateKey: z.string().optional(),
@ -82,6 +93,7 @@ export class UpdateSamlConfigurationDto extends Z.class({
firstName: z.string(),
lastName: z.string(),
userPrincipalName: z.string(),
emailVerified: z.string(),
n8nInstanceRole: z.string(),
n8nProjectRoles: z.array(z.string()),
}),
@ -94,6 +106,7 @@ export class UpdateSamlConfigurationDto extends Z.class({
authnRequestsSigned: z.boolean(),
wantAssertionsSigned: z.boolean(),
wantMessageSigned: z.boolean(),
emailVerifiedRequired: z.boolean(),
signingPrivateKey: z.string(),
signingCertificate: z.string(),
acsBinding: SamlLoginBindingSchema,

View File

@ -196,6 +196,7 @@ export interface FrontendSettings {
aiAssistant: {
enabled: boolean;
setup: boolean;
cloudUbbEnabled: boolean;
};
/**
* @deprecated Gates the AI Transform node's code generation. No longer gates
@ -391,8 +392,6 @@ export type FrontendModuleSettings = {
runDebugEnabled: boolean;
/** Whether this instance is in the activation-capped trial cohort (`N8N_INSTANCE_AI_ACTIVATION_CAPPED`). Optional. */
activationCapped?: boolean;
/** Whether the non-blocking setup panel replaces the suspending setup wizard (`N8N_INSTANCE_AI_SETUP_PANEL_ENABLED`). */
instanceAiSetupPanelEnabled?: boolean;
};
/**

View File

@ -357,6 +357,7 @@ export {
} from './schemas/credential-response.schema';
export {
instanceAiQuestionSchema,
buildRunWorkflowSessionGrantKey,
buildExecuteNodeSessionGrantKey,
buildRunStepSessionGrantKey,
@ -425,6 +426,8 @@ export {
INSTANCE_AI_CONVERSATION_HISTORY_FLAG,
INSTANCE_AI_CONVERSATION_HISTORY_ENABLED_VARIANT,
INSTANCE_AI_PROGRESSIVE_BUILDING_FLAG,
INSTANCE_AI_SETUP_PANEL_FLAG,
INSTANCE_AI_SETUP_PANEL_ENABLED_VARIANT,
INSTANCE_AI_PROGRESSIVE_BUILDING_ENABLED_VARIANT,
INSTANCE_AI_NODE_USAGE_FLAG,
INSTANCE_AI_FOLDER_EXPLORATION_FLAG,
@ -551,6 +554,7 @@ export type {
InstanceAiApprovalResumeData,
InstanceAiCredentialPlaceholderDef,
InstanceAiTargetApproval,
InstanceAiQuestion,
InstanceAiCredentialRequest,
InstanceAiCredentialSetupHint,
InstanceAiAgentStatus,

View File

@ -747,6 +747,26 @@ export const instanceAiTargetApprovalSchema = z.object({
});
export type InstanceAiTargetApproval = z.infer<typeof instanceAiTargetApprovalSchema>;
/** One question of the ask-user card (`inputType=questions`). */
export const instanceAiQuestionSchema = z.object({
id: z.string(),
question: z.string(),
type: z.enum(['single', 'multi', 'text']),
options: z.array(z.string()).optional(),
/** Hides Skip and blocks Next until the question has an answer. */
required: z.boolean().optional(),
/** Label of the built-in free-text row; the card shows "Something else" without it. */
freeTextLabel: z.string().optional(),
/**
* Options that follow an earlier `single` question of the same card: the option selected for
* `questionId` picks the list. A free-text or unknown answer falls back to `options`.
*/
optionsByAnswer: z
.object({ questionId: z.string(), options: z.record(z.string(), z.array(z.string())) })
.optional(),
});
export type InstanceAiQuestion = z.infer<typeof instanceAiQuestionSchema>;
export const confirmationRequestPayloadSchema = z.object({
requestId: z.string(),
inputThreadId: z
@ -790,14 +810,7 @@ export const confirmationRequestPayloadSchema = z.object({
'continue shows a single primary button (used by pause-for-user)',
),
questions: z
.array(
z.object({
id: z.string(),
question: z.string(),
type: z.enum(['single', 'multi', 'text']),
options: z.array(z.string()).optional(),
}),
)
.array(instanceAiQuestionSchema)
.optional()
.describe('Structured questions for the Q&A wizard (inputType=questions)'),
introMessage: z.string().optional().describe('Intro text shown above questions or plan review'),
@ -1588,6 +1601,7 @@ export class InstanceAiCorrectTaskRequest extends Z.class({
* - `agent_builder_page` — Instance AI hand-off from the agent builder
* - `agent_preview` — send a preview chat session to Instance AI
* - `assistant_page` — first message typed on the Instance AI empty/home page
* - `onboarding` — seeded "Welcome to n8n" thread for a new user; the greeting is stored before the first user turn
* - `evals` — Instance AI evaluation harness / offline eval runners
* - `playwright` — Playwright E2E helpers that create threads via the REST API
* Experiment cleanup: remove with openWorkflowInAssistant.
@ -1605,6 +1619,7 @@ export const INSTANCE_AI_THREAD_SOURCES = [
'agent_builder_page',
'agent_preview',
'assistant_page',
'onboarding',
// Experiment cleanup: remove with openWorkflowInAssistant.
'workflow_list_auto',
'workflow_list_button',
@ -2556,6 +2571,9 @@ export const INSTANCE_AI_CONVERSATION_HISTORY_ENABLED_VARIANT = 'variant';
export const INSTANCE_AI_PROGRESSIVE_BUILDING_FLAG = '111_instance_ai_progressive_building';
export const INSTANCE_AI_PROGRESSIVE_BUILDING_ENABLED_VARIANT = 'variant';
export const INSTANCE_AI_SETUP_PANEL_FLAG = '118_instance_ai_setup_overhaul';
export const INSTANCE_AI_SETUP_PANEL_ENABLED_VARIANT = 'variant';
/** Enables the node-usage context surface for Instance AI: the `node-usage`
* action and the `nodeTypes` filter on `workflows(action="list")`. */

View File

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

View File

@ -118,6 +118,10 @@ export class LicenseState {
return this.isLicensed('feat:aiGatewayCloudUbb');
}
isAiAssistantCloudUbbEntitlementLicensed() {
return this.isLicensed('feat:aiAssistantCloudUbbEntitlement');
}
isAdvancedExecutionFiltersLicensed() {
return this.isLicensed('feat:advancedExecutionFilters');
}

View File

@ -30,7 +30,6 @@ describe('getModuleEntryUrl', () => {
describe('eligibleModules', () => {
it('should not include opt-in modules by default', () => {
const eligible = Container.get(ModuleRegistry).eligibleModules;
expect(eligible).not.toContain('agents');
expect(eligible).not.toContain('type-availability-policies');
});
@ -43,14 +42,17 @@ describe('eligibleModules', () => {
expect(Container.get(ModuleRegistry).eligibleModules).not.toContain('policy-infrastructure');
});
it('should include instance-ai by default', () => {
expect(Container.get(ModuleRegistry).eligibleModules).toContain('instance-ai');
it.each(['instance-ai', 'agents'])('should include %s by default', (moduleName) => {
expect(Container.get(ModuleRegistry).eligibleModules).toContain(moduleName);
});
it('should allow opting out of a default module via env var', () => {
process.env.N8N_DISABLED_MODULES = 'instance-ai';
expect(Container.get(ModuleRegistry).eligibleModules).not.toContain('instance-ai');
});
it.each(['instance-ai', 'agents'])(
'should allow opting out of the default %s module via env var',
(moduleName) => {
process.env.N8N_DISABLED_MODULES = moduleName;
expect(Container.get(ModuleRegistry).eligibleModules).not.toContain(moduleName);
},
);
it('should consider a module ineligible if it was disabled via env var', () => {
process.env.N8N_DISABLED_MODULES = 'insights';
@ -85,11 +87,12 @@ describe('eligibleModules', () => {
'mcp-registry',
'workflow-reviews',
'instance-ai',
'agents',
]);
});
it('should consider a module eligible if it was enabled via env var', () => {
process.env.N8N_ENABLED_MODULES = 'agents';
process.env.N8N_ENABLED_MODULES = 'type-availability-policies';
expect(Container.get(ModuleRegistry).eligibleModules).toEqual([
'policy-infrastructure',
'insights',
@ -123,6 +126,7 @@ describe('eligibleModules', () => {
'workflow-reviews',
'instance-ai',
'agents',
'type-availability-policies',
]);
});

View File

@ -77,6 +77,7 @@ export class ModuleRegistry {
'mcp-registry',
'workflow-reviews',
'instance-ai',
'agents',
];
private readonly activeModules: string[] = [];

View File

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

View File

@ -0,0 +1,114 @@
import { startServer, type LocalServer } from '../local-server';
import {
buildDispatcher,
createDispatcherTransport,
dispatchedFetch,
limitResponseBody,
} from '../undici/transport';
describe('limitResponseBody', () => {
it('returns the same response when the limit is disabled', () => {
const response = new Response('payload');
expect(limitResponseBody(response, { maxBytes: 0 })).toBe(response);
});
it('returns the same response when the body is empty', () => {
const response = new Response(null);
expect(limitResponseBody(response, { maxBytes: 10 })).toBe(response);
});
it('passes a body that stays within the cap', async () => {
const response = limitResponseBody(new Response(new Uint8Array(5)), { maxBytes: 10 });
await expect(response.arrayBuffer()).resolves.toHaveProperty('byteLength', 5);
});
it('aborts a body that exceeds the cap', async () => {
const response = limitResponseBody(new Response(new Uint8Array(100)), { maxBytes: 10 });
await expect(response.text()).rejects.toThrow(/exceeded the maximum allowed size/);
});
it('throws the error the caller supplies', async () => {
class CustomLimitError extends Error {}
const response = limitResponseBody(new Response(new Uint8Array(100)), {
maxBytes: 10,
createError: () => new CustomLimitError('too big'),
});
await expect(response.text()).rejects.toBeInstanceOf(CustomLimitError);
});
it('preserves url, redirected and type', () => {
const source = new Response(new Uint8Array(5), { status: 200 });
Object.defineProperties(source, {
url: { value: 'https://example.test/' },
redirected: { value: true },
type: { value: 'basic' },
});
const limited = limitResponseBody(source, { maxBytes: 10 });
expect(limited.url).toBe('https://example.test/');
expect(limited.redirected).toBe(true);
expect(limited.type).toBe('basic');
});
});
describe('dispatchedFetch responseSizeLimit', () => {
let server: LocalServer;
beforeAll(async () => {
server = await startServer((_req, res) => {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('A'.repeat(1024));
});
});
afterAll(async () => await server.close());
it('streams the response unchanged without a limit', async () => {
const dispatcher = buildDispatcher(false, 'disabled', {});
const response = await dispatchedFetch(dispatcher, server.url, {});
expect((await response.text()).length).toBe(1024);
});
it('aborts a response over the cap', async () => {
const dispatcher = buildDispatcher(false, 'disabled', {});
await expect(
dispatchedFetch(dispatcher, server.url, {}, { maxBytes: 10 }).then(
async (response) => await response.text(),
),
).rejects.toThrow(/exceeded the maximum allowed size/);
});
it('passes a response under the cap', async () => {
const dispatcher = buildDispatcher(false, 'disabled', {});
const response = await dispatchedFetch(dispatcher, server.url, {}, { maxBytes: 1024 * 1024 });
expect((await response.text()).length).toBe(1024);
});
});
describe('createDispatcherTransport asCustomFetch responseSizeLimit', () => {
let server: LocalServer;
beforeAll(async () => {
server = await startServer((_req, res) => {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('A'.repeat(1024));
});
});
afterAll(async () => await server.close());
it('aborts a response over the cap', async () => {
const fetch = createDispatcherTransport({
proxy: false,
responseSizeLimit: { maxBytes: 10 },
}).asCustomFetch();
await expect(fetch(server.url).then(async (r) => await r.text())).rejects.toThrow(
/exceeded the maximum allowed size/,
);
});
it('streams the response unchanged without a limit', async () => {
const fetch = createDispatcherTransport({ proxy: false }).asCustomFetch();
const response = await fetch(server.url);
expect((await response.text()).length).toBe(1024);
});
});

View File

@ -72,6 +72,8 @@ export interface CreateDispatcherTransportOptions {
timeouts?: TransportTimeoutOptions;
/** When set, it runs on every dispatched request (including each redirect hop) after the SSRF check */
authorize?: RequestAuthorizer;
/** When set, `asCustomFetch()` bounds each response body to this decoded-byte limit. */
responseSizeLimit?: ResponseSizeLimit;
}
/**
@ -216,12 +218,16 @@ export function createDispatcherTransport(
const ssrf = options?.ssrf ?? 'disabled';
const timeouts = options?.timeouts;
const authorize = options?.authorize;
const responseSizeLimit = options?.responseSizeLimit;
const lazyDispatcher = lazyValue(() => buildDispatcher(proxy, ssrf, { timeouts, authorize }));
return {
// `getDispatcher()` returns a bare dispatcher, which sees only wire bytes;
// the response-size limit is a decoded-body concern, so it is applied here
// on the fetch path where the body is already decompressed.
asCustomFetch: () => async (input, init) =>
await dispatchedFetch(lazyDispatcher(), input, init),
await dispatchedFetch(lazyDispatcher(), input, init, responseSizeLimit),
getDispatcher: () => lazyDispatcher(),
};
}
@ -333,17 +339,77 @@ function failDispatch(handler: FailableDispatchHandler, error: Error): void {
}
}
/**
* Response-size limit for {@link dispatchedFetch} and {@link limitResponseBody}.
* `maxBytes <= 0` disables the limit.
*
* The count is of *decoded* bytes: `fetch` reverses any `Content-Encoding`
* before the body reaches this stream, so a compressed payload cannot expand
* past the cap. `createError` lets a caller supply its own error type (n8n's
* `OperationalError`, say) without this pure subpath importing `n8n-workflow`.
*/
export interface ResponseSizeLimit {
maxBytes: number;
createError?: (maxBytes: number) => Error;
}
function defaultResponseSizeError(maxBytes: number): Error {
return new Error(`Response body exceeded the maximum allowed size of ${maxBytes} bytes`);
}
/**
* A `TransformStream` that throws once the decoded bytes pass `maxBytes`. This
* rejects the whole response; it does not truncate the body to `maxBytes`. Its
* own function so it can be unit-tested without a live response.
*/
export function createResponseSizeLimit({
maxBytes,
createError = defaultResponseSizeError,
}: ResponseSizeLimit): TransformStream<Uint8Array, Uint8Array> {
let received = 0;
return new TransformStream({
transform(chunk, controller) {
received += chunk.byteLength;
if (received > maxBytes) throw createError(maxBytes);
controller.enqueue(chunk);
},
});
}
/**
* Wraps a fetch `Response` so that reading its body throws once the decoded
* size passes `limit.maxBytes`. The response is rejected in full, not truncated
* to the cap. Returns the response unchanged when the limit is disabled
* (`maxBytes <= 0`) or the body is empty.
*/
export function limitResponseBody(response: Response, limit: ResponseSizeLimit): Response {
if (limit.maxBytes <= 0 || !response.body) return response;
const body = response.body.pipeThrough(createResponseSizeLimit(limit));
// `new Response(body, response)` drops these read-only fields; restore them.
return Object.defineProperties(new Response(body, response), {
url: { value: response.url },
redirected: { value: response.redirected },
type: { value: response.type },
});
}
/**
* Performs a `fetch` bound to the given undici dispatcher (the engine behind `asCustomFetch`).
* Without a dispatcher it falls through to this undici's default dispatcher.
*
* Pass `responseSizeLimit` to reject a response whose decoded body passes the
* cap: reading the body then throws, rather than the body being truncated. It
* is opt-in; without it the response streams unchanged.
*/
export async function dispatchedFetch(
dispatcher: Dispatcher | undefined,
input: RequestInfo | URL,
init?: RequestInit,
responseSizeLimit?: ResponseSizeLimit,
): Promise<Response> {
return (await undiciFetch(
const response = (await undiciFetch(
input as Parameters<typeof undiciFetch>[0],
{ ...(init ?? {}), dispatcher } as Parameters<typeof undiciFetch>[1],
)) as unknown as Response;
return responseSizeLimit ? limitResponseBody(response, responseSizeLimit) : response;
}

View File

@ -19,13 +19,16 @@ export {
createSsrfInterceptor,
createAuthorizationInterceptor,
createDispatcherTransport,
createResponseSizeLimit,
dispatchedFetch,
limitResponseBody,
} from './http/undici/transport';
export type {
CustomFetch,
DispatcherTransport,
CreateDispatcherTransportOptions,
RequestAuthorizer,
ResponseSizeLimit,
TransportSsrfPolicy,
TransportTimeoutOptions,
} from './http/undici/transport';

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/blob-storage",
"version": "1.9.0",
"version": "1.9.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.34.0",
"version": "1.34.3",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/computer-use",
"version": "0.24.0",
"version": "0.24.1",
"description": "Local AI gateway for n8n Assistant — filesystem, shell, screenshots, mouse/keyboard, and browser automation",
"publishConfig": {
"bin": {

View File

@ -195,7 +195,7 @@ async function main(
const settingsStore = await SettingsStore.create();
const defaults = settingsStore.getDefaults(parsed.config);
const session = new GatewaySession(defaults, settingsStore);
const session = new GatewaySession(defaults, settingsStore, origin);
const confirmConnect = makeConfirmConnect(parsed.nonInteractive, parsed.autoConfirm);
const approved = await confirmConnect(url, session);

View File

@ -111,6 +111,7 @@ function makeSession(overrides: Partial<GatewaySession> = {}): Mocked<GatewaySes
clearSession: vi.fn(),
alwaysAllow: vi.fn(),
alwaysDeny: vi.fn(),
claimUnscopedRules: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
...overrides,
} as unknown as Mocked<GatewaySession>;
@ -373,12 +374,12 @@ describe('GatewayClient.uploadCapabilities', () => {
global.fetch = originalFetch;
});
function makeMinimalClient(): GatewayClient {
function makeMinimalClient(session = makeSession()): GatewayClient {
const client = new GatewayClient({
url: 'http://localhost:5678',
apiKey: 'tok',
config: makeConfig(),
session: makeSession(),
session,
confirmResourceAccess: vi.fn(),
});
@ -422,6 +423,28 @@ describe('GatewayClient.uploadCapabilities', () => {
await expect(promise).rejects.not.toBeInstanceOf(GatewayAuthError);
await expect(promise).rejects.toThrow(/Failed to upload capabilities: 500/);
});
it('start claims unscoped resource rules after the instance accepts the capabilities', async () => {
mockFetchResponse(200);
const session = makeSession();
const client = makeMinimalClient(session);
client['connectSSE'] = vi.fn();
await client.start();
expect(session.claimUnscopedRules).toHaveBeenCalled();
});
it('start does not claim unscoped resource rules when the instance rejects the capabilities', async () => {
mockFetchResponse(401, 'invalid token');
const session = makeSession();
const client = makeMinimalClient(session);
client['connectSSE'] = vi.fn();
await expect(client.start()).rejects.toBeInstanceOf(GatewayAuthError);
expect(session.claimUnscopedRules).not.toHaveBeenCalled();
});
});
describe('GatewayClient.runTeardowns', () => {

View File

@ -149,6 +149,8 @@ export class GatewayClient {
/** Start the client: upload capabilities, connect SSE, handle requests. */
async start(): Promise<void> {
await this.uploadCapabilities();
// Only after the instance accepts the pairing: a declined or failed connect must not claim them.
this.options.session.claimUnscopedRules();
this.connectSSE();
}

View File

@ -14,18 +14,26 @@ function makeStore(
allow: Record<string, string[]>;
deny: Record<string, string[]>;
}> = {},
): Mocked<Pick<SettingsStore, 'getResourcePermissions' | 'alwaysAllow' | 'alwaysDeny' | 'flush'>> {
): Mocked<
Pick<
SettingsStore,
'getResourcePermissions' | 'alwaysAllow' | 'alwaysDeny' | 'claimUnscopedRules' | 'flush'
>
> {
return {
getResourcePermissions: vi.fn((toolGroup: ToolGroup) => ({
getResourcePermissions: vi.fn((_origin: string, toolGroup: ToolGroup) => ({
allow: overrides.allow?.[toolGroup] ?? [],
deny: overrides.deny?.[toolGroup] ?? [],
})),
alwaysAllow: vi.fn(),
alwaysDeny: vi.fn(),
claimUnscopedRules: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
};
}
const ORIGIN = 'https://a.app.n8n.cloud';
const FULL_ALLOW_PERMISSIONS = buildDefaultPermissions({
filesystemRead: 'allow',
filesystemWrite: 'allow',
@ -78,6 +86,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/home/user' },
store as unknown as SettingsStore,
ORIGIN,
);
expect(session.dir).toBe('/home/user');
expect(session.getAllPermissions()).toEqual(FULL_ALLOW_PERMISSIONS);
@ -89,6 +98,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: defaults, dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
defaults.shell = 'deny';
expect(session.getAllPermissions().shell).toBe('allow');
@ -101,6 +111,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
session.setPermissions({ ...FULL_ALLOW_PERMISSIONS, shell: 'deny' });
expect(session.getAllPermissions().shell).toBe('deny');
@ -111,6 +122,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/old' },
store as unknown as SettingsStore,
ORIGIN,
);
session.setDir('/new');
expect(session.dir).toBe('/new');
@ -127,6 +139,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: FULL_ASK_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
expect(session.getGroupMode('filesystemRead')).toBe('ask');
expect(session.getGroupMode('shell')).toBe('ask');
@ -143,6 +156,7 @@ describe('GatewaySession', () => {
dir: '/',
},
store as unknown as SettingsStore,
ORIGIN,
);
expect(session.getGroupMode('filesystemWrite')).toBe('deny');
});
@ -155,6 +169,7 @@ describe('GatewaySession', () => {
dir: '/',
},
store as unknown as SettingsStore,
ORIGIN,
);
expect(session.getGroupMode('filesystemWrite')).toBe('allow');
});
@ -170,6 +185,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
expect(session.check('shell', 'rm -rf /')).toBe('deny');
});
@ -182,6 +198,7 @@ describe('GatewaySession', () => {
dir: '/',
},
store as unknown as SettingsStore,
ORIGIN,
);
expect(session.check('browser', 'example.com')).toBe('allow');
});
@ -194,6 +211,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
expect(session.check('shell', 'npm')).toBe('deny');
});
@ -203,6 +221,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: buildDefaultPermissions({ shell: 'ask' }), dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
session.allowForSession('shell', 'npm');
expect(session.check('shell', 'npm')).toBe('allow');
@ -213,6 +232,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: buildDefaultPermissions({ shell: 'ask' }), dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
expect(session.check('shell', 'npm')).toBe('ask');
});
@ -223,6 +243,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
expect(session.check('browser', 'credentials', 'credential-write')).toBe('ask');
// The group mode still covers ordinary domains.
@ -234,6 +255,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
expect(session.check('browser', 'credentials', 'host')).toBe('allow');
});
@ -243,6 +265,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
expect(session.check('browser', 'credentials')).toBe('allow');
});
@ -252,6 +275,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
session.allowForSession('browser', 'credentials');
expect(session.check('browser', 'credentials', 'credential-write')).toBe('allow');
@ -262,6 +286,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
expect(session.check('browser', 'credentials', 'credential-write')).toBe('allow');
});
@ -271,6 +296,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
expect(session.check('browser', 'credentials', 'credential-write')).toBe('deny');
});
@ -285,6 +311,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
expect(session.check('filesystemWrite', settingsFile)).toBe('deny');
});
@ -294,6 +321,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
expect(session.check('filesystemWrite', settingsDir)).toBe('deny');
});
@ -303,6 +331,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
expect(session.check('filesystemRead', settingsFile)).toBe('deny');
});
@ -312,6 +341,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
expect(session.check('filesystemWrite', settingsFile)).toBe('deny');
});
@ -321,6 +351,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
expect(session.check('filesystemWrite', '/tmp/unrelated.json')).toBe('allow');
});
@ -330,6 +361,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
expect(session.check('shell', settingsFile)).toBe('allow');
});
@ -346,6 +378,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: buildDefaultPermissions({ shell: 'ask' }), dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
session.allowForSession('shell', 'npm');
expect(session.check('shell', 'npm')).toBe('allow');
@ -358,6 +391,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: buildDefaultPermissions({ shell: 'ask', browser: 'ask' }), dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
session.allowForSession('shell', 'npm');
expect(session.check('browser', 'npm')).toBe('ask');
@ -374,9 +408,10 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
session.alwaysAllow('shell', 'npm');
expect(store.alwaysAllow).toHaveBeenCalledWith('shell', 'npm');
expect(store.alwaysAllow).toHaveBeenCalledWith(ORIGIN, 'shell', 'npm');
});
it('delegates alwaysDeny to the settings store', () => {
@ -384,9 +419,21 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
session.alwaysDeny('shell', 'rm -rf /');
expect(store.alwaysDeny).toHaveBeenCalledWith('shell', 'rm -rf /');
expect(store.alwaysDeny).toHaveBeenCalledWith(ORIGIN, 'shell', 'rm -rf /');
});
it('delegates claimUnscopedRules to the settings store with its origin', () => {
const store = makeStore();
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
session.claimUnscopedRules();
expect(store.claimUnscopedRules).toHaveBeenCalledWith(ORIGIN);
});
it('delegates flush to the settings store', async () => {
@ -394,6 +441,7 @@ describe('GatewaySession', () => {
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
ORIGIN,
);
await session.flush();
expect(store.flush).toHaveBeenCalled();
@ -409,6 +457,7 @@ describe('GatewaySession', () => {
return new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
makeStore() as unknown as SettingsStore,
ORIGIN,
);
}

View File

@ -21,6 +21,8 @@ export class GatewaySession {
constructor(
defaults: { permissions: Record<ToolGroup, PermissionMode>; dir: string },
private readonly settingsStore: SettingsStore,
/** Instance origin that persistent resource rules are scoped to. */
private readonly origin: string,
) {
this._permissions = { ...defaults.permissions };
this._dir = defaults.dir;
@ -87,7 +89,7 @@ export class GatewaySession {
return 'deny';
}
const rp = this.settingsStore.getResourcePermissions(toolGroup);
const rp = this.settingsStore.getResourcePermissions(this.origin, toolGroup);
if (rp.deny.includes(resource)) return 'deny';
if (rp.allow.includes(resource)) return 'allow';
if (this.hasSessionAllow(toolGroup, resource)) return 'allow';
@ -141,11 +143,15 @@ export class GatewaySession {
// ---------------------------------------------------------------------------
alwaysAllow(toolGroup: ToolGroup, resource: string): void {
this.settingsStore.alwaysAllow(toolGroup, resource);
this.settingsStore.alwaysAllow(this.origin, toolGroup, resource);
}
alwaysDeny(toolGroup: ToolGroup, resource: string): void {
this.settingsStore.alwaysDeny(toolGroup, resource);
this.settingsStore.alwaysDeny(this.origin, toolGroup, resource);
}
claimUnscopedRules(): void {
this.settingsStore.claimUnscopedRules(this.origin);
}
// ---------------------------------------------------------------------------

View File

@ -23,6 +23,9 @@ function parseJson<T>(raw: string): T {
}
}
const ORIGIN = 'https://a.app.n8n.cloud';
const OTHER_ORIGIN = 'https://b.app.n8n.cloud';
const BASE_CONFIG: GatewayConfig = {
logLevel: 'info',
allowedOrigins: [],
@ -69,15 +72,53 @@ describe('SettingsStore.create', () => {
it('creates a store when no file exists', async () => {
const store = await createStore(tmpDir);
// Should not throw; resource permissions are empty
expect(store.getResourcePermissions('shell')).toEqual({ allow: [], deny: [] });
expect(store.getResourcePermissions(ORIGIN, 'shell')).toEqual({ allow: [], deny: [] });
});
it('loads permissions and resource rules from file', async () => {
it('loads resource rules for an origin from file', async () => {
const store = await createStore(tmpDir, {
permissions: { shell: 'allow' },
resourcePermissions: {},
resourcePermissionsByOrigin: { [ORIGIN]: { shell: { allow: ['npm'], deny: [] } } },
});
expect(store.getResourcePermissions(ORIGIN, 'shell')).toEqual({ allow: ['npm'], deny: [] });
expect(store.getResourcePermissions(OTHER_ORIGIN, 'shell')).toEqual({ allow: [], deny: [] });
});
it('gives unscoped resource rules to the first origin that claims them', async () => {
const store = await createStore(tmpDir, {
permissions: {},
resourcePermissions: { shell: { allow: ['npm'], deny: ['rm'] } },
});
store.claimUnscopedRules(ORIGIN);
store.claimUnscopedRules(OTHER_ORIGIN);
expect(store.getResourcePermissions(ORIGIN, 'shell')).toEqual({ allow: ['npm'], deny: ['rm'] });
expect(store.getResourcePermissions(OTHER_ORIGIN, 'shell')).toEqual({ allow: [], deny: [] });
});
it('merges unscoped resource rules into rules the origin already has', async () => {
const store = await createStore(tmpDir, {
permissions: {},
resourcePermissions: { shell: { allow: ['npm', 'git'], deny: [] } },
resourcePermissionsByOrigin: { [ORIGIN]: { shell: { allow: ['git'], deny: [] } } },
});
store.claimUnscopedRules(ORIGIN);
expect(store.getResourcePermissions(ORIGIN, 'shell').allow).toEqual(['git', 'npm']);
});
it('does not apply unscoped resource rules before they are claimed', async () => {
const store = await createStore(tmpDir, {
permissions: {},
resourcePermissions: { shell: { allow: ['npm'], deny: [] } },
});
expect(store.getResourcePermissions('shell')).toEqual({ allow: ['npm'], deny: [] });
store.alwaysAllow(ORIGIN, 'shell', 'git');
expect(store.getResourcePermissions(ORIGIN, 'shell').allow).toEqual(['git']);
});
it('tolerates a malformed file and starts with empty state', async () => {
@ -86,7 +127,7 @@ describe('SettingsStore.create', () => {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, 'not-json', 'utf-8');
const store = await SettingsStore.create();
expect(store.getResourcePermissions('shell')).toEqual({ allow: [], deny: [] });
expect(store.getResourcePermissions(ORIGIN, 'shell')).toEqual({ allow: [], deny: [] });
});
});
@ -194,19 +235,26 @@ describe('getDefaults', () => {
describe('getResourcePermissions', () => {
it('returns empty lists for unknown groups', async () => {
const store = await createStore(tmpDir);
expect(store.getResourcePermissions('shell')).toEqual({ allow: [], deny: [] });
expect(store.getResourcePermissions(ORIGIN, 'shell')).toEqual({ allow: [], deny: [] });
});
it('reflects alwaysAllow additions', async () => {
const store = await createStore(tmpDir);
store.alwaysAllow('shell', 'npm');
expect(store.getResourcePermissions('shell').allow).toContain('npm');
store.alwaysAllow(ORIGIN, 'shell', 'npm');
expect(store.getResourcePermissions(ORIGIN, 'shell').allow).toContain('npm');
});
it('keeps rules saved for one origin away from other origins', async () => {
const store = await createStore(tmpDir);
store.alwaysAllow(ORIGIN, 'shell', 'npm');
store.alwaysDeny(ORIGIN, 'shell', 'rm');
expect(store.getResourcePermissions(OTHER_ORIGIN, 'shell')).toEqual({ allow: [], deny: [] });
});
it('reflects alwaysDeny additions', async () => {
const store = await createStore(tmpDir);
store.alwaysDeny('shell', 'rm -rf /');
expect(store.getResourcePermissions('shell').deny).toContain('rm -rf /');
store.alwaysDeny(ORIGIN, 'shell', 'rm -rf /');
expect(store.getResourcePermissions(ORIGIN, 'shell').deny).toContain('rm -rf /');
});
});
@ -217,16 +265,20 @@ describe('getResourcePermissions', () => {
describe('alwaysAllow / alwaysDeny deduplication', () => {
it('does not add a duplicate allow entry', async () => {
const store = await createStore(tmpDir);
store.alwaysAllow('shell', 'npm');
store.alwaysAllow('shell', 'npm');
expect(store.getResourcePermissions('shell').allow.filter((r) => r === 'npm')).toHaveLength(1);
store.alwaysAllow(ORIGIN, 'shell', 'npm');
store.alwaysAllow(ORIGIN, 'shell', 'npm');
expect(
store.getResourcePermissions(ORIGIN, 'shell').allow.filter((r) => r === 'npm'),
).toHaveLength(1);
});
it('does not add a duplicate deny entry', async () => {
const store = await createStore(tmpDir);
store.alwaysDeny('shell', 'rm');
store.alwaysDeny('shell', 'rm');
expect(store.getResourcePermissions('shell').deny.filter((r) => r === 'rm')).toHaveLength(1);
store.alwaysDeny(ORIGIN, 'shell', 'rm');
store.alwaysDeny(ORIGIN, 'shell', 'rm');
expect(
store.getResourcePermissions(ORIGIN, 'shell').deny.filter((r) => r === 'rm'),
).toHaveLength(1);
});
});
@ -237,11 +289,32 @@ describe('alwaysAllow / alwaysDeny deduplication', () => {
describe('flush', () => {
it('writes alwaysAllow changes to disk', async () => {
const store = await createStore(tmpDir);
store.alwaysAllow('shell', 'npm');
store.alwaysAllow(ORIGIN, 'shell', 'npm');
await store.flush();
const raw = await fs.readFile(path.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8');
const parsed = parseJson<{ resourcePermissions: { shell: { allow: string[] } } }>(raw);
expect(parsed.resourcePermissions.shell.allow).toContain('npm');
const parsed = parseJson<{
resourcePermissions: object;
resourcePermissionsByOrigin: Record<string, { shell: { allow: string[] } }>;
}>(raw);
expect(parsed.resourcePermissionsByOrigin[ORIGIN].shell.allow).toContain('npm');
expect(parsed.resourcePermissions).toEqual({});
});
it('writes moved unscoped rules under the origin', async () => {
const store = await createStore(tmpDir, {
permissions: {},
resourcePermissions: { shell: { allow: ['npm'], deny: [] } },
});
store.claimUnscopedRules(ORIGIN);
await store.flush();
const raw = await fs.readFile(path.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8');
const parsed = parseJson<{
resourcePermissions: object;
resourcePermissionsByOrigin: Record<string, { shell: { allow: string[] } }>;
}>(raw);
expect(parsed.resourcePermissionsByOrigin[ORIGIN].shell.allow).toEqual(['npm']);
expect(parsed.resourcePermissions).toEqual({});
});
});

View File

@ -28,6 +28,20 @@ interface ResourcePermissions {
deny: string[];
}
const resourcePermissionsSchema = z
.object(
Object.fromEntries(
Object.keys(TOOL_GROUP_DEFINITIONS).map((key) => [
key,
z.object({
allow: z.array(z.string()),
deny: z.array(z.string()),
}),
]),
),
)
.partial(); // Partial<Record<ToolGroup, ResourcePermissions>>
const persistentSettingsSchema = z.object({
logLevel: logLevelSchema.optional(),
permissions: z
@ -38,19 +52,12 @@ const persistentSettingsSchema = z.object({
)
.partial(), //Partial<Record<ToolGroup, PermissionMode>>,
filesystemDir: z.string().optional(),
resourcePermissions: z
.object(
Object.fromEntries(
Object.keys(TOOL_GROUP_DEFINITIONS).map((key) => [
key,
z.object({
allow: z.array(z.string()),
deny: z.array(z.string()),
}),
]),
),
)
.partial(), // Partial<Record<ToolGroup, ResourcePermissions>>,
/**
* Rules saved before rules were scoped to an origin. The first origin that connects takes them
* over. Still written as `{}` so that older versions accept the file.
*/
resourcePermissions: resourcePermissionsSchema,
resourcePermissionsByOrigin: z.record(z.string(), resourcePermissionsSchema).optional(),
});
type PersistentSettings = z.infer<typeof persistentSettingsSchema>;
@ -60,7 +67,7 @@ function isValidPersistentSettings(raw: unknown): raw is PersistentSettings {
}
function emptySettings(): PersistentSettings {
return { permissions: {}, resourcePermissions: {} };
return { permissions: {}, resourcePermissions: {}, resourcePermissionsByOrigin: {} };
}
// ---------------------------------------------------------------------------
@ -155,8 +162,11 @@ export class SettingsStore {
// ---------------------------------------------------------------------------
/** Read persistent resource rules for a tool group — used by GatewaySession.check(). */
getResourcePermissions(toolGroup: ToolGroup): { allow: string[]; deny: string[] } {
const rp = this.persistent.resourcePermissions[toolGroup];
getResourcePermissions(
origin: string,
toolGroup: ToolGroup,
): { allow: string[]; deny: string[] } {
const rp = this.persistent.resourcePermissionsByOrigin?.[origin]?.[toolGroup];
return { allow: rp?.allow ?? [], deny: rp?.deny ?? [] };
}
@ -164,22 +174,37 @@ export class SettingsStore {
// Mutation methods
// ---------------------------------------------------------------------------
alwaysAllow(toolGroup: ToolGroup, resource: string): void {
const rp = this.getOrInitResourcePermissions(toolGroup);
alwaysAllow(origin: string, toolGroup: ToolGroup, resource: string): void {
const rp = this.getOrInitResourcePermissions(origin, toolGroup);
if (!rp.allow.includes(resource)) {
rp.allow.push(resource);
this.scheduleWrite();
}
}
alwaysDeny(toolGroup: ToolGroup, resource: string): void {
const rp = this.getOrInitResourcePermissions(toolGroup);
alwaysDeny(origin: string, toolGroup: ToolGroup, resource: string): void {
const rp = this.getOrInitResourcePermissions(origin, toolGroup);
if (!rp.deny.includes(resource)) {
rp.deny.push(resource);
this.scheduleWrite();
}
}
/** Moves rules saved before rules were scoped to an origin to `origin`. */
claimUnscopedRules(origin: string): void {
const unscoped = Object.entries(this.persistent.resourcePermissions) as Array<
[ToolGroup, ResourcePermissions | undefined]
>;
if (unscoped.length === 0) return;
for (const [toolGroup, rules] of unscoped) {
const target = this.getOrInitResourcePermissions(origin, toolGroup);
target.allow = [...new Set([...target.allow, ...(rules?.allow ?? [])])];
target.deny = [...new Set([...target.deny, ...(rules?.deny ?? [])])];
}
this.persistent.resourcePermissions = {};
this.scheduleWrite();
}
/** Force immediate write — must be called on shutdown. */
async flush(): Promise<void> {
this.cancelDebounce();
@ -191,8 +216,10 @@ export class SettingsStore {
// Private helpers
// ---------------------------------------------------------------------------
private getOrInitResourcePermissions(toolGroup: ToolGroup): ResourcePermissions {
const existing = this.persistent.resourcePermissions[toolGroup];
private getOrInitResourcePermissions(origin: string, toolGroup: ToolGroup): ResourcePermissions {
const byOrigin = (this.persistent.resourcePermissionsByOrigin ??= {});
const rules = (byOrigin[origin] ??= {});
const existing = rules[toolGroup];
if (existing) {
// Normalise: zod schema marks allow/deny as optional; ensure they exist.
existing.allow ??= [];
@ -200,7 +227,7 @@ export class SettingsStore {
return existing;
}
const fresh: ResourcePermissions = { allow: [], deny: [] };
this.persistent.resourcePermissions[toolGroup] = fresh;
rules[toolGroup] = fresh;
return fresh;
}

View File

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

View File

@ -206,15 +206,6 @@ export class InstanceAiConfig {
@Env('N8N_INSTANCE_AI_NODE_CONTEXT_ENABLED')
canvasNodeContextEnabled: boolean = false;
/**
* Non-blocking setup panel (setup panel v2): the persistent checklist above
* the chat input replaces the suspending setup wizard. Env-settable so eval
* lanes can exercise both paths; a managed rollout flag may layer on top
* later behind the same accessors.
*/
@Env('N8N_INSTANCE_AI_SETUP_PANEL_ENABLED')
instanceAiSetupPanelEnabled: boolean = false;
/**
* Force-enable the node-usage context surface for Instance AI — the `node-usage` action and
* the `nodeTypes` filter on `workflows(action="list")`.

View File

@ -379,7 +379,6 @@ describe('GlobalConfig', () => {
thinkingEnabled: true,
mcpConnectionsEnabled: false,
canvasNodeContextEnabled: false,
instanceAiSetupPanelEnabled: false,
nodeUsageEnabled: false,
folderExplorationEnabled: false,
activationCapped: false,

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/constants",
"version": "0.39.0",
"version": "0.39.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -41,6 +41,7 @@ export const LICENSE_FEATURES = {
AI_CREDITS: 'feat:aiCredits',
AI_GATEWAY: 'feat:aiGateway',
AI_GATEWAY_CLOUD_UBB: 'feat:aiGatewayCloudUbb',
AI_ASSISTANT_CLOUD_UBB_ENTITLEMENT: 'feat:aiAssistantCloudUbbEntitlement',
FOLDERS: 'feat:folders',
INSIGHTS_VIEW_SUMMARY: 'feat:insights:viewSummary',
INSIGHTS_VIEW_DASHBOARD: 'feat:insights:viewDashboard',

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/create-node",
"version": "0.49.0",
"version": "0.49.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.41.0",
"version": "1.41.4",
"scripts": {
"clean": "rimraf dist .turbo .tmp-schema-docs",
"gen:migration-index": "node scripts/generate-migration-index.mjs",

View File

@ -181,6 +181,26 @@ describe('DbConnectionMonitor', () => {
expect(poolClient.release).toHaveBeenCalledWith(expect.any(Error));
});
it('should count a ping as successful when the reply is pending I/O as the timeout fires', async () => {
// @ts-expect-error readonly property
dataSource.isInitialized = true;
// @ts-expect-error private property
monitor.connected = true;
// The reply is delivered on the next I/O turn, as after an event-loop stall.
poolClient.query.mockImplementation(
async () => await new Promise((resolve) => setImmediate(() => resolve([]))),
);
mockedSetTimeoutP
.mockImplementationOnce(async () => await new Promise(() => {}))
.mockResolvedValueOnce(undefined);
// @ts-expect-error private property
await monitor.ping();
expect(onConnectedChange).not.toHaveBeenCalled();
expect(poolClient.release).toHaveBeenCalledWith();
});
it('should destroy the pool client when query creation throws', async () => {
// @ts-expect-error readonly property
dataSource.isInitialized = true;

View File

@ -6,7 +6,7 @@ import type { PostgresDriver } from '@n8n/typeorm/driver/postgres/PostgresDriver
import { ensureError } from '@n8n/utils/errors/ensure-error';
import type { ErrorReporter } from 'n8n-core';
import { OperationalError } from 'n8n-workflow';
import { setTimeout as setTimeoutP } from 'timers/promises';
import { setImmediate as setImmediateP, setTimeout as setTimeoutP } from 'timers/promises';
import { computeBackoff } from './backoff';
import type { DbConnectionMetrics } from './db-connection-metrics';
@ -276,7 +276,10 @@ export class DbConnectionMonitor {
work,
setTimeoutP(timeoutMs, undefined, {
signal: abortController.signal,
}).then(() => {
}).then(async () => {
// After a stalled event loop, due timers run before pending I/O is polled.
// Yield one I/O turn so a reply that already arrived still wins the race.
await setImmediateP();
throw new OperationalError('Database connection timed out');
}),
]);

View File

@ -2,10 +2,26 @@ import { Service } from '@n8n/di';
import { DataSource, Repository } from '@n8n/typeorm';
import { AuthIdentity } from '../entities';
import type { AuthProviderType } from '../entities/types-db';
@Service()
export class AuthIdentityRepository extends Repository<AuthIdentity> {
constructor(dataSource: DataSource) {
super(AuthIdentity, dataSource.manager);
}
/**
* Finds the identity of an external provider together with its user. SSO logins
* resolve the account by this identity before they fall back to the email.
* The user's role and identities are loaded because the entity hooks need them.
*/
async findByProviderIdWithUser(
providerId: string,
providerType: AuthProviderType,
): Promise<AuthIdentity | null> {
return await this.findOne({
where: { providerId, providerType },
relations: { user: { role: true, authIdentities: true } },
});
}
}

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/imap",
"version": "0.27.0",
"version": "0.27.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -47,10 +47,16 @@ For built-in providers, the setup service recognizes `ANTHROPIC_API_KEY`,
| `N8N_INSTANCE_AI_NODE_CONTEXT_ENABLED` | boolean | `false` | Force-enable canvas node context. `false` falls back to the PostHog flag. |
| `N8N_INSTANCE_AI_FOLDER_EXPLORATION_ENABLED` | boolean | `false` | Force-enable folder exploration (folder attribution and folder scoping on `workflows(action="list")`). `false` falls back to the PostHog flag. |
| `N8N_INSTANCE_AI_BROWSER_USE_ENABLED` | boolean | `true` | Computer Use browser tooling, used for credential setup. |
| `N8N_INSTANCE_AI_SETUP_PANEL_ENABLED` | boolean | `false` | Non-blocking setup panel (setup panel v2) instead of the suspending setup wizard. |
| `N8N_INSTANCE_AI_ACTIVATION_CAPPED` | boolean | `false` | Activation capping. |
| `N8N_INSTANCE_AI_ACTIVATION_LOCK_MESSAGE_THRESHOLD` | number | `1` | Assistant messages that must be sent, in addition to instance activation, before an activation lock applies. |
The PostHog flag `118_instance_ai_setup_overhaul` selects the setup flow.
`variant` enables the async panel. `control` or an unavailable assignment uses
the wizard. For local testing, set
`N8N_FEATURE_FLAG_OVERRIDES='{"118_instance_ai_setup_overhaul":"variant"}'`
on the n8n server, then restart it and reload the editor. Use `control` to test
the wizard.
### Debugging
| Variable | Type | Default | Description |

View File

@ -474,7 +474,7 @@ confirmation card.
`nodesStillNeedingSetup` is what nobody has configured yet, `skippedByUser` what the user
actively dismissed and the agent must not re-open (see `reopenSkipped`).
**Setup panel** (`N8N_INSTANCE_AI_SETUP_PANEL_ENABLED`): the normal setup call
**Setup panel** (`118_instance_ai_setup_overhaul: variant`): the normal setup call
analyzes the whole workflow, including bound slots. It publishes the `setup-items`
snapshot and confirms that it reached storage. It then saves the build's setup
routing marker. Only after both steps succeed does it return
@ -609,7 +609,15 @@ Default timeout: 5 minutes; max: 10 minutes. On timeout, execution is cancelled.
| `timeout` | number | no | 300000 | Max wait time in ms (max 600000) |
| `triggerNodeName` | string | no | — | Trigger node to use when a workflow has more than one trigger |
**Returns**: `{ executionId, status, data?, error?, startedAt?, finishedAt? }`
**Returns**: `{ executionId, status, data?, error?, startedAt?, finishedAt?, verificationClaim? }`
**Live test evidence**: `verify-built-workflow` always simulates destructive
nodes, so a live test runs through this action. When a successful run reaches
every planned node of the latest build, with no saved pins and no injected
trigger input, the run is recorded as a `verified` claim on the build outcome.
The publish gate then reads that claim. The result carries it as
`verificationClaim`. Other runs leave the stored claim unchanged: a live run
can raise the verdict but never lower it.
**Type-aware pin data**: Constructs proper pin data per trigger type:
- **Chat trigger**: `{ chatInput, sessionId, action }`
@ -831,7 +839,7 @@ a service. When `needsBrowserSetup=true`, the orchestrator should load the
directly, then call `credentials(action="setup")` again to select the created
credential.
**Setup panel** (`N8N_INSTANCE_AI_SETUP_PANEL_ENABLED`): when the call belongs
**Setup panel** (`118_instance_ai_setup_overhaul: variant`): when the call belongs
to a workflow (`workflowId`, or the workflow this run last saved) and the stage
is not `finalize`, the tool does not suspend. It merges the credential types
into the workflow's durable `setup-items` snapshot and returns

View File

@ -310,12 +310,13 @@ The harness remaps that ID and sends the normal chat request with the workflow
attachment and structured handoff context. The transcript records the Execute
action so process expectations can check the response.
Start each panel eval instance with `N8N_INSTANCE_AI_SETUP_PANEL_ENABLED=true`.
Start each panel eval instance with
`N8N_FEATURE_FLAG_OVERRIDES='{"118_instance_ai_setup_overhaul":"variant"}'`.
Set this variable on the n8n server process or in the lane's environment file.
Setting it only on the eval client does not enable the server feature.
Run the normal PR tier with the flag on and off. For panel cases, load the
Run the normal PR tier with `control` and `variant`. For panel cases, load the
external suite with `--source langtracer --suite <suite-id>`, or stage a local
case and select it with `--filter <case-slug>`. Run those cases with the flag on.
case and select it with `--filter <case-slug>`. Run those cases with `variant`.
The repository does not include a `setup-panel-v2` tier.
Remote Execute cases require LangTracer to preserve `attach.source` when it

View File

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

View File

@ -0,0 +1,142 @@
---
name: suggest-automations
description: >-
Offer the three most common automations for a user as a single-choice card,
from your own knowledge of their team and their apps, then build the one
they choose once they confirm, or offer more. The system preloads it on
onboarding threads, where the opening card already asked the user about
themselves.
Use it on other threads when the user asks what they could automate or
wants ideas for a first workflow.
recommended_tools:
- ask-user
- build-workflow
---
# Suggest automations
## Input
You need to know who the user is, at least their team and the apps they use.
The user knows little about n8n: no n8n vocabulary, and no questions about
tasks or pain points.
On an onboarding thread the conversation already holds an `<onboarding-answer>`
block with the user's team and apps, one line per question. Use every line of
the block. The answers are final: use them as they come and do not ask these
questions again. Two openings:
- The block is followed by your message "Last one: what do you usually do in
<their apps>?" and the user's reply. The reply describes how they
work, names a task in their own words, or names none. Start at step 1.
- The block is the latest message and one answer is free text the user typed
into the card. Read that text first. A tool name is one of their apps: ask
"Last one: what do you usually do in <their apps>?" and end the turn. A
task: start at step 1 with it. Anything else, for example a wish to stop, a
request about something else, a refusal or an insult: call
`leave-onboarding`, then write a one-sentence reply that invites the user to
explore the app and to come back with a task.
On any other thread, ask in ONE `ask-user` call: a `single` question "What
team are you on?" with the options Executive/Owner, Support, Product & Design,
Sales, IT, Engineering and Marketing, and a `multi` question "Which apps do you
use?" with the ten apps people use most at work, as product names such as
Slack or Google Sheets. Never run a command or read a file to find them.
## Flow
1. Pick three automations that each solve a different problem, ordered from
the most to the least relevant. Each one runs on the user's apps and is
realistic for n8n. When the user named a task, even a specific and
complete one, suggestion one is that task in their words and the other two
are what people on their team set up next to it. Otherwise pick the three
that people on the team most often set up with the user's apps. The card
always comes before a build: a specific task gets picked and confirmed,
never built unasked. Decide in one step: do not run a command, read a file
or write the reasoning out.
2. Write short text and no list: the card carries the options. First
reflect what the user shared, in their words, with no assumptions added:
- A goal or a problem: "Thanks, that helps. You want to ..."
- A clear workflow: "Thanks, that helps. You use <app> and <app> to ..."
- A general situation or activities: "Thanks, that helps. Your work
involves ..."
Say "You want" only when the user named an outcome they want. Then the
social proof that matches:
- A goal or a problem: "I've looked at how other users have solved
similar problems."
- A clear workflow: "I've looked at how other users approach similar
workflows."
- A general situation: "I've looked at how other users make their work
easier."
Then, after a blank line, "Here are three ways n8n could help:". With
nothing to reflect, drop the first part and use the general social proof.
Then ONE `ask-user` call with `questions` only: a `single`
question "Which one feels most useful?", `required: true`, with
four options: the three suggestions, most relevant first, and last
`Show me other ideas`. `Show me other ideas` is an action that asks for
three more suggestions, not a catch-all: always keep it as the fourth
option. Leave `introMessage` out. Set `freeTextLabel` to `Share more
context`: the card shows it as its built-in free-text row with a pencil
icon. Never add "Something else", "Share more context" or "None of these"
as an option.
A suggestion is one option string of two lines: the title, a newline,
then the description. The card shows the second line under the first.
- Title: a short action phrase of at most 6 words that names the result,
not the setup, for example "Capture new leads" or "Speed up customer
follow-ups". Not a sentence, and no punctuation at the end.
- Description: one plain sentence of at most 18 words about what the user
gets. State no trigger, schedule, recipient or other process detail the
user did not give. No Markdown, no n8n node names.
- Apps: name only apps the user selected or named, at most two in one
suggestion, and only where the name makes the suggestion clearer. With
no app or one app from the user, describe the result without app names.
Example option: "Capture new leads\nMove new enquiries into your sales
process so nothing gets missed."
3. Read the answer.
- A suggestion: its first line, the title, names the automation. Go to
step 4.
- `Show me other ideas`: repeat step 2 with three automations not shown in
this thread yet, after one sentence such as "Here are three more."
- Free text that describes a task: that is the user's request. Give it a
title as in step 2 and go to step 4.
- Free text that asks for a change, for example another app or a topic:
repeat step 2 with three automations that fit it.
- Free text that names no task and no change, for example "skip", "no" or
"later", or `answered: false`: do not build. On an onboarding thread call
`leave-onboarding`, then write a one-sentence reply, for example "No
problem. Explore the app and tell me when you want to automate something."
4. Ask before you build. Write no text, and make ONE `ask-user` call with
`questions` only: a `single` question "Nice! Should I build this now?",
`required: true`, with the options `Yes, build it` and `Not now`. Leave
`introMessage` and `freeTextLabel` out: the card's own "Something else"
row lets the user ask for another automation. Read the answer:
- `Yes, build it`: go to step 5.
- `Not now`: do not build, and stay in the flow. Repeat step 2 with the
same three automations, after one sentence such as "No problem. Pick
another one, or ask for other ideas."
- `answered: false`: do not build. Reply as for a skip in step 3.
- Free text: read it as in step 3.
5. Write exactly one line before the first tool call, `Building <title> now.`,
and no other text until the `build-workflow` result. Load `workflow-builder`
and build the automation with the user's apps the normal way, then follow
`postBuildFlow.instructions` from the result. Ask about an app only for a
step the user's list does not cover, one question per turn.
## Rules
- One `ask-user` call per turn. Keep every message to four sentences or fewer.
- A build needs `Yes, build it` from the user on the step 4 card, for a
picked suggestion and for a task in the user's words alike. Never start one
after a skip, a dismissal, or free text that names nothing.
- No apps given: suggest what people on the team most often automate, and
name no app.
- Talk about the automation, never about the mechanics. Do not say
"validate", "workspace" or a file name in a message. Say what is being
built and what the user gets, for example "Building the reminder now."
- If the user wants to stop the onboarding or asks for something unrelated,
drop this flow. On an onboarding thread, whenever you stop asking the card
questions, call `leave-onboarding` first and then reply. A reply that skips
the questions without that call leaves the user stuck in the flow.
- Never ask for credentials, keys, or passwords in chat.
- Reply in the language the user writes in.

View File

@ -1024,7 +1024,11 @@ describe('RunStateRegistry', () => {
createdAt: Date.now(),
});
registry.setSetupPanelEnabled('thread-1', true);
expect(registry.isSetupPanelEnabled('thread-1')).toBe(true);
expect(registry.isSetupPanelEnabled('thread-2')).toBe(false);
const result = registry.clearThread('thread-1');
expect(registry.isSetupPanelEnabled('thread-1')).toBe(false);
// Confirmations resolved
expect(resolve).toHaveBeenCalledWith({ approved: false });

View File

@ -157,6 +157,7 @@ export class RunStateRegistry<TUser = unknown> {
/** Build mode captured at user-run entry and reused by follow-up runs. */
private readonly threadBuildModes = new Map<string, InstanceAiBuildMode>();
private readonly threadSetupPanelEnabled = new Map<string, boolean>();
private readonly threadPromptSelections = new Map<
string,
{ version: string; metadata?: InstanceAiPromptConfiguration }
@ -521,6 +522,14 @@ export class RunStateRegistry<TUser = unknown> {
return this.threadBuildModes.get(threadId);
}
setSetupPanelEnabled(threadId: string, enabled: boolean): void {
this.threadSetupPanelEnabled.set(threadId, enabled);
}
isSetupPanelEnabled(threadId: string): boolean {
return this.threadSetupPanelEnabled.get(threadId) === true;
}
setPromptVersion(threadId: string, version: string | undefined): void {
if (version === undefined) this.threadPromptSelections.delete(threadId);
else this.threadPromptSelections.set(threadId, { version });
@ -690,6 +699,7 @@ export class RunStateRegistry<TUser = unknown> {
this.threadTimeZones.delete(threadId);
this.threadComputerUseChannels.delete(threadId);
this.threadBuildModes.delete(threadId);
this.threadSetupPanelEnabled.delete(threadId);
this.threadPromptSelections.delete(threadId);
const groupId = this.threadMessageGroupId.get(threadId);
@ -741,6 +751,7 @@ export class RunStateRegistry<TUser = unknown> {
this.threadTimeZones.clear();
this.threadComputerUseChannels.clear();
this.threadBuildModes.clear();
this.threadSetupPanelEnabled.clear();
this.threadPromptSelections.clear();
this.threadMessageGroupId.clear();
this.runIdsByMessageGroup.clear();

View File

@ -1,6 +1,7 @@
import { APPROVAL_SUSPEND_SCHEMA, type StreamChunk } from '@n8n/agents';
import {
instanceAiApprovalDetailsSchema,
instanceAiQuestionSchema,
credentialRequestSchema,
workflowSetupNodeSchema,
taskListSchema,
@ -13,19 +14,12 @@ import {
} from '@n8n/api-types';
import type { InstanceAiEvent } from '@n8n/api-types';
import { isRecord } from '@n8n/utils/is-record';
import { z } from 'zod';
import type { z } from 'zod';
import { isQuotaExhaustedError, QUOTA_EXHAUSTED_ERROR_CODE } from '../utils/quota-error';
export { isQuotaExhaustedError, QUOTA_EXHAUSTED_ERROR_CODE } from '../utils/quota-error';
const questionItemSchema = z.object({
id: z.string(),
question: z.string(),
type: z.enum(['single', 'multi', 'text']),
options: z.array(z.string()).optional(),
});
function getArrayProperty(record: Record<string, unknown>, key: string): unknown[] | undefined {
const value = record[key];
return Array.isArray(value) ? value : undefined;
@ -334,7 +328,7 @@ function mapSuspendedChunk(
const requireUserSelection = suspendPayload.requireUserSelection === true;
const projectId = presentString(suspendPayload.projectId);
const inputType = parseInputType(suspendPayload.inputType);
const questions = parseSchemaArray(suspendPayload.questions, questionItemSchema);
const questions = parseSchemaArray(suspendPayload.questions, instanceAiQuestionSchema);
const introMessage = presentString(suspendPayload.introMessage);
const tasks = parseSchemaRecord(suspendPayload.tasks, taskListSchema);
const planItems = parseSchemaArray(suspendPayload.planItems, plannedTaskArgSchema);

View File

@ -4,7 +4,13 @@ import type { z } from 'zod';
import { executeTool } from '../../__tests__/tool-test-utils';
import type { InstanceAiContext, ExecutionResult } from '../../types';
import type { VerificationClaim } from '../../workflow-loop/workflow-loop-state';
import { createExecutionsTool } from '../executions.tool';
import { recordLiveRunVerification } from '../orchestration/verification/record-live-run';
vi.mock('../orchestration/verification/record-live-run', () => ({
recordLiveRunVerification: vi.fn().mockResolvedValue(undefined),
}));
// ── Mock helpers ───────────────────────────────────────────────────────────────
@ -461,6 +467,63 @@ describe('executions tool', () => {
);
});
describe('live run verification', () => {
const runResult = { executionId: 'exec-1', status: 'success' as const };
function createAllowedContext() {
const context = createMockContext({
permissions: { runWorkflow: 'always_allow' },
aiCreatedWorkflowIds: new Set(['wf-1']),
});
(context.executionService.run as Mock).mockResolvedValue(runResult);
return context;
}
it('returns the recorded claim with the run result', async () => {
const context = createAllowedContext();
const claim: VerificationClaim = {
level: 'verified',
plannedNodeCount: 1,
reachedNodeCount: 1,
nodesNotReached: [],
simulatedNodes: [],
pinnedNodes: [],
unprovenTargets: [],
publishReady: true,
liveTestRecommended: false,
};
vi.mocked(recordLiveRunVerification).mockResolvedValueOnce(claim);
const tool = createExecutionsTool(context);
const result = await executeTool(
tool,
{ action: 'run' as const, workflowId: 'wf-1', triggerNodeName: 'Every Morning' },
createAgentCtx() as never,
);
expect(result).toEqual({ ...runResult, verificationClaim: claim });
expect(recordLiveRunVerification).toHaveBeenCalledWith({
context,
workflowId: 'wf-1',
triggerNodeName: 'Every Morning',
result: runResult,
});
});
it('returns the plain run result when no claim was recorded', async () => {
const context = createAllowedContext();
const tool = createExecutionsTool(context);
const result = await executeTool(
tool,
{ action: 'run' as const, workflowId: 'wf-1' },
createAgentCtx() as never,
);
expect(result).toEqual(runResult);
});
});
describe('session grant (always allow)', () => {
it('runs without HITL when the workflow has a session grant', async () => {
const context = createMockContext({

View File

@ -17,6 +17,7 @@ import { z } from 'zod';
import { sanitizeInputSchema } from '../agent/sanitize-mcp-schemas';
import type { InstanceAiContext } from '../types';
import { approvalSummarySchema, formatApprovalMessage } from './approval-copy';
import { recordLiveRunVerification } from './orchestration/verification/record-live-run';
// ── Constants ──────────────────────────────────────────────────────────────
@ -416,11 +417,20 @@ async function handleRun(
}
// Approved or always_allow — execute
return await context.executionService.run(workflowId, input.inputData, {
const result = await context.executionService.run(workflowId, input.inputData, {
timeout: input.timeout,
triggerNodeName: input.triggerNodeName,
abortSignal,
});
// A live test is the evidence verification cannot produce itself. Record it
// so the publish gate stops disclosing simulations that this run replaced.
const verificationClaim = await recordLiveRunVerification({
context,
workflowId,
triggerNodeName: input.triggerNodeName,
result,
});
return verificationClaim ? { ...result, verificationClaim } : result;
}
/**

View File

@ -76,6 +76,10 @@ const loadResearchTool = lazyMod(
const loadAskUserTool = lazyMod(
() => require('./shared/ask-user.tool') as typeof import('./shared/ask-user.tool'),
);
const loadLeaveOnboardingTool = lazyMod(
() =>
require('./shared/leave-onboarding.tool') as typeof import('./shared/leave-onboarding.tool'),
);
const loadTaskControlTool = lazyMod(
() => require('./task-control.tool') as typeof import('./task-control.tool'),
);
@ -116,6 +120,15 @@ function getOrchestratorDomainToolFactories(
],
];
// Onboarding threads only: the tool ends the flow the host seeded, and the frontend restores
// the chat chrome when it sees the call.
if (context.onboardingThread) {
tools.push([
DOMAIN_TOOL_IDS.LEAVE_ONBOARDING,
() => loadLeaveOnboardingTool().createLeaveOnboardingTool(),
]);
}
// eval-config is flag-gated: the adapter only wires evaluationConfigService
// when `088_config_evaluations` is on, so presence = expose the tool.
if (context.evaluationConfigService) {

View File

@ -0,0 +1,223 @@
import type { InstanceAiContext } from '../../../../types';
import type { WorkflowBuildOutcome } from '../../../../workflow-loop/workflow-loop-state';
import { recordLiveRunVerification } from '../record-live-run';
import type { ExecutionRunResult } from '../types';
function makeOutcome(overrides: Partial<WorkflowBuildOutcome> = {}): WorkflowBuildOutcome {
return {
workItemId: 'wi_1',
taskId: 'task_1',
workflowId: 'wf_1',
submitted: true,
triggerType: 'trigger_only',
needsUserInput: false,
summary: 'Built',
nodeSimulationPlan: [
{
nodeName: 'Send Telegram Message',
verdict: 'simulate',
reason: 'Sends a message to a Telegram chat.',
confidence: 'high',
source: 'deterministic',
},
],
verification: {
attempted: true,
success: true,
status: 'success',
claim: {
level: 'partial',
plannedNodeCount: 1,
reachedNodeCount: 1,
nodesNotReached: [],
simulatedNodes: [
{ nodeName: 'Send Telegram Message', reason: 'Sends a message to a Telegram chat.' },
],
pinnedNodes: [],
unprovenTargets: [],
publishReady: false,
liveTestRecommended: true,
},
},
...overrides,
};
}
function makeRunResult(overrides: Partial<ExecutionRunResult> = {}): ExecutionRunResult {
return {
executionId: 'exec-2',
status: 'success',
data: { 'Every Morning': [{}], 'Send Telegram Message': [{ ok: true }] },
executedNodeNames: ['Every Morning', 'Send Telegram Message'],
workflowVersionId: 'version-2',
...overrides,
};
}
function createContext({
outcome = makeOutcome(),
}: { outcome?: WorkflowBuildOutcome | null } = {}) {
const workflowTaskService = {
getLatestBuildOutcomeForWorkflow: vi.fn().mockResolvedValue(outcome ?? undefined),
recordVerification: vi.fn(
async (_workItemId: string, verification: { claim: unknown }) =>
await Promise.resolve(verification.claim),
),
};
const context = {
userId: 'user-1',
runId: 'run-2',
workflowService: {
getWorkflowHead: vi
.fn()
.mockResolvedValue({ versionId: 'version-2', activeVersionId: null, updatedAt: 0 }),
},
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
workflowBuildContext: {
threadId: 'thread-1',
runId: 'run-2',
taskId: 'task_1',
workItemId: 'wi_1',
workflowTaskService,
},
} as unknown as InstanceAiContext;
return { context, workflowTaskService };
}
describe('recordLiveRunVerification', () => {
it('records a verified claim for the executed version when the run covers every planned node', async () => {
const { context, workflowTaskService } = createContext();
const claim = await recordLiveRunVerification({
context,
workflowId: 'wf_1',
result: makeRunResult(),
});
expect(claim).toMatchObject({
level: 'verified',
publishReady: true,
simulatedNodes: [],
verifiedVersionId: 'version-2',
liveState: 'unpublished',
});
expect(workflowTaskService.recordVerification).toHaveBeenCalledWith(
'wi_1',
expect.objectContaining({
attempted: true,
success: true,
executionId: 'exec-2',
status: 'success',
claim: expect.objectContaining({ level: 'verified' }),
evidence: expect.objectContaining({
nodesExecuted: ['Every Morning', 'Send Telegram Message'],
}),
}),
);
});
it.each([
['used injected trigger input', { injectedTriggerNodeName: 'Every Morning' }],
['read saved pinned data', { workflowPinnedNodeNames: ['Send Telegram Message'] }],
[
'did not reach a planned node',
{ executedNodeNames: ['Every Morning'], data: { 'Every Morning': [{}] } },
],
['failed', { status: 'error' as const, error: 'Bad Request: chat not found' }],
[
'reported a node error',
{ nodeErrors: [{ nodeName: 'Send Telegram Message', message: 'Bad Request' }] },
],
['ran an unknown version', { workflowVersionId: undefined }],
])('keeps the stored claim when the run %s', async (_label, overrides) => {
const { context, workflowTaskService } = createContext();
const claim = await recordLiveRunVerification({
context,
workflowId: 'wf_1',
result: makeRunResult(overrides),
});
expect(claim).toBeUndefined();
expect(workflowTaskService.recordVerification).not.toHaveBeenCalled();
});
it.each([
['there is no build outcome', null],
['the build outcome has no simulation plan', makeOutcome({ nodeSimulationPlan: undefined })],
[
'a verification is still running',
makeOutcome({ verification: { attempted: false, success: false, status: 'running' } }),
],
])('does not record when %s', async (_label, outcome) => {
const { context, workflowTaskService } = createContext({ outcome });
const claim = await recordLiveRunVerification({
context,
workflowId: 'wf_1',
result: makeRunResult(),
});
expect(claim).toBeUndefined();
expect(workflowTaskService.recordVerification).not.toHaveBeenCalled();
});
it('does not record when the draft was saved during the run', async () => {
const { context, workflowTaskService } = createContext();
vi.mocked(context.workflowService.getWorkflowHead).mockResolvedValueOnce({
versionId: 'version-3',
activeVersionId: null,
updatedAt: 0,
});
const claim = await recordLiveRunVerification({
context,
workflowId: 'wf_1',
result: makeRunResult(),
});
expect(claim).toBeUndefined();
expect(workflowTaskService.recordVerification).not.toHaveBeenCalled();
});
it('does not record outside a workflow build context', async () => {
const { context } = createContext();
context.workflowBuildContext = undefined;
const claim = await recordLiveRunVerification({
context,
workflowId: 'wf_1',
result: makeRunResult(),
});
expect(claim).toBeUndefined();
});
it.each([
['reading the build outcome', 'outcome'],
['reading the workflow head', 'head'],
['recording the claim', 'record'],
] as const)('logs and returns undefined when %s fails', async (_label, failingCall) => {
const { context, workflowTaskService } = createContext();
const error = new Error('storage down');
if (failingCall === 'outcome') {
workflowTaskService.getLatestBuildOutcomeForWorkflow.mockRejectedValueOnce(error);
} else if (failingCall === 'head') {
vi.mocked(context.workflowService.getWorkflowHead).mockRejectedValueOnce(error);
} else {
workflowTaskService.recordVerification.mockRejectedValueOnce(error);
}
const claim = await recordLiveRunVerification({
context,
workflowId: 'wf_1',
result: makeRunResult(),
});
expect(claim).toBeUndefined();
expect(context.logger.warn).toHaveBeenCalledWith(
'Failed to record a live run as verification evidence',
{ workflowId: 'wf_1', error: 'storage down' },
);
});
});

View File

@ -0,0 +1,32 @@
import type { VerificationPublishState } from './claim';
import type { Logger } from '../../../logger';
import type { InstanceAiWorkflowService } from '../../../types';
/**
* Version pair behind `claim.liveState`. The executed version has to come from
* the execution record: the workflow head moves when anybody saves, so
* substituting it would let the claim describe a version this run never ran.
* Without that record there is no publish state — an unknown run version must
* not become `live-current`, which reads as "production is proven".
*/
export async function resolvePublishState(args: {
workflowService: InstanceAiWorkflowService;
workflowId: string;
executedVersionId: string | null | undefined;
logger: Logger;
}): Promise<VerificationPublishState | undefined> {
const { workflowService, workflowId, executedVersionId, logger } = args;
if (!executedVersionId) return undefined;
try {
const head = await workflowService.getWorkflowHead(workflowId);
return { activeVersionId: head.activeVersionId, draftVersionId: executedVersionId };
} catch (error) {
logger.warn('Failed to read publish state for the verification claim', {
workflowId,
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
}

View File

@ -0,0 +1,84 @@
/**
* Live runs as verification evidence.
*
* `verify-built-workflow` always simulates nodes with side effects, so the live
* test it recommends runs through `executions(action="run")`. Without this, the
* stored claim keeps the simulated verdict and the publish gate warns about a
* path the user just saw succeed.
*/
import { analyzeVerificationResult, countProducedOutputRows } from './analyze-result';
import { deriveVerificationClaim } from './claim';
import type { ExecutionRunResult } from './types';
import type { InstanceAiContext } from '../../../types';
import type { VerificationClaim } from '../../../workflow-loop/workflow-loop-state';
/**
* Record a live run on the workflow's latest build outcome when it proves the
* whole workflow. Returns the stored claim, or undefined when nothing changed.
*
* Only a `verified` run is recorded: a live run can raise the verdict but never
* lower it. A narrow or failed ad hoc run (one branch, injected trigger input,
* saved pins) says less than the verification it would replace.
*/
export async function recordLiveRunVerification(args: {
context: InstanceAiContext;
workflowId: string;
triggerNodeName?: string;
result: ExecutionRunResult;
}): Promise<VerificationClaim | undefined> {
const { context, workflowId, triggerNodeName, result } = args;
const buildContext = context.workflowBuildContext;
const workflowTaskService = buildContext?.workflowTaskService;
if (!buildContext || !workflowTaskService || result.status !== 'success') return undefined;
try {
const outcome = await workflowTaskService.getLatestBuildOutcomeForWorkflow(workflowId);
const plan = outcome?.nodeSimulationPlan;
// A verification in flight owns the record; do not race its write.
if (!outcome || !plan || outcome.verification?.status === 'running') return undefined;
// A save during the run moves the draft past the version that ran, and
// this run proves nothing about the newer draft.
const executedVersionId = result.workflowVersionId;
const head = await context.workflowService.getWorkflowHead(workflowId);
if (!executedVersionId || head.versionId !== executedVersionId) return undefined;
// No planned simulations: this run used no verification pin data. Saved
// pins and injected trigger input still count as simulated in the analysis.
const analysis = analyzeVerificationResult({
result,
buildOutcome: outcome,
simulatedNodes: [],
stateBefore: undefined,
runId: context.runId ?? buildContext.runId,
triggerNodeName,
});
const claim = deriveVerificationClaim({
analysis,
plannedNodeCount: plan.length,
publishState: { activeVersionId: head.activeVersionId, draftVersionId: executedVersionId },
});
if (claim.level !== 'verified') return undefined;
return await workflowTaskService.recordVerification(outcome.workItemId, {
attempted: true,
success: true,
executionId: result.executionId || undefined,
status: result.status,
claim,
evidence: {
nodesExecuted: analysis.nodesExecuted,
producedOutputRows: countProducedOutputRows(result.data),
},
verifiedAt: new Date().toISOString(),
});
} catch (error) {
// Advisory: the run itself succeeded, and the stored verdict stays as it was.
context.logger.warn('Failed to record a live run as verification evidence', {
workflowId,
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
}

View File

@ -10,16 +10,16 @@ import { Tool } from '@n8n/agents';
import { isTriggerNodeType } from 'n8n-workflow';
import { z } from 'zod';
import type { InstanceAiWorkflowService, OrchestrationContext } from '../../types';
import type { OrchestrationContext } from '../../types';
import { analyzeVerificationResult, buildNodePreviews } from './verification/analyze-result';
import { deriveVerificationClaim } from './verification/claim';
import type { VerificationPublishState } from './verification/claim';
import {
handleMissingSimulationPlan,
handleBlockedVerification,
persistVerificationOutcome,
} from './verification/finalize-result';
import { prepareVerificationRun } from './verification/prepare-run';
import { resolvePublishState } from './verification/publish-state';
import { reconcileStaleCredentialPlan } from './verification/reconcile-plan';
import { resolveVerificationTarget } from './verification/resolve-target';
import {
@ -64,35 +64,6 @@ function formatLiveStateNote(claim: VerificationClaim | undefined): string | und
: fact;
}
/**
* Version pair behind `claim.liveState`. The executed version has to come from
* the execution record: the workflow head moves when anybody saves, so
* substituting it would let the claim describe a version this run never ran.
* Without that record there is no publish state — an unknown run version must
* not become `live-current`, which reads as "production is proven".
*/
async function resolvePublishState(args: {
workflowService: InstanceAiWorkflowService;
workflowId: string;
executedVersionId: string | null | undefined;
logger: OrchestrationContext['logger'];
}): Promise<VerificationPublishState | undefined> {
const { workflowService, workflowId, executedVersionId, logger } = args;
if (!executedVersionId) return undefined;
try {
const head = await workflowService.getWorkflowHead(workflowId);
return { activeVersionId: head.activeVersionId, draftVersionId: executedVersionId };
} catch (error) {
logger.warn('Failed to read publish state for the verification claim', {
workflowId,
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
}
export const verifyBuiltWorkflowInputSchema = z.object({
workItemId: z
.string()

View File

@ -18,6 +18,14 @@ const questionSchema = z.object({
.array(z.string())
.optional()
.describe('Suggested answers (required for single/multi, ignored for text)'),
required: z
.boolean()
.optional()
.describe('true hides Skip: the user must answer before they can submit'),
freeTextLabel: z
.string()
.optional()
.describe('Label of the built-in free-text row, "Something else" by default'),
});
const answerSchema = z.object({

View File

@ -0,0 +1,45 @@
import { Tool } from '@n8n/agents';
import { z } from 'zod';
import { DOMAIN_TOOL_IDS } from '../tool-ids';
/**
* The agent's explicit exit from the onboarding flow. The host reacts to the tool call
* itself and restores the normal chat chrome. The result tells the agent that the
* onboarding rules end here: the skill block stays in context for the rest of the turn,
* and its card-only questions would otherwise leak into the help that follows.
*/
export function createLeaveOnboardingTool() {
return (
new Tool(DOMAIN_TOOL_IDS.LEAVE_ONBOARDING)
.description(
'End the onboarding flow. Call it when the user wants to stop the onboarding, explore n8n ' +
'on their own, or asks for something unrelated to picking a first automation. After the call, ' +
'reply with one sentence that invites the user to explore the app and to come back with a ' +
'task, then help them with what they asked. Not needed before a build: a build ends the ' +
'onboarding by itself.',
)
.input(
z.object({
// ponytail: optional, so a call without a reason still ends the onboarding.
reason: z
.enum(['stop', 'explore', 'unrelated_request'])
.optional()
.describe(
'Why the onboarding ends: the user wants to stop, wants to explore n8n on their ' +
'own, or asked for something unrelated to picking a first automation.',
),
}),
)
.output(z.object({ left: z.boolean(), note: z.string() }))
// ponytail: the frontend persists the exit (thread metadata `onboardingLeft`) when it sees
// this call. Persist it here once the backend must know without a connected client.
.handler(async () => ({
left: true,
note:
'The onboarding rules no longer apply. Continue as on a normal thread. Ask for files ' +
'in plain text: a card cannot take attachments.',
}))
.build()
);
}

View File

@ -12,6 +12,7 @@ export const DOMAIN_TOOL_IDS = {
NODES: 'nodes',
SEARCH_MODELS: 'searchModels',
ASK_USER: 'ask-user',
LEAVE_ONBOARDING: 'leave-onboarding',
BUILD_WORKFLOW: 'build-workflow',
PARSE_FILE: 'parse-file',
AGENTS: 'agents',
@ -56,6 +57,9 @@ export const ORCHESTRATION_TOOL_NAMES = new Set<string>(Object.values(ORCHESTRAT
export const ALWAYS_LOADED_TOOL_NAMES = new Set<string>([
DOMAIN_TOOL_IDS.ASK_USER,
// Registered on onboarding threads only, so every other thread pays nothing for the entry.
// Deferring it would price the user's "let me out" at search_tools + load_tool.
DOMAIN_TOOL_IDS.LEAVE_ONBOARDING,
DOMAIN_TOOL_IDS.CREDENTIALS,
DOMAIN_TOOL_IDS.WORKFLOWS,
DOMAIN_TOOL_IDS.EXECUTIONS,

View File

@ -1486,6 +1486,8 @@ export interface InstanceAiContext {
* and rows carry `folder`. Absent or false keeps the pre-feature shape.
*/
folderExplorationEnabled?: boolean;
/** True while the thread runs the host-seeded onboarding flow. Presence gates `leave-onboarding`. */
onboardingThread?: boolean;
/**
* Host-resolved model for the current run (proxy-managed on cloud). Domain
* tools pass it as the fallback for utility LLM calls (simulation fixtures,

View File

@ -0,0 +1,53 @@
const { mockShowMessageBox } = vi.hoisted(() => ({ mockShowMessageBox: vi.fn() }));
vi.mock('electron', () => ({
app: { focus: vi.fn() },
dialog: { showMessageBox: mockShowMessageBox },
}));
import { confirmConnect } from './connect-confirmation';
describe('confirmConnect', () => {
beforeEach(() => {
mockShowMessageBox.mockReset();
});
it('returns false when the user cancels', async () => {
mockShowMessageBox.mockResolvedValue({ response: 1 });
await expect(confirmConnect('https://a.app.n8n.cloud/', null)).resolves.toBe(false);
});
it('returns true when the user clicks Connect', async () => {
mockShowMessageBox.mockResolvedValue({ response: 0 });
await expect(confirmConnect('https://a.app.n8n.cloud/', null)).resolves.toBe(true);
});
it('shows the target origin and defaults to Cancel', async () => {
mockShowMessageBox.mockResolvedValue({ response: 1 });
await confirmConnect('https://a.app.n8n.cloud/some/path', null);
expect(mockShowMessageBox).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Connect n8n Gateway to https://a.app.n8n.cloud?',
buttons: ['Connect', 'Cancel'],
defaultId: 1,
cancelId: 1,
}),
);
});
it('names the current connection that will end', async () => {
mockShowMessageBox.mockResolvedValue({ response: 1 });
await confirmConnect('https://b.app.n8n.cloud', 'https://a.app.n8n.cloud');
expect(mockShowMessageBox).toHaveBeenCalledWith(
expect.objectContaining({
detail: expect.stringContaining('https://a.app.n8n.cloud'),
}),
);
});
});

View File

@ -0,0 +1,23 @@
import { app, dialog } from 'electron';
/** Asks the local user to approve the target instance before the gateway connects to it. */
export async function confirmConnect(url: string, connectedUrl: string | null): Promise<boolean> {
const origin = new URL(url).origin;
const replaceNote = connectedUrl
? `\n\nThis ends the current connection to ${connectedUrl}.`
: '';
// Tray-only app: without focus the dialog can open behind the browser.
app.focus({ steal: true });
const { response } = await dialog.showMessageBox({
type: 'warning',
title: 'n8n Gateway',
message: `Connect n8n Gateway to ${origin}?`,
detail: `This n8n instance will be able to use the tools you enabled on this computer. Only continue if you started this connection from n8n.${replaceNote}`,
buttons: ['Connect', 'Cancel'],
defaultId: 1,
cancelId: 1,
noLink: true,
});
return response === 0;
}

View File

@ -1,4 +1,4 @@
import { assertConnectOriginAllowed } from './connect-origin';
import { assertConnectOriginAllowed, isAlreadyConnectedTo } from './connect-origin';
describe('assertConnectOriginAllowed', () => {
it('does not throw when origin matches an allowed pattern', () => {
@ -19,3 +19,26 @@ describe('assertConnectOriginAllowed', () => {
);
});
});
describe('isAlreadyConnectedTo', () => {
const connected = {
status: 'connected' as const,
connectedUrl: 'http://localhost:5678',
lastError: null,
};
it('returns true for the connected URL, with or without a trailing slash', () => {
expect(isAlreadyConnectedTo('http://localhost:5678', connected)).toBe(true);
expect(isAlreadyConnectedTo('http://localhost:5678/', connected)).toBe(true);
});
it('returns false for a different URL', () => {
expect(isAlreadyConnectedTo('http://127.0.0.1:5678', connected)).toBe(false);
});
it('returns false when the gateway is not connected', () => {
expect(isAlreadyConnectedTo('http://localhost:5678', { ...connected, status: 'error' })).toBe(
false,
);
});
});

View File

@ -1,5 +1,7 @@
import { isOriginAllowed } from '@n8n/computer-use/config';
import type { StatusSnapshot } from '../shared/types';
/**
* Throws if the normalized instance URL's origin is not allowed by the configured patterns.
* Call before constructing GatewayClient (deep link / IPC connect).
@ -17,3 +19,8 @@ export function assertConnectOriginAllowed(url: string, allowedOriginPatterns: s
);
}
}
/** True when the gateway already has a live connection to this instance URL. */
export function isAlreadyConnectedTo(url: string, snapshot: StatusSnapshot): boolean {
return snapshot.status === 'connected' && snapshot.connectedUrl === url.replace(/\/$/, '');
}

View File

@ -78,7 +78,7 @@ export class DaemonController extends EventEmitter<DaemonControllerEvents> {
await this.closeCurrentConnection({ preserveServerSession: true });
const store = await this.getSettingsStore();
const defaults = store.getDefaults(config);
const session = new GatewaySession(defaults, store);
const session = new GatewaySession(defaults, store, new URL(normalizedUrl).origin);
const client = new GatewayClient({
url: normalizedUrl,
apiKey,

View File

@ -0,0 +1,88 @@
type Handler = (...args: unknown[]) => void;
const { appHandlers, snapshot, confirmConnect, connect } = vi.hoisted(() => {
const snapshot = {
status: 'connected' as string,
connectedUrl: 'https://a.app.n8n.cloud' as string | null,
lastError: null,
};
return {
appHandlers: new Map<string, Handler>(),
snapshot,
confirmConnect: vi.fn(),
connect: vi.fn(async (_config: unknown, url: string) => {
snapshot.connectedUrl = url;
}),
};
});
vi.mock('electron', () => ({
app: {
requestSingleInstanceLock: () => true,
whenReady: async () => {},
on: (event: string, handler: Handler) => appHandlers.set(event, handler),
setAsDefaultProtocolClient: vi.fn(),
dock: { hide: vi.fn() },
quit: vi.fn(),
},
}));
vi.mock('@n8n/computer-use/logger', () => ({
configure: vi.fn(),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
vi.mock('./connect-confirmation', () => ({ confirmConnect }));
vi.mock('./daemon-controller', () => ({
DaemonController: vi.fn(function () {
return { getSnapshot: () => ({ ...snapshot }), connect, on: vi.fn() };
}),
}));
vi.mock('./settings-store', () => ({
SettingsStore: vi.fn(function () {
return {
get: () => ({ allowedOrigins: ['https://*.app.n8n.cloud'], logLevel: 'info' }),
toGatewayConfig: () => ({}),
};
}),
}));
vi.mock('./ipc-handlers', () => ({ registerIpcHandlers: vi.fn() }));
vi.mock('./settings-window', () => ({
openSettingsWindow: vi.fn(),
notifySettingsWindow: vi.fn(),
}));
vi.mock('./tray', () => ({ createTray: vi.fn() }));
import './index';
function openUrl(host: string): void {
appHandlers.get('open-url')?.(
{ preventDefault: vi.fn() },
`n8n-computer-use://connect?url=https://${host}&token=t`,
);
}
describe('deep-link handling', () => {
beforeAll(async () => {
await vi.waitFor(() => expect(appHandlers.has('open-url')).toBe(true));
});
it('confirms a queued link against the connection made by the link before it', async () => {
const answers: Array<(approved: boolean) => void> = [];
confirmConnect.mockImplementation(
async () => await new Promise<boolean>((resolve) => answers.push(resolve)),
);
openUrl('b.app.n8n.cloud');
openUrl('c.app.n8n.cloud');
await vi.waitFor(() => expect(answers).toHaveLength(1));
answers[0](true);
await vi.waitFor(() => expect(answers).toHaveLength(2));
answers[1](true);
await vi.waitFor(() => expect(connect).toHaveBeenCalledTimes(2));
expect(confirmConnect).toHaveBeenNthCalledWith(
2,
'https://c.app.n8n.cloud',
'https://b.app.n8n.cloud',
);
});
});

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