Compare commits

..

155 commits

Author SHA1 Message Date
Iván Ovejero
10dbf32596
feat(core): Scale expression isolate pool to 0 after inactivity (#28472)
Some checks are pending
Build: Benchmark Image / build (push) Waiting to run
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
Util: Sync API Docs / sync-public-api (push) Waiting to run
Co-authored-by: Danny Martini <danny@n8n.io>
2026-04-21 15:20:01 +00:00
RomanDavydchuk
4869e0a463
fix(editor): HTTP request node showing warning about credentials not set when they are set (#28270) 2026-04-21 15:16:08 +00:00
Irénée
3bd7a2847c
feat(core): Make SSO connection settings configurable via env vars (#28714)
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-04-21 15:14:00 +00:00
Dimitri Lavrenük
9494f41c34
feat: Track computer use connect events (no-changelog) (#28815) 2026-04-21 14:49:48 +00:00
RomanDavydchuk
713c4981b7
fix(editor): Move tooltip for required RMC fields to the right (#28803) 2026-04-21 14:44:45 +00:00
Albert Alises
6db02fe928
fix(MCP Server Trigger Node): Only return error name and message in tool error responses (#28791)
Co-authored-by: Anand Reddy Jonnalagadda <15153801+joan1011@users.noreply.github.com>
2026-04-21 13:43:20 +00:00
Alex Grozav
a88f847708
refactor(editor): Migrate nodeMetadata to workflowDocumentStore (no-changelog) (#28788) 2026-04-21 13:22:52 +00:00
Svetoslav Dekov
7d74c1f04b
fix(editor): Resolve node parameter defaults in Instance AI setup wizard (no-changelog) (#28800)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:04:15 +00:00
Daria
b1ca129496
feat(core): Broadcast workflow updates from MCP tools to open editors (#28709) 2026-04-21 12:26:56 +00:00
Michael Kret
8e49800421
fix: Additional keys in routing nodes (#28758) 2026-04-21 12:24:43 +00:00
Albert Alises
782b2d18b2
fix(ai-builder): Prevent duplicate workflow creation on parallel submits in instance AI (#28793) 2026-04-21 12:21:48 +00:00
Milorad FIlipović
76358a60be
fix(editor): Allow name parameters to be defined by AI (#28763) 2026-04-21 11:52:25 +00:00
Jaakko Husso
86ceb68a05
feat(core): Include workflow names on instance AI confirmations (no-changelog) (#28719)
Co-authored-by: Albert Alises <albert.alises@gmail.com>
2026-04-21 11:24:16 +00:00
Jaakko Husso
2d624a521e
fix(core): Generate title once there's enough user context (#28721) 2026-04-21 10:28:19 +00:00
Matsuuu
ba2c5488c7
Merge tag 'n8n@2.18.0' 2026-04-21 13:32:15 +03:00
Daria
d1c7b31237
fix: Stop persisting client id in session storage to fix duplicate tab problem (no-changelog) (#28769) 2026-04-21 10:02:43 +00:00
Ricardo Espinoza
26ecadcf94
fix(core): Use upsert for MCP OAuth consent to allow re-authorization (#28703) 2026-04-21 09:58:01 +00:00
Svetoslav Dekov
45b5b9e383
fix(editor): Fix instance-ai setup parameter issues not resetting on input (no-changelog) (#28689) 2026-04-21 09:55:29 +00:00
Matsu
cb9882ce9c
ci: Run ci-pr-quality only on n8n team PRs (#28773) 2026-04-21 09:50:16 +00:00
Jaakko Husso
6592ed8047
refactor(core): Move instance AI user settings under actual user settings (no-changelog) (#28706) 2026-04-21 09:47:36 +00:00
Michael Kret
92f1dac835
chore(Microsoft Agent 365 Trigger Node): Change label on toggle to enable Microsoft MCP Servers (#28766) 2026-04-21 09:38:33 +00:00
Vitalii Borovyk
a88ee76553
fix(MongoDB Chat Memory Node): Add connection pool limit (#28042)
Co-authored-by: Eugene <eugene@n8n.io>
2026-04-21 09:21:40 +00:00
Suguru Inoue
b444a95e11
refactor(editor): Migrate workflow object usages (#28534) 2026-04-21 09:17:45 +00:00
Declan Carroll
5e8002ab28
test: Refactor test workflow initialization (#28772) 2026-04-21 09:15:26 +00:00
Guillaume Jacquart
c012b52ac2
feat(core): Bootstrap encryption key set from environment (#28716)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 09:13:11 +00:00
Garrit Franke
fc5424477d
feat(core): Add require-node-api-error ESLint rule for community nodes (no-changelog) (#28454) 2026-04-21 09:12:51 +00:00
Jaakko Husso
cb1244c041
refactor: Use napi-rs/image instead of sharp for screenshots (#28586) 2026-04-21 09:12:14 +00:00
n8n-assistant[bot]
6336f0a447
🚀 Release 2.18.0 (#28768)
Co-authored-by: Matsuuu <16068444+Matsuuu@users.noreply.github.com>
2026-04-21 08:58:38 +00:00
Albert Alises
9ea2ef1840
fix(core): Hide pre-resolved setup requests from Instance AI wizard (#28731) 2026-04-21 08:34:59 +00:00
Milorad FIlipović
5e111975d4
fix(editor): Reset remote values on credentials change (#26282)
Co-authored-by: Elias Meire <elias@meire.dev>
Co-authored-by: Nikhil Kuriakose <nikhilkuria@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-04-21 08:21:06 +00:00
José Braulio González Valido
87163163e6
fix(core): Add required field validation to MCP OAuth client registration (#28490)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 08:04:54 +00:00
Matsu
95c155859e
ci: Ensure stable npm packages are tagged as latest after release (#28755) 2026-04-21 08:04:21 +00:00
Ricardo Espinoza
575c34eae1
feat(core): Track workflow action source for external API and MCP requests (#28483) 2026-04-21 08:00:04 +00:00
Matsu
0d98d29ae4
ci: Only post QA metrics on n8n-io/n8n monorepo (#28692)
Some checks are pending
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
2026-04-21 04:59:06 +00:00
Ali Elkhateeb
9a65549575
feat(API): Add missing credential endpoints (GET by ID and test) (#28519)
Some checks are pending
Build: Benchmark Image / build (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
Util: Sync API Docs / sync-public-api (push) Waiting to run
2026-04-20 20:56:51 +00:00
Dawid Myslak
dd6c28c6d1
fix(Alibaba Cloud Chat Model Node): Add credential-level url field for AI gateway compatibility (#28697) 2026-04-20 19:40:12 +00:00
Joco-95
d14f2546a1
feat: Removes computer use setup logic on Assistant AI opt-in flow and minor UX changes (no-changelog) (#28679) 2026-04-20 18:25:09 +00:00
RomanDavydchuk
d179f667c0
fix(HubSpot Trigger Node): Add missing property selectors (#28595) 2026-04-20 18:05:37 +00:00
Mutasem Aldmour
5b376cb12d
feat(editor): Enable workflow execution from instance AI preview canvas (#28412)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 17:57:03 +00:00
Jaakko Husso
6cfa0ed559
feat(core): Rename instance AI to AI Assistant in the UI texts (no-changelog) (#28732) 2026-04-20 17:49:04 +00:00
Luca Mattiazzi
107c48f65c
fix(core): Ensure single zod instance across workspace packages (#28604) 2026-04-20 17:02:24 +00:00
Svetoslav Dekov
1b13d325f1
fix(editor): Show auth type selector in Instance AI workflow setup (#28707)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:50:21 +00:00
Albert Alises
db83a95522
fix(editor): Gate Instance AI input while setup wizard is open (#28685) 2026-04-20 16:32:14 +00:00
Joco-95
b42c890c5e
chore(core): Switch PostHog environment variables to EU region (#27115) 2026-04-20 16:21:37 +00:00
Albert Alises
3b15e470b5
fix(editor): Advance wizard step on Continue instead of applying setup (#28698) 2026-04-20 16:11:50 +00:00
Marc Littlemore
bef528cb21
fix: Restore OpenAPI schema version (no-changelog) (#28713) 2026-04-20 15:51:44 +00:00
Matsu
0b8fae6c5a
ci: Only run visual storybook on public monorepo (#28699)
Some checks are pending
Build: Benchmark Image / build (push) Waiting to run
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
Util: Sync API Docs / sync-public-api (push) Waiting to run
2026-04-20 14:25:42 +00:00
José Braulio González Valido
560f300716
test: Add Instance AI workflow evals CI pipeline (no-changelog) (#28366)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 14:15:41 +00:00
Jaakko Husso
73d93d4edf
fix(core): Better titles on instance AI, use common title logic on n8n agents sdk (no-changelog) (#28686) 2026-04-20 13:27:33 +00:00
Matsu
9f71e12e5f
chore: Migrate @n8n/json-schema-to-zod from Jest to Vitest (#28411)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 13:07:50 +00:00
Stephen Wright
9dd3e59acb
feat(core): Add KeyManagerService for encryption key lifecycle management (#28533) 2026-04-20 12:39:46 +00:00
Albert Alises
657bdf136f
fix(core): Filter stale credentials from setup wizard requests (#28478) 2026-04-20 12:37:51 +00:00
Bernhard Wittmann
2d0b231e31
fix(IMAP Node): Fix out-of-memory crash after ECONNRESET on reconnect (#28290) 2026-04-20 12:27:24 +00:00
Raúl Gómez Morales
c17f5b61fe
fix(editor): Prevent chat window jump when hovering prompt suggestions (no-changelog) (#28683) 2026-04-20 12:19:13 +00:00
Alex Grozav
db1eb91940
refactor(editor): Migrate workflow name consumers to workflowDocumentStore (#28682) 2026-04-20 12:17:08 +00:00
Matsu
a3292b738a
chore: Migrate @n8n/permissions to Vitest (#28408) 2026-04-20 12:14:53 +00:00
Declan Carroll
82ee4a9fce
ci: Strengthen Playwright test resilience (#28687) 2026-04-20 12:06:41 +00:00
Matsu
d608889e88
ci: Allow only bundles to 1.x (#28401) 2026-04-20 11:55:35 +00:00
Matsu
a39618a889
chore: Migrate @n8n/client-oauth2 to vitest (#28404) 2026-04-20 11:54:51 +00:00
oleg
bfee79dc21
fix(core): Fix instance-ai planner and prompts after tool consolidation (no-changelog) (#28684) 2026-04-20 11:29:49 +00:00
Jaakko Husso
3e724303c5
fix(core): Prevent nodes tool crash on flattened required fields (#28670) 2026-04-20 10:48:39 +00:00
RomanDavydchuk
19aadf19f7
fix(ClickUp Node): Unclear error message when using OAuth credentials (#28584)
Co-authored-by: Dawid Myslak <dawid.myslak@gmail.com>
2026-04-20 10:33:23 +00:00
Albert Alises
7b3696f3f7
fix(ai-builder): Scope artifacts panel to resources produced in-thread (#28678) 2026-04-20 10:11:46 +00:00
Albert Alises
35f9bed4de
fix(core): Cascade-cancel dependent planned tasks when a parent task fails (#28656) 2026-04-20 09:50:33 +00:00
Garrit Franke
b1c52dad58
test(core): Add credential isolation tests for same-type credentials (no-changelog) (#28308)
Co-authored-by: Jon <jonathan.bennetts@gmail.com>
2026-04-20 09:04:06 +00:00
Alex Grozav
d037fd4647
refactor(editor): Normalize sharedWithProjects field in workflow document store (no-changelog) (#28078) 2026-04-20 08:50:56 +00:00
Milorad FIlipović
0fc2d90b52
fix(core): Report success from mcp tool if workflow is created in DB (no-changelog) (#28529) 2026-04-20 08:48:32 +00:00
Matsu
b2fdcf16c0
ci: Update minor and patch release schedules (#28511) 2026-04-20 08:47:34 +00:00
RomanDavydchuk
73659cb3e7
fix(Google Gemini Node): Determine the file extention from MIME type for image and video operations (#28616) 2026-04-20 08:16:51 +00:00
Michael Kret
4070930e4c
fix(OpenAI Node): Replace hardcoded models with RLC (#28226) 2026-04-20 08:13:47 +00:00
Rob Hough
e848230947
fix(editor): Improve disabled Google sign-in button styling and tooltip alignment (#28536) 2026-04-20 07:31:15 +00:00
James Campbell
7094395cef
fix(Google Cloud Firestore Node): Fix empty array serialization in jsonToDocument (#28213)
Some checks failed
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Has been cancelled
CI: Master (Build, Test, Lint) / Unit tests (push) Has been cancelled
CI: Master (Build, Test, Lint) / Lint (push) Has been cancelled
CI: Master (Build, Test, Lint) / Performance (push) Has been cancelled
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Has been cancelled
Co-authored-by: RomanDavydchuk <roman.davydchuk@n8n.io>
2026-04-18 13:48:45 +00:00
Jon
f1dab3e295
feat(Slack Node): Add app_home_opened as a dedicated trigger event (#28626)
Some checks are pending
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Roman Davydchuk <roman.davydchuk@n8n.io>
2026-04-17 19:13:53 +00:00
RomanDavydchuk
ff950e5840
fix: Link to n8n website broken in n8n forms (#28627) 2026-04-17 17:09:14 +00:00
Jon
77d27bc826
fix(core): Guard against undefined config properties in credential overwrites (#28573)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 16:42:34 +00:00
R🂡hul
25e07cab5a
fix(LinkedIn Node): Update LinkedIn API version in request headers (#28564)
Co-authored-by: Jon <jonathan.bennetts@gmail.com>
2026-04-17 14:33:10 +00:00
robrown-hubspot
8c3e692174
fix(HubSpot Node): Rename HubSpot "App Token" auth to "Service Key" (#28479)
Co-authored-by: Jon <jonathan.bennetts@gmail.com>
2026-04-17 14:20:54 +00:00
Declan Carroll
ef4bfbfe94
ci: Skip non isolated tests (#28615)
Some checks are pending
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
2026-04-17 13:03:10 +00:00
Jon
51bc71e897
fix(editor): Restore WASM file paths for cURL import in HTTP Request node (#28610)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Matsuuu <huhta.matias@gmail.com>
2026-04-17 12:41:56 +00:00
Eugene
3b248eedc2
feat(Linear Trigger Node): Add signing secret validation (#28522)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-04-17 12:33:01 +00:00
Csaba Tuncsik
21317b8945
fix(editor): Re-initialize SSO store after login to populate OIDC redirect URL (#28386) 2026-04-17 12:05:48 +00:00
Jaakko Husso
46aa46d996
fix(editor): Handle plan confirmation correctly at the UI (no-changelog) (#28613) 2026-04-17 12:05:33 +00:00
Jaakko Husso
5c9a732af4
fix(core): Rework Instance ai settings (no-changelog) (#28495) 2026-04-17 11:36:49 +00:00
Mutasem Aldmour
cff2852332
fix(core): Preserve submitted workflow outcome when builder errors after submit (no-changelog) (#28606)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 10:19:42 +00:00
Raúl Gómez Morales
465478a829
feat(editor): Add collapsible sidebar and deferred thread creation to Instance AI (no-changelog) (#28459) 2026-04-17 10:00:37 +00:00
Albert Alises
d17211342e
fix(editor): Improve setup wizard placeholder detection and card completion scoping (#28474) 2026-04-17 08:47:54 +00:00
Stephen Wright
bb96d2e50a
feat(core): Persist deployment_key entries for stability across restarts and key rotation (#28518)
Some checks are pending
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
2026-04-16 19:49:11 +00:00
Mutasem Aldmour
c97c3b4d12
fix(editor): Resolve nodes stuck on loading after execution in instance-ai preview (#28450)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 19:07:19 +00:00
Garrit Franke
fb2bc1ca5f
feat: Add require-community-node-keyword ESLint rule (no-changelog) (#28395) 2026-04-16 17:26:11 +00:00
Rob Hough
04860d5cd7
fix(editor): Fix styles on disabled Publish button (no-changelog) (#28531) 2026-04-16 16:15:11 +00:00
Stephen Wright
c6534fa0b3
feat: Add Prometheus counters for token exchange (#28453)
Some checks are pending
CI: Master (Build, Test, Lint) / Unit tests (push) Waiting to run
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
2026-04-16 12:20:38 +00:00
Declan Carroll
bb9bec3ba4
revert: Make Wait node fully durable by removing in-memory execution path (#28538) 2026-04-16 11:42:22 +00:00
Stephen Wright
56f36a6d19
fix: Disable axios built-in proxy for OAuth2 token requests (#28513) 2026-04-16 09:35:15 +00:00
Luca Mattiazzi
e4fc753967
fix(core): Fix dev:ai script in package.json (no-changelog) (#28402) 2026-04-15 17:11:51 +00:00
Milorad FIlipović
1ecc290107
fix(core): Add strict input validation for workflow() (no-changelog) (#28517) 2026-04-15 14:57:43 +00:00
Jaakko Husso
6bb271d83c
fix(core): Position workflow correctly if opened while on a background tab (no-changelog) (#28421) 2026-04-15 13:26:07 +00:00
Michael Kret
d012346c77
feat: AI Gateway credentials endpoint instance url (#28520) 2026-04-15 12:12:19 +00:00
Tuukka Kantola
6739856aa3
fix(editor): Center sub-node icons and refresh triggers panel icons (#28515)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 10:35:12 +00:00
Suguru Inoue
b3e56437c8
refactor(editor): Migrate usages of workflowObject in canvas operations (#28128) 2026-04-15 10:34:00 +00:00
Milorad FIlipović
e5aaeb53a9
fix(core): Implement data tables name collision detection on pull (#26416)
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Nikhil Kuriakose <nikhilkuria@gmail.com>
Co-authored-by: Nikhil Kuriakose <nikhil.kuriakose@n8n.io>
2026-04-15 09:38:08 +00:00
Marcus
8b105cc0cf
feat(core): Support npm registry token authentication to install private community node packages (#28228)
Co-authored-by: Sandra Zollner <sandra.zollner@n8n.io>
2026-04-15 09:28:55 +00:00
Sandra Zollner
34430aedb1
fix(core): Fix public API package update process (#28475) 2026-04-15 09:04:39 +00:00
RomanDavydchuk
30128c9254
fix(Google Drive Node): Continue on error support for download file operation (#28276)
Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com>
2026-04-15 08:41:15 +00:00
Albert Alises
e20f8e91ce
feat(editor): Add admin toggle for computer use in AI settings (no-changelog) (#28452) 2026-04-15 08:08:39 +00:00
Charlie Kolb
f216fda511
fix(editor): Refine resource dependency badge (#28087) 2026-04-15 07:54:26 +00:00
Suguru Inoue
5368851506
refactor(editor): Migrate Workflow class usages in Vue props and function arguments (#28393) 2026-04-15 07:30:49 +00:00
Matsu
80de266be4
ci: Account for pnpm-workspace changes in bump-versions.mjs (#28503) 2026-04-15 07:02:10 +00:00
Matsu
57af37fc61
chore: Migrate @n8n/stylelint-config to Vitest (#28405) 2026-04-15 06:06:03 +00:00
Matsu
229256ee7c
chore: Migrate @n8n/api-types to Vitest (#28394) 2026-04-15 06:05:11 +00:00
Albert Alises
bb7d137cf7
fix(editor): Display placeholder sentinels as hint text in setup wizard (#28482) 2026-04-14 16:36:28 +00:00
Milorad FIlipović
62dc073b3d
fix(core): Fix workflow-sdk validation for plain workflow objects (#28416) 2026-04-14 16:29:20 +00:00
Dawid Myslak
3f57f1cc19
refactor(core): Rename AI Gateway credits to wallet with USD amounts (#28436) 2026-04-14 15:29:13 +00:00
Dimitri Lavrenük
819e707a61
feat: Simplify user consent flow for computer-use (no-changelog) (#28266) 2026-04-14 15:13:08 +00:00
Albert Alises
04d57c5fd6
fix(editor): Prevent setup wizard disappearing on requestId-driven remount (#28473) 2026-04-14 14:58:39 +00:00
Dawid Myslak
bd927d9350
feat(MiniMax Chat Model Node): Add MiniMax Chat Model sub-node (#28305) 2026-04-14 14:29:50 +00:00
Csaba Tuncsik
1042350f4e
fix(editor): Reset OIDC form dirty state after saving IdP settings (#28388) 2026-04-14 14:21:49 +00:00
Albert Alises
f54608e6e4
refactor(ai-builder): Consolidate native tools into 10 action families (no-changelog) (#28140) 2026-04-14 14:00:41 +00:00
Csaba Tuncsik
9c97931ca0
fix(editor): Only show role assignment warning modal when value actually changed (#28387) 2026-04-14 13:32:44 +00:00
Stephen Wright
ac41112731
fix(core): Enforce credential access checks in dynamic node parameter requests (#28446) 2026-04-14 13:23:41 +00:00
Bernhard Wittmann
2959b4dc2a
fix(core): Skip npm outdated check for verified-only community packages (#28335) 2026-04-14 13:09:13 +00:00
James Gee
36261fbe7a
feat(core): Configure OIDC settings via env vars (#28185)
Signed-off-by: James Gee <1285296+geemanjs@users.noreply.github.com>
Co-authored-by: Irénée <irenee.ajeneza@n8n.io>
Co-authored-by: Ali Elkhateeb <ali.elkhateeb@n8n.io>
2026-04-14 13:06:22 +00:00
Jaakko Husso
e849041c11
fix(core): Make workflow preview refresh after setup completes (no-changelog) (#28468) 2026-04-14 12:41:20 +00:00
Ali Elkhateeb
fa3299d042
fix(core): Handle git fetch failure during source control startup (#28422)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:40:06 +00:00
Sandra Zollner
24015b3449
feat(core): Project based data table creation and transfer (#28323) 2026-04-14 12:38:44 +00:00
Stephen Wright
59edd6ae54
feat: Add deployment_key table, entity, repository, and migration (#28329) 2026-04-14 12:20:22 +00:00
krisn0x
ca871cc10a
feat(core): Support npm dist-tags in community node installation (#28067)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 12:18:28 +00:00
yehorkardash
39189c3985
fix: Update working memory using tools (#28467) 2026-04-14 11:45:57 +00:00
Jaakko Husso
9ef55ca4f9
feat(core): Instance AI preview tags and command bar improvements (no-changelog) (#28383) 2026-04-14 11:38:00 +00:00
Charlie Kolb
90a3f460f1
feat(editor): Support showing full label in tooltip on hover of dropdown menu items (no-changelog) (#28231)
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-04-14 11:33:13 +00:00
Svetoslav Dekov
00b0558c2b
fix(editor): Hide setup parameter issue icons until user interacts with input (#28010)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:14:37 +00:00
Jaakko Husso
094a5b403e
fix(core): Switch to latest artifact when it updates / new one is created (no-changelog) (#28461) 2026-04-14 11:04:02 +00:00
Elias Meire
c9cab112f9
fix(editor): Show relevant node in workflow activation errors (#26691) 2026-04-14 11:03:50 +00:00
Matsu
dcbc3f14bd
chore: Bump axios to 1.15.0 (#28460) 2026-04-14 10:49:05 +00:00
Charlie Kolb
69a62e0906
docs: Add migration timestamp guidance to @n8n/db AGENTS.md (no-changelog) (#28444)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-04-14 10:40:42 +00:00
RomanDavydchuk
357fb7210a
fix(GraphQL Node): Improve error response handling (#28209) 2026-04-14 10:12:48 +00:00
Danny Martini
98b833a07d
fix(core): Resolve additional keys lazily in VM expression engine (#28430)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
2026-04-14 09:10:20 +00:00
Charlie Kolb
b1a075f760
feat(editor): Add favoriting for projects, folders, workflows and data tables (#26228)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
2026-04-14 09:09:00 +00:00
Matsu
d6fbe5f847
ci: Run lint:styles as a part of reusable linting workflow (#28449) 2026-04-14 08:44:48 +00:00
Matsu
d496f6f1bd
ci: Replace docker/login-action with retry-wrapped docker login for DockerHub (#28442)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 08:44:12 +00:00
oleg
bd9713bd67
feat(instance-ai): Add Brave Search and Daytona credential types (no-changelog) (#28420)
Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
2026-04-14 08:15:36 +00:00
Luca Mattiazzi
9078bb2306
feat(ai-builder): Add a binary check to avoid code import in code blocks (no-changelog) (#28382)
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-04-14 08:02:41 +00:00
Mutasem Aldmour
433370dc2f
test: Add isolated local Playwright runner (#28427)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:57:24 +00:00
Jaakko Husso
bbc3230dcf
chore: Suppress warning from style lint (#28426) 2026-04-14 07:54:05 +00:00
Albert Alises
3c850f2711
fix(ai-builder): Increase orchestrator max steps from default 5 to 60 (#28429) 2026-04-14 07:51:51 +00:00
Dimitri Lavrenük
b48aeef1f2
fix: Block concurring connection requests in computer use (no-changelog) (#28312) 2026-04-14 07:29:25 +00:00
Andreas Fitzek
e8360a497d
feat(core): Add instance registry service (no-changelog) (#27731) 2026-04-14 06:57:35 +00:00
Bernhard Wittmann
5f8ab01f9b
fix(Schedule Node): Use elapsed-time check to self-heal after missed triggers (#28423) 2026-04-13 15:44:42 +00:00
James Gee
9a22fe5a25
feat(core): Workflow tracing - add workflow version id (#28424) 2026-04-13 15:25:44 +00:00
Jon
ca71d89d88
fix(core): Handle invalid percent sequences and equals signs in HTTP response headers (#27691)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 15:17:33 +00:00
Garrit Franke
550409923a
feat(core): Add require-node-description-fields ESLint rule for icon and subtitle (#28400) 2026-04-13 14:55:17 +00:00
n8n-release-tag-merge[bot]
60503b60b1 Merge tag 'n8n@2.17.0' 2026-04-13 15:10:41 +00:00
Mutasem Aldmour
df5855d4c6
test(editor): Add comprehensive instance AI e2e tests (#28326)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:40:15 +00:00
Irénée
1108467f44
feat: Enable security policy settings via env vars (#28321) 2026-04-13 14:09:06 +00:00
Albert Alises
39c6217109
fix(ai-builder): Use placeholders for user-provided values instead of hardcoding fake addresses (#28407) 2026-04-13 13:29:31 +00:00
Ali Elkhateeb
6217d08ce9
fix(core): Skip disabled Azure Key Vault secrets and handle partial fetch failures (#28325) 2026-04-13 13:23:38 +00:00
1144 changed files with 49378 additions and 17514 deletions

View file

@ -39,10 +39,13 @@ runs:
- name: Login to DockerHub
if: inputs.login-dockerhub == 'true'
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
username: ${{ inputs.dockerhub-username }}
password: ${{ inputs.dockerhub-password }}
shell: bash
env:
DOCKER_USER: ${{ inputs.dockerhub-username }}
DOCKER_PASS: ${{ inputs.dockerhub-password }}
run: |
node .github/scripts/retry.mjs --attempts 3 --delay 10 \
'echo "$DOCKER_PASS" | docker login -u "$DOCKER_USER" --password-stdin'
- name: Login to DHI Registry
if: inputs.login-dhi == 'true'

View file

@ -1,4 +1,5 @@
import semver from 'semver';
import { parse } from 'yaml';
import { writeFile, readFile } from 'fs/promises';
import { resolve } from 'path';
import child_process from 'child_process';
@ -7,14 +8,19 @@ import assert from 'assert';
const exec = promisify(child_process.exec);
/**
* @param {string | semver.SemVer} currentVersion
*/
function generateExperimentalVersion(currentVersion) {
const parsed = semver.parse(currentVersion);
if (!parsed) throw new Error(`Invalid version: ${currentVersion}`);
// Check if it's already an experimental version
if (parsed.prerelease.length > 0 && parsed.prerelease[0] === 'exp') {
const minor = parsed.prerelease[1] || 0;
const minorInt = typeof minor === 'string' ? parseInt(minor) : minor;
// Increment the experimental minor version
const expMinor = (parsed.prerelease[1] || 0) + 1;
const expMinor = minorInt + 1;
return `${parsed.major}.${parsed.minor}.${parsed.patch}-exp.${expMinor}`;
}
@ -23,7 +29,10 @@ function generateExperimentalVersion(currentVersion) {
}
const rootDir = process.cwd();
const releaseType = process.env.RELEASE_TYPE;
const releaseType = /** @type { import('semver').ReleaseType | "experimental" } */ (
process.env.RELEASE_TYPE
);
assert.match(releaseType, /^(patch|minor|major|experimental|premajor)$/, 'Invalid RELEASE_TYPE');
// TODO: if releaseType is `auto` determine release type based on the changelog
@ -39,8 +48,12 @@ const packages = JSON.parse(
const packageMap = {};
for (let { name, path, version, private: isPrivate } of packages) {
if (isPrivate && path !== rootDir) continue;
if (path === rootDir) name = 'monorepo-root';
if (isPrivate && path !== rootDir) {
continue;
}
if (path === rootDir) {
name = 'monorepo-root';
}
const isDirty = await exec(`git diff --quiet HEAD ${lastTag} -- ${path}`)
.then(() => false)
@ -57,11 +70,94 @@ assert.ok(
// Propagate isDirty transitively: if a package's dependency will be bumped,
// that package also needs a bump (e.g. design-system → editor-ui → cli).
// Detect root-level changes that affect resolved dep versions without touching individual
// package.json files: pnpm.overrides (applies to all specifiers)
// and pnpm-workspace.yaml catalog entries (applies only to deps using a "catalog:…" specifier).
const rootPkgJson = JSON.parse(await readFile(resolve(rootDir, 'package.json'), 'utf-8'));
const rootPkgJsonAtTag = await exec(`git show ${lastTag}:package.json`)
.then(({ stdout }) => JSON.parse(stdout))
.catch(() => ({}));
const getOverrides = (pkg) => ({ ...pkg.pnpm?.overrides, ...pkg.overrides });
const currentOverrides = getOverrides(rootPkgJson);
const previousOverrides = getOverrides(rootPkgJsonAtTag);
const changedOverrides = new Set(
Object.keys({ ...currentOverrides, ...previousOverrides }).filter(
(k) => currentOverrides[k] !== previousOverrides[k],
),
);
const parseWorkspaceYaml = (content) => {
try {
return /** @type {Record<string, unknown>} */ (parse(content) ?? {});
} catch {
return {};
}
};
const workspaceYaml = parseWorkspaceYaml(
await readFile(resolve(rootDir, 'pnpm-workspace.yaml'), 'utf-8').catch(() => ''),
);
const workspaceYamlAtTag = parseWorkspaceYaml(
await exec(`git show ${lastTag}:pnpm-workspace.yaml`)
.then(({ stdout }) => stdout)
.catch(() => ''),
);
const getCatalogs = (ws) => {
const result = new Map();
if (ws.catalog) {
result.set('default', /** @type {Record<string,string>} */ (ws.catalog));
}
for (const [name, entries] of Object.entries(ws.catalogs ?? {})) {
result.set(name, entries);
}
return result;
};
// changedCatalogEntries: Map<catalogName, Set<depName>>
const currentCatalogs = getCatalogs(workspaceYaml);
const previousCatalogs = getCatalogs(workspaceYamlAtTag);
const changedCatalogEntries = new Map();
for (const catalogName of new Set([...currentCatalogs.keys(), ...previousCatalogs.keys()])) {
const current = currentCatalogs.get(catalogName) ?? {};
const previous = previousCatalogs.get(catalogName) ?? {};
const changedDeps = new Set(
Object.keys({ ...current, ...previous }).filter((dep) => current[dep] !== previous[dep]),
);
if (changedDeps.size > 0) {
changedCatalogEntries.set(catalogName, changedDeps);
}
}
// Store full dep objects (with specifiers) so we can inspect "catalog:…" values below.
const depsByPackage = {};
for (const packageName in packageMap) {
const packageFile = resolve(packageMap[packageName].path, 'package.json');
const packageJson = JSON.parse(await readFile(packageFile, 'utf-8'));
depsByPackage[packageName] = Object.keys(packageJson.dependencies || {});
depsByPackage[packageName] = /** @type {Record<string,string>} */ (
packageJson.dependencies ?? {}
);
}
// Mark packages dirty if any dep had a root-level override or catalog version change.
for (const [packageName, deps] of Object.entries(depsByPackage)) {
if (packageMap[packageName].isDirty) continue;
for (const [dep, specifier] of Object.entries(deps)) {
if (changedOverrides.has(dep)) {
packageMap[packageName].isDirty = true;
break;
}
if (typeof specifier === 'string' && specifier.startsWith('catalog:')) {
const catalogName = specifier === 'catalog:' ? 'default' : specifier.slice(8);
if (changedCatalogEntries.get(catalogName)?.has(dep)) {
packageMap[packageName].isDirty = true;
break;
}
}
}
}
let changed = true;
@ -69,7 +165,7 @@ while (changed) {
changed = false;
for (const packageName in packageMap) {
if (packageMap[packageName].isDirty) continue;
if (depsByPackage[packageName].some((dep) => packageMap[dep]?.isDirty)) {
if (Object.keys(depsByPackage[packageName]).some((dep) => packageMap[dep]?.isDirty)) {
packageMap[packageName].isDirty = true;
changed = true;
}

View file

@ -11,7 +11,8 @@
"glob": "13.0.6",
"minimatch": "10.2.4",
"semver": "7.7.4",
"tempfile": "6.0.1"
"tempfile": "6.0.1",
"yaml": "^2.8.3"
},
"devDependencies": {
"conventional-changelog-angular": "8.3.0"

View file

@ -32,6 +32,9 @@ importers:
tempfile:
specifier: 6.0.1
version: 6.0.1
yaml:
specifier: ^2.8.3
version: 2.8.3
devDependencies:
conventional-changelog-angular:
specifier: 8.3.0
@ -292,6 +295,11 @@ packages:
wordwrap@1.0.0:
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
yaml@2.8.3:
resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==}
engines: {node: '>= 14.6'}
hasBin: true
snapshots:
'@actions/github@9.0.0':
@ -540,3 +548,5 @@ snapshots:
walk-up-path@4.0.0: {}
wordwrap@1.0.0: {}
yaml@2.8.3: {}

25
.github/scripts/pnpm-utils.mjs vendored Normal file
View file

@ -0,0 +1,25 @@
import child_process from 'child_process';
import { promisify } from 'node:util';
const exec = promisify(child_process.exec);
/**
* @typedef PnpmPackage
* @property { string } name
* @property { string } version
* @property { string } path
* @property { boolean } private
* */
/**
* @returns { Promise<PnpmPackage[]> }
* */
export async function getMonorepoProjects() {
return JSON.parse(
(
await exec(
`pnpm ls -r --only-projects --json | jq -r '[.[] | { name: .name, version: .version, path: .path, private: .private}]'`,
)
).stdout,
);
}

View file

@ -0,0 +1,28 @@
import { trySh } from './github-helpers.mjs';
import { getMonorepoProjects } from './pnpm-utils.mjs';
async function setLatestForMonorepoPackages() {
const packages = await getMonorepoProjects();
const publishedPackages = packages //
.filter((pkg) => !pkg.private)
.filter((pkg) => pkg.version);
for (const pkg of publishedPackages) {
const versionName = `${pkg.name}@${pkg.version}`;
const res = trySh('npm', ['dist-tag', 'add', versionName, 'latest']);
if (res.ok) {
console.log(`Set ${versionName} as latest`);
} else {
console.warn(`Update failed for ${versionName}`);
}
}
}
// only run when executed directly, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) {
setLatestForMonorepoPackages().catch((err) => {
console.error(err);
process.exit(1);
});
}

View file

@ -48,6 +48,7 @@ jobs:
# by checking the checkbox in the PR summary.
if: |
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
!contains(github.event.pull_request.labels.*.name, 'automation:backport') &&
!contains(github.event.pull_request.title, '(backport to')
runs-on: ubuntu-latest
@ -76,6 +77,7 @@ jobs:
# Allows for override via '/size-limit-override' comment
if: |
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
!contains(github.event.pull_request.labels.*.name, 'automation:backport') &&
!contains(github.event.pull_request.title, '(backport to')
runs-on: ubuntu-latest

View file

@ -27,6 +27,7 @@ jobs:
db: ${{ fromJSON(steps.ci-filter.outputs.results).db == true }}
performance: ${{ fromJSON(steps.ci-filter.outputs.results).performance == true }}
e2e_performance: ${{ fromJSON(steps.ci-filter.outputs.results)['e2e-performance'] == true }}
instance_ai_workflow_eval: ${{ fromJSON(steps.ci-filter.outputs.results)['instance-ai-workflow-eval'] == true }}
commit_sha: ${{ steps.commit-sha.outputs.sha }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -63,12 +64,19 @@ jobs:
performance:
packages/testing/performance/**
packages/workflow/src/**
packages/@n8n/expression-runtime/src/**
.github/workflows/test-bench-reusable.yml
e2e-performance:
packages/testing/playwright/tests/performance/**
packages/testing/playwright/utils/performance-helper.ts
packages/testing/containers/**
.github/workflows/test-e2e-performance-reusable.yml
instance-ai-workflow-eval:
packages/@n8n/instance-ai/src/**
packages/@n8n/instance-ai/evaluations/**
packages/cli/src/modules/instance-ai/**
packages/core/src/execution-engine/eval-mock-helpers.ts
.github/workflows/test-evals-instance-ai*.yml
db:
packages/cli/src/databases/**
packages/cli/src/modules/*/database/**
@ -195,6 +203,18 @@ jobs:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
secrets: inherit
instance-ai-workflow-evals:
name: Instance AI Workflow Evals
needs: install-and-build
if: >-
needs.install-and-build.outputs.instance_ai_workflow_eval == 'true' &&
github.repository == 'n8n-io/n8n' &&
(github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork)
uses: ./.github/workflows/test-evals-instance-ai.yml
with:
branch: ${{ needs.install-and-build.outputs.commit_sha }}
secrets: inherit
# This job is required by GitHub branch protection rules.
# PRs cannot be merged unless this job passes.
required-checks:

View file

@ -4,6 +4,7 @@ on:
pull_request:
branches:
- master
- 1.x
permissions:
pull-requests: write
@ -46,7 +47,7 @@ jobs:
`${marker}\n` +
`🚫 **Merge blocked**: PRs into \`${base}\` are only allowed from branches named \`bundle/*\`.\n\n` +
`Current source branch: \`${head}\`\n\n` +
`Merge your developments into a bundle branch instead of directly merging to master.`;
`Merge your developments into a bundle branch instead of directly merging to master or 1.x.`;
// Find an existing marker comment (to update instead of spamming)
const { data: comments } = await github.rest.issues.listComments({
@ -79,7 +80,7 @@ jobs:
env:
HEAD_REF: ${{ github.head_ref }}
run: |
echo "::error::You can only merge to master from a bundle/* branch. Got '$HEAD_REF'."
echo "::error::You can only merge to master and 1.x from a bundle/* branch. Got '$HEAD_REF'."
exit 1
- name: Allowed

View file

@ -3,7 +3,7 @@ name: 'Release: Create Minor Release PR'
on:
workflow_dispatch:
schedule:
- cron: 0 13 * * 1 # 2pm CET (UTC+1), Monday
- cron: 0 8 * * 2 # 9am CET (UTC+1), Tuesday
jobs:
create-release-pr:

View file

@ -66,6 +66,14 @@ jobs:
uses: ./.github/workflows/util-ensure-release-candidate-branches.yml
secrets: inherit
ensure-correct-latest-version-on-npm:
name: Ensure correct latest version on npm
if: |
inputs.bump == 'minor' ||
inputs.track == 'stable'
uses: ./.github/workflows/release-set-stable-npm-packages-to-latest.yml
secrets: inherit
populate-cloud-with-releases:
name: 'Populate cloud database with releases'
uses: ./.github/workflows/release-populate-cloud-with-releases.yml

View file

@ -84,7 +84,7 @@ jobs:
- name: Publish other packages to NPM
env:
PUBLISH_BRANCH: ${{ github.event.pull_request.base.ref }}
PUBLISH_TAG: ${{ needs.determine-version-info.outputs.track == 'stable' && 'latest' || needs.determine-version-info.outputs.track }}
PUBLISH_TAG: ${{ needs.determine-version-info.outputs.track }}
run: |
# Prefix version-like track names (e.g. "1", "v1") to avoid npm rejecting them as semver ranges
if [[ "$PUBLISH_TAG" =~ ^v?[0-9] ]]; then

View file

@ -3,7 +3,7 @@ name: 'Release: Schedule Patch Release PRs'
on:
workflow_dispatch:
schedule:
- cron: '0 8 * * 2-5' # 9am CET (UTC+1), TuesdayFriday
- cron: '0 8 * * 3-5,1' # 9am CET (UTC+1), Wednesday - Friday and Monday. (Minor release on tuesday)
jobs:
create-patch-prs:

View file

@ -0,0 +1,35 @@
name: 'Release: Set stable npm packages to latest'
on:
workflow_call:
workflow_dispatch:
permissions:
contents: write
jobs:
promote-github-releases:
name: Promote current stable releases as latest
runs-on: ubuntu-slim
environment: release
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: refs/tags/stable
fetch-depth: 1
- name: Setup NodeJS
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace
# Remove after https://github.com/npm/cli/issues/8547 gets resolved
- run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Set npm packages to latest
run: node ./.github/scripts/set-latest-for-monorepo-packages.mjs

View file

@ -0,0 +1,141 @@
name: 'Test: Instance AI Exec Evals'
on:
workflow_call:
inputs:
branch:
description: 'GitHub branch to test'
required: false
type: string
default: 'master'
filter:
description: 'Filter test cases by name (e.g. "contact-form")'
required: false
type: string
default: ''
workflow_dispatch:
inputs:
branch:
description: 'GitHub branch to test'
required: false
default: 'master'
filter:
description: 'Filter test cases by name (e.g. "contact-form")'
required: false
default: ''
jobs:
run-evals:
name: 'Run Evals'
runs-on: blacksmith-4vcpu-ubuntu-2204
timeout-minutes: 45
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.branch || github.ref }}
fetch-depth: 1
- name: Setup Environment
uses: ./.github/actions/setup-nodejs
with:
build-command: 'pnpm build'
- name: Build Docker image
run: pnpm build:docker
env:
INCLUDE_TEST_CONTROLLER: 'true'
- name: Start n8n container
run: |
docker run -d --name n8n-eval \
-e E2E_TESTS=true \
-e N8N_ENABLED_MODULES=instance-ai \
-e N8N_AI_ENABLED=true \
-e N8N_INSTANCE_AI_MODEL_API_KEY=${{ secrets.EVALS_ANTHROPIC_KEY }} \
-e N8N_LICENSE_ACTIVATION_KEY=${{ secrets.N8N_LICENSE_ACTIVATION_KEY }} \
-e N8N_LICENSE_CERT=${{ secrets.N8N_LICENSE_CERT }} \
-e N8N_ENCRYPTION_KEY=${{ secrets.N8N_ENCRYPTION_KEY }} \
-p 5678:5678 \
n8nio/n8n:local
echo "Waiting for n8n to be ready..."
for i in $(seq 1 60); do
if curl -s http://localhost:5678/healthz/readiness -o /dev/null -w "%{http_code}" | grep -q 200; then
echo "n8n ready after ${i}s"
exit 0
fi
sleep 1
done
echo "::error::n8n failed to start within 60s"
docker logs n8n-eval --tail 30
exit 1
- name: Create test user
run: |
curl -sf -X POST http://localhost:5678/rest/e2e/reset \
-H "Content-Type: application/json" \
-d '{
"owner":{"email":"nathan@n8n.io","password":"PlaywrightTest123","firstName":"Eval","lastName":"Owner"},
"admin":{"email":"admin@n8n.io","password":"PlaywrightTest123","firstName":"Admin","lastName":"User"},
"members":[],
"chat":{"email":"chat@n8n.io","password":"PlaywrightTest123","firstName":"Chat","lastName":"User"}
}'
- name: Run Instance AI Evals
continue-on-error: true
working-directory: packages/@n8n/instance-ai
run: >-
pnpm eval:instance-ai
--base-url http://localhost:5678
--verbose
${{ inputs.filter && format('--filter "{0}"', inputs.filter) || '' }}
env:
N8N_INSTANCE_AI_MODEL_API_KEY: ${{ secrets.EVALS_ANTHROPIC_KEY }}
- name: Stop n8n container
if: ${{ always() }}
run: docker stop n8n-eval && docker rm n8n-eval || true
- name: Post eval results to PR
if: ${{ always() && github.event_name == 'pull_request' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RESULTS_FILE="packages/@n8n/instance-ai/eval-results.json"
if [ ! -f "$RESULTS_FILE" ]; then
echo "No eval results file found"
exit 0
fi
# Build the full comment body with jq
jq -r '
"### Instance AI Workflow Eval Results\n\n" +
"**\(.summary.built)/\(.summary.testCases) built | \(.summary.scenariosPassed)/\(.summary.scenariosTotal) passed (\(.summary.passRate * 100 | floor)%)**\n\n" +
"| Workflow | Build | Passed |\n|---|---|---|\n" +
([.testCases[] | "| \(.name) | \(if .built then "✅" else "❌" end) | \([.scenarios[] | select(.passed)] | length)/\(.scenarios | length) |"] | join("\n")) +
"\n\n<details><summary>Failure details</summary>\n\n" +
([.testCases[].scenarios[] | select(.passed == false) | "**\(.name)** \(if .failureCategory then "[\(.failureCategory)]" else "" end)\n> \(.reasoning | .[0:200])\n"] | join("\n")) +
"\n</details>"
' "$RESULTS_FILE" > /tmp/eval-comment.md
# Find and update existing eval comment, or create new one
COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \
--jq '.[] | select(.body | startswith("### Instance AI Workflow Eval")) | .id' | tail -1)
if [ -n "$COMMENT_ID" ]; then
gh api "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" -X PATCH -F body=@/tmp/eval-comment.md
else
gh pr comment "${{ github.event.pull_request.number }}" --body-file /tmp/eval-comment.md
fi
- name: Upload Results
if: ${{ always() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: instance-ai-workflow-eval-results
path: packages/@n8n/instance-ai/eval-results.json
retention-days: 14

View file

@ -29,5 +29,5 @@ jobs:
- name: Build and Test
uses: ./.github/actions/setup-nodejs
with:
build-command: pnpm lint
build-command: pnpm lint:ci
node-version: ${{ inputs.nodeVersion }}

View file

@ -20,7 +20,8 @@ jobs:
cloudflare:
name: Cloudflare Pages
if: |
!contains(github.event.pull_request.labels.*.name, 'community')
!contains(github.event.pull_request.labels.*.name, 'community') &&
github.repository == 'n8n-io/n8n'
runs-on: blacksmith-2vcpu-ubuntu-2204
permissions:
contents: read

View file

@ -18,7 +18,8 @@ jobs:
name: Post Metrics Comment
if: >-
github.event_name == 'pull_request' &&
!github.event.pull_request.head.repo.fork
!github.event.pull_request.head.repo.fork &&
github.repository == 'n8n-io/n8n'
runs-on: ubuntu-slim
continue-on-error: true
permissions:

2
.gitignore vendored
View file

@ -33,6 +33,8 @@ test-results.json
*.0x
packages/testing/playwright/playwright-report
packages/testing/playwright/test-results
packages/testing/playwright/eval-results.json
packages/@n8n/instance-ai/eval-results.json
packages/testing/playwright/.playwright-browsers
packages/testing/playwright/.playwright-cli
test-results/

View file

@ -81,6 +81,7 @@ The monorepo is organized into these key packages:
- **`packages/@n8n/i18n`**: Internationalization for UI text
- **`packages/nodes-base`**: Built-in nodes for integrations
- **`packages/@n8n/nodes-langchain`**: AI/LangChain nodes
- **`packages/@n8n/instance-ai`**: "AI Assistant" in the UI, "Instance AI" in code — AI assistant backend. See its `CLAUDE.md` for architecture docs.
- **`@n8n/design-system`**: Vue component library for UI consistency
- **`@n8n/config`**: Centralized configuration management

View file

@ -1,3 +1,87 @@
# [2.18.0](https://github.com/n8n-io/n8n/compare/n8n@2.17.0...n8n@2.18.0) (2026-04-21)
### Bug Fixes
* **ai-builder:** Increase orchestrator max steps from default 5 to 60 ([#28429](https://github.com/n8n-io/n8n/issues/28429)) ([3c850f2](https://github.com/n8n-io/n8n/commit/3c850f2711d53ded62a3540c67b9ec02143cbb3f))
* **ai-builder:** Scope artifacts panel to resources produced in-thread ([#28678](https://github.com/n8n-io/n8n/issues/28678)) ([7b3696f](https://github.com/n8n-io/n8n/commit/7b3696f3f7d95ab3cbaeb8ca58fdc74264a83b52))
* **ai-builder:** Use placeholders for user-provided values instead of hardcoding fake addresses ([#28407](https://github.com/n8n-io/n8n/issues/28407)) ([39c6217](https://github.com/n8n-io/n8n/commit/39c62171092618149fa67ccb9a384a5a3aadd4e8))
* **Alibaba Cloud Chat Model Node:** Add credential-level url field for AI gateway compatibility ([#28697](https://github.com/n8n-io/n8n/issues/28697)) ([dd6c28c](https://github.com/n8n-io/n8n/commit/dd6c28c6d16274354b83d3cc6a731f2f7a859a14))
* **ClickUp Node:** Unclear error message when using OAuth credentials ([#28584](https://github.com/n8n-io/n8n/issues/28584)) ([19aadf1](https://github.com/n8n-io/n8n/commit/19aadf19f753d64cc2cd80af3c5b3dd957a4ede7))
* **core:** Add required field validation to MCP OAuth client registration ([#28490](https://github.com/n8n-io/n8n/issues/28490)) ([8716316](https://github.com/n8n-io/n8n/commit/87163163e67001f69a2a2d7b4a650e0511614d62))
* **core:** Cascade-cancel dependent planned tasks when a parent task fails ([#28656](https://github.com/n8n-io/n8n/issues/28656)) ([35f9bed](https://github.com/n8n-io/n8n/commit/35f9bed4de39350717192d9f272ad044ad50b323))
* **core:** Enforce credential access checks in dynamic node parameter requests ([#28446](https://github.com/n8n-io/n8n/issues/28446)) ([ac41112](https://github.com/n8n-io/n8n/commit/ac411127314921aaf82b7b97d76eeaa2703b708c))
* **core:** Ensure single zod instance across workspace packages ([#28604](https://github.com/n8n-io/n8n/issues/28604)) ([107c48f](https://github.com/n8n-io/n8n/commit/107c48f65c10d26f8f01d1bee5d2eb77b9d26084))
* **core:** Filter stale credentials from setup wizard requests ([#28478](https://github.com/n8n-io/n8n/issues/28478)) ([657bdf1](https://github.com/n8n-io/n8n/commit/657bdf136fd0fc01cee3629baf65e130ee80840a))
* **core:** Fix public API package update process ([#28475](https://github.com/n8n-io/n8n/issues/28475)) ([34430ae](https://github.com/n8n-io/n8n/commit/34430aedb15fa5305be475582e04f08967415e38))
* **core:** Fix workflow-sdk validation for plain workflow objects ([#28416](https://github.com/n8n-io/n8n/issues/28416)) ([62dc073](https://github.com/n8n-io/n8n/commit/62dc073b3d954dc885359962c02ae8aa84d17c43))
* **core:** Guard against undefined config properties in credential overwrites ([#28573](https://github.com/n8n-io/n8n/issues/28573)) ([77d27bc](https://github.com/n8n-io/n8n/commit/77d27bc826e4e91c2c589a62cbb6b997cacccd16))
* **core:** Handle git fetch failure during source control startup ([#28422](https://github.com/n8n-io/n8n/issues/28422)) ([fa3299d](https://github.com/n8n-io/n8n/commit/fa3299d0425dfa2eaeaca6732dc46e0181e6fd68))
* **core:** Handle invalid percent sequences and equals signs in HTTP response headers ([#27691](https://github.com/n8n-io/n8n/issues/27691)) ([ca71d89](https://github.com/n8n-io/n8n/commit/ca71d89d885d01f8663e29a2a5b1f06c713aede8))
* **core:** Implement data tables name collision detection on pull ([#26416](https://github.com/n8n-io/n8n/issues/26416)) ([e5aaeb5](https://github.com/n8n-io/n8n/commit/e5aaeb53a93c63a04978e2a6eb7aa7255fcf510b))
* **core:** Prevent nodes tool crash on flattened required fields ([#28670](https://github.com/n8n-io/n8n/issues/28670)) ([3e72430](https://github.com/n8n-io/n8n/commit/3e724303c537739319e91f8bcaf7070fe105ffc7))
* **core:** Resolve additional keys lazily in VM expression engine ([#28430](https://github.com/n8n-io/n8n/issues/28430)) ([98b833a](https://github.com/n8n-io/n8n/commit/98b833a07d6d0f705633d7cb48298ee953688bd1))
* **core:** Skip disabled Azure Key Vault secrets and handle partial fetch failures ([#28325](https://github.com/n8n-io/n8n/issues/28325)) ([6217d08](https://github.com/n8n-io/n8n/commit/6217d08ce9b53d6fd5277fa0708ed13d36e0e934))
* **core:** Skip npm outdated check for verified-only community packages ([#28335](https://github.com/n8n-io/n8n/issues/28335)) ([2959b4d](https://github.com/n8n-io/n8n/commit/2959b4dc2a6cfd3733cc83bd6442dddd4cff08d2))
* Disable axios built-in proxy for OAuth2 token requests ([#28513](https://github.com/n8n-io/n8n/issues/28513)) ([56f36a6](https://github.com/n8n-io/n8n/commit/56f36a6d1961d95780fb8258e8876d7d512503c2))
* **editor:** Advance wizard step on Continue instead of applying setup ([#28698](https://github.com/n8n-io/n8n/issues/28698)) ([3b15e47](https://github.com/n8n-io/n8n/commit/3b15e470b54b13e9fe68e81c81a757c06b264783))
* **editor:** Center sub-node icons and refresh triggers panel icons ([#28515](https://github.com/n8n-io/n8n/issues/28515)) ([6739856](https://github.com/n8n-io/n8n/commit/6739856aa32689b43d143ae4909e1f3d85dc4106))
* **editor:** Display placeholder sentinels as hint text in setup wizard ([#28482](https://github.com/n8n-io/n8n/issues/28482)) ([bb7d137](https://github.com/n8n-io/n8n/commit/bb7d137cf735bcdf65bbcf8ff58fa911d83121f5))
* **editor:** Gate Instance AI input while setup wizard is open ([#28685](https://github.com/n8n-io/n8n/issues/28685)) ([db83a95](https://github.com/n8n-io/n8n/commit/db83a95522957c10a3466f0b57944c8b8827347a))
* **editor:** Hide setup parameter issue icons until user interacts with input ([#28010](https://github.com/n8n-io/n8n/issues/28010)) ([00b0558](https://github.com/n8n-io/n8n/commit/00b0558c2b1ed6bc4d47a86cb1bfca8eb55a47bc))
* **editor:** Improve disabled Google sign-in button styling and tooltip alignment ([#28536](https://github.com/n8n-io/n8n/issues/28536)) ([e848230](https://github.com/n8n-io/n8n/commit/e8482309478eed05793dcaa4d82185936439663f))
* **editor:** Improve setup wizard placeholder detection and card completion scoping ([#28474](https://github.com/n8n-io/n8n/issues/28474)) ([d172113](https://github.com/n8n-io/n8n/commit/d17211342e4ee8c8ec89a9c918017884e2de0763))
* **editor:** Only show role assignment warning modal when value actually changed ([#28387](https://github.com/n8n-io/n8n/issues/28387)) ([9c97931](https://github.com/n8n-io/n8n/commit/9c97931ca06d407bec1c6a8bab510d206afba394))
* **editor:** Prevent setup wizard disappearing on requestId-driven remount ([#28473](https://github.com/n8n-io/n8n/issues/28473)) ([04d57c5](https://github.com/n8n-io/n8n/commit/04d57c5fd62a5b9a2e086a3f540b7f50a932b62d))
* **editor:** Re-initialize SSO store after login to populate OIDC redirect URL ([#28386](https://github.com/n8n-io/n8n/issues/28386)) ([21317b8](https://github.com/n8n-io/n8n/commit/21317b8945dec9169e36b7e5fdf867713018661d))
* **editor:** Refine resource dependency badge ([#28087](https://github.com/n8n-io/n8n/issues/28087)) ([f216fda](https://github.com/n8n-io/n8n/commit/f216fda511062a40199b986351693677ebb2919e))
* **editor:** Reset OIDC form dirty state after saving IdP settings ([#28388](https://github.com/n8n-io/n8n/issues/28388)) ([1042350](https://github.com/n8n-io/n8n/commit/1042350f4e0f6ed44b51a1d707de665f71437faa))
* **editor:** Reset remote values on credentials change ([#26282](https://github.com/n8n-io/n8n/issues/26282)) ([5e11197](https://github.com/n8n-io/n8n/commit/5e111975d4086c060ac3d29d07da7c00ea2103a1))
* **editor:** Resolve nodes stuck on loading after execution in instance-ai preview ([#28450](https://github.com/n8n-io/n8n/issues/28450)) ([c97c3b4](https://github.com/n8n-io/n8n/commit/c97c3b4d12e166091be9ea1de969a17d64c36ec2))
* **editor:** Restore WASM file paths for cURL import in HTTP Request node ([#28610](https://github.com/n8n-io/n8n/issues/28610)) ([51bc71e](https://github.com/n8n-io/n8n/commit/51bc71e897e2baaf729963bf0f373a73505aee43))
* **editor:** Show auth type selector in Instance AI workflow setup ([#28707](https://github.com/n8n-io/n8n/issues/28707)) ([1b13d32](https://github.com/n8n-io/n8n/commit/1b13d325f12a5a27d139c75164114ee41583a902))
* **editor:** Show relevant node in workflow activation errors ([#26691](https://github.com/n8n-io/n8n/issues/26691)) ([c9cab11](https://github.com/n8n-io/n8n/commit/c9cab112f99a5da2742012773450bf7721484c28))
* **Google Cloud Firestore Node:** Fix empty array serialization in jsonToDocument ([#28213](https://github.com/n8n-io/n8n/issues/28213)) ([7094395](https://github.com/n8n-io/n8n/commit/7094395cef8e71f767df6fa5e242cf2fa42366ed))
* **Google Drive Node:** Continue on error support for download file operation ([#28276](https://github.com/n8n-io/n8n/issues/28276)) ([30128c9](https://github.com/n8n-io/n8n/commit/30128c9254be2214e746e0158296c1f1bd8ab4d8))
* **Google Gemini Node:** Determine the file extention from MIME type for image and video operations ([#28616](https://github.com/n8n-io/n8n/issues/28616)) ([73659cb](https://github.com/n8n-io/n8n/commit/73659cb3e7eccd48a739829be0a4d7a6557ce4a1))
* **GraphQL Node:** Improve error response handling ([#28209](https://github.com/n8n-io/n8n/issues/28209)) ([357fb72](https://github.com/n8n-io/n8n/commit/357fb7210ab201e13e2d3256a7886cf382656f22))
* **HubSpot Node:** Rename HubSpot "App Token" auth to "Service Key" ([#28479](https://github.com/n8n-io/n8n/issues/28479)) ([8c3e692](https://github.com/n8n-io/n8n/commit/8c3e6921741f0e28ba28f8fb39797d5e19db71c9))
* **HubSpot Trigger Node:** Add missing property selectors ([#28595](https://github.com/n8n-io/n8n/issues/28595)) ([d179f66](https://github.com/n8n-io/n8n/commit/d179f667c0044fd246d8e8535cd3a741d3f96b6f))
* **IMAP Node:** Fix out-of-memory crash after ECONNRESET on reconnect ([#28290](https://github.com/n8n-io/n8n/issues/28290)) ([2d0b231](https://github.com/n8n-io/n8n/commit/2d0b231e31f265f39dd95d6794bd74d9b5592056))
* Link to n8n website broken in n8n forms ([#28627](https://github.com/n8n-io/n8n/issues/28627)) ([ff950e5](https://github.com/n8n-io/n8n/commit/ff950e5840214c515d413b45f174d9638a51dd39))
* **LinkedIn Node:** Update LinkedIn API version in request headers ([#28564](https://github.com/n8n-io/n8n/issues/28564)) ([25e07ca](https://github.com/n8n-io/n8n/commit/25e07cab5a66b04960753055131d355e0323d971))
* **OpenAI Node:** Replace hardcoded models with RLC ([#28226](https://github.com/n8n-io/n8n/issues/28226)) ([4070930](https://github.com/n8n-io/n8n/commit/4070930e4c080c634df9b241175941c48afed9dc))
* **Schedule Node:** Use elapsed-time check to self-heal after missed triggers ([#28423](https://github.com/n8n-io/n8n/issues/28423)) ([5f8ab01](https://github.com/n8n-io/n8n/commit/5f8ab01f9bb26f4d27f6f882fe1024f27caf4d67))
* Update working memory using tools ([#28467](https://github.com/n8n-io/n8n/issues/28467)) ([39189c3](https://github.com/n8n-io/n8n/commit/39189c39859fbb4c1562a03ae3e6cd29195f7d1d))
### Features
* Add deployment_key table, entity, repository, and migration ([#28329](https://github.com/n8n-io/n8n/issues/28329)) ([59edd6a](https://github.com/n8n-io/n8n/commit/59edd6ae5421aa6be34ee009a3024e0ca9843467))
* Add Prometheus counters for token exchange ([#28453](https://github.com/n8n-io/n8n/issues/28453)) ([c6534fa](https://github.com/n8n-io/n8n/commit/c6534fa0b389a394e7591d3fc5ec565409279004))
* AI Gateway credentials endpoint instance url ([#28520](https://github.com/n8n-io/n8n/issues/28520)) ([d012346](https://github.com/n8n-io/n8n/commit/d012346c777455de5bde9cab218f0c4f2d712fa0))
* **API:** Add missing credential endpoints (GET by ID and test) ([#28519](https://github.com/n8n-io/n8n/issues/28519)) ([9a65549](https://github.com/n8n-io/n8n/commit/9a65549575bb201c3f55888d71e04663f622eb5b))
* **core:** Add `require-node-description-fields` ESLint rule for icon and subtitle ([#28400](https://github.com/n8n-io/n8n/issues/28400)) ([5504099](https://github.com/n8n-io/n8n/commit/550409923a3d8d6961648674024eabb0d0749cfc))
* **core:** Add KeyManagerService for encryption key lifecycle management ([#28533](https://github.com/n8n-io/n8n/issues/28533)) ([9dd3e59](https://github.com/n8n-io/n8n/commit/9dd3e59acb6eb94bb38ffe01677ea1c9a108d87b))
* **core:** Configure OIDC settings via env vars ([#28185](https://github.com/n8n-io/n8n/issues/28185)) ([36261fb](https://github.com/n8n-io/n8n/commit/36261fbe7ad55a7b3bcc19809b6decb401b245bb))
* **core:** Persist deployment_key entries for stability across restarts and key rotation ([#28518](https://github.com/n8n-io/n8n/issues/28518)) ([bb96d2e](https://github.com/n8n-io/n8n/commit/bb96d2e50a6b7cd77ea6256bb1446e8b3b348bd2))
* **core:** Support npm dist-tags in community node installation ([#28067](https://github.com/n8n-io/n8n/issues/28067)) ([ca871cc](https://github.com/n8n-io/n8n/commit/ca871cc10aca97de8c0892e0735c9fa2ed16d251))
* **core:** Support npm registry token authentication to install private community node packages ([#28228](https://github.com/n8n-io/n8n/issues/28228)) ([8b105cc](https://github.com/n8n-io/n8n/commit/8b105cc0cf6e84e069f6b7f3a98c334cd44876c1))
* **core:** Track workflow action source for external API and MCP requests ([#28483](https://github.com/n8n-io/n8n/issues/28483)) ([575c34e](https://github.com/n8n-io/n8n/commit/575c34eae1bdf8e9d5d5fe7d31c92f57f27fcc27))
* **core:** Workflow tracing - add workflow version id ([#28424](https://github.com/n8n-io/n8n/issues/28424)) ([9a22fe5](https://github.com/n8n-io/n8n/commit/9a22fe5a255b20be7d0e78fff7e03bf79e50a62f))
* **editor:** Add favoriting for projects, folders, workflows and data tables ([#26228](https://github.com/n8n-io/n8n/issues/26228)) ([b1a075f](https://github.com/n8n-io/n8n/commit/b1a075f7609045620563f86df0e15d27b1176d45))
* **editor:** Enable workflow execution from instance AI preview canvas ([#28412](https://github.com/n8n-io/n8n/issues/28412)) ([5b376cb](https://github.com/n8n-io/n8n/commit/5b376cb12d6331e4e458a1f1880fcddce76d1db9))
* Enable security policy settings via env vars ([#28321](https://github.com/n8n-io/n8n/issues/28321)) ([1108467](https://github.com/n8n-io/n8n/commit/1108467f44bf987c0f5a5a0eafb6396e2745b8ce))
* **Linear Trigger Node:** Add signing secret validation ([#28522](https://github.com/n8n-io/n8n/issues/28522)) ([3b248ee](https://github.com/n8n-io/n8n/commit/3b248eedc289c62f32f16da677c75b25df0fcb9f))
* **MiniMax Chat Model Node:** Add MiniMax Chat Model sub-node ([#28305](https://github.com/n8n-io/n8n/issues/28305)) ([bd927d9](https://github.com/n8n-io/n8n/commit/bd927d93503a65e0be18c4c40e68dcad96f68d82))
* **Slack Node:** Add app_home_opened as a dedicated trigger event ([#28626](https://github.com/n8n-io/n8n/issues/28626)) ([f1dab3e](https://github.com/n8n-io/n8n/commit/f1dab3e29530ee596d68db474024ddbae5fa055a))
### Reverts
* Make Wait node fully durable by removing in-memory execution path ([#28538](https://github.com/n8n-io/n8n/issues/28538)) ([bb9bec3](https://github.com/n8n-io/n8n/commit/bb9bec3ba419d46450122411839f20cd614db920))
# [2.17.0](https://github.com/n8n-io/n8n/compare/n8n@2.16.0...n8n@2.17.0) (2026-04-13)

View file

@ -16,7 +16,8 @@
"**/CHANGELOG.md",
"**/cl100k_base.json",
"**/o200k_base.json",
"**/*.generated.ts"
"**/*.generated.ts",
"**/expectations/**"
]
},
"formatter": {

View file

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "2.17.0",
"version": "2.18.0",
"private": true,
"engines": {
"node": ">=22.16",
@ -21,7 +21,7 @@
"typecheck": "turbo typecheck",
"dev": "turbo run dev --parallel --env-mode=loose --filter=!@n8n/design-system --filter=!@n8n/chat --filter=!@n8n/task-runner",
"dev:be": "turbo run dev --parallel --env-mode=loose --filter=!@n8n/design-system --filter=!@n8n/chat --filter=!@n8n/task-runner --filter=!n8n-editor-ui",
"dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core",
"dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/n8n-nodes-langchain --filter=n8n --filter=n8n-core",
"dev:fe": "run-p start \"dev:fe:editor --filter=@n8n/design-system\"",
"dev:fe:editor": "turbo run dev --parallel --env-mode=loose --filter=n8n-editor-ui",
"dev:e2e": "pnpm --filter=n8n-playwright dev --ui",
@ -34,6 +34,7 @@
"lint:styles:fix": "turbo run lint:styles:fix",
"lint:affected": "turbo run lint --affected",
"lint:fix": "turbo run lint:fix",
"lint:ci": "turbo run lint lint:styles",
"optimize-svg": "find ./packages -name '*.svg' ! -name 'pipedrive.svg' -print0 | xargs -0 -P16 -L20 npx svgo",
"generate:third-party-licenses": "node scripts/generate-third-party-licenses.mjs",
"setup-backend-module": "node scripts/ensure-zx.mjs && zx scripts/backend-module/setup.mjs",
@ -102,7 +103,7 @@
"@mistralai/mistralai": "^1.10.0",
"@n8n/typeorm>@sentry/node": "catalog:sentry",
"@types/node": "^20.17.50",
"axios": "1.13.5",
"axios": "1.15.0",
"chokidar": "4.0.3",
"esbuild": "^0.25.0",
"expr-eval@2.0.2": "npm:expr-eval-fork@3.0.0",

View file

@ -367,10 +367,11 @@ At end of turn, `saveToMemory()` uses `list.turnDelta()` and
`saveMessagesToThread`. If **semantic recall** is configured with an embedder
and `memory.saveEmbeddings`, new messages are embedded and stored.
**Working memory:** when configured, the runtime parses `<working_memory>`
`</working_memory>` regions from assistant text, validates structured JSON if a
schema exists, strips the tags from the visible message, and asynchronously
persists via `memory.saveWorkingMemory`.
**Working memory:** when configured, the runtime injects an `updateWorkingMemory`
tool into the agent's tool set. The current state is included in the system prompt
so the model can read it; when new information should be persisted the model calls
the tool, which validates the input and asynchronously persists via
`memory.saveWorkingMemory`.
**Thread titles:** `titleGeneration` triggers `generateThreadTitle` (fire-and-forget)
after a successful save when persistence and memory are present.
@ -414,7 +415,7 @@ src/
tool-adapter.ts — buildToolMap, executeTool, toAiSdkTools, suspend / agent-result guards
stream.ts — convertChunk, toTokenUsage
runtime-helpers.ts — normalizeInput, usage merge, stream error helpers, …
working-memory.ts — instruction text, parse/filter for working_memory tags
working-memory.ts — instruction text, updateWorkingMemory tool builder
strip-orphaned-tool-messages.ts
title-generation.ts
logger.ts

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/agents",
"version": "0.4.0",
"version": "0.5.0",
"description": "AI agent SDK for n8n's code-first execution engine",
"main": "dist/index.js",
"module": "dist/index.js",

View file

@ -224,7 +224,7 @@ describe('custom BuiltMemory backend', () => {
expect(findLastTextContent(result.messages)?.toLowerCase()).not.toContain('aurora');
// Thread 2 working memory should be independent
expect(store.workingMemory.get(thread2)).not.toContain('aurora');
expect(store.workingMemory.get(thread2)).toBeFalsy();
});
it('thread-scoped working memory allows recall within the same thread when history is truncated', async () => {

View file

@ -32,26 +32,24 @@ describe('freeform working memory', () => {
expect(findLastTextContent(result.messages)?.toLowerCase()).toContain('berlin');
});
it('working memory tags are stripped from visible response', async () => {
it('working memory is updated when new information is provided', async () => {
const memory = new Memory().storage('memory').lastMessages(10).freeform(template);
const agent = new Agent('strip-test')
const agent = new Agent('wm-update-test')
.model(getModel('anthropic'))
.instructions('You are a helpful assistant. Be concise.')
.memory(memory);
const threadId = `strip-${Date.now()}`;
const threadId = `wm-update-${Date.now()}`;
const options = { persistence: { threadId, resourceId: 'test-user' } };
const result = await agent.generate('My name is Bob.', options);
const allText = result.messages
.flatMap((m) => ('content' in m ? m.content : []))
.filter((c) => c.type === 'text')
.map((c) => (c as { text: string }).text)
.join(' ');
expect(allText).not.toContain('<working_memory>');
expect(allText).not.toContain('</working_memory>');
const toolCalls = result.messages.flatMap((m) =>
'content' in m ? m.content.filter((c) => c.type === 'tool-call') : [],
) as Array<{ type: 'tool-call'; toolName: string }>;
const wmToolCall = toolCalls.find((c) => c.toolName === 'updateWorkingMemory');
expect(wmToolCall).toBeDefined();
});
it('working memory persists across threads with same resourceId', async () => {

View file

@ -2,27 +2,56 @@ import type { LanguageModel } from 'ai';
import { createModel } from '../runtime/model-factory';
type ProviderOpts = {
apiKey?: string;
baseURL?: string;
fetch?: typeof globalThis.fetch;
headers?: Record<string, string>;
};
jest.mock('@ai-sdk/anthropic', () => ({
createAnthropic: (opts?: { apiKey?: string; baseURL?: string }) => (model: string) => ({
createAnthropic: (opts?: ProviderOpts) => (model: string) => ({
provider: 'anthropic',
modelId: model,
apiKey: opts?.apiKey,
baseURL: opts?.baseURL,
fetch: opts?.fetch,
headers: opts?.headers,
specificationVersion: 'v3',
}),
}));
jest.mock('@ai-sdk/openai', () => ({
createOpenAI: (opts?: { apiKey?: string; baseURL?: string }) => (model: string) => ({
createOpenAI: (opts?: ProviderOpts) => (model: string) => ({
provider: 'openai',
modelId: model,
apiKey: opts?.apiKey,
baseURL: opts?.baseURL,
fetch: opts?.fetch,
headers: opts?.headers,
specificationVersion: 'v3',
}),
}));
const mockProxyAgent = jest.fn();
jest.mock('undici', () => ({
ProxyAgent: mockProxyAgent,
}));
describe('createModel', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env.HTTPS_PROXY;
delete process.env.HTTP_PROXY;
mockProxyAgent.mockClear();
});
afterAll(() => {
process.env = originalEnv;
});
it('should accept a string config', () => {
const model = createModel('anthropic/claude-sonnet-4-5') as unknown as Record<string, unknown>;
expect(model.provider).toBe('anthropic');
@ -63,4 +92,42 @@ describe('createModel', () => {
expect(model.provider).toBe('openai');
expect(model.modelId).toBe('ft:gpt-4o:my-org:custom:abc123');
});
it('should not pass fetch when no proxy env vars are set', () => {
const model = createModel('anthropic/claude-sonnet-4-5') as unknown as Record<string, unknown>;
expect(model.fetch).toBeUndefined();
});
it('should pass proxy-aware fetch when HTTPS_PROXY is set', () => {
process.env.HTTPS_PROXY = 'http://proxy:8080';
const model = createModel('anthropic/claude-sonnet-4-5') as unknown as Record<string, unknown>;
expect(model.fetch).toBeInstanceOf(Function);
expect(mockProxyAgent).toHaveBeenCalledWith('http://proxy:8080');
});
it('should pass proxy-aware fetch when HTTP_PROXY is set', () => {
process.env.HTTP_PROXY = 'http://proxy:9090';
const model = createModel('openai/gpt-4o') as unknown as Record<string, unknown>;
expect(model.fetch).toBeInstanceOf(Function);
expect(mockProxyAgent).toHaveBeenCalledWith('http://proxy:9090');
});
it('should forward custom headers to the provider factory', () => {
const model = createModel({
id: 'anthropic/claude-sonnet-4-5',
apiKey: 'sk-test',
headers: { 'x-proxy-auth': 'Bearer abc', 'anthropic-beta': 'tools-2024' },
}) as unknown as Record<string, unknown>;
expect(model.headers).toEqual({
'x-proxy-auth': 'Bearer abc',
'anthropic-beta': 'tools-2024',
});
});
it('should prefer HTTPS_PROXY over HTTP_PROXY', () => {
process.env.HTTPS_PROXY = 'http://https-proxy:8080';
process.env.HTTP_PROXY = 'http://http-proxy:9090';
createModel('anthropic/claude-sonnet-4-5');
expect(mockProxyAgent).toHaveBeenCalledWith('http://https-proxy:8080');
});
});

View file

@ -0,0 +1,123 @@
import type * as AiImport from 'ai';
import type { LanguageModel } from 'ai';
import { generateTitleFromMessage } from '../runtime/title-generation';
type GenerateTextCall = {
messages: Array<{ role: string; content: string }>;
};
const mockGenerateText = jest.fn<Promise<{ text: string }>, [GenerateTextCall]>();
jest.mock('ai', () => {
const actual = jest.requireActual<typeof AiImport>('ai');
return {
...actual,
generateText: async (call: GenerateTextCall): Promise<{ text: string }> =>
await mockGenerateText(call),
};
});
const fakeModel = {} as LanguageModel;
describe('generateTitleFromMessage', () => {
beforeEach(() => {
mockGenerateText.mockReset();
});
it('returns null for empty input without calling the LLM', async () => {
const result = await generateTitleFromMessage(fakeModel, ' ');
expect(result).toBeNull();
expect(mockGenerateText).not.toHaveBeenCalled();
});
it('returns null for trivial greetings without calling the LLM', async () => {
const result = await generateTitleFromMessage(fakeModel, 'hey');
expect(result).toBeNull();
expect(mockGenerateText).not.toHaveBeenCalled();
});
it('returns null for short multi-word messages without calling the LLM', async () => {
const result = await generateTitleFromMessage(fakeModel, 'hi there');
expect(result).toBeNull();
expect(mockGenerateText).not.toHaveBeenCalled();
});
it('strips markdown heading prefixes from the LLM response', async () => {
mockGenerateText.mockResolvedValue({ text: '# Daily Berlin rain alert' });
const result = await generateTitleFromMessage(
fakeModel,
'Build a daily Berlin rain alert workflow',
);
expect(result).toBe('Daily Berlin rain alert');
});
it('strips inline emphasis markers from the LLM response', async () => {
mockGenerateText.mockResolvedValue({ text: 'Your **Berlin** rain alert' });
const result = await generateTitleFromMessage(
fakeModel,
'Build a daily Berlin rain alert workflow',
);
expect(result).toBe('Your Berlin rain alert');
});
it('strips <think> reasoning blocks from the LLM response', async () => {
mockGenerateText.mockResolvedValue({
text: '<think>Let me think about this</think>Deploy release pipeline',
});
const result = await generateTitleFromMessage(
fakeModel,
'Help me set up an automated deploy pipeline',
);
expect(result).toBe('Deploy release pipeline');
});
it('strips surrounding quotes from the LLM response', async () => {
mockGenerateText.mockResolvedValue({ text: '"Build Gmail to Slack workflow"' });
const result = await generateTitleFromMessage(
fakeModel,
'Build a workflow that forwards Gmail to Slack',
);
expect(result).toBe('Build Gmail to Slack workflow');
});
it('truncates titles longer than 80 characters at a word boundary', async () => {
mockGenerateText.mockResolvedValue({
text: 'Create a data table for users, then build a workflow that syncs them to our CRM every hour',
});
const result = await generateTitleFromMessage(
fakeModel,
'Create a data table for users and sync them to our CRM every hour with error alerting',
);
expect(result).not.toBeNull();
expect(result!.length).toBeLessThanOrEqual(81);
expect(result!.endsWith('\u2026')).toBe(true);
});
it('returns null when the LLM returns empty text', async () => {
mockGenerateText.mockResolvedValue({ text: ' ' });
const result = await generateTitleFromMessage(
fakeModel,
'Build a daily Berlin rain alert workflow',
);
expect(result).toBeNull();
});
it('passes the default instructions to the LLM', async () => {
mockGenerateText.mockResolvedValue({ text: 'Berlin rain alert' });
await generateTitleFromMessage(fakeModel, 'Build a daily Berlin rain alert workflow');
const call = mockGenerateText.mock.calls[0][0];
expect(call.messages[0].role).toBe('system');
expect(call.messages[0].content).toContain('markdown');
expect(call.messages[0].content).toContain('sentence case');
});
it('accepts custom instructions', async () => {
mockGenerateText.mockResolvedValue({ text: 'Custom title' });
await generateTitleFromMessage(fakeModel, 'Build a daily Berlin rain alert workflow', {
instructions: 'Custom system prompt',
});
const call = mockGenerateText.mock.calls[0][0];
expect(call.messages[0].content).toBe('Custom system prompt');
});
});

View file

@ -1,62 +1,74 @@
import { z } from 'zod';
import {
parseWorkingMemory,
buildWorkingMemoryInstruction,
buildWorkingMemoryTool,
templateFromSchema,
WorkingMemoryStreamFilter,
UPDATE_WORKING_MEMORY_TOOL_NAME,
WORKING_MEMORY_DEFAULT_INSTRUCTION,
} from '../runtime/working-memory';
import type { StreamChunk } from '../types';
describe('parseWorkingMemory', () => {
it('extracts content between tags at end of text', () => {
const text = 'Hello world.\n<working_memory>\n# Name: Alice\n</working_memory>';
const result = parseWorkingMemory(text);
expect(result.cleanText).toBe('Hello world.');
expect(result.workingMemory).toBe('# Name: Alice');
});
it('extracts content between tags in middle of text', () => {
const text = 'Before.\n<working_memory>\ndata\n</working_memory>\nAfter.';
const result = parseWorkingMemory(text);
expect(result.cleanText).toBe('Before.\nAfter.');
expect(result.workingMemory).toBe('data');
});
it('returns null when no tags present', () => {
const text = 'Just a normal response.';
const result = parseWorkingMemory(text);
expect(result.cleanText).toBe('Just a normal response.');
expect(result.workingMemory).toBeNull();
});
it('handles empty working memory', () => {
const text = 'Response.\n<working_memory>\n</working_memory>';
const result = parseWorkingMemory(text);
expect(result.cleanText).toBe('Response.');
expect(result.workingMemory).toBe('');
});
it('handles multiline content with markdown', () => {
const wm = '# User Context\n- **Name**: Alice\n- **City**: Berlin';
const text = `Response text.\n<working_memory>\n${wm}\n</working_memory>`;
const result = parseWorkingMemory(text);
expect(result.workingMemory).toBe(wm);
});
});
describe('buildWorkingMemoryInstruction', () => {
it('generates freeform instruction', () => {
it('mentions the updateWorkingMemory tool name', () => {
const result = buildWorkingMemoryInstruction('# Context\n- Name:', false);
expect(result).toContain('<working_memory>');
expect(result).toContain('</working_memory>');
expect(result).toContain('# Context\n- Name:');
expect(result).toContain(UPDATE_WORKING_MEMORY_TOOL_NAME);
});
it('generates structured instruction mentioning JSON', () => {
const result = buildWorkingMemoryInstruction('{"userName": ""}', true);
it('instructs the model to call the tool only when something changed', () => {
const result = buildWorkingMemoryInstruction('# Context\n- Name:', false);
expect(result).toContain('Only call it when something has actually changed');
});
it('includes the template in the instruction', () => {
const template = '# Context\n- Name:\n- City:';
const result = buildWorkingMemoryInstruction(template, false);
expect(result).toContain(template);
});
it('mentions JSON for structured variant', () => {
const result = buildWorkingMemoryInstruction('{"name": ""}', true);
expect(result).toContain('JSON');
expect(result).toContain('<working_memory>');
});
describe('custom instruction', () => {
it('replaces the default instruction body when provided', () => {
const custom = 'Always update working memory after every message.';
const result = buildWorkingMemoryInstruction('# Template', false, custom);
expect(result).toContain(custom);
expect(result).not.toContain(WORKING_MEMORY_DEFAULT_INSTRUCTION);
});
it('still includes the ## Working Memory heading', () => {
const result = buildWorkingMemoryInstruction('# Template', false, 'Custom text.');
expect(result).toContain('## Working Memory');
});
it('still includes the template block', () => {
const template = '# Context\n- Name:\n- City:';
const result = buildWorkingMemoryInstruction(template, false, 'Custom text.');
expect(result).toContain(template);
});
it('still includes the format hint for structured memory', () => {
const result = buildWorkingMemoryInstruction('{}', true, 'Custom text.');
expect(result).toContain('JSON');
});
it('still includes the format hint for freeform memory', () => {
const result = buildWorkingMemoryInstruction('# Template', false, 'Custom text.');
expect(result).toContain('Update the template with any new information learned');
});
it('uses the default instruction when undefined is passed explicitly', () => {
const withDefault = buildWorkingMemoryInstruction('# Template', false, undefined);
const withoutArg = buildWorkingMemoryInstruction('# Template', false);
expect(withDefault).toBe(withoutArg);
});
it('WORKING_MEMORY_DEFAULT_INSTRUCTION appears in the output when no custom instruction is set', () => {
const result = buildWorkingMemoryInstruction('# Template', false);
expect(result).toContain(WORKING_MEMORY_DEFAULT_INSTRUCTION);
});
});
});
@ -69,7 +81,6 @@ describe('templateFromSchema', () => {
const result = templateFromSchema(schema);
expect(result).toContain('userName');
expect(result).toContain('favoriteColor');
// Should be valid JSON
let parsed: unknown;
try {
parsed = JSON.parse(result);
@ -80,118 +91,117 @@ describe('templateFromSchema', () => {
});
});
/**
* Helper that feeds chunks through a WorkingMemoryStreamFilter and collects
* the output text and any persisted working memory content.
*/
async function runStreamFilter(
chunks: string[],
): Promise<{ outputText: string; persisted: string[] }> {
const persisted: string[] = [];
const stream = new TransformStream<StreamChunk>();
const writer = stream.writable.getWriter();
// eslint-disable-next-line @typescript-eslint/require-await
const filter = new WorkingMemoryStreamFilter(writer, async (content) => {
persisted.push(content);
describe('buildWorkingMemoryTool — freeform', () => {
it('returns a BuiltTool with the correct name', () => {
const tool = buildWorkingMemoryTool({
structured: false,
persist: async () => {},
});
expect(tool.name).toBe(UPDATE_WORKING_MEMORY_TOOL_NAME);
});
// Read the readable side concurrently to avoid backpressure deadlock
const reader = stream.readable.getReader();
const readAll = (async () => {
let outputText = '';
while (true) {
const result = await reader.read();
if (result.done) break;
const chunk = result.value as StreamChunk;
if (chunk.type === 'text-delta') outputText += chunk.delta;
}
return outputText;
})();
for (const chunk of chunks) {
await filter.write({ type: 'text-delta', delta: chunk });
}
await filter.flush();
await writer.close();
const outputText = await readAll;
return { outputText, persisted };
}
describe('WorkingMemoryStreamFilter with tag split across multiple chunks', () => {
it('handles tag split mid-open-tag', async () => {
const { outputText, persisted } = await runStreamFilter([
'Hello <work',
'ing_memory>state</working_memory>',
]);
expect(outputText).toBe('Hello ');
expect(persisted).toEqual(['state']);
it('has a description', () => {
const tool = buildWorkingMemoryTool({
structured: false,
persist: async () => {},
});
expect(tool.description).toBeTruthy();
});
it('handles tag split mid-close-tag', async () => {
const { outputText, persisted } = await runStreamFilter([
'<working_memory>state</worki',
'ng_memory> after',
]);
expect(persisted).toEqual(['state']);
expect(outputText).toBe(' after');
it('has a freeform input schema with a memory field', () => {
const tool = buildWorkingMemoryTool({
structured: false,
persist: async () => {},
});
expect(tool.inputSchema).toBeDefined();
const schema = tool.inputSchema as z.ZodObject<z.ZodRawShape>;
const result = schema.safeParse({ memory: 'hello' });
expect(result.success).toBe(true);
});
it('handles tag spread across 3+ chunks', async () => {
const { outputText, persisted } = await runStreamFilter([
'<wor',
'king_mem',
'ory>data</working_memory>',
]);
expect(persisted).toEqual(['data']);
expect(outputText).toBe('');
it('rejects input without memory field', () => {
const tool = buildWorkingMemoryTool({
structured: false,
persist: async () => {},
});
const schema = tool.inputSchema as z.ZodObject<z.ZodRawShape>;
const result = schema.safeParse({ other: 'value' });
expect(result.success).toBe(false);
});
it('handles partial < that is not a tag', async () => {
const { outputText, persisted } = await runStreamFilter(['Hello <', 'div>world']);
expect(outputText).toBe('Hello <div>world');
expect(persisted).toEqual([]);
it('handler calls persist with the memory string', async () => {
const persisted: string[] = [];
const tool = buildWorkingMemoryTool({
structured: false,
// eslint-disable-next-line @typescript-eslint/require-await
persist: async (content) => {
persisted.push(content);
},
});
const result = await tool.handler!({ memory: 'test content' }, {} as never);
expect(persisted).toEqual(['test content']);
expect(result).toMatchObject({ success: true });
});
});
describe('parseWorkingMemory with invalid structured content', () => {
it('strips tags and extracts content regardless of JSON validity', () => {
const invalidJson = '{not valid json!!!}';
const text = `Here is my response.\n<working_memory>\n${invalidJson}\n</working_memory>`;
const result = parseWorkingMemory(text);
expect(result.cleanText).toBe('Here is my response.');
expect(result.workingMemory).toBe(invalidJson);
describe('buildWorkingMemoryTool — structured', () => {
const schema = z.object({
userName: z.string().optional().describe("The user's name"),
location: z.string().optional().describe('Where the user lives'),
});
it('strips tags with content that fails Zod schema validation', () => {
// Content is valid JSON but wrong shape for the schema
const wrongShape = '{"unexpected": true}';
const text = `Response text.\n<working_memory>\n${wrongShape}\n</working_memory>`;
const result = parseWorkingMemory(text);
it('uses the Zod schema as input schema', () => {
const tool = buildWorkingMemoryTool({
structured: true,
schema,
persist: async () => {},
});
const inputSchema = tool.inputSchema as typeof schema;
const result = inputSchema.safeParse({ userName: 'Alice', location: 'Berlin' });
expect(result.success).toBe(true);
});
// Tags are stripped from response regardless
expect(result.cleanText).toBe('Response text.');
// Raw content is returned — caller decides whether it passes validation
expect(result.workingMemory).toBe(wrongShape);
it('handler serializes input to JSON and calls persist', async () => {
const persisted: string[] = [];
const tool = buildWorkingMemoryTool({
structured: true,
schema,
// eslint-disable-next-line @typescript-eslint/require-await
persist: async (content) => {
persisted.push(content);
},
});
// Verify the content would indeed fail schema validation
expect(result.workingMemory).not.toBeNull();
const input = { userName: 'Alice', location: 'Berlin' };
await tool.handler!(input, {} as never);
expect(persisted).toHaveLength(1);
let parsed: unknown;
try {
parsed = JSON.parse(result.workingMemory!);
parsed = JSON.parse(persisted[0]) as unknown;
} catch {
parsed = undefined;
}
expect(parsed).toBeDefined();
expect(parsed).toMatchObject(input);
});
it('strips tags even when content is completely non-JSON', () => {
const text =
'My reply.\n<working_memory>\nthis is just plain text, not JSON at all\n</working_memory>';
const result = parseWorkingMemory(text);
it('handler returns success confirmation', async () => {
const tool = buildWorkingMemoryTool({
structured: true,
schema,
persist: async () => {},
});
const result = await tool.handler!({ userName: 'Alice' }, {} as never);
expect(result).toMatchObject({ success: true });
});
expect(result.cleanText).toBe('My reply.');
expect(result.workingMemory).toBe('this is just plain text, not JSON at all');
it('falls back to freeform when no schema provided despite structured:true', () => {
const tool = buildWorkingMemoryTool({
structured: true,
persist: async () => {},
});
const inputSchema = tool.inputSchema as z.ZodObject<z.ZodRawShape>;
const result = inputSchema.safeParse({ memory: 'fallback text' });
expect(result.success).toBe(true);
});
});

View file

@ -106,10 +106,17 @@ export type {
ModelLimits,
} from './sdk/catalog';
export { SqliteMemory } from './storage/sqlite-memory';
export {
UPDATE_WORKING_MEMORY_TOOL_NAME,
WORKING_MEMORY_DEFAULT_INSTRUCTION,
} from './runtime/working-memory';
export type { SqliteMemoryConfig } from './storage/sqlite-memory';
export { PostgresMemory } from './storage/postgres-memory';
export type { PostgresMemoryConfig } from './storage/postgres-memory';
export { createModel } from './runtime/model-factory';
export { generateTitleFromMessage } from './runtime/title-generation';
export { Workspace } from './workspace';
export { BaseFilesystem } from './workspace';
export { BaseSandbox } from './workspace';

View file

@ -31,7 +31,6 @@ import type {
XaiThinkingConfig,
} from '../types';
import { AgentEventBus } from './event-bus';
import { createFilteredLogger } from './logger';
import { saveMessagesToThread } from './memory-store';
import { AgentMessageList, type SerializedMessageList } from './message-list';
import { fromAiFinishReason, fromAiMessages } from './messages';
@ -57,7 +56,7 @@ import {
toAiSdkProviderTools,
toAiSdkTools,
} from './tool-adapter';
import { parseWorkingMemory, WorkingMemoryStreamFilter } from './working-memory';
import { buildWorkingMemoryTool } from './working-memory';
import { AgentEvent } from '../types/runtime/event';
import type {
AgentPersistenceOptions,
@ -75,19 +74,6 @@ import type {
import type { JSONObject, JSONValue } from '../types/utils/json';
import { isZodSchema } from '../utils/zod';
const logger = createFilteredLogger();
/** Type guard for text content parts in LLM messages. */
function isTextPart(part: unknown): part is { type: 'text'; text: string } {
return (
typeof part === 'object' &&
part !== null &&
'type' in part &&
(part as Record<string, unknown>).type === 'text' &&
'text' in part
);
}
export interface AgentRuntimeConfig {
name: string;
model: ModelConfig;
@ -102,6 +88,7 @@ export interface AgentRuntimeConfig {
structured: boolean;
schema?: z.ZodObject<z.ZodRawShape>;
scope?: 'resource' | 'thread';
instruction?: string;
};
semanticRecall?: SemanticRecallConfig;
structuredOutput?: z.ZodType;
@ -628,7 +615,7 @@ export class AgentRuntime {
runId?: string,
): Promise<GenerateResult> {
const { model, toolMap, aiTools, providerOptions, hasTools, outputSpec } =
this.buildLoopContext(options);
this.buildLoopContext({ ...options, persistence: options?.persistence });
let totalUsage: TokenUsage | undefined;
let lastFinishReason: FinishReason = 'stop';
@ -760,19 +747,6 @@ export class AgentRuntime {
);
}
// Extract and strip working memory from assistant response
if (
this.config.workingMemory &&
this.config.memory?.saveWorkingMemory &&
options?.persistence
) {
this.extractAndPersistWorkingMemory(list, {
threadId: options.persistence.threadId,
resourceId: options.persistence.resourceId,
scope: this.config.workingMemory?.scope ?? 'resource',
});
}
await this.saveToMemory(list, options);
await this.flushTelemetry(options);
@ -850,22 +824,10 @@ export class AgentRuntime {
runId?: string,
): Promise<void> {
const { model, toolMap, aiTools, providerOptions, hasTools, outputSpec } =
this.buildLoopContext(options);
// Wrap writer with working memory filter if configured
const wmParamsStream = this.resolveWorkingMemoryParams(options?.persistence);
const wmFilter = wmParamsStream?.persistFn
? new WorkingMemoryStreamFilter(writer, async (content: string) => {
await wmParamsStream.persistFn(content);
})
: undefined;
this.buildLoopContext({ ...options, persistence: options?.persistence });
const writeChunk = async (chunk: StreamChunk): Promise<void> => {
if (wmFilter) {
await wmFilter.write(chunk);
} else {
await writer.write(chunk);
}
await writer.write(chunk);
};
let totalUsage: TokenUsage | undefined;
@ -877,7 +839,6 @@ export class AgentRuntime {
const closeStreamWithError = async (error: unknown, status: AgentRunState): Promise<void> => {
await this.cleanupRun(runId);
this.updateState({ status });
if (wmFilter) await wmFilter.flush();
await writer.write({ type: 'error', error });
await writer.write({ type: 'finish', finishReason: 'error' });
await writer.close();
@ -1065,8 +1026,6 @@ export class AgentRuntime {
this.emitTurnEnd(newMessages, extractToolResults(list.responseDelta()));
}
if (wmFilter) await wmFilter.flush();
const costUsage = this.applyCost(totalUsage);
const parentCost = costUsage?.cost ?? 0;
const subCost = collectedSubAgentUsage.reduce((sum, s) => sum + (s.usage.cost ?? 0), 0);
@ -1083,19 +1042,6 @@ export class AgentRuntime {
});
try {
// Extract and strip working memory from assistant response
if (
this.config.workingMemory &&
this.config.memory?.saveWorkingMemory &&
options?.persistence
) {
this.extractAndPersistWorkingMemory(list, {
threadId: options.persistence.threadId,
resourceId: options.persistence.resourceId,
scope: this.config.workingMemory?.scope ?? 'resource',
});
}
await this.saveToMemory(list, options);
if (this.config.titleGeneration && options?.persistence && this.config.memory) {
@ -1187,43 +1133,6 @@ export class AgentRuntime {
});
}
/**
* Extract <working_memory> tags from the last assistant message in the turn delta,
* strip them from the message, and persist the working memory content.
*/
private extractAndPersistWorkingMemory(
list: AgentMessageList,
params: { threadId: string; resourceId: string; scope: 'resource' | 'thread' },
): void {
const delta = list.responseDelta();
for (let i = delta.length - 1; i >= 0; i--) {
const msg = delta[i];
if (!isLlmMessage(msg) || msg.role !== 'assistant') continue;
for (const part of msg.content) {
if (!isTextPart(part)) continue;
const { cleanText, workingMemory } = parseWorkingMemory(part.text);
if (workingMemory !== null) {
// Validate structured working memory if schema is configured
if (this.config.workingMemory?.structured && this.config.workingMemory.schema) {
try {
this.config.workingMemory.schema.parse(JSON.parse(workingMemory));
} catch {
// Validation failed — keep previous state, still strip tags
part.text = cleanText;
return;
}
}
part.text = cleanText;
// Fire-and-forget persist
this.config.memory!.saveWorkingMemory!(params, workingMemory).catch((error: unknown) => {
logger.warn('Failed to persist working memory', { error });
});
}
return;
}
}
}
/** Build the providerOptions object for thinking/reasoning config. */
private buildThinkingProviderOptions(): Record<string, Record<string, unknown>> | undefined {
if (!this.config.thinking) return undefined;
@ -1691,13 +1600,19 @@ export class AgentRuntime {
}
/** Build common LLM call dependencies shared by both the generate and stream loops. */
private buildLoopContext(execOptions?: ExecutionOptions) {
const aiTools = toAiSdkTools(this.config.tools);
private buildLoopContext(
execOptions?: ExecutionOptions & { persistence?: AgentPersistenceOptions },
) {
const wmTool = this.buildWorkingMemoryToolForRun(execOptions?.persistence);
const allUserTools = wmTool
? [...(this.config.tools ?? []), wmTool]
: (this.config.tools ?? []);
const aiTools = toAiSdkTools(allUserTools);
const aiProviderTools = toAiSdkProviderTools(this.config.providerTools);
const allTools = { ...aiTools, ...aiProviderTools };
return {
model: createModel(this.config.model),
toolMap: buildToolMap(this.config.tools),
toolMap: buildToolMap(allUserTools),
aiTools: allTools,
providerOptions: this.buildCallProviderOptions(execOptions?.providerOptions),
hasTools: Object.keys(allTools).length > 0,
@ -1707,6 +1622,20 @@ export class AgentRuntime {
};
}
/**
* Build the updateWorkingMemory BuiltTool for the current run.
* Returns undefined when working memory is not configured or persistence is unavailable.
*/
private buildWorkingMemoryToolForRun(persistence: AgentPersistenceOptions | undefined) {
const wmParams = this.resolveWorkingMemoryParams(persistence);
if (!wmParams) return undefined;
return buildWorkingMemoryTool({
structured: wmParams.structured,
schema: wmParams.schema,
persist: wmParams.persistFn,
});
}
/**
* Persist a suspended run state and update the current state snapshot.
* Returns the runId (reuses existingRunId when resuming to prevent dangling runs).
@ -1804,6 +1733,7 @@ export class AgentRuntime {
template: wmParams.template,
structured: wmParams.structured,
state: wmState,
...(wmParams.instruction !== undefined && { instruction: wmParams.instruction }),
};
}
@ -1832,6 +1762,7 @@ export class AgentRuntime {
template: this.config.workingMemory.template,
structured: this.config.workingMemory.structured,
schema: this.config.workingMemory.schema,
instruction: this.config.workingMemory.instruction,
};
}
}

View file

@ -17,6 +17,8 @@ export interface WorkingMemoryContext {
structured: boolean;
/** The current persisted state, or null if not yet loaded. Falls back to template. */
state: string | null;
/** Custom instruction text. When absent the default instruction is used. */
instruction?: string;
}
/**
@ -144,10 +146,11 @@ export class AgentMessageList {
const wmInstruction = buildWorkingMemoryInstruction(
this.workingMemory.template,
this.workingMemory.structured,
this.workingMemory.instruction,
);
const wmState = this.workingMemory.state ?? this.workingMemory.template;
systemPrompt +=
wmInstruction + '\n\nCurrent working memory state:\n```\n' + wmState + '\n```';
wmInstruction + '\n\nCurrent working memory state:\n```\n' + wmState + '\n```\n';
}
const systemMessage: ModelMessage = instructionProviderOptions

View file

@ -1,11 +1,15 @@
/* eslint-disable @typescript-eslint/no-require-imports */
import type { EmbeddingModel, LanguageModel } from 'ai';
import type * as Undici from 'undici';
import type { ModelConfig } from '../types/sdk/agent';
type FetchFn = typeof globalThis.fetch;
type CreateProviderFn = (opts?: {
apiKey?: string;
baseURL?: string;
fetch?: FetchFn;
headers?: Record<string, string>;
}) => (model: string) => LanguageModel;
type CreateEmbeddingProviderFn = (opts?: { apiKey?: string }) => {
embeddingModel(model: string): EmbeddingModel;
@ -15,6 +19,26 @@ function isLanguageModel(config: unknown): config is LanguageModel {
return typeof config === 'object' && config !== null && 'doGenerate' in config;
}
/**
* When HTTP_PROXY / HTTPS_PROXY is set (e.g. in e2e tests with MockServer),
* return a fetch function that routes requests through the proxy. The default
* globalThis.fetch in Node 18 does NOT respect these env vars, so AI SDK
* providers would bypass the proxy without this.
*/
function getProxyFetch(): FetchFn | undefined {
const proxyUrl = process.env.HTTPS_PROXY ?? process.env.HTTP_PROXY;
if (!proxyUrl) return undefined;
const { ProxyAgent } = require('undici') as typeof Undici;
const dispatcher = new ProxyAgent(proxyUrl);
return (async (url, init) =>
await globalThis.fetch(url, {
...init,
// @ts-expect-error dispatcher is a valid undici option for Node.js fetch
dispatcher,
})) as FetchFn;
}
/**
* Provider packages are loaded dynamically via require() so only the
* provider needed at runtime must be installed.
@ -33,6 +57,7 @@ export function createModel(config: ModelConfig): LanguageModel {
const modelId = stripEmpty(typeof config === 'string' ? config : config.id);
const apiKey = stripEmpty(typeof config === 'string' ? undefined : config.apiKey);
const baseURL = stripEmpty(typeof config === 'string' ? undefined : config.url);
const headers = typeof config === 'string' ? undefined : config.headers;
if (!modelId) {
throw new Error('Model ID is required');
@ -40,31 +65,32 @@ export function createModel(config: ModelConfig): LanguageModel {
const [provider, ...rest] = modelId.split('/');
const modelName = rest.join('/');
const fetch = getProxyFetch();
switch (provider) {
case 'anthropic': {
const { createAnthropic } = require('@ai-sdk/anthropic') as {
createAnthropic: CreateProviderFn;
};
return createAnthropic({ apiKey, baseURL })(modelName);
return createAnthropic({ apiKey, baseURL, fetch, headers })(modelName);
}
case 'openai': {
const { createOpenAI } = require('@ai-sdk/openai') as {
createOpenAI: CreateProviderFn;
};
return createOpenAI({ apiKey, baseURL })(modelName);
return createOpenAI({ apiKey, baseURL, fetch, headers })(modelName);
}
case 'google': {
const { createGoogleGenerativeAI } = require('@ai-sdk/google') as {
createGoogleGenerativeAI: CreateProviderFn;
};
return createGoogleGenerativeAI({ apiKey, baseURL })(modelName);
return createGoogleGenerativeAI({ apiKey, baseURL, fetch, headers })(modelName);
}
case 'xai': {
const { createXai } = require('@ai-sdk/xai') as {
createXai: CreateProviderFn;
};
return createXai({ apiKey, baseURL })(modelName);
return createXai({ apiKey, baseURL, fetch, headers })(modelName);
}
default:
throw new Error(

View file

@ -1,4 +1,4 @@
import { generateText } from 'ai';
import { generateText, type LanguageModel } from 'ai';
import type { BuiltMemory, TitleGenerationConfig } from '../types';
import { createFilteredLogger } from './logger';
@ -10,13 +10,83 @@ const logger = createFilteredLogger();
const DEFAULT_TITLE_INSTRUCTIONS = [
'- you will generate a short title based on the first message a user begins a conversation with',
"- the title should be a summary of the user's message",
'- the title should describe what the user asked for, not what an assistant might reply',
'- 1 to 5 words, no more than 80 characters',
'- use sentence case (e.g. "Conversation title" instead of "Conversation Title")',
'- do not use quotes, colons, or markdown formatting',
'- the entire text you return will be used directly as the title, so respond with the title only',
].join('\n');
const TRIVIAL_MESSAGE_MAX_CHARS = 15;
const TRIVIAL_MESSAGE_MAX_WORDS = 3;
const MAX_TITLE_LENGTH = 80;
/**
* Whether a user message has too little substance to title a conversation
* (e.g. "hey", "hello"). For these, the LLM tends to hallucinate an
* assistant-voice reply as the title better to signal "defer, not enough
* signal yet" so the caller can retry once more context accumulates.
*/
function isTrivialMessage(message: string): boolean {
const normalized = message.trim();
if (normalized.length <= TRIVIAL_MESSAGE_MAX_CHARS) return true;
const wordCount = normalized.split(/\s+/).filter(Boolean).length;
return wordCount <= TRIVIAL_MESSAGE_MAX_WORDS;
}
function sanitizeTitle(raw: string): string {
// Strip <think>...</think> blocks (e.g. from DeepSeek R1)
let title = raw.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
// Strip markdown heading prefixes and inline emphasis markers
title = title
.replace(/^#{1,6}\s+/, '')
.replace(/\*+/g, '')
.trim();
// Strip surrounding quotes
title = title.replace(/^["']|["']$/g, '').trim();
if (title.length > MAX_TITLE_LENGTH) {
const truncated = title.slice(0, MAX_TITLE_LENGTH);
const lastSpace = truncated.lastIndexOf(' ');
title = (lastSpace > 20 ? truncated.slice(0, lastSpace) : truncated) + '\u2026';
}
return title;
}
/**
* Generate a sanitized thread title from a user message using an LLM.
*
* Returns `null` on empty input or empty LLM output. For trivial messages
* (e.g. greetings), returns the sanitized message itself without calling
* the LLM this avoids the failure mode where the model responds with
* an assistant-voice reply as the title.
*/
export async function generateTitleFromMessage(
model: LanguageModel,
userMessage: string,
opts?: { instructions?: string },
): Promise<string | null> {
const trimmed = userMessage.trim();
if (!trimmed) return null;
if (isTrivialMessage(trimmed)) {
return null;
}
const result = await generateText({
model,
messages: [
{ role: 'system', content: opts?.instructions ?? DEFAULT_TITLE_INSTRUCTIONS },
{ role: 'user', content: trimmed },
],
});
const raw = result.text?.trim();
if (!raw) return null;
const title = sanitizeTitle(raw);
return title || null;
}
/**
* Generate a title for a thread if it doesn't already have one.
*
@ -49,28 +119,9 @@ export async function generateThreadTitle(opts: {
const titleModelId = opts.titleConfig.model ?? opts.agentModel;
const titleModel = createModel(titleModelId);
const instructions = opts.titleConfig.instructions ?? DEFAULT_TITLE_INSTRUCTIONS;
const result = await generateText({
model: titleModel,
messages: [
{ role: 'system', content: instructions },
{ role: 'user', content: userText },
],
const title = await generateTitleFromMessage(titleModel, userText, {
instructions: opts.titleConfig.instructions,
});
let title = result.text?.trim();
if (!title) return;
// Strip <think>...</think> blocks (e.g. from DeepSeek R1)
title = title.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
if (!title) return;
// Strip markdown heading prefixes and inline formatting
title = title
.replace(/^#{1,6}\s+/, '')
.replace(/\*+/g, '')
.trim();
if (!title) return;
await opts.memory.saveThread({

View file

@ -1,58 +1,48 @@
import type { z } from 'zod';
import { z } from 'zod';
import type { StreamChunk } from '../types';
import { createFilteredLogger } from './logger';
const logger = createFilteredLogger();
import type { BuiltTool } from '../types';
type ZodObjectSchema = z.ZodObject<z.ZodRawShape>;
const OPEN_TAG = '<working_memory>';
const CLOSE_TAG = '</working_memory>';
export const UPDATE_WORKING_MEMORY_TOOL_NAME = 'updateWorkingMemory';
/**
* Extract working memory content from an LLM response.
* Returns the clean text (tags stripped) and the extracted working memory (or null).
* The default instruction block injected into the system prompt when working memory
* is configured. Exported so callers can reference it when building custom instructions.
*/
export function parseWorkingMemory(text: string): {
cleanText: string;
workingMemory: string | null;
} {
const openIdx = text.indexOf(OPEN_TAG);
if (openIdx === -1) return { cleanText: text, workingMemory: null };
const closeIdx = text.indexOf(CLOSE_TAG, openIdx);
if (closeIdx === -1) return { cleanText: text, workingMemory: null };
const contentStart = openIdx + OPEN_TAG.length;
const rawContent = text.slice(contentStart, closeIdx);
const workingMemory = rawContent.replace(/^\n/, '').replace(/\n$/, '');
const before = text.slice(0, openIdx).replace(/\n$/, '');
const after = text.slice(closeIdx + CLOSE_TAG.length).replace(/^\n/, '');
const cleanText = (before + (after ? '\n' + after : '')).trim();
return { cleanText, workingMemory };
}
export const WORKING_MEMORY_DEFAULT_INSTRUCTION = [
'You have persistent working memory that survives across conversations.',
'Your current working memory state is shown below.',
`When you learn new information about the user or conversation that should be remembered, call the \`${UPDATE_WORKING_MEMORY_TOOL_NAME}\` tool.`,
'Only call it when something has actually changed — do NOT call it if nothing new was learned.',
].join('\n');
/**
* Generate the system prompt instruction for working memory.
* Tells the LLM to call the updateWorkingMemory tool when it has new information to persist.
*
* @param template - The working memory template or schema.
* @param structured - Whether the working memory is structured (JSON schema).
* @param instruction - Custom instruction text to replace the default. Defaults to
* {@link WORKING_MEMORY_DEFAULT_INSTRUCTION}.
*/
export function buildWorkingMemoryInstruction(template: string, structured: boolean): string {
export function buildWorkingMemoryInstruction(
template: string,
structured: boolean,
instruction?: string,
): string {
const format = structured
? 'Emit the updated state as valid JSON matching the schema'
? 'The memory argument must be valid JSON matching the schema'
: 'Update the template with any new information learned';
const body = instruction ?? WORKING_MEMORY_DEFAULT_INSTRUCTION;
return [
'',
'## Working Memory',
'',
'You have persistent working memory that survives across conversations.',
'The current state will be shown to you in a system message.',
'IMPORTANT: Always respond to the user first with your normal reply.',
`Then, at the very end of your response, emit your updated working memory inside ${OPEN_TAG}...${CLOSE_TAG} tags on a new line.`,
`${format}. If nothing changed, emit the current state unchanged.`,
'The working memory block must be the last thing in your response, after your reply to the user.',
body,
`${format}.`,
'',
'Current template:',
'```',
@ -73,111 +63,51 @@ export function templateFromSchema(schema: ZodObjectSchema): string {
return JSON.stringify(obj, null, 2);
}
type PersistFn = (content: string) => Promise<void>;
export interface WorkingMemoryToolConfig {
/** Whether this is structured (Zod-schema-driven) working memory. */
structured: boolean;
/** Zod schema for structured working memory input validation. */
schema?: ZodObjectSchema;
/** Called with the serialized working memory string to persist it. */
persist: (content: string) => Promise<void>;
}
/**
* Wraps a stream writer to intercept <working_memory> tags from text-delta chunks.
* All non-text-delta chunks pass through unchanged.
* Text inside the tags is buffered and persisted when the closing tag is detected.
* Build the updateWorkingMemory BuiltTool that the agent calls to persist working memory.
*
* For freeform working memory the input schema is `{ memory: string }`.
* For structured working memory the input schema is the configured Zod object schema,
* whose values are serialized to JSON before persisting.
*/
export class WorkingMemoryStreamFilter {
private writer: WritableStreamDefaultWriter<StreamChunk>;
private persist: PersistFn;
private state: 'normal' | 'inside' = 'normal';
private buffer = '';
private pendingText = '';
constructor(writer: WritableStreamDefaultWriter<StreamChunk>, persist: PersistFn) {
this.writer = writer;
this.persist = persist;
export function buildWorkingMemoryTool(config: WorkingMemoryToolConfig): BuiltTool {
if (config.structured && config.schema) {
const schema = config.schema;
return {
name: UPDATE_WORKING_MEMORY_TOOL_NAME,
description:
'Update your persistent working memory with new information about the user or conversation. Only call this when something has actually changed.',
inputSchema: schema,
handler: async (input: unknown) => {
const content = JSON.stringify(input, null, 2);
await config.persist(content);
return { success: true, message: 'Working memory updated.' };
},
};
}
async write(chunk: StreamChunk): Promise<void> {
if (chunk.type !== 'text-delta') {
await this.writer.write(chunk);
return;
}
const freeformSchema = z.object({
memory: z.string().describe('The updated working memory content.'),
});
this.pendingText += chunk.delta;
while (this.pendingText.length > 0) {
if (this.state === 'normal') {
const openIdx = this.pendingText.indexOf(OPEN_TAG);
if (openIdx === -1) {
// No full open tag found. Check if the tail is a valid prefix of OPEN_TAG.
const lastLt = this.pendingText.lastIndexOf('<');
if (
lastLt !== -1 &&
this.pendingText.length - lastLt < OPEN_TAG.length &&
OPEN_TAG.startsWith(this.pendingText.slice(lastLt))
) {
// Potential partial tag at end — forward everything before it, hold the rest
if (lastLt > 0) {
await this.writer.write({
type: 'text-delta',
delta: this.pendingText.slice(0, lastLt),
});
}
this.pendingText = this.pendingText.slice(lastLt);
} else {
// No partial tag concern — forward everything
await this.writer.write({ type: 'text-delta', delta: this.pendingText });
this.pendingText = '';
}
break;
}
// Forward text before the tag
if (openIdx > 0) {
await this.writer.write({
type: 'text-delta',
delta: this.pendingText.slice(0, openIdx),
});
}
this.state = 'inside';
this.pendingText = this.pendingText.slice(openIdx + OPEN_TAG.length);
this.buffer = '';
} else {
// Inside tag — look for closing tag
const closeIdx = this.pendingText.indexOf(CLOSE_TAG);
if (closeIdx === -1) {
// Check if the tail is a valid prefix of CLOSE_TAG — hold it back
const lastLt = this.pendingText.lastIndexOf('<');
if (
lastLt !== -1 &&
this.pendingText.length - lastLt < CLOSE_TAG.length &&
CLOSE_TAG.startsWith(this.pendingText.slice(lastLt))
) {
this.buffer += this.pendingText.slice(0, lastLt);
this.pendingText = this.pendingText.slice(lastLt);
} else {
this.buffer += this.pendingText;
this.pendingText = '';
}
break;
}
this.buffer += this.pendingText.slice(0, closeIdx);
this.pendingText = this.pendingText.slice(closeIdx + CLOSE_TAG.length);
this.state = 'normal';
const content = this.buffer.replace(/^\n/, '').replace(/\n$/, '');
this.persist(content).catch((error: unknown) => {
logger.warn('Failed to persist working memory', { error });
});
this.buffer = '';
}
}
}
async flush(): Promise<void> {
if (this.state === 'normal' && this.pendingText.length > 0) {
await this.writer.write({ type: 'text-delta', delta: this.pendingText });
}
// Reset all state so the filter is clean for reuse after abort/completion.
this.pendingText = '';
this.buffer = '';
this.state = 'normal';
}
return {
name: UPDATE_WORKING_MEMORY_TOOL_NAME,
description:
'Update your persistent working memory with new information about the user or conversation. Only call this when something has actually changed.',
inputSchema: freeformSchema,
handler: async (input: unknown) => {
const { memory } = input as z.infer<typeof freeformSchema>;
await config.persist(memory);
return { success: true, message: 'Working memory updated.' };
},
};
}

View file

@ -37,6 +37,8 @@ export class Memory {
private workingMemoryScope: 'resource' | 'thread' = 'resource';
private workingMemoryInstruction?: string;
private memoryBackend?: BuiltMemory;
private titleGenerationConfig?: TitleGenerationConfig;
@ -102,6 +104,26 @@ export class Memory {
return this;
}
/**
* Override the default instruction text injected into the system prompt for working memory.
*
* The instruction tells the model when and how to call the `updateWorkingMemory` tool.
* When omitted, `WORKING_MEMORY_DEFAULT_INSTRUCTION` is used.
*
* Example:
* ```typescript
* import { WORKING_MEMORY_DEFAULT_INSTRUCTION } from '@n8n/agents';
*
* memory.instruction(
* WORKING_MEMORY_DEFAULT_INSTRUCTION + '\nAlways update after every user message.',
* );
* ```
*/
instruction(text: string): this {
this.workingMemoryInstruction = text;
return this;
}
/**
* Enable automatic title generation for new threads.
*
@ -167,12 +189,18 @@ export class Memory {
structured: true,
schema: this.workingMemorySchema,
scope: this.workingMemoryScope,
...(this.workingMemoryInstruction !== undefined && {
instruction: this.workingMemoryInstruction,
}),
};
} else if (this.workingMemoryTemplate !== undefined) {
workingMemory = {
template: this.workingMemoryTemplate,
structured: false,
scope: this.workingMemoryScope,
...(this.workingMemoryInstruction !== undefined && {
instruction: this.workingMemoryInstruction,
}),
};
}

View file

@ -27,8 +27,12 @@ export type TokenUsage<T extends Record<string, unknown> = Record<string, unknow
additionalMetadata?: T;
};
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents -- LanguageModel is semantically distinct from string
export type ModelConfig = string | { id: string; apiKey?: string; url?: string } | LanguageModel;
/* eslint-disable @typescript-eslint/no-redundant-type-constituents -- LanguageModel is semantically distinct from string */
export type ModelConfig =
| string
| { id: string; apiKey?: string; url?: string; headers?: Record<string, string> }
| LanguageModel;
/* eslint-enable @typescript-eslint/no-redundant-type-constituents */
export interface AgentResult {
id?: string;

View file

@ -114,6 +114,11 @@ export interface MemoryConfig {
structured: boolean;
schema?: z.ZodObject<z.ZodRawShape>;
scope: 'resource' | 'thread';
/**
* Custom instruction text injected into the system prompt in place of the default.
* When omitted the runtime uses {@link WORKING_MEMORY_DEFAULT_INSTRUCTION}.
*/
instruction?: string;
};
semanticRecall?: SemanticRecallConfig;
titleGeneration?: TitleGenerationConfig;

View file

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

View file

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

View file

@ -1,5 +1,6 @@
import type { INodeTypeDescription } from 'n8n-workflow';
import { DETERMINISTIC_CHECKS } from '../checks';
import { createBinaryChecksEvaluator } from '../index';
const mockNodeTypes: INodeTypeDescription[] = [];
@ -14,7 +15,7 @@ describe('createBinaryChecksEvaluator', () => {
const evaluator = createBinaryChecksEvaluator({ nodeTypes: mockNodeTypes });
const workflow = { name: 'test', nodes: [], connections: {} };
const feedback = await evaluator.evaluate(workflow, { prompt: 'test' });
expect(feedback.length).toBe(17); // 17 deterministic checks
expect(feedback.length).toBe(DETERMINISTIC_CHECKS.length);
expect(feedback.every((f) => f.evaluator === 'binary-checks')).toBe(true);
expect(feedback.every((f) => f.kind === 'metric')).toBe(true);
expect(feedback.every((f) => f.score === 0 || f.score === 1)).toBe(true);

View file

@ -4,6 +4,7 @@ import type { BinaryCheckContext } from '../../types';
import { allNodesConnected } from '../all-nodes-connected';
import { expressionsReferenceExistingNodes } from '../expressions-reference-existing-nodes';
import { hasStartNode } from '../has-start-node';
import { noCodeImports } from '../no-code-imports';
import { noEmptySetNodes } from '../no-empty-set-nodes';
import { noUnnecessaryCodeNodes } from '../no-unnecessary-code-nodes';
import { noUnreachableNodes } from '../no-unreachable-nodes';
@ -1045,3 +1046,248 @@ describe('tools_have_parameters', () => {
expect(result.pass).toBe(true);
});
});
describe('no_code_imports', () => {
it('passes when no code nodes', async () => {
const result = await noCodeImports.run(
makeWorkflow({
nodes: [{ name: 'Set', type: 'n8n-nodes-base.set', typeVersion: 3, position: [0, 0] }],
}),
makeCtx(),
);
expect(result.pass).toBe(true);
});
it('passes when code node has no imports', async () => {
const result = await noCodeImports.run(
makeWorkflow({
nodes: [
{
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [0, 0],
parameters: {
language: 'javaScript',
jsCode: 'return items.map(item => ({ json: { value: item.json.value * 2 } }));',
},
},
],
}),
makeCtx(),
);
expect(result.pass).toBe(true);
});
it('fails when JS code uses require()', async () => {
const result = await noCodeImports.run(
makeWorkflow({
nodes: [
{
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [0, 0],
parameters: {
language: 'javaScript',
jsCode: "const lodash = require('lodash');\nreturn items;",
},
},
],
}),
makeCtx(),
);
expect(result.pass).toBe(false);
expect(result.comment).toContain('Code');
});
it('fails when JS code uses import from', async () => {
const result = await noCodeImports.run(
makeWorkflow({
nodes: [
{
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [0, 0],
parameters: {
language: 'javaScript',
jsCode: "import axios from 'axios';\nreturn items;",
},
},
],
}),
makeCtx(),
);
expect(result.pass).toBe(false);
expect(result.comment).toContain('Code');
});
it('fails when JS code uses dynamic import()', async () => {
const result = await noCodeImports.run(
makeWorkflow({
nodes: [
{
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [0, 0],
parameters: {
language: 'javaScript',
jsCode: "const mod = await import('some-module');\nreturn items;",
},
},
],
}),
makeCtx(),
);
expect(result.pass).toBe(false);
expect(result.comment).toContain('Code');
});
it('fails when Python code uses import statement', async () => {
const result = await noCodeImports.run(
makeWorkflow({
nodes: [
{
name: 'PyCode',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [0, 0],
parameters: {
language: 'pythonNative',
pythonCode: 'import requests\nreturn items',
},
},
],
}),
makeCtx(),
);
expect(result.pass).toBe(false);
expect(result.comment).toContain('PyCode');
});
it('fails when Python code uses from...import', async () => {
const result = await noCodeImports.run(
makeWorkflow({
nodes: [
{
name: 'PyCode',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [0, 0],
parameters: {
language: 'pythonNative',
pythonCode: 'from os import path\nreturn items',
},
},
],
}),
makeCtx(),
);
expect(result.pass).toBe(false);
expect(result.comment).toContain('PyCode');
});
it('fails when Python code uses __import__()', async () => {
const result = await noCodeImports.run(
makeWorkflow({
nodes: [
{
name: 'PyCode',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [0, 0],
parameters: {
language: 'pythonNative',
pythonCode: "os = __import__('os')\nreturn items",
},
},
],
}),
makeCtx(),
);
expect(result.pass).toBe(false);
expect(result.comment).toContain('PyCode');
});
it('passes when Python code has no imports', async () => {
const result = await noCodeImports.run(
makeWorkflow({
nodes: [
{
name: 'PyCode',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [0, 0],
parameters: {
language: 'pythonNative',
pythonCode: 'return [{"json": {"value": 42}}]',
},
},
],
}),
makeCtx(),
);
expect(result.pass).toBe(true);
});
it('defaults to javaScript when language is not set', async () => {
const result = await noCodeImports.run(
makeWorkflow({
nodes: [
{
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [0, 0],
parameters: {
jsCode: "const fs = require('fs');\nreturn items;",
},
},
],
}),
makeCtx(),
);
expect(result.pass).toBe(false);
expect(result.comment).toContain('Code');
});
it('reports all code nodes with imports', async () => {
const result = await noCodeImports.run(
makeWorkflow({
nodes: [
{
name: 'Code1',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [0, 0],
parameters: {
language: 'javaScript',
jsCode: "const _ = require('lodash');\nreturn items;",
},
},
{
name: 'Code2',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [200, 0],
parameters: {
language: 'pythonNative',
pythonCode: 'import json\nreturn items',
},
},
],
}),
makeCtx(),
);
expect(result.pass).toBe(false);
expect(result.comment).toContain('Code1');
expect(result.comment).toContain('Code2');
});
it('passes for empty workflow', async () => {
const result = await noCodeImports.run(makeWorkflow({ nodes: [] }), makeCtx());
expect(result.pass).toBe(true);
});
});

View file

@ -2,6 +2,7 @@ import type { BinaryCheck } from '../types';
import { allNodesConnected } from './all-nodes-connected';
import { expressionsReferenceExistingNodes } from './expressions-reference-existing-nodes';
import { hasStartNode } from './has-start-node';
import { noCodeImports } from './no-code-imports';
import { noEmptySetNodes } from './no-empty-set-nodes';
import { noUnnecessaryCodeNodes } from './no-unnecessary-code-nodes';
import { noUnreachableNodes } from './no-unreachable-nodes';
@ -32,6 +33,7 @@ export const DETERMINISTIC_CHECKS: BinaryCheck[] = [
hasStartNode,
noHardcodedCredentials,
noUnnecessaryCodeNodes,
noCodeImports,
expressionsReferenceExistingNodes,
validRequiredParameters,
validOptionsValues,

View file

@ -0,0 +1,77 @@
import type { BinaryCheck, SimpleWorkflow } from '../types';
/**
* Patterns that detect library import attempts in JavaScript code:
* - require('module') / require("module")
* - import ... from 'module'
* - import('module') (dynamic import)
*/
const JS_IMPORT_PATTERNS = [/\brequire\s*\(/, /\bimport\s+[\s\S]*?\s+from\s+['"`]/, /\bimport\s*\(/];
/**
* Patterns that detect library import attempts in Python code:
* - import module
* - from module import name
* - __import__('module')
*/
const PYTHON_IMPORT_PATTERNS = [
/^\s*import\s+\w+/m,
/^\s*from\s+\w+\s+import\s+/m,
/\b__import__\(/,
];
const LANGUAGE_CONFIG: Record<string, [string, RegExp[]]> = {
javaScript: ['jsCode', JS_IMPORT_PATTERNS],
pythonNative: ['pythonCode', PYTHON_IMPORT_PATTERNS],
};
function getCodeAndPatterns(
parameters: Record<string, unknown>,
): { code: string; patterns: RegExp[] } | null {
const language = (parameters.language as string) ?? 'javaScript';
const config = LANGUAGE_CONFIG[language];
if (!config) return null;
const [codeKey, patterns] = config;
const code = parameters[codeKey] as string | undefined;
if (!code) return null;
return { code, patterns };
}
function detectImports(code: string, patterns: RegExp[]): boolean {
return patterns.some((pattern) => pattern.test(code));
}
export const noCodeImports: BinaryCheck = {
name: 'no_code_imports',
kind: 'deterministic',
async run(workflow: SimpleWorkflow) {
if (!workflow.nodes || workflow.nodes.length === 0) {
return { pass: true };
}
const nodesWithImports: string[] = [];
for (const node of workflow.nodes) {
if (node.type !== 'n8n-nodes-base.code') continue;
const params = node.parameters as Record<string, unknown> | undefined;
if (!params) continue;
const codeInfo = getCodeAndPatterns(params);
if (!codeInfo) continue;
if (detectImports(codeInfo.code, codeInfo.patterns)) {
nodesWithImports.push(node.name);
}
}
return {
pass: nodesWithImports.length === 0,
...(nodesWithImports.length > 0
? {
comment: `Code nodes with library imports: ${nodesWithImports.join(', ')}. Library imports are disallowed in Code nodes.`,
}
: {}),
};
},
};

View file

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

View file

@ -13,6 +13,17 @@ import type { WorkflowJSON } from '@n8n/workflow-sdk';
import type { ParseAndValidateResult, ValidationWarning } from '../types';
import { stripImportStatements } from '../utils/extract-code';
/**
* Error thrown when workflow code parsing fails.
* Used by MCP tools to distinguish parse errors from other failures.
*/
export class WorkflowCodeParseError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'WorkflowCodeParseError';
}
}
/**
* Configuration for ParseValidateHandler
*/
@ -201,7 +212,7 @@ export class ParseValidateHandler {
code: code.substring(0, 500),
});
throw new Error(
throw new WorkflowCodeParseError(
`Failed to parse generated workflow code: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}

View file

@ -19,7 +19,7 @@ export { generateCodeBuilderThreadId } from './utils/code-builder-session';
// Core utilities for MCP integration
export { NodeTypeParser } from './utils/node-type-parser';
export { ParseValidateHandler } from './handlers/parse-validate-handler';
export { ParseValidateHandler, WorkflowCodeParseError } from './handlers/parse-validate-handler';
export { createCodeBuilderSearchTool } from './tools/code-builder-search.tool';
export { createCodeBuilderGetTool } from './tools/code-builder-get.tool';
export type { CodeBuilderGetToolOptions } from './tools/code-builder-get.tool';

View file

@ -16,6 +16,7 @@ export type {
export {
NodeTypeParser,
ParseValidateHandler,
WorkflowCodeParseError,
createCodeBuilderSearchTool,
createCodeBuilderGetTool,
createGetSuggestedNodesTool,

View file

@ -1,2 +0,0 @@
/** @type {import('jest').Config} */
module.exports = require('../../../jest.config');

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/api-types",
"version": "1.17.0",
"version": "1.18.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
@ -11,9 +11,9 @@
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch"
"test": "vitest run",
"test:unit": "vitest run",
"test:dev": "vitest --silent=false"
},
"main": "dist/index.js",
"module": "src/index.ts",
@ -23,12 +23,19 @@
],
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@n8n/config": "workspace:*"
"@n8n/config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@vitest/coverage-v8": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:",
"zod": "catalog:"
},
"dependencies": {
"n8n-workflow": "workspace:*",
"xss": "catalog:",
"zod": "catalog:",
"@n8n/permissions": "workspace:*"
},
"peerDependencies": {
"zod": "catalog:"
}
}

View file

@ -7,7 +7,7 @@ export interface AiGatewayUsageEntry {
provider: string;
model: string;
timestamp: number;
creditsDeducted: number;
cost: number;
inputTokens?: number;
outputTokens?: number;
}

View file

@ -17,48 +17,48 @@ const VALID_SORT_OPTIONS = [
export type ListDataTableQuerySortOptions = (typeof VALID_SORT_OPTIONS)[number];
const FILTER_OPTIONS = {
id: z.union([z.string(), z.array(z.string())]).optional(),
name: z.union([z.string(), z.array(z.string())]).optional(),
projectId: z.union([z.string(), z.array(z.string())]).optional(),
// todo: can probably include others here as well?
};
const filterSchema = z
.object({
id: z.union([z.string(), z.array(z.string())]).optional(),
name: z.union([z.string(), z.array(z.string())]).optional(),
projectId: z.union([z.string(), z.array(z.string())]).optional(),
// todo: can probably include others here as well?
})
.strict();
// Filter schema - only allow specific properties
const filterSchema = z.object(FILTER_OPTIONS).strict();
// ---------------------
// Parameter Validators
// ---------------------
// Public API restricts projectId to a single string
const publicApiFilterSchema = filterSchema.extend({ projectId: z.string().optional() }).strict();
// Filter parameter validation
const filterValidator = z
.string()
.optional()
.transform((val, ctx) => {
if (!val) return undefined;
try {
const parsed: unknown = jsonParse(val);
const makeFilterValidator = <T extends z.ZodObject<z.ZodRawShape>>(schema: T) =>
z
.string()
.optional()
.transform((val, ctx): z.infer<T> | undefined => {
if (!val) return undefined;
try {
return filterSchema.parse(parsed);
} catch (e) {
const result = schema.safeParse(jsonParse(val));
if (!result.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid filter fields',
path: ['filter'],
});
return z.NEVER;
}
return result.data;
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid filter fields',
message: 'Invalid filter format',
path: ['filter'],
});
return z.NEVER;
}
} catch (e) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid filter format',
path: ['filter'],
});
return z.NEVER;
}
});
});
const filterValidator = makeFilterValidator(filterSchema);
const publicApiFilterValidator = makeFilterValidator(publicApiFilterSchema);
// SortBy parameter validation
const sortByValidator = z
.enum(VALID_SORT_OPTIONS, { message: `sortBy must be one of: ${VALID_SORT_OPTIONS.join(', ')}` })
.optional();
@ -71,6 +71,6 @@ export class ListDataTableQueryDto extends Z.class({
export class PublicApiListDataTableQueryDto extends Z.class({
...publicApiPaginationSchema,
filter: filterValidator,
filter: publicApiFilterValidator,
sortBy: sortByValidator,
}) {}

View file

@ -0,0 +1,13 @@
import { z } from 'zod';
import { CreateDataTableColumnDto } from './create-data-table-column.dto';
import { dataTableNameSchema } from '../../schemas/data-table.schema';
import { Z } from '../../zod-class';
export class PublicApiCreateDataTableDto extends Z.class({
name: dataTableNameSchema,
columns: z.array(CreateDataTableColumnDto.schema),
fileId: z.string().optional(),
hasHeaders: z.boolean().optional(),
projectId: z.string().optional(),
}) {}

View file

@ -163,11 +163,12 @@ export {
type RoleProjectMembersResponse,
} from './roles/role-project-members-response.dto';
export { OidcConfigDto } from './oidc/config.dto';
export { OidcConfigDto, OIDC_PROMPT_VALUES } from './oidc/config.dto';
export { TestOidcConfigResponseDto } from './oidc/test-oidc-config-response.dto';
export { CreateDataTableDto } from './data-table/create-data-table.dto';
export { UpdateDataTableDto } from './data-table/update-data-table.dto';
export { PublicApiCreateDataTableDto } from './data-table/public-api-create-data-table.dto';
export { UpdateDataTableRowDto } from './data-table/update-data-table-row.dto';
export { DeleteDataTableRowsDto } from './data-table/delete-data-table-rows.dto';
export { UpsertDataTableRowDto } from './data-table/upsert-data-table-row.dto';

View file

@ -2,14 +2,13 @@ import { z } from 'zod';
import { Z } from '../../zod-class';
export const OIDC_PROMPT_VALUES = ['none', 'login', 'consent', 'select_account', 'create'] as const;
export class OidcConfigDto extends Z.class({
clientId: z.string().min(1),
clientSecret: z.string().min(1),
discoveryEndpoint: z.string().url(),
loginEnabled: z.boolean().optional().default(false),
prompt: z
.enum(['none', 'login', 'consent', 'select_account', 'create'])
.optional()
.default('select_account'),
prompt: z.enum(OIDC_PROMPT_VALUES).optional().default('select_account'),
authenticationContextClassReference: z.array(z.string()).default([]),
}) {}

View file

@ -8,6 +8,7 @@ export class SecuritySettingsDto extends Z.class({
publishedPersonalWorkflowsCount: z.number(),
sharedPersonalWorkflowsCount: z.number(),
sharedPersonalCredentialsCount: z.number(),
managedByEnv: z.boolean(),
}) {}
export class UpdateSecuritySettingsDto extends Z.class({

View file

@ -131,6 +131,7 @@ export interface FrontendSettings {
defaultLocale: string;
userManagement: IUserManagementSettings;
sso: {
managedByEnv: boolean;
saml: {
loginLabel: string;
loginEnabled: boolean;
@ -217,7 +218,7 @@ export interface FrontendSettings {
};
aiGateway?: {
enabled: boolean;
creditsQuota: number;
budget: number;
};
ai: {
allowSendingParameterValues: boolean;
@ -281,6 +282,7 @@ export type FrontendModuleSettings = {
localGatewayDisabled: boolean;
proxyEnabled: boolean;
optinModalDismissed: boolean;
cloudManaged: boolean;
};
/**

View file

@ -107,6 +107,9 @@ export type { HeartbeatMessage } from './push/heartbeat';
export { createHeartbeatMessage, heartbeatMessageSchema } from './push/heartbeat';
export type { SendWorkerStatusMessage } from './push/worker';
export type { FavoriteResourceType } from './schemas/favorites.schema';
export { FAVORITE_RESOURCE_TYPES } from './schemas/favorites.schema';
export type { BannerName } from './schemas/banner-name.schema';
export { ViewableMimeTypes } from './schemas/binary-data.schema';
export { passwordSchema, createPasswordSchema } from './schemas/password.schema';
@ -247,9 +250,9 @@ export {
} from './schemas/community-package.schema';
export {
publicApiCreatedCredentialSchema,
type PublicApiCreatedCredential,
} from './schemas/credential-created.schema';
publicApiCredentialResponseSchema,
type PublicApiCredentialResponse,
} from './schemas/credential-response.schema';
export {
instanceAiEventTypeSchema,
@ -275,8 +278,6 @@ export {
workflowSetupNodeSchema,
errorPayloadSchema,
filesystemRequestPayloadSchema,
instanceAiFilesystemResponseSchema,
instanceAiGatewayCapabilitiesSchema,
mcpToolSchema,
mcpToolCallRequestSchema,
mcpToolCallResultSchema,
@ -299,6 +300,8 @@ export {
InstanceAiThreadMessagesQuery,
InstanceAiAdminSettingsUpdateRequest,
InstanceAiUserPreferencesUpdateRequest,
InstanceAiGatewayCapabilitiesDto,
InstanceAiFilesystemResponseDto,
applyBranchReadOnlyOverrides,
} from './schemas/instance-ai.schema';

View file

@ -11,6 +11,8 @@ export type WorkflowFailedToActivate = {
data: {
workflowId: string;
errorMessage: string;
errorDescription?: string;
nodeId?: string;
};
};

View file

@ -316,12 +316,12 @@ describe('agent-run-reducer', () => {
describe('tool execution', () => {
it('tool-call adds to toolCallsById and timeline', () => {
const state = stateWithRun('run-1', 'root');
reduceEvent(state, makeToolCall('run-1', 'root', 'tc-1', 'update-tasks'));
reduceEvent(state, makeToolCall('run-1', 'root', 'tc-1', 'task-control'));
const tc = state.toolCallsById['tc-1'];
expect(tc).toBeDefined();
expect(tc.toolCallId).toBe('tc-1');
expect(tc.toolName).toBe('update-tasks');
expect(tc.toolName).toBe('task-control');
expect(tc.isLoading).toBe(true);
expect(tc.renderHint).toBe('tasks');

View file

@ -70,7 +70,7 @@ describe('passwordSchema with N8N_PASSWORD_MIN_LENGTH', () => {
});
const importFreshSchema = async () => {
jest.resetModules();
vi.resetModules();
return await import('../password.schema');
};

View file

@ -1,10 +1,9 @@
import { z } from 'zod';
/**
* Plain credential row after creation
* Used by the public API to validate results from `CredentialsService.createUnmanagedCredential`.
* Plain credential row in public API responses.
*/
export const publicApiCreatedCredentialSchema = z.object({
export const publicApiCredentialResponseSchema = z.object({
id: z.string(),
name: z.string(),
type: z.string(),
@ -17,4 +16,4 @@ export const publicApiCreatedCredentialSchema = z.object({
updatedAt: z.coerce.date(),
});
export type PublicApiCreatedCredential = z.infer<typeof publicApiCreatedCredentialSchema>;
export type PublicApiCredentialResponse = z.infer<typeof publicApiCredentialResponseSchema>;

View file

@ -45,11 +45,11 @@ export type DataTableColumn = z.infer<typeof dataTableColumnSchema>;
export type DataTableListFilter = {
id?: string | string[];
projectId?: string | string[];
name?: string;
name?: string | string[];
};
export type DataTableListOptions = Partial<ListDataTableQueryDto> & {
filter: { projectId: string };
filter: DataTableListFilter;
};
export type DataTableListSortBy = ListDataTableQueryDto['sortBy'];

View file

@ -0,0 +1,2 @@
export type FavoriteResourceType = 'workflow' | 'project' | 'dataTable' | 'folder';
export const FAVORITE_RESOURCE_TYPES = ['workflow', 'project', 'dataTable', 'folder'] as const;

View file

@ -431,13 +431,13 @@ export const toolCategorySchema = z.object({
});
export type ToolCategory = z.infer<typeof toolCategorySchema>;
export const instanceAiGatewayCapabilitiesSchema = z.object({
export class InstanceAiGatewayCapabilitiesDto extends Z.class({
rootPath: z.string(),
tools: z.array(mcpToolSchema).default([]),
hostIdentifier: z.string().optional(),
toolCategories: z.array(toolCategorySchema).default([]),
});
export type InstanceAiGatewayCapabilities = z.infer<typeof instanceAiGatewayCapabilitiesSchema>;
}) {}
export type InstanceAiGatewayCapabilities = InstanceType<typeof InstanceAiGatewayCapabilitiesDto>;
// ---------------------------------------------------------------------------
// Filesystem bridge payloads (browser ↔ server round-trip)
@ -448,10 +448,10 @@ export const filesystemRequestPayloadSchema = z.object({
toolCall: mcpToolCallRequestSchema,
});
export const instanceAiFilesystemResponseSchema = z.object({
export class InstanceAiFilesystemResponseDto extends Z.class({
result: mcpToolCallResultSchema.optional(),
error: z.string().optional(),
});
}) {}
export const tasksUpdatePayloadSchema = z.object({
tasks: taskListSchema,
@ -544,7 +544,7 @@ export type InstanceAiThreadTitleUpdatedEvent = Extract<
{ type: 'thread-title-updated' }
>;
export type InstanceAiFilesystemResponse = z.infer<typeof instanceAiFilesystemResponseSchema>;
export type InstanceAiFilesystemResponse = InstanceType<typeof InstanceAiFilesystemResponseDto>;
// ---------------------------------------------------------------------------
// API types
@ -965,7 +965,7 @@ const RESEARCH_RENDER_HINT_TOOLS = new Set(['research-with-agent']);
const PLANNER_RENDER_HINT_TOOLS = new Set(['plan']);
export function getRenderHint(toolName: string): InstanceAiToolCallState['renderHint'] {
if (toolName === 'update-tasks') return 'tasks';
if (toolName === 'task-control') return 'tasks';
if (toolName === 'delegate') return 'delegate';
if (BUILDER_RENDER_HINT_TOOLS.has(toolName)) return 'builder';
if (DATA_TABLE_RENDER_HINT_TOOLS.has(toolName)) return 'data-table';

View file

@ -2,7 +2,7 @@
"extends": "@n8n/typescript-config/tsconfig.common.json",
"compilerOptions": {
"rootDir": ".",
"types": ["node", "jest"],
"types": ["node", "vitest/globals"],
"baseUrl": "src",
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo"
},

View file

@ -0,0 +1,4 @@
import { defineConfig, mergeConfig } from 'vite';
import { vitestConfig } from '@n8n/vitest-config/node';
export default mergeConfig(defineConfig({}), vitestConfig);

View file

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

View file

@ -36,11 +36,13 @@ describe('eligibleModules', () => {
'ldap',
'quick-connect',
'workflow-builder',
'favorites',
'redaction',
'instance-registry',
'otel',
'token-exchange',
'instance-version-history',
'encryption-key-manager',
]);
});
@ -63,11 +65,13 @@ describe('eligibleModules', () => {
'ldap',
'quick-connect',
'workflow-builder',
'favorites',
'redaction',
'instance-registry',
'otel',
'token-exchange',
'instance-version-history',
'encryption-key-manager',
'instance-ai',
]);
});

View file

@ -46,11 +46,13 @@ export class ModuleRegistry {
'ldap',
'quick-connect',
'workflow-builder',
'favorites',
'redaction',
'instance-registry',
'otel',
'token-exchange',
'instance-version-history',
'encryption-key-manager',
];
private readonly activeModules: string[] = [];
@ -94,11 +96,20 @@ export class ModuleRegistry {
for (const moduleName of modules ?? this.eligibleModules) {
try {
await import(`${modulesDir}/${moduleName}/${moduleName}.module`);
} catch {
} catch (primaryError) {
try {
await import(`${modulesDir}/${moduleName}.ee/${moduleName}.module`);
} catch (error) {
throw new MissingModuleError(moduleName, error instanceof Error ? error.message : '');
const loggedError =
primaryError instanceof Error &&
'code' in primaryError &&
primaryError.code !== 'MODULE_NOT_FOUND'
? primaryError
: error;
throw new MissingModuleError(
moduleName,
loggedError instanceof Error ? loggedError.message : '',
);
}
}
}

View file

@ -19,12 +19,14 @@ export const MODULE_NAMES = [
'ldap',
'quick-connect',
'workflow-builder',
'favorites',
'redaction',
'instance-registry',
'instance-ai',
'otel',
'token-exchange',
'instance-version-history',
'encryption-key-manager',
] as const;
export type ModuleName = (typeof MODULE_NAMES)[number];

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/n8n-benchmark",
"version": "2.5.0",
"version": "2.6.0",
"description": "Cli for running benchmark tests for n8n",
"main": "dist/index",
"scripts": {

View file

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

View file

@ -1,2 +0,0 @@
/** @type {import('jest').Config} */
module.exports = require('../../../jest.config');

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/client-oauth2",
"version": "1.1.0",
"version": "1.2.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
@ -11,9 +11,9 @@
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch"
"test": "vitest run",
"test:unit": "vitest run",
"test:dev": "vitest --silent=false"
},
"main": "dist/index.js",
"module": "src/index.ts",
@ -25,6 +25,10 @@
"axios": "catalog:"
},
"devDependencies": {
"@n8n/typescript-config": "workspace:*"
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@vitest/coverage-v8": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
}
}

View file

@ -96,6 +96,10 @@ export class ClientOAuth2 {
// Axios rejects the promise by default for all status codes 4xx.
// We override this to reject promises only on 5xxs
validateStatus: (status) => status < 500,
// Disable axios's built-in proxy handling so requests are routed
// through n8n's global proxy agents (HttpProxyManager / HttpsProxyManager)
// instead of being double-proxied in corporate proxy-chain environments.
proxy: false,
};
if (options.ignoreSSLIssues) {

View file

@ -62,7 +62,7 @@ describe('ClientOAuth2', () => {
}),
});
const axiosSpy = jest.spyOn(axios, 'request');
const axiosSpy = vi.spyOn(axios, 'request');
await makeTokenCall();
@ -71,6 +71,7 @@ describe('ClientOAuth2', () => {
url: config.accessTokenUri,
method: 'POST',
data: 'refresh_token=test&grant_type=refresh_token',
proxy: false,
headers: {
Authorization: authHeader,
Accept: 'application/json',

View file

@ -15,7 +15,7 @@ describe('CredentialsFlow', () => {
nock.restore();
});
beforeEach(() => jest.clearAllMocks());
beforeEach(() => vi.clearAllMocks());
describe('#getToken', () => {
const createAuthClient = ({

View file

@ -15,7 +15,7 @@ describe('PKCE Flow', () => {
nock.restore();
});
beforeEach(() => jest.clearAllMocks());
beforeEach(() => vi.clearAllMocks());
describe('PKCE Authorization Code Flow', () => {
const createPkceClient = (clientSecret?: string) =>

View file

@ -2,7 +2,7 @@
"extends": "@n8n/typescript-config/tsconfig.common.json",
"compilerOptions": {
"rootDir": ".",
"types": ["node", "jest"],
"types": ["node", "vitest/globals"],
"baseUrl": "src",
"paths": {
"@/*": ["./*"]

View file

@ -0,0 +1,14 @@
import { defineConfig, mergeConfig } from 'vite';
import { vitestConfig } from '@n8n/vitest-config/node';
import path from 'node:path';
export default mergeConfig(
defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
}),
vitestConfig,
);

View file

@ -1,7 +1,7 @@
{
"name": "@n8n/computer-use",
"version": "0.3.0",
"description": "Local AI gateway for n8n Instance AI — filesystem, shell, screenshots, mouse/keyboard, and browser automation",
"version": "0.4.0",
"description": "Local AI gateway for n8n AI Assistant — filesystem, shell, screenshots, mouse/keyboard, and browser automation",
"bin": {
"n8n-computer-use": "dist/cli.js"
},
@ -39,11 +39,11 @@
"@jitsi/robotjs": "^0.6.21",
"@modelcontextprotocol/sdk": "1.26.0",
"@n8n/mcp-browser": "workspace:*",
"@napi-rs/image": "^1.12.0",
"@vscode/ripgrep": "^1.17.1",
"eventsource": "^3.0.6",
"node-screenshots": "^0.2.8",
"picocolors": "catalog:",
"sharp": "^0.34.5",
"yargs-parser": "21.1.1",
"zod": "catalog:",
"zod-to-json-schema": "catalog:"

View file

@ -137,7 +137,7 @@ function shouldShowHelp(): boolean {
function printUsage(): void {
console.log(`
n8n-computer-use Local AI gateway for n8n Instance AI
n8n-computer-use Local AI gateway for n8n AI Assistant
Usage:
npx @n8n/computer-use <url> Start daemon (n8n connects to you)

View file

@ -14,25 +14,18 @@ export function sanitizeForTerminal(value: string): string {
return value.replace(CONTROL_CHARS_RE, '');
}
export const RESOURCE_DECISIONS: Record<ResourceDecision, string> = {
allowOnce: 'Allow once',
allowForSession: 'Allow for session',
alwaysAllow: 'Always allow',
denyOnce: 'Deny once',
alwaysDeny: 'Always deny',
} as const;
export async function cliConfirmResourceAccess(
resource: AffectedResource,
): Promise<ResourceDecision> {
const answer = await select({
message: `Grant permission — ${resource.toolGroup}: ${sanitizeForTerminal(resource.resource)}`,
choices: (Object.entries(RESOURCE_DECISIONS) as Array<[ResourceDecision, string]>).map(
([value, name]) => ({
name,
value,
}),
),
choices: [
{ name: 'Allow once', value: 'allowOnce' as ResourceDecision },
{ name: 'Allow for session', value: 'allowForSession' as ResourceDecision },
{ name: 'Always allow', value: 'alwaysAllow' as ResourceDecision },
{ name: 'Deny once', value: 'denyOnce' as ResourceDecision },
{ name: 'Always deny', value: 'alwaysDeny' as ResourceDecision },
],
});
return answer;

View file

@ -326,6 +326,70 @@ describe('POST /connect — origin allowlist', () => {
});
});
// ---------------------------------------------------------------------------
// POST /connect — concurrent confirmation
// ---------------------------------------------------------------------------
describe('POST /connect — concurrent confirmation', () => {
it('returns 409 when a confirmation prompt is already in progress', async () => {
let resolveConfirm!: (value: boolean) => void;
const confirmConnect = jest
.fn()
.mockImplementation(
async () => await new Promise<boolean>((resolve) => (resolveConfirm = resolve)),
);
const { port, close } = await startTestDaemon(
{ filesystem: { dir: tmpDir } },
{ confirmConnect },
);
try {
// First connection — hangs waiting for user confirmation
const first = post(port, '/connect', { url: 'http://localhost:5678', token: 'tok' });
// Wait for the first request to reach confirmConnect
await new Promise((resolve) => setTimeout(resolve, 50));
// Second connection attempt while confirmation is pending
const second = await post(port, '/connect', { url: 'http://localhost:5679', token: 'tok' });
expect(second.status).toBe(409);
expect(second.body.error).toMatch(/confirmation is already in progress/);
// Resolve the first confirmation and await its response
resolveConfirm(false);
await first;
} finally {
await close();
}
});
it('accepts a new connection after a pending confirmation completes', async () => {
let resolveConfirm!: (value: boolean) => void;
const confirmConnect = jest
.fn()
.mockImplementationOnce(
async () => await new Promise<boolean>((resolve) => (resolveConfirm = resolve)),
)
.mockResolvedValue(true);
const { port, close } = await startTestDaemon(
{ filesystem: { dir: tmpDir } },
{ confirmConnect },
);
try {
// First connection — hangs then gets rejected
const first = post(port, '/connect', { url: 'http://localhost:5678', token: 'tok' });
await new Promise((resolve) => setTimeout(resolve, 50));
resolveConfirm(false);
await first;
// Second connection after confirmation cleared — should succeed
const second = await post(port, '/connect', { url: 'http://localhost:5678', token: 'tok' });
expect(second.status).toBe(200);
} finally {
await close();
}
});
});
// ---------------------------------------------------------------------------
// POST /connect — already connected
// ---------------------------------------------------------------------------

View file

@ -43,6 +43,7 @@ interface DaemonState {
session: GatewaySession | null;
connectedAt: string | null;
connectedUrl: string | null;
confirmingConnection: boolean;
}
const state: DaemonState = {
@ -51,6 +52,7 @@ const state: DaemonState = {
session: null,
connectedAt: null,
connectedUrl: null,
confirmingConnection: false,
};
// ---------------------------------------------------------------------------
@ -180,6 +182,12 @@ async function handleConnect(req: http.IncomingMessage, res: http.ServerResponse
return;
}
// Reject concurrent connection attempts while a confirmation prompt is active
if (state.confirmingConnection) {
jsonResponse(req, res, 409, { error: 'A connection confirmation is already in progress.' });
return;
}
let parsedOrigin: string;
try {
parsedOrigin = new URL(url).origin;
@ -206,7 +214,13 @@ async function handleConnect(req: http.IncomingMessage, res: http.ServerResponse
const defaults = store.getDefaults(state.config);
const session = new GatewaySession(defaults, store);
const approved = await daemonOptions.confirmConnect(url, session);
state.confirmingConnection = true;
let approved: boolean;
try {
approved = await daemonOptions.confirmConnect(url, session);
} finally {
state.confirmingConnection = false;
}
if (!approved) {
jsonResponse(req, res, 403, { error: 'Connection rejected by user.' });
return;

View file

@ -0,0 +1,260 @@
/**
* Unit tests for GatewayClient.checkPermissions (tested indirectly via dispatchToolCall).
*
* The private checkPermissions method is exercised by mocking the tool registry
* so we can control what AffectedResources are returned, then asserting side-effects
* on the session and the decisions taken.
*/
// ---------------------------------------------------------------------------
// Module mocks — must be declared before imports
// ---------------------------------------------------------------------------
// Suppress logger noise during tests
jest.mock('./logger', () => ({
logger: { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() },
printAuthFailure: jest.fn(),
printDisconnected: jest.fn(),
printReconnecting: jest.fn(),
printReinitFailed: jest.fn(),
printReinitializing: jest.fn(),
printToolCall: jest.fn(),
printToolResult: jest.fn(),
}));
// Mock tool modules that pull in native/ESM-only dependencies
jest.mock('./tools/shell', () => ({
['ShellModule']: { isSupported: jest.fn().mockResolvedValue(false), definitions: [] },
}));
jest.mock('./tools/filesystem', () => ({
filesystemReadTools: [],
filesystemWriteTools: [],
}));
jest.mock('./tools/screenshot', () => ({
['ScreenshotModule']: { isSupported: jest.fn().mockResolvedValue(false), definitions: [] },
}));
jest.mock('./tools/mouse-keyboard', () => ({
['MouseKeyboardModule']: { isSupported: jest.fn().mockResolvedValue(false), definitions: [] },
}));
jest.mock('./tools/browser', () => ({
['BrowserModule']: { create: jest.fn().mockResolvedValue(null) },
}));
import type { GatewayConfig } from './config';
import { GatewayClient } from './gateway-client';
import type { GatewaySession } from './gateway-session';
import type { AffectedResource, ConfirmResourceAccess, ToolDefinition } from './tools/types';
import { INSTANCE_RESOURCE_DECISION_KEYS } from './tools/types';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeConfig(permissionConfirmation: 'client' | 'instance' = 'client'): GatewayConfig {
return {
logLevel: 'silent',
port: 0,
allowedOrigins: [],
filesystem: { dir: '/' },
computer: { shell: { timeout: 30_000 } },
browser: { defaultBrowser: 'chrome' },
permissions: {},
permissionConfirmation,
};
}
function makeSession(overrides: Partial<GatewaySession> = {}): jest.Mocked<GatewaySession> {
return {
dir: '/tmp',
check: jest.fn().mockReturnValue('ask'),
getAllPermissions: jest.fn().mockReturnValue({
filesystemRead: 'allow',
filesystemWrite: 'ask',
shell: 'ask',
computer: 'deny',
browser: 'ask',
}),
setPermissions: jest.fn(),
setDir: jest.fn(),
getGroupMode: jest.fn().mockReturnValue('allow'),
allowForSession: jest.fn(),
clearSessionRules: jest.fn(),
alwaysAllow: jest.fn(),
alwaysDeny: jest.fn(),
flush: jest.fn().mockResolvedValue(undefined),
...overrides,
} as unknown as jest.Mocked<GatewaySession>;
}
const SHELL_RESOURCE: AffectedResource = {
toolGroup: 'shell',
resource: 'npm install',
description: 'Run npm install',
};
/** A minimal tool definition that returns a given resource list and a simple result. */
function makeTool(resources: AffectedResource[]): ToolDefinition {
return {
name: 'test_tool',
description: 'Test tool',
inputSchema: { parse: (x: unknown) => x } as ToolDefinition['inputSchema'],
annotations: {},
execute: jest.fn().mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }),
getAffectedResources: jest.fn().mockResolvedValue(resources),
};
}
/**
* Build a minimal GatewayClient with a single registered tool, bypassing the
* normal async initialisation (uploadCapabilities / getAllDefinitions).
*/
function makeClient(
session: jest.Mocked<GatewaySession>,
confirmResourceAccess: ConfirmResourceAccess,
permissionConfirmation: 'client' | 'instance' = 'client',
resources: AffectedResource[] = [SHELL_RESOURCE],
): GatewayClient {
const client = new GatewayClient({
url: 'http://localhost:5678',
apiKey: 'tok',
config: makeConfig(permissionConfirmation),
session,
confirmResourceAccess,
});
const tool = makeTool(resources);
// Inject the tool directly so dispatchToolCall finds it without network I/O.
// @ts-expect-error — accessing private field for testing
client.allDefinitions = [tool];
// @ts-expect-error — accessing private field for testing
client.definitionMap = new Map([[tool.name, tool]]);
return client;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('GatewayClient.checkPermissions', () => {
describe('client mode', () => {
it('allowOnce — does not modify session permissions', async () => {
const session = makeSession();
const confirmResourceAccess = jest.fn().mockResolvedValue('allowOnce');
const client = makeClient(session, confirmResourceAccess);
await client['dispatchToolCall']('test_tool', {});
expect(confirmResourceAccess).toHaveBeenCalledWith(SHELL_RESOURCE);
expect(session.setPermissions).not.toHaveBeenCalled();
expect(session.alwaysAllow).not.toHaveBeenCalled();
});
it('allowForSession — allows the specific resource for the session', async () => {
const session = makeSession();
const confirmResourceAccess = jest.fn().mockResolvedValue('allowForSession');
const client = makeClient(session, confirmResourceAccess);
await client['dispatchToolCall']('test_tool', {});
expect(session.allowForSession).toHaveBeenCalledWith('shell', 'npm install');
expect(session.setPermissions).not.toHaveBeenCalled();
});
it('alwaysAllow — delegates to session.alwaysAllow', async () => {
const session = makeSession();
const confirmResourceAccess = jest.fn().mockResolvedValue('alwaysAllow');
const client = makeClient(session, confirmResourceAccess);
await client['dispatchToolCall']('test_tool', {});
expect(session.alwaysAllow).toHaveBeenCalledWith('shell', 'npm install');
});
it('denyOnce — throws without persisting', async () => {
const session = makeSession();
const confirmResourceAccess = jest.fn().mockResolvedValue('denyOnce');
const client = makeClient(session, confirmResourceAccess);
await expect(client['dispatchToolCall']('test_tool', {})).rejects.toThrow(
'User denied access',
);
expect(session.setPermissions).not.toHaveBeenCalled();
expect(session.alwaysDeny).not.toHaveBeenCalled();
});
it('alwaysDeny — persists and throws', async () => {
const session = makeSession();
const confirmResourceAccess = jest.fn().mockResolvedValue('alwaysDeny');
const client = makeClient(session, confirmResourceAccess);
await expect(client['dispatchToolCall']('test_tool', {})).rejects.toThrow(
'User permanently denied',
);
expect(session.alwaysDeny).toHaveBeenCalledWith('shell', 'npm install');
});
it('skips confirmation when session.check returns allow', async () => {
const session = makeSession({ check: jest.fn().mockReturnValue('allow') });
const confirmResourceAccess = jest.fn();
const client = makeClient(session, confirmResourceAccess);
await client['dispatchToolCall']('test_tool', {});
expect(confirmResourceAccess).not.toHaveBeenCalled();
});
it('throws immediately when session.check returns deny', async () => {
const session = makeSession({ check: jest.fn().mockReturnValue('deny') });
const confirmResourceAccess = jest.fn();
const client = makeClient(session, confirmResourceAccess);
await expect(client['dispatchToolCall']('test_tool', {})).rejects.toThrow(
'User permanently denied',
);
expect(confirmResourceAccess).not.toHaveBeenCalled();
});
});
describe('instance mode', () => {
it('throws GATEWAY_CONFIRMATION_REQUIRED with the 3-option list', async () => {
const session = makeSession();
const confirmResourceAccess = jest.fn();
const client = makeClient(session, confirmResourceAccess, 'instance');
await expect(client['dispatchToolCall']('test_tool', {})).rejects.toThrow(
'GATEWAY_CONFIRMATION_REQUIRED::',
);
// Extract the JSON payload from the error
let errorMessage = '';
try {
await client['dispatchToolCall']('test_tool', {});
} catch (e) {
errorMessage = e instanceof Error ? e.message : '';
}
let json: { options: string[] };
try {
json = JSON.parse(errorMessage.slice('GATEWAY_CONFIRMATION_REQUIRED::'.length)) as {
options: string[];
};
} catch {
throw new Error(`Failed to parse GATEWAY_CONFIRMATION_REQUIRED payload: ${errorMessage}`);
}
expect(json.options).toEqual(INSTANCE_RESOURCE_DECISION_KEYS);
});
it('applies _confirmation decision in instance mode without prompting', async () => {
const session = makeSession();
const confirmResourceAccess = jest.fn();
const client = makeClient(session, confirmResourceAccess, 'instance');
// Simulate the agent sending back _confirmation=allowForSession
await client['dispatchToolCall']('test_tool', { _confirmation: 'allowForSession' });
expect(session.allowForSession).toHaveBeenCalledWith('shell', 'npm install');
expect(confirmResourceAccess).not.toHaveBeenCalled();
});
});
});

View file

@ -25,7 +25,7 @@ import {
type ResourceDecision,
type ToolDefinition,
GATEWAY_CONFIRMATION_REQUIRED_PREFIX,
RESOURCE_DECISION_KEYS,
INSTANCE_RESOURCE_DECISION_KEYS,
} from './tools/types';
import { formatErrorResult } from './tools/utils';
@ -443,7 +443,7 @@ export class GatewayClient {
toolGroup: resource.toolGroup,
resource: resource.resource,
description: resource.description,
options: RESOURCE_DECISION_KEYS,
options: INSTANCE_RESOURCE_DECISION_KEYS,
})}`,
);
} else {

View file

@ -1,18 +0,0 @@
declare module 'sharp' {
interface Sharp {
resize(width: number, height?: number): Sharp;
png(): Sharp;
jpeg(options?: { quality?: number }): Sharp;
toBuffer(): Promise<Buffer>;
metadata(): Promise<{ width?: number; height?: number; format?: string }>;
}
interface SharpOptions {
raw?: { width: number; height: number; channels: 1 | 2 | 3 | 4 };
}
function sharp(input?: Buffer | string, options?: SharpOptions): Sharp;
// eslint-disable-next-line import-x/no-default-export
export default sharp;
}

View file

@ -5,11 +5,14 @@ import { screenshotTool, screenshotRegionTool } from './screenshot';
jest.mock('node-screenshots');
const mockSharp = jest.fn<unknown, unknown[]>();
jest.mock('sharp', () => ({
const mockFromRgbaPixels = jest.fn<unknown, unknown[]>();
jest.mock('@napi-rs/image', () => ({
__esModule: true,
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
default: (...args: unknown[]) => mockSharp(...args),
// eslint-disable-next-line @typescript-eslint/naming-convention
Transformer: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
fromRgbaPixels: (...args: unknown[]) => mockFromRgbaPixels(...args),
},
}));
const MockMonitor = Monitor as jest.MockedClass<typeof Monitor>;
@ -75,13 +78,11 @@ function makeMockMonitor(opts: {
}
beforeEach(() => {
// sharp(buffer, opts)[.resize()].jpeg().toBuffer() → fake JPEG
const mockToBuffer = jest.fn().mockResolvedValue(Buffer.from('fake-jpeg'));
const mockJpeg = jest.fn().mockReturnValue({ toBuffer: mockToBuffer });
const mockJpeg = jest.fn().mockResolvedValue(Buffer.from('fake-jpeg'));
const mockResize = jest.fn();
const pipeline = { resize: mockResize, jpeg: mockJpeg };
mockResize.mockReturnValue(pipeline);
mockSharp.mockReturnValue(pipeline);
mockFromRgbaPixels.mockReturnValue(pipeline);
});
describe('screen_screenshot tool', () => {
@ -136,7 +137,7 @@ describe('screen_screenshot tool', () => {
await screenshotTool.execute({}, DUMMY_CONTEXT);
const pipeline = mockSharp.mock.results[0].value as { resize: jest.Mock };
const pipeline = mockFromRgbaPixels.mock.results[0].value as { resize: jest.Mock };
expect(pipeline.resize).toHaveBeenCalledWith(1920, 1080);
});
@ -151,7 +152,7 @@ describe('screen_screenshot tool', () => {
await screenshotTool.execute({}, DUMMY_CONTEXT);
const pipeline = mockSharp.mock.results[0].value as { resize: jest.Mock };
const pipeline = mockFromRgbaPixels.mock.results[0].value as { resize: jest.Mock };
// No HiDPI resize, but LLM downscale kicks in (1920x1080 → 1024x576)
expect(pipeline.resize).toHaveBeenCalledWith(1024, 576);
});
@ -252,7 +253,7 @@ describe('screen_screenshot_region tool', () => {
await screenshotRegionTool.execute({ x: 100, y: 200, width: 400, height: 300 }, DUMMY_CONTEXT);
// Cropped image (800×600 physical) must be resized to logical 400×300
const pipeline = mockSharp.mock.results[0].value as { resize: jest.Mock };
const pipeline = mockFromRgbaPixels.mock.results[0].value as { resize: jest.Mock };
expect(pipeline.resize).toHaveBeenCalledWith(400, 300);
});
});

View file

@ -19,8 +19,8 @@ async function toJpeg(
logicalWidth?: number,
logicalHeight?: number,
): Promise<Buffer> {
const { default: sharp } = await import('sharp');
let pipeline = sharp(rawBuffer, { raw: { width, height, channels: 4 } });
const { Transformer } = await import('@napi-rs/image');
let pipeline = Transformer.fromRgbaPixels(rawBuffer, width, height);
if (logicalWidth && logicalHeight && (width !== logicalWidth || height !== logicalHeight)) {
pipeline = pipeline.resize(logicalWidth, logicalHeight);
}
@ -32,7 +32,7 @@ async function toJpeg(
const scale = maxDim / Math.max(w, h);
pipeline = pipeline.resize(Math.round(w * scale), Math.round(h * scale));
}
return await pipeline.jpeg({ quality: 85 }).toBuffer();
return await pipeline.jpeg(85);
}
export const screenshotTool: ToolDefinition<typeof screenshotSchema> = {

View file

@ -56,6 +56,13 @@ export const RESOURCE_DECISION_KEYS: ResourceDecision[] = [
'alwaysDeny',
];
/** Reduced option set sent to the n8n instance UI — no persistent allow/deny to avoid fatigue. */
export const INSTANCE_RESOURCE_DECISION_KEYS: ResourceDecision[] = [
'denyOnce',
'allowOnce',
'allowForSession',
];
/** Prefix used to signal a gateway confirmation is required (instance mode). */
export const GATEWAY_CONFIRMATION_REQUIRED_PREFIX = 'GATEWAY_CONFIRMATION_REQUIRED::';

View file

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

View file

@ -20,6 +20,21 @@ describe('ExpressionEngineConfig', () => {
});
});
describe('N8N_EXPRESSION_ENGINE_IDLE_TIMEOUT', () => {
test('overrides idleTimeout', () => {
jest.replaceProperty(process, 'env', { N8N_EXPRESSION_ENGINE_IDLE_TIMEOUT: '60' });
const config = Container.get(ExpressionEngineConfig);
expect(config.idleTimeout).toBe(60);
});
test('parses "0" as the number 0 (distinct from undefined/unset)', () => {
jest.replaceProperty(process, 'env', { N8N_EXPRESSION_ENGINE_IDLE_TIMEOUT: '0' });
const config = Container.get(ExpressionEngineConfig);
expect(config.idleTimeout).toBe(0);
expect(config.idleTimeout).not.toBeUndefined();
});
});
describe('N8N_EXPRESSION_ENGINE_TIMEOUT', () => {
test('overrides bridgeTimeout', () => {
jest.replaceProperty(process, 'env', { N8N_EXPRESSION_ENGINE_TIMEOUT: '1000' });

View file

@ -4,11 +4,11 @@ import { Config, Env, Nested } from '../decorators';
class PostHogConfig {
/** PostHog project API key for product analytics. */
@Env('N8N_DIAGNOSTICS_POSTHOG_API_KEY')
apiKey: string = 'phc_4URIAm1uYfJO7j8kWSe0J8lc8IqnstRLS7Jx8NcakHo';
apiKey: string = 'phc_kMstNfAgBcBkWSh6KdsgN09heqqNe5VNmalHP1Ni9Q4';
/** PostHog API host URL. */
@Env('N8N_DIAGNOSTICS_POSTHOG_API_HOST')
apiHost: string = 'https://us.i.posthog.com';
apiHost: string = 'https://ph.n8n.io';
}
@Config

View file

@ -33,4 +33,8 @@ export class ExpressionEngineConfig {
/** Memory limit in MB for the V8 isolate used by the VM bridge. */
@Env('N8N_EXPRESSION_ENGINE_MEMORY_LIMIT')
bridgeMemoryLimit: number = 128;
/** If set, scale the pool to 0 warm isolates after this many seconds with no acquire. */
@Env('N8N_EXPRESSION_ENGINE_IDLE_TIMEOUT')
idleTimeout?: number;
}

View file

@ -26,4 +26,66 @@ export class InstanceSettingsLoaderConfig {
*/
@Env('N8N_INSTANCE_OWNER_PASSWORD_HASH')
ownerPasswordHash: string = '';
// --- SSO ---
/** When true, SSO connection config is read from env vars on every startup and the UI is locked. */
@Env('N8N_SSO_MANAGED_BY_ENV')
ssoManagedByEnv: boolean = false;
/** User role provisioning mode: disabled, instance_role, or instance_and_project_roles. */
@Env('N8N_SSO_USER_ROLE_PROVISIONING')
ssoUserRoleProvisioning: string = 'disabled';
// --- OIDC ---
@Env('N8N_SSO_OIDC_CLIENT_ID')
oidcClientId: string = '';
@Env('N8N_SSO_OIDC_CLIENT_SECRET')
oidcClientSecret: string = '';
@Env('N8N_SSO_OIDC_DISCOVERY_ENDPOINT')
oidcDiscoveryEndpoint: string = '';
@Env('N8N_SSO_OIDC_LOGIN_ENABLED')
oidcLoginEnabled: boolean = false;
/** Values can be found in packages/@n8n/api-types/src/dto/oidc/config.dto.ts */
@Env('N8N_SSO_OIDC_PROMPT')
oidcPrompt: string = 'select_account';
/** Comma-separated ACR values */
@Env('N8N_SSO_OIDC_ACR_VALUES')
oidcAcrValues: string = '';
/**
* When true, security policy settings are managed via environment variables.
* On every startup the security policy will be overridden by env vars.
* When false (default), security policy env vars are ignored even if set.
*/
@Env('N8N_SECURITY_POLICY_MANAGED_BY_ENV')
securityPolicyManagedByEnv: boolean = false;
@Env('N8N_MFA_ENFORCED_ENABLED')
mfaEnforcedEnabled: boolean = false;
@Env('N8N_PERSONAL_SPACE_PUBLISHING_ENABLED')
personalSpacePublishingEnabled: boolean = true;
@Env('N8N_PERSONAL_SPACE_SHARING_ENABLED')
personalSpaceSharingEnabled: boolean = true;
// --- SAML ---
/** XML metadata string from the identity provider. */
@Env('N8N_SSO_SAML_METADATA')
samlMetadata: string = '';
/** URL to fetch SAML metadata from (mutually exclusive with metadata). */
@Env('N8N_SSO_SAML_METADATA_URL')
samlMetadataUrl: string = '';
@Env('N8N_SSO_SAML_LOGIN_ENABLED')
samlLoginEnabled: boolean = false;
}

View file

@ -39,6 +39,8 @@ export const LOG_SCOPES = [
'instance-ai',
'instance-version-history',
'instance-settings-loader',
'instance-registry',
'encryption-key-manager',
] as const;
export type LogScope = (typeof LOG_SCOPES)[number];

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