mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 08:46:56 +02:00
Compare commits
23 Commits
master
...
n8n@2.41.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
063ba759a7 | ||
|
|
e137e9741f | ||
|
|
1d655e3e19 | ||
|
|
a578ee7730 | ||
|
|
dc84b24c3b | ||
|
|
940f65e55e | ||
|
|
596d8aad39 | ||
|
|
afafb025ff | ||
|
|
fe3c5bceb7 | ||
|
|
af4fa9bbe3 | ||
|
|
7f7a8ac25b | ||
|
|
29afe517b3 | ||
|
|
2db9ab8dce | ||
|
|
56f9e97b8e | ||
|
|
2de7d47c2e | ||
|
|
b4e38b3cc7 | ||
|
|
665bcd9af7 | ||
|
|
07f2d3675a | ||
|
|
4c5ecc22fc | ||
|
|
397e4f2eb7 | ||
|
|
d8c49e2a3e | ||
|
|
69022dc863 | ||
|
|
b3196fb520 |
|
|
@ -43,7 +43,8 @@ frontend, and extensible node-based workflow engine.
|
||||||
comments keep the historical `n8nConnect` / `n8n credits` / AI Gateway names
|
comments keep the historical `n8nConnect` / `n8n credits` / AI Gateway names
|
||||||
- **Shared utilities:** before you hand-roll a utility (`isRecord`, secret or
|
- **Shared utilities:** before you hand-roll a utility (`isRecord`, secret or
|
||||||
PII redaction, JSON extraction from LLM output, Zod to JSON Schema, model-id
|
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),
|
implementation and use it: `@n8n/utils` (generic helpers, redaction),
|
||||||
`@n8n/ai-utilities` (AI- and LLM-specific helpers) and `n8n-workflow`
|
`@n8n/ai-utilities` (AI- and LLM-specific helpers) and `n8n-workflow`
|
||||||
(workflow graph and traversal). A new shared helper usually belongs in one of
|
(workflow graph and traversal). A new shared helper usually belongs in one of
|
||||||
|
|
|
||||||
53
CHANGELOG.md
53
CHANGELOG.md
|
|
@ -1,3 +1,56 @@
|
||||||
|
## [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)
|
# [2.41.0](https://github.com/n8n-io/n8n/compare/n8n@2.40.0...n8n@2.41.0) (2026-09-22)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-monorepo",
|
"name": "n8n-monorepo",
|
||||||
"version": "2.41.0",
|
"version": "2.41.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.0.0",
|
"node": ">=24.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/agents",
|
"name": "@n8n/agents",
|
||||||
"version": "0.26.0",
|
"version": "0.26.3",
|
||||||
"description": "AI agent SDK for n8n's code-first execution engine",
|
"description": "AI agent SDK for n8n's code-first execution engine",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.0.0"
|
"node": ">=24.0.0"
|
||||||
|
|
|
||||||
|
|
@ -67,29 +67,31 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"status": 200,
|
"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": {
|
"rawHeaders": {
|
||||||
"anthropic-ratelimit-input-tokens-limit": "10000000",
|
"anthropic-ratelimit-input-tokens-limit": "10000000",
|
||||||
"anthropic-ratelimit-input-tokens-remaining": "9999000",
|
"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-limit": "2000000",
|
||||||
"anthropic-ratelimit-output-tokens-remaining": "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-limit": "20000",
|
||||||
"anthropic-ratelimit-requests-remaining": "19999",
|
"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-limit": "12000000",
|
||||||
"anthropic-ratelimit-tokens-remaining": "11999000",
|
"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-cache-status": "DYNAMIC",
|
||||||
"cf-ray": "a38c9f485dd8bc7b-PRG",
|
"cf-ray": "a3f01ad7ea6cbbdd-FRA",
|
||||||
|
"connection": "keep-alive",
|
||||||
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
|
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
"date": "Thu, 10 Sep 2026 07:12:04 GMT",
|
"date": "Tue, 22 Sep 2026 08:57:55 GMT",
|
||||||
"request-id": "req_011CeuLqs6fxp1AkSuMSRWYb",
|
"request-id": "req_011CfJCd2nsYW1GzkhCoPFhw",
|
||||||
"server": "cloudflare",
|
"server": "cloudflare",
|
||||||
|
"server-timing": "x-originResponse;dur=667",
|
||||||
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
||||||
"traceresponse": "00-77c37e49f4c62f7623d8cc66c4f3a26a-6bf7325e3f3f69d3-01",
|
"traceresponse": "00-234e70eda24ff35d6c847bca1695098f-c9010c0bb2f634e5-01",
|
||||||
"vary": "Accept-Encoding",
|
"vary": "Accept-Encoding",
|
||||||
"x-robots-tag": "none"
|
"x-robots-tag": "none"
|
||||||
},
|
},
|
||||||
|
|
@ -105,7 +107,7 @@
|
||||||
"system": [
|
"system": [
|
||||||
{
|
{
|
||||||
"type": "text",
|
"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": [
|
"messages": [
|
||||||
|
|
@ -121,9 +123,13 @@
|
||||||
{
|
{
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": [
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "I'll load the engineering-status skill first, then use it to format the status token."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "tool_use",
|
"type": "tool_use",
|
||||||
"id": "toolu_01Krds9KGve8gJNTNa9jn6N7",
|
"id": "toolu_019R6LssCYfGrz8ddVcPvYQC",
|
||||||
"name": "load_skill",
|
"name": "load_skill",
|
||||||
"input": {
|
"input": {
|
||||||
"skillId": "engineering-status"
|
"skillId": "engineering-status"
|
||||||
|
|
@ -139,8 +145,17 @@
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "tool_result",
|
"type": "tool_result",
|
||||||
"tool_use_id": "toolu_01Krds9KGve8gJNTNa9jn6N7",
|
"tool_use_id": "toolu_019R6LssCYfGrz8ddVcPvYQC",
|
||||||
"content": "{\"skillId\":\"engineering-status\",\"active\":true}"
|
"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,
|
"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": {
|
"rawHeaders": {
|
||||||
"anthropic-ratelimit-input-tokens-limit": "10000000",
|
"anthropic-ratelimit-input-tokens-limit": "10000000",
|
||||||
"anthropic-ratelimit-input-tokens-remaining": "9999000",
|
"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-limit": "2000000",
|
||||||
"anthropic-ratelimit-output-tokens-remaining": "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-limit": "20000",
|
||||||
"anthropic-ratelimit-requests-remaining": "19999",
|
"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-limit": "12000000",
|
||||||
"anthropic-ratelimit-tokens-remaining": "11999000",
|
"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-cache-status": "DYNAMIC",
|
||||||
"cf-ray": "a38c9f4f38babc7b-PRG",
|
"cf-ray": "a3f01add2e2bbbdd-FRA",
|
||||||
|
"connection": "keep-alive",
|
||||||
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
|
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
"date": "Thu, 10 Sep 2026 07:12:06 GMT",
|
"date": "Tue, 22 Sep 2026 08:57:57 GMT",
|
||||||
"request-id": "req_011CeuLqwpw3fPBk6xt6nrrm",
|
"request-id": "req_011CfJCd6Vcovu2SLcUWAPC4",
|
||||||
"server": "cloudflare",
|
"server": "cloudflare",
|
||||||
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
||||||
"traceresponse": "00-d17795404368021a8e502b8755cc4b35-c750063766d5eb87-01",
|
"traceresponse": "00-e98096358e475d79d338f7d1cf690dbb-aa6a8d0521eacc76-01",
|
||||||
"vary": "Accept-Encoding",
|
"vary": "Accept-Encoding",
|
||||||
"x-robots-tag": "none"
|
"x-robots-tag": "none"
|
||||||
},
|
},
|
||||||
|
|
@ -227,7 +243,7 @@
|
||||||
"system": [
|
"system": [
|
||||||
{
|
{
|
||||||
"type": "text",
|
"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": [
|
"messages": [
|
||||||
|
|
@ -243,9 +259,13 @@
|
||||||
{
|
{
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": [
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "I'll load the engineering-status skill first, then use it to format the status token."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "tool_use",
|
"type": "tool_use",
|
||||||
"id": "toolu_01Krds9KGve8gJNTNa9jn6N7",
|
"id": "toolu_019R6LssCYfGrz8ddVcPvYQC",
|
||||||
"name": "load_skill",
|
"name": "load_skill",
|
||||||
"input": {
|
"input": {
|
||||||
"skillId": "engineering-status"
|
"skillId": "engineering-status"
|
||||||
|
|
@ -261,8 +281,17 @@
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "tool_result",
|
"type": "tool_result",
|
||||||
"tool_use_id": "toolu_01Krds9KGve8gJNTNa9jn6N7",
|
"tool_use_id": "toolu_019R6LssCYfGrz8ddVcPvYQC",
|
||||||
"content": "{\"skillId\":\"engineering-status\",\"active\":true}"
|
"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": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "text",
|
"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",
|
"type": "tool_use",
|
||||||
"id": "toolu_01CKRsNPiekmX9TWf3ooQdHU",
|
"id": "toolu_011UF3b8wMRddXy2WYPGRYZe",
|
||||||
"name": "format_status",
|
"name": "format_status",
|
||||||
"input": {
|
"input": {
|
||||||
"status": "waiting_on_vendor_info"
|
"status": "waiting_on_vendor_info"
|
||||||
|
|
@ -291,7 +320,7 @@
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "tool_result",
|
"type": "tool_result",
|
||||||
"tool_use_id": "toolu_01CKRsNPiekmX9TWf3ooQdHU",
|
"tool_use_id": "toolu_011UF3b8wMRddXy2WYPGRYZe",
|
||||||
"content": "{\"formatted\":\"STATUS_FORMATTED_BRAVO: WAITING_ON_VENDOR_INFO\"}"
|
"content": "{\"formatted\":\"STATUS_FORMATTED_BRAVO: WAITING_ON_VENDOR_INFO\"}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -341,29 +370,30 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"status": 200,
|
"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": {
|
"rawHeaders": {
|
||||||
"anthropic-ratelimit-input-tokens-limit": "10000000",
|
"anthropic-ratelimit-input-tokens-limit": "10000000",
|
||||||
"anthropic-ratelimit-input-tokens-remaining": "9999000",
|
"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-limit": "2000000",
|
||||||
"anthropic-ratelimit-output-tokens-remaining": "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-limit": "20000",
|
||||||
"anthropic-ratelimit-requests-remaining": "19999",
|
"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-limit": "12000000",
|
||||||
"anthropic-ratelimit-tokens-remaining": "11999000",
|
"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-cache-status": "DYNAMIC",
|
||||||
"cf-ray": "a38c9f561a95bc7b-PRG",
|
"cf-ray": "a3f01ae61be4bbdd-FRA",
|
||||||
|
"connection": "keep-alive",
|
||||||
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
|
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
"date": "Thu, 10 Sep 2026 07:12:07 GMT",
|
"date": "Tue, 22 Sep 2026 08:57:58 GMT",
|
||||||
"request-id": "req_011CeuLr2Whz42pkAm7AzGhs",
|
"request-id": "req_011CfJCdCcii1dW1VhpqXjuj",
|
||||||
"server": "cloudflare",
|
"server": "cloudflare",
|
||||||
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
||||||
"traceresponse": "00-80319e2d05f81fe007810b9d0f60ed01-c7956b0a3a60554e-01",
|
"traceresponse": "00-4d1157be05f9b0ab551736730e6ba0e2-b17f94e742ef7628-01",
|
||||||
"vary": "Accept-Encoding",
|
"vary": "Accept-Encoding",
|
||||||
"x-robots-tag": "none"
|
"x-robots-tag": "none"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -51,29 +51,30 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"status": 200,
|
"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": {
|
"rawHeaders": {
|
||||||
"anthropic-ratelimit-input-tokens-limit": "10000000",
|
"anthropic-ratelimit-input-tokens-limit": "10000000",
|
||||||
"anthropic-ratelimit-input-tokens-remaining": "9999000",
|
"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-limit": "2000000",
|
||||||
"anthropic-ratelimit-output-tokens-remaining": "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-limit": "20000",
|
||||||
"anthropic-ratelimit-requests-remaining": "19999",
|
"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-limit": "12000000",
|
||||||
"anthropic-ratelimit-tokens-remaining": "11999000",
|
"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-cache-status": "DYNAMIC",
|
||||||
"cf-ray": "a38c9f3bd9d2bc7b-PRG",
|
"cf-ray": "a3f01acbda36bbdd-FRA",
|
||||||
|
"connection": "keep-alive",
|
||||||
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
|
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
"date": "Thu, 10 Sep 2026 07:12:02 GMT",
|
"date": "Tue, 22 Sep 2026 08:57:54 GMT",
|
||||||
"request-id": "req_011CeuLqiWV7tDxhVGbXmda8",
|
"request-id": "req_011CfJCcteyXnhh5BmvQdDTp",
|
||||||
"server": "cloudflare",
|
"server": "cloudflare",
|
||||||
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
||||||
"traceresponse": "00-7cf7d7c3cfa9378a22214da3c62a1ea0-1ad2b8e7d42a1569-01",
|
"traceresponse": "00-66ff12c75cd9a23f50952244bb3cba2c-133f511c645be506-01",
|
||||||
"vary": "Accept-Encoding",
|
"vary": "Accept-Encoding",
|
||||||
"x-robots-tag": "none"
|
"x-robots-tag": "none"
|
||||||
},
|
},
|
||||||
|
|
@ -89,7 +90,7 @@
|
||||||
"system": [
|
"system": [
|
||||||
{
|
{
|
||||||
"type": "text",
|
"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": [
|
"messages": [
|
||||||
|
|
@ -107,7 +108,7 @@
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "tool_use",
|
"type": "tool_use",
|
||||||
"id": "toolu_01Eq5aFLJhGriLQF4UBg7tyT",
|
"id": "toolu_01Rhg9DwH7n3MzSh9iz6TFzh",
|
||||||
"name": "load_skill",
|
"name": "load_skill",
|
||||||
"input": {
|
"input": {
|
||||||
"skillId": "summary-marker"
|
"skillId": "summary-marker"
|
||||||
|
|
@ -123,8 +124,17 @@
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "tool_result",
|
"type": "tool_result",
|
||||||
"tool_use_id": "toolu_01Eq5aFLJhGriLQF4UBg7tyT",
|
"tool_use_id": "toolu_01Rhg9DwH7n3MzSh9iz6TFzh",
|
||||||
"content": "{\"skillId\":\"summary-marker\",\"active\":true}"
|
"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,
|
"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": {
|
"rawHeaders": {
|
||||||
"anthropic-ratelimit-input-tokens-limit": "10000000",
|
"anthropic-ratelimit-input-tokens-limit": "10000000",
|
||||||
"anthropic-ratelimit-input-tokens-remaining": "9999000",
|
"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-limit": "2000000",
|
||||||
"anthropic-ratelimit-output-tokens-remaining": "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-limit": "20000",
|
||||||
"anthropic-ratelimit-requests-remaining": "19999",
|
"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-limit": "12000000",
|
||||||
"anthropic-ratelimit-tokens-remaining": "11999000",
|
"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-cache-status": "DYNAMIC",
|
||||||
"cf-ray": "a38c9f417db9bc7b-PRG",
|
"cf-ray": "a3f01ad17e0abbdd-FRA",
|
||||||
|
"connection": "keep-alive",
|
||||||
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
|
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
"date": "Thu, 10 Sep 2026 07:12:03 GMT",
|
"date": "Tue, 22 Sep 2026 08:57:55 GMT",
|
||||||
"request-id": "req_011CeuLqnRP3vkdg9opi7gNC",
|
"request-id": "req_011CfJCcxUAmJjy1fhrHrwCP",
|
||||||
"server": "cloudflare",
|
"server": "cloudflare",
|
||||||
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
||||||
"traceresponse": "00-86beb3374d817d1d427aabf5c903ca34-f5a5fa52f1cb2789-01",
|
"traceresponse": "00-7063877d53ab0eae4686a22ce533a4ec-69260e5b962e00c7-01",
|
||||||
"vary": "Accept-Encoding",
|
"vary": "Accept-Encoding",
|
||||||
"x-robots-tag": "none"
|
"x-robots-tag": "none"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -53,32 +53,34 @@
|
||||||
},
|
},
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"response": [
|
"response": [
|
||||||
"1f8b0800000000000013c5554d8bdb3010bde757189d1348c2ba21be9585d2427ae852e8a12942b1a6896259f2ea23ec12f4df3b72e2f823d96d2f253e18346fa479ef69d0c00194cb9212ac655ba0d631e3469c39962547e25e2b2019e98164dcac497624a5e620312597cc7398ec9828fce461924ee6d3793a9b4e67982e783cc36ee974367b04bf328ba765fa8def3fa63f5e162bf3b9c09c7e250c182d6380592bb0ac8a6573ad1c9225d9cf5fa705130a0cc99497724cacd31535c0ac56bd9085670f2a875e90036e96b689f9468e509577d4e9021482cbf4010bb17c0734c7939dd08af633a60d8e307f0b6bf6c60250eda004c3244dcbebfc169ded86681813ed5d3734ff806ac01c440ed489e8048956716678745dfd061385d32d68849476941d5035dba0b3212449188de074fb6767e946eabcb8dd033752ea221c5e4e52bb78547adee7b496d45b68fa20ae3d76c222cfbf2f3f6ded53517eddad9ee71b2b166a85598a95719fd468a82d84947519d48d878668a99451eaa500170672570b3a7fadae4aa8ed4048426290bca51dbbd9b1f7b5d7293deda7484be974737becc44b72857e09bcd77ddd9e84843ed1bb9038ae892561e8d95da8c47bfec2d7b52f77a69225688b2fb1a1eecba364e67582bf8284ebc6be0b23306b125a36ef528aafecdf9e105d7508b50735d3e6a6aa1e483a2a7acf7ff7d5f9e721309828e1ff8d85c13b9e2ec2c0cb76e05ebbd8c5c8e512fe00c6a9d291c7070000"
|
"1f8b0800000000000013cd544d6fd34010bdf757587376243b6a5a756f10a440c501845490085a4dbdd364f17ad7ec47441bf9bfa3b5ebc63629f482826f3b6fc6f3dedbd9a11d69cf928a9cc30d71e7d1fa33811e59b2077f5f1330188190f667607ba88c20050c0a8541d06c8bb20cb3f3d96236cfe68b3ccb7248418af80fb7e1599e2fefae9762e5de5cac1e56e2cb03bef5b95c423ae9042958a362009d93cea38e6d0ba33d690fecebb7ee80529305a6835229386f6a6e099dd1a390a31f817441a3a0208f52b93e167a3952d7c1736f4ad20ed8d5e23c85028b2df1c2127a69341f67643d6e09c573585f1b1b50bda58a2c2abea87ecf3fa0f9768a362998e087a1f9450a8eec4e16c4bd8c4e40b44aa015d1757d47360ae71b32c0401bcf718752e1ad22689a2469cecea8bbfd4767f9ad3245797c068ea4b44d04fdeca40ef1a8f4b1ce1ba37870d4cf413c079ee5d757bbf2e6f2755d7d78f5193fdd6cdf7f9ccf439c048d55ac53060577a554aa6d53070f6cdf444b958a529f1a0869a9f0ada0f63ba8aaa5de4c64241083f09c7241cae39f95b72923e55de440a8bbb7efcee8a7e41aad97a8da2030809eeb98ef49b8ecd7d0b9dcfc0764de8935b0640dae65736232a1aad0decf0e9775623e15da92ec1a9a178d4f5c737f7bc3a61e509afcaddff947c58d401888192de1e1db7ff12a9eecf5e6df2de7c9365d5c0e6d3de2c431478718b445bf00ccc6474350070000"
|
||||||
],
|
],
|
||||||
"rawHeaders": {
|
"rawHeaders": {
|
||||||
"anthropic-ratelimit-input-tokens-limit": "10000000",
|
"anthropic-ratelimit-input-tokens-limit": "10000000",
|
||||||
"anthropic-ratelimit-input-tokens-remaining": "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-limit": "2000000",
|
||||||
"anthropic-ratelimit-output-tokens-remaining": "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-limit": "20000",
|
||||||
"anthropic-ratelimit-requests-remaining": "19999",
|
"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-limit": "12000000",
|
||||||
"anthropic-ratelimit-tokens-remaining": "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",
|
"cache-control": "no-cache",
|
||||||
"cf-cache-status": "DYNAMIC",
|
"cf-cache-status": "DYNAMIC",
|
||||||
"cf-ray": "a38c9f5c5a57bc7b-PRG",
|
"cf-ray": "a3f01aebf8c4bbdd-FRA",
|
||||||
|
"connection": "keep-alive",
|
||||||
"content-encoding": "gzip",
|
"content-encoding": "gzip",
|
||||||
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
|
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
|
||||||
"content-type": "text/event-stream; charset=utf-8",
|
"content-type": "text/event-stream; charset=utf-8",
|
||||||
"date": "Thu, 10 Sep 2026 07:12:08 GMT",
|
"date": "Tue, 22 Sep 2026 08:57:59 GMT",
|
||||||
"request-id": "req_011CeuLr6og4ahrpyfnPec45",
|
"request-id": "req_011CfJCdGeJvd8FiNh3bikfH",
|
||||||
"server": "cloudflare",
|
"server": "cloudflare",
|
||||||
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
||||||
"traceresponse": "00-db154d560f530266545ecc9baf7015e2-c0470cb196446bc0-01",
|
"traceresponse": "00-5b8f08c4391de2116ab063ddaa4cdd99-24fa357e98008a81-01",
|
||||||
|
"transfer-encoding": "chunked",
|
||||||
"vary": "Accept-Encoding",
|
"vary": "Accept-Encoding",
|
||||||
"x-robots-tag": "none"
|
"x-robots-tag": "none"
|
||||||
},
|
},
|
||||||
|
|
@ -94,7 +96,7 @@
|
||||||
"system": [
|
"system": [
|
||||||
{
|
{
|
||||||
"type": "text",
|
"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": [
|
"messages": [
|
||||||
|
|
@ -112,7 +114,7 @@
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "tool_use",
|
"type": "tool_use",
|
||||||
"id": "toolu_017ccT9FgsRkmMhLq2bsi7nL",
|
"id": "toolu_01J9vkV7BpmPAWaSVhLQ22uC",
|
||||||
"name": "load_skill",
|
"name": "load_skill",
|
||||||
"input": {
|
"input": {
|
||||||
"skillId": "summary-marker"
|
"skillId": "summary-marker"
|
||||||
|
|
@ -128,8 +130,17 @@
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "tool_result",
|
"type": "tool_result",
|
||||||
"tool_use_id": "toolu_017ccT9FgsRkmMhLq2bsi7nL",
|
"tool_use_id": "toolu_01J9vkV7BpmPAWaSVhLQ22uC",
|
||||||
"content": "{\"skillId\":\"summary-marker\",\"active\":true}"
|
"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,
|
"status": 200,
|
||||||
"response": [
|
"response": [
|
||||||
"1f8b0800000000000013c555cb8e1a3110bcf315968f082266b3ec616e681569a32551b428a76c6419bb99b1f0d8133f106885948fc817e64bd21e98c0f0489443122e305d6557755334b00213725281f7bc00e60377a12779e03979a1615303cd6907a483f699e62fb4b212345284e651c2b0e46a1987b7c3f1f0667433ce46a30ce94aa63b7cc14659760f71eaee65b40f73fbf1bd288a30dddc21a7ab840567752a70ef15ca9a242bac096896e69f3eef1eb832e0686ea2d603ea83ad9903eeade9943c7c896004748a12f0b0f66d2db6ed2853c7c0825d824130cb5edfa112172530815707650deb52462d8eb0bc86b5679302d42554e0b866e3ea9c7f40b3f214dd0ea88da1e30fbb01b7520258506912348d4a7227d3d4cd025c6a9c156011323630bec2aef91c27bbdd1242b6bd1eecbefefd68d95c5bb1bc1c820b944645c27ad7ea319e3add9f0bb04ec4e62da734e936af8378ad4c71a246682ad26b06317381ffda6043e918dc55bac67ed2f6f6fafdbdc17fae3c8b55c5dd26eff7c92c605e2aec9f602a4df0042a1548b05693c693fffef51b274bd8e0af5694dc285f918575043f4add1cf31b234a678d8d9e28f4e6b848f9f30419a47676a564e2a18c1e0655015900c839174b22a34bc84e6c0d2236b9fd4f3379f56c9ecdecf1ed74cade4d9e1edf3cb1c9f4c3c3e490a1b3289d86d4d6bf8bb1ad8f6c5db9b5dd7f171bed80f4a8b1ce42a260240bd119fa076be964c76dffe2a23ad92cb7d9f5191ffe0dcea77b8cd1c3c91f64c432c465060000"
|
"1f8b0800000000000013c598514fdb3010c7dff914d6bd4c4201356c4c53de109a36a09b26d8db98a2233e1aab8e9dda97b608e5bb4f4e5b9ab4c0b60792bc44b9bbe47ef78fedf84273329c8882bcc709a59ed1f18144c6443c023f940409749c106dae217984c24ad29040a6b1927494a39a56471f8e4e8f4e4627a7f1681443044a8667f8493a8ae3f3fbcb73399ebae5a74b9fcf5c7c15cfe4178876324104ceea6040ef956734216d660d9361487efd5e5da032e4203195d61178b665ea08bd351d93a7594526a38e5112a3d27e63ab36e52853569cb29d92f190c4f1e87d04196639a5992364654dda0d196dfc8e50bee4dbdc1b32509953410e757a5aecc76fbd71beebad23b01577f822f0e4e62aa3945550028254129d0caa9b7b72a1f0744216123096539ca3d278a709ea5aac8ffae080566360ad6f7aa76d367d7e243c13d2a492b45cd5dbf68772d7f7312d4360734a00ea76da5299c94e1e01c1082fa149d28cafa335211db495a58bf414b606fb6e1750bf26ca9b651617efb416a5b37325a961e81f81f355e6f6d13f85af8a02dd835828cebb388328221ccd2ae548b658fa0729d04dc925b7e6d61c1e6e487ac7b859bd9a648b30941e37ec080b6526022764d80b2a140bb6568b86c41f0f358f7fe6e4879ac0e868b0b1b1d6bd3d30fa6798902187bc9dacfd23dc3dac87e47008b60c22341fd0810894197a81f04f0b44d8a146629193a36681189a4c9939d4fde7b559b305f502cd80b3c391af34fbb05289cc1645655416e6ab400f75ff3852f9cc11ef6d7b865a3d8543cec909ced18805aad6241e84eadebaf09a4abdafd100122d29ab9a36aa1e8ae1b8dbb9f49435ecfa6eae2ec6e3f4dbd9f5d5e7ebf46cfce3ebd9bf7c6943affbb716ce962da01777ff9b5f00cf16d97142aba84e4f0e6464ca9533f01f9df94e9b5fbf61afbed35c7f3cdd5b25b7ff41f6656dfb605fc63f07b52da767110000"
|
||||||
],
|
],
|
||||||
"rawHeaders": {
|
"rawHeaders": {
|
||||||
"anthropic-ratelimit-input-tokens-limit": "10000000",
|
"anthropic-ratelimit-input-tokens-limit": "10000000",
|
||||||
"anthropic-ratelimit-input-tokens-remaining": "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-limit": "2000000",
|
||||||
"anthropic-ratelimit-output-tokens-remaining": "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-limit": "20000",
|
||||||
"anthropic-ratelimit-requests-remaining": "19999",
|
"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-limit": "12000000",
|
||||||
"anthropic-ratelimit-tokens-remaining": "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",
|
"cache-control": "no-cache",
|
||||||
"cf-cache-status": "DYNAMIC",
|
"cf-cache-status": "DYNAMIC",
|
||||||
"cf-ray": "a38c9f63ce57bc7b-PRG",
|
"cf-ray": "a3f01af1acffbbdd-FRA",
|
||||||
|
"connection": "keep-alive",
|
||||||
"content-encoding": "gzip",
|
"content-encoding": "gzip",
|
||||||
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
|
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
|
||||||
"content-type": "text/event-stream; charset=utf-8",
|
"content-type": "text/event-stream; charset=utf-8",
|
||||||
"date": "Thu, 10 Sep 2026 07:12:08 GMT",
|
"date": "Tue, 22 Sep 2026 08:57:59 GMT",
|
||||||
"request-id": "req_011CeuLrBuGK4E7uxpMM6NMq",
|
"request-id": "req_011CfJCdLVz3EfLeiVHjxihN",
|
||||||
"server": "cloudflare",
|
"server": "cloudflare",
|
||||||
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
||||||
"traceresponse": "00-ae497abd3e1ae1a20e06ccaeba57c754-f8de5c4ed0f0dac4-01",
|
"traceresponse": "00-0dcb0fbb57a4e0ed3374476fe9b454e3-795c47a4b1e4f26c-01",
|
||||||
|
"transfer-encoding": "chunked",
|
||||||
"vary": "Accept-Encoding",
|
"vary": "Accept-Encoding",
|
||||||
"x-robots-tag": "none"
|
"x-robots-tag": "none"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,19 @@ import { AgentMessageList } from '../model/message-list';
|
||||||
import { ActiveSkills } from '../skills/active-skills';
|
import { ActiveSkills } from '../skills/active-skills';
|
||||||
|
|
||||||
const scope = { threadId: 'thread-1', resourceId: 'user-1' };
|
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([
|
const source = createRuntimeSkillSource([
|
||||||
{
|
{
|
||||||
id: 'planning',
|
id: 'planning',
|
||||||
|
|
@ -87,12 +100,46 @@ describe('active skills', () => {
|
||||||
|
|
||||||
await active.restore(list, scope);
|
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([
|
await expect(memory.skillState.load({ ...scope, agentName: 'assistant' })).resolves.toEqual([
|
||||||
'builder',
|
'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 () => {
|
it('persists checkpoint skills when they differ from stored state', async () => {
|
||||||
const memory = new InMemoryMemory();
|
const memory = new InMemoryMemory();
|
||||||
await memory.skillState.save({ ...scope, agentName: 'assistant' }, ['planning']);
|
await memory.skillState.save({ ...scope, agentName: 'assistant' }, ['planning']);
|
||||||
|
|
@ -120,7 +167,15 @@ describe('active skills', () => {
|
||||||
{
|
{
|
||||||
type: 'tool-call',
|
type: 'tool-call',
|
||||||
toolName: 'load_skill',
|
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' },
|
input: { skillId: 'planning' },
|
||||||
state: 'resolved',
|
state: 'resolved',
|
||||||
output: { type: 'content', value: [{ type: 'text', text: 'Create a task plan.' }] },
|
output: { type: 'content', value: [{ type: 'text', text: 'Create a task plan.' }] },
|
||||||
|
|
@ -135,16 +190,132 @@ describe('active skills', () => {
|
||||||
);
|
);
|
||||||
await active.restore(list, scope);
|
await active.restore(list, scope);
|
||||||
|
|
||||||
expect(active.instructions()).toContain('Build one workflow.');
|
// Both loads are still visible, so the block stays empty; the enabled skill
|
||||||
expect(active.instructions()).not.toContain('Create a task plan.');
|
// rides on its result while the disabled one is collapsed out of it.
|
||||||
expect(JSON.stringify(active.modelMessages(list.forLlm('').messages, list))).not.toContain(
|
expect(active.instructions()).toBeUndefined();
|
||||||
'Create a task plan.',
|
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([
|
await expect(memory.skillState.load({ ...scope, agentName: 'assistant' })).resolves.toEqual([
|
||||||
'builder',
|
'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 () => {
|
it('keeps threads, resources, and agents isolated', async () => {
|
||||||
const memory = new InMemoryMemory();
|
const memory = new InMemoryMemory();
|
||||||
const active = new ActiveSkills(source, 'assistant', memory.skillState);
|
const active = new ActiveSkills(source, 'assistant', memory.skillState);
|
||||||
|
|
@ -165,6 +336,40 @@ describe('active skills', () => {
|
||||||
).resolves.toBeUndefined();
|
).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 () => {
|
it('does not activate a missing skill or a failed load', async () => {
|
||||||
const missing: RuntimeSkillSource = { ...source, loadSkill: vi.fn().mockResolvedValue(null) };
|
const missing: RuntimeSkillSource = { ...source, loadSkill: vi.fn().mockResolvedValue(null) };
|
||||||
const active = new ActiveSkills(missing, 'assistant');
|
const active = new ActiveSkills(missing, 'assistant');
|
||||||
|
|
|
||||||
|
|
@ -295,6 +295,24 @@ export class AgentMessageList {
|
||||||
block.suspension = suspension;
|
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 {
|
private findToolCallHost(toolCallId: string): AgentDbMessage | undefined {
|
||||||
// Start from the last message and go backwards to find the host message
|
// Start from the last message and go backwards to find the host message
|
||||||
for (let i = this.all.length - 1; i >= 0; i--) {
|
for (let i = this.all.length - 1; i >= 0; i--) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { ModelMessage } from 'ai';
|
import type { ModelMessage, ToolResultPart } from 'ai';
|
||||||
|
|
||||||
import { formatActiveSkill } from '../../skills/tools';
|
import { formatActiveSkill } from '../../skills/tools';
|
||||||
import {
|
import {
|
||||||
|
|
@ -11,7 +11,30 @@ import {
|
||||||
import type { AgentPersistenceOptions } from '../../types/sdk/agent';
|
import type { AgentPersistenceOptions } from '../../types/sdk/agent';
|
||||||
import type { AgentMessageList } from '../model/message-list';
|
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 {
|
export class ActiveSkills {
|
||||||
private readonly loaded = new Map<string, RuntimeSkillContent>();
|
private readonly loaded = new Map<string, RuntimeSkillContent>();
|
||||||
private list?: AgentMessageList;
|
private list?: AgentMessageList;
|
||||||
|
|
@ -38,7 +61,11 @@ export class ActiveSkills {
|
||||||
list.activeSkillIds === undefined && this.scope
|
list.activeSkillIds === undefined && this.scope
|
||||||
? await this.store?.load(this.scope)
|
? await this.store?.load(this.scope)
|
||||||
: undefined;
|
: 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));
|
const registered = new Set(this.source.registry.skills.map(({ id }) => id));
|
||||||
for (const id of new Set(ids)) {
|
for (const id of new Set(ids)) {
|
||||||
if (!registered.has(id)) continue;
|
if (!registered.has(id)) continue;
|
||||||
|
|
@ -53,7 +80,10 @@ export class ActiveSkills {
|
||||||
if (!storeIsCurrent && (ids.length > 0 || stored !== undefined)) await this.persist();
|
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;
|
if (!this.source.registry.skills.some(({ id }) => id === skillId)) return null;
|
||||||
const skill = this.loaded.get(skillId) ?? (await this.source.loadSkill(skillId));
|
const skill = this.loaded.get(skillId) ?? (await this.source.loadSkill(skillId));
|
||||||
if (!skill) return null;
|
if (!skill) return null;
|
||||||
|
|
@ -61,6 +91,9 @@ export class ActiveSkills {
|
||||||
if (!this.loaded.has(skillId)) {
|
if (!this.loaded.has(skillId)) {
|
||||||
this.loaded.set(skillId, skill);
|
this.loaded.set(skillId, skill);
|
||||||
this.list.activeSkillIds = [...this.loaded.keys()];
|
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();
|
await this.persist();
|
||||||
}
|
}
|
||||||
return skill;
|
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 {
|
instructions(): string | undefined {
|
||||||
if (this.loaded.size === 0) return undefined;
|
const { inBlock } = this.placement();
|
||||||
const sections = [...this.loaded].flatMap(([id, skill]) => {
|
const sections = inBlock.flatMap((id) => {
|
||||||
const entry = this.source.registry.skills.find((candidate) => candidate.id === id);
|
const text = this.formatSkill(id);
|
||||||
return entry ? [formatActiveSkill(skill, entry)] : [];
|
return text ? [text] : [];
|
||||||
});
|
});
|
||||||
|
if (sections.length === 0) return undefined;
|
||||||
return [
|
return [
|
||||||
'<active_skills>',
|
'<active_skills>',
|
||||||
'Use these current skill versions when relevant to the task. Earlier tool results may describe older versions.',
|
'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');
|
].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[] {
|
modelMessages(messages: ModelMessage[], list: AgentMessageList): ModelMessage[] {
|
||||||
const loads = this.recordedLoads(list);
|
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) => {
|
return messages.map((message) => {
|
||||||
if (message.role !== 'tool') return message;
|
if (message.role !== 'tool') return message;
|
||||||
return {
|
return {
|
||||||
|
|
@ -96,19 +150,105 @@ export class ActiveSkills {
|
||||||
content: message.content.map((part) => {
|
content: message.content.map((part) => {
|
||||||
if (part.type !== 'tool-result') return part;
|
if (part.type !== 'tool-result') return part;
|
||||||
const skillId = loads.get(part.toolCallId);
|
const skillId = loads.get(part.toolCallId);
|
||||||
if (!skillId) return part;
|
const collapsed: ToolResultPart = skillId
|
||||||
return {
|
? {
|
||||||
...part,
|
...part,
|
||||||
output: {
|
output: {
|
||||||
type: 'json' as const,
|
type: 'json' as const,
|
||||||
value: { skillId, active: this.loaded.has(skillId) },
|
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> {
|
private recordedLoads(list: AgentMessageList): Map<string, string> {
|
||||||
const loads = new Map<string, string>();
|
const loads = new Map<string, string>();
|
||||||
for (const message of list.messages()) {
|
for (const message of list.messages()) {
|
||||||
|
|
@ -154,3 +294,32 @@ export class ActiveSkills {
|
||||||
await save;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,15 @@ export async function executeTool(
|
||||||
throw new Error(`No handler found for tool "${builtTool.name}"`);
|
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) {
|
if (builtTool.suspendSchema) {
|
||||||
const isCancelled = isCancellation(resumeData);
|
const isCancelled = isCancellation(resumeData);
|
||||||
const ctx: InterruptibleToolContext = {
|
const ctx: InterruptibleToolContext = {
|
||||||
|
|
@ -139,7 +148,7 @@ export async function executeTool(
|
||||||
toolCallId,
|
toolCallId,
|
||||||
toolName: builtTool.name,
|
toolName: builtTool.name,
|
||||||
runId: executionContext.runId,
|
runId: executionContext.runId,
|
||||||
...(executionContext.loadSkill ? { loadSkill: executionContext.loadSkill } : {}),
|
...(loadSkill ? { loadSkill } : {}),
|
||||||
persistence: executionContext.persistence,
|
persistence: executionContext.persistence,
|
||||||
emitEvent: executionContext.emitEvent,
|
emitEvent: executionContext.emitEvent,
|
||||||
abortSignal: executionContext.abortSignal,
|
abortSignal: executionContext.abortSignal,
|
||||||
|
|
@ -156,7 +165,7 @@ export async function executeTool(
|
||||||
toolCallId,
|
toolCallId,
|
||||||
toolName: builtTool.name,
|
toolName: builtTool.name,
|
||||||
runId: executionContext.runId,
|
runId: executionContext.runId,
|
||||||
...(executionContext.loadSkill ? { loadSkill: executionContext.loadSkill } : {}),
|
...(loadSkill ? { loadSkill } : {}),
|
||||||
persistence: executionContext.persistence,
|
persistence: executionContext.persistence,
|
||||||
emitEvent: executionContext.emitEvent,
|
emitEvent: executionContext.emitEvent,
|
||||||
abortSignal: executionContext.abortSignal,
|
abortSignal: executionContext.abortSignal,
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,15 @@ export interface RuntimeSkillContent extends RuntimeSkillIndexEntry {
|
||||||
linkedFiles?: RuntimeSkillLinkedFiles;
|
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 {
|
export interface RuntimeSkillFileContent {
|
||||||
skillId: string;
|
skillId: string;
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,13 @@ export type ContentToolCall = ContentMetadata & {
|
||||||
input: JSONValue;
|
input: JSONValue;
|
||||||
|
|
||||||
providerExecuted?: boolean;
|
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: 'pending'; suspension?: ToolCallSuspensionInfo }
|
||||||
| { state: 'resolved'; output: JSONValue; canceled?: boolean }
|
| { state: 'resolved'; output: JSONValue; canceled?: boolean }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/ai-node-sdk",
|
"name": "@n8n/ai-node-sdk",
|
||||||
"version": "0.31.0",
|
"version": "0.31.3",
|
||||||
"description": "SDK for building AI nodes in n8n",
|
"description": "SDK for building AI nodes in n8n",
|
||||||
"types": "dist/esm/index.d.ts",
|
"types": "dist/esm/index.d.ts",
|
||||||
"module": "dist/esm/index.js",
|
"module": "dist/esm/index.js",
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,26 @@ model configuration, and model output.
|
||||||
- Put generic helpers and generic secret or PII redaction in `@n8n/utils`.
|
- Put generic helpers and generic secret or PII redaction in `@n8n/utils`.
|
||||||
- Put workflow graph and traversal utilities in `n8n-workflow`.
|
- Put workflow graph and traversal utilities in `n8n-workflow`.
|
||||||
- Keep domain logic in the package that owns that domain.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/ai-utilities",
|
"name": "@n8n/ai-utilities",
|
||||||
"version": "0.34.0",
|
"version": "0.34.3",
|
||||||
"description": "Utilities for building AI nodes in n8n",
|
"description": "Utilities for building AI nodes in n8n",
|
||||||
"types": "dist/esm/index.d.ts",
|
"types": "dist/esm/index.d.ts",
|
||||||
"module": "dist/esm/index.js",
|
"module": "dist/esm/index.js",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ vi.mock('@langchain/openai', () => ({
|
||||||
|
|
||||||
vi.mock('src/utils/http-proxy-agent', () => ({
|
vi.mock('src/utils/http-proxy-agent', () => ({
|
||||||
getProxyAgent: vi.fn().mockReturnValue({ __agent: true }),
|
getProxyAgent: vi.fn().mockReturnValue({ __agent: true }),
|
||||||
|
aiClientFetch: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('src/utils/n8n-llm-tracing', () => ({
|
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 ChatOpenAI = vi.mocked(openai.ChatOpenAI);
|
||||||
const LangchainChatModelAdapter = vi.mocked(langchainChatModelAdapter.LangchainChatModelAdapter);
|
const LangchainChatModelAdapter = vi.mocked(langchainChatModelAdapter.LangchainChatModelAdapter);
|
||||||
const getProxyAgent = vi.mocked(httpProxyAgent.getProxyAgent);
|
const getProxyAgent = vi.mocked(httpProxyAgent.getProxyAgent);
|
||||||
|
const aiClientFetch = vi.mocked(httpProxyAgent.aiClientFetch);
|
||||||
const makeN8nLlmFailedAttemptHandler = vi.mocked(
|
const makeN8nLlmFailedAttemptHandler = vi.mocked(
|
||||||
failedAttemptHandler.makeN8nLlmFailedAttemptHandler,
|
failedAttemptHandler.makeN8nLlmFailedAttemptHandler,
|
||||||
);
|
);
|
||||||
|
|
@ -82,6 +84,7 @@ describe('supplyModel', () => {
|
||||||
model: 'gpt-4',
|
model: 'gpt-4',
|
||||||
apiKey: 'test-key',
|
apiKey: 'test-key',
|
||||||
configuration: expect.objectContaining({
|
configuration: expect.objectContaining({
|
||||||
|
fetch: aiClientFetch,
|
||||||
baseURL: 'https://api.openai.com',
|
baseURL: 'https://api.openai.com',
|
||||||
}),
|
}),
|
||||||
onFailedAttempt: expect.any(Function),
|
onFailedAttempt: expect.any(Function),
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,25 @@ import {
|
||||||
type EgressFilter,
|
type EgressFilter,
|
||||||
} from 'src/utils/http-proxy-agent';
|
} 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', () => ({
|
vi.mock('@n8n/backend-network/transport', () => ({
|
||||||
buildDispatcher: vi.fn((proxy: unknown, ssrf: unknown, options: unknown) => ({
|
buildDispatcher: vi.fn((proxy: unknown, ssrf: unknown, options: unknown) => ({
|
||||||
type: 'Dispatcher',
|
type: 'Dispatcher',
|
||||||
|
|
@ -39,6 +58,15 @@ const DEFAULT_BUILD_OPTIONS = {
|
||||||
timeouts: { headersTimeout: 3600000, bodyTimeout: 3600000 },
|
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', () => {
|
describe('getProxyAgent', () => {
|
||||||
// Store original environment variables
|
// Store original environment variables
|
||||||
const originalEnv = { ...process.env };
|
const originalEnv = { ...process.env };
|
||||||
|
|
@ -336,6 +364,7 @@ describe('proxyFetch', () => {
|
||||||
expect.objectContaining({ type: 'Dispatcher', proxy: false }),
|
expect.objectContaining({ type: 'Dispatcher', proxy: false }),
|
||||||
url,
|
url,
|
||||||
undefined,
|
undefined,
|
||||||
|
EXPECTED_RESPONSE_SIZE_LIMIT,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -356,6 +385,7 @@ describe('proxyFetch', () => {
|
||||||
expect.objectContaining({ ssrf: egressFilter }),
|
expect.objectContaining({ ssrf: egressFilter }),
|
||||||
'https://api.openai.com/v1',
|
'https://api.openai.com/v1',
|
||||||
undefined,
|
undefined,
|
||||||
|
EXPECTED_RESPONSE_SIZE_LIMIT,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -374,6 +404,7 @@ describe('proxyFetch', () => {
|
||||||
expect.objectContaining({ type: 'Dispatcher' }),
|
expect.objectContaining({ type: 'Dispatcher' }),
|
||||||
url,
|
url,
|
||||||
undefined,
|
undefined,
|
||||||
|
EXPECTED_RESPONSE_SIZE_LIMIT,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -387,14 +418,24 @@ describe('proxyFetch', () => {
|
||||||
|
|
||||||
await proxyFetch({ input: url, init, egressFilter: passthroughFilter });
|
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 () => {
|
it('should handle URL objects', async () => {
|
||||||
const url = new URL('https://api.openai.com/v1');
|
const url = new URL('https://api.openai.com/v1');
|
||||||
await proxyFetch({ input: url, egressFilter: passthroughFilter });
|
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 () => {
|
it('should handle Request objects', async () => {
|
||||||
|
|
@ -414,6 +455,7 @@ describe('proxyFetch', () => {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: [['content-type', 'application/json']],
|
headers: [['content-type', 'application/json']],
|
||||||
}),
|
}),
|
||||||
|
EXPECTED_RESPONSE_SIZE_LIMIT,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -429,6 +471,7 @@ describe('proxyFetch', () => {
|
||||||
expect.objectContaining({ type: 'Dispatcher', proxy: 'env' }),
|
expect.objectContaining({ type: 'Dispatcher', proxy: 'env' }),
|
||||||
url,
|
url,
|
||||||
undefined,
|
undefined,
|
||||||
|
EXPECTED_RESPONSE_SIZE_LIMIT,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -447,6 +490,7 @@ describe('proxyFetch', () => {
|
||||||
expect.objectContaining({ type: 'Dispatcher', proxy: 'env' }),
|
expect.objectContaining({ type: 'Dispatcher', proxy: 'env' }),
|
||||||
url,
|
url,
|
||||||
init,
|
init,
|
||||||
|
EXPECTED_RESPONSE_SIZE_LIMIT,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -461,6 +505,7 @@ describe('proxyFetch', () => {
|
||||||
expect.objectContaining({ type: 'Dispatcher', proxy: false }),
|
expect.objectContaining({ type: 'Dispatcher', proxy: false }),
|
||||||
url,
|
url,
|
||||||
undefined,
|
undefined,
|
||||||
|
EXPECTED_RESPONSE_SIZE_LIMIT,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -53,6 +53,7 @@ export { encodingForModel, getEncoding } from './utils/tokenizer/tiktoken';
|
||||||
export { makeN8nLlmFailedAttemptHandler } from './utils/failed-attempt-handler/n8nLlmFailedAttemptHandler';
|
export { makeN8nLlmFailedAttemptHandler } from './utils/failed-attempt-handler/n8nLlmFailedAttemptHandler';
|
||||||
export {
|
export {
|
||||||
getProxyAgent,
|
getProxyAgent,
|
||||||
|
aiClientFetch,
|
||||||
getNodeProxyAgent,
|
getNodeProxyAgent,
|
||||||
proxyFetch,
|
proxyFetch,
|
||||||
type AgentTimeoutOptions,
|
type AgentTimeoutOptions,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { BaseChatModel } from '../chat-model/base';
|
||||||
import type { ChatModel } from '../types/chat-model';
|
import type { ChatModel } from '../types/chat-model';
|
||||||
import type { OpenAIModelOptions } from '../types/openai';
|
import type { OpenAIModelOptions } from '../types/openai';
|
||||||
import { makeN8nLlmFailedAttemptHandler } from '../utils/failed-attempt-handler/n8nLlmFailedAttemptHandler';
|
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';
|
import { N8nLlmTracing } from '../utils/n8n-llm-tracing';
|
||||||
|
|
||||||
export type OpenAiModel = OpenAIModelOptions & {
|
export type OpenAiModel = OpenAIModelOptions & {
|
||||||
|
|
@ -21,6 +21,7 @@ function isOpenAiModel(model: SupplyModelOptions): model is OpenAiModel {
|
||||||
|
|
||||||
function getOpenAiModel(ctx: ISupplyDataFunctions, model: OpenAiModel) {
|
function getOpenAiModel(ctx: ISupplyDataFunctions, model: OpenAiModel) {
|
||||||
const clientConfiguration: ClientOptions = {
|
const clientConfiguration: ClientOptions = {
|
||||||
|
fetch: aiClientFetch,
|
||||||
baseURL: model.baseUrl,
|
baseURL: model.baseUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,14 @@ import {
|
||||||
import {
|
import {
|
||||||
buildDispatcher,
|
buildDispatcher,
|
||||||
dispatchedFetch,
|
dispatchedFetch,
|
||||||
|
limitResponseBody,
|
||||||
|
type ResponseSizeLimit,
|
||||||
type TransportSsrfPolicy,
|
type TransportSsrfPolicy,
|
||||||
} from '@n8n/backend-network/transport';
|
} from '@n8n/backend-network/transport';
|
||||||
import type { AgentOptions } from 'node:https';
|
import type { AgentOptions } from 'node:https';
|
||||||
|
import { pipeline, type Readable, Transform } from 'node:stream';
|
||||||
import type { Dispatcher } from 'undici';
|
import type { Dispatcher } from 'undici';
|
||||||
|
import { OperationalError } from 'n8n-workflow';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for configuring HTTP agent timeouts.
|
* 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
|
// 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);
|
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).
|
* 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
|
* 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
|
// 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
|
// a Request (the Mistral SDK builds one with the global class) is passed
|
||||||
// as url + init instead.
|
// 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, {
|
return await dispatchedFetch(
|
||||||
method: input.method,
|
dispatcher,
|
||||||
headers: [...input.headers],
|
targetUrl,
|
||||||
body: input.body === null ? undefined : await input.arrayBuffer(),
|
{
|
||||||
signal: input.signal,
|
method: input.method,
|
||||||
redirect: input.redirect,
|
headers: [...input.headers],
|
||||||
...init,
|
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);
|
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.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/ai-workflow-builder",
|
"name": "@n8n/ai-workflow-builder",
|
||||||
"version": "1.41.0",
|
"version": "1.41.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { BaseChatModel } from '@langchain/core/language_models/chat_models'
|
||||||
import type { MemorySaver } from '@langchain/langgraph';
|
import type { MemorySaver } from '@langchain/langgraph';
|
||||||
import { GraphRecursionError } from '@langchain/langgraph';
|
import { GraphRecursionError } from '@langchain/langgraph';
|
||||||
import type { Logger } from '@n8n/backend-common';
|
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 { OperationalError, UserError } from 'n8n-workflow';
|
||||||
import type { Mock, MockedClass, MockedFunction } from 'vitest';
|
import type { Mock, MockedClass, MockedFunction } from 'vitest';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
@ -52,6 +52,7 @@ import { MAX_AI_BUILDER_PROMPT_LENGTH } from '@/constants';
|
||||||
import { ValidationError } from '@/errors';
|
import { ValidationError } from '@/errors';
|
||||||
import type { PlanInterruptValue, PlanOutput } from '@/types/planning';
|
import type { PlanInterruptValue, PlanOutput } from '@/types/planning';
|
||||||
import type { StreamOutput } from '@/types/streaming';
|
import type { StreamOutput } from '@/types/streaming';
|
||||||
|
import type { SimpleWorkflow } from '@/types/workflow';
|
||||||
import { createStreamProcessor } from '@/utils/stream-processor';
|
import { createStreamProcessor } from '@/utils/stream-processor';
|
||||||
import {
|
import {
|
||||||
WorkflowBuilderAgent,
|
WorkflowBuilderAgent,
|
||||||
|
|
@ -121,6 +122,74 @@ describe('WorkflowBuilderAgent', () => {
|
||||||
agent = new WorkflowBuilderAgent(config);
|
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', () => {
|
describe('chat method', () => {
|
||||||
let mockPayload: ChatPayload;
|
let mockPayload: ChatPayload;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import type {
|
||||||
IConnection,
|
IConnection,
|
||||||
NodeConnectionType,
|
NodeConnectionType,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeConnectionTypes, NodeHelpers } from 'n8n-workflow';
|
import { isUsableObjectKey, NodeConnectionTypes, NodeHelpers } from 'n8n-workflow';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ConnectionValidationResult,
|
ConnectionValidationResult,
|
||||||
|
|
@ -137,6 +137,13 @@ export function createConnection(
|
||||||
sourceOutputIndex: number = 0,
|
sourceOutputIndex: number = 0,
|
||||||
targetInputIndex: number = 0,
|
targetInputIndex: number = 0,
|
||||||
): IConnections {
|
): 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
|
// Ensure source node exists in connections
|
||||||
if (!connections[sourceNodeName]) {
|
if (!connections[sourceNodeName]) {
|
||||||
connections[sourceNodeName] = {};
|
connections[sourceNodeName] = {};
|
||||||
|
|
@ -194,6 +201,10 @@ export function removeConnection(
|
||||||
sourceOutputIndex?: number,
|
sourceOutputIndex?: number,
|
||||||
targetInputIndex?: number,
|
targetInputIndex?: number,
|
||||||
): IConnections {
|
): IConnections {
|
||||||
|
if (!isUsableObjectKey(sourceNodeName) || !isUsableObjectKey(connectionType)) {
|
||||||
|
return connections;
|
||||||
|
}
|
||||||
|
|
||||||
if (!connections[sourceNodeName]?.[connectionType]) {
|
if (!connections[sourceNodeName]?.[connectionType]) {
|
||||||
return connections;
|
return connections;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1001,4 +1001,74 @@ describe('connection.utils', () => {
|
||||||
expect(NodeHelpers.nodeHasOutputType(returnExprNode, 'ai_tool')).toBe(true);
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { INode, IConnections, INodeTypes } from 'n8n-workflow';
|
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';
|
import type { SimpleWorkflow, WorkflowOperation } from '../types/workflow';
|
||||||
|
|
||||||
|
|
@ -63,10 +63,14 @@ function applyRemoveNodeOperation(
|
||||||
|
|
||||||
// Copy connections, excluding those from/to removed nodes (using node names)
|
// Copy connections, excluding those from/to removed nodes (using node names)
|
||||||
for (const [sourceName, nodeConnections] of Object.entries(workflow.connections)) {
|
for (const [sourceName, nodeConnections] of Object.entries(workflow.connections)) {
|
||||||
|
if (!isUsableObjectKey(sourceName)) continue;
|
||||||
|
|
||||||
if (!nodeNamesToRemove.has(sourceName)) {
|
if (!nodeNamesToRemove.has(sourceName)) {
|
||||||
cleanedConnections[sourceName] = {};
|
cleanedConnections[sourceName] = {};
|
||||||
|
|
||||||
for (const [connectionType, outputs] of Object.entries(nodeConnections)) {
|
for (const [connectionType, outputs] of Object.entries(nodeConnections)) {
|
||||||
|
if (!isUsableObjectKey(connectionType)) continue;
|
||||||
|
|
||||||
if (Array.isArray(outputs)) {
|
if (Array.isArray(outputs)) {
|
||||||
cleanedConnections[sourceName][connectionType] = outputs.map((outputConnections) => {
|
cleanedConnections[sourceName][connectionType] = outputs.map((outputConnections) => {
|
||||||
if (Array.isArray(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
|
* Handle 'setConnections' operation - replace all connections
|
||||||
*/
|
*/
|
||||||
|
|
@ -143,7 +177,7 @@ function applySetConnectionsOperation(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...workflow,
|
...workflow,
|
||||||
connections: operation.connections,
|
connections: pickSafeConnections(operation.connections),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,11 +194,17 @@ function applyMergeConnectionsOperation(
|
||||||
|
|
||||||
// Merge connections additively
|
// Merge connections additively
|
||||||
for (const [sourceId, nodeConnections] of Object.entries(operation.connections)) {
|
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]) {
|
if (!connections[sourceId]) {
|
||||||
connections[sourceId] = nodeConnections;
|
connections[sourceId] = pickSafeConnectionTypes(nodeConnections);
|
||||||
} else {
|
} else {
|
||||||
// Merge connections for this source node
|
// Merge connections for this source node
|
||||||
for (const [connectionType, newOutputs] of Object.entries(nodeConnections)) {
|
for (const [connectionType, newOutputs] of Object.entries(nodeConnections)) {
|
||||||
|
if (!isUsableObjectKey(connectionType)) continue;
|
||||||
|
|
||||||
if (!connections[sourceId][connectionType]) {
|
if (!connections[sourceId][connectionType]) {
|
||||||
connections[sourceId][connectionType] = newOutputs;
|
connections[sourceId][connectionType] = newOutputs;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -221,6 +261,10 @@ function applyRemoveConnectionOperation(
|
||||||
|
|
||||||
const { sourceNode, targetNode, connectionType, sourceOutputIndex, targetInputIndex } = operation;
|
const { sourceNode, targetNode, connectionType, sourceOutputIndex, targetInputIndex } = operation;
|
||||||
|
|
||||||
|
if (!isUsableObjectKey(sourceNode) || !isUsableObjectKey(connectionType)) {
|
||||||
|
return workflow;
|
||||||
|
}
|
||||||
|
|
||||||
const connections = { ...workflow.connections };
|
const connections = { ...workflow.connections };
|
||||||
|
|
||||||
// Check if source node has connections
|
// Check if source node has connections
|
||||||
|
|
|
||||||
|
|
@ -1128,4 +1128,196 @@ describe('operations-processor', () => {
|
||||||
expect(result.workflowOperations).toBeNull();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -602,4 +602,120 @@ describe('autoFixConnections', () => {
|
||||||
expect(result.fixed).toHaveLength(1);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { INodeTypeDescription, NodeConnectionType, IConnections } from 'n8n-workflow';
|
import type { INodeTypeDescription, NodeConnectionType, IConnections } from 'n8n-workflow';
|
||||||
|
import { isUsableObjectKey } from 'n8n-workflow';
|
||||||
|
|
||||||
import { createConnection } from '@/tools/utils/connection.utils';
|
import { createConnection } from '@/tools/utils/connection.utils';
|
||||||
import type { SimpleWorkflow } from '@/types';
|
import type { SimpleWorkflow } from '@/types';
|
||||||
|
|
@ -141,6 +142,11 @@ function findCandidateSubNodes(
|
||||||
const candidates: string[] = [];
|
const candidates: string[] = [];
|
||||||
|
|
||||||
for (const [subNodeName, outputs] of ctx.subNodeOutputs) {
|
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)) {
|
if (outputs.has(missingType)) {
|
||||||
// Check if this sub-node is already connected to THIS specific target
|
// Check if this sub-node is already connected to THIS specific target
|
||||||
const alreadyConnectedToTarget = connectionExistsBetweenNodes(
|
const alreadyConnectedToTarget = connectionExistsBetweenNodes(
|
||||||
|
|
@ -174,6 +180,17 @@ function processMissingInputViolation(ctx: AutoFixContext, violation: Programmat
|
||||||
// Only auto-fix AI connections (ai_*)
|
// Only auto-fix AI connections (ai_*)
|
||||||
if (!missingType.startsWith('ai_')) return;
|
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);
|
const candidates = findCandidateSubNodes(ctx, nodeName, missingType as NodeConnectionType);
|
||||||
|
|
||||||
if (candidates.length === 1) {
|
if (candidates.length === 1) {
|
||||||
|
|
@ -222,6 +239,9 @@ function findTargetCandidates(
|
||||||
|
|
||||||
for (const node of ctx.workflow.nodes) {
|
for (const node of ctx.workflow.nodes) {
|
||||||
if (node.disabled || node.name === subNodeName) continue;
|
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);
|
const nodeType = getNodeTypeForNode(node, ctx.nodeTypeMap, ctx.nodeTypesByName);
|
||||||
if (!nodeType) continue;
|
if (!nodeType) continue;
|
||||||
|
|
@ -269,6 +289,16 @@ function processDisconnectedSubNodeViolation(
|
||||||
|
|
||||||
if (ctx.connectedSubNodes.has(subNodeName)) return;
|
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(
|
const nowConnected = findExistingOutgoingConnection(
|
||||||
ctx.result.updatedConnections,
|
ctx.result.updatedConnections,
|
||||||
subNodeName,
|
subNodeName,
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import type { HITLInterruptValue, PlanOutput } from './types/planning';
|
||||||
import type { SimpleWorkflow } from './types/workflow';
|
import type { SimpleWorkflow } from './types/workflow';
|
||||||
import { sanitizeLlmErrorMessage } from './utils/error-sanitizer';
|
import { sanitizeLlmErrorMessage } from './utils/error-sanitizer';
|
||||||
import { createStreamProcessor, type StreamEvent } from './utils/stream-processor';
|
import { createStreamProcessor, type StreamEvent } from './utils/stream-processor';
|
||||||
|
import { sanitizeWorkflowForBuilder } from './utils/workflow-sanitization';
|
||||||
import type { WorkflowState } from './workflow-state';
|
import type { WorkflowState } from './workflow-state';
|
||||||
|
|
||||||
const PROMPT_IS_TOO_LARGE_ERROR =
|
const PROMPT_IS_TOO_LARGE_ERROR =
|
||||||
|
|
@ -202,12 +203,13 @@ export class WorkflowBuilderAgent {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDefaultWorkflowJSON(payload: ChatPayload): SimpleWorkflow {
|
private getDefaultWorkflowJSON(payload: ChatPayload): SimpleWorkflow {
|
||||||
return (
|
const currentWorkflow = payload.workflowContext?.currentWorkflow as SimpleWorkflow | undefined;
|
||||||
(payload.workflowContext?.currentWorkflow as SimpleWorkflow) ?? {
|
|
||||||
nodes: [],
|
if (!currentWorkflow) {
|
||||||
connections: {},
|
return { nodes: [], connections: {}, name: '' };
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
return sanitizeWorkflowForBuilder(currentWorkflow);
|
||||||
}
|
}
|
||||||
|
|
||||||
async *chat(
|
async *chat(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/api-types",
|
"name": "@n8n/api-types",
|
||||||
"version": "1.41.0",
|
"version": "1.41.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,19 @@ describe('SAML preference DTOs', () => {
|
||||||
relayState: 'https://example.com/relay',
|
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 }) => {
|
])('should validate $name', ({ request }) => {
|
||||||
const result = SamlPreferences.safeParse(request);
|
const result = SamlPreferences.safeParse(request);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
|
@ -119,6 +132,13 @@ describe('SAML preference DTOs', () => {
|
||||||
},
|
},
|
||||||
expectedErrorPath: ['mapping', 'email'],
|
expectedErrorPath: ['mapping', 'email'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'invalid emailVerifiedRequired',
|
||||||
|
request: {
|
||||||
|
emailVerifiedRequired: 'yes',
|
||||||
|
},
|
||||||
|
expectedErrorPath: ['emailVerifiedRequired'],
|
||||||
|
},
|
||||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||||
const result = SamlPreferences.safeParse(request);
|
const result = SamlPreferences.safeParse(request);
|
||||||
|
|
||||||
|
|
@ -153,6 +173,7 @@ describe('SAML preference DTOs', () => {
|
||||||
expect(result.data?.authnRequestsSigned).toBe(false);
|
expect(result.data?.authnRequestsSigned).toBe(false);
|
||||||
expect(result.data?.wantAssertionsSigned).toBe(true);
|
expect(result.data?.wantAssertionsSigned).toBe(true);
|
||||||
expect(result.data?.wantMessageSigned).toBe(true);
|
expect(result.data?.wantMessageSigned).toBe(true);
|
||||||
|
expect(result.data?.emailVerifiedRequired).toBeUndefined();
|
||||||
expect(result.data?.acsBinding).toBe('post');
|
expect(result.data?.acsBinding).toBe('post');
|
||||||
expect(result.data?.signatureConfig).toEqual({
|
expect(result.data?.signatureConfig).toEqual({
|
||||||
prefix: 'ds',
|
prefix: 'ds',
|
||||||
|
|
@ -174,6 +195,7 @@ describe('SAML preference DTOs', () => {
|
||||||
firstName: 'John',
|
firstName: 'John',
|
||||||
lastName: 'Doe',
|
lastName: 'Doe',
|
||||||
userPrincipalName: 'johndoe',
|
userPrincipalName: 'johndoe',
|
||||||
|
emailVerified: '',
|
||||||
n8nInstanceRole: '',
|
n8nInstanceRole: '',
|
||||||
n8nProjectRoles: [] as string[],
|
n8nProjectRoles: [] as string[],
|
||||||
},
|
},
|
||||||
|
|
@ -186,6 +208,7 @@ describe('SAML preference DTOs', () => {
|
||||||
authnRequestsSigned: false,
|
authnRequestsSigned: false,
|
||||||
wantAssertionsSigned: true,
|
wantAssertionsSigned: true,
|
||||||
wantMessageSigned: true,
|
wantMessageSigned: true,
|
||||||
|
emailVerifiedRequired: false,
|
||||||
signingPrivateKey: '',
|
signingPrivateKey: '',
|
||||||
signingCertificate: '',
|
signingCertificate: '',
|
||||||
acsBinding: 'post' as const,
|
acsBinding: 'post' as const,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,11 @@ export class SamlPreferencesAttributeMapping extends Z.class({
|
||||||
lastName: z.string(),
|
lastName: z.string(),
|
||||||
/** SAML attribute mapped to the user's principal name. */
|
/** SAML attribute mapped to the user's principal name. */
|
||||||
userPrincipalName: z.string(),
|
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. */
|
/** SAML attribute mapped to the n8n instance role. */
|
||||||
n8nInstanceRole: z.string().optional(),
|
n8nInstanceRole: z.string().optional(),
|
||||||
/** Each element in the array is formatted like "<projectId>:<role>" */
|
/** 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),
|
authnRequestsSigned: z.boolean().default(false),
|
||||||
wantAssertionsSigned: z.boolean().default(true),
|
wantAssertionsSigned: z.boolean().default(true),
|
||||||
wantMessageSigned: 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. */
|
/** PEM-encoded private key for signing SAML AuthnRequests. Stored encrypted at rest. */
|
||||||
signingPrivateKey: z.string().optional(),
|
signingPrivateKey: z.string().optional(),
|
||||||
|
|
@ -82,6 +93,7 @@ export class UpdateSamlConfigurationDto extends Z.class({
|
||||||
firstName: z.string(),
|
firstName: z.string(),
|
||||||
lastName: z.string(),
|
lastName: z.string(),
|
||||||
userPrincipalName: z.string(),
|
userPrincipalName: z.string(),
|
||||||
|
emailVerified: z.string(),
|
||||||
n8nInstanceRole: z.string(),
|
n8nInstanceRole: z.string(),
|
||||||
n8nProjectRoles: z.array(z.string()),
|
n8nProjectRoles: z.array(z.string()),
|
||||||
}),
|
}),
|
||||||
|
|
@ -94,6 +106,7 @@ export class UpdateSamlConfigurationDto extends Z.class({
|
||||||
authnRequestsSigned: z.boolean(),
|
authnRequestsSigned: z.boolean(),
|
||||||
wantAssertionsSigned: z.boolean(),
|
wantAssertionsSigned: z.boolean(),
|
||||||
wantMessageSigned: z.boolean(),
|
wantMessageSigned: z.boolean(),
|
||||||
|
emailVerifiedRequired: z.boolean(),
|
||||||
signingPrivateKey: z.string(),
|
signingPrivateKey: z.string(),
|
||||||
signingCertificate: z.string(),
|
signingCertificate: z.string(),
|
||||||
acsBinding: SamlLoginBindingSchema,
|
acsBinding: SamlLoginBindingSchema,
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,7 @@ export interface FrontendSettings {
|
||||||
aiAssistant: {
|
aiAssistant: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
setup: boolean;
|
setup: boolean;
|
||||||
|
cloudUbbEnabled: boolean;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* @deprecated Gates the AI Transform node's code generation. No longer gates
|
* @deprecated Gates the AI Transform node's code generation. No longer gates
|
||||||
|
|
@ -391,8 +392,6 @@ export type FrontendModuleSettings = {
|
||||||
runDebugEnabled: boolean;
|
runDebugEnabled: boolean;
|
||||||
/** Whether this instance is in the activation-capped trial cohort (`N8N_INSTANCE_AI_ACTIVATION_CAPPED`). Optional. */
|
/** Whether this instance is in the activation-capped trial cohort (`N8N_INSTANCE_AI_ACTIVATION_CAPPED`). Optional. */
|
||||||
activationCapped?: boolean;
|
activationCapped?: boolean;
|
||||||
/** Whether the non-blocking setup panel replaces the suspending setup wizard (`N8N_INSTANCE_AI_SETUP_PANEL_ENABLED`). */
|
|
||||||
instanceAiSetupPanelEnabled?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -357,6 +357,7 @@ export {
|
||||||
} from './schemas/credential-response.schema';
|
} from './schemas/credential-response.schema';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
instanceAiQuestionSchema,
|
||||||
buildRunWorkflowSessionGrantKey,
|
buildRunWorkflowSessionGrantKey,
|
||||||
buildExecuteNodeSessionGrantKey,
|
buildExecuteNodeSessionGrantKey,
|
||||||
buildRunStepSessionGrantKey,
|
buildRunStepSessionGrantKey,
|
||||||
|
|
@ -425,6 +426,8 @@ export {
|
||||||
INSTANCE_AI_CONVERSATION_HISTORY_FLAG,
|
INSTANCE_AI_CONVERSATION_HISTORY_FLAG,
|
||||||
INSTANCE_AI_CONVERSATION_HISTORY_ENABLED_VARIANT,
|
INSTANCE_AI_CONVERSATION_HISTORY_ENABLED_VARIANT,
|
||||||
INSTANCE_AI_PROGRESSIVE_BUILDING_FLAG,
|
INSTANCE_AI_PROGRESSIVE_BUILDING_FLAG,
|
||||||
|
INSTANCE_AI_SETUP_PANEL_FLAG,
|
||||||
|
INSTANCE_AI_SETUP_PANEL_ENABLED_VARIANT,
|
||||||
INSTANCE_AI_PROGRESSIVE_BUILDING_ENABLED_VARIANT,
|
INSTANCE_AI_PROGRESSIVE_BUILDING_ENABLED_VARIANT,
|
||||||
INSTANCE_AI_NODE_USAGE_FLAG,
|
INSTANCE_AI_NODE_USAGE_FLAG,
|
||||||
INSTANCE_AI_FOLDER_EXPLORATION_FLAG,
|
INSTANCE_AI_FOLDER_EXPLORATION_FLAG,
|
||||||
|
|
@ -551,6 +554,7 @@ export type {
|
||||||
InstanceAiApprovalResumeData,
|
InstanceAiApprovalResumeData,
|
||||||
InstanceAiCredentialPlaceholderDef,
|
InstanceAiCredentialPlaceholderDef,
|
||||||
InstanceAiTargetApproval,
|
InstanceAiTargetApproval,
|
||||||
|
InstanceAiQuestion,
|
||||||
InstanceAiCredentialRequest,
|
InstanceAiCredentialRequest,
|
||||||
InstanceAiCredentialSetupHint,
|
InstanceAiCredentialSetupHint,
|
||||||
InstanceAiAgentStatus,
|
InstanceAiAgentStatus,
|
||||||
|
|
|
||||||
|
|
@ -747,6 +747,26 @@ export const instanceAiTargetApprovalSchema = z.object({
|
||||||
});
|
});
|
||||||
export type InstanceAiTargetApproval = z.infer<typeof instanceAiTargetApprovalSchema>;
|
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({
|
export const confirmationRequestPayloadSchema = z.object({
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
inputThreadId: z
|
inputThreadId: z
|
||||||
|
|
@ -790,14 +810,7 @@ export const confirmationRequestPayloadSchema = z.object({
|
||||||
'continue shows a single primary button (used by pause-for-user)',
|
'continue shows a single primary button (used by pause-for-user)',
|
||||||
),
|
),
|
||||||
questions: z
|
questions: z
|
||||||
.array(
|
.array(instanceAiQuestionSchema)
|
||||||
z.object({
|
|
||||||
id: z.string(),
|
|
||||||
question: z.string(),
|
|
||||||
type: z.enum(['single', 'multi', 'text']),
|
|
||||||
options: z.array(z.string()).optional(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Structured questions for the Q&A wizard (inputType=questions)'),
|
.describe('Structured questions for the Q&A wizard (inputType=questions)'),
|
||||||
introMessage: z.string().optional().describe('Intro text shown above questions or plan review'),
|
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_builder_page` — Instance AI hand-off from the agent builder
|
||||||
* - `agent_preview` — send a preview chat session to Instance AI
|
* - `agent_preview` — send a preview chat session to Instance AI
|
||||||
* - `assistant_page` — first message typed on the Instance AI empty/home page
|
* - `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
|
* - `evals` — Instance AI evaluation harness / offline eval runners
|
||||||
* - `playwright` — Playwright E2E helpers that create threads via the REST API
|
* - `playwright` — Playwright E2E helpers that create threads via the REST API
|
||||||
* Experiment cleanup: remove with openWorkflowInAssistant.
|
* Experiment cleanup: remove with openWorkflowInAssistant.
|
||||||
|
|
@ -1605,6 +1619,7 @@ export const INSTANCE_AI_THREAD_SOURCES = [
|
||||||
'agent_builder_page',
|
'agent_builder_page',
|
||||||
'agent_preview',
|
'agent_preview',
|
||||||
'assistant_page',
|
'assistant_page',
|
||||||
|
'onboarding',
|
||||||
// Experiment cleanup: remove with openWorkflowInAssistant.
|
// Experiment cleanup: remove with openWorkflowInAssistant.
|
||||||
'workflow_list_auto',
|
'workflow_list_auto',
|
||||||
'workflow_list_button',
|
'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_FLAG = '111_instance_ai_progressive_building';
|
||||||
export const INSTANCE_AI_PROGRESSIVE_BUILDING_ENABLED_VARIANT = 'variant';
|
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`
|
/** Enables the node-usage context surface for Instance AI: the `node-usage`
|
||||||
|
|
||||||
* action and the `nodeTypes` filter on `workflows(action="list")`. */
|
* action and the `nodeTypes` filter on `workflows(action="list")`. */
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/backend-common",
|
"name": "@n8n/backend-common",
|
||||||
"version": "1.41.0",
|
"version": "1.41.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,10 @@ export class LicenseState {
|
||||||
return this.isLicensed('feat:aiGatewayCloudUbb');
|
return this.isLicensed('feat:aiGatewayCloudUbb');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isAiAssistantCloudUbbEntitlementLicensed() {
|
||||||
|
return this.isLicensed('feat:aiAssistantCloudUbbEntitlement');
|
||||||
|
}
|
||||||
|
|
||||||
isAdvancedExecutionFiltersLicensed() {
|
isAdvancedExecutionFiltersLicensed() {
|
||||||
return this.isLicensed('feat:advancedExecutionFilters');
|
return this.isLicensed('feat:advancedExecutionFilters');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ describe('getModuleEntryUrl', () => {
|
||||||
describe('eligibleModules', () => {
|
describe('eligibleModules', () => {
|
||||||
it('should not include opt-in modules by default', () => {
|
it('should not include opt-in modules by default', () => {
|
||||||
const eligible = Container.get(ModuleRegistry).eligibleModules;
|
const eligible = Container.get(ModuleRegistry).eligibleModules;
|
||||||
expect(eligible).not.toContain('agents');
|
|
||||||
expect(eligible).not.toContain('type-availability-policies');
|
expect(eligible).not.toContain('type-availability-policies');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -43,14 +42,17 @@ describe('eligibleModules', () => {
|
||||||
expect(Container.get(ModuleRegistry).eligibleModules).not.toContain('policy-infrastructure');
|
expect(Container.get(ModuleRegistry).eligibleModules).not.toContain('policy-infrastructure');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include instance-ai by default', () => {
|
it.each(['instance-ai', 'agents'])('should include %s by default', (moduleName) => {
|
||||||
expect(Container.get(ModuleRegistry).eligibleModules).toContain('instance-ai');
|
expect(Container.get(ModuleRegistry).eligibleModules).toContain(moduleName);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow opting out of a default module via env var', () => {
|
it.each(['instance-ai', 'agents'])(
|
||||||
process.env.N8N_DISABLED_MODULES = 'instance-ai';
|
'should allow opting out of the default %s module via env var',
|
||||||
expect(Container.get(ModuleRegistry).eligibleModules).not.toContain('instance-ai');
|
(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', () => {
|
it('should consider a module ineligible if it was disabled via env var', () => {
|
||||||
process.env.N8N_DISABLED_MODULES = 'insights';
|
process.env.N8N_DISABLED_MODULES = 'insights';
|
||||||
|
|
@ -85,11 +87,12 @@ describe('eligibleModules', () => {
|
||||||
'mcp-registry',
|
'mcp-registry',
|
||||||
'workflow-reviews',
|
'workflow-reviews',
|
||||||
'instance-ai',
|
'instance-ai',
|
||||||
|
'agents',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should consider a module eligible if it was enabled via env var', () => {
|
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([
|
expect(Container.get(ModuleRegistry).eligibleModules).toEqual([
|
||||||
'policy-infrastructure',
|
'policy-infrastructure',
|
||||||
'insights',
|
'insights',
|
||||||
|
|
@ -123,6 +126,7 @@ describe('eligibleModules', () => {
|
||||||
'workflow-reviews',
|
'workflow-reviews',
|
||||||
'instance-ai',
|
'instance-ai',
|
||||||
'agents',
|
'agents',
|
||||||
|
'type-availability-policies',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ export class ModuleRegistry {
|
||||||
'mcp-registry',
|
'mcp-registry',
|
||||||
'workflow-reviews',
|
'workflow-reviews',
|
||||||
'instance-ai',
|
'instance-ai',
|
||||||
|
'agents',
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly activeModules: string[] = [];
|
private readonly activeModules: string[] = [];
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/backend-network",
|
"name": "@n8n/backend-network",
|
||||||
"version": "1.15.0",
|
"version": "1.15.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -72,6 +72,8 @@ export interface CreateDispatcherTransportOptions {
|
||||||
timeouts?: TransportTimeoutOptions;
|
timeouts?: TransportTimeoutOptions;
|
||||||
/** When set, it runs on every dispatched request (including each redirect hop) after the SSRF check */
|
/** When set, it runs on every dispatched request (including each redirect hop) after the SSRF check */
|
||||||
authorize?: RequestAuthorizer;
|
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 ssrf = options?.ssrf ?? 'disabled';
|
||||||
const timeouts = options?.timeouts;
|
const timeouts = options?.timeouts;
|
||||||
const authorize = options?.authorize;
|
const authorize = options?.authorize;
|
||||||
|
const responseSizeLimit = options?.responseSizeLimit;
|
||||||
|
|
||||||
const lazyDispatcher = lazyValue(() => buildDispatcher(proxy, ssrf, { timeouts, authorize }));
|
const lazyDispatcher = lazyValue(() => buildDispatcher(proxy, ssrf, { timeouts, authorize }));
|
||||||
|
|
||||||
return {
|
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) =>
|
asCustomFetch: () => async (input, init) =>
|
||||||
await dispatchedFetch(lazyDispatcher(), input, init),
|
await dispatchedFetch(lazyDispatcher(), input, init, responseSizeLimit),
|
||||||
getDispatcher: () => lazyDispatcher(),
|
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`).
|
* 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.
|
* 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(
|
export async function dispatchedFetch(
|
||||||
dispatcher: Dispatcher | undefined,
|
dispatcher: Dispatcher | undefined,
|
||||||
input: RequestInfo | URL,
|
input: RequestInfo | URL,
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
|
responseSizeLimit?: ResponseSizeLimit,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
return (await undiciFetch(
|
const response = (await undiciFetch(
|
||||||
input as Parameters<typeof undiciFetch>[0],
|
input as Parameters<typeof undiciFetch>[0],
|
||||||
{ ...(init ?? {}), dispatcher } as Parameters<typeof undiciFetch>[1],
|
{ ...(init ?? {}), dispatcher } as Parameters<typeof undiciFetch>[1],
|
||||||
)) as unknown as Response;
|
)) as unknown as Response;
|
||||||
|
return responseSizeLimit ? limitResponseBody(response, responseSizeLimit) : response;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,16 @@ export {
|
||||||
createSsrfInterceptor,
|
createSsrfInterceptor,
|
||||||
createAuthorizationInterceptor,
|
createAuthorizationInterceptor,
|
||||||
createDispatcherTransport,
|
createDispatcherTransport,
|
||||||
|
createResponseSizeLimit,
|
||||||
dispatchedFetch,
|
dispatchedFetch,
|
||||||
|
limitResponseBody,
|
||||||
} from './http/undici/transport';
|
} from './http/undici/transport';
|
||||||
export type {
|
export type {
|
||||||
CustomFetch,
|
CustomFetch,
|
||||||
DispatcherTransport,
|
DispatcherTransport,
|
||||||
CreateDispatcherTransportOptions,
|
CreateDispatcherTransportOptions,
|
||||||
RequestAuthorizer,
|
RequestAuthorizer,
|
||||||
|
ResponseSizeLimit,
|
||||||
TransportSsrfPolicy,
|
TransportSsrfPolicy,
|
||||||
TransportTimeoutOptions,
|
TransportTimeoutOptions,
|
||||||
} from './http/undici/transport';
|
} from './http/undici/transport';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/backend-test-utils",
|
"name": "@n8n/backend-test-utils",
|
||||||
"version": "1.41.0",
|
"version": "1.41.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/blob-storage",
|
"name": "@n8n/blob-storage",
|
||||||
"version": "1.9.0",
|
"version": "1.9.3",
|
||||||
"description": "Blob storage layer for n8n, with filesystem, S3, and Azure Blob backends",
|
"description": "Blob storage layer for n8n, with filesystem, S3, and Azure Blob backends",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/chat-hub",
|
"name": "@n8n/chat-hub",
|
||||||
"version": "1.34.0",
|
"version": "1.34.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/client-oauth2",
|
"name": "@n8n/client-oauth2",
|
||||||
"version": "1.23.0",
|
"version": "1.23.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/computer-use",
|
"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",
|
"description": "Local AI gateway for n8n Assistant — filesystem, shell, screenshots, mouse/keyboard, and browser automation",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,7 @@ async function main(
|
||||||
|
|
||||||
const settingsStore = await SettingsStore.create();
|
const settingsStore = await SettingsStore.create();
|
||||||
const defaults = settingsStore.getDefaults(parsed.config);
|
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 confirmConnect = makeConfirmConnect(parsed.nonInteractive, parsed.autoConfirm);
|
||||||
const approved = await confirmConnect(url, session);
|
const approved = await confirmConnect(url, session);
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ function makeSession(overrides: Partial<GatewaySession> = {}): Mocked<GatewaySes
|
||||||
clearSession: vi.fn(),
|
clearSession: vi.fn(),
|
||||||
alwaysAllow: vi.fn(),
|
alwaysAllow: vi.fn(),
|
||||||
alwaysDeny: vi.fn(),
|
alwaysDeny: vi.fn(),
|
||||||
|
claimUnscopedRules: vi.fn(),
|
||||||
flush: vi.fn().mockResolvedValue(undefined),
|
flush: vi.fn().mockResolvedValue(undefined),
|
||||||
...overrides,
|
...overrides,
|
||||||
} as unknown as Mocked<GatewaySession>;
|
} as unknown as Mocked<GatewaySession>;
|
||||||
|
|
@ -373,12 +374,12 @@ describe('GatewayClient.uploadCapabilities', () => {
|
||||||
global.fetch = originalFetch;
|
global.fetch = originalFetch;
|
||||||
});
|
});
|
||||||
|
|
||||||
function makeMinimalClient(): GatewayClient {
|
function makeMinimalClient(session = makeSession()): GatewayClient {
|
||||||
const client = new GatewayClient({
|
const client = new GatewayClient({
|
||||||
url: 'http://localhost:5678',
|
url: 'http://localhost:5678',
|
||||||
apiKey: 'tok',
|
apiKey: 'tok',
|
||||||
config: makeConfig(),
|
config: makeConfig(),
|
||||||
session: makeSession(),
|
session,
|
||||||
confirmResourceAccess: vi.fn(),
|
confirmResourceAccess: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -422,6 +423,28 @@ describe('GatewayClient.uploadCapabilities', () => {
|
||||||
await expect(promise).rejects.not.toBeInstanceOf(GatewayAuthError);
|
await expect(promise).rejects.not.toBeInstanceOf(GatewayAuthError);
|
||||||
await expect(promise).rejects.toThrow(/Failed to upload capabilities: 500/);
|
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', () => {
|
describe('GatewayClient.runTeardowns', () => {
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,8 @@ export class GatewayClient {
|
||||||
/** Start the client: upload capabilities, connect SSE, handle requests. */
|
/** Start the client: upload capabilities, connect SSE, handle requests. */
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
await this.uploadCapabilities();
|
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();
|
this.connectSSE();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,18 +14,26 @@ function makeStore(
|
||||||
allow: Record<string, string[]>;
|
allow: Record<string, string[]>;
|
||||||
deny: Record<string, string[]>;
|
deny: Record<string, string[]>;
|
||||||
}> = {},
|
}> = {},
|
||||||
): Mocked<Pick<SettingsStore, 'getResourcePermissions' | 'alwaysAllow' | 'alwaysDeny' | 'flush'>> {
|
): Mocked<
|
||||||
|
Pick<
|
||||||
|
SettingsStore,
|
||||||
|
'getResourcePermissions' | 'alwaysAllow' | 'alwaysDeny' | 'claimUnscopedRules' | 'flush'
|
||||||
|
>
|
||||||
|
> {
|
||||||
return {
|
return {
|
||||||
getResourcePermissions: vi.fn((toolGroup: ToolGroup) => ({
|
getResourcePermissions: vi.fn((_origin: string, toolGroup: ToolGroup) => ({
|
||||||
allow: overrides.allow?.[toolGroup] ?? [],
|
allow: overrides.allow?.[toolGroup] ?? [],
|
||||||
deny: overrides.deny?.[toolGroup] ?? [],
|
deny: overrides.deny?.[toolGroup] ?? [],
|
||||||
})),
|
})),
|
||||||
alwaysAllow: vi.fn(),
|
alwaysAllow: vi.fn(),
|
||||||
alwaysDeny: vi.fn(),
|
alwaysDeny: vi.fn(),
|
||||||
|
claimUnscopedRules: vi.fn(),
|
||||||
flush: vi.fn().mockResolvedValue(undefined),
|
flush: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ORIGIN = 'https://a.app.n8n.cloud';
|
||||||
|
|
||||||
const FULL_ALLOW_PERMISSIONS = buildDefaultPermissions({
|
const FULL_ALLOW_PERMISSIONS = buildDefaultPermissions({
|
||||||
filesystemRead: 'allow',
|
filesystemRead: 'allow',
|
||||||
filesystemWrite: 'allow',
|
filesystemWrite: 'allow',
|
||||||
|
|
@ -78,6 +86,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/home/user' },
|
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/home/user' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
expect(session.dir).toBe('/home/user');
|
expect(session.dir).toBe('/home/user');
|
||||||
expect(session.getAllPermissions()).toEqual(FULL_ALLOW_PERMISSIONS);
|
expect(session.getAllPermissions()).toEqual(FULL_ALLOW_PERMISSIONS);
|
||||||
|
|
@ -89,6 +98,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: defaults, dir: '/' },
|
{ permissions: defaults, dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
defaults.shell = 'deny';
|
defaults.shell = 'deny';
|
||||||
expect(session.getAllPermissions().shell).toBe('allow');
|
expect(session.getAllPermissions().shell).toBe('allow');
|
||||||
|
|
@ -101,6 +111,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
session.setPermissions({ ...FULL_ALLOW_PERMISSIONS, shell: 'deny' });
|
session.setPermissions({ ...FULL_ALLOW_PERMISSIONS, shell: 'deny' });
|
||||||
expect(session.getAllPermissions().shell).toBe('deny');
|
expect(session.getAllPermissions().shell).toBe('deny');
|
||||||
|
|
@ -111,6 +122,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/old' },
|
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/old' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
session.setDir('/new');
|
session.setDir('/new');
|
||||||
expect(session.dir).toBe('/new');
|
expect(session.dir).toBe('/new');
|
||||||
|
|
@ -127,6 +139,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: FULL_ASK_PERMISSIONS, dir: '/' },
|
{ permissions: FULL_ASK_PERMISSIONS, dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
expect(session.getGroupMode('filesystemRead')).toBe('ask');
|
expect(session.getGroupMode('filesystemRead')).toBe('ask');
|
||||||
expect(session.getGroupMode('shell')).toBe('ask');
|
expect(session.getGroupMode('shell')).toBe('ask');
|
||||||
|
|
@ -143,6 +156,7 @@ describe('GatewaySession', () => {
|
||||||
dir: '/',
|
dir: '/',
|
||||||
},
|
},
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
expect(session.getGroupMode('filesystemWrite')).toBe('deny');
|
expect(session.getGroupMode('filesystemWrite')).toBe('deny');
|
||||||
});
|
});
|
||||||
|
|
@ -155,6 +169,7 @@ describe('GatewaySession', () => {
|
||||||
dir: '/',
|
dir: '/',
|
||||||
},
|
},
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
expect(session.getGroupMode('filesystemWrite')).toBe('allow');
|
expect(session.getGroupMode('filesystemWrite')).toBe('allow');
|
||||||
});
|
});
|
||||||
|
|
@ -170,6 +185,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
expect(session.check('shell', 'rm -rf /')).toBe('deny');
|
expect(session.check('shell', 'rm -rf /')).toBe('deny');
|
||||||
});
|
});
|
||||||
|
|
@ -182,6 +198,7 @@ describe('GatewaySession', () => {
|
||||||
dir: '/',
|
dir: '/',
|
||||||
},
|
},
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
expect(session.check('browser', 'example.com')).toBe('allow');
|
expect(session.check('browser', 'example.com')).toBe('allow');
|
||||||
});
|
});
|
||||||
|
|
@ -194,6 +211,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
expect(session.check('shell', 'npm')).toBe('deny');
|
expect(session.check('shell', 'npm')).toBe('deny');
|
||||||
});
|
});
|
||||||
|
|
@ -203,6 +221,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: buildDefaultPermissions({ shell: 'ask' }), dir: '/' },
|
{ permissions: buildDefaultPermissions({ shell: 'ask' }), dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
session.allowForSession('shell', 'npm');
|
session.allowForSession('shell', 'npm');
|
||||||
expect(session.check('shell', 'npm')).toBe('allow');
|
expect(session.check('shell', 'npm')).toBe('allow');
|
||||||
|
|
@ -213,6 +232,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: buildDefaultPermissions({ shell: 'ask' }), dir: '/' },
|
{ permissions: buildDefaultPermissions({ shell: 'ask' }), dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
expect(session.check('shell', 'npm')).toBe('ask');
|
expect(session.check('shell', 'npm')).toBe('ask');
|
||||||
});
|
});
|
||||||
|
|
@ -223,6 +243,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
expect(session.check('browser', 'credentials', 'credential-write')).toBe('ask');
|
expect(session.check('browser', 'credentials', 'credential-write')).toBe('ask');
|
||||||
// The group mode still covers ordinary domains.
|
// The group mode still covers ordinary domains.
|
||||||
|
|
@ -234,6 +255,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
expect(session.check('browser', 'credentials', 'host')).toBe('allow');
|
expect(session.check('browser', 'credentials', 'host')).toBe('allow');
|
||||||
});
|
});
|
||||||
|
|
@ -243,6 +265,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
expect(session.check('browser', 'credentials')).toBe('allow');
|
expect(session.check('browser', 'credentials')).toBe('allow');
|
||||||
});
|
});
|
||||||
|
|
@ -252,6 +275,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
session.allowForSession('browser', 'credentials');
|
session.allowForSession('browser', 'credentials');
|
||||||
expect(session.check('browser', 'credentials', 'credential-write')).toBe('allow');
|
expect(session.check('browser', 'credentials', 'credential-write')).toBe('allow');
|
||||||
|
|
@ -262,6 +286,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
expect(session.check('browser', 'credentials', 'credential-write')).toBe('allow');
|
expect(session.check('browser', 'credentials', 'credential-write')).toBe('allow');
|
||||||
});
|
});
|
||||||
|
|
@ -271,6 +296,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
expect(session.check('browser', 'credentials', 'credential-write')).toBe('deny');
|
expect(session.check('browser', 'credentials', 'credential-write')).toBe('deny');
|
||||||
});
|
});
|
||||||
|
|
@ -285,6 +311,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
expect(session.check('filesystemWrite', settingsFile)).toBe('deny');
|
expect(session.check('filesystemWrite', settingsFile)).toBe('deny');
|
||||||
});
|
});
|
||||||
|
|
@ -294,6 +321,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
expect(session.check('filesystemWrite', settingsDir)).toBe('deny');
|
expect(session.check('filesystemWrite', settingsDir)).toBe('deny');
|
||||||
});
|
});
|
||||||
|
|
@ -303,6 +331,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
expect(session.check('filesystemRead', settingsFile)).toBe('deny');
|
expect(session.check('filesystemRead', settingsFile)).toBe('deny');
|
||||||
});
|
});
|
||||||
|
|
@ -312,6 +341,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
expect(session.check('filesystemWrite', settingsFile)).toBe('deny');
|
expect(session.check('filesystemWrite', settingsFile)).toBe('deny');
|
||||||
});
|
});
|
||||||
|
|
@ -321,6 +351,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
expect(session.check('filesystemWrite', '/tmp/unrelated.json')).toBe('allow');
|
expect(session.check('filesystemWrite', '/tmp/unrelated.json')).toBe('allow');
|
||||||
});
|
});
|
||||||
|
|
@ -330,6 +361,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
expect(session.check('shell', settingsFile)).toBe('allow');
|
expect(session.check('shell', settingsFile)).toBe('allow');
|
||||||
});
|
});
|
||||||
|
|
@ -346,6 +378,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: buildDefaultPermissions({ shell: 'ask' }), dir: '/' },
|
{ permissions: buildDefaultPermissions({ shell: 'ask' }), dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
session.allowForSession('shell', 'npm');
|
session.allowForSession('shell', 'npm');
|
||||||
expect(session.check('shell', 'npm')).toBe('allow');
|
expect(session.check('shell', 'npm')).toBe('allow');
|
||||||
|
|
@ -358,6 +391,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: buildDefaultPermissions({ shell: 'ask', browser: 'ask' }), dir: '/' },
|
{ permissions: buildDefaultPermissions({ shell: 'ask', browser: 'ask' }), dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
session.allowForSession('shell', 'npm');
|
session.allowForSession('shell', 'npm');
|
||||||
expect(session.check('browser', 'npm')).toBe('ask');
|
expect(session.check('browser', 'npm')).toBe('ask');
|
||||||
|
|
@ -374,9 +408,10 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
session.alwaysAllow('shell', 'npm');
|
session.alwaysAllow('shell', 'npm');
|
||||||
expect(store.alwaysAllow).toHaveBeenCalledWith('shell', 'npm');
|
expect(store.alwaysAllow).toHaveBeenCalledWith(ORIGIN, 'shell', 'npm');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('delegates alwaysDeny to the settings store', () => {
|
it('delegates alwaysDeny to the settings store', () => {
|
||||||
|
|
@ -384,9 +419,21 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
session.alwaysDeny('shell', 'rm -rf /');
|
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 () => {
|
it('delegates flush to the settings store', async () => {
|
||||||
|
|
@ -394,6 +441,7 @@ describe('GatewaySession', () => {
|
||||||
const session = new GatewaySession(
|
const session = new GatewaySession(
|
||||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||||
store as unknown as SettingsStore,
|
store as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
await session.flush();
|
await session.flush();
|
||||||
expect(store.flush).toHaveBeenCalled();
|
expect(store.flush).toHaveBeenCalled();
|
||||||
|
|
@ -409,6 +457,7 @@ describe('GatewaySession', () => {
|
||||||
return new GatewaySession(
|
return new GatewaySession(
|
||||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||||
makeStore() as unknown as SettingsStore,
|
makeStore() as unknown as SettingsStore,
|
||||||
|
ORIGIN,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ export class GatewaySession {
|
||||||
constructor(
|
constructor(
|
||||||
defaults: { permissions: Record<ToolGroup, PermissionMode>; dir: string },
|
defaults: { permissions: Record<ToolGroup, PermissionMode>; dir: string },
|
||||||
private readonly settingsStore: SettingsStore,
|
private readonly settingsStore: SettingsStore,
|
||||||
|
/** Instance origin that persistent resource rules are scoped to. */
|
||||||
|
private readonly origin: string,
|
||||||
) {
|
) {
|
||||||
this._permissions = { ...defaults.permissions };
|
this._permissions = { ...defaults.permissions };
|
||||||
this._dir = defaults.dir;
|
this._dir = defaults.dir;
|
||||||
|
|
@ -87,7 +89,7 @@ export class GatewaySession {
|
||||||
return 'deny';
|
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.deny.includes(resource)) return 'deny';
|
||||||
if (rp.allow.includes(resource)) return 'allow';
|
if (rp.allow.includes(resource)) return 'allow';
|
||||||
if (this.hasSessionAllow(toolGroup, resource)) return 'allow';
|
if (this.hasSessionAllow(toolGroup, resource)) return 'allow';
|
||||||
|
|
@ -141,11 +143,15 @@ export class GatewaySession {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
alwaysAllow(toolGroup: ToolGroup, resource: string): void {
|
alwaysAllow(toolGroup: ToolGroup, resource: string): void {
|
||||||
this.settingsStore.alwaysAllow(toolGroup, resource);
|
this.settingsStore.alwaysAllow(this.origin, toolGroup, resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
alwaysDeny(toolGroup: ToolGroup, resource: string): void {
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
const BASE_CONFIG: GatewayConfig = {
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
allowedOrigins: [],
|
allowedOrigins: [],
|
||||||
|
|
@ -69,15 +72,53 @@ describe('SettingsStore.create', () => {
|
||||||
it('creates a store when no file exists', async () => {
|
it('creates a store when no file exists', async () => {
|
||||||
const store = await createStore(tmpDir);
|
const store = await createStore(tmpDir);
|
||||||
// Should not throw; resource permissions are empty
|
// 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, {
|
const store = await createStore(tmpDir, {
|
||||||
permissions: { shell: 'allow' },
|
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: [] } },
|
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 () => {
|
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.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
await fs.writeFile(filePath, 'not-json', 'utf-8');
|
await fs.writeFile(filePath, 'not-json', 'utf-8');
|
||||||
const store = await SettingsStore.create();
|
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', () => {
|
describe('getResourcePermissions', () => {
|
||||||
it('returns empty lists for unknown groups', async () => {
|
it('returns empty lists for unknown groups', async () => {
|
||||||
const store = await createStore(tmpDir);
|
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 () => {
|
it('reflects alwaysAllow additions', async () => {
|
||||||
const store = await createStore(tmpDir);
|
const store = await createStore(tmpDir);
|
||||||
store.alwaysAllow('shell', 'npm');
|
store.alwaysAllow(ORIGIN, 'shell', 'npm');
|
||||||
expect(store.getResourcePermissions('shell').allow).toContain('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 () => {
|
it('reflects alwaysDeny additions', async () => {
|
||||||
const store = await createStore(tmpDir);
|
const store = await createStore(tmpDir);
|
||||||
store.alwaysDeny('shell', 'rm -rf /');
|
store.alwaysDeny(ORIGIN, 'shell', 'rm -rf /');
|
||||||
expect(store.getResourcePermissions('shell').deny).toContain('rm -rf /');
|
expect(store.getResourcePermissions(ORIGIN, 'shell').deny).toContain('rm -rf /');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -217,16 +265,20 @@ describe('getResourcePermissions', () => {
|
||||||
describe('alwaysAllow / alwaysDeny deduplication', () => {
|
describe('alwaysAllow / alwaysDeny deduplication', () => {
|
||||||
it('does not add a duplicate allow entry', async () => {
|
it('does not add a duplicate allow entry', async () => {
|
||||||
const store = await createStore(tmpDir);
|
const store = await createStore(tmpDir);
|
||||||
store.alwaysAllow('shell', 'npm');
|
store.alwaysAllow(ORIGIN, 'shell', 'npm');
|
||||||
store.alwaysAllow('shell', 'npm');
|
store.alwaysAllow(ORIGIN, 'shell', 'npm');
|
||||||
expect(store.getResourcePermissions('shell').allow.filter((r) => r === 'npm')).toHaveLength(1);
|
expect(
|
||||||
|
store.getResourcePermissions(ORIGIN, 'shell').allow.filter((r) => r === 'npm'),
|
||||||
|
).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not add a duplicate deny entry', async () => {
|
it('does not add a duplicate deny entry', async () => {
|
||||||
const store = await createStore(tmpDir);
|
const store = await createStore(tmpDir);
|
||||||
store.alwaysDeny('shell', 'rm');
|
store.alwaysDeny(ORIGIN, 'shell', 'rm');
|
||||||
store.alwaysDeny('shell', 'rm');
|
store.alwaysDeny(ORIGIN, 'shell', 'rm');
|
||||||
expect(store.getResourcePermissions('shell').deny.filter((r) => r === 'rm')).toHaveLength(1);
|
expect(
|
||||||
|
store.getResourcePermissions(ORIGIN, 'shell').deny.filter((r) => r === 'rm'),
|
||||||
|
).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -237,11 +289,32 @@ describe('alwaysAllow / alwaysDeny deduplication', () => {
|
||||||
describe('flush', () => {
|
describe('flush', () => {
|
||||||
it('writes alwaysAllow changes to disk', async () => {
|
it('writes alwaysAllow changes to disk', async () => {
|
||||||
const store = await createStore(tmpDir);
|
const store = await createStore(tmpDir);
|
||||||
store.alwaysAllow('shell', 'npm');
|
store.alwaysAllow(ORIGIN, 'shell', 'npm');
|
||||||
await store.flush();
|
await store.flush();
|
||||||
|
|
||||||
const raw = await fs.readFile(path.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8');
|
const raw = await fs.readFile(path.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8');
|
||||||
const parsed = parseJson<{ resourcePermissions: { shell: { allow: string[] } } }>(raw);
|
const parsed = parseJson<{
|
||||||
expect(parsed.resourcePermissions.shell.allow).toContain('npm');
|
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({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,20 @@ interface ResourcePermissions {
|
||||||
deny: string[];
|
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({
|
const persistentSettingsSchema = z.object({
|
||||||
logLevel: logLevelSchema.optional(),
|
logLevel: logLevelSchema.optional(),
|
||||||
permissions: z
|
permissions: z
|
||||||
|
|
@ -38,19 +52,12 @@ const persistentSettingsSchema = z.object({
|
||||||
)
|
)
|
||||||
.partial(), //Partial<Record<ToolGroup, PermissionMode>>,
|
.partial(), //Partial<Record<ToolGroup, PermissionMode>>,
|
||||||
filesystemDir: z.string().optional(),
|
filesystemDir: z.string().optional(),
|
||||||
resourcePermissions: z
|
/**
|
||||||
.object(
|
* Rules saved before rules were scoped to an origin. The first origin that connects takes them
|
||||||
Object.fromEntries(
|
* over. Still written as `{}` so that older versions accept the file.
|
||||||
Object.keys(TOOL_GROUP_DEFINITIONS).map((key) => [
|
*/
|
||||||
key,
|
resourcePermissions: resourcePermissionsSchema,
|
||||||
z.object({
|
resourcePermissionsByOrigin: z.record(z.string(), resourcePermissionsSchema).optional(),
|
||||||
allow: z.array(z.string()),
|
|
||||||
deny: z.array(z.string()),
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.partial(), // Partial<Record<ToolGroup, ResourcePermissions>>,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type PersistentSettings = z.infer<typeof persistentSettingsSchema>;
|
type PersistentSettings = z.infer<typeof persistentSettingsSchema>;
|
||||||
|
|
@ -60,7 +67,7 @@ function isValidPersistentSettings(raw: unknown): raw is PersistentSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptySettings(): 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(). */
|
/** Read persistent resource rules for a tool group — used by GatewaySession.check(). */
|
||||||
getResourcePermissions(toolGroup: ToolGroup): { allow: string[]; deny: string[] } {
|
getResourcePermissions(
|
||||||
const rp = this.persistent.resourcePermissions[toolGroup];
|
origin: string,
|
||||||
|
toolGroup: ToolGroup,
|
||||||
|
): { allow: string[]; deny: string[] } {
|
||||||
|
const rp = this.persistent.resourcePermissionsByOrigin?.[origin]?.[toolGroup];
|
||||||
return { allow: rp?.allow ?? [], deny: rp?.deny ?? [] };
|
return { allow: rp?.allow ?? [], deny: rp?.deny ?? [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,22 +174,37 @@ export class SettingsStore {
|
||||||
// Mutation methods
|
// Mutation methods
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
alwaysAllow(toolGroup: ToolGroup, resource: string): void {
|
alwaysAllow(origin: string, toolGroup: ToolGroup, resource: string): void {
|
||||||
const rp = this.getOrInitResourcePermissions(toolGroup);
|
const rp = this.getOrInitResourcePermissions(origin, toolGroup);
|
||||||
if (!rp.allow.includes(resource)) {
|
if (!rp.allow.includes(resource)) {
|
||||||
rp.allow.push(resource);
|
rp.allow.push(resource);
|
||||||
this.scheduleWrite();
|
this.scheduleWrite();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
alwaysDeny(toolGroup: ToolGroup, resource: string): void {
|
alwaysDeny(origin: string, toolGroup: ToolGroup, resource: string): void {
|
||||||
const rp = this.getOrInitResourcePermissions(toolGroup);
|
const rp = this.getOrInitResourcePermissions(origin, toolGroup);
|
||||||
if (!rp.deny.includes(resource)) {
|
if (!rp.deny.includes(resource)) {
|
||||||
rp.deny.push(resource);
|
rp.deny.push(resource);
|
||||||
this.scheduleWrite();
|
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. */
|
/** Force immediate write — must be called on shutdown. */
|
||||||
async flush(): Promise<void> {
|
async flush(): Promise<void> {
|
||||||
this.cancelDebounce();
|
this.cancelDebounce();
|
||||||
|
|
@ -191,8 +216,10 @@ export class SettingsStore {
|
||||||
// Private helpers
|
// Private helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
private getOrInitResourcePermissions(toolGroup: ToolGroup): ResourcePermissions {
|
private getOrInitResourcePermissions(origin: string, toolGroup: ToolGroup): ResourcePermissions {
|
||||||
const existing = this.persistent.resourcePermissions[toolGroup];
|
const byOrigin = (this.persistent.resourcePermissionsByOrigin ??= {});
|
||||||
|
const rules = (byOrigin[origin] ??= {});
|
||||||
|
const existing = rules[toolGroup];
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Normalise: zod schema marks allow/deny as optional; ensure they exist.
|
// Normalise: zod schema marks allow/deny as optional; ensure they exist.
|
||||||
existing.allow ??= [];
|
existing.allow ??= [];
|
||||||
|
|
@ -200,7 +227,7 @@ export class SettingsStore {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
const fresh: ResourcePermissions = { allow: [], deny: [] };
|
const fresh: ResourcePermissions = { allow: [], deny: [] };
|
||||||
this.persistent.resourcePermissions[toolGroup] = fresh;
|
rules[toolGroup] = fresh;
|
||||||
return fresh;
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/config",
|
"name": "@n8n/config",
|
||||||
"version": "2.39.0",
|
"version": "2.39.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -206,15 +206,6 @@ export class InstanceAiConfig {
|
||||||
@Env('N8N_INSTANCE_AI_NODE_CONTEXT_ENABLED')
|
@Env('N8N_INSTANCE_AI_NODE_CONTEXT_ENABLED')
|
||||||
canvasNodeContextEnabled: boolean = false;
|
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
|
* Force-enable the node-usage context surface for Instance AI — the `node-usage` action and
|
||||||
* the `nodeTypes` filter on `workflows(action="list")`.
|
* the `nodeTypes` filter on `workflows(action="list")`.
|
||||||
|
|
|
||||||
|
|
@ -379,7 +379,6 @@ describe('GlobalConfig', () => {
|
||||||
thinkingEnabled: true,
|
thinkingEnabled: true,
|
||||||
mcpConnectionsEnabled: false,
|
mcpConnectionsEnabled: false,
|
||||||
canvasNodeContextEnabled: false,
|
canvasNodeContextEnabled: false,
|
||||||
instanceAiSetupPanelEnabled: false,
|
|
||||||
nodeUsageEnabled: false,
|
nodeUsageEnabled: false,
|
||||||
folderExplorationEnabled: false,
|
folderExplorationEnabled: false,
|
||||||
activationCapped: false,
|
activationCapped: false,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/constants",
|
"name": "@n8n/constants",
|
||||||
"version": "0.39.0",
|
"version": "0.39.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export const LICENSE_FEATURES = {
|
||||||
AI_CREDITS: 'feat:aiCredits',
|
AI_CREDITS: 'feat:aiCredits',
|
||||||
AI_GATEWAY: 'feat:aiGateway',
|
AI_GATEWAY: 'feat:aiGateway',
|
||||||
AI_GATEWAY_CLOUD_UBB: 'feat:aiGatewayCloudUbb',
|
AI_GATEWAY_CLOUD_UBB: 'feat:aiGatewayCloudUbb',
|
||||||
|
AI_ASSISTANT_CLOUD_UBB_ENTITLEMENT: 'feat:aiAssistantCloudUbbEntitlement',
|
||||||
FOLDERS: 'feat:folders',
|
FOLDERS: 'feat:folders',
|
||||||
INSIGHTS_VIEW_SUMMARY: 'feat:insights:viewSummary',
|
INSIGHTS_VIEW_SUMMARY: 'feat:insights:viewSummary',
|
||||||
INSIGHTS_VIEW_DASHBOARD: 'feat:insights:viewDashboard',
|
INSIGHTS_VIEW_DASHBOARD: 'feat:insights:viewDashboard',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/create-node",
|
"name": "@n8n/create-node",
|
||||||
"version": "0.49.0",
|
"version": "0.49.3",
|
||||||
"description": "Official CLI to create new community nodes for n8n",
|
"description": "Official CLI to create new community nodes for n8n",
|
||||||
"bin": {
|
"bin": {
|
||||||
"create-node": "bin/create-node.cjs"
|
"create-node": "bin/create-node.cjs"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/db",
|
"name": "@n8n/db",
|
||||||
"version": "1.41.0",
|
"version": "1.41.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo .tmp-schema-docs",
|
"clean": "rimraf dist .turbo .tmp-schema-docs",
|
||||||
"gen:migration-index": "node scripts/generate-migration-index.mjs",
|
"gen:migration-index": "node scripts/generate-migration-index.mjs",
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,26 @@ describe('DbConnectionMonitor', () => {
|
||||||
expect(poolClient.release).toHaveBeenCalledWith(expect.any(Error));
|
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 () => {
|
it('should destroy the pool client when query creation throws', async () => {
|
||||||
// @ts-expect-error readonly property
|
// @ts-expect-error readonly property
|
||||||
dataSource.isInitialized = true;
|
dataSource.isInitialized = true;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import type { PostgresDriver } from '@n8n/typeorm/driver/postgres/PostgresDriver
|
||||||
import { ensureError } from '@n8n/utils/errors/ensure-error';
|
import { ensureError } from '@n8n/utils/errors/ensure-error';
|
||||||
import type { ErrorReporter } from 'n8n-core';
|
import type { ErrorReporter } from 'n8n-core';
|
||||||
import { OperationalError } from 'n8n-workflow';
|
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 { computeBackoff } from './backoff';
|
||||||
import type { DbConnectionMetrics } from './db-connection-metrics';
|
import type { DbConnectionMetrics } from './db-connection-metrics';
|
||||||
|
|
@ -276,7 +276,10 @@ export class DbConnectionMonitor {
|
||||||
work,
|
work,
|
||||||
setTimeoutP(timeoutMs, undefined, {
|
setTimeoutP(timeoutMs, undefined, {
|
||||||
signal: abortController.signal,
|
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');
|
throw new OperationalError('Database connection timed out');
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,26 @@ import { Service } from '@n8n/di';
|
||||||
import { DataSource, Repository } from '@n8n/typeorm';
|
import { DataSource, Repository } from '@n8n/typeorm';
|
||||||
|
|
||||||
import { AuthIdentity } from '../entities';
|
import { AuthIdentity } from '../entities';
|
||||||
|
import type { AuthProviderType } from '../entities/types-db';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class AuthIdentityRepository extends Repository<AuthIdentity> {
|
export class AuthIdentityRepository extends Repository<AuthIdentity> {
|
||||||
constructor(dataSource: DataSource) {
|
constructor(dataSource: DataSource) {
|
||||||
super(AuthIdentity, dataSource.manager);
|
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 } },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/decorators",
|
"name": "@n8n/decorators",
|
||||||
"version": "1.41.0",
|
"version": "1.41.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/engine",
|
"name": "@n8n/engine",
|
||||||
"version": "0.21.0",
|
"version": "0.21.2",
|
||||||
"description": "n8n workflow execution engine (v2)",
|
"description": "n8n workflow execution engine (v2)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo compiled",
|
"clean": "rimraf dist .turbo compiled",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/imap",
|
"name": "@n8n/imap",
|
||||||
"version": "0.27.0",
|
"version": "0.27.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -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_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_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_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_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. |
|
| `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
|
### Debugging
|
||||||
|
|
||||||
| Variable | Type | Default | Description |
|
| Variable | Type | Default | Description |
|
||||||
|
|
|
||||||
|
|
@ -474,7 +474,7 @@ confirmation card.
|
||||||
`nodesStillNeedingSetup` is what nobody has configured yet, `skippedByUser` what the user
|
`nodesStillNeedingSetup` is what nobody has configured yet, `skippedByUser` what the user
|
||||||
actively dismissed and the agent must not re-open (see `reopenSkipped`).
|
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`
|
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
|
snapshot and confirms that it reached storage. It then saves the build's setup
|
||||||
routing marker. Only after both steps succeed does it return
|
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) |
|
| `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 |
|
| `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:
|
**Type-aware pin data**: Constructs proper pin data per trigger type:
|
||||||
- **Chat trigger**: `{ chatInput, sessionId, action }`
|
- **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
|
directly, then call `credentials(action="setup")` again to select the created
|
||||||
credential.
|
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
|
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
|
is not `finalize`, the tool does not suspend. It merges the credential types
|
||||||
into the workflow's durable `setup-items` snapshot and returns
|
into the workflow's durable `setup-items` snapshot and returns
|
||||||
|
|
|
||||||
|
|
@ -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
|
attachment and structured handoff context. The transcript records the Execute
|
||||||
action so process expectations can check the response.
|
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.
|
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.
|
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
|
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.
|
The repository does not include a `setup-panel-v2` tier.
|
||||||
|
|
||||||
Remote Execute cases require LangTracer to preserve `attach.source` when it
|
Remote Execute cases require LangTracer to preserve `attach.source` when it
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/instance-ai",
|
"name": "@n8n/instance-ai",
|
||||||
"version": "1.26.0",
|
"version": "1.26.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json",
|
"typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json",
|
||||||
|
|
|
||||||
127
packages/@n8n/instance-ai/skills/suggest-automations/SKILL.md
Normal file
127
packages/@n8n/instance-ai/skills/suggest-automations/SKILL.md
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
---
|
||||||
|
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 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 "Got it. Finally, tell me a little
|
||||||
|
about how you use <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
|
||||||
|
"Got it. Finally, tell me a little about how you use <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 confirmed with one click,
|
||||||
|
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 to build.
|
||||||
|
- `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; build it.
|
||||||
|
- 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. 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 a picked suggestion or a task in the user's words. 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.
|
||||||
|
|
@ -1024,7 +1024,11 @@ describe('RunStateRegistry', () => {
|
||||||
createdAt: Date.now(),
|
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');
|
const result = registry.clearThread('thread-1');
|
||||||
|
expect(registry.isSetupPanelEnabled('thread-1')).toBe(false);
|
||||||
|
|
||||||
// Confirmations resolved
|
// Confirmations resolved
|
||||||
expect(resolve).toHaveBeenCalledWith({ approved: false });
|
expect(resolve).toHaveBeenCalledWith({ approved: false });
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,7 @@ export class RunStateRegistry<TUser = unknown> {
|
||||||
|
|
||||||
/** Build mode captured at user-run entry and reused by follow-up runs. */
|
/** Build mode captured at user-run entry and reused by follow-up runs. */
|
||||||
private readonly threadBuildModes = new Map<string, InstanceAiBuildMode>();
|
private readonly threadBuildModes = new Map<string, InstanceAiBuildMode>();
|
||||||
|
private readonly threadSetupPanelEnabled = new Map<string, boolean>();
|
||||||
private readonly threadPromptSelections = new Map<
|
private readonly threadPromptSelections = new Map<
|
||||||
string,
|
string,
|
||||||
{ version: string; metadata?: InstanceAiPromptConfiguration }
|
{ version: string; metadata?: InstanceAiPromptConfiguration }
|
||||||
|
|
@ -521,6 +522,14 @@ export class RunStateRegistry<TUser = unknown> {
|
||||||
return this.threadBuildModes.get(threadId);
|
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 {
|
setPromptVersion(threadId: string, version: string | undefined): void {
|
||||||
if (version === undefined) this.threadPromptSelections.delete(threadId);
|
if (version === undefined) this.threadPromptSelections.delete(threadId);
|
||||||
else this.threadPromptSelections.set(threadId, { version });
|
else this.threadPromptSelections.set(threadId, { version });
|
||||||
|
|
@ -690,6 +699,7 @@ export class RunStateRegistry<TUser = unknown> {
|
||||||
this.threadTimeZones.delete(threadId);
|
this.threadTimeZones.delete(threadId);
|
||||||
this.threadComputerUseChannels.delete(threadId);
|
this.threadComputerUseChannels.delete(threadId);
|
||||||
this.threadBuildModes.delete(threadId);
|
this.threadBuildModes.delete(threadId);
|
||||||
|
this.threadSetupPanelEnabled.delete(threadId);
|
||||||
this.threadPromptSelections.delete(threadId);
|
this.threadPromptSelections.delete(threadId);
|
||||||
|
|
||||||
const groupId = this.threadMessageGroupId.get(threadId);
|
const groupId = this.threadMessageGroupId.get(threadId);
|
||||||
|
|
@ -741,6 +751,7 @@ export class RunStateRegistry<TUser = unknown> {
|
||||||
this.threadTimeZones.clear();
|
this.threadTimeZones.clear();
|
||||||
this.threadComputerUseChannels.clear();
|
this.threadComputerUseChannels.clear();
|
||||||
this.threadBuildModes.clear();
|
this.threadBuildModes.clear();
|
||||||
|
this.threadSetupPanelEnabled.clear();
|
||||||
this.threadPromptSelections.clear();
|
this.threadPromptSelections.clear();
|
||||||
this.threadMessageGroupId.clear();
|
this.threadMessageGroupId.clear();
|
||||||
this.runIdsByMessageGroup.clear();
|
this.runIdsByMessageGroup.clear();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { APPROVAL_SUSPEND_SCHEMA, type StreamChunk } from '@n8n/agents';
|
import { APPROVAL_SUSPEND_SCHEMA, type StreamChunk } from '@n8n/agents';
|
||||||
import {
|
import {
|
||||||
instanceAiApprovalDetailsSchema,
|
instanceAiApprovalDetailsSchema,
|
||||||
|
instanceAiQuestionSchema,
|
||||||
credentialRequestSchema,
|
credentialRequestSchema,
|
||||||
workflowSetupNodeSchema,
|
workflowSetupNodeSchema,
|
||||||
taskListSchema,
|
taskListSchema,
|
||||||
|
|
@ -13,19 +14,12 @@ import {
|
||||||
} from '@n8n/api-types';
|
} from '@n8n/api-types';
|
||||||
import type { InstanceAiEvent } from '@n8n/api-types';
|
import type { InstanceAiEvent } from '@n8n/api-types';
|
||||||
import { isRecord } from '@n8n/utils/is-record';
|
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';
|
import { isQuotaExhaustedError, QUOTA_EXHAUSTED_ERROR_CODE } from '../utils/quota-error';
|
||||||
|
|
||||||
export { 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 {
|
function getArrayProperty(record: Record<string, unknown>, key: string): unknown[] | undefined {
|
||||||
const value = record[key];
|
const value = record[key];
|
||||||
return Array.isArray(value) ? value : undefined;
|
return Array.isArray(value) ? value : undefined;
|
||||||
|
|
@ -334,7 +328,7 @@ function mapSuspendedChunk(
|
||||||
const requireUserSelection = suspendPayload.requireUserSelection === true;
|
const requireUserSelection = suspendPayload.requireUserSelection === true;
|
||||||
const projectId = presentString(suspendPayload.projectId);
|
const projectId = presentString(suspendPayload.projectId);
|
||||||
const inputType = parseInputType(suspendPayload.inputType);
|
const inputType = parseInputType(suspendPayload.inputType);
|
||||||
const questions = parseSchemaArray(suspendPayload.questions, questionItemSchema);
|
const questions = parseSchemaArray(suspendPayload.questions, instanceAiQuestionSchema);
|
||||||
const introMessage = presentString(suspendPayload.introMessage);
|
const introMessage = presentString(suspendPayload.introMessage);
|
||||||
const tasks = parseSchemaRecord(suspendPayload.tasks, taskListSchema);
|
const tasks = parseSchemaRecord(suspendPayload.tasks, taskListSchema);
|
||||||
const planItems = parseSchemaArray(suspendPayload.planItems, plannedTaskArgSchema);
|
const planItems = parseSchemaArray(suspendPayload.planItems, plannedTaskArgSchema);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,13 @@ import type { z } from 'zod';
|
||||||
|
|
||||||
import { executeTool } from '../../__tests__/tool-test-utils';
|
import { executeTool } from '../../__tests__/tool-test-utils';
|
||||||
import type { InstanceAiContext, ExecutionResult } from '../../types';
|
import type { InstanceAiContext, ExecutionResult } from '../../types';
|
||||||
|
import type { VerificationClaim } from '../../workflow-loop/workflow-loop-state';
|
||||||
import { createExecutionsTool } from '../executions.tool';
|
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 ───────────────────────────────────────────────────────────────
|
// ── 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)', () => {
|
describe('session grant (always allow)', () => {
|
||||||
it('runs without HITL when the workflow has a session grant', async () => {
|
it('runs without HITL when the workflow has a session grant', async () => {
|
||||||
const context = createMockContext({
|
const context = createMockContext({
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { z } from 'zod';
|
||||||
import { sanitizeInputSchema } from '../agent/sanitize-mcp-schemas';
|
import { sanitizeInputSchema } from '../agent/sanitize-mcp-schemas';
|
||||||
import type { InstanceAiContext } from '../types';
|
import type { InstanceAiContext } from '../types';
|
||||||
import { approvalSummarySchema, formatApprovalMessage } from './approval-copy';
|
import { approvalSummarySchema, formatApprovalMessage } from './approval-copy';
|
||||||
|
import { recordLiveRunVerification } from './orchestration/verification/record-live-run';
|
||||||
|
|
||||||
// ── Constants ──────────────────────────────────────────────────────────────
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -416,11 +417,20 @@ async function handleRun(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Approved or always_allow — execute
|
// 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,
|
timeout: input.timeout,
|
||||||
triggerNodeName: input.triggerNodeName,
|
triggerNodeName: input.triggerNodeName,
|
||||||
abortSignal,
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,10 @@ const loadResearchTool = lazyMod(
|
||||||
const loadAskUserTool = lazyMod(
|
const loadAskUserTool = lazyMod(
|
||||||
() => require('./shared/ask-user.tool') as typeof import('./shared/ask-user.tool'),
|
() => 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(
|
const loadTaskControlTool = lazyMod(
|
||||||
() => require('./task-control.tool') as typeof import('./task-control.tool'),
|
() => 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
|
// eval-config is flag-gated: the adapter only wires evaluationConfigService
|
||||||
// when `088_config_evaluations` is on, so presence = expose the tool.
|
// when `088_config_evaluations` is on, so presence = expose the tool.
|
||||||
if (context.evaluationConfigService) {
|
if (context.evaluationConfigService) {
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,16 +10,16 @@ import { Tool } from '@n8n/agents';
|
||||||
import { isTriggerNodeType } from 'n8n-workflow';
|
import { isTriggerNodeType } from 'n8n-workflow';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { InstanceAiWorkflowService, OrchestrationContext } from '../../types';
|
import type { OrchestrationContext } from '../../types';
|
||||||
import { analyzeVerificationResult, buildNodePreviews } from './verification/analyze-result';
|
import { analyzeVerificationResult, buildNodePreviews } from './verification/analyze-result';
|
||||||
import { deriveVerificationClaim } from './verification/claim';
|
import { deriveVerificationClaim } from './verification/claim';
|
||||||
import type { VerificationPublishState } from './verification/claim';
|
|
||||||
import {
|
import {
|
||||||
handleMissingSimulationPlan,
|
handleMissingSimulationPlan,
|
||||||
handleBlockedVerification,
|
handleBlockedVerification,
|
||||||
persistVerificationOutcome,
|
persistVerificationOutcome,
|
||||||
} from './verification/finalize-result';
|
} from './verification/finalize-result';
|
||||||
import { prepareVerificationRun } from './verification/prepare-run';
|
import { prepareVerificationRun } from './verification/prepare-run';
|
||||||
|
import { resolvePublishState } from './verification/publish-state';
|
||||||
import { reconcileStaleCredentialPlan } from './verification/reconcile-plan';
|
import { reconcileStaleCredentialPlan } from './verification/reconcile-plan';
|
||||||
import { resolveVerificationTarget } from './verification/resolve-target';
|
import { resolveVerificationTarget } from './verification/resolve-target';
|
||||||
import {
|
import {
|
||||||
|
|
@ -64,35 +64,6 @@ function formatLiveStateNote(claim: VerificationClaim | undefined): string | und
|
||||||
: fact;
|
: 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({
|
export const verifyBuiltWorkflowInputSchema = z.object({
|
||||||
workItemId: z
|
workItemId: z
|
||||||
.string()
|
.string()
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,14 @@ const questionSchema = z.object({
|
||||||
.array(z.string())
|
.array(z.string())
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Suggested answers (required for single/multi, ignored for text)'),
|
.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({
|
const answerSchema = z.object({
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ export const DOMAIN_TOOL_IDS = {
|
||||||
NODES: 'nodes',
|
NODES: 'nodes',
|
||||||
SEARCH_MODELS: 'searchModels',
|
SEARCH_MODELS: 'searchModels',
|
||||||
ASK_USER: 'ask-user',
|
ASK_USER: 'ask-user',
|
||||||
|
LEAVE_ONBOARDING: 'leave-onboarding',
|
||||||
BUILD_WORKFLOW: 'build-workflow',
|
BUILD_WORKFLOW: 'build-workflow',
|
||||||
PARSE_FILE: 'parse-file',
|
PARSE_FILE: 'parse-file',
|
||||||
AGENTS: 'agents',
|
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>([
|
export const ALWAYS_LOADED_TOOL_NAMES = new Set<string>([
|
||||||
DOMAIN_TOOL_IDS.ASK_USER,
|
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.CREDENTIALS,
|
||||||
DOMAIN_TOOL_IDS.WORKFLOWS,
|
DOMAIN_TOOL_IDS.WORKFLOWS,
|
||||||
DOMAIN_TOOL_IDS.EXECUTIONS,
|
DOMAIN_TOOL_IDS.EXECUTIONS,
|
||||||
|
|
|
||||||
|
|
@ -1486,6 +1486,8 @@ export interface InstanceAiContext {
|
||||||
* and rows carry `folder`. Absent or false keeps the pre-feature shape.
|
* and rows carry `folder`. Absent or false keeps the pre-feature shape.
|
||||||
*/
|
*/
|
||||||
folderExplorationEnabled?: boolean;
|
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
|
* Host-resolved model for the current run (proxy-managed on cloud). Domain
|
||||||
* tools pass it as the fallback for utility LLM calls (simulation fixtures,
|
* tools pass it as the fallback for utility LLM calls (simulation fixtures,
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
23
packages/@n8n/local-gateway/src/main/connect-confirmation.ts
Normal file
23
packages/@n8n/local-gateway/src/main/connect-confirmation.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { assertConnectOriginAllowed } from './connect-origin';
|
import { assertConnectOriginAllowed, isAlreadyConnectedTo } from './connect-origin';
|
||||||
|
|
||||||
describe('assertConnectOriginAllowed', () => {
|
describe('assertConnectOriginAllowed', () => {
|
||||||
it('does not throw when origin matches an allowed pattern', () => {
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { isOriginAllowed } from '@n8n/computer-use/config';
|
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.
|
* Throws if the normalized instance URL's origin is not allowed by the configured patterns.
|
||||||
* Call before constructing GatewayClient (deep link / IPC connect).
|
* 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(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ export class DaemonController extends EventEmitter<DaemonControllerEvents> {
|
||||||
await this.closeCurrentConnection({ preserveServerSession: true });
|
await this.closeCurrentConnection({ preserveServerSession: true });
|
||||||
const store = await this.getSettingsStore();
|
const store = await this.getSettingsStore();
|
||||||
const defaults = store.getDefaults(config);
|
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({
|
const client = new GatewayClient({
|
||||||
url: normalizedUrl,
|
url: normalizedUrl,
|
||||||
apiKey,
|
apiKey,
|
||||||
|
|
|
||||||
88
packages/@n8n/local-gateway/src/main/index.test.ts
Normal file
88
packages/@n8n/local-gateway/src/main/index.test.ts
Normal 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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,7 +2,8 @@ import { configure, logger } from '@n8n/computer-use/logger';
|
||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
|
||||||
import { assertConnectOriginAllowed } from './connect-origin';
|
import { confirmConnect } from './connect-confirmation';
|
||||||
|
import { assertConnectOriginAllowed, isAlreadyConnectedTo } from './connect-origin';
|
||||||
import {
|
import {
|
||||||
deepLinkProtocolsInArgv,
|
deepLinkProtocolsInArgv,
|
||||||
parseConnectPayload,
|
parseConnectPayload,
|
||||||
|
|
@ -57,6 +58,16 @@ if (!app.requestSingleInstanceLock()) {
|
||||||
'Missing gateway token in deeplink. Connect from n8n using the computer-use link.',
|
'Missing gateway token in deeplink. Connect from n8n using the computer-use link.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (isAlreadyConnectedTo(payload.url, controller.getSnapshot())) {
|
||||||
|
logger.info('Deep-link ignored: already connected to this instance', {
|
||||||
|
url: payload.url,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await confirmConnect(payload.url, controller.getSnapshot().connectedUrl))) {
|
||||||
|
logger.info('Deep-link connection declined by user', { url: payload.url });
|
||||||
|
return;
|
||||||
|
}
|
||||||
await controller.connect(config, payload.url, token);
|
await controller.connect(config, payload.url, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,13 +75,18 @@ if (!app.requestSingleInstanceLock()) {
|
||||||
await controller.disconnect();
|
await controller.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One link at a time, so each dialog and connect sees the state the previous link left.
|
||||||
|
let connectQueue = Promise.resolve();
|
||||||
|
|
||||||
function handleConnectPayload(payload: ConnectPayload): void {
|
function handleConnectPayload(payload: ConnectPayload): void {
|
||||||
logger.info('Handling deep-link connection payload', { url: payload.url });
|
logger.info('Handling deep-link connection payload', { url: payload.url });
|
||||||
void connect(payload).catch((error: unknown) => {
|
connectQueue = connectQueue.then(async () => {
|
||||||
logger.error('Deep-link connection failed', {
|
await connect(payload).catch((error: unknown) => {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
logger.error('Deep-link connection failed', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
openSettingsWindow(preloadPath, rendererPath);
|
||||||
});
|
});
|
||||||
openSettingsWindow(preloadPath, rendererPath);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/mcp-apps",
|
"name": "@n8n/mcp-apps",
|
||||||
"version": "0.18.0",
|
"version": "0.18.2",
|
||||||
"description": "MCP Apps UI resources and server helpers for n8n",
|
"description": "MCP Apps UI resources and server helpers for n8n",
|
||||||
"main": "dist/server/index.js",
|
"main": "dist/server/index.js",
|
||||||
"types": "dist/server/index.d.ts",
|
"types": "dist/server/index.d.ts",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/node-cli",
|
"name": "@n8n/node-cli",
|
||||||
"version": "0.50.0",
|
"version": "0.50.3",
|
||||||
"description": "Official CLI for developing community nodes for n8n",
|
"description": "Official CLI for developing community nodes for n8n",
|
||||||
"bin": {
|
"bin": {
|
||||||
"n8n-node": "bin/n8n-node.mjs"
|
"n8n-node": "bin/n8n-node.mjs"
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user