mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
Compare commits
31 Commits
master
...
n8n@2.39.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
829b4ac490 | ||
|
|
3ac6dda7d0 | ||
|
|
28945cb71d | ||
|
|
c6a01e2895 | ||
|
|
b9f2eeb5f0 | ||
|
|
742810cdad | ||
|
|
0cf7e992d0 | ||
|
|
bb72aa27ee | ||
|
|
c3dcbbef45 | ||
|
|
a103c805a9 | ||
|
|
ab030877b4 | ||
|
|
8e7bbabd9a | ||
|
|
6174e8e375 | ||
|
|
3d8f578a39 | ||
|
|
b10537fee3 | ||
|
|
b6ce05d7e2 | ||
|
|
ea100c416b | ||
|
|
17376d6df2 | ||
|
|
1db02cd614 | ||
|
|
bd794f7a39 | ||
|
|
cfeb4531be | ||
|
|
f1d044cdfc | ||
|
|
55ae555ef6 | ||
|
|
d1d58017c8 | ||
|
|
720d9a7f0c | ||
|
|
1feb9f2dac | ||
|
|
3e4f082109 | ||
|
|
387eeae868 | ||
|
|
514e20b83d | ||
|
|
63e2795e01 | ||
|
|
082305a895 |
|
|
@ -103,6 +103,7 @@ recognises this vocabulary:
|
||||||
| Dismiss a setup card / skip a value | `[When the setup card asks for the API base URL, dismiss it — the user hasn't decided yet.]` (proxy dismisses via `approve_or_reject(false)`) |
|
| Dismiss a setup card / skip a value | `[When the setup card asks for the API base URL, dismiss it — the user hasn't decided yet.]` (proxy dismisses via `approve_or_reject(false)`) |
|
||||||
| Reject a plan that misses a requirement | `[When the agent shows its plan, reject it unless it sorts descending by count.]` |
|
| Reject a plan that misses a requirement | `[When the agent shows its plan, reject it unless it sorts descending by count.]` |
|
||||||
| Iterate change-by-change, in order | `[Send each change below in order, waiting for the build after each; keep bundled changes in one message.]` |
|
| Iterate change-by-change, in order | `[Send each change below in order, waiting for the build after each; keep bundled changes in one message.]` |
|
||||||
|
| Run the workflow themselves and report back | `[When the agent asks you to test the workflow, run it yourself, then tell it you ran it.]` Set `allowUserExecution: true` on the case. The proxy then sets `runWorkflowId` to the intended saved workflow. The harness uses normal execution without mocks. This action supports credential-free manual or schedule workflows without pinned data. |
|
||||||
|
|
||||||
The `text` of a turn may be an **array of strings** (joined with newlines) so a
|
The `text` of a turn may be an **array of strings** (joined with newlines) so a
|
||||||
long director note stays readable in JSON. A note governs only what it covers;
|
long director note stays readable in JSON. A note governs only what it covers;
|
||||||
|
|
|
||||||
|
|
@ -72,12 +72,6 @@
|
||||||
"message": "openai@^6.34.0 should use \"catalog:\" (exists in pnpm-workspace.yaml)",
|
"message": "openai@^6.34.0 should use \"catalog:\" (exists in pnpm-workspace.yaml)",
|
||||||
"hash": "3c1f53f0afe3"
|
"hash": "3c1f53f0afe3"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"rule": "catalog-violations",
|
|
||||||
"line": 307,
|
|
||||||
"message": "tmp-promise appears in 4 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
|
|
||||||
"hash": "88d67e2ef747"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"rule": "catalog-violations",
|
"rule": "catalog-violations",
|
||||||
"line": 265,
|
"line": 265,
|
||||||
|
|
@ -239,12 +233,6 @@
|
||||||
"line": 48,
|
"line": 48,
|
||||||
"message": "@oclif/core appears in 4 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
|
"message": "@oclif/core appears in 4 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
|
||||||
"hash": "da9b64834300"
|
"hash": "da9b64834300"
|
||||||
},
|
|
||||||
{
|
|
||||||
"rule": "catalog-violations",
|
|
||||||
"line": 55,
|
|
||||||
"message": "tmp-promise appears in 4 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
|
|
||||||
"hash": "632a744e397e"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"packages/nodes-base/package.json": [
|
"packages/nodes-base/package.json": [
|
||||||
|
|
@ -254,12 +242,6 @@
|
||||||
"message": "change-case appears in 5 packages with 3 different versions — add to pnpm-workspace.yaml catalog",
|
"message": "change-case appears in 5 packages with 3 different versions — add to pnpm-workspace.yaml catalog",
|
||||||
"hash": "2d1fab7a5b05"
|
"hash": "2d1fab7a5b05"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"rule": "catalog-violations",
|
|
||||||
"line": 976,
|
|
||||||
"message": "tmp-promise appears in 4 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
|
|
||||||
"hash": "3f93c404ae9c"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"rule": "catalog-violations",
|
"rule": "catalog-violations",
|
||||||
"line": 909,
|
"line": 909,
|
||||||
|
|
@ -368,12 +350,6 @@
|
||||||
"message": "undici appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
|
"message": "undici appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
|
||||||
"hash": "c14cd05614e8"
|
"hash": "c14cd05614e8"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"rule": "catalog-violations",
|
|
||||||
"line": 102,
|
|
||||||
"message": "tmp-promise appears in 4 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
|
|
||||||
"hash": "884a45bdbcf2"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"rule": "catalog-violations",
|
"rule": "catalog-violations",
|
||||||
"line": 110,
|
"line": 110,
|
||||||
|
|
@ -1310,6 +1286,916 @@
|
||||||
"message": "A bare `eslint-disable` directive silences every lint rule in its range, including the encryption guardrails.",
|
"message": "A bare `eslint-disable` directive silences every lint rule in its range, including the encryption guardrails.",
|
||||||
"hash": "7d9779f96d99"
|
"hash": "7d9779f96d99"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"packages/core/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 14,
|
||||||
|
"message": "packages/core turns 'no-prototype-builtins' down to 'warn' for the whole package.",
|
||||||
|
"hash": "f6d9c62ac3fa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 15,
|
||||||
|
"message": "packages/core turns 'no-ex-assign' down to 'warn' for the whole package.",
|
||||||
|
"hash": "1bb332c34de8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 16,
|
||||||
|
"message": "packages/core turns 'no-useless-escape' down to 'warn' for the whole package.",
|
||||||
|
"hash": "8c7b005323e5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 17,
|
||||||
|
"message": "packages/core turns '@typescript-eslint/no-require-imports' down to 'warn' for the whole package.",
|
||||||
|
"hash": "efdd5147a463"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 18,
|
||||||
|
"message": "packages/core turns '@typescript-eslint/no-base-to-string' down to 'warn' for the whole package.",
|
||||||
|
"hash": "5328912625c6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 19,
|
||||||
|
"message": "packages/core turns '@typescript-eslint/prefer-optional-chain' down to 'warn' for the whole package.",
|
||||||
|
"hash": "eddccedaafb7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 20,
|
||||||
|
"message": "packages/core turns '@typescript-eslint/no-array-delete' down to 'warn' for the whole package.",
|
||||||
|
"hash": "c05a6f2ae2f1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/cli/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 90,
|
||||||
|
"message": "packages/cli turns '@typescript-eslint/ban-ts-comment' down to 'off' for the whole package.",
|
||||||
|
"hash": "7e7e5e67871c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 91,
|
||||||
|
"message": "packages/cli turns 'import-x/no-cycle' down to 'warn' for the whole package.",
|
||||||
|
"hash": "e53210bcd5e5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 92,
|
||||||
|
"message": "packages/cli turns 'import-x/extensions' down to 'off' for the whole package.",
|
||||||
|
"hash": "e9ac61307734"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 93,
|
||||||
|
"message": "packages/cli turns 'no-ex-assign' down to 'warn' for the whole package.",
|
||||||
|
"hash": "9555ecb54f1c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 94,
|
||||||
|
"message": "packages/cli turns 'no-case-declarations' down to 'warn' for the whole package.",
|
||||||
|
"hash": "ac66463536bc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 95,
|
||||||
|
"message": "packages/cli turns 'no-fallthrough' down to 'warn' for the whole package.",
|
||||||
|
"hash": "d27fa33c682d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 96,
|
||||||
|
"message": "packages/cli turns 'no-unsafe-optional-chaining' down to 'warn' for the whole package.",
|
||||||
|
"hash": "ea7a4933c714"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 97,
|
||||||
|
"message": "packages/cli turns 'no-async-promise-executor' down to 'warn' for the whole package.",
|
||||||
|
"hash": "af41798867ce"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 98,
|
||||||
|
"message": "packages/cli turns 'complexity' down to 'off' for the whole package.",
|
||||||
|
"hash": "55ccec6cbe85"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 99,
|
||||||
|
"message": "packages/cli turns '@typescript-eslint/prefer-promise-reject-errors' down to 'warn' for the whole package.",
|
||||||
|
"hash": "27aa46ca0c89"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 100,
|
||||||
|
"message": "packages/cli turns '@typescript-eslint/no-explicit-any' down to 'warn' for the whole package.",
|
||||||
|
"hash": "ec58ba458ff5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 101,
|
||||||
|
"message": "packages/cli turns '@typescript-eslint/no-base-to-string' down to 'warn' for the whole package.",
|
||||||
|
"hash": "ada384a6c58c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 102,
|
||||||
|
"message": "packages/cli turns '@typescript-eslint/no-redundant-type-constituents' down to 'warn' for the whole package.",
|
||||||
|
"hash": "fb40bae42683"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 103,
|
||||||
|
"message": "packages/cli turns '@typescript-eslint/no-restricted-types' down to 'warn' for the whole package.",
|
||||||
|
"hash": "ee8be51f48f3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 104,
|
||||||
|
"message": "packages/cli turns '@typescript-eslint/no-unsafe-enum-comparison' down to 'warn' for the whole package.",
|
||||||
|
"hash": "e1d09e3cb444"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 105,
|
||||||
|
"message": "packages/cli turns '@typescript-eslint/no-unsafe-declaration-merging' down to 'warn' for the whole package.",
|
||||||
|
"hash": "abf613bdcbec"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 106,
|
||||||
|
"message": "packages/cli turns '@typescript-eslint/only-throw-error' down to 'warn' for the whole package.",
|
||||||
|
"hash": "f6b9cb56f2da"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 107,
|
||||||
|
"message": "packages/cli turns '@typescript-eslint/no-require-imports' down to 'warn' for the whole package.",
|
||||||
|
"hash": "ce5c4ddb8b21"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 108,
|
||||||
|
"message": "packages/cli turns '@typescript-eslint/array-type' down to 'warn' for the whole package.",
|
||||||
|
"hash": "06eb8c68c0e3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 109,
|
||||||
|
"message": "packages/cli turns 'no-useless-escape' down to 'warn' for the whole package.",
|
||||||
|
"hash": "581e9d0a38b0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 110,
|
||||||
|
"message": "packages/cli turns '@typescript-eslint/prefer-optional-chain' down to 'warn' for the whole package.",
|
||||||
|
"hash": "60a340796f69"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 111,
|
||||||
|
"message": "packages/cli turns '@typescript-eslint/no-duplicate-type-constituents' down to 'warn' for the whole package.",
|
||||||
|
"hash": "499e9bcc262e"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/node-dev/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 7,
|
||||||
|
"message": "packages/node-dev turns 'unicorn/filename-case' down to 'warn' for the whole package.",
|
||||||
|
"hash": "55a9d5b897d9"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/nodes-base/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 27,
|
||||||
|
"message": "packages/nodes-base turns 'no-ex-assign' down to 'warn' for the whole package.",
|
||||||
|
"hash": "405b1823ac6e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 28,
|
||||||
|
"message": "packages/nodes-base turns 'no-control-regex' down to 'warn' for the whole package.",
|
||||||
|
"hash": "be29a0fb44a3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 29,
|
||||||
|
"message": "packages/nodes-base turns 'no-constant-condition' down to 'warn' for the whole package.",
|
||||||
|
"hash": "f7df4e39eb2e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 30,
|
||||||
|
"message": "packages/nodes-base turns 'no-dupe-else-if' down to 'warn' for the whole package.",
|
||||||
|
"hash": "f5be7d5a7f26"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 31,
|
||||||
|
"message": "packages/nodes-base turns 'no-fallthrough' down to 'warn' for the whole package.",
|
||||||
|
"hash": "c899a707272e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 33,
|
||||||
|
"message": "packages/nodes-base turns 'import-x/export' down to 'warn' for the whole package.",
|
||||||
|
"hash": "9ed273bfcc65"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 34,
|
||||||
|
"message": "packages/nodes-base turns 'import-x/no-extraneous-dependencies' down to 'warn' for the whole package.",
|
||||||
|
"hash": "5eb27aed13a1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 38,
|
||||||
|
"message": "packages/nodes-base turns '@typescript-eslint/ban-ts-comment' down to 'off' for the whole package.",
|
||||||
|
"hash": "496821aa3902"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 40,
|
||||||
|
"message": "packages/nodes-base turns '@typescript-eslint/prefer-promise-reject-errors' down to 'warn' for the whole package.",
|
||||||
|
"hash": "2d61f9d00a79"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 26,
|
||||||
|
"message": "packages/nodes-base turns '@n8n/community-nodes/node-class-description-icon-missing' down to 'warn' for the whole package.",
|
||||||
|
"hash": "203610fabf04"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 27,
|
||||||
|
"message": "packages/nodes-base turns '@n8n/community-nodes/cred-class-field-icon-missing' down to 'warn' for the whole package.",
|
||||||
|
"hash": "ac79bc55562e"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/workflow/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 12,
|
||||||
|
"message": "packages/workflow turns 'id-denylist' down to 'warn' for the whole package.",
|
||||||
|
"hash": "03927870bdf1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 13,
|
||||||
|
"message": "packages/workflow turns 'no-fallthrough' down to 'warn' for the whole package.",
|
||||||
|
"hash": "cd442ee0d22b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 14,
|
||||||
|
"message": "packages/workflow turns 'no-useless-escape' down to 'warn' for the whole package.",
|
||||||
|
"hash": "b0c2a209531b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 15,
|
||||||
|
"message": "packages/workflow turns 'no-extra-boolean-cast' down to 'warn' for the whole package.",
|
||||||
|
"hash": "f1b59e9a9170"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 16,
|
||||||
|
"message": "packages/workflow turns 'no-case-declarations' down to 'warn' for the whole package.",
|
||||||
|
"hash": "e0313976c4b5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 17,
|
||||||
|
"message": "packages/workflow turns 'no-prototype-builtins' down to 'warn' for the whole package.",
|
||||||
|
"hash": "974eaf5efe54"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 18,
|
||||||
|
"message": "packages/workflow turns '@typescript-eslint/no-base-to-string' down to 'warn' for the whole package.",
|
||||||
|
"hash": "d3d0cc6967be"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 19,
|
||||||
|
"message": "packages/workflow turns '@typescript-eslint/no-redundant-type-constituents' down to 'warn' for the whole package.",
|
||||||
|
"hash": "39ee215cccec"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 20,
|
||||||
|
"message": "packages/workflow turns '@typescript-eslint/prefer-optional-chain' down to 'warn' for the whole package.",
|
||||||
|
"hash": "155e0b154873"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 22,
|
||||||
|
"message": "packages/workflow turns '@typescript-eslint/no-duplicate-type-constituents' down to 'warn' for the whole package.",
|
||||||
|
"hash": "b3f813b386ed"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/@n8n/ai-node-sdk/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 6,
|
||||||
|
"message": "packages/@n8n/ai-node-sdk turns '@typescript-eslint/no-explicit-any' down to 'warn' for the whole package.",
|
||||||
|
"hash": "8e1ddde4bb6d"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/@n8n/ai-utilities/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 9,
|
||||||
|
"message": "packages/@n8n/ai-utilities turns '@typescript-eslint/no-explicit-any' down to 'warn' for the whole package.",
|
||||||
|
"hash": "d5a8d7cd9636"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 10,
|
||||||
|
"message": "packages/@n8n/ai-utilities turns 'no-case-declarations' down to 'warn' for the whole package.",
|
||||||
|
"hash": "b95bfb2a3056"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 17,
|
||||||
|
"message": "packages/@n8n/ai-utilities turns 'unicorn/filename-case' down to 'off' for the whole package.",
|
||||||
|
"hash": "de46ec6eb650"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/@n8n/backend-common/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 8,
|
||||||
|
"message": "packages/@n8n/backend-common turns '@typescript-eslint/no-wrapper-object-types' down to 'warn' for the whole package.",
|
||||||
|
"hash": "c1e3aa8a2e1c"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/@n8n/codemirror-lang/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 6,
|
||||||
|
"message": "packages/@n8n/codemirror-lang turns 'no-useless-escape' down to 'warn' for the whole package.",
|
||||||
|
"hash": "98b3e5d89db4"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/@n8n/codemirror-lang-sql/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 6,
|
||||||
|
"message": "packages/@n8n/codemirror-lang-sql turns 'no-useless-escape' down to 'warn' for the whole package.",
|
||||||
|
"hash": "b03974f4c1da"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 7,
|
||||||
|
"message": "packages/@n8n/codemirror-lang-sql turns '@typescript-eslint/no-unsafe-enum-comparison' down to 'off' for the whole package.",
|
||||||
|
"hash": "3ee104dc1cd2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/@n8n/db/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 25,
|
||||||
|
"message": "packages/@n8n/db turns '@typescript-eslint/no-base-to-string' down to 'warn' for the whole package.",
|
||||||
|
"hash": "21a54ac84b21"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 26,
|
||||||
|
"message": "packages/@n8n/db turns '@typescript-eslint/no-restricted-types' down to 'warn' for the whole package.",
|
||||||
|
"hash": "1b7a8eee4264"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 27,
|
||||||
|
"message": "packages/@n8n/db turns 'no-useless-escape' down to 'warn' for the whole package.",
|
||||||
|
"hash": "aa31a2b9878a"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/@n8n/decorators/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 8,
|
||||||
|
"message": "packages/@n8n/decorators turns '@typescript-eslint/no-base-to-string' down to 'warn' for the whole package.",
|
||||||
|
"hash": "b4aab632b998"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 9,
|
||||||
|
"message": "packages/@n8n/decorators turns 'import-x/export' down to 'warn' for the whole package.",
|
||||||
|
"hash": "efaf9102fe79"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/@n8n/engine/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 11,
|
||||||
|
"message": "packages/@n8n/engine turns 'unicorn/filename-case' down to 'off' for the whole package.",
|
||||||
|
"hash": "2455a576216c"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/@n8n/eslint-plugin-community-nodes/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 18,
|
||||||
|
"message": "packages/@n8n/eslint-plugin-community-nodes turns 'eslint-plugin/require-meta-docs-url' down to 'off' for the whole package.",
|
||||||
|
"hash": "160308f43107"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 20,
|
||||||
|
"message": "packages/@n8n/eslint-plugin-community-nodes turns 'eslint-plugin/require-meta-default-options' down to 'off' for the whole package.",
|
||||||
|
"hash": "fb3cdbc0b797"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 22,
|
||||||
|
"message": "packages/@n8n/eslint-plugin-community-nodes turns '@typescript-eslint/naming-convention' down to 'off' for the whole package.",
|
||||||
|
"hash": "b9b7b80295d3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 24,
|
||||||
|
"message": "packages/@n8n/eslint-plugin-community-nodes turns 'import-x/no-default-export' down to 'off' for the whole package.",
|
||||||
|
"hash": "3f7dfc88d2cc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/@n8n/extension-sdk/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 7,
|
||||||
|
"message": "packages/@n8n/extension-sdk turns '@typescript-eslint/no-unnecessary-boolean-literal-compare' down to 'warn' for the whole package.",
|
||||||
|
"hash": "1358f2270558"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 8,
|
||||||
|
"message": "packages/@n8n/extension-sdk turns '@typescript-eslint/no-floating-promises' down to 'warn' for the whole package.",
|
||||||
|
"hash": "a01fcd4e7fd1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/@n8n/imap/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 9,
|
||||||
|
"message": "packages/@n8n/imap turns '@typescript-eslint/no-unnecessary-boolean-literal-compare' down to 'warn' for the whole package.",
|
||||||
|
"hash": "c01451c28a03"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 10,
|
||||||
|
"message": "packages/@n8n/imap turns '@typescript-eslint/no-floating-promises' down to 'warn' for the whole package.",
|
||||||
|
"hash": "9466b50c2cbc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/@n8n/instance-ai/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 100,
|
||||||
|
"message": "packages/@n8n/instance-ai turns 'unicorn/filename-case' down to 'off' for the whole package.",
|
||||||
|
"hash": "b76f6179d75b"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/@n8n/json-schema-to-zod/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 8,
|
||||||
|
"message": "packages/@n8n/json-schema-to-zod turns 'import-x/no-cycle' down to 'off' for the whole package.",
|
||||||
|
"hash": "bd1fd9f55f2e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 12,
|
||||||
|
"message": "packages/@n8n/json-schema-to-zod turns 'no-constant-condition' down to 'warn' for the whole package.",
|
||||||
|
"hash": "e7995ba65bba"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/@n8n/node-cli/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 21,
|
||||||
|
"message": "packages/@n8n/node-cli turns 'unicorn/filename-case' down to 'off' for the whole package.",
|
||||||
|
"hash": "450dc7735f37"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/@n8n/task-runner/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 10,
|
||||||
|
"message": "packages/@n8n/task-runner turns '@typescript-eslint/no-require-imports' down to 'warn' for the whole package.",
|
||||||
|
"hash": "9eab9fc92af8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/@n8n/tournament/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 8,
|
||||||
|
"message": "packages/@n8n/tournament turns '@typescript-eslint/no-restricted-types' down to 'off' for the whole package.",
|
||||||
|
"hash": "a7bd4d72aeaa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 9,
|
||||||
|
"message": "packages/@n8n/tournament turns '@typescript-eslint/no-implied-eval' down to 'off' for the whole package.",
|
||||||
|
"hash": "38c062765f50"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 10,
|
||||||
|
"message": "packages/@n8n/tournament turns '@typescript-eslint/no-explicit-any' down to 'off' for the whole package.",
|
||||||
|
"hash": "228a8d26e7bf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 17,
|
||||||
|
"message": "packages/@n8n/tournament turns 'unicorn/filename-case' down to 'off' for the whole package.",
|
||||||
|
"hash": "a8dd0ff69014"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/@n8n/utils/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 7,
|
||||||
|
"message": "packages/@n8n/utils turns 'no-prototype-builtins' down to 'warn' for the whole package.",
|
||||||
|
"hash": "cc7739fda55b"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/frontend/editor-ui/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 55,
|
||||||
|
"message": "packages/frontend/editor-ui turns 'no-restricted-syntax' down to 'off' for the whole package.",
|
||||||
|
"hash": "991d49a8245c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 234,
|
||||||
|
"message": "packages/frontend/editor-ui turns 'n8n-local-rules/no-internal-package-import' down to 'warn' for the whole package.",
|
||||||
|
"hash": "4b3a85b06980"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 235,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@typescript-eslint/ban-ts-comment' down to 'off' for the whole package.",
|
||||||
|
"hash": "e8c5a011088f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 236,
|
||||||
|
"message": "packages/frontend/editor-ui turns 'id-denylist' down to 'warn' for the whole package.",
|
||||||
|
"hash": "bd2977006e41"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 237,
|
||||||
|
"message": "packages/frontend/editor-ui turns 'no-case-declarations' down to 'warn' for the whole package.",
|
||||||
|
"hash": "e305144fdb0f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 238,
|
||||||
|
"message": "packages/frontend/editor-ui turns 'no-useless-escape' down to 'warn' for the whole package.",
|
||||||
|
"hash": "9280bef13c13"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 239,
|
||||||
|
"message": "packages/frontend/editor-ui turns 'no-prototype-builtins' down to 'warn' for the whole package.",
|
||||||
|
"hash": "352fae23e0d1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 240,
|
||||||
|
"message": "packages/frontend/editor-ui turns 'no-fallthrough' down to 'warn' for the whole package.",
|
||||||
|
"hash": "db7c8593f721"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 241,
|
||||||
|
"message": "packages/frontend/editor-ui turns 'no-extra-boolean-cast' down to 'warn' for the whole package.",
|
||||||
|
"hash": "0cbbb7983686"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 242,
|
||||||
|
"message": "packages/frontend/editor-ui turns 'no-sparse-arrays' down to 'warn' for the whole package.",
|
||||||
|
"hash": "750f119df597"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 243,
|
||||||
|
"message": "packages/frontend/editor-ui turns 'no-control-regex' down to 'warn' for the whole package.",
|
||||||
|
"hash": "5f096c4e5a30"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 244,
|
||||||
|
"message": "packages/frontend/editor-ui turns 'import-x/extensions' down to 'warn' for the whole package.",
|
||||||
|
"hash": "301322f12ba8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 245,
|
||||||
|
"message": "packages/frontend/editor-ui turns 'import-x/no-cycle' down to 'warn' for the whole package.",
|
||||||
|
"hash": "3f9ee03a211f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 246,
|
||||||
|
"message": "packages/frontend/editor-ui turns 'import-x/no-duplicates' down to 'warn' for the whole package.",
|
||||||
|
"hash": "7ff9cd71e584"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 247,
|
||||||
|
"message": "packages/frontend/editor-ui turns 'no-unsafe-optional-chaining' down to 'warn' for the whole package.",
|
||||||
|
"hash": "29c386f9afa1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 248,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@typescript-eslint/no-restricted-types' down to 'warn' for the whole package.",
|
||||||
|
"hash": "674bc7c925bb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 249,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@typescript-eslint/dot-notation' down to 'warn' for the whole package.",
|
||||||
|
"hash": "b01af73ed4af"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 250,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@stylistic/lines-between-class-members' down to 'warn' for the whole package.",
|
||||||
|
"hash": "abd5d6c83376"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 251,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@stylistic/member-delimiter-style' down to 'warn' for the whole package.",
|
||||||
|
"hash": "c48bcd875f1a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 252,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@typescript-eslint/no-empty-interface' down to 'warn' for the whole package.",
|
||||||
|
"hash": "271c5309b542"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 253,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@typescript-eslint/no-for-in-array' down to 'warn' for the whole package.",
|
||||||
|
"hash": "249d350f8066"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 254,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@typescript-eslint/no-loop-func' down to 'warn' for the whole package.",
|
||||||
|
"hash": "fa39d584681d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 255,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@typescript-eslint/no-non-null-assertion' down to 'warn' for the whole package.",
|
||||||
|
"hash": "875c5c8d162e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 256,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@typescript-eslint/no-shadow' down to 'warn' for the whole package.",
|
||||||
|
"hash": "b4bfbb79e513"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 257,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@typescript-eslint/no-this-alias' down to 'warn' for the whole package.",
|
||||||
|
"hash": "cd21cdd292fa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 258,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@typescript-eslint/no-unnecessary-boolean-literal-compare' down to 'warn' for the whole package.",
|
||||||
|
"hash": "59d1ce2379ac"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 259,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@typescript-eslint/no-unnecessary-type-assertion' down to 'warn' for the whole package.",
|
||||||
|
"hash": "2eb8b8a9ba23"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 260,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@typescript-eslint/no-unused-expressions' down to 'warn' for the whole package.",
|
||||||
|
"hash": "27661db19f4f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 261,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@typescript-eslint/no-var-requires' down to 'warn' for the whole package.",
|
||||||
|
"hash": "32b4b7a30c8e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 262,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@typescript-eslint/prefer-optional-chain' down to 'warn' for the whole package.",
|
||||||
|
"hash": "8379fe8350b5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 263,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@typescript-eslint/restrict-plus-operands' down to 'warn' for the whole package.",
|
||||||
|
"hash": "f41881f25dad"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 264,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@typescript-eslint/no-redundant-type-constituents' down to 'warn' for the whole package.",
|
||||||
|
"hash": "edfe82c98ea3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 265,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@typescript-eslint/no-unsafe-enum-comparison' down to 'warn' for the whole package.",
|
||||||
|
"hash": "577beb091f06"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 266,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@typescript-eslint/prefer-promise-reject-errors' down to 'warn' for the whole package.",
|
||||||
|
"hash": "cb1d57707d43"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 267,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@typescript-eslint/no-base-to-string' down to 'warn' for the whole package.",
|
||||||
|
"hash": "96f813e59fa6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 268,
|
||||||
|
"message": "packages/frontend/editor-ui turns 'vue/attribute-hyphenation' down to 'warn' for the whole package.",
|
||||||
|
"hash": "33e956db8dec"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 269,
|
||||||
|
"message": "packages/frontend/editor-ui turns '@typescript-eslint/restrict-template-expressions' down to 'warn' for the whole package.",
|
||||||
|
"hash": "affee27335fc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/@n8n/workflow-sdk/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 71,
|
||||||
|
"message": "packages/@n8n/workflow-sdk turns 'n8n-local-rules/no-interpolation-in-regular-string' down to 'off' for the whole package.",
|
||||||
|
"hash": "e5999d7c77a9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 73,
|
||||||
|
"message": "packages/@n8n/workflow-sdk turns 'id-denylist' down to 'off' for the whole package.",
|
||||||
|
"hash": "0f94a71d5ae8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/testing/playwright/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 21,
|
||||||
|
"message": "packages/testing/playwright turns '@typescript-eslint/no-unused-expressions' down to 'off' for the whole package.",
|
||||||
|
"hash": "e2f93b5b591a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 22,
|
||||||
|
"message": "packages/testing/playwright turns '@typescript-eslint/no-use-before-define' down to 'off' for the whole package.",
|
||||||
|
"hash": "75d3ec1677ba"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 23,
|
||||||
|
"message": "packages/testing/playwright turns '@typescript-eslint/promise-function-async' down to 'off' for the whole package.",
|
||||||
|
"hash": "35dbe9e44ada"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 24,
|
||||||
|
"message": "packages/testing/playwright turns 'playwright/expect-expect' down to 'warn' for the whole package.",
|
||||||
|
"hash": "e8b5f4b0f0dd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 25,
|
||||||
|
"message": "packages/testing/playwright turns 'playwright/max-nested-describe' down to 'warn' for the whole package.",
|
||||||
|
"hash": "77d82ee8a802"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 27,
|
||||||
|
"message": "packages/testing/playwright turns 'playwright/no-skipped-test' down to 'warn' for the whole package.",
|
||||||
|
"hash": "14eb9eecd1b4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 78,
|
||||||
|
"message": "packages/testing/playwright turns 'unicorn/filename-case' down to 'off' for the whole package.",
|
||||||
|
"hash": "de86b9645930"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/frontend/@n8n/design-system/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 12,
|
||||||
|
"message": "packages/frontend/@n8n/design-system turns 'no-prototype-builtins' down to 'warn' for the whole package.",
|
||||||
|
"hash": "f7e604999631"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 13,
|
||||||
|
"message": "packages/frontend/@n8n/design-system turns '@typescript-eslint/prefer-optional-chain' down to 'warn' for the whole package.",
|
||||||
|
"hash": "e6d8e187bb0b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 14,
|
||||||
|
"message": "packages/frontend/@n8n/design-system turns '@typescript-eslint/restrict-template-expressions' down to 'warn' for the whole package.",
|
||||||
|
"hash": "86a0b84b8415"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/frontend/@n8n/i18n/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 6,
|
||||||
|
"message": "packages/frontend/@n8n/i18n turns '@typescript-eslint/no-unnecessary-type-assertion' down to 'warn' for the whole package.",
|
||||||
|
"hash": "5836fd4581b2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/frontend/@n8n/stores/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 6,
|
||||||
|
"message": "packages/frontend/@n8n/stores turns '@typescript-eslint/no-unnecessary-type-assertion' down to 'warn' for the whole package.",
|
||||||
|
"hash": "63e2dd35dc47"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages/modules/insights/frontend/eslint.config.mjs": [
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 24,
|
||||||
|
"message": "packages/modules/insights/frontend turns '@typescript-eslint/naming-convention' down to 'warn' for the whole package.",
|
||||||
|
"hash": "3da608aa064b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 25,
|
||||||
|
"message": "packages/modules/insights/frontend turns '@typescript-eslint/no-unsafe-argument' down to 'warn' for the whole package.",
|
||||||
|
"hash": "e9ae1f320e01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 26,
|
||||||
|
"message": "packages/modules/insights/frontend turns '@typescript-eslint/no-unsafe-assignment' down to 'warn' for the whole package.",
|
||||||
|
"hash": "a488f2f44a8e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 27,
|
||||||
|
"message": "packages/modules/insights/frontend turns '@typescript-eslint/no-unsafe-call' down to 'warn' for the whole package.",
|
||||||
|
"hash": "58fd1d708522"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 28,
|
||||||
|
"message": "packages/modules/insights/frontend turns '@typescript-eslint/no-unsafe-member-access' down to 'warn' for the whole package.",
|
||||||
|
"hash": "7de9b4bde334"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 29,
|
||||||
|
"message": "packages/modules/insights/frontend turns '@typescript-eslint/no-unsafe-return' down to 'warn' for the whole package.",
|
||||||
|
"hash": "14aa32c2f578"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 30,
|
||||||
|
"message": "packages/modules/insights/frontend turns '@typescript-eslint/await-thenable' down to 'warn' for the whole package.",
|
||||||
|
"hash": "f6b34bf81039"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "lint-config-layering",
|
||||||
|
"line": 31,
|
||||||
|
"message": "packages/modules/insights/frontend turns '@typescript-eslint/require-await' down to 'warn' for the whole package.",
|
||||||
|
"hash": "b857963c49d1"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
74
CHANGELOG.md
74
CHANGELOG.md
|
|
@ -1,3 +1,77 @@
|
||||||
|
## [2.39.7](https://github.com/n8n-io/n8n/compare/n8n@2.39.6...n8n@2.39.7) (2026-09-17)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** Keep breaking change detection running when a rule throws ([#38790](https://github.com/n8n-io/n8n/issues/38790)) ([28945cb](https://github.com/n8n-io/n8n/commit/28945cb71d09a4cf232148ad00873dd11f59e484))
|
||||||
|
* **editor:** Send number values in credential inputs ([#38881](https://github.com/n8n-io/n8n/issues/38881)) ([3ac6dda](https://github.com/n8n-io/n8n/commit/3ac6dda7d03ba19444fa4a1597488623a146e550))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.39.6](https://github.com/n8n-io/n8n/compare/n8n@2.39.5...n8n@2.39.6) (2026-09-16)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** Check Gateway credits eligibility against the correct sub-node ([#38673](https://github.com/n8n-io/n8n/issues/38673)) ([0cf7e99](https://github.com/n8n-io/n8n/commit/0cf7e992d0c70797b608db78bc71684ab184b530))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **core:** Add v3 breaking change rule for the storage directory rename ([#38636](https://github.com/n8n-io/n8n/issues/38636)) ([c3dcbbe](https://github.com/n8n-io/n8n/commit/c3dcbbef454a49a75a5dd54f6b54be8422d57c88))
|
||||||
|
* **core:** Warn on deprecated N8N_DB_PING_TIMEOUT env var ([#38569](https://github.com/n8n-io/n8n/issues/38569)) ([ab03087](https://github.com/n8n-io/n8n/commit/ab030877b4113628b996c3b2678c7cb331328ccf))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.39.5](https://github.com/n8n-io/n8n/compare/n8n@2.39.4...n8n@2.39.5) (2026-09-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **editor:** Fixes issue that prevented some users from revoking their end-user credentials ([#38455](https://github.com/n8n-io/n8n/issues/38455)) ([3d8f578](https://github.com/n8n-io/n8n/commit/3d8f578a390f196e22b0077aceff04cfbab28f6d))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **core:** Add log streaming events for instance reports ([#38483](https://github.com/n8n-io/n8n/issues/38483)) ([6174e8e](https://github.com/n8n-io/n8n/commit/6174e8e375bdd20e2d6096a58a78286dad5a4e67))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.39.4](https://github.com/n8n-io/n8n/compare/n8n@2.39.3...n8n@2.39.4) (2026-09-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** Select sandbox npm install flags by provider ([#38373](https://github.com/n8n-io/n8n/issues/38373)) ([17376d6](https://github.com/n8n-io/n8n/commit/17376d6df26201cdd848df4901b2a107c585133b))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.39.3](https://github.com/n8n-io/n8n/compare/n8n@2.39.2...n8n@2.39.3) (2026-09-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** Trust the CA from GIT_SSL_CAINFO for source control HTTPS remotes ([#38296](https://github.com/n8n-io/n8n/issues/38296)) ([bd794f7](https://github.com/n8n-io/n8n/commit/bd794f7a39befe12797eaab4db3e82d4e7463965))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.39.2](https://github.com/n8n-io/n8n/compare/n8n@2.39.1...n8n@2.39.2) (2026-09-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** Memoize pairedItem ancestry traversal ([#38212](https://github.com/n8n-io/n8n/issues/38212)) ([1feb9f2](https://github.com/n8n-io/n8n/commit/1feb9f2dac99744748e79b382b6f606d63c0ca5f))
|
||||||
|
* **core:** Reuse an existing consent grant after the visitor authenticates mid-flow ([#38248](https://github.com/n8n-io/n8n/issues/38248)) ([d1d5801](https://github.com/n8n-io/n8n/commit/d1d58017c81f5d76a3860ba0c216d2faee538617))
|
||||||
|
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
* **core:** Stop copying user workflows into the Instance AI sandbox ([#38199](https://github.com/n8n-io/n8n/issues/38199)) ([387eeae](https://github.com/n8n-io/n8n/commit/387eeae8683df9dd316a3809325807af3e54e26c))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.39.1](https://github.com/n8n-io/n8n/compare/n8n@2.39.0...n8n@2.39.1) (2026-09-09)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** Repair legacy-format data-encryption keys during bootstrap ([#38142](https://github.com/n8n-io/n8n/issues/38142)) ([514e20b](https://github.com/n8n-io/n8n/commit/514e20b83d509bd07618cc48650fe783c4bf74f7))
|
||||||
|
|
||||||
|
|
||||||
# [2.39.0](https://github.com/n8n-io/n8n/compare/n8n@2.38.1...n8n@2.39.0) (2026-09-08)
|
# [2.39.0](https://github.com/n8n-io/n8n/compare/n8n@2.38.1...n8n@2.39.0) (2026-09-08)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-monorepo",
|
"name": "n8n-monorepo",
|
||||||
"version": "2.39.0",
|
"version": "2.39.7",
|
||||||
"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.24.0",
|
"version": "0.24.4",
|
||||||
"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,30 +67,29 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_0153mr7MZ5RaibNuJF3t1kkn\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_017u8Xd5bungZRiNvzXXLg7p\",\"name\":\"load_skill\",\"input\":{\"skillId\":\"engineering-status\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1167,\"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_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\"}}",
|
||||||
"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": "9999000",
|
||||||
"anthropic-ratelimit-input-tokens-reset": "2026-07-06T08:45:04Z",
|
"anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:04Z",
|
||||||
"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-07-06T08:45:04Z",
|
"anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:04Z",
|
||||||
"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-07-06T08:45:03Z",
|
"anthropic-ratelimit-requests-reset": "2026-09-10T07:12:04Z",
|
||||||
"anthropic-ratelimit-tokens-limit": "12000000",
|
"anthropic-ratelimit-tokens-limit": "12000000",
|
||||||
"anthropic-ratelimit-tokens-remaining": "12000000",
|
"anthropic-ratelimit-tokens-remaining": "11999000",
|
||||||
"anthropic-ratelimit-tokens-reset": "2026-07-06T08:45:04Z",
|
"anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:04Z",
|
||||||
"cf-cache-status": "DYNAMIC",
|
"cf-cache-status": "DYNAMIC",
|
||||||
"cf-ray": "a16d54c1fb0e589a-BCN",
|
"cf-ray": "a38c9f485dd8bc7b-PRG",
|
||||||
"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": "Mon, 06 Jul 2026 08:45:04 GMT",
|
"date": "Thu, 10 Sep 2026 07:12:04 GMT",
|
||||||
"request-id": "req_011CckWYjo6QtopbKeU9fVgX",
|
"request-id": "req_011CeuLqs6fxp1AkSuMSRWYb",
|
||||||
"server": "cloudflare",
|
"server": "cloudflare",
|
||||||
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
||||||
"traceresponse": "00-4575c6c2498118f4235cb15c53994d86-dd185342703b474e-01",
|
"traceresponse": "00-77c37e49f4c62f7623d8cc66c4f3a26a-6bf7325e3f3f69d3-01",
|
||||||
"vary": "Accept-Encoding",
|
"vary": "Accept-Encoding",
|
||||||
"x-robots-tag": "none"
|
"x-robots-tag": "none"
|
||||||
},
|
},
|
||||||
|
|
@ -106,7 +105,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."
|
"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>"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"messages": [
|
"messages": [
|
||||||
|
|
@ -124,7 +123,7 @@
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "tool_use",
|
"type": "tool_use",
|
||||||
"id": "toolu_017u8Xd5bungZRiNvzXXLg7p",
|
"id": "toolu_01Krds9KGve8gJNTNa9jn6N7",
|
||||||
"name": "load_skill",
|
"name": "load_skill",
|
||||||
"input": {
|
"input": {
|
||||||
"skillId": "engineering-status"
|
"skillId": "engineering-status"
|
||||||
|
|
@ -140,13 +139,8 @@
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "tool_result",
|
"type": "tool_result",
|
||||||
"tool_use_id": "toolu_017u8Xd5bungZRiNvzXXLg7p",
|
"tool_use_id": "toolu_01Krds9KGve8gJNTNa9jn6N7",
|
||||||
"content": [
|
"content": "{\"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."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -195,30 +189,29 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01AncHteAYY9SRacTTHzKv6w\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Now I'll format the status token as instructed:\"},{\"type\":\"tool_use\",\"id\":\"toolu_01LaHkuQAUWKJMdaMRfDEgPM\",\"name\":\"format_status\",\"input\":{\"status\":\"waiting_on_vendor_info\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1320,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":71,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
|
"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\"}}",
|
||||||
"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-07-06T08:45:05Z",
|
"anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:05Z",
|
||||||
"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-07-06T08:45:05Z",
|
"anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:05Z",
|
||||||
"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-07-06T08:45:04Z",
|
"anthropic-ratelimit-requests-reset": "2026-09-10T07:12:05Z",
|
||||||
"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-07-06T08:45:05Z",
|
"anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:05Z",
|
||||||
"cf-cache-status": "DYNAMIC",
|
"cf-cache-status": "DYNAMIC",
|
||||||
"cf-ray": "a16d54c878bc589a-BCN",
|
"cf-ray": "a38c9f4f38babc7b-PRG",
|
||||||
"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": "Mon, 06 Jul 2026 08:45:05 GMT",
|
"date": "Thu, 10 Sep 2026 07:12:06 GMT",
|
||||||
"request-id": "req_011CckWYpHUBbu1v83kyVSuv",
|
"request-id": "req_011CeuLqwpw3fPBk6xt6nrrm",
|
||||||
"server": "cloudflare",
|
"server": "cloudflare",
|
||||||
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
||||||
"traceresponse": "00-5d29afad8d6483cabba40e5acf4a5fbf-0d9b0424157724b8-01",
|
"traceresponse": "00-d17795404368021a8e502b8755cc4b35-c750063766d5eb87-01",
|
||||||
"vary": "Accept-Encoding",
|
"vary": "Accept-Encoding",
|
||||||
"x-robots-tag": "none"
|
"x-robots-tag": "none"
|
||||||
},
|
},
|
||||||
|
|
@ -234,7 +227,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."
|
"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>"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"messages": [
|
"messages": [
|
||||||
|
|
@ -252,7 +245,7 @@
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "tool_use",
|
"type": "tool_use",
|
||||||
"id": "toolu_017u8Xd5bungZRiNvzXXLg7p",
|
"id": "toolu_01Krds9KGve8gJNTNa9jn6N7",
|
||||||
"name": "load_skill",
|
"name": "load_skill",
|
||||||
"input": {
|
"input": {
|
||||||
"skillId": "engineering-status"
|
"skillId": "engineering-status"
|
||||||
|
|
@ -268,13 +261,8 @@
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "tool_result",
|
"type": "tool_result",
|
||||||
"tool_use_id": "toolu_017u8Xd5bungZRiNvzXXLg7p",
|
"tool_use_id": "toolu_01Krds9KGve8gJNTNa9jn6N7",
|
||||||
"content": [
|
"content": "{\"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."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -283,11 +271,11 @@
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": "Now I'll format the status token as instructed:"
|
"text": "Now I'll format the status token:"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "tool_use",
|
"type": "tool_use",
|
||||||
"id": "toolu_01LaHkuQAUWKJMdaMRfDEgPM",
|
"id": "toolu_01CKRsNPiekmX9TWf3ooQdHU",
|
||||||
"name": "format_status",
|
"name": "format_status",
|
||||||
"input": {
|
"input": {
|
||||||
"status": "waiting_on_vendor_info"
|
"status": "waiting_on_vendor_info"
|
||||||
|
|
@ -303,7 +291,7 @@
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "tool_result",
|
"type": "tool_result",
|
||||||
"tool_use_id": "toolu_01LaHkuQAUWKJMdaMRfDEgPM",
|
"tool_use_id": "toolu_01CKRsNPiekmX9TWf3ooQdHU",
|
||||||
"content": "{\"formatted\":\"STATUS_FORMATTED_BRAVO: WAITING_ON_VENDOR_INFO\"}"
|
"content": "{\"formatted\":\"STATUS_FORMATTED_BRAVO: WAITING_ON_VENDOR_INFO\"}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -353,30 +341,29 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_018vDNTGTPYvQft63MaduXM2\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"The status token has been formatted as requested:\\n\\n**STATUS_FORMATTED_BRAVO: WAITING_ON_VENDOR_INFO**\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1425,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":33,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
|
"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\"}}",
|
||||||
"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-07-06T08:45:06Z",
|
"anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:06Z",
|
||||||
"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-07-06T08:45:06Z",
|
"anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:06Z",
|
||||||
"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-07-06T08:45:06Z",
|
"anthropic-ratelimit-requests-reset": "2026-09-10T07:12:06Z",
|
||||||
"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-07-06T08:45:06Z",
|
"anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:06Z",
|
||||||
"cf-cache-status": "DYNAMIC",
|
"cf-cache-status": "DYNAMIC",
|
||||||
"cf-ray": "a16d54cf88d1589a-BCN",
|
"cf-ray": "a38c9f561a95bc7b-PRG",
|
||||||
"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": "Mon, 06 Jul 2026 08:45:06 GMT",
|
"date": "Thu, 10 Sep 2026 07:12:07 GMT",
|
||||||
"request-id": "req_011CckWYu6wR6tBum89WmZrE",
|
"request-id": "req_011CeuLr2Whz42pkAm7AzGhs",
|
||||||
"server": "cloudflare",
|
"server": "cloudflare",
|
||||||
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
||||||
"traceresponse": "00-0b21690b3bc7148f30f536a4baea9082-3a53115aa7a77a17-01",
|
"traceresponse": "00-80319e2d05f81fe007810b9d0f60ed01-c7956b0a3a60554e-01",
|
||||||
"vary": "Accept-Encoding",
|
"vary": "Accept-Encoding",
|
||||||
"x-robots-tag": "none"
|
"x-robots-tag": "none"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -51,30 +51,29 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_017Ydh5amLQEJMZi2SL2BFZp\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01L1gQY84Qqkbb2Vj91eQHay\",\"name\":\"load_skill\",\"input\":{\"skillId\":\"summary-marker\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1079,\"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_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\"}}",
|
||||||
"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": "9999000",
|
||||||
"anthropic-ratelimit-input-tokens-reset": "2026-07-06T08:44:58Z",
|
"anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:02Z",
|
||||||
"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-07-06T08:44:58Z",
|
"anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:02Z",
|
||||||
"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-07-06T08:44:57Z",
|
"anthropic-ratelimit-requests-reset": "2026-09-10T07:12:02Z",
|
||||||
"anthropic-ratelimit-tokens-limit": "12000000",
|
"anthropic-ratelimit-tokens-limit": "12000000",
|
||||||
"anthropic-ratelimit-tokens-remaining": "12000000",
|
"anthropic-ratelimit-tokens-remaining": "11999000",
|
||||||
"anthropic-ratelimit-tokens-reset": "2026-07-06T08:44:58Z",
|
"anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:02Z",
|
||||||
"cf-cache-status": "DYNAMIC",
|
"cf-cache-status": "DYNAMIC",
|
||||||
"cf-ray": "a16d549adc3e589a-BCN",
|
"cf-ray": "a38c9f3bd9d2bc7b-PRG",
|
||||||
"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": "Mon, 06 Jul 2026 08:44:58 GMT",
|
"date": "Thu, 10 Sep 2026 07:12:02 GMT",
|
||||||
"request-id": "req_011CckWYH41USJ75EenpGCQY",
|
"request-id": "req_011CeuLqiWV7tDxhVGbXmda8",
|
||||||
"server": "cloudflare",
|
"server": "cloudflare",
|
||||||
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
||||||
"traceresponse": "00-97b48f09aca0aecc15e1ed8c9fe8651d-dd31bf983e86dc71-01",
|
"traceresponse": "00-7cf7d7c3cfa9378a22214da3c62a1ea0-1ad2b8e7d42a1569-01",
|
||||||
"vary": "Accept-Encoding",
|
"vary": "Accept-Encoding",
|
||||||
"x-robots-tag": "none"
|
"x-robots-tag": "none"
|
||||||
},
|
},
|
||||||
|
|
@ -90,7 +89,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."
|
"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>"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"messages": [
|
"messages": [
|
||||||
|
|
@ -108,7 +107,7 @@
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "tool_use",
|
"type": "tool_use",
|
||||||
"id": "toolu_01L1gQY84Qqkbb2Vj91eQHay",
|
"id": "toolu_01Eq5aFLJhGriLQF4UBg7tyT",
|
||||||
"name": "load_skill",
|
"name": "load_skill",
|
||||||
"input": {
|
"input": {
|
||||||
"skillId": "summary-marker"
|
"skillId": "summary-marker"
|
||||||
|
|
@ -124,13 +123,8 @@
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "tool_result",
|
"type": "tool_result",
|
||||||
"tool_use_id": "toolu_01L1gQY84Qqkbb2Vj91eQHay",
|
"tool_use_id": "toolu_01Eq5aFLJhGriLQF4UBg7tyT",
|
||||||
"content": [
|
"content": "{\"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."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -163,30 +157,29 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01MLCY35L5o8Yag5erP4BPCM\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"**Summary:**\\n\\nn8n agents maintain workflow context efficiently by keeping it compact. This allows for streamlined execution and better memory management within automation workflows, reducing overhead while preserving essential information needed for agent operations.\\n\\nSKILL_MARKER_ALPHA\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1215,\"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\"}}",
|
"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\"}}",
|
||||||
"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-07-06T08:44:59Z",
|
"anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:03Z",
|
||||||
"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-07-06T08:45:00Z",
|
"anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:03Z",
|
||||||
"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-07-06T08:44:58Z",
|
"anthropic-ratelimit-requests-reset": "2026-09-10T07:12:02Z",
|
||||||
"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-07-06T08:44:59Z",
|
"anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:03Z",
|
||||||
"cf-cache-status": "DYNAMIC",
|
"cf-cache-status": "DYNAMIC",
|
||||||
"cf-ray": "a16d54a14884589a-BCN",
|
"cf-ray": "a38c9f417db9bc7b-PRG",
|
||||||
"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": "Mon, 06 Jul 2026 08:45:00 GMT",
|
"date": "Thu, 10 Sep 2026 07:12:03 GMT",
|
||||||
"request-id": "req_011CckWYMTgCwuyKTBSpSK89",
|
"request-id": "req_011CeuLqnRP3vkdg9opi7gNC",
|
||||||
"server": "cloudflare",
|
"server": "cloudflare",
|
||||||
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
||||||
"traceresponse": "00-2c3af9579d58ddd9db43455722b54b60-d02bea40ae7237b6-01",
|
"traceresponse": "00-86beb3374d817d1d427aabf5c903ca34-f5a5fa52f1cb2789-01",
|
||||||
"vary": "Accept-Encoding",
|
"vary": "Accept-Encoding",
|
||||||
"x-robots-tag": "none"
|
"x-robots-tag": "none"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -53,34 +53,32 @@
|
||||||
},
|
},
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"response": [
|
"response": [
|
||||||
"1f8b0800000000000013cd55cb6edb3010bcfb2b8c3dcb8094564dca6b50f4851445d25b1d101b7163d3a248950fd786a17f2f6859b1e447da4b9beac699a5766756bba22569cfc615398733e2cea3f523811ed978037e5d13301890907467601ba88c20050c0a8541d0648eb20c93d7937c72915ee4599a66908014f11d6ec6d3ecc65fb99b578bdbfcfdd5dab80fd75fc2cf2cbb83e4201324608d8a003a279d471dd316467bd21ed8f7fb049c3735b784ce68603a28b5831cfd08a40b1a80823c4ae53a2c74d54b5d07cfbd29493b60597af92681028b39f1c2127a69341f86a41d6f09c539aebb1b33503da78a2c2a9e57c7f17b369b1fb24d0226f83e945f26e0c82e6541dc4bb2c0205a23d08ae8b27e241b95f3191960a08de7b844a9f0411134cd78ff34a311b56ddf59ca1f9429cad3cd3f11b2cd2668d56aeef351f2ee9e3746f1e0a8fb00e239f034fb5abf7bfbed512fe6b3dbebcf2bfb290fe5dd6a050968ace23d655070574aa5b669eae0816d9ae8ad5251f35302212d15bea76c2fab967a76a0630c118473d205298fcf4bdf860ca4b7c8bea2b6830b67f453708dd64b545b101840f36c03fe51159b294483a1ff4dbc744deaa398021b4f5b8b5eb81817aa0aed7a52a1fd3f3a56929d4203cdf9c935f5ef06d7d4bd228e9adeedf893820624f4040cb6707fe4ff7017377f71191f6dcfc34eee7f6bc7e6f539180cc92ff4b205fb31070000"
|
"1f8b0800000000000013c5554d8bdb3010bde757189d1348c2ba21be9585d2427ae852e8a12942b1a6896259f2ea23ec12f4df3b72e2f823d96d2f253e18346fa479ef69d0c00194cb9212ac655ba0d631e3469c39962547e25e2b2019e98164dcac497624a5e620312597cc7398ec9828fce461924ee6d3793a9b4e67982e783cc36ee974367b04bf328ba765fa8def3fa63f5e162bf3b9c09c7e250c182d6380592bb0ac8a6573ad1c9225d9cf5fa705130a0cc99497724cacd31535c0ac56bd9085670f2a875e90036e96b689f9468e509577d4e9021482cbf4010bb17c0734c7939dd08af633a60d8e307f0b6bf6c60250eda004c3244dcbebfc169ded86681813ed5d3734ff806ac01c440ed489e8048956716678745dfd061385d32d68849476941d5035dba0b3212449188de074fb6767e946eabcb8dd033752ea221c5e4e52bb78547adee7b496d45b68fa20ae3d76c222cfbf2f3f6ded53517eddad9ee71b2b166a85598a95719fd468a82d84947519d48d878668a99451eaa500170672570b3a7fadae4aa8ed4048426290bca51dbbd9b1f7b5d7293deda7484be974737becc44b72857e09bcd77ddd9e84843ed1bb9038ae892561e8d95da8c47bfec2d7b52f77a69225688b2fb1a1eecba364e67582bf8284ebc6be0b23306b125a36ef528aafecdf9e105d7508b50735d3e6a6aa1e483a2a7acf7ff7d5f9e721309828e1ff8d85c13b9e2ec2c0cb76e05ebbd8c5c8e512fe00c6a9d291c7070000"
|
||||||
],
|
],
|
||||||
"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": "10000000",
|
||||||
"anthropic-ratelimit-input-tokens-reset": "2026-07-06T08:45:07Z",
|
"anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:07Z",
|
||||||
"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-07-06T08:45:07Z",
|
"anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:07Z",
|
||||||
"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-07-06T08:45:07Z",
|
"anthropic-ratelimit-requests-reset": "2026-09-10T07:12:07Z",
|
||||||
"anthropic-ratelimit-tokens-limit": "12000000",
|
"anthropic-ratelimit-tokens-limit": "12000000",
|
||||||
"anthropic-ratelimit-tokens-remaining": "11999000",
|
"anthropic-ratelimit-tokens-remaining": "12000000",
|
||||||
"anthropic-ratelimit-tokens-reset": "2026-07-06T08:45:07Z",
|
"anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:07Z",
|
||||||
"cache-control": "no-cache",
|
"cache-control": "no-cache",
|
||||||
"cf-cache-status": "DYNAMIC",
|
"cf-cache-status": "DYNAMIC",
|
||||||
"cf-ray": "a16d54d64e86589a-BCN",
|
"cf-ray": "a38c9f5c5a57bc7b-PRG",
|
||||||
"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": "Mon, 06 Jul 2026 08:45:08 GMT",
|
"date": "Thu, 10 Sep 2026 07:12:08 GMT",
|
||||||
"request-id": "req_011CckWYyhktjiUUzVGjbaRn",
|
"request-id": "req_011CeuLr6og4ahrpyfnPec45",
|
||||||
"server": "cloudflare",
|
"server": "cloudflare",
|
||||||
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
||||||
"traceresponse": "00-540508076234ed11018ad8b9be047cfd-9a43dd87abb7b745-01",
|
"traceresponse": "00-db154d560f530266545ecc9baf7015e2-c0470cb196446bc0-01",
|
||||||
"transfer-encoding": "chunked",
|
|
||||||
"vary": "Accept-Encoding",
|
"vary": "Accept-Encoding",
|
||||||
"x-robots-tag": "none"
|
"x-robots-tag": "none"
|
||||||
},
|
},
|
||||||
|
|
@ -96,7 +94,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."
|
"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>"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"messages": [
|
"messages": [
|
||||||
|
|
@ -114,7 +112,7 @@
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "tool_use",
|
"type": "tool_use",
|
||||||
"id": "toolu_01PpE9TfnjhgRCKxrJ5ukSxx",
|
"id": "toolu_017ccT9FgsRkmMhLq2bsi7nL",
|
||||||
"name": "load_skill",
|
"name": "load_skill",
|
||||||
"input": {
|
"input": {
|
||||||
"skillId": "summary-marker"
|
"skillId": "summary-marker"
|
||||||
|
|
@ -130,13 +128,8 @@
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "tool_result",
|
"type": "tool_result",
|
||||||
"tool_use_id": "toolu_01PpE9TfnjhgRCKxrJ5ukSxx",
|
"tool_use_id": "toolu_017ccT9FgsRkmMhLq2bsi7nL",
|
||||||
"content": [
|
"content": "{\"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."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -171,34 +164,32 @@
|
||||||
},
|
},
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"response": [
|
"response": [
|
||||||
"1f8b0800000000000013c555c16e133110bdf72bac39461b940482c0b71c10452d0835a812a2c89aaea71b13afbdd8b36d4a15898fe00bf912e4dd6c9a4d5bcaa5229728f3c69ef7de782674498ea52829462c4845c6c0071a19a5b801beae0824f440c8badf206fa0f49a2c48c82dd69a860b34cb7af862381d4e4693e978341a430646a73b62a146e3d7d5f3cf85bf08a76f4f5787935978f56d8ed34f90ed55820c82b72980319ac8e852d9dc3b26c720bf7ccd20b2af54208cde8174b5b59b50a4ef35b99c7a414d8cc6c62e5677ec8dab6a56ec97e422c8f1643cc920c77c412a0f846cbc53fd9451870742fd10d69d4d15a85a504901ad9a9677f36fd1f1621f5d67e06bde0d4d3288142e4d4e8a0d0590909cd1187432d95d5048c255411e2438cf0a2fd1583cb704ebb5683feb83036a3bbe71539d5b9f2fefeffb3d294d254dab56ee2e9ed46ece31ad5262f32501d6fdc29571c55e250129080f91d36419ff4eae49e9916b237d52dbb40db50ffe4ac4ba2c314067d0233e3d1915f3c3b8425414042f48c4a5b156181739d4797a4b519eb9333718cc1bb6d772303873730e84653a8605398e824ac382bdb7a2e11e7ffffcc50bba1605390ac824d06978ec253c994211c9e9969948d78b401699b460df926ec652182702a11db229291368adbf4a122f7c10a62c499b24c49fa74968a6ec3f8b0a142bef226d5518c714b06d9ad075d8f647d08af23ac59fa55ece8fde1d1fabf7b393a337276a76fcf170d67b830f0faaaf1e9b535fede8e8ddd56df27bfde881b0a3bfb76b819c565c0707ffbc71d74fb872f776e4cbd1deb6b9fdefbaebdb2e065bdbff006bc3e0fa12070000"
|
"1f8b0800000000000013c555cb8e1a3110bcf315968f082266b3ec616e681569a32551b428a76c6419bb99b1f0d8133f106885948fc817e64bd21e98c0f0489443122e305d6557755334b00213725281f7bc00e60377a12779e03979a1615303cd6907a483f699e62fb4b212345284e651c2b0e46a1987b7c3f1f0667433ce46a30ce94aa63b7cc14659760f71eaee65b40f73fbf1bd288a30dddc21a7ab840567752a70ef15ca9a242bac096896e69f3eef1eb832e0686ea2d603ea83ad9903eeade9943c7c896004748a12f0b0f66d2db6ed2853c7c0825d824130cb5edfa112172530815707650deb52462d8eb0bc86b5679302d42554e0b866e3ea9c7f40b3f214dd0ea88da1e30fbb01b7520258506912348d4a7227d3d4cd025c6a9c156011323630bec2aef91c27bbdd1242b6bd1eecbefefd68d95c5bb1bc1c820b944645c27ad7ea319e3add9f0bb04ec4e62da734e936af8378ad4c71a246682ad26b06317381ffda6043e918dc55bac67ed2f6f6fafdbdc17fae3c8b55c5dd26eff7c92c605e2aec9f602a4df0042a1548b05693c693fffef51b274bd8e0af5694dc285f918575043f4add1cf31b234a678d8d9e28f4e6b848f9f30419a47676a564e2a18c1e0655015900c839174b22a34bc84e6c0d2236b9fd4f3379f56c9ecdecf1ed74cade4d9e1edf3cb1c9f4c3c3e490a1b3289d86d4d6bf8bb1ad8f6c5db9b5dd7f171bed80f4a8b1ce42a260240bd119fa076be964c76dffe2a23ad92cb7d9f5191ffe0dcea77b8cd1c3c91f64c432c465060000"
|
||||||
],
|
],
|
||||||
"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": "10000000",
|
||||||
"anthropic-ratelimit-input-tokens-reset": "2026-07-06T08:45:08Z",
|
"anthropic-ratelimit-input-tokens-reset": "2026-09-10T07:12:08Z",
|
||||||
"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-07-06T08:45:08Z",
|
"anthropic-ratelimit-output-tokens-reset": "2026-09-10T07:12:08Z",
|
||||||
"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-07-06T08:45:08Z",
|
"anthropic-ratelimit-requests-reset": "2026-09-10T07:12:08Z",
|
||||||
"anthropic-ratelimit-tokens-limit": "12000000",
|
"anthropic-ratelimit-tokens-limit": "12000000",
|
||||||
"anthropic-ratelimit-tokens-remaining": "11999000",
|
"anthropic-ratelimit-tokens-remaining": "12000000",
|
||||||
"anthropic-ratelimit-tokens-reset": "2026-07-06T08:45:08Z",
|
"anthropic-ratelimit-tokens-reset": "2026-09-10T07:12:08Z",
|
||||||
"cache-control": "no-cache",
|
"cache-control": "no-cache",
|
||||||
"cf-cache-status": "DYNAMIC",
|
"cf-cache-status": "DYNAMIC",
|
||||||
"cf-ray": "a16d54dedfda589a-BCN",
|
"cf-ray": "a38c9f63ce57bc7b-PRG",
|
||||||
"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": "Mon, 06 Jul 2026 08:45:10 GMT",
|
"date": "Thu, 10 Sep 2026 07:12:08 GMT",
|
||||||
"request-id": "req_011CckWZ5cCxTdKpxXsHDHfM",
|
"request-id": "req_011CeuLrBuGK4E7uxpMM6NMq",
|
||||||
"server": "cloudflare",
|
"server": "cloudflare",
|
||||||
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
|
||||||
"traceresponse": "00-c629ba9e76d576cd6e5f26e49c3eccda-a115b633407cc82c-01",
|
"traceresponse": "00-ae497abd3e1ae1a20e06ccaeba57c754-f8de5c4ed0f0dac4-01",
|
||||||
"transfer-encoding": "chunked",
|
|
||||||
"vary": "Accept-Encoding",
|
"vary": "Accept-Encoding",
|
||||||
"x-robots-tag": "none"
|
"x-robots-tag": "none"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,8 @@ export type {
|
||||||
RuntimeSkillRegistry,
|
RuntimeSkillRegistry,
|
||||||
RuntimeSkillRegistryEntry,
|
RuntimeSkillRegistryEntry,
|
||||||
RuntimeSkillSource,
|
RuntimeSkillSource,
|
||||||
|
RuntimeSkillStateScope,
|
||||||
|
RuntimeSkillStateStore,
|
||||||
RuntimeSkillValidationError,
|
RuntimeSkillValidationError,
|
||||||
RuntimeSkillValidationResult,
|
RuntimeSkillValidationResult,
|
||||||
} from './skills';
|
} from './skills';
|
||||||
|
|
|
||||||
194
packages/@n8n/agents/src/runtime/__tests__/active-skills.test.ts
Normal file
194
packages/@n8n/agents/src/runtime/__tests__/active-skills.test.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import { createRuntimeSkillSource, filterRuntimeSkillSource } from '../../skills/registry';
|
||||||
|
import type { RuntimeSkillSource } from '../../skills/types';
|
||||||
|
import { InMemoryMemory } from '../memory/memory-store';
|
||||||
|
import { AgentMessageList } from '../model/message-list';
|
||||||
|
import { ActiveSkills } from '../skills/active-skills';
|
||||||
|
|
||||||
|
const scope = { threadId: 'thread-1', resourceId: 'user-1' };
|
||||||
|
const source = createRuntimeSkillSource([
|
||||||
|
{
|
||||||
|
id: 'planning',
|
||||||
|
name: 'planning',
|
||||||
|
description: 'Plan tasks.',
|
||||||
|
instructions: 'Create a task plan.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'builder',
|
||||||
|
name: 'builder',
|
||||||
|
description: 'Build workflows.',
|
||||||
|
instructions: 'Build one workflow.',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
describe('active skills', () => {
|
||||||
|
it('does not rewrite unchanged stored skills on later turns', async () => {
|
||||||
|
const memory = new InMemoryMemory();
|
||||||
|
await memory.skillState.save({ ...scope, agentName: 'assistant' }, ['builder', 'planning']);
|
||||||
|
const save = vi.spyOn(memory.skillState, 'save');
|
||||||
|
|
||||||
|
for (let turn = 0; turn < 2; turn++) {
|
||||||
|
const active = new ActiveSkills(source, 'assistant', memory.skillState);
|
||||||
|
await active.restore(new AgentMessageList(), scope);
|
||||||
|
expect(active.instructions()).toContain('Build one workflow.');
|
||||||
|
expect(active.instructions()).toContain('Create a task plan.');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not rewrite an explicitly empty stored state', async () => {
|
||||||
|
const memory = new InMemoryMemory();
|
||||||
|
await memory.skillState.save({ ...scope, agentName: 'assistant' }, []);
|
||||||
|
const save = vi.spyOn(memory.skillState, 'save');
|
||||||
|
const active = new ActiveSkills(source, 'assistant', memory.skillState);
|
||||||
|
const list = new AgentMessageList();
|
||||||
|
|
||||||
|
await active.restore(list, scope);
|
||||||
|
|
||||||
|
expect(list.activeSkillIds).toEqual([]);
|
||||||
|
expect(active.instructions()).toBeUndefined();
|
||||||
|
expect(save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears stored state when all restored skills are disabled', async () => {
|
||||||
|
const memory = new InMemoryMemory();
|
||||||
|
await memory.skillState.save({ ...scope, agentName: 'assistant' }, ['planning']);
|
||||||
|
const active = new ActiveSkills(
|
||||||
|
filterRuntimeSkillSource(source, ['planning']),
|
||||||
|
'assistant',
|
||||||
|
memory.skillState,
|
||||||
|
);
|
||||||
|
|
||||||
|
await active.restore(new AgentMessageList(), scope);
|
||||||
|
|
||||||
|
expect(active.instructions()).toBeUndefined();
|
||||||
|
await expect(memory.skillState.load({ ...scope, agentName: 'assistant' })).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initializes stored state from historical skill loads', 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);
|
||||||
|
|
||||||
|
expect(active.instructions()).toContain('Build one workflow.');
|
||||||
|
await expect(memory.skillState.load({ ...scope, agentName: 'assistant' })).resolves.toEqual([
|
||||||
|
'builder',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists checkpoint skills when they differ from stored state', async () => {
|
||||||
|
const memory = new InMemoryMemory();
|
||||||
|
await memory.skillState.save({ ...scope, agentName: 'assistant' }, ['planning']);
|
||||||
|
const list = new AgentMessageList();
|
||||||
|
list.activeSkillIds = ['builder'];
|
||||||
|
const active = new ActiveSkills(source, 'assistant', memory.skillState);
|
||||||
|
|
||||||
|
await active.restore(list, scope);
|
||||||
|
|
||||||
|
expect(active.instructions()).toContain('Build one workflow.');
|
||||||
|
expect(active.instructions()).not.toContain('Create a task plan.');
|
||||||
|
await expect(memory.skillState.load({ ...scope, agentName: 'assistant' })).resolves.toEqual([
|
||||||
|
'builder',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes disabled skills from restored state and from historical tool output', async () => {
|
||||||
|
const memory = new InMemoryMemory();
|
||||||
|
await memory.skillState.save({ ...scope, agentName: 'assistant' }, ['planning', 'builder']);
|
||||||
|
const list = new AgentMessageList();
|
||||||
|
list.addHistory([
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool-call',
|
||||||
|
toolName: 'load_skill',
|
||||||
|
toolCallId: 'old-load',
|
||||||
|
input: { skillId: 'planning' },
|
||||||
|
state: 'resolved',
|
||||||
|
output: { type: 'content', value: [{ type: 'text', text: 'Create a task plan.' }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const active = new ActiveSkills(
|
||||||
|
filterRuntimeSkillSource(source, ['planning']),
|
||||||
|
'assistant',
|
||||||
|
memory.skillState,
|
||||||
|
);
|
||||||
|
await active.restore(list, scope);
|
||||||
|
|
||||||
|
expect(active.instructions()).toContain('Build one workflow.');
|
||||||
|
expect(active.instructions()).not.toContain('Create a task plan.');
|
||||||
|
expect(JSON.stringify(active.modelMessages(list.forLlm('').messages, list))).not.toContain(
|
||||||
|
'Create a task plan.',
|
||||||
|
);
|
||||||
|
await expect(memory.skillState.load({ ...scope, agentName: 'assistant' })).resolves.toEqual([
|
||||||
|
'builder',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps threads, resources, and agents isolated', async () => {
|
||||||
|
const memory = new InMemoryMemory();
|
||||||
|
const active = new ActiveSkills(source, 'assistant', memory.skillState);
|
||||||
|
await active.restore(new AgentMessageList(), scope);
|
||||||
|
await active.load('builder');
|
||||||
|
for (const [name, persistence] of [
|
||||||
|
['other-agent', scope],
|
||||||
|
['assistant', { ...scope, resourceId: 'other-user' }],
|
||||||
|
['assistant', { ...scope, threadId: 'other-thread' }],
|
||||||
|
] as const) {
|
||||||
|
const other = new ActiveSkills(source, name, memory.skillState);
|
||||||
|
await other.restore(new AgentMessageList(), persistence);
|
||||||
|
expect(other.instructions()).toBeUndefined();
|
||||||
|
}
|
||||||
|
await memory.deleteThread(scope.threadId);
|
||||||
|
await expect(
|
||||||
|
memory.skillState.load({ ...scope, agentName: 'assistant' }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not activate a missing skill or a failed load', async () => {
|
||||||
|
const missing: RuntimeSkillSource = { ...source, loadSkill: vi.fn().mockResolvedValue(null) };
|
||||||
|
const active = new ActiveSkills(missing, 'assistant');
|
||||||
|
const list = new AgentMessageList();
|
||||||
|
await active.restore(list);
|
||||||
|
await expect(active.load('unknown')).resolves.toBeNull();
|
||||||
|
await expect(active.load('builder')).resolves.toBeNull();
|
||||||
|
expect(list.activeSkillIds).toEqual([]);
|
||||||
|
expect(active.instructions()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records concurrent loads without dropping either skill', async () => {
|
||||||
|
const memory = new InMemoryMemory();
|
||||||
|
const active = new ActiveSkills(source, 'assistant', memory.skillState);
|
||||||
|
const list = new AgentMessageList();
|
||||||
|
await active.restore(list, scope);
|
||||||
|
await Promise.all([active.load('builder'), active.load('planning')]);
|
||||||
|
await expect(memory.skillState.load({ ...scope, agentName: 'assistant' })).resolves.toEqual([
|
||||||
|
'builder',
|
||||||
|
'planning',
|
||||||
|
]);
|
||||||
|
expect(AgentMessageList.deserialize(structuredClone(list.serialize())).activeSkillIds).toEqual([
|
||||||
|
'builder',
|
||||||
|
'planning',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -8,6 +8,9 @@ import { Agent } from '../../sdk/agent';
|
||||||
import { createCancellation } from '../../sdk/cancellation';
|
import { createCancellation } from '../../sdk/cancellation';
|
||||||
import { isLlmMessage } from '../../sdk/message';
|
import { isLlmMessage } from '../../sdk/message';
|
||||||
import { Tool, Tool as ToolBuilder } from '../../sdk/tool';
|
import { Tool, Tool as ToolBuilder } from '../../sdk/tool';
|
||||||
|
import { createRuntimeSkillSource } from '../../skills/registry';
|
||||||
|
import { createRuntimeSkillTools } from '../../skills/tools';
|
||||||
|
import type { RuntimeSkillSource } from '../../skills/types';
|
||||||
import type { CheckpointStore, SerializableAgentState } from '../../types';
|
import type { CheckpointStore, SerializableAgentState } from '../../types';
|
||||||
import { AgentEvent } from '../../types/runtime/event';
|
import { AgentEvent } from '../../types/runtime/event';
|
||||||
import type { AgentEventData } from '../../types/runtime/event';
|
import type { AgentEventData } from '../../types/runtime/event';
|
||||||
|
|
@ -6598,7 +6601,11 @@ describe('AgentRuntime — mid-run observation', () => {
|
||||||
|
|
||||||
function buildMidRunRuntime(
|
function buildMidRunRuntime(
|
||||||
memory: InMemoryMemory,
|
memory: InMemoryMemory,
|
||||||
extra?: { tools?: BuiltTool[]; checkpointStorage?: CheckpointStore },
|
extra?: {
|
||||||
|
skillSource?: RuntimeSkillSource;
|
||||||
|
tools?: BuiltTool[];
|
||||||
|
checkpointStorage?: CheckpointStore;
|
||||||
|
},
|
||||||
): AgentRuntime {
|
): AgentRuntime {
|
||||||
return new AgentRuntime({
|
return new AgentRuntime({
|
||||||
name: 'mid-run-agent',
|
name: 'mid-run-agent',
|
||||||
|
|
@ -6606,6 +6613,7 @@ describe('AgentRuntime — mid-run observation', () => {
|
||||||
instructions: 'You are a test assistant.',
|
instructions: 'You are a test assistant.',
|
||||||
memory,
|
memory,
|
||||||
tools: extra?.tools ?? [makeStepTool()],
|
tools: extra?.tools ?? [makeStepTool()],
|
||||||
|
...(extra?.skillSource ? { skillSource: extra.skillSource } : {}),
|
||||||
...(extra?.checkpointStorage ? { checkpointStorage: extra.checkpointStorage } : {}),
|
...(extra?.checkpointStorage ? { checkpointStorage: extra.checkpointStorage } : {}),
|
||||||
observationalMemory: {
|
observationalMemory: {
|
||||||
observerThresholdTokens: 1,
|
observerThresholdTokens: 1,
|
||||||
|
|
@ -6646,6 +6654,130 @@ describe('AgentRuntime — mid-run observation', () => {
|
||||||
expect(await memory.getCursor('thread-1')).not.toBeNull();
|
expect(await memory.getCursor('thread-1')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists full skill content when the memory adapter has no skill state store', async () => {
|
||||||
|
const instructions = 'Wait for a real execution before extending the workflow.';
|
||||||
|
const source = createRuntimeSkillSource([
|
||||||
|
{ id: 'builder', name: 'builder', description: 'Build workflows.', instructions },
|
||||||
|
]);
|
||||||
|
const memory = new InMemoryMemory();
|
||||||
|
Object.defineProperty(memory, 'skillState', { value: undefined });
|
||||||
|
const runtime = buildMidRunRuntime(memory, {
|
||||||
|
skillSource: source,
|
||||||
|
tools: createRuntimeSkillTools(source),
|
||||||
|
});
|
||||||
|
generateText
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
makeGenerateWithToolCall('load-builder', 'load_skill', { skillId: 'builder' }),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(makeGenerateSuccess('Please test the first workflow.'));
|
||||||
|
|
||||||
|
await runtime.generate('Build it', { persistence: PERSISTENCE });
|
||||||
|
await runtime.dispose();
|
||||||
|
|
||||||
|
const messages = await memory.getMessages(PERSISTENCE.threadId);
|
||||||
|
const loads = messages
|
||||||
|
.flatMap((message) => (isLlmMessage(message) ? message.content : []))
|
||||||
|
.filter((part) => part.type === 'tool-call' && part.toolName === 'load_skill');
|
||||||
|
expect(loads).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
output: {
|
||||||
|
type: 'content',
|
||||||
|
value: [{ type: 'text', text: expect.stringContaining(instructions) }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(await memory.getCursor(PERSISTENCE.threadId)).not.toBeNull();
|
||||||
|
expect(flattenInstructions(capturedCall(1).instructions)).not.toContain('<active_skills>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains loaded skills through compaction and a new user turn', async () => {
|
||||||
|
const source = createRuntimeSkillSource([
|
||||||
|
{
|
||||||
|
id: 'builder',
|
||||||
|
name: 'builder',
|
||||||
|
description: 'Build workflows.',
|
||||||
|
instructions: 'Wait for a real execution before extending the workflow.',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const memory = new InMemoryMemory();
|
||||||
|
const options = { skillSource: source, tools: createRuntimeSkillTools(source) };
|
||||||
|
const runtime = buildMidRunRuntime(memory, options);
|
||||||
|
generateText
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
makeGenerateWithToolCall('load-builder', 'load_skill', { skillId: 'builder' }),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(makeGenerateSuccess('Please test the first workflow.'));
|
||||||
|
await runtime.generate('Build it', { persistence: PERSISTENCE });
|
||||||
|
await runtime.dispose();
|
||||||
|
|
||||||
|
expect(capturedCall(1).messages).toEqual([
|
||||||
|
{ role: 'user', content: OBSERVATION_CONTINUATION_REMINDER },
|
||||||
|
]);
|
||||||
|
expect(flattenInstructions(capturedCall(1).instructions)).toContain(
|
||||||
|
'Wait for a real execution',
|
||||||
|
);
|
||||||
|
const next = buildMidRunRuntime(memory, options);
|
||||||
|
generateText.mockResolvedValueOnce(makeGenerateSuccess('The live test is still needed.'));
|
||||||
|
await next.generate('Continue', { persistence: PERSISTENCE });
|
||||||
|
await next.dispose();
|
||||||
|
expect(flattenInstructions(capturedCall(2).instructions)).toContain(
|
||||||
|
'Wait for a real execution',
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(capturedCall(2).messages)).not.toContain('Wait for a real execution');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores active skills from a checkpoint with the newly selected content', async () => {
|
||||||
|
const source = createRuntimeSkillSource([
|
||||||
|
{
|
||||||
|
id: 'builder',
|
||||||
|
name: 'builder',
|
||||||
|
description: 'Build workflows.',
|
||||||
|
instructions: 'Old workflow policy.',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const checkpointStore = makeClaimingCheckpointStore();
|
||||||
|
const memory = new InMemoryMemory();
|
||||||
|
const first = buildMidRunRuntime(memory, {
|
||||||
|
skillSource: source,
|
||||||
|
tools: [...createRuntimeSkillTools(source), makeInterruptibleTool()],
|
||||||
|
checkpointStorage: checkpointStore,
|
||||||
|
});
|
||||||
|
generateText
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
makeGenerateWithToolCall('load-builder', 'load_skill', { skillId: 'builder' }),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
makeGenerateWithToolCall('confirm', 'approve', { question: 'Set up?' }),
|
||||||
|
);
|
||||||
|
const result = await first.generate('Build it', { persistence: PERSISTENCE });
|
||||||
|
const suspension = result.pendingSuspend?.[0];
|
||||||
|
if (!suspension) throw new Error('Expected a setup confirmation');
|
||||||
|
|
||||||
|
const current = createRuntimeSkillSource([
|
||||||
|
{
|
||||||
|
id: 'builder',
|
||||||
|
name: 'builder',
|
||||||
|
description: 'Build workflows.',
|
||||||
|
instructions: 'Current workflow policy.',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const resumed = buildMidRunRuntime(memory, {
|
||||||
|
skillSource: current,
|
||||||
|
tools: [...createRuntimeSkillTools(current), makeInterruptibleTool()],
|
||||||
|
checkpointStorage: checkpointStore,
|
||||||
|
});
|
||||||
|
generateText.mockResolvedValueOnce(makeGenerateSuccess('Ready.'));
|
||||||
|
await resumed.resume(
|
||||||
|
'generate',
|
||||||
|
{ approved: true },
|
||||||
|
{ runId: suspension.runId, toolCallId: suspension.toolCallId },
|
||||||
|
);
|
||||||
|
await first.dispose();
|
||||||
|
await resumed.dispose();
|
||||||
|
expect(flattenInstructions(capturedCall(2).instructions)).toContain('Current workflow policy.');
|
||||||
|
expect(JSON.stringify(capturedCall(2))).not.toContain('Old workflow policy.');
|
||||||
|
});
|
||||||
|
|
||||||
it('re-derives the mask from the cursor when resuming a suspended run', async () => {
|
it('re-derives the mask from the cursor when resuming a suspended run', async () => {
|
||||||
const memory = new InMemoryMemory();
|
const memory = new InMemoryMemory();
|
||||||
const checkpointStore = makeClaimingCheckpointStore();
|
const checkpointStore = makeClaimingCheckpointStore();
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,28 @@ describe('MemoryOrchestrator.maybeObserveMidRun', () => {
|
||||||
expect(list.forLlm('base').messages).toHaveLength(1);
|
expect(list.forLlm('base').messages).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does nothing when midRunObservation is disabled, even past the threshold', async () => {
|
||||||
|
const store = new InMemoryMemory();
|
||||||
|
const observe = vi.fn(
|
||||||
|
async () => await Promise.resolve('* CRITICAL (14:30) Should not appear.'),
|
||||||
|
);
|
||||||
|
const { orchestrator } = buildOrchestrator(store, {
|
||||||
|
observerThresholdTokens: 10,
|
||||||
|
midRunObservation: false,
|
||||||
|
observe,
|
||||||
|
observationLogTailLimit: 20,
|
||||||
|
});
|
||||||
|
const list = new AgentMessageList();
|
||||||
|
list.addInput([userMsg('a user message crossing the threshold')]);
|
||||||
|
list.addResponse([assistantMsg('an assistant reply with more work')]);
|
||||||
|
|
||||||
|
await orchestrator.maybeObserveMidRun(list, runOptions());
|
||||||
|
|
||||||
|
expect(observe).not.toHaveBeenCalled();
|
||||||
|
expect(await store.getMessagesForObservationScope(THREAD_ID)).toHaveLength(0);
|
||||||
|
expect(list.forLlm('base').messages).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('persists the turn, writes observations, advances the cursor, and masks the window on crossing', async () => {
|
it('persists the turn, writes observations, advances the cursor, and masks the window on crossing', async () => {
|
||||||
const store = new InMemoryMemory();
|
const store = new InMemoryMemory();
|
||||||
const { orchestrator } = buildOrchestrator(store, {
|
const { orchestrator } = buildOrchestrator(store, {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
import { StreamSink } from './stream-sink';
|
import { StreamSink } from './stream-sink';
|
||||||
import { isCancellation } from '../../sdk/cancellation';
|
import { isCancellation } from '../../sdk/cancellation';
|
||||||
import { computeCost, getModelCost, type ModelCost } from '../../sdk/catalog';
|
import { computeCost, getModelCost, type ModelCost } from '../../sdk/catalog';
|
||||||
|
import type { RuntimeSkillSource } from '../../skills/types';
|
||||||
import type {
|
import type {
|
||||||
BuiltFileStore,
|
BuiltFileStore,
|
||||||
BuiltMemory,
|
BuiltMemory,
|
||||||
|
|
@ -68,6 +69,7 @@ import {
|
||||||
getEffectiveAnthropicCacheTtl,
|
getEffectiveAnthropicCacheTtl,
|
||||||
mergeProviderOptions,
|
mergeProviderOptions,
|
||||||
} from '../model/prompt-cache';
|
} from '../model/prompt-cache';
|
||||||
|
import { ActiveSkills } from '../skills/active-skills';
|
||||||
import { BackgroundTaskTracker } from '../state/background-task-tracker';
|
import { BackgroundTaskTracker } from '../state/background-task-tracker';
|
||||||
import { AgentEventBus, type AgentAbortScope } from '../state/event-bus';
|
import { AgentEventBus, type AgentAbortScope } from '../state/event-bus';
|
||||||
import { generateRunId, RunStateManager, StaleResumeError } from '../state/run-state';
|
import { generateRunId, RunStateManager, StaleResumeError } from '../state/run-state';
|
||||||
|
|
@ -92,6 +94,7 @@ export interface AgentRuntimeConfig {
|
||||||
*/
|
*/
|
||||||
modelFetch?: FetchFn;
|
modelFetch?: FetchFn;
|
||||||
instructions: string;
|
instructions: string;
|
||||||
|
skillSource?: RuntimeSkillSource;
|
||||||
instructionProviderOptions?: ProviderOptions;
|
instructionProviderOptions?: ProviderOptions;
|
||||||
tools?: BuiltTool[];
|
tools?: BuiltTool[];
|
||||||
deferredTools?: BuiltTool[];
|
deferredTools?: BuiltTool[];
|
||||||
|
|
@ -200,9 +203,18 @@ export class AgentRuntime {
|
||||||
private context: RuntimeContextBuilder;
|
private context: RuntimeContextBuilder;
|
||||||
|
|
||||||
private toolExecutor: ToolCallExecutor;
|
private toolExecutor: ToolCallExecutor;
|
||||||
|
private activeSkills?: ActiveSkills;
|
||||||
|
|
||||||
constructor(config: AgentRuntimeConfig) {
|
constructor(config: AgentRuntimeConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
// Keep full tool results when the memory backend cannot persist active skill IDs.
|
||||||
|
if (config.skillSource && (!config.memory || config.memory.skillState)) {
|
||||||
|
this.activeSkills = new ActiveSkills(
|
||||||
|
config.skillSource,
|
||||||
|
config.name,
|
||||||
|
config.memory?.skillState,
|
||||||
|
);
|
||||||
|
}
|
||||||
const tokenCounter = createModelTokenCounter(config.model);
|
const tokenCounter = createModelTokenCounter(config.model);
|
||||||
this.telemetry = new RuntimeTelemetry(config);
|
this.telemetry = new RuntimeTelemetry(config);
|
||||||
this.runId = config.runId ?? generateRunId();
|
this.runId = config.runId ?? generateRunId();
|
||||||
|
|
@ -226,6 +238,7 @@ export class AgentRuntime {
|
||||||
onCancelled: () => this.updateState({ status: 'cancelled' }),
|
onCancelled: () => this.updateState({ status: 'cancelled' }),
|
||||||
tokenCounter,
|
tokenCounter,
|
||||||
...(config.workspaceFilesystem ? { workspaceFilesystem: config.workspaceFilesystem } : {}),
|
...(config.workspaceFilesystem ? { workspaceFilesystem: config.workspaceFilesystem } : {}),
|
||||||
|
...(this.activeSkills ? { loadSkill: this.activeSkills.load.bind(this.activeSkills) } : {}),
|
||||||
});
|
});
|
||||||
this.modelCost = config.modelCost;
|
this.modelCost = config.modelCost;
|
||||||
this.currentState = {
|
this.currentState = {
|
||||||
|
|
@ -753,6 +766,7 @@ export class AgentRuntime {
|
||||||
*/
|
*/
|
||||||
private async runAgentLoop<T>(ctx: LoopContext, sink: RunOutputSink<T>): Promise<T> {
|
private async runAgentLoop<T>(ctx: LoopContext, sink: RunOutputSink<T>): Promise<T> {
|
||||||
const { list, options, abortScope, pendingResume } = ctx;
|
const { list, options, abortScope, pendingResume } = ctx;
|
||||||
|
await this.activeSkills?.restore(list, options?.persistence);
|
||||||
this.context.hydrateDeferredToolsFromList(list);
|
this.context.hydrateDeferredToolsFromList(list);
|
||||||
// Inject a model-facing note for any MCP servers that failed to connect
|
// Inject a model-facing note for any MCP servers that failed to connect
|
||||||
// during build(). The agent can mention the outage to the user when
|
// during build(). The agent can mention the outage to the user when
|
||||||
|
|
@ -872,7 +886,10 @@ export class AgentRuntime {
|
||||||
options?.executionCounter,
|
options?.executionCounter,
|
||||||
);
|
);
|
||||||
const { system, messages } = list.forLlm(
|
const { system, messages } = list.forLlm(
|
||||||
effectiveInstructions,
|
// Skill content changes only on activation. Keep it cached when memory compacts.
|
||||||
|
[effectiveInstructions, this.activeSkills?.instructions()]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n'),
|
||||||
instructionProviderOptions,
|
instructionProviderOptions,
|
||||||
volatileInstructions,
|
volatileInstructions,
|
||||||
);
|
);
|
||||||
|
|
@ -880,7 +897,7 @@ export class AgentRuntime {
|
||||||
// only — never persisted back to the message list or tool set.
|
// only — never persisted back to the message list or tool set.
|
||||||
const cached = applyRuntimeCacheBreakpoints({
|
const cached = applyRuntimeCacheBreakpoints({
|
||||||
system,
|
system,
|
||||||
messages,
|
messages: this.activeSkills?.modelMessages(messages, list) ?? messages,
|
||||||
aiTools,
|
aiTools,
|
||||||
promptCaching: this.config.promptCaching,
|
promptCaching: this.config.promptCaching,
|
||||||
modelId: this.modelIdString,
|
modelId: this.modelIdString,
|
||||||
|
|
|
||||||
|
|
@ -495,6 +495,9 @@ export class MemoryOrchestrator {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { memory, observationalMemory } = this.config;
|
const { memory, observationalMemory } = this.config;
|
||||||
if (!memory || !options?.persistence || !hasObservationLogObserverMemory(memory)) return;
|
if (!memory || !options?.persistence || !hasObservationLogObserverMemory(memory)) return;
|
||||||
|
// Hosts can restrict the Observer to the post-turn path, so the visible
|
||||||
|
// window stays byte-stable within a turn (better prompt-cache reuse).
|
||||||
|
if (observationalMemory?.midRunObservation === false) return;
|
||||||
const observerThresholdTokens = observationalMemory?.observerThresholdTokens;
|
const observerThresholdTokens = observationalMemory?.observerThresholdTokens;
|
||||||
if (!observationalMemory?.observe || observerThresholdTokens === undefined) return;
|
if (!observationalMemory?.observe || observerThresholdTokens === undefined) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
uniqueStrings,
|
uniqueStrings,
|
||||||
} from './memory-lifecycle';
|
} from './memory-lifecycle';
|
||||||
import { normalizeObservationLogReflection } from './observation-log-reflector';
|
import { normalizeObservationLogReflection } from './observation-log-reflector';
|
||||||
|
import type { RuntimeSkillStateStore } from '../../skills/types';
|
||||||
import type {
|
import type {
|
||||||
BuiltEpisodicMemoryStore,
|
BuiltEpisodicMemoryStore,
|
||||||
BuiltMemory,
|
BuiltMemory,
|
||||||
|
|
@ -88,6 +89,23 @@ export class InMemoryMemory
|
||||||
private threads = new Map<string, Thread>();
|
private threads = new Map<string, Thread>();
|
||||||
|
|
||||||
private messagesByThread = new Map<string, StoredMessage[]>();
|
private messagesByThread = new Map<string, StoredMessage[]>();
|
||||||
|
private skillsByThread = new Map<string, Map<string, string[]>>();
|
||||||
|
|
||||||
|
readonly skillState: RuntimeSkillStateStore = {
|
||||||
|
load: async ({ threadId, resourceId, agentName }) =>
|
||||||
|
await Promise.resolve(
|
||||||
|
this.skillsByThread
|
||||||
|
.get(threadId)
|
||||||
|
?.get(JSON.stringify([resourceId, agentName]))
|
||||||
|
?.slice(),
|
||||||
|
),
|
||||||
|
save: async ({ threadId, resourceId, agentName }, ids) => {
|
||||||
|
const states = this.skillsByThread.get(threadId) ?? new Map<string, string[]>();
|
||||||
|
states.set(JSON.stringify([resourceId, agentName]), [...ids]);
|
||||||
|
this.skillsByThread.set(threadId, states);
|
||||||
|
await Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
private observationLogByScope = new Map<string, ObservationLogEntry[]>();
|
private observationLogByScope = new Map<string, ObservationLogEntry[]>();
|
||||||
|
|
||||||
|
|
@ -143,6 +161,7 @@ export class InMemoryMemory
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
async deleteThread(threadId: string): Promise<void> {
|
async deleteThread(threadId: string): Promise<void> {
|
||||||
this.threads.delete(threadId);
|
this.threads.delete(threadId);
|
||||||
|
this.skillsByThread.delete(threadId);
|
||||||
this.messagesByThread.delete(threadId);
|
this.messagesByThread.delete(threadId);
|
||||||
this.observationLogByScope.delete(threadId);
|
this.observationLogByScope.delete(threadId);
|
||||||
this.cursorsByScope.delete(threadId);
|
this.cursorsByScope.delete(threadId);
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,8 @@ type MessageSource = 'history' | 'input' | 'response';
|
||||||
* the full three-way source distinction survives a round-trip.
|
* the full three-way source distinction survives a round-trip.
|
||||||
*/
|
*/
|
||||||
export class AgentMessageList {
|
export class AgentMessageList {
|
||||||
|
activeSkillIds?: string[];
|
||||||
|
|
||||||
private all: AgentDbMessage[] = [];
|
private all: AgentDbMessage[] = [];
|
||||||
|
|
||||||
private historySet = new Set<AgentDbMessage>();
|
private historySet = new Set<AgentDbMessage>();
|
||||||
|
|
@ -414,11 +416,13 @@ export class AgentMessageList {
|
||||||
historyIds: toIds(this.historySet),
|
historyIds: toIds(this.historySet),
|
||||||
inputIds: toIds(this.inputSet),
|
inputIds: toIds(this.inputSet),
|
||||||
responseIds: toIds(this.responseSet),
|
responseIds: toIds(this.responseSet),
|
||||||
|
...(this.activeSkillIds !== undefined ? { activeSkillIds: [...this.activeSkillIds] } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static deserialize(data: SerializedMessageList): AgentMessageList {
|
static deserialize(data: SerializedMessageList): AgentMessageList {
|
||||||
const list = new AgentMessageList();
|
const list = new AgentMessageList();
|
||||||
|
list.activeSkillIds = data.activeSkillIds ? [...data.activeSkillIds] : undefined;
|
||||||
const historyIdSet = new Set(data.historyIds);
|
const historyIdSet = new Set(data.historyIds);
|
||||||
const inputIdSet = new Set(data.inputIds);
|
const inputIdSet = new Set(data.inputIds);
|
||||||
const responseIdSet = new Set(data.responseIds);
|
const responseIdSet = new Set(data.responseIds);
|
||||||
|
|
|
||||||
150
packages/@n8n/agents/src/runtime/skills/active-skills.ts
Normal file
150
packages/@n8n/agents/src/runtime/skills/active-skills.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import type { ModelMessage } from 'ai';
|
||||||
|
|
||||||
|
import { formatActiveSkill } from '../../skills/tools';
|
||||||
|
import {
|
||||||
|
SKILL_LOAD_TOOL_NAME,
|
||||||
|
type RuntimeSkillContent,
|
||||||
|
type RuntimeSkillSource,
|
||||||
|
type RuntimeSkillStateScope,
|
||||||
|
type RuntimeSkillStateStore,
|
||||||
|
} from '../../skills/types';
|
||||||
|
import type { AgentPersistenceOptions } from '../../types/sdk/agent';
|
||||||
|
import type { AgentMessageList } from '../model/message-list';
|
||||||
|
|
||||||
|
/** Keeps trusted skill instructions outside the conversation's compaction window. */
|
||||||
|
export class ActiveSkills {
|
||||||
|
private readonly loaded = new Map<string, RuntimeSkillContent>();
|
||||||
|
private list?: AgentMessageList;
|
||||||
|
private scope?: RuntimeSkillStateScope;
|
||||||
|
private pendingSave = Promise.resolve();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly source: RuntimeSkillSource,
|
||||||
|
private readonly agentName: string,
|
||||||
|
private readonly store?: RuntimeSkillStateStore,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async restore(list: AgentMessageList, persistence?: AgentPersistenceOptions): Promise<void> {
|
||||||
|
this.loaded.clear();
|
||||||
|
this.list = list;
|
||||||
|
this.scope = persistence
|
||||||
|
? {
|
||||||
|
threadId: persistence.threadId,
|
||||||
|
resourceId: persistence.resourceId,
|
||||||
|
agentName: this.agentName,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
const stored =
|
||||||
|
list.activeSkillIds === undefined && this.scope
|
||||||
|
? await this.store?.load(this.scope)
|
||||||
|
: undefined;
|
||||||
|
const ids = list.activeSkillIds ?? stored ?? [...this.recordedLoads(list).values()];
|
||||||
|
const registered = new Set(this.source.registry.skills.map(({ id }) => id));
|
||||||
|
for (const id of new Set(ids)) {
|
||||||
|
if (!registered.has(id)) continue;
|
||||||
|
const skill = await this.source.loadSkill(id);
|
||||||
|
if (skill) this.loaded.set(id, skill);
|
||||||
|
}
|
||||||
|
list.activeSkillIds = [...this.loaded.keys()];
|
||||||
|
const storeIsCurrent =
|
||||||
|
stored !== undefined &&
|
||||||
|
new Set(stored).size === this.loaded.size &&
|
||||||
|
stored.every((id) => this.loaded.has(id));
|
||||||
|
if (!storeIsCurrent && (ids.length > 0 || stored !== undefined)) await this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(skillId: string): Promise<RuntimeSkillContent | null> {
|
||||||
|
if (!this.source.registry.skills.some(({ id }) => id === skillId)) return null;
|
||||||
|
const skill = this.loaded.get(skillId) ?? (await this.source.loadSkill(skillId));
|
||||||
|
if (!skill) return null;
|
||||||
|
if (!this.list) throw new Error('Active skills must be restored before loading');
|
||||||
|
if (!this.loaded.has(skillId)) {
|
||||||
|
this.loaded.set(skillId, skill);
|
||||||
|
this.list.activeSkillIds = [...this.loaded.keys()];
|
||||||
|
await this.persist();
|
||||||
|
}
|
||||||
|
return skill;
|
||||||
|
}
|
||||||
|
|
||||||
|
instructions(): string | undefined {
|
||||||
|
if (this.loaded.size === 0) return undefined;
|
||||||
|
const sections = [...this.loaded].flatMap(([id, skill]) => {
|
||||||
|
const entry = this.source.registry.skills.find((candidate) => candidate.id === id);
|
||||||
|
return entry ? [formatActiveSkill(skill, entry)] : [];
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
'<active_skills>',
|
||||||
|
'Use these current skill versions when relevant to the task. Earlier tool results may describe older versions.',
|
||||||
|
...sections,
|
||||||
|
'</active_skills>',
|
||||||
|
].join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Old conversations can contain full skill bodies. Do not replay an obsolete version. */
|
||||||
|
modelMessages(messages: ModelMessage[], list: AgentMessageList): ModelMessage[] {
|
||||||
|
const loads = this.recordedLoads(list);
|
||||||
|
return messages.map((message) => {
|
||||||
|
if (message.role !== 'tool') return message;
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
content: message.content.map((part) => {
|
||||||
|
if (part.type !== 'tool-result') return part;
|
||||||
|
const skillId = loads.get(part.toolCallId);
|
||||||
|
if (!skillId) return part;
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
output: {
|
||||||
|
type: 'json' as const,
|
||||||
|
value: { skillId, active: this.loaded.has(skillId) },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordedLoads(list: AgentMessageList): Map<string, string> {
|
||||||
|
const loads = new Map<string, string>();
|
||||||
|
for (const message of list.messages()) {
|
||||||
|
if (!('content' in message)) continue;
|
||||||
|
for (const part of message.content) {
|
||||||
|
if (
|
||||||
|
part.type !== 'tool-call' ||
|
||||||
|
part.toolName !== SKILL_LOAD_TOOL_NAME ||
|
||||||
|
part.state !== 'resolved' ||
|
||||||
|
part.canceled ||
|
||||||
|
!part.input ||
|
||||||
|
typeof part.input !== 'object' ||
|
||||||
|
Array.isArray(part.input) ||
|
||||||
|
!part.output ||
|
||||||
|
typeof part.output !== 'object' ||
|
||||||
|
Array.isArray(part.output)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
if (part.output.success !== true && part.output.type !== 'content') continue;
|
||||||
|
const { skillId, name, filePath } = part.input;
|
||||||
|
if (typeof filePath === 'string' && !['', '/', '.', 'SKILL.md'].includes(filePath.trim()))
|
||||||
|
continue;
|
||||||
|
const requested =
|
||||||
|
typeof skillId === 'string' ? skillId : typeof name === 'string' ? name : undefined;
|
||||||
|
if (!requested) continue;
|
||||||
|
const entry = this.source.registry.skills.find(
|
||||||
|
(skill) => skill.id === requested || skill.name === requested,
|
||||||
|
);
|
||||||
|
loads.set(part.toolCallId, entry?.id ?? requested);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return loads;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persist(): Promise<void> {
|
||||||
|
const scope = this.scope;
|
||||||
|
const store = this.store;
|
||||||
|
if (!scope || !store) return;
|
||||||
|
const ids = [...(this.list?.activeSkillIds ?? [])];
|
||||||
|
// Concurrent tool calls must not overwrite a newer activation with an older set.
|
||||||
|
const save = this.pendingSave.then(async () => await store.save(scope, ids));
|
||||||
|
this.pendingSave = save.catch(() => undefined);
|
||||||
|
await save;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -139,6 +139,7 @@ export async function executeTool(
|
||||||
toolCallId,
|
toolCallId,
|
||||||
toolName: builtTool.name,
|
toolName: builtTool.name,
|
||||||
runId: executionContext.runId,
|
runId: executionContext.runId,
|
||||||
|
...(executionContext.loadSkill ? { loadSkill: executionContext.loadSkill } : {}),
|
||||||
persistence: executionContext.persistence,
|
persistence: executionContext.persistence,
|
||||||
emitEvent: executionContext.emitEvent,
|
emitEvent: executionContext.emitEvent,
|
||||||
abortSignal: executionContext.abortSignal,
|
abortSignal: executionContext.abortSignal,
|
||||||
|
|
@ -155,6 +156,7 @@ export async function executeTool(
|
||||||
toolCallId,
|
toolCallId,
|
||||||
toolName: builtTool.name,
|
toolName: builtTool.name,
|
||||||
runId: executionContext.runId,
|
runId: executionContext.runId,
|
||||||
|
...(executionContext.loadSkill ? { loadSkill: executionContext.loadSkill } : {}),
|
||||||
persistence: executionContext.persistence,
|
persistence: executionContext.persistence,
|
||||||
emitEvent: executionContext.emitEvent,
|
emitEvent: executionContext.emitEvent,
|
||||||
abortSignal: executionContext.abortSignal,
|
abortSignal: executionContext.abortSignal,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
import { isAbortError, raceWithAbort } from '../../sdk/abort';
|
import { isAbortError, raceWithAbort } from '../../sdk/abort';
|
||||||
import { isCancellation } from '../../sdk/cancellation';
|
import { isCancellation } from '../../sdk/cancellation';
|
||||||
import { isLlmMessage } from '../../sdk/message';
|
import { isLlmMessage } from '../../sdk/message';
|
||||||
|
import type { RuntimeSkillLoader } from '../../skills/types';
|
||||||
import type {
|
import type {
|
||||||
AgentExecutionCounter,
|
AgentExecutionCounter,
|
||||||
BuiltTelemetry,
|
BuiltTelemetry,
|
||||||
|
|
@ -178,6 +179,7 @@ function getToolResumeJsonSchema(
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolCallExecutorDeps {
|
export interface ToolCallExecutorDeps {
|
||||||
|
loadSkill?: RuntimeSkillLoader;
|
||||||
telemetry: RuntimeTelemetry;
|
telemetry: RuntimeTelemetry;
|
||||||
eventBus: AgentEventBus;
|
eventBus: AgentEventBus;
|
||||||
/** Effective tool-call concurrency (default 1 = sequential). */
|
/** Effective tool-call concurrency (default 1 = sequential). */
|
||||||
|
|
@ -980,6 +982,7 @@ export class ToolCallExecutor {
|
||||||
await executeTool(input, builtTool, resumeData, resolvedTelemetry, toolCallId, {
|
await executeTool(input, builtTool, resumeData, resolvedTelemetry, toolCallId, {
|
||||||
runId,
|
runId,
|
||||||
persistence,
|
persistence,
|
||||||
|
...(this.deps.loadSkill ? { loadSkill: this.deps.loadSkill } : {}),
|
||||||
emitEvent: (event) => this.eventBus.emit(event),
|
emitEvent: (event) => this.eventBus.emit(event),
|
||||||
abortSignal,
|
abortSignal,
|
||||||
executionCounter,
|
executionCounter,
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ describe('Memory builder — observation log memory', () => {
|
||||||
observationLogTailLimit: 20,
|
observationLogTailLimit: 20,
|
||||||
lockTtlMs: DEFAULT_OBSERVATION_LOG_LOCK_TTL_MS,
|
lockTtlMs: DEFAULT_OBSERVATION_LOG_LOCK_TTL_MS,
|
||||||
});
|
});
|
||||||
|
expect(resolved.midRunObservation).toBeUndefined();
|
||||||
expect(typeof resolved.observe).toBe('function');
|
expect(typeof resolved.observe).toBe('function');
|
||||||
expect(typeof resolved.reflect).toBe('function');
|
expect(typeof resolved.reflect).toBe('function');
|
||||||
});
|
});
|
||||||
|
|
@ -85,6 +86,7 @@ describe('Memory builder — observation log memory', () => {
|
||||||
renderTokenBudget: 789,
|
renderTokenBudget: 789,
|
||||||
observationLogTailLimit: 3,
|
observationLogTailLimit: 3,
|
||||||
lockTtlMs: 1_000,
|
lockTtlMs: 1_000,
|
||||||
|
midRunObservation: false,
|
||||||
observe,
|
observe,
|
||||||
reflect,
|
reflect,
|
||||||
},
|
},
|
||||||
|
|
@ -97,6 +99,7 @@ describe('Memory builder — observation log memory', () => {
|
||||||
renderTokenBudget: 789,
|
renderTokenBudget: 789,
|
||||||
observationLogTailLimit: 3,
|
observationLogTailLimit: 3,
|
||||||
lockTtlMs: 1_000,
|
lockTtlMs: 1_000,
|
||||||
|
midRunObservation: false,
|
||||||
});
|
});
|
||||||
expect(resolved.observe).toBe(observe);
|
expect(resolved.observe).toBe(observe);
|
||||||
expect(resolved.reflect).toBe(reflect);
|
expect(resolved.reflect).toBe(reflect);
|
||||||
|
|
@ -148,10 +151,27 @@ describe('Memory builder — observation log memory', () => {
|
||||||
observerThresholdTokens: 8_000,
|
observerThresholdTokens: 8_000,
|
||||||
reflectorThresholdTokens: 12_000,
|
reflectorThresholdTokens: 12_000,
|
||||||
});
|
});
|
||||||
|
expect(runtimeConfig.observationalMemory?.midRunObservation).toBeUndefined();
|
||||||
expect(typeof runtimeConfig.observationalMemory?.observe).toBe('function');
|
expect(typeof runtimeConfig.observationalMemory?.observe).toBe('function');
|
||||||
expect(typeof runtimeConfig.observationalMemory?.reflect).toBe('function');
|
expect(typeof runtimeConfig.observationalMemory?.reflect).toBe('function');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes midRunObservation through agent build', async () => {
|
||||||
|
const memory = new Memory()
|
||||||
|
.storage(new InMemoryMemory())
|
||||||
|
.observationalMemory({ midRunObservation: false });
|
||||||
|
const agent = new Agent('a')
|
||||||
|
.model('openai/gpt-4o-mini')
|
||||||
|
.instructions('You are a test assistant.')
|
||||||
|
.memory(memory);
|
||||||
|
|
||||||
|
const runtimeConfig = await (
|
||||||
|
agent as unknown as { build(): Promise<AgentRuntimeConfig> }
|
||||||
|
).build();
|
||||||
|
|
||||||
|
expect(runtimeConfig.observationalMemory?.midRunObservation).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects backends that do not implement the observation-log store', () => {
|
it('rejects backends that do not implement the observation-log store', () => {
|
||||||
const minimalBackend = {
|
const minimalBackend = {
|
||||||
getThread: vi.fn().mockResolvedValue(null),
|
getThread: vi.fn().mockResolvedValue(null),
|
||||||
|
|
|
||||||
|
|
@ -1114,6 +1114,7 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
||||||
model: modelConfig,
|
model: modelConfig,
|
||||||
...(this.modelFetchValue !== undefined ? { modelFetch: this.modelFetchValue } : {}),
|
...(this.modelFetchValue !== undefined ? { modelFetch: this.modelFetchValue } : {}),
|
||||||
instructions,
|
instructions,
|
||||||
|
...(this.skillSource ? { skillSource: this.skillSource } : {}),
|
||||||
tools: allTools.length > 0 ? allTools : undefined,
|
tools: allTools.length > 0 ? allTools : undefined,
|
||||||
deferredTools: finalDeferredTools.length > 0 ? finalDeferredTools : undefined,
|
deferredTools: finalDeferredTools.length > 0 ? finalDeferredTools : undefined,
|
||||||
...(this.workspaceInstance?.filesystem && this.workspaceInstance.filesystem.readOnly !== true
|
...(this.workspaceInstance?.filesystem && this.workspaceInstance.filesystem.readOnly !== true
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,9 @@ export function resolveObservationalMemoryConfig(
|
||||||
renderTokenBudget: config.renderTokenBudget ?? DEFAULT_OBSERVATION_LOG_RENDER_TOKEN_BUDGET,
|
renderTokenBudget: config.renderTokenBudget ?? DEFAULT_OBSERVATION_LOG_RENDER_TOKEN_BUDGET,
|
||||||
observationLogTailLimit: config.observationLogTailLimit ?? DEFAULT_OBSERVATION_LOG_TAIL_LIMIT,
|
observationLogTailLimit: config.observationLogTailLimit ?? DEFAULT_OBSERVATION_LOG_TAIL_LIMIT,
|
||||||
lockTtlMs: config.lockTtlMs ?? DEFAULT_OBSERVATION_LOG_LOCK_TTL_MS,
|
lockTtlMs: config.lockTtlMs ?? DEFAULT_OBSERVATION_LOG_LOCK_TTL_MS,
|
||||||
|
...(config.midRunObservation !== undefined
|
||||||
|
? { midRunObservation: config.midRunObservation }
|
||||||
|
: {}),
|
||||||
observe: config.observe ?? createObservationLogObserveFn(observerModel),
|
observe: config.observe ?? createObservationLogObserveFn(observerModel),
|
||||||
reflect: config.reflect ?? createObservationLogReflectFn(reflectorModel),
|
reflect: config.reflect ?? createObservationLogReflectFn(reflectorModel),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -519,6 +519,7 @@ Use the workflow SDK.`,
|
||||||
agent as unknown as { build(): Promise<AgentRuntimeConfig> }
|
agent as unknown as { build(): Promise<AgentRuntimeConfig> }
|
||||||
).build();
|
).build();
|
||||||
const { instructions } = runtimeConfig;
|
const { instructions } = runtimeConfig;
|
||||||
|
expect(runtimeConfig.skillSource).toBe(source);
|
||||||
|
|
||||||
expect(prepare).toHaveBeenCalledTimes(1);
|
expect(prepare).toHaveBeenCalledTimes(1);
|
||||||
expect(instructions).toContain('name: "Summarize notes"');
|
expect(instructions).toContain('name: "Summarize notes"');
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ export type {
|
||||||
RuntimeSkillRegistry,
|
RuntimeSkillRegistry,
|
||||||
RuntimeSkillRegistryEntry,
|
RuntimeSkillRegistryEntry,
|
||||||
RuntimeSkillSource,
|
RuntimeSkillSource,
|
||||||
|
RuntimeSkillStateScope,
|
||||||
|
RuntimeSkillStateStore,
|
||||||
RuntimeSkillValidationError,
|
RuntimeSkillValidationError,
|
||||||
RuntimeSkillValidationResult,
|
RuntimeSkillValidationResult,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import {
|
||||||
type RuntimeSkillRegistry,
|
type RuntimeSkillRegistry,
|
||||||
type RuntimeSkillRegistryEntry,
|
type RuntimeSkillRegistryEntry,
|
||||||
type RuntimeSkillSource,
|
type RuntimeSkillSource,
|
||||||
|
type RuntimeSkillLoader,
|
||||||
|
type RuntimeSkillContent,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const MAX_OUTPUT_BYTES = 64 * 1024;
|
const MAX_OUTPUT_BYTES = 64 * 1024;
|
||||||
|
|
@ -109,7 +111,10 @@ export function createSkillLoadTool(source: RuntimeSkillSource): BuiltTool {
|
||||||
)
|
)
|
||||||
.input(skillLoadInputSchema)
|
.input(skillLoadInputSchema)
|
||||||
.output(skillLoadResultSchema)
|
.output(skillLoadResultSchema)
|
||||||
.handler(async ({ skillId, name }) => await loadSkill(source, { skillId, name }))
|
.handler(
|
||||||
|
async ({ skillId, name }, context) =>
|
||||||
|
await loadSkill(source, { skillId, name }, context.loadSkill),
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,8 +126,8 @@ export function createSkillLoadTool(source: RuntimeSkillSource): BuiltTool {
|
||||||
.input(skillLoadInputWithFilesSchema)
|
.input(skillLoadInputWithFilesSchema)
|
||||||
.output(skillLoadResultSchema)
|
.output(skillLoadResultSchema)
|
||||||
.handler(
|
.handler(
|
||||||
async ({ skillId, name, filePath }) =>
|
async ({ skillId, name, filePath }, context) =>
|
||||||
await loadSkill(source, { skillId, name, filePath, loadFile }),
|
await loadSkill(source, { skillId, name, filePath, loadFile }, context.loadSkill),
|
||||||
)
|
)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
@ -135,6 +140,7 @@ async function loadSkill(
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
loadFile?: NonNullable<RuntimeSkillSource['loadFile']>;
|
loadFile?: NonNullable<RuntimeSkillSource['loadFile']>;
|
||||||
},
|
},
|
||||||
|
activate?: RuntimeSkillLoader,
|
||||||
): Promise<SkillLoadOutput | SkillLoadContentOutput> {
|
): Promise<SkillLoadOutput | SkillLoadContentOutput> {
|
||||||
const { skillId, name, filePath, loadFile } = input;
|
const { skillId, name, filePath, loadFile } = input;
|
||||||
await source.prepare?.();
|
await source.prepare?.();
|
||||||
|
|
@ -205,7 +211,7 @@ async function loadSkill(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const skill = await source.loadSkill(skillEntry.id);
|
const skill = await (activate ?? source.loadSkill)(skillEntry.id);
|
||||||
if (!skill) {
|
if (!skill) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|
@ -217,6 +223,26 @@ async function loadSkill(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activate) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
skillId: skillEntry.id,
|
||||||
|
name: skillEntry.name,
|
||||||
|
hash: skillEntry.hash,
|
||||||
|
content: 'The skill instructions are active. Follow them for this task.',
|
||||||
|
linkedFiles: skillEntry.linkedFiles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'content',
|
||||||
|
value: [{ type: 'text', text: formatActiveSkill(skill, skillEntry) }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatActiveSkill(
|
||||||
|
skill: RuntimeSkillContent,
|
||||||
|
skillEntry: RuntimeSkillRegistryEntry,
|
||||||
|
): string {
|
||||||
const content = cap(skill.instructions);
|
const content = cap(skill.instructions);
|
||||||
const linkedFilePaths = LINKED_FILE_GROUPS.flatMap((group) => skillEntry.linkedFiles[group]).map(
|
const linkedFilePaths = LINKED_FILE_GROUPS.flatMap((group) => skillEntry.linkedFiles[group]).map(
|
||||||
(file) => file.path,
|
(file) => file.path,
|
||||||
|
|
@ -229,10 +255,7 @@ async function loadSkill(
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
return {
|
return `${header.join('\n')}\n\n${content}`;
|
||||||
type: 'content',
|
|
||||||
value: [{ type: 'text', text: `${header.join('\n')}\n\n${content}` }],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function findSkillEntry(
|
function findSkillEntry(
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,18 @@ export interface RuntimeSkillSource {
|
||||||
loadFile?: RuntimeSkillFileLoader;
|
loadFile?: RuntimeSkillFileLoader;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RuntimeSkillStateScope {
|
||||||
|
threadId: string;
|
||||||
|
resourceId: string;
|
||||||
|
agentName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stores active IDs separately from conversation text that memory can compact. */
|
||||||
|
export interface RuntimeSkillStateStore {
|
||||||
|
load(scope: RuntimeSkillStateScope): Promise<string[] | undefined>;
|
||||||
|
save(scope: RuntimeSkillStateScope, skillIds: string[]): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RuntimeSkillValidationError {
|
export interface RuntimeSkillValidationError {
|
||||||
code: string;
|
code: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,6 @@ export interface SerializedMessageList {
|
||||||
historyIds: string[];
|
historyIds: string[];
|
||||||
inputIds: string[];
|
inputIds: string[];
|
||||||
responseIds: string[];
|
responseIds: string[];
|
||||||
|
/** IDs only. Resumed runs load their content from the current skill catalog. */
|
||||||
|
activeSkillIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type {
|
||||||
ObservationLogReflectFn,
|
ObservationLogReflectFn,
|
||||||
ObservationLogScope,
|
ObservationLogScope,
|
||||||
} from './observation-log';
|
} from './observation-log';
|
||||||
|
import type { RuntimeSkillStateStore } from '../../skills/types';
|
||||||
import type { JSONObject } from '../utils/json';
|
import type { JSONObject } from '../utils/json';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -34,6 +35,8 @@ export interface Thread {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BuiltMemory {
|
export interface BuiltMemory {
|
||||||
|
/** Retains active skills across separate runs, including compacted conversations. */
|
||||||
|
skillState?: RuntimeSkillStateStore;
|
||||||
// --- Thread management ---
|
// --- Thread management ---
|
||||||
getThread(threadId: string): Promise<Thread | null>;
|
getThread(threadId: string): Promise<Thread | null>;
|
||||||
saveThread(thread: Omit<Thread, 'createdAt' | 'updatedAt'>): Promise<Thread>;
|
saveThread(thread: Omit<Thread, 'createdAt' | 'updatedAt'>): Promise<Thread>;
|
||||||
|
|
@ -291,6 +294,11 @@ export interface ObservationLogMemoryConfig {
|
||||||
export interface ObservationalMemoryConfig {
|
export interface ObservationalMemoryConfig {
|
||||||
/** Estimated visible-window tokens at which the Observer is scheduled mid-run and post-turn. */
|
/** Estimated visible-window tokens at which the Observer is scheduled mid-run and post-turn. */
|
||||||
observerThresholdTokens?: number;
|
observerThresholdTokens?: number;
|
||||||
|
/**
|
||||||
|
* Run the Observer at tool-loop boundaries inside a turn. Default `true`.
|
||||||
|
* When `false`, the Observer runs only after the turn completes.
|
||||||
|
*/
|
||||||
|
midRunObservation?: boolean;
|
||||||
/** Estimated active observation-log tokens required before the Reflector runs. */
|
/** Estimated active observation-log tokens required before the Reflector runs. */
|
||||||
reflectorThresholdTokens?: number;
|
reflectorThresholdTokens?: number;
|
||||||
/** Maximum estimated tokens to render into the system prompt. */
|
/** Maximum estimated tokens to render into the system prompt. */
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { ZodType } from 'zod';
|
||||||
|
|
||||||
import type { AgentExecutionCounter } from './agent';
|
import type { AgentExecutionCounter } from './agent';
|
||||||
import type { AgentMessage } from './message';
|
import type { AgentMessage } from './message';
|
||||||
|
import type { RuntimeSkillLoader } from '../../skills/types';
|
||||||
import type { AgentEventData } from '../runtime/event';
|
import type { AgentEventData } from '../runtime/event';
|
||||||
import type { BuiltTelemetry } from '../telemetry';
|
import type { BuiltTelemetry } from '../telemetry';
|
||||||
import type { JSONObject, JSONValue } from '../utils/json';
|
import type { JSONObject, JSONValue } from '../utils/json';
|
||||||
|
|
@ -15,6 +16,8 @@ export interface ToolSuspendOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolExecutionContext {
|
export interface ToolExecutionContext {
|
||||||
|
/** Load and retain instructions from this run's selected skill catalog. */
|
||||||
|
loadSkill?: RuntimeSkillLoader;
|
||||||
/** Agent run ID for the current execution. */
|
/** Agent run ID for the current execution. */
|
||||||
runId?: string;
|
runId?: string;
|
||||||
/**
|
/**
|
||||||
|
|
@ -52,6 +55,7 @@ export interface ToolExecutionContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolContext {
|
export interface ToolContext {
|
||||||
|
loadSkill?: ToolExecutionContext['loadSkill'];
|
||||||
/** AI SDK tool call ID for the current local tool execution. */
|
/** AI SDK tool call ID for the current local tool execution. */
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
/** Exact model-facing name of the tool being executed. */
|
/** Exact model-facing name of the tool being executed. */
|
||||||
|
|
@ -71,6 +75,7 @@ export interface ToolContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InterruptibleToolContext<S = unknown, R = unknown> {
|
export interface InterruptibleToolContext<S = unknown, R = unknown> {
|
||||||
|
loadSkill?: ToolExecutionContext['loadSkill'];
|
||||||
/**
|
/**
|
||||||
* Suspend execution and send a payload to the consumer.
|
* Suspend execution and send a payload to the consumer.
|
||||||
* Must be used with `return await` — the branded return type signals
|
* Must be used with `return await` — the branded return type signals
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/ai-node-sdk",
|
"name": "@n8n/ai-node-sdk",
|
||||||
"version": "0.29.0",
|
"version": "0.29.4",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/ai-utilities",
|
"name": "@n8n/ai-utilities",
|
||||||
"version": "0.32.0",
|
"version": "0.32.4",
|
||||||
"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",
|
||||||
|
|
@ -148,7 +148,7 @@
|
||||||
"@n8n/typescript-config": "workspace:*",
|
"@n8n/typescript-config": "workspace:*",
|
||||||
"@n8n/utils": "workspace:*",
|
"@n8n/utils": "workspace:*",
|
||||||
"n8n-workflow": "workspace:*",
|
"n8n-workflow": "workspace:*",
|
||||||
"tmp-promise": "3.0.3",
|
"tmp-promise": "catalog:",
|
||||||
"js-tiktoken": "catalog:",
|
"js-tiktoken": "catalog:",
|
||||||
"@thednp/dommatrix": "^3.1.1",
|
"@thednp/dommatrix": "^3.1.1",
|
||||||
"pdf-parse": "catalog:",
|
"pdf-parse": "catalog:",
|
||||||
|
|
|
||||||
|
|
@ -47,11 +47,13 @@ const makeN8nLlmFailedAttemptHandler = vi.mocked(
|
||||||
const N8nLlmTracing = vi.mocked(n8nLlmTracing.N8nLlmTracing);
|
const N8nLlmTracing = vi.mocked(n8nLlmTracing.N8nLlmTracing);
|
||||||
|
|
||||||
describe('supplyModel', () => {
|
describe('supplyModel', () => {
|
||||||
|
const egressFilter = { createSecureLookup: vi.fn() };
|
||||||
const mockCtx = {
|
const mockCtx = {
|
||||||
getNode: vi.fn(),
|
getNode: vi.fn(),
|
||||||
addOutputData: vi.fn(),
|
addOutputData: vi.fn(),
|
||||||
addInputData: vi.fn(),
|
addInputData: vi.fn(),
|
||||||
getNextRunIndex: vi.fn(),
|
getNextRunIndex: vi.fn(),
|
||||||
|
helpers: { getSecureEgressFilter: vi.fn().mockReturnValue(egressFilter) },
|
||||||
} as unknown as ISupplyDataFunctions;
|
} as unknown as ISupplyDataFunctions;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -190,10 +192,14 @@ describe('supplyModel', () => {
|
||||||
timeout: 12345,
|
timeout: 12345,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getProxyAgent).toHaveBeenCalledWith('https://api.openai.com', {
|
expect(getProxyAgent).toHaveBeenCalledWith(
|
||||||
headersTimeout: 12345,
|
'https://api.openai.com',
|
||||||
bodyTimeout: 12345,
|
{
|
||||||
});
|
headersTimeout: 12345,
|
||||||
|
bodyTimeout: 12345,
|
||||||
|
},
|
||||||
|
egressFilter,
|
||||||
|
);
|
||||||
|
|
||||||
expect(ChatOpenAI).toHaveBeenCalledWith(
|
expect(ChatOpenAI).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,42 @@
|
||||||
|
import { passthroughEgressFilter } from '@n8n/backend-network/egress';
|
||||||
|
import { buildDispatcher, dispatchedFetch } from '@n8n/backend-network/transport';
|
||||||
import { lookup as dnsLookup } from 'node:dns';
|
import { lookup as dnsLookup } from 'node:dns';
|
||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import type { AddressInfo } from 'node:net';
|
import type { AddressInfo } from 'node:net';
|
||||||
import { Agent, ProxyAgent, fetch as undiciFetch } from 'undici';
|
|
||||||
import type { MockedFunction } from 'vitest';
|
import type { MockedFunction } from 'vitest';
|
||||||
|
|
||||||
import { getNodeProxyAgent, getProxyAgent, proxyFetch } from 'src/utils/http-proxy-agent';
|
import {
|
||||||
|
getNodeProxyAgent,
|
||||||
|
getProxyAgent,
|
||||||
|
proxyFetch,
|
||||||
|
type EgressFilter,
|
||||||
|
} from 'src/utils/http-proxy-agent';
|
||||||
|
|
||||||
// Mock the dependencies
|
vi.mock('@n8n/backend-network/transport', () => ({
|
||||||
vi.mock('undici', () => ({
|
buildDispatcher: vi.fn((proxy: unknown, ssrf: unknown, options: unknown) => ({
|
||||||
Agent: vi.fn(function (options) {
|
type: 'Dispatcher',
|
||||||
return { type: 'Agent', options };
|
proxy,
|
||||||
}),
|
ssrf,
|
||||||
ProxyAgent: vi.fn(function (options) {
|
options,
|
||||||
return { type: 'ProxyAgent', options };
|
})),
|
||||||
}),
|
dispatchedFetch: vi.fn(),
|
||||||
fetch: vi.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const DEFAULT_AGENT_OPTIONS = {
|
const mockBuildDispatcher = buildDispatcher as unknown as MockedFunction<typeof buildDispatcher>;
|
||||||
headersTimeout: 3600000,
|
const mockDispatchedFetch = dispatchedFetch as unknown as MockedFunction<typeof dispatchedFetch>;
|
||||||
bodyTimeout: 3600000,
|
|
||||||
connect: { lookup: dnsLookup },
|
const makeEgressFilter = (): EgressFilter => ({
|
||||||
|
validateUrl: vi.fn(async () => ({ ok: true as const })),
|
||||||
|
validateConnectionHost: vi.fn(() => ({ ok: true as const })),
|
||||||
|
createSecureLookup: vi.fn(() => vi.fn()),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The real "no policy configured" singleton — detected by object identity.
|
||||||
|
const passthroughFilter: EgressFilter = passthroughEgressFilter;
|
||||||
|
|
||||||
|
const DEFAULT_BUILD_OPTIONS = {
|
||||||
|
timeouts: { headersTimeout: 3600000, bodyTimeout: 3600000 },
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('getProxyAgent', () => {
|
describe('getProxyAgent', () => {
|
||||||
|
|
@ -56,286 +71,228 @@ describe('getProxyAgent', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('default behavior (no timeout options)', () => {
|
describe('default behavior (no timeout options)', () => {
|
||||||
it('should return an Agent with the default lookup when no proxy environment variables are set and no timeout options', () => {
|
it('should return a direct dispatcher when no proxy environment variables are set and no timeout options', () => {
|
||||||
const agent = getProxyAgent();
|
const agent = getProxyAgent(undefined, undefined, passthroughFilter);
|
||||||
|
|
||||||
expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS });
|
expect(agent).toEqual({
|
||||||
expect(ProxyAgent).not.toHaveBeenCalled();
|
type: 'Dispatcher',
|
||||||
|
proxy: false,
|
||||||
|
ssrf: 'disabled',
|
||||||
|
options: DEFAULT_BUILD_OPTIONS,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return an Agent when no proxy is configured for target URL and no timeout options', () => {
|
it('should return a direct dispatcher when no proxy is configured for target URL and no timeout options', () => {
|
||||||
const agent = getProxyAgent('https://api.openai.com/v1');
|
const agent = getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
|
||||||
|
|
||||||
expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS });
|
expect(agent).toEqual(expect.objectContaining({ type: 'Dispatcher', proxy: false }));
|
||||||
expect(ProxyAgent).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reuse a single Agent across calls when no proxy, timeout options nor custom lookup are given', () => {
|
it('should reuse a single dispatcher across calls when no proxy, timeout options nor egress filter are given', () => {
|
||||||
const first = getProxyAgent('https://api.openai.com/v1');
|
const first = getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
|
||||||
const second = getProxyAgent('https://api.anthropic.com/v1');
|
const second = getProxyAgent('https://api.anthropic.com/v1', undefined, passthroughFilter);
|
||||||
|
|
||||||
expect(second).toBe(first);
|
expect(second).toBe(first);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should build a fresh Agent when timeout options or a custom lookup are given', () => {
|
it('should keep the shared dispatcher for the passthrough egress filter', () => {
|
||||||
const shared = getProxyAgent('https://api.openai.com/v1');
|
const shared = getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
|
||||||
|
|
||||||
expect(getProxyAgent('https://api.openai.com/v1', {})).not.toBe(shared);
|
expect(getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter)).toBe(shared);
|
||||||
expect(getProxyAgent('https://api.openai.com/v1', undefined, vi.fn())).not.toBe(shared);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create ProxyAgent with default timeouts when HTTPS_PROXY is set', () => {
|
it('should keep enforcement for a non-passthrough policy whose lookup is the system dns lookup', () => {
|
||||||
const proxyUrl = 'https://proxy.example.com:8080';
|
const filter = makeEgressFilter();
|
||||||
process.env.HTTPS_PROXY = proxyUrl;
|
vi.mocked(filter.createSecureLookup).mockReturnValue(dnsLookup);
|
||||||
|
|
||||||
const agent = getProxyAgent();
|
getProxyAgent('https://api.example.com', undefined, filter);
|
||||||
|
|
||||||
expect(ProxyAgent).toHaveBeenCalledWith({
|
expect(mockBuildDispatcher).toHaveBeenCalledWith(false, filter, expect.anything());
|
||||||
uri: proxyUrl,
|
|
||||||
headersTimeout: 3600000,
|
|
||||||
bodyTimeout: 3600000,
|
|
||||||
});
|
|
||||||
expect(agent).toEqual({
|
|
||||||
type: 'ProxyAgent',
|
|
||||||
options: { uri: proxyUrl, headersTimeout: 3600000, bodyTimeout: 3600000 },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create ProxyAgent when https_proxy is set', () => {
|
it('should build a fresh dispatcher when timeout options or an egress filter are given', () => {
|
||||||
const proxyUrl = 'https://proxy.example.com:8080';
|
const shared = getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
|
||||||
process.env.https_proxy = proxyUrl;
|
|
||||||
|
|
||||||
getProxyAgent();
|
expect(getProxyAgent('https://api.openai.com/v1', {}, passthroughFilter)).not.toBe(shared);
|
||||||
|
expect(getProxyAgent('https://api.openai.com/v1', undefined, makeEgressFilter())).not.toBe(
|
||||||
expect(ProxyAgent).toHaveBeenCalledWith({
|
shared,
|
||||||
uri: proxyUrl,
|
|
||||||
headersTimeout: 3600000,
|
|
||||||
bodyTimeout: 3600000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should respect priority order of proxy environment variables', () => {
|
|
||||||
// Set multiple proxy environment variables
|
|
||||||
process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080';
|
|
||||||
process.env.http_proxy = 'http://http-proxy-lowercase.example.com:8080';
|
|
||||||
process.env.HTTPS_PROXY = 'https://https-proxy.example.com:8080';
|
|
||||||
process.env.https_proxy = 'https://https-proxy-lowercase.example.com:8080';
|
|
||||||
|
|
||||||
getProxyAgent();
|
|
||||||
|
|
||||||
// Should use https_proxy as it has highest priority now
|
|
||||||
expect(ProxyAgent).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
uri: 'https://https-proxy-lowercase.example.com:8080',
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should build an env-proxied dispatcher with default timeouts when HTTPS_PROXY is set', () => {
|
||||||
|
process.env.HTTPS_PROXY = 'https://proxy.example.com:8080';
|
||||||
|
|
||||||
|
const agent = getProxyAgent(undefined, undefined, passthroughFilter);
|
||||||
|
|
||||||
|
expect(mockBuildDispatcher).toHaveBeenCalledWith('env', 'disabled', DEFAULT_BUILD_OPTIONS);
|
||||||
|
expect(agent).toEqual(expect.objectContaining({ type: 'Dispatcher', proxy: 'env' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should build an env-proxied dispatcher when https_proxy is set', () => {
|
||||||
|
process.env.https_proxy = 'https://proxy.example.com:8080';
|
||||||
|
|
||||||
|
getProxyAgent(undefined, undefined, passthroughFilter);
|
||||||
|
|
||||||
|
expect(mockBuildDispatcher).toHaveBeenCalledWith('env', 'disabled', expect.anything());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('target URL provided', () => {
|
describe('target URL provided', () => {
|
||||||
it('should create ProxyAgent for HTTPS URL when HTTPS_PROXY is set', () => {
|
it('should build an env-proxied dispatcher for HTTPS URL when HTTPS_PROXY is set', () => {
|
||||||
const proxyUrl = 'https://proxy.example.com:8080';
|
process.env.HTTPS_PROXY = 'https://proxy.example.com:8080';
|
||||||
process.env.HTTPS_PROXY = proxyUrl;
|
|
||||||
|
|
||||||
getProxyAgent('https://api.openai.com/v1');
|
getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
|
||||||
|
|
||||||
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
|
expect(mockBuildDispatcher).toHaveBeenCalledWith('env', 'disabled', expect.anything());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create ProxyAgent for HTTP URL when HTTP_PROXY is set', () => {
|
it('should build an env-proxied dispatcher for HTTP URL when HTTP_PROXY is set', () => {
|
||||||
const proxyUrl = 'http://proxy.example.com:8080';
|
process.env.HTTP_PROXY = 'http://proxy.example.com:8080';
|
||||||
process.env.HTTP_PROXY = proxyUrl;
|
|
||||||
|
|
||||||
getProxyAgent('http://api.example.com');
|
getProxyAgent('http://api.example.com', undefined, passthroughFilter);
|
||||||
|
|
||||||
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
|
expect(mockBuildDispatcher).toHaveBeenCalledWith('env', 'disabled', expect.anything());
|
||||||
});
|
|
||||||
|
|
||||||
it('should use HTTPS_PROXY for HTTPS URLs even when HTTP_PROXY is set', () => {
|
|
||||||
const httpProxy = 'http://http-proxy.example.com:8080';
|
|
||||||
const httpsProxy = 'https://https-proxy.example.com:8443';
|
|
||||||
process.env.HTTP_PROXY = httpProxy;
|
|
||||||
process.env.HTTPS_PROXY = httpsProxy;
|
|
||||||
|
|
||||||
getProxyAgent('https://api.openai.com/v1');
|
|
||||||
|
|
||||||
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: httpsProxy }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should respect NO_PROXY for localhost', () => {
|
it('should respect NO_PROXY for localhost', () => {
|
||||||
const proxyUrl = 'http://proxy.example.com:8080';
|
process.env.HTTP_PROXY = 'http://proxy.example.com:8080';
|
||||||
process.env.HTTP_PROXY = proxyUrl;
|
|
||||||
process.env.NO_PROXY = 'localhost,127.0.0.1';
|
process.env.NO_PROXY = 'localhost,127.0.0.1';
|
||||||
|
|
||||||
const agent = getProxyAgent('http://localhost:3000');
|
const agent = getProxyAgent('http://localhost:3000', undefined, passthroughFilter);
|
||||||
|
|
||||||
expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS });
|
expect(agent).toEqual(expect.objectContaining({ type: 'Dispatcher', proxy: false }));
|
||||||
expect(ProxyAgent).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should respect NO_PROXY wildcard patterns', () => {
|
it('should respect NO_PROXY wildcard patterns', () => {
|
||||||
const proxyUrl = 'http://proxy.example.com:8080';
|
process.env.HTTPS_PROXY = 'http://proxy.example.com:8080';
|
||||||
process.env.HTTPS_PROXY = proxyUrl;
|
|
||||||
process.env.NO_PROXY = '*.internal.company.com,localhost';
|
process.env.NO_PROXY = '*.internal.company.com,localhost';
|
||||||
|
|
||||||
const agent = getProxyAgent('https://api.internal.company.com');
|
const agent = getProxyAgent('https://api.internal.company.com', undefined, passthroughFilter);
|
||||||
|
|
||||||
expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS });
|
expect(agent).toEqual(expect.objectContaining({ type: 'Dispatcher', proxy: false }));
|
||||||
expect(ProxyAgent).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use proxy for URLs not in NO_PROXY', () => {
|
it('should use proxy for URLs not in NO_PROXY', () => {
|
||||||
const proxyUrl = 'http://proxy.example.com:8080';
|
process.env.HTTPS_PROXY = 'http://proxy.example.com:8080';
|
||||||
process.env.HTTPS_PROXY = proxyUrl;
|
|
||||||
process.env.NO_PROXY = 'localhost,127.0.0.1';
|
process.env.NO_PROXY = 'localhost,127.0.0.1';
|
||||||
|
|
||||||
getProxyAgent('https://api.openai.com/v1');
|
getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
|
||||||
|
|
||||||
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
|
expect(mockBuildDispatcher).toHaveBeenCalledWith('env', 'disabled', expect.anything());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle mixed case environment variables', () => {
|
it('should handle mixed case environment variables', () => {
|
||||||
const proxyUrl = 'http://proxy.example.com:8080';
|
process.env.https_proxy = 'http://proxy.example.com:8080';
|
||||||
process.env.https_proxy = proxyUrl;
|
|
||||||
process.env.no_proxy = 'localhost';
|
process.env.no_proxy = 'localhost';
|
||||||
|
|
||||||
getProxyAgent('https://api.openai.com/v1');
|
getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
|
||||||
|
|
||||||
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
|
expect(mockBuildDispatcher).toHaveBeenCalledWith('env', 'disabled', expect.anything());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('timeout options', () => {
|
describe('timeout options', () => {
|
||||||
it('should pass custom timeout options to ProxyAgent when proxy is set', () => {
|
it('should pass custom timeout options when a proxy is set', () => {
|
||||||
const proxyUrl = 'https://proxy.example.com:8080';
|
process.env.HTTPS_PROXY = 'https://proxy.example.com:8080';
|
||||||
process.env.HTTPS_PROXY = proxyUrl;
|
|
||||||
|
|
||||||
getProxyAgent('https://api.openai.com/v1', {
|
getProxyAgent(
|
||||||
headersTimeout: 120000,
|
'https://api.openai.com/v1',
|
||||||
bodyTimeout: 180000,
|
{
|
||||||
});
|
headersTimeout: 120000,
|
||||||
|
bodyTimeout: 180000,
|
||||||
|
},
|
||||||
|
passthroughFilter,
|
||||||
|
);
|
||||||
|
|
||||||
expect(ProxyAgent).toHaveBeenCalledWith({
|
expect(mockBuildDispatcher).toHaveBeenCalledWith('env', 'disabled', {
|
||||||
uri: proxyUrl,
|
timeouts: { headersTimeout: 120000, bodyTimeout: 180000 },
|
||||||
headersTimeout: 120000,
|
|
||||||
bodyTimeout: 180000,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create Agent with timeout options when no proxy is configured', () => {
|
it('should build a direct dispatcher with timeout options when no proxy is configured', () => {
|
||||||
const agent = getProxyAgent('https://api.openai.com/v1', {
|
const agent = getProxyAgent(
|
||||||
headersTimeout: 120000,
|
'https://api.openai.com/v1',
|
||||||
bodyTimeout: 180000,
|
{
|
||||||
});
|
headersTimeout: 120000,
|
||||||
|
bodyTimeout: 180000,
|
||||||
|
},
|
||||||
|
passthroughFilter,
|
||||||
|
);
|
||||||
|
|
||||||
expect(Agent).toHaveBeenCalledWith({
|
expect(mockBuildDispatcher).toHaveBeenCalledWith(false, 'disabled', {
|
||||||
headersTimeout: 120000,
|
timeouts: { headersTimeout: 120000, bodyTimeout: 180000 },
|
||||||
bodyTimeout: 180000,
|
|
||||||
connect: { lookup: dnsLookup },
|
|
||||||
});
|
|
||||||
expect(agent).toEqual({
|
|
||||||
type: 'Agent',
|
|
||||||
options: { headersTimeout: 120000, bodyTimeout: 180000, connect: { lookup: dnsLookup } },
|
|
||||||
});
|
});
|
||||||
|
expect(agent).toEqual(expect.objectContaining({ type: 'Dispatcher', proxy: false }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use default timeouts when empty timeout options object is passed', () => {
|
it('should use default timeouts when empty timeout options object is passed', () => {
|
||||||
getProxyAgent('https://api.openai.com/v1', {});
|
getProxyAgent('https://api.openai.com/v1', {}, passthroughFilter);
|
||||||
|
|
||||||
expect(Agent).toHaveBeenCalledWith(DEFAULT_AGENT_OPTIONS);
|
expect(mockBuildDispatcher).toHaveBeenCalledWith(false, 'disabled', DEFAULT_BUILD_OPTIONS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include connectTimeout when provided', () => {
|
it('should include connectTimeout when provided', () => {
|
||||||
getProxyAgent('https://api.openai.com/v1', {
|
getProxyAgent(
|
||||||
headersTimeout: 60000,
|
'https://api.openai.com/v1',
|
||||||
bodyTimeout: 60000,
|
{
|
||||||
connectTimeout: 30000,
|
headersTimeout: 60000,
|
||||||
});
|
bodyTimeout: 60000,
|
||||||
|
connectTimeout: 30000,
|
||||||
|
},
|
||||||
|
passthroughFilter,
|
||||||
|
);
|
||||||
|
|
||||||
expect(Agent).toHaveBeenCalledWith({
|
expect(mockBuildDispatcher).toHaveBeenCalledWith(false, 'disabled', {
|
||||||
headersTimeout: 60000,
|
timeouts: { headersTimeout: 60000, bodyTimeout: 60000, connectTimeout: 30000 },
|
||||||
bodyTimeout: 60000,
|
|
||||||
connectTimeout: 30000,
|
|
||||||
connect: { lookup: dnsLookup },
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should respect custom timeout from environment variable', () => {
|
it('should build a fresh dispatcher when N8N_AI_TIMEOUT_MAX is set, even without a proxy or explicit timeout options', () => {
|
||||||
process.env.N8N_AI_TIMEOUT_MAX = '300000';
|
const shared = getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
|
||||||
|
|
||||||
// Need to re-import to pick up env vars (or mock module)
|
|
||||||
// For this test, we just verify the default timeout parsing
|
|
||||||
// The actual behavior is tested by integration tests
|
|
||||||
|
|
||||||
// Empty options should use env var defaults
|
|
||||||
getProxyAgent('https://api.openai.com/v1', {});
|
|
||||||
|
|
||||||
// Since we can't easily re-import, we verify the mock was called with defaults
|
|
||||||
expect(Agent).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should build a fresh Agent when N8N_AI_TIMEOUT_MAX is set, even without a proxy or explicit timeout options', () => {
|
|
||||||
const shared = getProxyAgent('https://api.openai.com/v1');
|
|
||||||
process.env.N8N_AI_TIMEOUT_MAX = '120000';
|
process.env.N8N_AI_TIMEOUT_MAX = '120000';
|
||||||
|
|
||||||
const agent = getProxyAgent('https://api.openai.com/v1');
|
const agent = getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
|
||||||
|
|
||||||
// DEFAULT_TIMEOUT was captured from the env at module load time (before this test set it),
|
// DEFAULT_TIMEOUT was captured from the env at module load time (before this test set it),
|
||||||
// so the value here reflects that capture, not '120000' — the module-reset test below
|
// so the value here reflects that capture, not '120000' — the module-reset test below
|
||||||
// covers the env value actually being picked up end to end.
|
// covers the env value actually being picked up end to end.
|
||||||
expect(agent).toEqual(expect.objectContaining({ type: 'Agent' }));
|
expect(agent).toEqual(expect.objectContaining({ type: 'Dispatcher' }));
|
||||||
expect(agent).not.toBe(shared);
|
expect(agent).not.toBe(shared);
|
||||||
expect(ProxyAgent).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should honor N8N_AI_TIMEOUT_MAX when there is no proxy and the caller passes no timeout options at all', async () => {
|
it('should honor N8N_AI_TIMEOUT_MAX when there is no proxy and the caller passes no timeout options at all', async () => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
process.env.N8N_AI_TIMEOUT_MAX = '120000';
|
process.env.N8N_AI_TIMEOUT_MAX = '120000';
|
||||||
|
|
||||||
const undici = await import('undici');
|
const transport = await import('@n8n/backend-network/transport');
|
||||||
|
const egress = await import('@n8n/backend-network/egress');
|
||||||
const { getProxyAgent: freshGetProxyAgent } = await import('../../utils/http-proxy-agent.js');
|
const { getProxyAgent: freshGetProxyAgent } = await import('../../utils/http-proxy-agent.js');
|
||||||
|
|
||||||
const agent = freshGetProxyAgent('https://api.openai.com/v1');
|
freshGetProxyAgent('https://api.openai.com/v1', undefined, egress.passthroughEgressFilter);
|
||||||
|
|
||||||
expect(undici.Agent).toHaveBeenCalledWith({
|
expect(transport.buildDispatcher).toHaveBeenCalledWith(false, 'disabled', {
|
||||||
headersTimeout: 120000,
|
timeouts: { headersTimeout: 120000, bodyTimeout: 120000 },
|
||||||
bodyTimeout: 120000,
|
|
||||||
connect: { lookup: dnsLookup },
|
|
||||||
});
|
|
||||||
expect(agent).toEqual({
|
|
||||||
type: 'Agent',
|
|
||||||
options: {
|
|
||||||
headersTimeout: 120000,
|
|
||||||
bodyTimeout: 120000,
|
|
||||||
connect: { lookup: dnsLookup },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('secure lookup', () => {
|
describe('egress filter', () => {
|
||||||
it('should build an Agent with the lookup on connect when no proxy is configured', () => {
|
it('should build the dispatcher with the filter when no proxy is configured', () => {
|
||||||
const lookup = vi.fn();
|
const egressFilter = makeEgressFilter();
|
||||||
|
|
||||||
const agent = getProxyAgent('https://api.openai.com/v1', undefined, lookup);
|
const agent = getProxyAgent('https://api.openai.com/v1', undefined, egressFilter);
|
||||||
|
|
||||||
expect(Agent).toHaveBeenCalledWith(expect.objectContaining({ connect: { lookup } }));
|
expect(mockBuildDispatcher).toHaveBeenCalledWith(false, egressFilter, DEFAULT_BUILD_OPTIONS);
|
||||||
expect(agent).toEqual(expect.objectContaining({ type: 'Agent' }));
|
expect(agent).toEqual(expect.objectContaining({ ssrf: egressFilter }));
|
||||||
expect(ProxyAgent).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not attach the lookup to a ProxyAgent when a proxy is configured', () => {
|
it('should build the dispatcher with the filter when a proxy is configured', () => {
|
||||||
const proxyUrl = 'https://proxy.example.com:8080';
|
process.env.HTTPS_PROXY = 'https://proxy.example.com:8080';
|
||||||
process.env.HTTPS_PROXY = proxyUrl;
|
const egressFilter = makeEgressFilter();
|
||||||
const lookup = vi.fn();
|
|
||||||
|
|
||||||
getProxyAgent('https://api.openai.com/v1', undefined, lookup);
|
getProxyAgent('https://api.openai.com/v1', undefined, egressFilter);
|
||||||
|
|
||||||
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
|
expect(mockBuildDispatcher).toHaveBeenCalledWith('env', egressFilter, DEFAULT_BUILD_OPTIONS);
|
||||||
expect(ProxyAgent).not.toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ connect: expect.anything() }),
|
|
||||||
);
|
|
||||||
expect(Agent).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -343,7 +300,6 @@ describe('getProxyAgent', () => {
|
||||||
describe('proxyFetch', () => {
|
describe('proxyFetch', () => {
|
||||||
// Store original environment variables
|
// Store original environment variables
|
||||||
const originalEnv = { ...process.env };
|
const originalEnv = { ...process.env };
|
||||||
const mockFetch = undiciFetch as unknown as MockedFunction<typeof fetch>;
|
|
||||||
|
|
||||||
// Reset environment variables and mocks before each test
|
// Reset environment variables and mocks before each test
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -357,7 +313,7 @@ describe('proxyFetch', () => {
|
||||||
delete process.env.no_proxy;
|
delete process.env.no_proxy;
|
||||||
|
|
||||||
// Setup default fetch mock response
|
// Setup default fetch mock response
|
||||||
mockFetch.mockResolvedValue(
|
mockDispatchedFetch.mockResolvedValue(
|
||||||
new Response('{}', {
|
new Response('{}', {
|
||||||
status: 200,
|
status: 200,
|
||||||
statusText: 'OK',
|
statusText: 'OK',
|
||||||
|
|
@ -372,44 +328,53 @@ describe('proxyFetch', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('with no proxy configured', () => {
|
describe('with no proxy configured', () => {
|
||||||
it('should call fetch with an Agent dispatcher when no proxy is set and no timeout options', async () => {
|
it('should fetch with a direct dispatcher when no proxy is set and no timeout options', async () => {
|
||||||
const url = 'https://api.openai.com/v1';
|
const url = 'https://api.openai.com/v1';
|
||||||
await proxyFetch({ input: url, lookup: dnsLookup });
|
await proxyFetch({ input: url, egressFilter: passthroughFilter });
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith(url, {
|
expect(mockDispatchedFetch).toHaveBeenCalledWith(
|
||||||
dispatcher: expect.objectContaining({ type: 'Agent' }),
|
expect.objectContaining({ type: 'Dispatcher', proxy: false }),
|
||||||
});
|
url,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reuse the same dispatcher across plain calls', async () => {
|
it('should reuse the same dispatcher across plain calls', async () => {
|
||||||
await proxyFetch({ input: 'https://api.openai.com/v1', lookup: dnsLookup });
|
await proxyFetch({ input: 'https://api.openai.com/v1', egressFilter: passthroughFilter });
|
||||||
await proxyFetch({ input: 'https://api.anthropic.com/v1', lookup: dnsLookup });
|
await proxyFetch({ input: 'https://api.anthropic.com/v1', egressFilter: passthroughFilter });
|
||||||
|
|
||||||
const [[, first], [, second]] = mockFetch.mock.calls as unknown as Array<
|
const [[first], [second]] = mockDispatchedFetch.mock.calls;
|
||||||
[unknown, { dispatcher: unknown }]
|
expect(second).toBe(first);
|
||||||
>;
|
|
||||||
expect(second.dispatcher).toBe(first.dispatcher);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should build the dispatcher with the supplied lookup', async () => {
|
it('should build the dispatcher with the supplied egress filter', async () => {
|
||||||
const lookup = vi.fn();
|
const egressFilter = makeEgressFilter();
|
||||||
await proxyFetch({ input: 'https://api.openai.com/v1', lookup });
|
await proxyFetch({ input: 'https://api.openai.com/v1', egressFilter });
|
||||||
|
|
||||||
expect(Agent).toHaveBeenCalledWith(expect.objectContaining({ connect: { lookup } }));
|
expect(mockBuildDispatcher).toHaveBeenCalledWith(false, egressFilter, expect.anything());
|
||||||
|
expect(mockDispatchedFetch).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ ssrf: egressFilter }),
|
||||||
|
'https://api.openai.com/v1',
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call fetch with Agent dispatcher when timeout options are provided', async () => {
|
it('should fetch with a dispatcher when timeout options are provided', async () => {
|
||||||
const url = 'https://api.openai.com/v1';
|
const url = 'https://api.openai.com/v1';
|
||||||
await proxyFetch({
|
await proxyFetch({
|
||||||
input: url,
|
input: url,
|
||||||
timeoutOptions: { headersTimeout: 60000 },
|
timeoutOptions: { headersTimeout: 60000 },
|
||||||
lookup: dnsLookup,
|
egressFilter: passthroughFilter,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(Agent).toHaveBeenCalled();
|
expect(mockBuildDispatcher).toHaveBeenCalledWith(false, 'disabled', {
|
||||||
expect(mockFetch).toHaveBeenCalledWith(url, {
|
timeouts: { headersTimeout: 60000, bodyTimeout: 3600000 },
|
||||||
dispatcher: expect.objectContaining({ type: 'Agent' }),
|
|
||||||
});
|
});
|
||||||
|
expect(mockDispatchedFetch).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ type: 'Dispatcher' }),
|
||||||
|
url,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should pass through RequestInit options', async () => {
|
it('should pass through RequestInit options', async () => {
|
||||||
|
|
@ -420,41 +385,55 @@ describe('proxyFetch', () => {
|
||||||
body: JSON.stringify({ test: 'data' }),
|
body: JSON.stringify({ test: 'data' }),
|
||||||
};
|
};
|
||||||
|
|
||||||
await proxyFetch({ input: url, init, lookup: dnsLookup });
|
await proxyFetch({ input: url, init, egressFilter: passthroughFilter });
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith(url, {
|
expect(mockDispatchedFetch).toHaveBeenCalledWith(expect.anything(), url, init);
|
||||||
...init,
|
|
||||||
dispatcher: expect.objectContaining({ type: 'Agent' }),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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, lookup: dnsLookup });
|
await proxyFetch({ input: url, egressFilter: passthroughFilter });
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith(url.href, {
|
expect(mockDispatchedFetch).toHaveBeenCalledWith(expect.anything(), url, undefined);
|
||||||
dispatcher: expect.objectContaining({ type: 'Agent' }),
|
});
|
||||||
|
|
||||||
|
it('should handle Request objects', async () => {
|
||||||
|
const request = new Request('https://api.openai.com/v1', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ test: 'data' }),
|
||||||
});
|
});
|
||||||
|
await proxyFetch({ input: request, egressFilter: passthroughFilter });
|
||||||
|
|
||||||
|
// A Request is decomposed into url + init: the package's undici fetch does
|
||||||
|
// not recognize a Request built by another undici.
|
||||||
|
expect(mockDispatchedFetch).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
'https://api.openai.com/v1',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: [['content-type', 'application/json']],
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('with proxy configured', () => {
|
describe('with proxy configured', () => {
|
||||||
it('should call fetch with ProxyAgent dispatcher when proxy is set', async () => {
|
it('should fetch with an env-proxied dispatcher when proxy is set', async () => {
|
||||||
const proxyUrl = 'https://proxy.example.com:8080';
|
process.env.HTTPS_PROXY = 'https://proxy.example.com:8080';
|
||||||
process.env.HTTPS_PROXY = proxyUrl;
|
|
||||||
|
|
||||||
const url = 'https://api.openai.com/v1';
|
const url = 'https://api.openai.com/v1';
|
||||||
await proxyFetch({ input: url, lookup: dnsLookup });
|
await proxyFetch({ input: url, egressFilter: passthroughFilter });
|
||||||
|
|
||||||
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
|
expect(mockDispatchedFetch).toHaveBeenCalledWith(
|
||||||
expect(mockFetch).toHaveBeenCalledWith(url, {
|
expect.objectContaining({ type: 'Dispatcher', proxy: 'env' }),
|
||||||
dispatcher: expect.objectContaining({ type: 'ProxyAgent' }),
|
url,
|
||||||
});
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should pass through RequestInit options with proxy', async () => {
|
it('should pass through RequestInit options with proxy', async () => {
|
||||||
const proxyUrl = 'https://proxy.example.com:8080';
|
process.env.HTTPS_PROXY = 'https://proxy.example.com:8080';
|
||||||
process.env.HTTPS_PROXY = proxyUrl;
|
|
||||||
|
|
||||||
const url = 'https://api.openai.com/v1';
|
const url = 'https://api.openai.com/v1';
|
||||||
const init: RequestInit = {
|
const init: RequestInit = {
|
||||||
|
|
@ -462,72 +441,41 @@ describe('proxyFetch', () => {
|
||||||
headers: { Authorization: 'Bearer token123' },
|
headers: { Authorization: 'Bearer token123' },
|
||||||
};
|
};
|
||||||
|
|
||||||
await proxyFetch({ input: url, init, lookup: dnsLookup });
|
await proxyFetch({ input: url, init, egressFilter: passthroughFilter });
|
||||||
|
|
||||||
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
|
expect(mockDispatchedFetch).toHaveBeenCalledWith(
|
||||||
expect(mockFetch).toHaveBeenCalledWith(url, {
|
expect.objectContaining({ type: 'Dispatcher', proxy: 'env' }),
|
||||||
...init,
|
url,
|
||||||
dispatcher: expect.objectContaining({ type: 'ProxyAgent' }),
|
init,
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle URL objects with proxy', async () => {
|
|
||||||
const proxyUrl = 'http://proxy.example.com:8080';
|
|
||||||
process.env.HTTP_PROXY = proxyUrl;
|
|
||||||
|
|
||||||
const url = new URL('http://api.example.com/data');
|
|
||||||
await proxyFetch({ input: url, lookup: dnsLookup });
|
|
||||||
|
|
||||||
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith(url.href, {
|
|
||||||
dispatcher: expect.objectContaining({ type: 'ProxyAgent' }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle Request objects with proxy', async () => {
|
|
||||||
const proxyUrl = 'https://proxy.example.com:8080';
|
|
||||||
process.env.HTTPS_PROXY = proxyUrl;
|
|
||||||
|
|
||||||
const request = new Request('https://api.openai.com/v1');
|
|
||||||
await proxyFetch({ input: request, lookup: dnsLookup });
|
|
||||||
|
|
||||||
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
|
||||||
request.url,
|
|
||||||
expect.objectContaining({ dispatcher: expect.objectContaining({ type: 'ProxyAgent' }) }),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should respect NO_PROXY environment variable', async () => {
|
it('should respect NO_PROXY environment variable', async () => {
|
||||||
const proxyUrl = 'http://proxy.example.com:8080';
|
process.env.HTTPS_PROXY = 'http://proxy.example.com:8080';
|
||||||
process.env.HTTPS_PROXY = proxyUrl;
|
|
||||||
process.env.NO_PROXY = 'localhost,127.0.0.1';
|
process.env.NO_PROXY = 'localhost,127.0.0.1';
|
||||||
|
|
||||||
const url = 'https://localhost:3000/api';
|
const url = 'https://localhost:3000/api';
|
||||||
await proxyFetch({ input: url, lookup: dnsLookup });
|
await proxyFetch({ input: url, egressFilter: passthroughFilter });
|
||||||
|
|
||||||
// Should not create ProxyAgent for localhost
|
expect(mockDispatchedFetch).toHaveBeenCalledWith(
|
||||||
expect(ProxyAgent).not.toHaveBeenCalled();
|
expect.objectContaining({ type: 'Dispatcher', proxy: false }),
|
||||||
expect(mockFetch).toHaveBeenCalledWith(url, {
|
url,
|
||||||
dispatcher: expect.objectContaining({ type: 'Agent' }),
|
undefined,
|
||||||
});
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should pass timeout options to ProxyAgent when proxy is configured', async () => {
|
it('should pass timeout options to the dispatcher when proxy is configured', async () => {
|
||||||
const proxyUrl = 'https://proxy.example.com:8080';
|
process.env.HTTPS_PROXY = 'https://proxy.example.com:8080';
|
||||||
process.env.HTTPS_PROXY = proxyUrl;
|
|
||||||
|
|
||||||
const url = 'https://api.openai.com/v1';
|
const url = 'https://api.openai.com/v1';
|
||||||
await proxyFetch({
|
await proxyFetch({
|
||||||
input: url,
|
input: url,
|
||||||
timeoutOptions: { headersTimeout: 300000, bodyTimeout: 300000 },
|
timeoutOptions: { headersTimeout: 300000, bodyTimeout: 300000 },
|
||||||
lookup: dnsLookup,
|
egressFilter: passthroughFilter,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(ProxyAgent).toHaveBeenCalledWith({
|
expect(mockBuildDispatcher).toHaveBeenCalledWith('env', 'disabled', {
|
||||||
uri: proxyUrl,
|
timeouts: { headersTimeout: 300000, bodyTimeout: 300000 },
|
||||||
headersTimeout: 300000,
|
|
||||||
bodyTimeout: 300000,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -538,21 +486,23 @@ describe('proxyFetch', () => {
|
||||||
status: 200,
|
status: 200,
|
||||||
statusText: 'OK',
|
statusText: 'OK',
|
||||||
});
|
});
|
||||||
mockFetch.mockResolvedValueOnce(expectedResponse);
|
mockDispatchedFetch.mockResolvedValueOnce(expectedResponse);
|
||||||
|
|
||||||
const url = 'https://api.openai.com/v1';
|
const url = 'https://api.openai.com/v1';
|
||||||
const result = await proxyFetch({ input: url, lookup: dnsLookup });
|
const result = await proxyFetch({ input: url, egressFilter: passthroughFilter });
|
||||||
|
|
||||||
expect(result).toBe(expectedResponse);
|
expect(result).toBe(expectedResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should propagate fetch errors', async () => {
|
it('should propagate fetch errors', async () => {
|
||||||
const error = new Error('Network error');
|
const error = new Error('Network error');
|
||||||
mockFetch.mockRejectedValueOnce(error);
|
mockDispatchedFetch.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
const url = 'https://api.openai.com/v1';
|
const url = 'https://api.openai.com/v1';
|
||||||
|
|
||||||
await expect(proxyFetch({ input: url, lookup: dnsLookup })).rejects.toThrow('Network error');
|
await expect(proxyFetch({ input: url, egressFilter: passthroughFilter })).rejects.toThrow(
|
||||||
|
'Network error',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return error responses without throwing', async () => {
|
it('should return error responses without throwing', async () => {
|
||||||
|
|
@ -560,10 +510,10 @@ describe('proxyFetch', () => {
|
||||||
status: 404,
|
status: 404,
|
||||||
statusText: 'Not Found',
|
statusText: 'Not Found',
|
||||||
});
|
});
|
||||||
mockFetch.mockResolvedValueOnce(errorResponse);
|
mockDispatchedFetch.mockResolvedValueOnce(errorResponse);
|
||||||
|
|
||||||
const url = 'https://api.openai.com/v1';
|
const url = 'https://api.openai.com/v1';
|
||||||
const result = await proxyFetch({ input: url, lookup: dnsLookup });
|
const result = await proxyFetch({ input: url, egressFilter: passthroughFilter });
|
||||||
|
|
||||||
expect(result).toBe(errorResponse);
|
expect(result).toBe(errorResponse);
|
||||||
});
|
});
|
||||||
|
|
@ -628,7 +578,9 @@ describe('proxyFetch with the real undici', () => {
|
||||||
async () => (await import('undici')).Request as unknown as typeof Request,
|
async () => (await import('undici')).Request as unknown as typeof Request,
|
||||||
],
|
],
|
||||||
])('should send a Request built with %s', async (_, loadRequestClass) => {
|
])('should send a Request built with %s', async (_, loadRequestClass) => {
|
||||||
vi.doUnmock('undici');
|
// The dispatcher and the fetch bound to it are mocked for the rest of this
|
||||||
|
// file, so the real transport has to come back for these two.
|
||||||
|
vi.doUnmock('@n8n/backend-network/transport');
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
const { proxyFetch: realProxyFetch } = await import('../../utils/http-proxy-agent.js');
|
const { proxyFetch: realProxyFetch } = await import('../../utils/http-proxy-agent.js');
|
||||||
const RequestClass = await loadRequestClass();
|
const RequestClass = await loadRequestClass();
|
||||||
|
|
@ -655,7 +607,7 @@ describe('proxyFetch with the real undici', () => {
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: '{"model":"mistral-small"}',
|
body: '{"model":"mistral-small"}',
|
||||||
});
|
});
|
||||||
const response = await realProxyFetch({ input: request, lookup: dnsLookup });
|
const response = await realProxyFetch({ input: request, egressFilter: passthroughFilter });
|
||||||
|
|
||||||
expect(await response.text()).toBe('ok');
|
expect(await response.text()).toBe('ok');
|
||||||
expect(received).toEqual({
|
expect(received).toEqual({
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ export {
|
||||||
getNodeProxyAgent,
|
getNodeProxyAgent,
|
||||||
proxyFetch,
|
proxyFetch,
|
||||||
type AgentTimeoutOptions,
|
type AgentTimeoutOptions,
|
||||||
|
type EgressFilter,
|
||||||
type ProxyFetchOptions,
|
type ProxyFetchOptions,
|
||||||
} from './utils/http-proxy-agent';
|
} from './utils/http-proxy-agent';
|
||||||
export { braveSearch, searxngSearch, type BraveSearchOptions } from './web-search';
|
export { braveSearch, searxngSearch, type BraveSearchOptions } from './web-search';
|
||||||
|
|
|
||||||
|
|
@ -112,3 +112,53 @@ describe('getNodeTypeDefinition — latest version resolution', () => {
|
||||||
expect(result.content).toBe('// notion v3 get def');
|
expect(result.content).toBe('// notion v3 get def');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getNodeTypeDefinition — version segment handling', () => {
|
||||||
|
let defsDir: string;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
defsDir = mkdtempSync(join(tmpdir(), 'ai-utils-node-versions-'));
|
||||||
|
const nodesDir = join(defsDir, 'nodes', 'n8n-nodes-base');
|
||||||
|
|
||||||
|
mkdirSync(join(nodesDir, 'set'), { recursive: true });
|
||||||
|
writeFileSync(join(nodesDir, 'set', 'v2.ts'), '// set v2 def');
|
||||||
|
writeFileSync(join(nodesDir, 'set', 'v34.ts'), '// set v34 def');
|
||||||
|
|
||||||
|
mkdirSync(join(nodesDir, 'webhook'), { recursive: true });
|
||||||
|
writeFileSync(join(nodesDir, 'webhook', 'v2.ts'), '// webhook v2 def');
|
||||||
|
|
||||||
|
writeFileSync(join(defsDir, 'index.ts'), '// definitions root index');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(defsDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['2', '// set v2 def'],
|
||||||
|
['v2', '// set v2 def'],
|
||||||
|
['3.4', '// set v34 def'],
|
||||||
|
['v3.4', '// set v34 def'],
|
||||||
|
])('resolves the definition for version %s', (version, expected) => {
|
||||||
|
const result = getNodeTypeDefinition('n8n-nodes-base.set', version, [defsDir]);
|
||||||
|
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.content).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'v./../../webhook/v2',
|
||||||
|
'v./../../../../index',
|
||||||
|
'v./../../../../../../nodes/n8n-nodes-base/webhook/v2',
|
||||||
|
'v.\\..\\..\\webhook\\v2',
|
||||||
|
'../webhook/v2',
|
||||||
|
'2\0',
|
||||||
|
'..',
|
||||||
|
'latest',
|
||||||
|
])('rejects the version value %j', (version) => {
|
||||||
|
const result = getNodeTypeDefinition('n8n-nodes-base.set', version, [defsDir]);
|
||||||
|
|
||||||
|
expect(result.error).toContain('not found for node');
|
||||||
|
expect(result.content).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,15 @@ export function isValidPathComponent(component: string): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a normalized version segment (`v1`, `v31`).
|
||||||
|
* Generated definitions only ever use this shape, so anything else is rejected
|
||||||
|
* before it reaches a path join.
|
||||||
|
*/
|
||||||
|
export function isValidVersionSegment(segment: string): boolean {
|
||||||
|
return /^v\d+$/.test(segment);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that a resolved path is within the expected base directory.
|
* Validate that a resolved path is within the expected base directory.
|
||||||
* Prevents path traversal even if components pass basic validation.
|
* Prevents path traversal even if components pass basic validation.
|
||||||
|
|
@ -462,6 +471,10 @@ function getNodeFilePath(
|
||||||
targetVersion = `v${targetVersion.slice(1).replace('.', '')}`;
|
targetVersion = `v${targetVersion.slice(1).replace('.', '')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isValidVersionSegment(targetVersion)) {
|
||||||
|
return { error: `Version '${version}' not found for node '${nodeId}'` };
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this is a split version structure
|
// Check if this is a split version structure
|
||||||
if (isSplitVersionStructure(nodeDir, targetVersion)) {
|
if (isSplitVersionStructure(nodeDir, targetVersion)) {
|
||||||
const available = getAvailableDiscriminators(nodeDir, targetVersion);
|
const available = getAvailableDiscriminators(nodeDir, targetVersion);
|
||||||
|
|
@ -491,6 +504,10 @@ function getNodeFilePath(
|
||||||
// Flat file structure
|
// Flat file structure
|
||||||
const filePath = join(nodeDir, `${targetVersion}.ts`);
|
const filePath = join(nodeDir, `${targetVersion}.ts`);
|
||||||
|
|
||||||
|
if (!validatePathWithinBase(filePath, nodeDir)) {
|
||||||
|
return { error: 'Error: Invalid path - path traversal detected' };
|
||||||
|
}
|
||||||
|
|
||||||
if (!existsSync(filePath)) {
|
if (!existsSync(filePath)) {
|
||||||
return { error: `Version '${version}' not found for node '${nodeId}'` };
|
return { error: `Version '${version}' not found for node '${nodeId}'` };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export {
|
||||||
getNodeTypeDefinition,
|
getNodeTypeDefinition,
|
||||||
getNodeTypes,
|
getNodeTypes,
|
||||||
isValidPathComponent,
|
isValidPathComponent,
|
||||||
|
isValidVersionSegment,
|
||||||
validatePathWithinBase,
|
validatePathWithinBase,
|
||||||
parseNodeId,
|
parseNodeId,
|
||||||
toSnakeCase,
|
toSnakeCase,
|
||||||
|
|
|
||||||
|
|
@ -30,10 +30,14 @@ function getOpenAiModel(ctx: ISupplyDataFunctions, model: OpenAiModel) {
|
||||||
|
|
||||||
const timeout = model.timeout;
|
const timeout = model.timeout;
|
||||||
clientConfiguration.fetchOptions = {
|
clientConfiguration.fetchOptions = {
|
||||||
dispatcher: getProxyAgent(model.baseUrl, {
|
dispatcher: getProxyAgent(
|
||||||
headersTimeout: timeout,
|
model.baseUrl,
|
||||||
bodyTimeout: timeout,
|
{
|
||||||
}),
|
headersTimeout: timeout,
|
||||||
|
bodyTimeout: timeout,
|
||||||
|
},
|
||||||
|
ctx.helpers.getSecureEgressFilter(),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const openAiModel = new ChatOpenAI({
|
const openAiModel = new ChatOpenAI({
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,36 @@
|
||||||
/**
|
/**
|
||||||
* Proxy/transport helpers for the AI model suppliers.
|
* Proxy/transport helpers for the AI model suppliers.
|
||||||
*
|
*
|
||||||
* These are the last AI proxy-fetch helpers not yet consolidated onto `@n8n/backend-network`.
|
* The dispatchers this module hands out go to AI SDK clients via
|
||||||
|
* `fetchOptions: { dispatcher }` and are dispatched by the SDK's fetch. They
|
||||||
|
* must be built by a v7 undici — which is why construction is delegated to
|
||||||
|
* `@n8n/backend-network/transport` (itself on `catalog:undici-v7`): a v7
|
||||||
|
* dispatcher accepts the dispatch handlers of every supported Node's fetch,
|
||||||
|
* while a v6 dispatcher rejects the v7 handlers of Node >= 26
|
||||||
|
* (`invalid onError method`).
|
||||||
*
|
*
|
||||||
* The dispatchers built here are handed to AI SDK clients via
|
* Dispatcher construction, egress validation and the dispatcher-bound fetch
|
||||||
* `fetchOptions: { dispatcher }` and dispatched by the global `fetch`. They
|
* come from `@n8n/backend-network`'s DI-free subpaths, so this
|
||||||
* must come from undici v7: a v7 dispatcher accepts the dispatch handlers of
|
* module only maps its callers' options — AI timeout defaults, the shared
|
||||||
* every supported Node's fetch, while a v6 dispatcher rejects the v7 handlers
|
* default dispatcher — onto that shared core.
|
||||||
* of Node >= 26 (`invalid onError method`).
|
|
||||||
*
|
|
||||||
* Proxy URL resolution and the Node `http(s).Agent` (both version-agnostic) do come from `@n8n/backend-network/proxy`,
|
|
||||||
* so this module no longer depends on `proxy-from-env` / `https-proxy-agent` directly.
|
|
||||||
*
|
|
||||||
* TODO: drop these helpers and route their calls through
|
|
||||||
* `@n8n/backend-network/transport` (CAT-3377 consolidated the backend callers
|
|
||||||
* and left these runner-side ones in place).
|
|
||||||
*/
|
*/
|
||||||
import { createHttpsProxyAgent, resolveProxyUrl } from '@n8n/backend-network/proxy'; // `@n8n/backend-network/proxy` is a DI-free subpath: it pulls in only the proxy-agent libs
|
import { passthroughEgressFilter } from '@n8n/backend-network/egress';
|
||||||
import { lookup as defaultLookup } from 'node:dns';
|
import {
|
||||||
|
createHttpsProxyAgent,
|
||||||
|
isProxyRequired,
|
||||||
|
resolveProxyUrl,
|
||||||
|
} from '@n8n/backend-network/proxy'; // `@n8n/backend-network/proxy` is a DI-free subpath: it pulls in only the proxy-agent libs
|
||||||
|
import {
|
||||||
|
buildDispatcher,
|
||||||
|
dispatchedFetch,
|
||||||
|
type TransportSsrfPolicy,
|
||||||
|
} from '@n8n/backend-network/transport';
|
||||||
import type { AgentOptions } from 'node:https';
|
import type { AgentOptions } from 'node:https';
|
||||||
import type { LookupFunction } from 'node:net';
|
import type { Dispatcher } from 'undici';
|
||||||
/* eslint-disable n8n-local-rules/no-uncentralized-http -- raw dispatchers for AI SDK `fetchOptions`; see block comment above */
|
|
||||||
import { Agent, ProxyAgent, fetch as undiciFetch } from 'undici';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for configuring HTTP agent timeouts.
|
* Options for configuring HTTP agent timeouts.
|
||||||
* These timeouts are passed to undici's Agent/ProxyAgent to override default 5-minute timeouts.
|
* These timeouts are passed to the underlying undici agents to override default 5-minute timeouts.
|
||||||
*/
|
*/
|
||||||
export interface AgentTimeoutOptions {
|
export interface AgentTimeoutOptions {
|
||||||
headersTimeout?: number;
|
headersTimeout?: number;
|
||||||
|
|
@ -33,6 +38,8 @@ export interface AgentTimeoutOptions {
|
||||||
connectTimeout?: number;
|
connectTimeout?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EgressFilter = TransportSsrfPolicy;
|
||||||
|
|
||||||
// Default timeout for AI operations (1 hour)
|
// Default timeout for AI operations (1 hour)
|
||||||
// Aligned with EXECUTIONS_TIMEOUT_MAX to ensure AI requests don't exceed workflow execution limits
|
// Aligned with EXECUTIONS_TIMEOUT_MAX to ensure AI requests don't exceed workflow execution limits
|
||||||
// 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
|
||||||
|
|
@ -40,41 +47,57 @@ const DEFAULT_TIMEOUT = parseInt(process.env.N8N_AI_TIMEOUT_MAX ?? '3600000', 10
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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).
|
||||||
* Resolving against a stand-in cannot honor `NO_PROXY` for the real target, but it is better than having no proxy agent at all.
|
* It only decides whether an env-proxied dispatcher is built at all: that dispatcher re-resolves
|
||||||
|
* the proxy from the environment per dispatched request, so `NO_PROXY` is honored for the real target.
|
||||||
*/
|
*/
|
||||||
const PROXY_FALLBACK_TARGET = 'https://example.nonexistent/';
|
const PROXY_FALLBACK_TARGET = 'https://example.nonexistent/';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent shared by every caller that needs no proxy, no timeout overrides and the
|
* Dispatcher shared by every caller that needs no proxy, no timeout overrides
|
||||||
* plain system DNS lookup, so those callers keep a single connection pool instead
|
* and no effective egress filter, so those callers keep a single connection
|
||||||
* of building an agent (and a pool) per request.
|
* pool instead of building a dispatcher (and a pool) per request.
|
||||||
*/
|
*/
|
||||||
let sharedAgent: Agent | undefined;
|
let sharedDispatcher: Dispatcher | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an undici Agent or ProxyAgent with configured timeouts based on the environment variables and target URL.
|
* The passthrough filter (the "no policy configured" singleton) enforces
|
||||||
|
* nothing, so it resolves to `'disabled'` and keeps the shared-dispatcher
|
||||||
|
* fast path. Every other supplied policy is returned unchanged.
|
||||||
|
*/
|
||||||
|
function toSsrfOption(egressFilter: EgressFilter): EgressFilter | 'disabled' {
|
||||||
|
if (egressFilter === passthroughEgressFilter) {
|
||||||
|
return 'disabled';
|
||||||
|
}
|
||||||
|
return egressFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an undici dispatcher with configured timeouts based on the environment variables and target URL.
|
||||||
* When target URL is not provided, NO_PROXY environment variable is not respected.
|
* When target URL is not provided, NO_PROXY environment variable is not respected.
|
||||||
*
|
*
|
||||||
* @param targetUrl - The target URL to check proxy configuration for (optional)
|
* @param targetUrl - The target URL to check proxy configuration for (optional)
|
||||||
* @param timeoutOptions - Optional timeout configuration to override defaults.
|
* @param timeoutOptions - Optional timeout configuration to override defaults.
|
||||||
* @param lookup - Connect-time DNS lookup, applied to every Agent this builds.
|
* @param egressFilter - The execution context's secure egress filter, enforced on every
|
||||||
* Defaults to the plain system lookup.
|
* dispatched request: pre-flight URL validation on each redirect hop and
|
||||||
* @returns A ProxyAgent when a proxy applies to the target, otherwise an Agent. Callers using the
|
* a pinned DNS resolution. The passthrough filter enforces nothing.
|
||||||
* defaults (no timeout options, no `N8N_AI_TIMEOUT_MAX`, plain lookup) share one Agent.
|
* @returns A dispatcher routing through the environment proxy when one applies to the target,
|
||||||
|
* otherwise a direct dispatcher. Callers using the defaults (no timeout options, no
|
||||||
|
* `N8N_AI_TIMEOUT_MAX`, no effective egress filter) share one dispatcher.
|
||||||
*
|
*
|
||||||
* @remarks
|
* @remarks
|
||||||
* The default undici timeouts (5 minutes) are too short for many AI operations, so an agent is
|
* The default undici timeouts (5 minutes) are too short for many AI operations, so a dispatcher
|
||||||
* always returned rather than falling back to undici's global dispatcher. `N8N_AI_TIMEOUT_MAX`
|
* is always returned rather than falling back to undici's global dispatcher. `N8N_AI_TIMEOUT_MAX`
|
||||||
* is honoured even when no proxy and no explicit timeout options are configured.
|
* is honoured even when no proxy and no explicit timeout options are configured.
|
||||||
*/
|
*/
|
||||||
export function getProxyAgent(
|
export function getProxyAgent(
|
||||||
targetUrl?: string,
|
targetUrl: string | undefined,
|
||||||
timeoutOptions?: AgentTimeoutOptions,
|
timeoutOptions: AgentTimeoutOptions | undefined,
|
||||||
lookup: LookupFunction = defaultLookup,
|
egressFilter: EgressFilter,
|
||||||
) {
|
): Dispatcher {
|
||||||
const proxyUrl = resolveProxyUrl(targetUrl, PROXY_FALLBACK_TARGET);
|
const proxyRequired = isProxyRequired(targetUrl, PROXY_FALLBACK_TARGET);
|
||||||
|
const ssrf = toSsrfOption(egressFilter);
|
||||||
|
|
||||||
const agentOptions = {
|
const timeouts = {
|
||||||
headersTimeout: timeoutOptions?.headersTimeout ?? DEFAULT_TIMEOUT,
|
headersTimeout: timeoutOptions?.headersTimeout ?? DEFAULT_TIMEOUT,
|
||||||
bodyTimeout: timeoutOptions?.bodyTimeout ?? DEFAULT_TIMEOUT,
|
bodyTimeout: timeoutOptions?.bodyTimeout ?? DEFAULT_TIMEOUT,
|
||||||
...(timeoutOptions?.connectTimeout !== undefined && {
|
...(timeoutOptions?.connectTimeout !== undefined && {
|
||||||
|
|
@ -82,19 +105,15 @@ export function getProxyAgent(
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (proxyUrl) {
|
|
||||||
return new ProxyAgent({ uri: proxyUrl, ...agentOptions });
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDefaultCase =
|
const isDefaultCase =
|
||||||
!timeoutOptions && !process.env.N8N_AI_TIMEOUT_MAX && lookup === defaultLookup;
|
!proxyRequired && !timeoutOptions && !process.env.N8N_AI_TIMEOUT_MAX && ssrf === 'disabled';
|
||||||
|
|
||||||
if (isDefaultCase) {
|
if (isDefaultCase) {
|
||||||
sharedAgent ??= new Agent({ ...agentOptions, connect: { lookup } });
|
sharedDispatcher ??= buildDispatcher(false, 'disabled', { timeouts });
|
||||||
return sharedAgent;
|
return sharedDispatcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Agent({ ...agentOptions, connect: { lookup } });
|
return buildDispatcher(proxyRequired ? 'env' : false, ssrf, { timeouts });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -107,42 +126,41 @@ export interface ProxyFetchOptions {
|
||||||
init?: RequestInit;
|
init?: RequestInit;
|
||||||
/** Optional timeout configuration to override defaults */
|
/** Optional timeout configuration to override defaults */
|
||||||
timeoutOptions?: AgentTimeoutOptions;
|
timeoutOptions?: AgentTimeoutOptions;
|
||||||
/** Connect-time DNS lookup, e.g. an egress filter's secure lookup */
|
/** Egress filter enforced on every dispatched request, e.g. the execution context's secure egress filter */
|
||||||
lookup: LookupFunction;
|
egressFilter: EgressFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make a fetch() request with an Agent/ProxyAgent that has configured timeouts.
|
* Make a fetch() request with a dispatcher that has configured timeouts.
|
||||||
* If proxy environment variables are set, uses ProxyAgent; otherwise uses Agent.
|
* If proxy environment variables are set, the dispatcher routes through the proxy.
|
||||||
*/
|
*/
|
||||||
export async function proxyFetch({
|
export async function proxyFetch({
|
||||||
input,
|
input,
|
||||||
init,
|
init,
|
||||||
timeoutOptions,
|
timeoutOptions,
|
||||||
lookup,
|
egressFilter,
|
||||||
}: ProxyFetchOptions): Promise<Response> {
|
}: ProxyFetchOptions): Promise<Response> {
|
||||||
// Two Request classes exist at runtime (the global one and this package's
|
// Two Request classes exist at runtime (the global one and this package's
|
||||||
// undici), so detect a Request by exclusion instead of `instanceof`.
|
// undici), so detect a Request by exclusion instead of `instanceof`.
|
||||||
const isRequest = typeof input !== 'string' && !(input instanceof URL);
|
const isRequest = typeof input !== 'string' && !(input instanceof URL);
|
||||||
const targetUrl = isRequest ? input.url : input.toString();
|
const targetUrl = isRequest ? input.url : input.toString();
|
||||||
const dispatcher = getProxyAgent(targetUrl, timeoutOptions, lookup);
|
const dispatcher = getProxyAgent(targetUrl, timeoutOptions, egressFilter);
|
||||||
|
|
||||||
// The dispatcher comes from this package's undici, so the request must use
|
// `dispatchedFetch` runs the fetch of the undici the dispatcher was built
|
||||||
// the same undici's fetch: the global fetch on Node >= 26 rejects it. That
|
// from: the global fetch on Node >= 26 rejects a foreign dispatcher. That
|
||||||
// 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.
|
||||||
return (await undiciFetch(targetUrl, {
|
if (!isRequest) return await dispatchedFetch(dispatcher, input, init);
|
||||||
...(isRequest && {
|
|
||||||
method: input.method,
|
return await dispatchedFetch(dispatcher, targetUrl, {
|
||||||
headers: [...input.headers],
|
method: input.method,
|
||||||
body: input.body === null ? undefined : await input.arrayBuffer(),
|
headers: [...input.headers],
|
||||||
signal: input.signal,
|
body: input.body === null ? undefined : await input.arrayBuffer(),
|
||||||
redirect: input.redirect,
|
signal: input.signal,
|
||||||
}),
|
redirect: input.redirect,
|
||||||
...(init as Parameters<typeof undiciFetch>[1]),
|
...init,
|
||||||
dispatcher,
|
});
|
||||||
})) as unknown as Response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/ai-workflow-builder",
|
"name": "@n8n/ai-workflow-builder",
|
||||||
"version": "1.39.0",
|
"version": "1.39.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/api-types",
|
"name": "@n8n/api-types",
|
||||||
"version": "1.39.0",
|
"version": "1.39.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { Z } from '../../zod-class';
|
||||||
|
|
||||||
|
export class TestCredentialRequestDto extends Z.class({
|
||||||
|
credentials: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string().default(''),
|
||||||
|
type: z.string(),
|
||||||
|
data: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
}),
|
||||||
|
}) {}
|
||||||
|
|
@ -122,6 +122,7 @@ export {
|
||||||
} from './git-connections/git-connections.dto';
|
} from './git-connections/git-connections.dto';
|
||||||
|
|
||||||
export { CreateCredentialDto } from './credentials/create-credential.dto';
|
export { CreateCredentialDto } from './credentials/create-credential.dto';
|
||||||
|
export { TestCredentialRequestDto } from './credentials/test-credential-request.dto';
|
||||||
export {
|
export {
|
||||||
CredentialPublicDto,
|
CredentialPublicDto,
|
||||||
CredentialListPublicDto,
|
CredentialListPublicDto,
|
||||||
|
|
|
||||||
|
|
@ -336,6 +336,17 @@ export type FrontendModuleSettings = {
|
||||||
autoExposeNewWorkflows: boolean;
|
autoExposeNewWorkflows: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client settings for the instance-reporting module. Present only when the
|
||||||
|
* module is enabled on this instance.
|
||||||
|
*/
|
||||||
|
'instance-reporting'?: {
|
||||||
|
/** Whether a receiver is configured, i.e. whether reports are actually sent. */
|
||||||
|
enabled: boolean;
|
||||||
|
/** Minute of the UTC day the daily report fires at, as `HH:mm`. Absent when disabled. */
|
||||||
|
reportTime?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client settings for the encryption-key-manager module.
|
* Client settings for the encryption-key-manager module.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export type * from './community-node-types';
|
||||||
export type * from './quick-connect';
|
export type * from './quick-connect';
|
||||||
export * from './agents/index';
|
export * from './agents/index';
|
||||||
export * from './instance-registry-types';
|
export * from './instance-registry-types';
|
||||||
|
export type * from './instance-reporting';
|
||||||
export type * from './worker-pools';
|
export type * from './worker-pools';
|
||||||
export * from './redaction-enforcement';
|
export * from './redaction-enforcement';
|
||||||
export * from './redaction-enforcement-floor';
|
export * from './redaction-enforcement-floor';
|
||||||
|
|
@ -368,6 +369,8 @@ export {
|
||||||
CANVAS_NODE_CONTEXT_FLAG,
|
CANVAS_NODE_CONTEXT_FLAG,
|
||||||
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_ENABLED_VARIANT,
|
||||||
INSTANCE_AI_NODE_USAGE_FLAG,
|
INSTANCE_AI_NODE_USAGE_FLAG,
|
||||||
INSTANCE_AI_FOLDER_EXPLORATION_FLAG,
|
INSTANCE_AI_FOLDER_EXPLORATION_FLAG,
|
||||||
domainAccessActionSchema,
|
domainAccessActionSchema,
|
||||||
|
|
@ -437,10 +440,14 @@ export {
|
||||||
deriveInstanceAiSetupState,
|
deriveInstanceAiSetupState,
|
||||||
INSTANCE_AI_THREAD_SOURCES,
|
INSTANCE_AI_THREAD_SOURCES,
|
||||||
INSTANCE_AI_THREAD_SOURCE_FALLBACK,
|
INSTANCE_AI_THREAD_SOURCE_FALLBACK,
|
||||||
|
instanceAiBuildModeSchema,
|
||||||
|
instanceAiPromptConfigurationSchema,
|
||||||
INSTANCE_AI_RUN_LIMIT_REASONS,
|
INSTANCE_AI_RUN_LIMIT_REASONS,
|
||||||
} from './schemas/instance-ai.schema';
|
} from './schemas/instance-ai.schema';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
InstanceAiBuildMode,
|
||||||
|
InstanceAiPromptConfiguration,
|
||||||
InstanceAiThreadSource,
|
InstanceAiThreadSource,
|
||||||
InstanceAiThreadSourcePersisted,
|
InstanceAiThreadSourcePersisted,
|
||||||
InstanceAiThreadOrigin,
|
InstanceAiThreadOrigin,
|
||||||
|
|
|
||||||
7
packages/@n8n/api-types/src/instance-reporting.ts
Normal file
7
packages/@n8n/api-types/src/instance-reporting.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export type InstanceReportingStatus = {
|
||||||
|
/**
|
||||||
|
* When the receiver last accepted a report, as an ISO 8601 UTC string, or
|
||||||
|
* `null` when it never did.
|
||||||
|
*/
|
||||||
|
lastSuccessfulReport: string | null;
|
||||||
|
};
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
errorPayloadSchema,
|
errorPayloadSchema,
|
||||||
FETCH_URL_ALLOW_ALL_GRANT_KEY,
|
FETCH_URL_ALLOW_ALL_GRANT_KEY,
|
||||||
InstanceAiAdminSettingsUpdateRequest,
|
InstanceAiAdminSettingsUpdateRequest,
|
||||||
|
InstanceAiSendMessageRequest,
|
||||||
instanceAiEventSchema,
|
instanceAiEventSchema,
|
||||||
INSTANCE_AI_EPHEMERAL_EVENT_TYPES,
|
INSTANCE_AI_EPHEMERAL_EVENT_TYPES,
|
||||||
isDisplayableConfirmationRequest,
|
isDisplayableConfirmationRequest,
|
||||||
|
|
@ -46,6 +47,22 @@ import {
|
||||||
type InstanceAiPermissions,
|
type InstanceAiPermissions,
|
||||||
} from '../instance-ai.schema';
|
} from '../instance-ai.schema';
|
||||||
|
|
||||||
|
describe('Instance AI prompt version requests', () => {
|
||||||
|
it('accepts an optional version pin and rejects empty or oversized pins', () => {
|
||||||
|
const base = { message: 'Build a workflow', timeZone: 'UTC' };
|
||||||
|
expect(InstanceAiSendMessageRequest.safeParse(base).success).toBe(true);
|
||||||
|
expect(
|
||||||
|
InstanceAiSendMessageRequest.parse({ ...base, promptVersion: ' progressive@1 ' })
|
||||||
|
.promptVersion,
|
||||||
|
).toBe('progressive@1');
|
||||||
|
for (const promptVersion of ['', ' ', 'x'.repeat(129)]) {
|
||||||
|
expect(InstanceAiSendMessageRequest.safeParse({ ...base, promptVersion }).success).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('sandbox provider', () => {
|
describe('sandbox provider', () => {
|
||||||
it('accepts supported providers', () => {
|
it('accepts supported providers', () => {
|
||||||
expect(isInstanceAiSandboxProvider('n8n-sandbox')).toBe(true);
|
expect(isInstanceAiSandboxProvider('n8n-sandbox')).toBe(true);
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export type BreakingChangeVersion = z.infer<typeof breakingChangeVersionSchema>;
|
||||||
* When a new major version is released, update this value and add the
|
* When a new major version is released, update this value and add the
|
||||||
* corresponding breaking-change rules on the backend.
|
* corresponding breaking-change rules on the backend.
|
||||||
*/
|
*/
|
||||||
export const MIGRATION_REPORT_TARGET_VERSION: BreakingChangeVersion | null = null;
|
export const MIGRATION_REPORT_TARGET_VERSION: BreakingChangeVersion | null = 'v3';
|
||||||
|
|
||||||
// Common schemas
|
// Common schemas
|
||||||
const recommendationSchema = z.object({
|
const recommendationSchema = z.object({
|
||||||
|
|
|
||||||
|
|
@ -1390,12 +1390,33 @@ export const instanceAiHandoffContextSchema = z.discriminatedUnion('source', [
|
||||||
]);
|
]);
|
||||||
export type InstanceAiHandoffContext = z.infer<typeof instanceAiHandoffContextSchema>;
|
export type InstanceAiHandoffContext = z.infer<typeof instanceAiHandoffContextSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build style for a run. `progressive` makes the agent build a minimal working
|
||||||
|
* slice first, gate increments on real executions, and extend on actual
|
||||||
|
* execution data. `default` uses the standard building policy.
|
||||||
|
*/
|
||||||
|
export const instanceAiBuildModeSchema = z.enum(['default', 'progressive']);
|
||||||
|
export type InstanceAiBuildMode = z.infer<typeof instanceAiBuildModeSchema>;
|
||||||
|
|
||||||
|
export const instanceAiPromptConfigurationSchema = z.object({
|
||||||
|
version: z.string(),
|
||||||
|
systemPromptVersion: z.string(),
|
||||||
|
skillVariants: z.array(z.string()),
|
||||||
|
skillsHash: z.string(),
|
||||||
|
fallbackFrom: z.string().optional(),
|
||||||
|
});
|
||||||
|
export type InstanceAiPromptConfiguration = z.infer<typeof instanceAiPromptConfigurationSchema>;
|
||||||
|
|
||||||
export class InstanceAiSendMessageRequest extends Z.class({
|
export class InstanceAiSendMessageRequest extends Z.class({
|
||||||
message: z.string().default(''),
|
message: z.string().default(''),
|
||||||
attachments: z.array(instanceAiAttachmentSchema).max(10).optional(),
|
attachments: z.array(instanceAiAttachmentSchema).max(10).optional(),
|
||||||
context: instanceAiHandoffContextSchema.optional(),
|
context: instanceAiHandoffContextSchema.optional(),
|
||||||
timeZone: TimeZoneSchema,
|
timeZone: TimeZoneSchema,
|
||||||
pushRef: z.string().optional(),
|
pushRef: z.string().optional(),
|
||||||
|
/** Explicit override for evals. Omit to use the backend experiment assignment. */
|
||||||
|
mode: instanceAiBuildModeSchema.optional(),
|
||||||
|
/** Pin a published prompt profile. Takes precedence over mode. */
|
||||||
|
promptVersion: z.string().trim().min(1).max(128).optional(),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class InstanceAiCorrectTaskRequest extends Z.class({
|
export class InstanceAiCorrectTaskRequest extends Z.class({
|
||||||
|
|
@ -1765,6 +1786,7 @@ export interface InstanceAiMemoryTaskSnapshot {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstanceAiThreadStatusResponse {
|
export interface InstanceAiThreadStatusResponse {
|
||||||
|
promptConfiguration?: InstanceAiPromptConfiguration;
|
||||||
hasActiveRun: boolean;
|
hasActiveRun: boolean;
|
||||||
isSuspended: boolean;
|
isSuspended: boolean;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
|
|
@ -2288,6 +2310,10 @@ export const CANVAS_NODE_CONTEXT_FLAG = '104_canvas_aia_node_context';
|
||||||
export const INSTANCE_AI_CONVERSATION_HISTORY_FLAG = '109_instance_ai_conversation_history';
|
export const INSTANCE_AI_CONVERSATION_HISTORY_FLAG = '109_instance_ai_conversation_history';
|
||||||
|
|
||||||
export const INSTANCE_AI_CONVERSATION_HISTORY_ENABLED_VARIANT = 'variant';
|
export const INSTANCE_AI_CONVERSATION_HISTORY_ENABLED_VARIANT = 'variant';
|
||||||
|
|
||||||
|
export const INSTANCE_AI_PROGRESSIVE_BUILDING_FLAG = '111_instance_ai_progressive_building';
|
||||||
|
export const INSTANCE_AI_PROGRESSIVE_BUILDING_ENABLED_VARIANT = 'variant';
|
||||||
|
|
||||||
/** 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.39.0",
|
"version": "1.39.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/backend-network",
|
"name": "@n8n/backend-network",
|
||||||
"version": "1.13.0",
|
"version": "1.13.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
@ -35,6 +35,11 @@
|
||||||
"import": "./src/transport.ts",
|
"import": "./src/transport.ts",
|
||||||
"require": "./dist/transport.js"
|
"require": "./dist/transport.js"
|
||||||
},
|
},
|
||||||
|
"./egress": {
|
||||||
|
"types": "./dist/egress.d.ts",
|
||||||
|
"import": "./src/egress.ts",
|
||||||
|
"require": "./dist/egress.js"
|
||||||
|
},
|
||||||
"./proxy": {
|
"./proxy": {
|
||||||
"types": "./dist/proxy/index.d.ts",
|
"types": "./dist/proxy/index.d.ts",
|
||||||
"import": "./src/proxy/index.ts",
|
"import": "./src/proxy/index.ts",
|
||||||
|
|
@ -49,6 +54,9 @@
|
||||||
"transport": [
|
"transport": [
|
||||||
"dist/transport.d.ts"
|
"dist/transport.d.ts"
|
||||||
],
|
],
|
||||||
|
"egress": [
|
||||||
|
"dist/egress.d.ts"
|
||||||
|
],
|
||||||
"proxy": [
|
"proxy": [
|
||||||
"dist/proxy/index.d.ts"
|
"dist/proxy/index.d.ts"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
9
packages/@n8n/backend-network/src/egress.ts
Normal file
9
packages/@n8n/backend-network/src/egress.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/**
|
||||||
|
* Egress entry point (`@n8n/backend-network/egress`).
|
||||||
|
*
|
||||||
|
* DI-free like `./transport`, but unlike it this subpath depends on
|
||||||
|
* `n8n-workflow` (types) and `@n8n/utils`: it exposes the passthrough filter
|
||||||
|
* singleton so DI-less consumers can detect "no egress policy configured" by
|
||||||
|
* object identity.
|
||||||
|
*/
|
||||||
|
export { passthroughEgressFilter } from './ssrf/passthrough-egress-filter';
|
||||||
|
|
@ -4,7 +4,7 @@ import { Container } from '@n8n/di';
|
||||||
import { fetch as undiciFetch } from 'undici';
|
import { fetch as undiciFetch } from 'undici';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
import type { SsrfProtectionService } from '../../ssrf';
|
import { passthroughEgressFilter, type SsrfProtectionService } from '../../ssrf';
|
||||||
import { makeLookupFn } from '../../ssrf/__tests__/mock-ssrf-bridge';
|
import { makeLookupFn } from '../../ssrf/__tests__/mock-ssrf-bridge';
|
||||||
import { OutboundHttp } from '../outbound-http';
|
import { OutboundHttp } from '../outbound-http';
|
||||||
|
|
||||||
|
|
@ -36,6 +36,50 @@ describe('DI registration', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('egressFilter', () => {
|
||||||
|
it('returns the SSRF protection service when the instance enables protection', () => {
|
||||||
|
const service = mock<SsrfProtectionService>();
|
||||||
|
const facade = new OutboundHttp(
|
||||||
|
service,
|
||||||
|
mock<SsrfProtectionConfig>({ enabled: true }),
|
||||||
|
mock<Logger>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(facade.egressFilter()).toBe(service);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the passthrough filter when the instance disables protection', () => {
|
||||||
|
const facade = new OutboundHttp(
|
||||||
|
mock<SsrfProtectionService>(),
|
||||||
|
mock<SsrfProtectionConfig>({ enabled: false }),
|
||||||
|
mock<Logger>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(facade.egressFilter()).toBe(passthroughEgressFilter);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the SSRF protection service for 'enforced' even when the instance disables protection", () => {
|
||||||
|
const service = mock<SsrfProtectionService>();
|
||||||
|
const facade = new OutboundHttp(
|
||||||
|
service,
|
||||||
|
mock<SsrfProtectionConfig>({ enabled: false }),
|
||||||
|
mock<Logger>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(facade.egressFilter('enforced')).toBe(service);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the passthrough filter for 'unsafe' even when the instance enables protection", () => {
|
||||||
|
const facade = new OutboundHttp(
|
||||||
|
mock<SsrfProtectionService>(),
|
||||||
|
mock<SsrfProtectionConfig>({ enabled: true }),
|
||||||
|
mock<Logger>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(facade.egressFilter('unsafe')).toBe(passthroughEgressFilter);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('transport asCustomFetch', () => {
|
describe('transport asCustomFetch', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,9 @@ export function isSupportedProxyUrl(value: string | null | undefined): value is
|
||||||
export type ProxyOption = 'env' | ProxyUrl | false;
|
export type ProxyOption = 'env' | ProxyUrl | false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controls SSRF protection for an outbound HTTP client.
|
* Resolved SSRF policy for an outbound HTTP client:
|
||||||
* Explicitly passing `'disabled'` makes the opt-out visible in calling code.
|
* - the bridge to enforce
|
||||||
|
* - or 'disabled' when none applies.
|
||||||
*/
|
*/
|
||||||
export type SsrfOption = SsrfBridge | 'disabled';
|
export type SsrfOption = SsrfBridge | 'disabled';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,14 @@ import type {
|
||||||
IN8nHttpFullResponse,
|
IN8nHttpFullResponse,
|
||||||
IN8nHttpResponse,
|
IN8nHttpResponse,
|
||||||
IRequestOptions,
|
IRequestOptions,
|
||||||
|
NodeEgressFilter,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import type http from 'node:http';
|
import type http from 'node:http';
|
||||||
import type https from 'node:https';
|
import type https from 'node:https';
|
||||||
import type { Readable } from 'node:stream';
|
import type { Readable } from 'node:stream';
|
||||||
import type { Dispatcher } from 'undici';
|
import type { Dispatcher } from 'undici';
|
||||||
|
|
||||||
import { SsrfProtectionService } from '../ssrf';
|
import { passthroughEgressFilter, SsrfProtectionService } from '../ssrf';
|
||||||
import { httpRequest } from './axios/request';
|
import { httpRequest } from './axios/request';
|
||||||
import { withClientDefaults } from './client-default-headers';
|
import { withClientDefaults } from './client-default-headers';
|
||||||
import { HttpRequestClientOptions } from './client-options';
|
import { HttpRequestClientOptions } from './client-options';
|
||||||
|
|
@ -241,6 +242,17 @@ export class OutboundHttp {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The egress filter enforcing the given {@link UseDefaultSsrfPolicy}, for
|
||||||
|
* callers that hand a filter to a third-party SDK: the container's
|
||||||
|
* `SsrfProtectionService` when the policy resolves to guarded, a passthrough
|
||||||
|
* filter otherwise.
|
||||||
|
*/
|
||||||
|
egressFilter(useDefaultSsrfPolicy: UseDefaultSsrfPolicy = 'safe'): NodeEgressFilter {
|
||||||
|
const ssrf = this.resolveSsrf(useDefaultSsrfPolicy);
|
||||||
|
return ssrf === 'disabled' ? passthroughEgressFilter : ssrf;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a caller's {@link UseDefaultSsrfPolicy} to the SSRF policy to enforce:
|
* Resolves a caller's {@link UseDefaultSsrfPolicy} to the SSRF policy to enforce:
|
||||||
* the container's `SsrfProtectionService` always in `'enforced'` mode, and in
|
* the container's `SsrfProtectionService` always in `'enforced'` mode, and in
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,36 @@
|
||||||
|
import type { LookupFunction } from 'node:net';
|
||||||
import type { Dispatcher } from 'undici';
|
import type { Dispatcher } from 'undici';
|
||||||
import { Agent, EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from 'undici';
|
import { Agent, EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from 'undici';
|
||||||
|
|
||||||
import type { SsrfBridge } from '../../ssrf';
|
import type { ProxyOption } from '../node-agents';
|
||||||
import type { ProxyOption, SsrfOption } from '../node-agents';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop-in replacement type for the global `fetch`.
|
* Drop-in replacement type for the global `fetch`.
|
||||||
*/
|
*/
|
||||||
export type CustomFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
export type CustomFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural SSRF policy for the dispatcher path: the subset of `SsrfBridge`
|
||||||
|
* this transport core consumes. Kept structural (and dependency-free) so
|
||||||
|
* DI-less callers can satisfy it with their own policy object.
|
||||||
|
*/
|
||||||
|
export interface TransportSsrfPolicy {
|
||||||
|
/** Pre-flight validation of a dispatched target (the initial request and every redirect hop). */
|
||||||
|
validateUrl(url: string | URL): Promise<{ ok: true } | { ok: false; error: Error }>;
|
||||||
|
/** Validation of a connection host no DNS lookup will see (e.g. an IP-literal proxy host). */
|
||||||
|
validateConnectionHost(host: string): { ok: true } | { ok: false; error: Error };
|
||||||
|
/** DNS lookup drop-in that pins the validated address to the socket at connect time. */
|
||||||
|
createSecureLookup(): LookupFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolved SSRF policy for the dispatcher path: the policy to enforce, or
|
||||||
|
* `'disabled'` when none applies (instance protection off, no egress filter in
|
||||||
|
* the calling context, or an `'unsafe'` opt-out already resolved upstream).
|
||||||
|
* Intent is expressed at the client layer (`UseDefaultSsrfPolicy`), not here.
|
||||||
|
*/
|
||||||
|
export type TransportSsrfOption = TransportSsrfPolicy | 'disabled';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-request authorization gate run against the target of every dispatched request
|
* Per-request authorization gate run against the target of every dispatched request
|
||||||
* (the initial request and every redirect hop).
|
* (the initial request and every redirect hop).
|
||||||
|
|
@ -45,7 +67,7 @@ export interface CreateDispatcherTransportOptions {
|
||||||
/** Proxy routing. Defaults to `'env'` (HTTP(S)_PROXY / NO_PROXY). */
|
/** Proxy routing. Defaults to `'env'` (HTTP(S)_PROXY / NO_PROXY). */
|
||||||
proxy?: ProxyOption;
|
proxy?: ProxyOption;
|
||||||
/** SSRF policy. Defaults to `'disabled'`. */
|
/** SSRF policy. Defaults to `'disabled'`. */
|
||||||
ssrf?: SsrfOption;
|
ssrf?: TransportSsrfOption;
|
||||||
/** Undici agent timeout overrides. */
|
/** Undici agent timeout overrides. */
|
||||||
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 */
|
||||||
|
|
@ -85,7 +107,7 @@ export interface BuildDispatcherOptions {
|
||||||
*/
|
*/
|
||||||
export function buildDispatcher(
|
export function buildDispatcher(
|
||||||
proxy: ProxyOption,
|
proxy: ProxyOption,
|
||||||
ssrf: SsrfOption,
|
ssrf: TransportSsrfOption,
|
||||||
options: BuildDispatcherOptions = {},
|
options: BuildDispatcherOptions = {},
|
||||||
): Dispatcher {
|
): Dispatcher {
|
||||||
let dispatcher = buildDispatcherFromProxy(proxy, ssrf, options?.timeouts);
|
let dispatcher = buildDispatcherFromProxy(proxy, ssrf, options?.timeouts);
|
||||||
|
|
@ -100,7 +122,7 @@ export function buildDispatcher(
|
||||||
|
|
||||||
function buildDispatcherFromProxy(
|
function buildDispatcherFromProxy(
|
||||||
proxy: ProxyOption,
|
proxy: ProxyOption,
|
||||||
ssrf: SsrfOption,
|
ssrf: TransportSsrfOption,
|
||||||
timeouts?: TransportTimeoutOptions,
|
timeouts?: TransportTimeoutOptions,
|
||||||
): Dispatcher {
|
): Dispatcher {
|
||||||
const agentOptions = toAgentTimeoutOptions(timeouts);
|
const agentOptions = toAgentTimeoutOptions(timeouts);
|
||||||
|
|
@ -127,7 +149,7 @@ function buildDispatcherFromProxy(
|
||||||
* A caller's `connect` does not reach it: `ProxyAgent` overwrites that with its own
|
* A caller's `connect` does not reach it: `ProxyAgent` overwrites that with its own
|
||||||
* tunnel handshake.
|
* tunnel handshake.
|
||||||
*/
|
*/
|
||||||
function secureProxyConnect(ssrf: SsrfOption) {
|
function secureProxyConnect(ssrf: TransportSsrfOption) {
|
||||||
return ssrf === 'disabled' ? {} : { proxyTls: { lookup: ssrf.createSecureLookup() } };
|
return ssrf === 'disabled' ? {} : { proxyTls: { lookup: ssrf.createSecureLookup() } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,7 +157,7 @@ function secureProxyConnect(ssrf: SsrfOption) {
|
||||||
* Catches an IP-literal proxy host, which no lookup sees.
|
* Catches an IP-literal proxy host, which no lookup sees.
|
||||||
* Applies to an explicit proxy URI, the one form that can come from a request.
|
* Applies to an explicit proxy URI, the one form that can come from a request.
|
||||||
*/
|
*/
|
||||||
function assertProxyHostAllowed(ssrf: SsrfOption, proxyUri: string): void {
|
function assertProxyHostAllowed(ssrf: TransportSsrfOption, proxyUri: string): void {
|
||||||
if (ssrf === 'disabled') {
|
if (ssrf === 'disabled') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -162,7 +184,7 @@ function proxyHostname(uri: string): string | undefined {
|
||||||
* It pins the validated IP to the socket, so a hostname that passed the interceptor's pre-flight
|
* It pins the validated IP to the socket, so a hostname that passed the interceptor's pre-flight
|
||||||
* `validateUrl` cannot be rebound to a private IP before undici resolves it again at connect time (DNS-rebinding / TOCTOU).
|
* `validateUrl` cannot be rebound to a private IP before undici resolves it again at connect time (DNS-rebinding / TOCTOU).
|
||||||
*/
|
*/
|
||||||
function secureConnect(ssrf: SsrfOption) {
|
function secureConnect(ssrf: TransportSsrfOption) {
|
||||||
return ssrf === 'disabled' ? {} : { connect: { lookup: ssrf.createSecureLookup() } };
|
return ssrf === 'disabled' ? {} : { connect: { lookup: ssrf.createSecureLookup() } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,7 +244,9 @@ function lazyValue<T>(factory: () => T): () => T {
|
||||||
* That host is fixed for the dispatcher rather than per request, so
|
* That host is fixed for the dispatcher rather than per request, so
|
||||||
* {@link buildDispatcherFromProxy} decides it where the dispatcher is built.
|
* {@link buildDispatcherFromProxy} decides it where the dispatcher is built.
|
||||||
*/
|
*/
|
||||||
export function createSsrfInterceptor(bridge: SsrfBridge): Dispatcher.DispatcherComposeInterceptor {
|
export function createSsrfInterceptor(
|
||||||
|
bridge: Pick<TransportSsrfPolicy, 'validateUrl'>,
|
||||||
|
): Dispatcher.DispatcherComposeInterceptor {
|
||||||
return (dispatch) => (opts, handler) => {
|
return (dispatch) => (opts, handler) => {
|
||||||
let targetUrl: URL;
|
let targetUrl: URL;
|
||||||
try {
|
try {
|
||||||
|
|
@ -309,9 +333,12 @@ function failDispatch(handler: FailableDispatchHandler, error: Error): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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.
|
||||||
|
*/
|
||||||
export async function dispatchedFetch(
|
export async function dispatchedFetch(
|
||||||
dispatcher: Dispatcher,
|
dispatcher: Dispatcher | undefined,
|
||||||
input: RequestInfo | URL,
|
input: RequestInfo | URL,
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1 @@
|
||||||
/**
|
export type { UseDefaultSsrfPolicy } from 'n8n-workflow';
|
||||||
* Controls whether an outbound HTTP client is subject to the instance's
|
|
||||||
* outbound network policy (SSRF protection).
|
|
||||||
*
|
|
||||||
* - `'safe'` (default): the client enforces the instance policy. Whether the
|
|
||||||
* guard actually runs is decided inside `OutboundHttp` from
|
|
||||||
* `SsrfProtectionConfig.enabled` — callers never read that flag themselves.
|
|
||||||
* - `'enforced'`: the guard runs unconditionally, regardless of
|
|
||||||
* `SsrfProtectionConfig.enabled`. Reserve this for destinations that must
|
|
||||||
* stay guarded even on instances that leave protection off.
|
|
||||||
* - `'unsafe'`: the client bypasses the policy unconditionally. Reserve this
|
|
||||||
* for fixed, n8n-owned or operator-configured destinations, and state the
|
|
||||||
* reason in a comment at the call site.
|
|
||||||
*/
|
|
||||||
export type UseDefaultSsrfPolicy = 'safe' | 'enforced' | 'unsafe';
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { resolveProxyUrl } from '../proxy-resolution';
|
import { isProxyRequired, resolveProxyUrl } from '../proxy-resolution';
|
||||||
|
|
||||||
const PROXY_URL = 'http://127.0.0.1:8888';
|
const PROXY_URL = 'http://127.0.0.1:8888';
|
||||||
|
|
||||||
|
|
@ -50,3 +50,29 @@ describe('resolveProxyUrl', () => {
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isProxyRequired', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.HTTP_PROXY;
|
||||||
|
delete process.env.HTTPS_PROXY;
|
||||||
|
delete process.env.NO_PROXY;
|
||||||
|
delete process.env.ALL_PROXY;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns true when a proxy applies to the target', () => {
|
||||||
|
process.env.HTTP_PROXY = PROXY_URL;
|
||||||
|
|
||||||
|
expect(isProxyRequired('http://api.example.com:8080/test')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when no proxy is configured', () => {
|
||||||
|
expect(isProxyRequired('http://api.example.com:8080/test')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when the target is excluded by NO_PROXY', () => {
|
||||||
|
process.env.HTTP_PROXY = PROXY_URL;
|
||||||
|
process.env.NO_PROXY = 'api.example.com';
|
||||||
|
|
||||||
|
expect(isProxyRequired('http://api.example.com:8080/test')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export {
|
||||||
createHttpProxyAgent,
|
createHttpProxyAgent,
|
||||||
createHttpsProxyAgent,
|
createHttpsProxyAgent,
|
||||||
hasProxyEnvironmentVariables,
|
hasProxyEnvironmentVariables,
|
||||||
|
isProxyRequired,
|
||||||
resolveProxyUrl,
|
resolveProxyUrl,
|
||||||
} from './proxy-resolution';
|
} from './proxy-resolution';
|
||||||
export { ensureHostsBypassProxy } from './no-proxy-patch';
|
export { ensureHostsBypassProxy } from './no-proxy-patch';
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,15 @@ export function resolveProxyUrl(
|
||||||
return getProxyForUrl(targetUrl ?? fallbackUrl ?? '') || undefined;
|
return getProxyForUrl(targetUrl ?? fallbackUrl ?? '') || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an environment proxy applies to the given target URL, i.e. whether
|
||||||
|
* {@link resolveProxyUrl} resolves to a proxy for it. For callers that only
|
||||||
|
* need the yes/no (e.g. to pick a proxy mode) rather than the proxy URL itself.
|
||||||
|
*/
|
||||||
|
export function isProxyRequired(targetUrl: string | undefined, fallbackUrl?: string): boolean {
|
||||||
|
return resolveProxyUrl(targetUrl, fallbackUrl) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DI-free proxy resolution and Node proxy-agent factory.
|
* DI-free proxy resolution and Node proxy-agent factory.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import type { LookupFunction } from 'node:net';
|
||||||
*/
|
*/
|
||||||
export const passthroughEgressFilter: NodeEgressFilter = {
|
export const passthroughEgressFilter: NodeEgressFilter = {
|
||||||
validateUrl: async () => await Promise.resolve(createResultOk(undefined)),
|
validateUrl: async () => await Promise.resolve(createResultOk(undefined)),
|
||||||
|
validateConnectionHost: () => createResultOk(undefined),
|
||||||
// Same function reference every call, so callers can compare it by identity.
|
// Same function reference every call, so callers can compare it by identity.
|
||||||
createSecureLookup: (): LookupFunction => lookup,
|
createSecureLookup: (): LookupFunction => lookup,
|
||||||
// No egress policy configured, so every redirect target is allowed.
|
// No egress policy configured, so every redirect target is allowed.
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export type {
|
||||||
DispatcherTransport,
|
DispatcherTransport,
|
||||||
CreateDispatcherTransportOptions,
|
CreateDispatcherTransportOptions,
|
||||||
RequestAuthorizer,
|
RequestAuthorizer,
|
||||||
|
TransportSsrfPolicy,
|
||||||
TransportTimeoutOptions,
|
TransportTimeoutOptions,
|
||||||
} from './http/undici/transport';
|
} from './http/undici/transport';
|
||||||
export type { ProxyOption, ProxyUrl, SsrfOption } from './http/node-agents';
|
export type { ProxyOption, ProxyUrl, SsrfOption } from './http/node-agents';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/backend-test-utils",
|
"name": "@n8n/backend-test-utils",
|
||||||
"version": "1.39.0",
|
"version": "1.39.5",
|
||||||
"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.7.0",
|
"version": "1.7.4",
|
||||||
"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.32.0",
|
"version": "1.32.4",
|
||||||
"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.21.0",
|
"version": "1.21.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/config",
|
"name": "@n8n/config",
|
||||||
"version": "2.37.0",
|
"version": "2.37.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,14 @@ export class InstanceAiConfig {
|
||||||
@Env('N8N_INSTANCE_AI_REFLECTOR_OBSERVATION_TOKENS')
|
@Env('N8N_INSTANCE_AI_REFLECTOR_OBSERVATION_TOKENS')
|
||||||
reflectorObservationTokens: number = 40_000;
|
reflectorObservationTokens: number = 40_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the Observer inside a turn (at tool-loop boundaries). When false the
|
||||||
|
* Observer runs only after the turn, which keeps the prompt prefix stable
|
||||||
|
* within a turn and improves provider prompt-cache reuse.
|
||||||
|
*/
|
||||||
|
@Env('N8N_INSTANCE_AI_MID_RUN_OBSERVATION')
|
||||||
|
midRunObservation: boolean = false;
|
||||||
|
|
||||||
/** Disable the local gateway (filesystem, shell, browser, etc.) for all users. */
|
/** Disable the local gateway (filesystem, shell, browser, etc.) for all users. */
|
||||||
@Env('N8N_INSTANCE_AI_LOCAL_GATEWAY_DISABLED')
|
@Env('N8N_INSTANCE_AI_LOCAL_GATEWAY_DISABLED')
|
||||||
localGatewayDisabled: boolean = false;
|
localGatewayDisabled: boolean = false;
|
||||||
|
|
|
||||||
|
|
@ -344,6 +344,7 @@ describe('GlobalConfig', () => {
|
||||||
browserUseEnabled: true,
|
browserUseEnabled: true,
|
||||||
observerMessageTokens: 30_000,
|
observerMessageTokens: 30_000,
|
||||||
reflectorObservationTokens: 40_000,
|
reflectorObservationTokens: 40_000,
|
||||||
|
midRunObservation: false,
|
||||||
sandboxEnabled: false,
|
sandboxEnabled: false,
|
||||||
sandboxProvider: 'n8n-sandbox',
|
sandboxProvider: 'n8n-sandbox',
|
||||||
sandboxImage: 'daytonaio/sandbox:0.5.0',
|
sandboxImage: 'daytonaio/sandbox:0.5.0',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/create-node",
|
"name": "@n8n/create-node",
|
||||||
"version": "0.47.0",
|
"version": "0.47.4",
|
||||||
"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.39.0",
|
"version": "1.39.5",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,18 @@ export class DeploymentKeyRepository extends Repository<DeploymentKey> {
|
||||||
return await this.find({ where: { type } });
|
return await this.find({ where: { type } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findDataEncryptionKeys(): Promise<DeploymentKey[]> {
|
||||||
|
return await this.find({ where: { type: 'data_encryption' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async rewrapLegacyDataEncryptionValue(
|
||||||
|
id: string,
|
||||||
|
oldValue: string,
|
||||||
|
wrappedValue: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.update({ id, type: 'data_encryption', value: oldValue }, { value: wrappedValue });
|
||||||
|
}
|
||||||
|
|
||||||
async findAndCountForList(
|
async findAndCountForList(
|
||||||
opts: ListDeploymentKeysOptions,
|
opts: ListDeploymentKeysOptions,
|
||||||
): Promise<{ items: DeploymentKey[]; count: number }> {
|
): Promise<{ items: DeploymentKey[]; count: number }> {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/decorators",
|
"name": "@n8n/decorators",
|
||||||
"version": "1.39.0",
|
"version": "1.39.4",
|
||||||
"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.19.0",
|
"version": "0.19.1",
|
||||||
"description": "n8n workflow execution engine (v2)",
|
"description": "n8n workflow execution engine (v2)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo compiled",
|
"clean": "rimraf dist .turbo compiled",
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,10 @@
|
||||||
"@stylistic/eslint-plugin": "^5.0.0",
|
"@stylistic/eslint-plugin": "^5.0.0",
|
||||||
"@types/eslint": "^9.6.1",
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.35.0",
|
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||||
"@typescript-eslint/parser": "^8.35.0",
|
"@typescript-eslint/parser": "catalog:",
|
||||||
"@typescript-eslint/rule-tester": "^8.35.0",
|
"@typescript-eslint/rule-tester": "catalog:",
|
||||||
"@typescript-eslint/utils": "^8.35.0",
|
"@typescript-eslint/utils": "catalog:",
|
||||||
"@typescript/native": "catalog:typescript-tooling",
|
"@typescript/native": "catalog:typescript-tooling",
|
||||||
"eslint-config-prettier": "^10.1.5",
|
"eslint-config-prettier": "^10.1.5",
|
||||||
"eslint-import-resolver-typescript": "^4.4.3",
|
"eslint-import-resolver-typescript": "^4.4.3",
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import { NoRepositoryInPublicApiHandlerRule } from './no-repository-in-public-ap
|
||||||
import { RequirePublicApiControllerRule } from './require-public-api-controller.js';
|
import { RequirePublicApiControllerRule } from './require-public-api-controller.js';
|
||||||
import { NoLegacyCipherMethodsRule } from './no-legacy-cipher-methods.js';
|
import { NoLegacyCipherMethodsRule } from './no-legacy-cipher-methods.js';
|
||||||
import { NoUnsealedWorkflowEntityWriteRule } from './no-unsealed-workflow-entity-write.js';
|
import { NoUnsealedWorkflowEntityWriteRule } from './no-unsealed-workflow-entity-write.js';
|
||||||
|
import { RequireEscapedQueryValuesRule } from './require-escaped-query-values.js';
|
||||||
import { NoOnLeaderTakeoverRule } from './no-on-leader-takeover.js';
|
import { NoOnLeaderTakeoverRule } from './no-on-leader-takeover.js';
|
||||||
import { NoMisplacedCipherPrimitivesRule } from './no-misplaced-cipher-primitives.js';
|
import { NoMisplacedCipherPrimitivesRule } from './no-misplaced-cipher-primitives.js';
|
||||||
import { NoDeploymentKeyDeleteRule } from './no-deployment-key-delete.js';
|
import { NoDeploymentKeyDeleteRule } from './no-deployment-key-delete.js';
|
||||||
|
|
@ -69,6 +70,7 @@ export const rules = {
|
||||||
'require-public-api-controller': RequirePublicApiControllerRule,
|
'require-public-api-controller': RequirePublicApiControllerRule,
|
||||||
'no-legacy-cipher-methods': NoLegacyCipherMethodsRule,
|
'no-legacy-cipher-methods': NoLegacyCipherMethodsRule,
|
||||||
'no-unsealed-workflow-entity-write': NoUnsealedWorkflowEntityWriteRule,
|
'no-unsealed-workflow-entity-write': NoUnsealedWorkflowEntityWriteRule,
|
||||||
|
'require-escaped-query-values': RequireEscapedQueryValuesRule,
|
||||||
'no-on-leader-takeover': NoOnLeaderTakeoverRule,
|
'no-on-leader-takeover': NoOnLeaderTakeoverRule,
|
||||||
'no-misplaced-cipher-primitives': NoMisplacedCipherPrimitivesRule,
|
'no-misplaced-cipher-primitives': NoMisplacedCipherPrimitivesRule,
|
||||||
'no-deployment-key-delete': NoDeploymentKeyDeleteRule,
|
'no-deployment-key-delete': NoDeploymentKeyDeleteRule,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,217 @@
|
||||||
|
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||||
|
import { RequireEscapedQueryValuesRule } from './require-escaped-query-values.js';
|
||||||
|
|
||||||
|
const ruleTester = new RuleTester();
|
||||||
|
|
||||||
|
const IMPORT =
|
||||||
|
"import { escapeBackslashQuotedValue, escapeODataSearchValue, escapeODataValue } from '@utils/query-escaping';\n";
|
||||||
|
|
||||||
|
/** Prefixes the escaper import the rule requires before trusting a call. */
|
||||||
|
const withImport = (code: string) => IMPORT + code;
|
||||||
|
|
||||||
|
ruleTester.run('require-escaped-query-values', RequireEscapedQueryValuesRule, {
|
||||||
|
valid: [
|
||||||
|
{ code: withImport("qs.$filter = `displayName eq '${escapeODataValue(filter)}'`;") },
|
||||||
|
{
|
||||||
|
code: withImport(
|
||||||
|
"const qs = { q: `name contains '${escapeBackslashQuotedValue(filter)}'` };",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ code: withImport("query.push(`name contains '${escapeBackslashQuotedValue(filter)}'`);") },
|
||||||
|
{ code: withImport("qs.$filter += ` AND contains(subject, '${escapeODataValue(value)}')`;") },
|
||||||
|
{ code: withImport("const query = `email LIKE '${escapeBackslashQuotedValue(email)}'`;") },
|
||||||
|
|
||||||
|
// Escaped one hop back, through a `const`.
|
||||||
|
{
|
||||||
|
code: withImport(`const filterValue = escapeODataValue(encodeURI(filter));
|
||||||
|
qs.$filter = \`contains(displayName, '\${filterValue}')\`;`),
|
||||||
|
},
|
||||||
|
// Escaped in both arms of a conditional.
|
||||||
|
{
|
||||||
|
code: withImport("const q = `name contains '${filter ? escapeODataValue(filter) : ''}'`;"),
|
||||||
|
},
|
||||||
|
|
||||||
|
// A module constant is fixed at build time.
|
||||||
|
{
|
||||||
|
code: `import { DRIVE } from './interfaces';
|
||||||
|
query.push(\`mimeType = '\${DRIVE.FOLDER}'\`);`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: `import { FOLDER_MIME } from './interfaces';
|
||||||
|
query.push(\`mimeType = '\${FOLDER_MIME}'\`);`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Escaped through the constructs isNeutralised looks past.
|
||||||
|
{ code: withImport("qs.$filter = `eq '${escapeODataValue(a) || escapeODataValue(b)}'`;") },
|
||||||
|
{ code: withImport("qs.$filter = `eq '${escapeODataValue(x) as string}'`;") },
|
||||||
|
{ code: withImport("qs.$filter = `eq '${escapeODataValue(x)!}'`;") },
|
||||||
|
|
||||||
|
// A literal with no quote of its own cannot end the literal it sits in.
|
||||||
|
{ code: 'qs.$filter = `eq \'${"ab"}\'`;' },
|
||||||
|
|
||||||
|
// An alias of a sanctioned helper is still that helper.
|
||||||
|
{
|
||||||
|
code: `import { escapeODataValue as esc } from '@utils/query-escaping';
|
||||||
|
qs.$filter = \`eq '\${esc(filter)}'\`;`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// A quote of the other kind inside a literal is data, not a delimiter.
|
||||||
|
{ code: withImport("qs.q = `name = \"it's\" and owner = '${escapeODataValue(ownerId)}'`;") },
|
||||||
|
// `satisfies` is transparent, like `as` and `!`.
|
||||||
|
{ code: withImport("qs.$filter = `eq '${escapeODataValue(x)}'` satisfies string;") },
|
||||||
|
|
||||||
|
{ code: "qs.q = `owner = ${ownerId} and name = 'x'`;" },
|
||||||
|
|
||||||
|
// Double-quoted literals count too, e.g. an OData `$search` phrase.
|
||||||
|
{ code: withImport('qs.$search = `"displayName:${escapeODataSearchValue(filter)}"`;') },
|
||||||
|
|
||||||
|
// Outside a quoted literal the value cannot close one, so the language's
|
||||||
|
// own syntax applies and escaping is not what protects it.
|
||||||
|
{ code: "qs.$filter = `contains(${nameProperty}, 'literal')`;" },
|
||||||
|
{ code: 'qs.sysparm_query = `table_name=${tableName}`;' },
|
||||||
|
|
||||||
|
// Not a query sink.
|
||||||
|
{ code: "throw new Error(`The resource '${resource}' is unknown`);" },
|
||||||
|
{ code: "const message = `Column '${key}' does not exist`;" },
|
||||||
|
{ code: "items.push(`field '${name}' is missing`);" },
|
||||||
|
{ code: "const body = { subject: `Re: '${subject}'` };" },
|
||||||
|
|
||||||
|
// A quote already closed by the time the value is interpolated.
|
||||||
|
{ code: "const q = `name = 'literal' and owner = ${ownerId}`;" },
|
||||||
|
|
||||||
|
// Escaped quotes in the template do not open a literal.
|
||||||
|
{ code: "const q = `label = \\'x\\' and owner = ${ownerId}`;" },
|
||||||
|
],
|
||||||
|
|
||||||
|
invalid: [
|
||||||
|
{
|
||||||
|
code: "qs.$filter = `displayName eq '${filter}'`;",
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "Object.assign(body, { query: `email LIKE '${email}' ` });",
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "query.push(`name contains '${filter}'`);",
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "const q = filter ? `name contains '${filter}'` : undefined;",
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "qs['$filter'] = `fields/Title eq '${filter}'`;",
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
// Both interpolations sit inside a literal, so both are reported.
|
||||||
|
{
|
||||||
|
code: "qs.$filter = `startsWith(displayName, '${filter}') OR startsWith(mail, '${filter}')`;",
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }, { messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "const query = `name contains '${prefix}${suffix}'`;",
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }, { messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
// A `let` may be reassigned, so following it back proves nothing.
|
||||||
|
{
|
||||||
|
code: withImport(`let filterValue = escapeODataValue(filter);
|
||||||
|
qs.$filter = \`displayName eq '\${filterValue}'\`;`),
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
// Only one arm of the conditional is escaped.
|
||||||
|
{
|
||||||
|
code: withImport(
|
||||||
|
"const q = `name contains '${filter ? filter : escapeODataValue(fallback)}'`;",
|
||||||
|
),
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
// encodeURI leaves single quotes untouched, so it is not an escape.
|
||||||
|
{
|
||||||
|
code: "qs.$filter = `contains(subject, '${encodeURI(filter)}')`;",
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "qs.$filter = `contains(subject, '${encodeURIComponent(filter)}')`;",
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'qs.$search = `"displayName:${filter}"`;',
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
// The sink is still found when reached through a choosing expression or a cast.
|
||||||
|
{
|
||||||
|
code: "const q = fallback || `name contains '${filter}'`;",
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "qs.$filter = `eq '${filter}'` as string;",
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
// Only one arm of the logical expression is escaped.
|
||||||
|
{
|
||||||
|
code: withImport("qs.$filter = `eq '${a || escapeODataValue(b)}'`;"),
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
// The single quote inside the double-quoted literal must not be mistaken for
|
||||||
|
// a delimiter — the interpolation below it really is inside a literal.
|
||||||
|
{
|
||||||
|
code: "qs.q = `name = \"it's\" and owner = '${ownerId}'`;",
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
// An escaper for a different dialect does not make this value safe.
|
||||||
|
{
|
||||||
|
code: "query.push(`name contains '${escapeSqlIdentifier(filter)}'`);",
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
// The sink is still found through a `satisfies` wrapper.
|
||||||
|
{
|
||||||
|
code: "qs.$filter = `eq '${filter}'` satisfies string;",
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
// A literal carrying the active quote would end the literal it sits in.
|
||||||
|
{
|
||||||
|
code: "qs.$filter = `eq '${\"a'b\"}'`;",
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
// Same through a const, which is the shape that actually occurs.
|
||||||
|
{
|
||||||
|
code: `const DEFAULT_NAME = "it's";
|
||||||
|
qs.$filter = \`eq '\${DEFAULT_NAME}'\`;`,
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
// The local spelling does not matter — this is `noop` under an approved name.
|
||||||
|
{
|
||||||
|
code: `import { noop as escapeODataValue } from './helpers';
|
||||||
|
qs.$filter = \`eq '\${escapeODataValue(filter)}'\`;`,
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
// An approved name reached through a member access proves nothing.
|
||||||
|
{
|
||||||
|
code: `const evil = { escapeODataValue: (v: string) => v };
|
||||||
|
qs.$filter = \`eq '\${evil.escapeODataValue(filter)}'\`;`,
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "qs.$filter = `eq '${this.escapeODataValue(filter)}'`;",
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
// A local function with an approved name is not the approved escaper.
|
||||||
|
{
|
||||||
|
code: `const escapeODataValue = (v: string) => v;
|
||||||
|
qs.$filter = \`eq '\${escapeODataValue(filter)}'\`;`,
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
// A property of a bound parameter is not a module constant.
|
||||||
|
{
|
||||||
|
code: `const options = this.getNodeParameter('options', i);
|
||||||
|
query.push(\`mimeType = '\${options.fileType}'\`);`,
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
// `String.replace` with a string pattern replaces only the first match.
|
||||||
|
{
|
||||||
|
code: `query.push(\`name contains '\${filter.replace("'", "\\\\'")}'\`);`,
|
||||||
|
errors: [{ messageId: 'escapeQueryValue' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,285 @@
|
||||||
|
import { ASTUtils, ESLintUtils, type TSESTree } from '@typescript-eslint/utils';
|
||||||
|
import type { RuleContext } from '@typescript-eslint/utils/ts-eslint';
|
||||||
|
|
||||||
|
type MessageIds = 'escapeQueryValue';
|
||||||
|
type Options = [];
|
||||||
|
|
||||||
|
/** A template literal reaching one of these is treated as a query being assembled. */
|
||||||
|
const QUERY_SINKS = new Set([
|
||||||
|
'$filter',
|
||||||
|
'$search',
|
||||||
|
'filter',
|
||||||
|
'jql',
|
||||||
|
'q',
|
||||||
|
'query',
|
||||||
|
'sql',
|
||||||
|
'sysparm_query',
|
||||||
|
'where',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Names of arrays whose entries are joined into a query. */
|
||||||
|
const QUERY_PART_SINKS = new Set(['conditions', 'filterString', 'filters', 'query']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sanctioned escapers, by name. A prefix pattern such as `/^escape/` would
|
||||||
|
* accept any similarly named function, including one for a different query
|
||||||
|
* language or one that escapes identifiers rather than values — so a new dialect
|
||||||
|
* helper has to be added here deliberately.
|
||||||
|
*
|
||||||
|
* `encodeURI`/`encodeURIComponent` are absent on purpose: neither encodes a
|
||||||
|
* quote, so neither keeps a value inside its literal.
|
||||||
|
*/
|
||||||
|
const ESCAPERS = new Set([
|
||||||
|
'escapeBackslashQuotedValue',
|
||||||
|
'escapeCognitoFilterValue',
|
||||||
|
'escapeSgqlLikeValue',
|
||||||
|
'escapeFilterValue',
|
||||||
|
'escapeODataSearchValue',
|
||||||
|
'escapeODataValue',
|
||||||
|
'escapeSoqlString',
|
||||||
|
'escapeSqlString',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Deliberately absent: `escapeSqlIdentifier`, which escapes a backtick. That
|
||||||
|
// makes a value safe as an identifier, not inside a quoted string literal.
|
||||||
|
|
||||||
|
/** How far to follow `const` bindings back to their initialiser. */
|
||||||
|
const MAX_BINDING_DEPTH = 5;
|
||||||
|
|
||||||
|
type OpenQuote = "'" | '"';
|
||||||
|
type OpenLiteral = OpenQuote | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counting each quote character independently would be wrong: query languages
|
||||||
|
* use both (OData `$filter` quotes with `'`, `$search` with `"`), and a `'`
|
||||||
|
* inside a `"…"` literal is data, not a delimiter.
|
||||||
|
*
|
||||||
|
* Reads the cooked text, because that is what the query language receives — a
|
||||||
|
* `\'` written in the template source is a plain quote by then, and a backslash
|
||||||
|
* the query language itself should treat as an escape is written `\\'`.
|
||||||
|
*/
|
||||||
|
function scanLiteral(text: string, open: OpenLiteral): OpenLiteral {
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const char = text[i];
|
||||||
|
|
||||||
|
if (open !== null && char === '\\') {
|
||||||
|
i++; // an escaped character cannot close the literal
|
||||||
|
} else if (open === null) {
|
||||||
|
if (char === "'" || char === '"') open = char;
|
||||||
|
} else if (char === open) {
|
||||||
|
open = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNeutralised(
|
||||||
|
node: TSESTree.Node,
|
||||||
|
open: OpenQuote,
|
||||||
|
getScope: (
|
||||||
|
node: TSESTree.Node,
|
||||||
|
) => ReturnType<RuleContext<MessageIds, Options>['sourceCode']['getScope']>,
|
||||||
|
depth = 0,
|
||||||
|
): boolean {
|
||||||
|
// Report rather than pass when the chain gets too long to follow: an unread
|
||||||
|
// value is not a safe one.
|
||||||
|
if (depth >= MAX_BINDING_DEPTH) return false;
|
||||||
|
|
||||||
|
switch (node.type) {
|
||||||
|
// A literal contributes no runtime value, but it can still carry the quote
|
||||||
|
// that would end the literal it sits in.
|
||||||
|
case 'Literal':
|
||||||
|
return !String(node.value).includes(open);
|
||||||
|
|
||||||
|
case 'CallExpression': {
|
||||||
|
// Only a bare call to an imported helper counts. A member access such as
|
||||||
|
// `helpers.escapeODataValue(x)` carries an approved name but proves nothing
|
||||||
|
// about what it calls, so it is not accepted.
|
||||||
|
if (node.callee.type !== 'Identifier') return false;
|
||||||
|
|
||||||
|
const variable = ASTUtils.findVariable(getScope(node.callee), node.callee.name);
|
||||||
|
const binding = variable?.defs.find((definition) => definition.type === 'ImportBinding');
|
||||||
|
if (binding === undefined) return false;
|
||||||
|
|
||||||
|
// Match the name the helper is exported under rather than its local
|
||||||
|
// spelling, so an alias still counts and a same-named local does not.
|
||||||
|
const specifier = binding.node;
|
||||||
|
if (specifier.type !== 'ImportSpecifier' || specifier.imported.type !== 'Identifier') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ESCAPERS.has(specifier.imported.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assumed fixed at build time. Proving it would need cross-file analysis, so
|
||||||
|
// an import initialised from a runtime call is accepted here — same
|
||||||
|
// assumption as no-dynamic-regexp.
|
||||||
|
case 'MemberExpression':
|
||||||
|
return !node.computed && isNeutralised(node.object, open, getScope, depth + 1);
|
||||||
|
|
||||||
|
case 'Identifier': {
|
||||||
|
const variable = ASTUtils.findVariable(getScope(node), node.name);
|
||||||
|
if (!variable || variable.defs.length !== 1) return false;
|
||||||
|
|
||||||
|
const [definition] = variable.defs;
|
||||||
|
if (definition.type === 'ImportBinding') return true;
|
||||||
|
if (definition.type !== 'Variable' || definition.parent.kind !== 'const') return false;
|
||||||
|
|
||||||
|
const init = definition.node.type === 'VariableDeclarator' ? definition.node.init : null;
|
||||||
|
return init !== null && isNeutralised(init, open, getScope, depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ConditionalExpression':
|
||||||
|
return (
|
||||||
|
isNeutralised(node.consequent, open, getScope, depth + 1) &&
|
||||||
|
isNeutralised(node.alternate, open, getScope, depth + 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'LogicalExpression':
|
||||||
|
return (
|
||||||
|
isNeutralised(node.left, open, getScope, depth + 1) &&
|
||||||
|
isNeutralised(node.right, open, getScope, depth + 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
// TypeScript-only wrappers that do not change the runtime value.
|
||||||
|
case 'TSAsExpression':
|
||||||
|
case 'TSNonNullExpression':
|
||||||
|
case 'TSSatisfiesExpression':
|
||||||
|
return isNeutralised(node.expression, open, getScope, depth + 1);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function propertyName(node: TSESTree.MemberExpression): string | undefined {
|
||||||
|
if (!node.computed && node.property.type === 'Identifier') return node.property.name;
|
||||||
|
if (
|
||||||
|
node.computed &&
|
||||||
|
node.property.type === 'Literal' &&
|
||||||
|
typeof node.property.value === 'string'
|
||||||
|
) {
|
||||||
|
return node.property.value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Climbs past expressions that only choose between values, so a query reached
|
||||||
|
* through one — `q: filter ? \`...\` : undefined` — is still recognised.
|
||||||
|
*/
|
||||||
|
function effectiveParent(node: TSESTree.Node): TSESTree.Node | undefined {
|
||||||
|
let parent: TSESTree.Node | undefined = node.parent;
|
||||||
|
|
||||||
|
while (
|
||||||
|
parent?.type === 'ConditionalExpression' ||
|
||||||
|
parent?.type === 'LogicalExpression' ||
|
||||||
|
parent?.type === 'TSAsExpression' ||
|
||||||
|
parent?.type === 'TSNonNullExpression' ||
|
||||||
|
parent?.type === 'TSSatisfiesExpression'
|
||||||
|
) {
|
||||||
|
parent = parent.parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isQuerySink(node: TSESTree.TemplateLiteral): boolean {
|
||||||
|
const parent = effectiveParent(node);
|
||||||
|
if (!parent) return false;
|
||||||
|
|
||||||
|
switch (parent.type) {
|
||||||
|
// `{ query: `...` }`
|
||||||
|
case 'Property':
|
||||||
|
return (
|
||||||
|
(parent.key.type === 'Identifier' && QUERY_SINKS.has(parent.key.name)) ||
|
||||||
|
(parent.key.type === 'Literal' &&
|
||||||
|
typeof parent.key.value === 'string' &&
|
||||||
|
QUERY_SINKS.has(parent.key.value))
|
||||||
|
);
|
||||||
|
|
||||||
|
// `qs.$filter = `...`` / `qs.$filter += `...``
|
||||||
|
case 'AssignmentExpression': {
|
||||||
|
if (parent.left.type !== 'MemberExpression') return false;
|
||||||
|
const name = propertyName(parent.left);
|
||||||
|
return name !== undefined && QUERY_SINKS.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `const q = `...``
|
||||||
|
case 'VariableDeclarator':
|
||||||
|
return (
|
||||||
|
parent.id.type === 'Identifier' &&
|
||||||
|
(QUERY_SINKS.has(parent.id.name) || QUERY_PART_SINKS.has(parent.id.name))
|
||||||
|
);
|
||||||
|
|
||||||
|
// `query.push(`...`)`
|
||||||
|
case 'CallExpression':
|
||||||
|
return (
|
||||||
|
parent.callee.type === 'MemberExpression' &&
|
||||||
|
propertyName(parent.callee) === 'push' &&
|
||||||
|
parent.callee.object.type === 'Identifier' &&
|
||||||
|
QUERY_PART_SINKS.has(parent.callee.object.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RequireEscapedQueryValuesRule = ESLintUtils.RuleCreator.withoutDocs<
|
||||||
|
Options,
|
||||||
|
MessageIds
|
||||||
|
>({
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
docs: {
|
||||||
|
// Coverage boundary, so this is not mistaken for a complete gate: the rule
|
||||||
|
// only inspects a template literal that lands directly in a recognised
|
||||||
|
// sink, and only proves safety from the interpolated expression itself. It
|
||||||
|
// does not see a template passed as a call argument (a query inside a URL
|
||||||
|
// path), returned from a helper, built by string concatenation, assembled
|
||||||
|
// as `.map()` fragments joined later, or assigned to a `let`. Nor does it
|
||||||
|
// know about a guard on an earlier statement, such as
|
||||||
|
// `assertNoQueryDelimiters`. Those need a test, not this rule.
|
||||||
|
//
|
||||||
|
// It also checks only that a value went through an approved escaper, not
|
||||||
|
// that the escaper matches the sink's dialect. A sink name does not carry
|
||||||
|
// one: `filterString` is used by an OData `$filter` builder, which doubles
|
||||||
|
// a quote, and by the Cognito `ListUsers` filter, which backslash-escapes
|
||||||
|
// it; `q` by both the Google Drive query language and SOQL. Picking the
|
||||||
|
// dialect stays a review judgement.
|
||||||
|
description:
|
||||||
|
"Require values interpolated into a quoted literal of a third-party query language to go through that language's escape helper.",
|
||||||
|
},
|
||||||
|
schema: [],
|
||||||
|
messages: {
|
||||||
|
escapeQueryValue:
|
||||||
|
'A value interpolated into a quoted query literal must go through the escape helper for that query language (@utils/query-escaping). Disable this rule on the line if the value provably cannot contain a quote.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultOptions: [],
|
||||||
|
create(context) {
|
||||||
|
const sourceCode = context.sourceCode;
|
||||||
|
|
||||||
|
return {
|
||||||
|
TemplateLiteral(node) {
|
||||||
|
if (!isQuerySink(node)) return;
|
||||||
|
|
||||||
|
let open: OpenLiteral = null;
|
||||||
|
|
||||||
|
node.quasis.forEach((quasi, index) => {
|
||||||
|
open = scanLiteral(quasi.value.cooked ?? quasi.value.raw, open);
|
||||||
|
|
||||||
|
const expression = node.expressions[index];
|
||||||
|
// Only an interpolation inside a quoted literal can close it.
|
||||||
|
if (!expression || open === null) return;
|
||||||
|
|
||||||
|
if (isNeutralised(expression, open, (n) => sourceCode.getScope(n))) return;
|
||||||
|
|
||||||
|
context.report({ node: expression, messageId: 'escapeQueryValue' });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
12
packages/@n8n/eslint-config/src/rules/rule-tester.d.ts
vendored
Normal file
12
packages/@n8n/eslint-config/src/rules/rule-tester.d.ts
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import type { RuleTester, RunTests } from '@typescript-eslint/rule-tester';
|
||||||
|
import type { RuleModule } from '@typescript-eslint/utils/ts-eslint';
|
||||||
|
|
||||||
|
declare module '@typescript-eslint/rule-tester' {
|
||||||
|
interface RuleTester {
|
||||||
|
run<MessageIds extends string, Options extends readonly unknown[]>(
|
||||||
|
ruleName: string,
|
||||||
|
rule: RuleModule<MessageIds, Options>,
|
||||||
|
test: RunTests<MessageIds, Options>,
|
||||||
|
): void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/eslint-plugin-community-nodes",
|
"name": "@n8n/eslint-plugin-community-nodes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.32.0",
|
"version": "0.32.1",
|
||||||
"main": "./dist/plugin.js",
|
"main": "./dist/plugin.js",
|
||||||
"types": "./dist/plugin.d.ts",
|
"types": "./dist/plugin.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
@ -28,8 +28,8 @@
|
||||||
"watch": "tsc --watch --project tsconfig.build.json"
|
"watch": "tsc --watch --project tsconfig.build.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/typescript-estree": "^8.35.0",
|
"@typescript-eslint/typescript-estree": "catalog:",
|
||||||
"@typescript-eslint/utils": "^8.35.0",
|
"@typescript-eslint/utils": "catalog:",
|
||||||
"fastest-levenshtein": "catalog:"
|
"fastest-levenshtein": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
"@n8n/vitest-config": "workspace:*",
|
"@n8n/vitest-config": "workspace:*",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"n8n-workflow": "workspace:*",
|
"n8n-workflow": "workspace:*",
|
||||||
"@typescript-eslint/rule-tester": "^8.35.0",
|
"@typescript-eslint/rule-tester": "catalog:",
|
||||||
"eslint-doc-generator": "^2.2.2",
|
"eslint-doc-generator": "^2.2.2",
|
||||||
"eslint-plugin-eslint-plugin": "^7.0.0",
|
"eslint-plugin-eslint-plugin": "^7.0.0",
|
||||||
"rimraf": "catalog:",
|
"rimraf": "catalog:",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/expression-runtime",
|
"name": "@n8n/expression-runtime",
|
||||||
"version": "0.30.0",
|
"version": "0.30.1",
|
||||||
"description": "Secure, isolated expression evaluation runtime for n8n",
|
"description": "Secure, isolated expression evaluation runtime for n8n",
|
||||||
"main": "dist/cjs/index.js",
|
"main": "dist/cjs/index.js",
|
||||||
"module": "dist/esm/index.js",
|
"module": "dist/esm/index.js",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest';
|
||||||
import { extendedFunctions } from '../function-extensions';
|
import { extendedFunctions } from '../function-extensions';
|
||||||
|
|
||||||
const zip = extendedFunctions.zip;
|
const zip = extendedFunctions.zip;
|
||||||
|
const toPathSegment = extendedFunctions.toPathSegment;
|
||||||
|
|
||||||
describe('zip', () => {
|
describe('zip', () => {
|
||||||
it('should pair keys with values', () => {
|
it('should pair keys with values', () => {
|
||||||
|
|
@ -21,3 +22,13 @@ describe('zip', () => {
|
||||||
expect(({} as Record<string, unknown>).marker).toBeUndefined();
|
expect(({} as Record<string, unknown>).marker).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('toPathSegment', () => {
|
||||||
|
it('encodes a value as one path segment', () => {
|
||||||
|
expect(toPathSegment('a/b c')).toBe('a%2Fb%20c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['', '.', '..', null, undefined])('rejects %s', (input) => {
|
||||||
|
expect(() => toPathSegment(input)).toThrow('Invalid identifier');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,19 @@ const not = (value: unknown): boolean => {
|
||||||
return !value;
|
return !value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function toPathSegment(input: unknown): string {
|
||||||
|
if (input === null || input === undefined) {
|
||||||
|
throw new ExpressionExtensionError('Invalid identifier: a value is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = String(input);
|
||||||
|
if (value === '' || value === '.' || value === '..') {
|
||||||
|
throw new ExpressionExtensionError(`Invalid identifier: "${value}" is not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return encodeURIComponent(value);
|
||||||
|
}
|
||||||
|
|
||||||
function ifEmpty<T, V>(value: V, defaultValue: T) {
|
function ifEmpty<T, V>(value: V, defaultValue: T) {
|
||||||
if (arguments.length !== 2) {
|
if (arguments.length !== 2) {
|
||||||
// DIVERGENCE from packages/workflow/src/extensions/extended-functions.ts:
|
// DIVERGENCE from packages/workflow/src/extensions/extended-functions.ts:
|
||||||
|
|
@ -95,6 +108,7 @@ export const extendedFunctions = {
|
||||||
average,
|
average,
|
||||||
numberList,
|
numberList,
|
||||||
zip,
|
zip,
|
||||||
|
toPathSegment,
|
||||||
$min: min,
|
$min: min,
|
||||||
$max: max,
|
$max: max,
|
||||||
$average: average,
|
$average: average,
|
||||||
|
|
|
||||||
111
packages/@n8n/instance-ai/docs/prompt-profiles.md
Normal file
111
packages/@n8n/instance-ai/docs/prompt-profiles.md
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
# Prompt profiles
|
||||||
|
|
||||||
|
A prompt profile selects a system prompt version, skill variants, and tool
|
||||||
|
exclusions together. Definitions live in `src/prompts/prompt-profiles.ts`.
|
||||||
|
The backend resolves the profile before creating the agent. Prompt rendering
|
||||||
|
does not read feature flags.
|
||||||
|
|
||||||
|
The initial profiles are `default@1` and `progressive@1`. The general system
|
||||||
|
prompt is the same in both. The progressive profile changes the workflow
|
||||||
|
skills and removes the planning skill and `create-tasks` tool.
|
||||||
|
|
||||||
|
## Selection and recovery
|
||||||
|
|
||||||
|
A request's `promptVersion` takes precedence over `mode`. Without a version pin,
|
||||||
|
an explicit mode selects its current profile. Without either override, the
|
||||||
|
backend experiment assignment selects the profile.
|
||||||
|
|
||||||
|
Internal follow-ups retain the selected version. Checkpoints store it in
|
||||||
|
`persistence.hostMetadata.promptVersion`. If that version is no longer registered
|
||||||
|
after deployment, recovery selects the default profile and records `fallbackFrom`.
|
||||||
|
Legacy checkpoints use their saved mode, or default when no valid mode exists.
|
||||||
|
An unknown explicit request pin is rejected instead of silently falling back.
|
||||||
|
|
||||||
|
## Define a variant
|
||||||
|
|
||||||
|
1. Put reusable instructions in a skill fragment.
|
||||||
|
2. Add a versioned `SkillVariant` with its target skill IDs and fragment IDs.
|
||||||
|
3. Declare required tools in the fragment's `dependencies.tools`. The composer
|
||||||
|
rejects a profile that disables a required tool. Recommended tools are optional;
|
||||||
|
disabled recommendations are removed from the selected catalog.
|
||||||
|
4. Add the variant to a profile. Put skill and tool exclusions in that same variant.
|
||||||
|
5. Add selection and behavior tests. Include existing-resource operations when
|
||||||
|
changing workflow-building behavior.
|
||||||
|
|
||||||
|
Different variants may change different skills. Duplicate variants, overlapping
|
||||||
|
skill changes, and changes to excluded skills are rejected. Use an explicit
|
||||||
|
combined variant when two policies must change the same skill. Fragment order
|
||||||
|
within a skill is its original body followed by the selected fragment.
|
||||||
|
Fragments do not appear as standalone catalog entries.
|
||||||
|
|
||||||
|
Published version IDs identify immutable definitions. Add a new version when
|
||||||
|
changing a published policy or system template. Keep the old renderer and fragments
|
||||||
|
if old runs must remain reproducible. Otherwise remove the old profile and use the
|
||||||
|
documented default fallback. Content hashes identify the concrete instructions
|
||||||
|
in addition to their version IDs.
|
||||||
|
|
||||||
|
## Inspect a profile
|
||||||
|
|
||||||
|
From this package:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm prompts:print --profile default@1
|
||||||
|
pnpm prompts:print --profile progressive@1
|
||||||
|
```
|
||||||
|
|
||||||
|
The output contains the system prompt, selected skill bodies, and a manifest.
|
||||||
|
This shows the profile before user-specific feature gates and context are applied.
|
||||||
|
|
||||||
|
Include `promptVersion` in a chat request to select an exact profile. Send the
|
||||||
|
pin with each new user message. Internal follow-ups retain the selected version.
|
||||||
|
|
||||||
|
Traces include `prompt_configuration` and the rendered `system_prompt_hash`.
|
||||||
|
Thread status includes `promptConfiguration` with the selected version and skill
|
||||||
|
hash. Both include fallback information when available.
|
||||||
|
|
||||||
|
Trace metadata also includes `prompt_version`, such as `progressive@1`, for
|
||||||
|
filtering. The same property is sent with `instance_ai_run_finished`,
|
||||||
|
`Builder sent message`, `Builder satisfied user intent`, and
|
||||||
|
`Builder generation errored`. It identifies the resolved profile for that run,
|
||||||
|
including after a resume. Events omit it when no resolved profile is available.
|
||||||
|
|
||||||
|
## Evaluate profiles
|
||||||
|
|
||||||
|
Set `promptVersion` in a local eval case, or set `N8N_EVAL_PROMPT_VERSION` for a
|
||||||
|
suite. Case version pins take precedence. An explicit case mode also overrides
|
||||||
|
suite settings. The runner sends the selected pin with every user message and
|
||||||
|
rejects unknown versions before provisioning resources.
|
||||||
|
|
||||||
|
Eval rows carry the backend's selected version and skill hash in
|
||||||
|
`run.outputs.buildTrace.promptConfiguration` when the status lookup succeeds.
|
||||||
|
Keep pinned case files local while the LangTracer case-write contract does not
|
||||||
|
carry their version field. Existing suites can use the runner's environment
|
||||||
|
override without changing their stored cases.
|
||||||
|
|
||||||
|
## Active skills and compaction
|
||||||
|
|
||||||
|
The agents SDK retains loaded skill IDs outside the conversation text. It renders
|
||||||
|
their current bodies in a cached instruction block, so memory compaction can
|
||||||
|
remove large tool results without removing the active instructions.
|
||||||
|
|
||||||
|
Checkpoints retain the IDs. Memory backends can implement `BuiltMemory.skillState`
|
||||||
|
to retain them across separate runs. The in-memory backend and Instance AI's
|
||||||
|
database adapter provide this store. It is scoped by thread, resource, and agent.
|
||||||
|
Custom memory backends need this optional store for cross-run retention after
|
||||||
|
compaction. Resumes always load content from the selected catalog; removed skills
|
||||||
|
are dropped, and a changed version replaces the old body.
|
||||||
|
|
||||||
|
Tools that inline skill guidance should use `ToolContext.loadSkill`. This activates
|
||||||
|
the selected skill and avoids persisting another copy of its instructions in a
|
||||||
|
tool result. Ordinary tool output remains untrusted data for memory extraction.
|
||||||
|
|
||||||
|
## Roll back or remove a profile
|
||||||
|
|
||||||
|
Switch the assignment to default for new user messages. Internal follow-ups keep
|
||||||
|
their selected version until that user turn ends. New turns resolve the assignment
|
||||||
|
again, and active IDs load their bodies from the newly selected catalog.
|
||||||
|
|
||||||
|
To retire a version, remove its profile entry after deciding whether existing
|
||||||
|
checkpoints may use the default fallback. Remove unreferenced variants and fragments
|
||||||
|
and update the mode-to-profile defaults. Run the selection, recovery, and behavior
|
||||||
|
tests. Do not leave profile-specific conditions in the general system prompt.
|
||||||
|
|
@ -116,7 +116,6 @@ reattached. The setup creates or materializes:
|
||||||
| `node-types/index.txt` | Searchable node-type catalog |
|
| `node-types/index.txt` | Searchable node-type catalog |
|
||||||
| `src/` | Workflow source files |
|
| `src/` | Workflow source files |
|
||||||
| `chunks/` | Reusable source modules |
|
| `chunks/` | Reusable source modules |
|
||||||
| `workflows/` | Existing workflows materialized as WorkflowJSON |
|
|
||||||
| `knowledge-base/` | Best-practice, template, and SDK reference material |
|
| `knowledge-base/` | Best-practice, template, and SDK reference material |
|
||||||
| `.sandbox-initialized` | Setup marker |
|
| `.sandbox-initialized` | Setup marker |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
|
import type { CliArgs } from '../cli/args';
|
||||||
|
import type { WorkflowTestCaseWithFile } from '../data/workflows';
|
||||||
|
import { resolveEvalBuildMode, resolveEvalPromptSettings } from '../harness/build-mode';
|
||||||
|
import type { EvalLogger } from '../harness/logger';
|
||||||
|
import { selectCases } from '../run/case-selection';
|
||||||
|
|
||||||
|
const args = mock<CliArgs>({ buildViaMcp: false, prebuiltWorkflows: undefined });
|
||||||
|
const logger = mock<EvalLogger>();
|
||||||
|
const entry = (buildMode?: 'progressive' | 'default'): WorkflowTestCaseWithFile => ({
|
||||||
|
fileSlug: 'test-case',
|
||||||
|
testCase: {
|
||||||
|
complexity: 'simple',
|
||||||
|
tags: [],
|
||||||
|
datasets: ['full'],
|
||||||
|
buildMode,
|
||||||
|
conversation: [{ role: 'user', text: 'Build a workflow' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('eval build mode', () => {
|
||||||
|
beforeEach(() => vi.stubEnv('N8N_EVAL_PROMPT_VERSION', undefined));
|
||||||
|
afterEach(() => vi.unstubAllEnvs());
|
||||||
|
it('uses control unless the suite explicitly selects progressive', () => {
|
||||||
|
vi.stubEnv('N8N_EVAL_BUILD_MODE', '');
|
||||||
|
expect(resolveEvalBuildMode(undefined)).toBe('default');
|
||||||
|
vi.stubEnv('N8N_EVAL_BUILD_MODE', 'progressive');
|
||||||
|
expect(resolveEvalBuildMode(undefined)).toBe('progressive');
|
||||||
|
});
|
||||||
|
it('preserves case overrides in both suite modes', () => {
|
||||||
|
vi.stubEnv('N8N_EVAL_BUILD_MODE', 'progressive');
|
||||||
|
expect(resolveEvalBuildMode('default')).toBe('default');
|
||||||
|
vi.stubEnv('N8N_EVAL_BUILD_MODE', 'default');
|
||||||
|
expect(resolveEvalBuildMode('progressive')).toBe('progressive');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([undefined, '', ' ', 'default', ' default '])('uses control for %s', (value) => {
|
||||||
|
vi.stubEnv('N8N_EVAL_BUILD_MODE', value);
|
||||||
|
expect(resolveEvalBuildMode(undefined)).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid environment mode when a selected case needs it', () => {
|
||||||
|
vi.stubEnv('N8N_EVAL_BUILD_MODE', 'progresssive');
|
||||||
|
expect(() => selectCases(args, [entry()], logger)).toThrow('N8N_EVAL_BUILD_MODE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves explicit case overrides when the environment value is invalid', () => {
|
||||||
|
vi.stubEnv('N8N_EVAL_BUILD_MODE', 'progresssive');
|
||||||
|
expect(resolveEvalBuildMode('default')).toBe('default');
|
||||||
|
expect(resolveEvalBuildMode('progressive')).toBe('progressive');
|
||||||
|
expect(
|
||||||
|
selectCases(args, [entry('default'), entry('progressive')], logger).testCasesWithFiles,
|
||||||
|
).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores the orchestrator mode for MCP builds', () => {
|
||||||
|
vi.stubEnv('N8N_EVAL_BUILD_MODE', 'progresssive');
|
||||||
|
expect(
|
||||||
|
selectCases({ ...args, buildViaMcp: true }, [entry()], logger).testCasesWithFiles,
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pins a case version before suite settings and mode', () => {
|
||||||
|
vi.stubEnv('N8N_EVAL_BUILD_MODE', 'invalid');
|
||||||
|
vi.stubEnv('N8N_EVAL_PROMPT_VERSION', 'default@1');
|
||||||
|
expect(
|
||||||
|
resolveEvalPromptSettings({ promptVersion: 'progressive@1', buildMode: 'default' }),
|
||||||
|
).toEqual({
|
||||||
|
promptVersion: 'progressive@1',
|
||||||
|
buildMode: 'progressive',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses suite versions only for cases without explicit overrides', () => {
|
||||||
|
vi.stubEnv('N8N_EVAL_PROMPT_VERSION', 'progressive@1');
|
||||||
|
expect(resolveEvalPromptSettings({}).promptVersion).toBe('progressive@1');
|
||||||
|
expect(resolveEvalPromptSettings({ buildMode: 'default' })).toEqual({
|
||||||
|
buildMode: 'default',
|
||||||
|
promptVersion: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unavailable pinned version before running cases', () => {
|
||||||
|
const pinned = entry();
|
||||||
|
pinned.testCase.promptVersion = 'missing@1';
|
||||||
|
expect(() => selectCases(args, [pinned], logger)).toThrow('Unknown Instance AI prompt version');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
import type { N8nClient } from '../clients/n8n-client';
|
import type { N8nClient } from '../clients/n8n-client';
|
||||||
import { buildWorkflow } from '../harness/build-workflow';
|
import { buildWorkflow, buildFailedOnInfra } from '../harness/build-workflow';
|
||||||
import { recordUserTurn } from '../harness/chat-loop';
|
import { recordUserTurn, runMultiTurnConversation } from '../harness/chat-loop';
|
||||||
import type { ConversationSeed } from '../harness/conversation-seed';
|
import type { ConversationSeed } from '../harness/conversation-seed';
|
||||||
import type { EvalLogger } from '../harness/logger';
|
import type { EvalLogger } from '../harness/logger';
|
||||||
|
|
||||||
|
|
@ -452,6 +452,7 @@ describe('buildWorkflow with an inline seed', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildWorkflow with scenario seed data tables', () => {
|
describe('buildWorkflow with scenario seed data tables', () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
const jobApplications = {
|
const jobApplications = {
|
||||||
id: 'job-applications-1234',
|
id: 'job-applications-1234',
|
||||||
name: 'Job Applications',
|
name: 'Job Applications',
|
||||||
|
|
@ -459,6 +460,64 @@ describe('buildWorkflow with scenario seed data tables', () => {
|
||||||
rows: [{ application_id: 'row_001' }],
|
rows: [{ application_id: 'row_001' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
it.each([false, true])(
|
||||||
|
'classifies input reseeding failures (case expired=%s)',
|
||||||
|
async (expired) => {
|
||||||
|
let now = 1_000;
|
||||||
|
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||||
|
const client = makeClient(
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
restored: 0,
|
||||||
|
workflowIds: [],
|
||||||
|
dataTableIds: ['dt-real-1'],
|
||||||
|
agentIds: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
client.seedDataTableRows = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new Error('Input rows could not be seeded'));
|
||||||
|
vi.mocked(runMultiTurnConversation).mockImplementationOnce(async (config) => {
|
||||||
|
if (!config.beforeUserExecution) throw new Error('Missing input preparation');
|
||||||
|
const deadline = config.startTime + config.timeoutMs;
|
||||||
|
if (expired) now = deadline;
|
||||||
|
await config.beforeUserExecution(deadline);
|
||||||
|
});
|
||||||
|
const result = await buildWorkflow({
|
||||||
|
...baseConfig,
|
||||||
|
client,
|
||||||
|
allowUserExecution: true,
|
||||||
|
conversation: [
|
||||||
|
{ role: 'user', text: 'Build a contact log' },
|
||||||
|
{ role: 'user', text: 'I ran it' },
|
||||||
|
],
|
||||||
|
executionScenarios: [
|
||||||
|
{
|
||||||
|
name: 'inputs',
|
||||||
|
description: '',
|
||||||
|
dataSetup: '',
|
||||||
|
successCriteria: '',
|
||||||
|
seedDataTables: [jobApplications],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.seedingFailed).toBe(!expired);
|
||||||
|
expect(buildFailedOnInfra(result)).toBe(!expired);
|
||||||
|
expect(result.error).toContain(expired ? 'Case timed out' : 'Input rows could not be seeded');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rejects an invalid mode before creating a thread', async () => {
|
||||||
|
vi.stubEnv('N8N_EVAL_BUILD_MODE', 'progresssive');
|
||||||
|
try {
|
||||||
|
const client = makeClient(vi.fn());
|
||||||
|
await expect(buildWorkflow({ ...baseConfig, client })).rejects.toThrow('N8N_EVAL_BUILD_MODE');
|
||||||
|
expect(client.ensureThread).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('creates the table under a per-run name and tells the agent THAT name', async () => {
|
it('creates the table under a per-run name and tells the agent THAT name', async () => {
|
||||||
// The name is project-unique, so a per-run suffix is what lets two iterations of
|
// The name is project-unique, so a per-run suffix is what lets two iterations of
|
||||||
// one case coexist. The agent binds by discovery, so it has to be told the real
|
// one case coexist. The agent binds by discovery, so it has to be told the real
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,55 @@ import {
|
||||||
extractOutcomeFromEvents,
|
extractOutcomeFromEvents,
|
||||||
mergeSeededConversationMetrics,
|
mergeSeededConversationMetrics,
|
||||||
seededTurnCounters,
|
seededTurnCounters,
|
||||||
|
savedWorkflowsFromEvents,
|
||||||
|
lastSavedWorkflowIdFromEvents,
|
||||||
} from '../outcome/event-parser';
|
} from '../outcome/event-parser';
|
||||||
import type { CapturedEvent, ConversationMetrics, TranscriptTurn } from '../types';
|
import type { CapturedEvent, ConversationMetrics, TranscriptTurn } from '../types';
|
||||||
|
|
||||||
|
describe('saved workflow names', () => {
|
||||||
|
function saved(id: string, workflowName?: string, success = true): CapturedEvent {
|
||||||
|
return {
|
||||||
|
timestamp: 0,
|
||||||
|
type: 'tool-result',
|
||||||
|
data: {
|
||||||
|
payload: {
|
||||||
|
toolName: 'build-workflow',
|
||||||
|
result: { success, workflowId: id, workflowName },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each([undefined, '', ' '])('preserves the last name when a later save has name %s', (name) => {
|
||||||
|
const events = [saved('a', 'Contact log'), saved('b', 'Helper'), saved('a', name)];
|
||||||
|
expect(savedWorkflowsFromEvents(events)).toEqual([
|
||||||
|
{ id: 'a', name: 'Contact log' },
|
||||||
|
{ id: 'b', name: 'Helper' },
|
||||||
|
{ id: 'a', name: 'Contact log' },
|
||||||
|
]);
|
||||||
|
expect(lastSavedWorkflowIdFromEvents(events)).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates names only after successful saves and falls back to IDs for unknown names', () => {
|
||||||
|
expect(
|
||||||
|
savedWorkflowsFromEvents([
|
||||||
|
saved('a'),
|
||||||
|
saved('a', 'Original'),
|
||||||
|
saved('a', 'Rejected', false),
|
||||||
|
saved('a'),
|
||||||
|
saved('a', 'Renamed'),
|
||||||
|
saved('a'),
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
{ id: 'a', name: 'a' },
|
||||||
|
{ id: 'a', name: 'Original' },
|
||||||
|
{ id: 'a', name: 'Original' },
|
||||||
|
{ id: 'a', name: 'Renamed' },
|
||||||
|
{ id: 'a', name: 'Renamed' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// extractOutcomeFromEvents
|
// extractOutcomeFromEvents
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,18 @@ describe('diskCaseToLangTracerCreate', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('unsupportedPushReason', () => {
|
describe('unsupportedPushReason', () => {
|
||||||
|
it('refuses a case whose prompt version would be lost', () => {
|
||||||
|
expect(unsupportedPushReason(diskCase({ promptVersion: 'progressive@1' }))).toContain(
|
||||||
|
'promptVersion',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it.each(['default', 'progressive'] as const)(
|
||||||
|
'refuses a case whose %s mode would be lost',
|
||||||
|
(buildMode) => {
|
||||||
|
expect(unsupportedPushReason(diskCase({ buildMode }))).toContain('buildMode');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it('returns null for a plain conversation-driven case', () => {
|
it('returns null for a plain conversation-driven case', () => {
|
||||||
expect(unsupportedPushReason(diskCase())).toBeNull();
|
expect(unsupportedPushReason(diskCase())).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,22 @@ describe('uniqueProjectScopes', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('unsupportedMcpBuildSetupFields', () => {
|
describe('unsupportedMcpBuildSetupFields', () => {
|
||||||
|
it.each([undefined, false])('allows user execution setting %s', (allowUserExecution) => {
|
||||||
|
expect(unsupportedMcpBuildSetupFields(testCase({ allowUserExecution }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects cases that enable user execution', () => {
|
||||||
|
expect(unsupportedMcpBuildSetupFields(testCase({ allowUserExecution: true }))).toEqual([
|
||||||
|
'allowUserExecution',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['default', 'progressive'] as const)(
|
||||||
|
'keeps explicit mode %s unsupported',
|
||||||
|
(buildMode) => {
|
||||||
|
expect(unsupportedMcpBuildSetupFields(testCase({ buildMode }))).toEqual(['buildMode']);
|
||||||
|
},
|
||||||
|
);
|
||||||
it('classifies every test-case schema key, so adding a field forces a decision', () => {
|
it('classifies every test-case schema key, so adding a field forces a decision', () => {
|
||||||
// MCP_BUILD_KEY_SUPPORT must stay in lockstep with the case schema: a new
|
// MCP_BUILD_KEY_SUPPORT must stay in lockstep with the case schema: a new
|
||||||
// build-side setup field left unclassified would let --build-via-mcp build
|
// build-side setup field left unclassified would let --build-via-mcp build
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,49 @@ function makeClient(seedDataTableRows: Mock): N8nClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('reseedScenarioTables', () => {
|
describe('reseedScenarioTables', () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
it('reduces the next request budget by the time spent seeding earlier tables', async () => {
|
||||||
|
let now = 1_000;
|
||||||
|
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||||
|
const seedDataTableRows = vi.fn<N8nClient['seedDataTableRows']>(async () => {
|
||||||
|
now += 600;
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await reseedScenarioTables(
|
||||||
|
makeClient(seedDataTableRows),
|
||||||
|
scenario({ seedDataTables: [jobApplications, { ...jobApplications, name: 'Second' }] }),
|
||||||
|
'thread-1',
|
||||||
|
{ 'Job Applications': 'dt-real-1', Second: 'dt-real-2' },
|
||||||
|
silentLogger,
|
||||||
|
2_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(seedDataTableRows.mock.calls.map((call) => call[3])).toEqual([1_000, 400]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not seed the next table when the previous request consumes the deadline', async () => {
|
||||||
|
let now = 1_000;
|
||||||
|
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||||
|
const seedDataTableRows = vi.fn<N8nClient['seedDataTableRows']>(async () => {
|
||||||
|
now = 2_000;
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
reseedScenarioTables(
|
||||||
|
makeClient(seedDataTableRows),
|
||||||
|
scenario({ seedDataTables: [jobApplications, { ...jobApplications, name: 'Second' }] }),
|
||||||
|
'thread-1',
|
||||||
|
{ 'Job Applications': 'dt-real-1', Second: 'dt-real-2' },
|
||||||
|
silentLogger,
|
||||||
|
2_000,
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Case timed out');
|
||||||
|
expect(seedDataTableRows).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('clears + seeds each declared table by its bound real id', async () => {
|
it('clears + seeds each declared table by its bound real id', async () => {
|
||||||
const seedDataTableRows = vi.fn().mockResolvedValue(undefined);
|
const seedDataTableRows = vi.fn().mockResolvedValue(undefined);
|
||||||
const client = makeClient(seedDataTableRows);
|
const client = makeClient(seedDataTableRows);
|
||||||
|
|
@ -160,7 +203,12 @@ describe('reseedScenarioTables', () => {
|
||||||
silentLogger,
|
silentLogger,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(seedDataTableRows).toHaveBeenCalledWith('thread-1', 'dt-real-1', jobApplications.rows);
|
expect(seedDataTableRows).toHaveBeenCalledWith(
|
||||||
|
'thread-1',
|
||||||
|
'dt-real-1',
|
||||||
|
jobApplications.rows,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('seeds an empty row set when a table declares no rows', async () => {
|
it('seeds an empty row set when a table declares no rows', async () => {
|
||||||
|
|
@ -176,7 +224,7 @@ describe('reseedScenarioTables', () => {
|
||||||
silentLogger,
|
silentLogger,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(seedDataTableRows).toHaveBeenCalledWith('thread-1', 'dt-real-1', []);
|
expect(seedDataTableRows).toHaveBeenCalledWith('thread-1', 'dt-real-1', [], undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does nothing when the scenario declares no seed tables', async () => {
|
it('does nothing when the scenario declares no seed tables', async () => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,238 @@
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
|
import { N8nClient, type WorkflowResponse } from '../clients/n8n-client';
|
||||||
|
import { runMultiTurnConversation } from '../harness/chat-loop';
|
||||||
|
import type { EvalLogger } from '../harness/logger';
|
||||||
|
import { reseedScenarioTables } from '../harness/seed-tables';
|
||||||
|
import type { CapturedEvent } from '../types';
|
||||||
|
|
||||||
|
const client = mock<N8nClient>();
|
||||||
|
const logger = mock<EvalLogger>();
|
||||||
|
const workflow: WorkflowResponse = {
|
||||||
|
id: 'primary',
|
||||||
|
name: 'Primary',
|
||||||
|
active: false,
|
||||||
|
versionId: 'v1',
|
||||||
|
connections: {},
|
||||||
|
nodes: [{ id: 'trigger', name: 'Start', type: 'n8n-nodes-base.manualTrigger' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
function saved(workflowId: string, success = true): CapturedEvent {
|
||||||
|
return {
|
||||||
|
timestamp: 0,
|
||||||
|
type: 'tool-result',
|
||||||
|
data: {
|
||||||
|
payload: {
|
||||||
|
toolCallId: workflowId,
|
||||||
|
toolName: 'build-workflow',
|
||||||
|
result: { workflowId, success },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(
|
||||||
|
runWorkflowId = 'primary',
|
||||||
|
events: CapturedEvent[] = [saved('primary'), saved('helper')],
|
||||||
|
beforeUserExecution?: (deadline: number) => Promise<void>,
|
||||||
|
) {
|
||||||
|
const decisions = [
|
||||||
|
{ kind: 'followUp', message: 'I ran it', runWorkflowId } as const,
|
||||||
|
{ kind: 'done' } as const,
|
||||||
|
];
|
||||||
|
await runMultiTurnConversation({
|
||||||
|
client,
|
||||||
|
logger,
|
||||||
|
threadId: 'thread',
|
||||||
|
buildMode: 'progressive',
|
||||||
|
promptVersion: 'progressive@1',
|
||||||
|
allowUserExecution: true,
|
||||||
|
beforeUserExecution,
|
||||||
|
events: [
|
||||||
|
{ timestamp: 0, type: 'run-start', data: { type: 'run-start' } },
|
||||||
|
{ timestamp: 0, type: 'run-finish', data: { type: 'run-finish' } },
|
||||||
|
...events,
|
||||||
|
],
|
||||||
|
approvedRequests: new Set(),
|
||||||
|
startTime: Date.now(),
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
nextMessageDecider: async () => await Promise.resolve(decisions.shift() ?? { kind: 'done' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('user execution during a conversation', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
client.getThreadStatus.mockResolvedValue({ backgroundTasks: [] } as never);
|
||||||
|
client.getWorkflow.mockResolvedValue(workflow);
|
||||||
|
client.executeWorkflow.mockResolvedValue({ executionId: 'execution' });
|
||||||
|
client.getExecution.mockResolvedValue({
|
||||||
|
id: 'execution',
|
||||||
|
workflowId: 'primary',
|
||||||
|
status: 'success',
|
||||||
|
data: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs the selected primary workflow before the message, even after a helper was saved', async () => {
|
||||||
|
await run();
|
||||||
|
expect(client.executeWorkflow).toHaveBeenCalledWith('primary', 'Start', expect.any(Number));
|
||||||
|
expect(client.executeWithLlmMock).not.toHaveBeenCalled();
|
||||||
|
expect(client.getExecution.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
client.sendMessage.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||||
|
'thread',
|
||||||
|
'I ran it',
|
||||||
|
undefined,
|
||||||
|
'progressive',
|
||||||
|
'progressive@1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([{ events: [] }, { events: [saved('primary', false)] }])(
|
||||||
|
'rejects a target without a successful save',
|
||||||
|
async ({ events }) => {
|
||||||
|
await expect(run('primary', events)).rejects.toThrow('was not saved');
|
||||||
|
expect(client.executeWorkflow).not.toHaveBeenCalled();
|
||||||
|
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rejects pinned data instead of presenting it as a live run', async () => {
|
||||||
|
client.getWorkflow.mockResolvedValue({ ...workflow, pinData: { Start: [{ json: {} }] } });
|
||||||
|
await expect(run()).rejects.toThrow('without pinned data');
|
||||||
|
expect(client.executeWorkflow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects credentialed workflows in the live eval action', async () => {
|
||||||
|
client.getWorkflow.mockResolvedValue({
|
||||||
|
...workflow,
|
||||||
|
nodes: [
|
||||||
|
...workflow.nodes,
|
||||||
|
{
|
||||||
|
id: 'slack',
|
||||||
|
name: 'Slack',
|
||||||
|
type: 'n8n-nodes-base.slack',
|
||||||
|
credentials: { slackApi: { id: 'credential' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await expect(run()).rejects.toThrow('without credentials');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['new', 'running', 'waiting', 'unknown'] as const)(
|
||||||
|
'waits through %s and preserves failures for inspection',
|
||||||
|
async (status) => {
|
||||||
|
client.getExecution.mockResolvedValueOnce({
|
||||||
|
id: 'execution',
|
||||||
|
workflowId: 'primary',
|
||||||
|
status,
|
||||||
|
data: '',
|
||||||
|
});
|
||||||
|
client.getExecution.mockResolvedValueOnce({
|
||||||
|
id: 'execution',
|
||||||
|
workflowId: 'primary',
|
||||||
|
status: 'error',
|
||||||
|
data: '',
|
||||||
|
});
|
||||||
|
await run();
|
||||||
|
expect(client.getExecution).toHaveBeenCalledTimes(2);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('status=error'));
|
||||||
|
expect(client.sendMessage).toHaveBeenCalledTimes(1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('stops an indeterminate execution when the case deadline expires', async () => {
|
||||||
|
let now = 1_000;
|
||||||
|
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||||
|
client.getExecution.mockImplementationOnce(async () => {
|
||||||
|
now += 10_000;
|
||||||
|
await Promise.resolve();
|
||||||
|
return { id: 'execution', workflowId: 'primary', status: 'unknown', data: '' };
|
||||||
|
});
|
||||||
|
client.stopExecution.mockResolvedValueOnce(undefined);
|
||||||
|
await expect(run()).rejects.toThrow('Case timed out');
|
||||||
|
expect(client.stopExecution).toHaveBeenCalledWith('execution');
|
||||||
|
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds each execution request by the remaining case time', async () => {
|
||||||
|
await run();
|
||||||
|
const [, , timeoutMs] = client.executeWorkflow.mock.calls[0];
|
||||||
|
expect(timeoutMs).toBeGreaterThan(0);
|
||||||
|
expect(timeoutMs).toBeLessThan(10_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prepares the declared data before executing the workflow', async () => {
|
||||||
|
const prepare = vi.fn().mockResolvedValue(undefined);
|
||||||
|
await run('primary', [saved('primary')], prepare);
|
||||||
|
expect(prepare).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prepare.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
client.executeWorkflow.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops the execution if waiting fails', async () => {
|
||||||
|
client.getExecution.mockRejectedValueOnce(new Error('Request timed out'));
|
||||||
|
client.stopExecution.mockResolvedValueOnce(undefined);
|
||||||
|
await expect(run()).rejects.toThrow('Request timed out');
|
||||||
|
expect(client.stopExecution).toHaveBeenCalledWith('execution');
|
||||||
|
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aborts input seeding at the case deadline before starting another table or workflow', async () => {
|
||||||
|
let now = 1_000;
|
||||||
|
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||||
|
const fetchMock = vi.fn(async (_url: string, options: RequestInit) => {
|
||||||
|
return await new Promise<Response>((_resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error('Request exceeded test allowance')), 1_000);
|
||||||
|
options.signal?.addEventListener(
|
||||||
|
'abort',
|
||||||
|
() => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
const reason: unknown = options.signal?.reason;
|
||||||
|
reject(reason instanceof Error ? reason : new Error(String(reason)));
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const seedClient = new N8nClient('http://n8n.test');
|
||||||
|
const prepare = async (deadline: number) => {
|
||||||
|
now += 9_800;
|
||||||
|
await reseedScenarioTables(
|
||||||
|
seedClient,
|
||||||
|
{
|
||||||
|
name: 'inputs',
|
||||||
|
description: '',
|
||||||
|
dataSetup: '',
|
||||||
|
successCriteria: '',
|
||||||
|
seedDataTables: [
|
||||||
|
{ id: 'first-table', name: 'first', columns: [] },
|
||||||
|
{ id: 'second-table', name: 'second', columns: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'thread',
|
||||||
|
{ first: 'first-table', second: 'second-table' },
|
||||||
|
logger,
|
||||||
|
deadline,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(run('primary', [saved('primary')], prepare)).rejects.toMatchObject({
|
||||||
|
name: 'TimeoutError',
|
||||||
|
});
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock.mock.calls[0][1].signal?.aborted).toBe(true);
|
||||||
|
expect(client.getWorkflow).not.toHaveBeenCalled();
|
||||||
|
expect(client.executeWorkflow).not.toHaveBeenCalled();
|
||||||
|
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -16,7 +16,8 @@ import { UserProxyLlm } from '../utils/user-proxy';
|
||||||
import type { UserProxyAgent } from '../utils/user-proxy/agent';
|
import type { UserProxyAgent } from '../utils/user-proxy/agent';
|
||||||
import {
|
import {
|
||||||
confirmationDecisionSchema,
|
confirmationDecisionSchema,
|
||||||
userTurnDecisionSchema,
|
createUserTurnDecisionSchema,
|
||||||
|
userTurnWithoutExecutionSchema,
|
||||||
type Decision,
|
type Decision,
|
||||||
type ProxyDecisionMode,
|
type ProxyDecisionMode,
|
||||||
} from '../utils/user-proxy/tools';
|
} from '../utils/user-proxy/tools';
|
||||||
|
|
@ -75,6 +76,7 @@ function fakeCredentialClient(
|
||||||
class FakeAgent implements UserProxyAgent {
|
class FakeAgent implements UserProxyAgent {
|
||||||
readonly prompts: string[] = [];
|
readonly prompts: string[] = [];
|
||||||
readonly modes: ProxyDecisionMode[] = [];
|
readonly modes: ProxyDecisionMode[] = [];
|
||||||
|
readonly savedWorkflowIds: string[][] = [];
|
||||||
private queue: Array<Decision | undefined | Error> = [];
|
private queue: Array<Decision | undefined | Error> = [];
|
||||||
|
|
||||||
enqueue(...decisions: Array<Decision | undefined | Error>): void {
|
enqueue(...decisions: Array<Decision | undefined | Error>): void {
|
||||||
|
|
@ -82,9 +84,14 @@ class FakeAgent implements UserProxyAgent {
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
async decide(userPrompt: string, mode: ProxyDecisionMode): Promise<Decision | undefined> {
|
async decide(
|
||||||
|
userPrompt: string,
|
||||||
|
mode: ProxyDecisionMode,
|
||||||
|
savedWorkflowIds: string[] = [],
|
||||||
|
): Promise<Decision | undefined> {
|
||||||
this.prompts.push(userPrompt);
|
this.prompts.push(userPrompt);
|
||||||
this.modes.push(mode);
|
this.modes.push(mode);
|
||||||
|
this.savedWorkflowIds.push(savedWorkflowIds);
|
||||||
const next = this.queue.shift();
|
const next = this.queue.shift();
|
||||||
if (next instanceof Error) throw next;
|
if (next instanceof Error) throw next;
|
||||||
return next;
|
return next;
|
||||||
|
|
@ -1835,6 +1842,37 @@ describe('UserProxyLlm.respondToConfirmation', () => {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('UserProxyLlm.decideFollowUp', () => {
|
describe('UserProxyLlm.decideFollowUp', () => {
|
||||||
|
it('shows saved workflow IDs and names for user executions', async () => {
|
||||||
|
const agent = new FakeAgent();
|
||||||
|
agent.enqueue({ action: 'declare_done' });
|
||||||
|
const proxy = new UserProxyLlm({
|
||||||
|
conversation: [{ role: 'user', text: 'Build a contact log' }],
|
||||||
|
allowUserExecution: true,
|
||||||
|
agent,
|
||||||
|
});
|
||||||
|
proxy.ingestEvents([
|
||||||
|
{
|
||||||
|
timestamp: 0,
|
||||||
|
type: 'tool-result',
|
||||||
|
data: {
|
||||||
|
payload: {
|
||||||
|
toolCallId: 'build',
|
||||||
|
toolName: 'build-workflow',
|
||||||
|
result: {
|
||||||
|
success: true,
|
||||||
|
workflowId: 'wf-primary',
|
||||||
|
workflowName: 'Contact log',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await proxy.decideFollowUp();
|
||||||
|
expect(agent.prompts[0]).toContain('wf-primary');
|
||||||
|
expect(agent.prompts[0]).toContain('Contact log');
|
||||||
|
expect(agent.savedWorkflowIds[0]).toEqual(['wf-primary']);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns done immediately when messageBudget is 0 without invoking the agent', async () => {
|
it('returns done immediately when messageBudget is 0 without invoking the agent', async () => {
|
||||||
const agent = new FakeAgent();
|
const agent = new FakeAgent();
|
||||||
const proxy = new UserProxyLlm({
|
const proxy = new UserProxyLlm({
|
||||||
|
|
@ -1992,6 +2030,31 @@ describe('UserProxyLlm.decideFollowUp', () => {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('mode-scoped decision schemas', () => {
|
describe('mode-scoped decision schemas', () => {
|
||||||
|
const userTurnDecisionSchema = createUserTurnDecisionSchema(['workflow']);
|
||||||
|
it('restricts executions to saved IDs and omits the field without candidates', () => {
|
||||||
|
const schema = createUserTurnDecisionSchema(['primary-id', 'helper-id']);
|
||||||
|
const decision = { action: 'send_follow_up_message', message: 'I ran it' };
|
||||||
|
for (const runWorkflowId of ['primary-id', 'helper-id']) {
|
||||||
|
expect(schema.safeParse({ ...decision, runWorkflowId }).success).toBe(true);
|
||||||
|
}
|
||||||
|
for (const runWorkflowId of ['', 'Contact log', 'unknown-id']) {
|
||||||
|
expect(schema.safeParse({ ...decision, runWorkflowId }).success).toBe(false);
|
||||||
|
}
|
||||||
|
const emptySchema = createUserTurnDecisionSchema([]);
|
||||||
|
expect(emptySchema.safeParse({ ...decision, runWorkflowId: 'primary-id' }).success).toBe(false);
|
||||||
|
expect(emptySchema.safeParse(decision).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes user executions unless the case enables them', () => {
|
||||||
|
const decision = {
|
||||||
|
action: 'send_follow_up_message',
|
||||||
|
message: 'I ran it',
|
||||||
|
runWorkflowId: 'workflow',
|
||||||
|
};
|
||||||
|
expect(userTurnWithoutExecutionSchema.safeParse(decision).success).toBe(false);
|
||||||
|
expect(userTurnDecisionSchema.safeParse(decision).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('user-turn schema does not offer confirmation actions', () => {
|
it('user-turn schema does not offer confirmation actions', () => {
|
||||||
expect(
|
expect(
|
||||||
userTurnDecisionSchema.safeParse({
|
userTurnDecisionSchema.safeParse({
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,12 @@ export const MCP_BUILD_KEY_SUPPORT: Record<
|
||||||
// Caps user-proxy follow-ups in the orchestrator chat loop — simply doesn't
|
// Caps user-proxy follow-ups in the orchestrator chat loop — simply doesn't
|
||||||
// apply to a single-shot `claude` build, so it is NOT flagged.
|
// apply to a single-shot `claude` build, so it is NOT flagged.
|
||||||
messageBudget: 'supported',
|
messageBudget: 'supported',
|
||||||
|
// Drives the Instance AI orchestrator's progressive-building loop; a
|
||||||
|
// single-shot `claude` build has no mode to set, so a case that pins it
|
||||||
|
// explicitly must run through the orchestrator.
|
||||||
|
buildMode: 'orchestrator-only',
|
||||||
|
promptVersion: 'orchestrator-only',
|
||||||
|
allowUserExecution: 'orchestrator-only',
|
||||||
// Judged by the harness after the build (processExpectations are skipped for
|
// Judged by the harness after the build (processExpectations are skipped for
|
||||||
// transcript-less MCP builds there); declaring them needs no build-side setup.
|
// transcript-less MCP builds there); declaring them needs no build-side setup.
|
||||||
processExpectations: 'supported',
|
processExpectations: 'supported',
|
||||||
|
|
@ -215,6 +221,7 @@ export function unsupportedMcpBuildSetupFields(testCase: WorkflowTestCase): stri
|
||||||
const values: Partial<Record<string, unknown>> = { ...testCase };
|
const values: Partial<Record<string, unknown>> = { ...testCase };
|
||||||
return ORCHESTRATOR_ONLY_KEYS.filter((key) => {
|
return ORCHESTRATOR_ONLY_KEYS.filter((key) => {
|
||||||
const value = values[key];
|
const value = values[key];
|
||||||
|
if (key === 'allowUserExecution') return value === true;
|
||||||
if (value === undefined || value === null || value === '') return false;
|
if (value === undefined || value === null || value === '') return false;
|
||||||
// An empty array declares nothing to seed.
|
// An empty array declares nothing to seed.
|
||||||
return !Array.isArray(value) || value.length > 0;
|
return !Array.isArray(value) || value.length > 0;
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,51 @@ function stubFetch(body: unknown) {
|
||||||
return fetchMock;
|
return fetchMock;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('N8nClient chat build mode', () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('sends an explicit prompt version with the chat message', async () => {
|
||||||
|
const fetchMock = stubFetch({ data: { runId: 'run-1' } });
|
||||||
|
await new N8nClient(BASE_URL).sendMessage(
|
||||||
|
'thread-1',
|
||||||
|
'Build it',
|
||||||
|
undefined,
|
||||||
|
'default',
|
||||||
|
'progressive@1',
|
||||||
|
);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
`${BASE_URL}/rest/instance-ai/chat/thread-1`,
|
||||||
|
expect.objectContaining({
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: 'Build it',
|
||||||
|
mode: 'default',
|
||||||
|
promptVersion: 'progressive@1',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([undefined, 'default', 'progressive'] as const)(
|
||||||
|
'sends an explicit eval mode when the override is %s',
|
||||||
|
async (mode) => {
|
||||||
|
const fetchMock = stubFetch({ data: { runId: 'run-1' } });
|
||||||
|
const client = new N8nClient(BASE_URL);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.sendMessage('thread-1', 'Build a workflow', undefined, mode),
|
||||||
|
).resolves.toEqual({ runId: 'run-1' });
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
`${BASE_URL}/rest/instance-ai/chat/thread-1`,
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ message: 'Build a workflow', mode: mode ?? 'default' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
describe('N8nClient — TRUST-229 artifact fetch methods', () => {
|
describe('N8nClient — TRUST-229 artifact fetch methods', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
InstanceAiSendMessageRequest,
|
||||||
|
InstanceAiBuildMode,
|
||||||
InstanceAiConfirmRequest,
|
InstanceAiConfirmRequest,
|
||||||
InstanceAiRichMessagesResponse,
|
InstanceAiRichMessagesResponse,
|
||||||
InstanceAiEvalAgentExecutionResult,
|
InstanceAiEvalAgentExecutionResult,
|
||||||
|
|
@ -22,6 +24,7 @@ import type {
|
||||||
AgentSkill,
|
AgentSkill,
|
||||||
EvaluationConfigDto,
|
EvaluationConfigDto,
|
||||||
} from '@n8n/api-types';
|
} from '@n8n/api-types';
|
||||||
|
import type { ExecutionStatus } from 'n8n-workflow';
|
||||||
import { Agent, setGlobalDispatcher } from 'undici';
|
import { Agent, setGlobalDispatcher } from 'undici';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
|
@ -186,7 +189,7 @@ interface ExecutionListItem {
|
||||||
export interface ExecutionDetail {
|
export interface ExecutionDetail {
|
||||||
id: string;
|
id: string;
|
||||||
workflowId: string;
|
workflowId: string;
|
||||||
status: string;
|
status: ExecutionStatus;
|
||||||
/** Flatted-serialized execution data (contains error details, run data per node) */
|
/** Flatted-serialized execution data (contains error details, run data per node) */
|
||||||
data: string;
|
data: string;
|
||||||
}
|
}
|
||||||
|
|
@ -304,12 +307,19 @@ export class N8nClient {
|
||||||
threadId: string,
|
threadId: string,
|
||||||
message: string,
|
message: string,
|
||||||
attachments?: InstanceAiWorkflowAttachment[],
|
attachments?: InstanceAiWorkflowAttachment[],
|
||||||
|
mode: InstanceAiBuildMode = 'default',
|
||||||
|
promptVersion?: string,
|
||||||
): Promise<{ runId: string }> {
|
): Promise<{ runId: string }> {
|
||||||
const result = await this.fetch(`/rest/instance-ai/chat/${threadId}`, {
|
const result = await this.fetch(`/rest/instance-ai/chat/${threadId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: attachments && attachments.length > 0 ? { message, attachments } : { message },
|
body: {
|
||||||
|
message,
|
||||||
|
...(attachments?.length ? { attachments } : {}),
|
||||||
|
mode,
|
||||||
|
...(promptVersion ? { promptVersion } : {}),
|
||||||
|
} satisfies InstanceAiSendMessageRequest,
|
||||||
});
|
});
|
||||||
return result as { runId: string };
|
return this.unwrapRestData<{ runId: string }>(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -338,9 +348,12 @@ export class N8nClient {
|
||||||
* Get the current status of a thread (active run, suspended, background tasks).
|
* Get the current status of a thread (active run, suspended, background tasks).
|
||||||
* GET /rest/instance-ai/threads/:threadId/status
|
* GET /rest/instance-ai/threads/:threadId/status
|
||||||
*/
|
*/
|
||||||
async getThreadStatus(threadId: string): Promise<InstanceAiThreadStatusResponse> {
|
async getThreadStatus(
|
||||||
|
threadId: string,
|
||||||
|
timeoutMs?: number,
|
||||||
|
): Promise<InstanceAiThreadStatusResponse> {
|
||||||
return this.unwrapRestData<InstanceAiThreadStatusResponse>(
|
return this.unwrapRestData<InstanceAiThreadStatusResponse>(
|
||||||
await this.fetch(`/rest/instance-ai/threads/${threadId}/status`),
|
await this.fetch(`/rest/instance-ai/threads/${threadId}/status`, { timeoutMs }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -519,8 +532,8 @@ export class N8nClient {
|
||||||
* Get a single workflow by ID.
|
* Get a single workflow by ID.
|
||||||
* GET /rest/workflows/:id
|
* GET /rest/workflows/:id
|
||||||
*/
|
*/
|
||||||
async getWorkflow(id: string): Promise<WorkflowResponse> {
|
async getWorkflow(id: string, timeoutMs?: number): Promise<WorkflowResponse> {
|
||||||
const result = (await this.fetch(`/rest/workflows/${id}`)) as {
|
const result = (await this.fetch(`/rest/workflows/${id}`, { timeoutMs })) as {
|
||||||
data: WorkflowResponse;
|
data: WorkflowResponse;
|
||||||
};
|
};
|
||||||
return result.data;
|
return result.data;
|
||||||
|
|
@ -581,12 +594,14 @@ export class N8nClient {
|
||||||
async executeWorkflow(
|
async executeWorkflow(
|
||||||
workflowId: string,
|
workflowId: string,
|
||||||
triggerNodeName?: string,
|
triggerNodeName?: string,
|
||||||
|
timeoutMs?: number,
|
||||||
): Promise<{ executionId: string }> {
|
): Promise<{ executionId: string }> {
|
||||||
const body: Record<string, unknown> = {};
|
const body: Record<string, unknown> = {};
|
||||||
if (triggerNodeName) {
|
if (triggerNodeName) {
|
||||||
body.triggerToStartFrom = { name: triggerNodeName };
|
body.triggerToStartFrom = { name: triggerNodeName };
|
||||||
}
|
}
|
||||||
const result = (await this.fetch(`/rest/workflows/${workflowId}/run`, {
|
const result = (await this.fetch(`/rest/workflows/${workflowId}/run`, {
|
||||||
|
timeoutMs,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
})) as { data: { executionId: string } };
|
})) as { data: { executionId: string } };
|
||||||
|
|
@ -597,13 +612,17 @@ export class N8nClient {
|
||||||
* Get a single execution by ID.
|
* Get a single execution by ID.
|
||||||
* GET /rest/executions/:id
|
* GET /rest/executions/:id
|
||||||
*/
|
*/
|
||||||
async getExecution(executionId: string): Promise<ExecutionDetail> {
|
async getExecution(executionId: string, timeoutMs?: number): Promise<ExecutionDetail> {
|
||||||
const result = (await this.fetch(`/rest/executions/${executionId}`)) as {
|
const result = (await this.fetch(`/rest/executions/${executionId}`, { timeoutMs })) as {
|
||||||
data: ExecutionDetail;
|
data: ExecutionDetail;
|
||||||
};
|
};
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async stopExecution(executionId: string): Promise<void> {
|
||||||
|
await this.fetch(`/rest/executions/${executionId}/stop`, { method: 'POST', timeoutMs: 5_000 });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a workflow (partial update).
|
* Update a workflow (partial update).
|
||||||
* PATCH /rest/workflows/:id -- used to set/restore pin data for execution eval.
|
* PATCH /rest/workflows/:id -- used to set/restore pin data for execution eval.
|
||||||
|
|
@ -928,8 +947,10 @@ export class N8nClient {
|
||||||
threadId: string,
|
threadId: string,
|
||||||
tableId: string,
|
tableId: string,
|
||||||
rows: Array<Record<string, string | number | boolean | null>>,
|
rows: Array<Record<string, string | number | boolean | null>>,
|
||||||
|
timeoutMs?: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.fetch('/rest/instance-ai/eval/seed-data-table-rows', {
|
await this.fetch('/rest/instance-ai/eval/seed-data-table-rows', {
|
||||||
|
timeoutMs,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { threadId, tableId, rows },
|
body: { threadId, tableId, rows },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
33
packages/@n8n/instance-ai/evaluations/harness/build-mode.ts
Normal file
33
packages/@n8n/instance-ai/evaluations/harness/build-mode.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import type { InstanceAiBuildMode } from '@n8n/api-types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
assertInstanceAiPromptVersion,
|
||||||
|
resolvePromptProfile,
|
||||||
|
} from '../../src/prompts/prompt-profiles';
|
||||||
|
import type { WorkflowTestCase } from '../types';
|
||||||
|
|
||||||
|
export function resolveEvalPromptSettings(
|
||||||
|
testCase: Pick<WorkflowTestCase, 'buildMode' | 'promptVersion'>,
|
||||||
|
) {
|
||||||
|
const promptVersion =
|
||||||
|
testCase.promptVersion ??
|
||||||
|
(testCase.buildMode === undefined ? process.env.N8N_EVAL_PROMPT_VERSION?.trim() : undefined);
|
||||||
|
if (promptVersion) {
|
||||||
|
assertInstanceAiPromptVersion(promptVersion);
|
||||||
|
return {
|
||||||
|
promptVersion,
|
||||||
|
buildMode: resolvePromptProfile({ version: promptVersion }).profile.mode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { promptVersion: undefined, buildMode: resolveEvalBuildMode(testCase.buildMode) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case overrides take precedence. Validate the environment only when it is used. */
|
||||||
|
export function resolveEvalBuildMode(
|
||||||
|
buildMode: WorkflowTestCase['buildMode'],
|
||||||
|
): InstanceAiBuildMode {
|
||||||
|
const mode = buildMode ?? process.env.N8N_EVAL_BUILD_MODE?.trim();
|
||||||
|
if (!mode || mode === 'default') return 'default';
|
||||||
|
if (mode === 'progressive') return mode;
|
||||||
|
throw new Error('N8N_EVAL_BUILD_MODE must be "progressive" or "default", or unset');
|
||||||
|
}
|
||||||
|
|
@ -7,11 +7,16 @@
|
||||||
// execution and cleanup.
|
// execution and cleanup.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import type { InstanceAiConfirmRequest, InstanceAiWorkflowAttachment } from '@n8n/api-types';
|
import type {
|
||||||
|
InstanceAiBuildMode,
|
||||||
|
InstanceAiConfirmRequest,
|
||||||
|
InstanceAiWorkflowAttachment,
|
||||||
|
} from '@n8n/api-types';
|
||||||
import { truncate } from '@n8n/utils/string/truncate';
|
import { truncate } from '@n8n/utils/string/truncate';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { setTimeout as delay } from 'node:timers/promises';
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
|
|
||||||
|
import { resolveEvalPromptSettings } from './build-mode';
|
||||||
import {
|
import {
|
||||||
SSE_SETTLE_DELAY_MS,
|
SSE_SETTLE_DELAY_MS,
|
||||||
startSseConnection,
|
startSseConnection,
|
||||||
|
|
@ -51,6 +56,7 @@ import {
|
||||||
buildSeededTablesNote,
|
buildSeededTablesNote,
|
||||||
dedupeScenarioSeedTables,
|
dedupeScenarioSeedTables,
|
||||||
evictLeftoverSeedTables,
|
evictLeftoverSeedTables,
|
||||||
|
reseedScenarioTables,
|
||||||
uniquifyScenarioTableNames,
|
uniquifyScenarioTableNames,
|
||||||
} from './seed-tables';
|
} from './seed-tables';
|
||||||
import type { CheckOutcome } from '../binaryChecks/types';
|
import type { CheckOutcome } from '../binaryChecks/types';
|
||||||
|
|
@ -106,6 +112,11 @@ interface MultiTurnDriverConfig {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
conversation: ConversationTurn[];
|
conversation: ConversationTurn[];
|
||||||
messageBudget?: number;
|
messageBudget?: number;
|
||||||
|
/** Resolved wire value sent with every message (see `resolveEvalBuildMode`). */
|
||||||
|
buildMode?: InstanceAiBuildMode;
|
||||||
|
promptVersion?: string;
|
||||||
|
allowUserExecution?: boolean;
|
||||||
|
beforeUserExecution?: (deadline: number) => Promise<void>;
|
||||||
events: CapturedEvent[];
|
events: CapturedEvent[];
|
||||||
approvedRequests: Set<string>;
|
approvedRequests: Set<string>;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
|
|
@ -162,6 +173,7 @@ async function driveMultiTurnConversation(
|
||||||
const proxy = new UserProxyLlm({
|
const proxy = new UserProxyLlm({
|
||||||
conversation: proxyConversation,
|
conversation: proxyConversation,
|
||||||
messageBudget: config.messageBudget,
|
messageBudget: config.messageBudget,
|
||||||
|
allowUserExecution: config.allowUserExecution,
|
||||||
logger: config.logger,
|
logger: config.logger,
|
||||||
...(config.allowlistedCredentialIds !== undefined
|
...(config.allowlistedCredentialIds !== undefined
|
||||||
? {
|
? {
|
||||||
|
|
@ -193,6 +205,8 @@ async function driveMultiTurnConversation(
|
||||||
config.threadId,
|
config.threadId,
|
||||||
openingMessage + (config.openingMessageSuffix ?? ''),
|
openingMessage + (config.openingMessageSuffix ?? ''),
|
||||||
config.openingAttachments,
|
config.openingAttachments,
|
||||||
|
config.buildMode,
|
||||||
|
config.promptVersion,
|
||||||
);
|
);
|
||||||
|
|
||||||
await runMultiTurnConversation({
|
await runMultiTurnConversation({
|
||||||
|
|
@ -206,6 +220,10 @@ async function driveMultiTurnConversation(
|
||||||
confirmationStrategy,
|
confirmationStrategy,
|
||||||
nextMessageDecider,
|
nextMessageDecider,
|
||||||
proxyResponses: config.proxyResponses,
|
proxyResponses: config.proxyResponses,
|
||||||
|
buildMode: config.buildMode,
|
||||||
|
promptVersion: config.promptVersion,
|
||||||
|
allowUserExecution: config.allowUserExecution,
|
||||||
|
beforeUserExecution: config.beforeUserExecution,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ...proxy.getDecisionStats() };
|
return { ...proxy.getDecisionStats() };
|
||||||
|
|
@ -432,6 +450,10 @@ export interface BuildWorkflowConfig {
|
||||||
conversation?: ConversationTurn[];
|
conversation?: ConversationTurn[];
|
||||||
/** Max follow-up messages the proxy will send. Ignored in auto-approve mode. */
|
/** Max follow-up messages the proxy will send. Ignored in auto-approve mode. */
|
||||||
messageBudget?: number;
|
messageBudget?: number;
|
||||||
|
/** Case-declared build style; resolved via `resolveEvalBuildMode` (absent → default). */
|
||||||
|
buildMode?: WorkflowTestCase['buildMode'];
|
||||||
|
promptVersion?: string;
|
||||||
|
allowUserExecution?: boolean;
|
||||||
/** Credentials this build should see (created for real, view pinned to them). */
|
/** Credentials this build should see (created for real, view pinned to them). */
|
||||||
credentials?: TestCaseCredential[];
|
credentials?: TestCaseCredential[];
|
||||||
/** Run-level registry the created credential IDs are added to for cleanup. */
|
/** Run-level registry the created credential IDs are added to for cleanup. */
|
||||||
|
|
@ -494,6 +516,7 @@ export function workflowExpectedForCase(
|
||||||
*/
|
*/
|
||||||
export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildResult> {
|
export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildResult> {
|
||||||
const { client, logger } = config;
|
const { client, logger } = config;
|
||||||
|
const { buildMode, promptVersion } = resolveEvalPromptSettings(config);
|
||||||
const threadId = crypto.randomUUID();
|
const threadId = crypto.randomUUID();
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||||
|
|
@ -950,6 +973,28 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
|
||||||
threadId,
|
threadId,
|
||||||
conversation,
|
conversation,
|
||||||
messageBudget: config.messageBudget,
|
messageBudget: config.messageBudget,
|
||||||
|
buildMode,
|
||||||
|
promptVersion,
|
||||||
|
allowUserExecution: config.allowUserExecution,
|
||||||
|
beforeUserExecution: async (deadline) => {
|
||||||
|
const scenario = config.executionScenarios?.[0];
|
||||||
|
if (scenario) {
|
||||||
|
try {
|
||||||
|
await reseedScenarioTables(
|
||||||
|
client,
|
||||||
|
scenario,
|
||||||
|
threadId,
|
||||||
|
scenarioTableIdsByName,
|
||||||
|
logger,
|
||||||
|
deadline,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Keep overall case timeouts separate from input setup failures.
|
||||||
|
seedingFailed = Date.now() < deadline;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
events,
|
events,
|
||||||
approvedRequests,
|
approvedRequests,
|
||||||
startTime,
|
startTime,
|
||||||
|
|
@ -980,6 +1025,8 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
|
||||||
threadId,
|
threadId,
|
||||||
openingMessage + scenarioSeedTablesNote,
|
openingMessage + scenarioSeedTablesNote,
|
||||||
openingAttachments,
|
openingAttachments,
|
||||||
|
buildMode,
|
||||||
|
promptVersion,
|
||||||
);
|
);
|
||||||
await waitForAllActivity({
|
await waitForAllActivity({
|
||||||
client,
|
client,
|
||||||
|
|
@ -1048,6 +1095,16 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
|
||||||
toolCalls: eventOutcome.toolCalls,
|
toolCalls: eventOutcome.toolCalls,
|
||||||
agentActivities: eventOutcome.agentActivities,
|
agentActivities: eventOutcome.agentActivities,
|
||||||
};
|
};
|
||||||
|
const metadataBudget = Math.min(5_000, startTime + timeoutMs - Date.now());
|
||||||
|
if (metadataBudget > 0) {
|
||||||
|
try {
|
||||||
|
buildTrace.promptConfiguration = (
|
||||||
|
await client.getThreadStatus(threadId, metadataBudget)
|
||||||
|
).promptConfiguration;
|
||||||
|
} catch {
|
||||||
|
logger.verbose('Prompt configuration was not available for this build.');
|
||||||
|
}
|
||||||
|
}
|
||||||
const outcome = await buildAgentOutcome(
|
const outcome = await buildAgentOutcome(
|
||||||
client,
|
client,
|
||||||
{ ...eventOutcome, workflowIds: threadWorkflowIds },
|
{ ...eventOutcome, workflowIds: threadWorkflowIds },
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,15 @@
|
||||||
// flows automatically.
|
// flows automatically.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import type { InstanceAiConfirmRequest } from '@n8n/api-types';
|
import type { InstanceAiBuildMode, InstanceAiConfirmRequest } from '@n8n/api-types';
|
||||||
import { INSTANCE_AI_MEMORY_TASK_WAIT_TIMEOUT_MS } from '@n8n/api-types';
|
import { INSTANCE_AI_MEMORY_TASK_WAIT_TIMEOUT_MS } from '@n8n/api-types';
|
||||||
|
import { isTerminalExecutionStatus } from 'n8n-workflow';
|
||||||
import { setTimeout as delay } from 'node:timers/promises';
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
|
|
||||||
import type { EvalLogger } from './logger';
|
import type { EvalLogger } from './logger';
|
||||||
import type { N8nClient } from '../clients/n8n-client';
|
import type { N8nClient } from '../clients/n8n-client';
|
||||||
import { consumeSseStream } from '../clients/sse-client';
|
import { consumeSseStream } from '../clients/sse-client';
|
||||||
import { lastSavedWorkflowIdFromEvents } from '../outcome/event-parser';
|
import { lastSavedWorkflowIdFromEvents, savedWorkflowsFromEvents } from '../outcome/event-parser';
|
||||||
import type { CapturedEvent } from '../types';
|
import type { CapturedEvent } from '../types';
|
||||||
import { USER_TURN_EVENT } from '../types';
|
import { USER_TURN_EVENT } from '../types';
|
||||||
import { getEventPayload, tryInfrastructureResponse } from '../utils/confirmation-payload';
|
import { getEventPayload, tryInfrastructureResponse } from '../utils/confirmation-payload';
|
||||||
|
|
@ -288,11 +289,19 @@ export type NextMessageDecision =
|
||||||
* its own setup or credential work happens to advance the checksum.
|
* its own setup or credential work happens to advance the checksum.
|
||||||
*/
|
*/
|
||||||
renameWorkflowTo?: string;
|
renameWorkflowTo?: string;
|
||||||
|
/** A normal user run, performed before delivering the next message. */
|
||||||
|
runWorkflowId?: string;
|
||||||
}
|
}
|
||||||
| { kind: 'done' };
|
| { kind: 'done' };
|
||||||
|
|
||||||
export interface MultiTurnConfig extends WaitConfig {
|
export interface MultiTurnConfig extends WaitConfig {
|
||||||
nextMessageDecider: () => Promise<NextMessageDecision>;
|
nextMessageDecider: () => Promise<NextMessageDecision>;
|
||||||
|
/** Restore the case's declared input rows before a normal user execution. */
|
||||||
|
beforeUserExecution?: (deadline: number) => Promise<void>;
|
||||||
|
allowUserExecution?: boolean;
|
||||||
|
/** Repeat the eval override on each message to bypass the backend assignment. */
|
||||||
|
buildMode?: InstanceAiBuildMode;
|
||||||
|
promptVersion?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runMultiTurnConversation(config: MultiTurnConfig): Promise<void> {
|
export async function runMultiTurnConversation(config: MultiTurnConfig): Promise<void> {
|
||||||
|
|
@ -319,12 +328,27 @@ export async function runMultiTurnConversation(config: MultiTurnConfig): Promise
|
||||||
await applyExternalRename(config, decision.renameWorkflowTo);
|
await applyExternalRename(config, decision.renameWorkflowTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Before the follow-up is delivered, so a "I just ran it" message is true
|
||||||
|
// by the time the agent reads it and inspects the executions list.
|
||||||
|
if (decision.runWorkflowId !== undefined) {
|
||||||
|
if (!config.allowUserExecution) throw new Error('User executions are disabled for this case');
|
||||||
|
await applyUserExecution(config, decision.runWorkflowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - config.startTime >= config.timeoutMs) return;
|
||||||
|
|
||||||
config.logger.verbose(
|
config.logger.verbose(
|
||||||
`[multi-turn] Sending follow-up: ${decision.message.slice(0, 80)}${decision.message.length > 80 ? '...' : ''}`,
|
`[multi-turn] Sending follow-up: ${decision.message.slice(0, 80)}${decision.message.length > 80 ? '...' : ''}`,
|
||||||
);
|
);
|
||||||
recordUserTurn(config.events, decision.message);
|
recordUserTurn(config.events, decision.message);
|
||||||
try {
|
try {
|
||||||
await config.client.sendMessage(config.threadId, decision.message);
|
await config.client.sendMessage(
|
||||||
|
config.threadId,
|
||||||
|
decision.message,
|
||||||
|
undefined,
|
||||||
|
config.buildMode,
|
||||||
|
config.promptVersion,
|
||||||
|
);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const msg = error instanceof Error ? error.message : String(error);
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
config.logger.verbose(`[multi-turn] sendMessage failed: ${msg} — exiting loop`);
|
config.logger.verbose(`[multi-turn] sendMessage failed: ${msg} — exiting loop`);
|
||||||
|
|
@ -391,6 +415,54 @@ async function applyExternalRename(config: MultiTurnConfig, rename: string): Pro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Use the normal execution route so the agent can inspect user-run evidence. */
|
||||||
|
async function applyUserExecution(config: MultiTurnConfig, workflowId: string): Promise<void> {
|
||||||
|
if (!savedWorkflowsFromEvents(config.events).some((workflow) => workflow.id === workflowId)) {
|
||||||
|
throw new Error(`User-run workflow ${workflowId} was not saved in this conversation`);
|
||||||
|
}
|
||||||
|
const remainingMs = () => {
|
||||||
|
const remaining = config.timeoutMs - (Date.now() - config.startTime);
|
||||||
|
if (remaining <= 0) throw new Error('Case timed out before the user execution completed');
|
||||||
|
return remaining;
|
||||||
|
};
|
||||||
|
remainingMs();
|
||||||
|
await config.beforeUserExecution?.(config.startTime + config.timeoutMs);
|
||||||
|
const workflow = await config.client.getWorkflow(workflowId, remainingMs());
|
||||||
|
if (Object.keys(workflow.pinData ?? {}).length > 0) {
|
||||||
|
throw new Error('User-run evals require a workflow without pinned data');
|
||||||
|
}
|
||||||
|
if (workflow.nodes.some((node) => Object.keys(node.credentials ?? {}).length > 0)) {
|
||||||
|
throw new Error('User-run evals require a workflow without credentials');
|
||||||
|
}
|
||||||
|
const trigger = workflow.nodes.find((node) =>
|
||||||
|
['n8n-nodes-base.manualTrigger', 'n8n-nodes-base.scheduleTrigger'].includes(node.type),
|
||||||
|
);
|
||||||
|
if (!trigger) throw new Error('User-run evals require a manual or schedule trigger');
|
||||||
|
|
||||||
|
const { executionId } = await config.client.executeWorkflow(
|
||||||
|
workflowId,
|
||||||
|
trigger.name,
|
||||||
|
remainingMs(),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const execution = await config.client.getExecution(executionId, remainingMs());
|
||||||
|
if (isTerminalExecutionStatus(execution.status)) {
|
||||||
|
config.logger.info(
|
||||||
|
`[user-run] Executed ${workflowId}: status=${execution.status} executionId=${executionId}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await delay(Math.min(POLL_INTERVAL_MS, remainingMs()));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await config.client.stopExecution(executionId).catch(() => {
|
||||||
|
config.logger.warn(`[user-run] Could not stop execution ${executionId}`);
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Confirmation auto-approval
|
// Confirmation auto-approval
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,10 @@ const evalTestCaseObjectSchema = z
|
||||||
triggerType: z.enum(['manual', 'webhook', 'schedule', 'form']).optional(),
|
triggerType: z.enum(['manual', 'webhook', 'schedule', 'form']).optional(),
|
||||||
executionScenarios: z.array(ExecutionScenarioSchema).optional(),
|
executionScenarios: z.array(ExecutionScenarioSchema).optional(),
|
||||||
messageBudget: z.number().int().positive().optional(),
|
messageBudget: z.number().int().positive().optional(),
|
||||||
|
/** Optional case override. Unset cases use the suite mode or control. */
|
||||||
|
buildMode: z.enum(['progressive', 'default']).optional(),
|
||||||
|
promptVersion: z.string().trim().min(1).max(128).optional(),
|
||||||
|
allowUserExecution: z.boolean().optional(),
|
||||||
/** Optional NL assertions about the build CONVERSATION (process: clarifications, push-back,
|
/** Optional NL assertions about the build CONVERSATION (process: clarifications, push-back,
|
||||||
* ordering). LLM-judged from the transcript, so skipped in prebuilt/MCP runs. Counted as units. */
|
* ordering). LLM-judged from the transcript, so skipped in prebuilt/MCP runs. Counted as units. */
|
||||||
processExpectations: z.array(z.string().min(1)).optional(),
|
processExpectations: z.array(z.string().min(1)).optional(),
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user