Compare commits

...

25 Commits

Author SHA1 Message Date
n8n-assistant[bot]
a2d0f7638b
🚀 Release 2.38.7 (#38406)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-09-11 07:36:23 +00:00
n8n-assistant[bot]
32340787f6
fix(core): Trust the CA from GIT_SSL_CAINFO for source control HTTPS remotes (backport to release-candidate/2.38.x) (#38297)
Co-authored-by: Jan Kalkan <jan.kalkan@n8n.io>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-10 16:07:26 +00:00
n8n-assistant[bot]
d177d76fdb
fix(API): Report the full route path in public API usage telemetry (no-changelog) (backport to release-candidate/2.38.x) (#38189)
Co-authored-by: Matt Kay <123335827+kayonaise@users.noreply.github.com>
Co-authored-by: Matthias Kay <matthew.kay@n8n.io>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:57:53 +02:00
n8n-assistant[bot]
d073c1f953
fix(core): Point the Docker-only deprecation notices at the hosting docs (no-changelog) (backport to release-candidate/2.38.x) (#38304)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 11:14:16 +00:00
n8n-assistant[bot]
1025dc717d
fix(core): Execute MCP toolkit members on workers (backport to release-candidate/2.38.x) (#38277)
Co-authored-by: Eugene <eugene@n8n.io>
2026-09-10 08:43:07 +00:00
n8n-assistant[bot]
52c8a72ea1
fix(core): Reuse an existing consent grant after the visitor authenticates mid-flow (backport to release-candidate/2.38.x) (#38249)
Co-authored-by: MHMD <45968454+dou-mhmd@users.noreply.github.com>
Co-authored-by: James Martin <james.martin@n8n.io>
2026-09-10 10:28:19 +02:00
n8n-assistant[bot]
0e7a133ee2
🚀 Release 2.38.6 (#38272)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-09-10 07:37:53 +00:00
n8n-assistant[bot]
b3c32da290
perf(core): Bound peak memory during source control push (backport to release-candidate/2.38.x) (#37975)
Co-authored-by: ocdejong <52361003+ocdejong@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 16:34:36 +00:00
ocdejong
b8f10a33d1
test: Use the hosted sandbox service when it is configured (backport to release-candidate/2.38.x) (#38179)
Co-authored-by: Jaakko Husso <jaakko@n8n.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:45:12 +02:00
n8n-assistant[bot]
ef83e7038e
🚀 Release 2.38.5 (#38202)
Co-authored-by: Matsuuu <16068444+Matsuuu@users.noreply.github.com>
2026-09-09 11:35:05 +00:00
n8n-assistant[bot]
16199c6e56
chore(core): Deprecate internal task runner mode and warn about SSRF default block list changes (no-changelog) (backport to release-candidate/2.38.x) (#38141)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 17:34:02 +00:00
n8n-assistant[bot]
3d2f334c24
feat: Enable the v3 migration report and fix findings table layout (no-changelog) (backport to release-candidate/2.38.x) (#38098)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
2026-09-08 14:58:13 +00:00
n8n-assistant[bot]
cfc385f49f
fix(core): Keep reporting the original job error when the job key is gone (backport to release-candidate/2.38.x) (#38028)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 08:34:26 +00:00
n8n-assistant[bot]
d68d94af3b
🚀 Release 2.38.4 (#37802)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-09-07 07:32:47 +00:00
n8n-assistant[bot]
a8fc772e48
fix(core): Stop task broker before task runner processes on shutdown (backport to release-candidate/2.38.x) (#37886)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 15:58:49 +00:00
n8n-assistant[bot]
1abbd4b808
fix(core): Keep a serving external secrets provider active when its replacement fails (backport to release-candidate/2.38.x) (#37882)
Co-authored-by: Jan Kalkan <jan.kalkan@n8n.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 15:34:49 +00:00
n8n-assistant[bot]
6120419fdd
fix(core): Stop npm audit from stalling Instance AI sandbox setup (backport to release-candidate/2.38.x) (#37877)
Co-authored-by: oleg <me@olegivaniv.com>
2026-09-04 15:08:36 +00:00
n8n-assistant[bot]
477da82845
test: Remove unstable staged TCR test (backport to release-candidate/2.38.x) (#37837)
Co-authored-by: Declan Carroll <declan@n8n.io>
2026-09-04 10:52:21 +00:00
n8n-assistant[bot]
6e12447f9b
fix(core): Cap task runner task timeouts to the graceful shutdown window (backport to release-candidate/2.38.x) (#37765)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 15:20:05 +00:00
n8n-assistant[bot]
1007dff4be
fix(core): Prevent Anthropic agent threads from breaking permanently and keep run errors visible (backport to release-candidate/2.38.x) (#37722)
Co-authored-by: Robin Braumann <50590409+bjorger@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 10:14:20 +00:00
n8n-assistant[bot]
3a77307e04
🚀 Release 2.38.3 (#37696)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-09-03 07:39:10 +00:00
n8n-assistant[bot]
cebb7bd639
fix(core): Ensure running job cleanup when a workflow run rejects (backport to release-candidate/2.38.x) (#37629)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 10:30:58 +00:00
n8n-assistant[bot]
7bd63f41c2
🚀 Release 2.38.2 (#37608)
Co-authored-by: Matsuuu <16068444+Matsuuu@users.noreply.github.com>
2026-09-02 10:14:01 +03:00
Matsu
8c8b059e03
chore: Bundle 2.x (backport to release-candidate/2.38.x) (#37593) 2026-09-02 10:04:47 +03:00
n8n-assistant[bot]
603668834c
ci: Pin sandbox service test images (no-changelog) (backport to release-candidate/2.38.x) (#37597)
Co-authored-by: Matsu <matias.huhta@n8n.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 09:35:53 +03:00
340 changed files with 14684 additions and 2302 deletions

View File

@ -172,6 +172,11 @@ jobs:
N8N_LICENSE_ACTIVATION_KEY: ${{ secrets.N8N_LICENSE_ACTIVATION_KEY }} N8N_LICENSE_ACTIVATION_KEY: ${{ secrets.N8N_LICENSE_ACTIVATION_KEY }}
N8N_LICENSE_CERT: ${{ secrets.N8N_LICENSE_CERT }} N8N_LICENSE_CERT: ${{ secrets.N8N_LICENSE_CERT }}
N8N_ENCRYPTION_KEY: ${{ secrets.N8N_ENCRYPTION_KEY }} N8N_ENCRYPTION_KEY: ${{ secrets.N8N_ENCRYPTION_KEY }}
# Hosted sandbox service for the instance-ai specs. When both are set the
# `sandbox` test service skips its local dind stack and points n8n here.
# Unset (fork PRs get no secrets) falls back to the local containers.
N8N_SANDBOX_SERVICE_URL: ${{ secrets.N8N_SANDBOX_SERVICE_URL }}
N8N_SANDBOX_SERVICE_API_KEY: ${{ secrets.N8N_SANDBOX_SERVICE_API_KEY }}
N8N_TEST_ENV: ${{ inputs.n8n-env }} N8N_TEST_ENV: ${{ inputs.n8n-env }}
- name: Upload Shard Artifacts - name: Upload Shard Artifacts

View File

@ -224,9 +224,29 @@ jobs:
done' > /dev/null 2>&1 & done' > /dev/null 2>&1 &
echo $! > eval-diag/sampler.pid echo $! > eval-diag/sampler.pid
# Only needed when there's no usable hosted deployment to point at. The guard
# lives in the script because the `secrets` context isn't available to
# `step.if`. `SANDBOX_HOSTED_OK` carries the decision to the next step, so a
# deployment that fails the preflight is never handed to n8n.
- name: Start sandbox service - name: Start sandbox service
if: ${{ inputs.sandbox-provider == 'n8n-sandbox' }} if: ${{ inputs.sandbox-provider == 'n8n-sandbox' }}
run: pnpm --filter n8n-containers services --services sandbox --network n8n-eval-net --name n8n-svc-sandbox env:
HOSTED_SANDBOX_URL: ${{ secrets.N8N_SANDBOX_SERVICE_URL }}
HOSTED_SANDBOX_API_KEY: ${{ secrets.N8N_SANDBOX_SERVICE_API_KEY }}
run: |
if [ -n "$HOSTED_SANDBOX_URL" ]; then
# Authenticated so a revoked key fails here rather than mid-eval;
# /healthz is unauthenticated and would pass with a bad key.
if curl -fsS -m 10 -o /dev/null \
-H "X-Api-Key: $HOSTED_SANDBOX_API_KEY" \
"${HOSTED_SANDBOX_URL%/}/sandboxes"; then
echo "Hosted sandbox service healthy — skipping the local stack."
echo "SANDBOX_HOSTED_OK=true" >> "$GITHUB_ENV"
exit 0
fi
echo "::warning::Hosted sandbox service is not usable — falling back to the local sandbox stack."
fi
pnpm --filter n8n-containers services --services sandbox --network n8n-eval-net --name n8n-svc-sandbox
- name: Start n8n containers - name: Start n8n containers
env: env:
@ -249,6 +269,8 @@ jobs:
N8N_LICENSE_CERT: ${{ secrets.N8N_LICENSE_CERT }} N8N_LICENSE_CERT: ${{ secrets.N8N_LICENSE_CERT }}
N8N_ENCRYPTION_KEY: ${{ secrets.N8N_ENCRYPTION_KEY }} N8N_ENCRYPTION_KEY: ${{ secrets.N8N_ENCRYPTION_KEY }}
DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }} DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
HOSTED_SANDBOX_URL: ${{ secrets.N8N_SANDBOX_SERVICE_URL }}
HOSTED_SANDBOX_API_KEY: ${{ secrets.N8N_SANDBOX_SERVICE_API_KEY }}
# LangSmith creds for backend thread traces, routed to the same # LangSmith creds for backend thread traces, routed to the same
# dedicated project as the eval CLI to keep the 'instance-ai' corpus clean. # dedicated project as the eval CLI to keep the 'instance-ai' corpus clean.
LANGSMITH_API_KEY: ${{ secrets.EVALS_LANGSMITH_API_KEY }} LANGSMITH_API_KEY: ${{ secrets.EVALS_LANGSMITH_API_KEY }}
@ -264,6 +286,7 @@ jobs:
run: | run: |
# Build provider-specific env args # Build provider-specific env args
SANDBOX_ARGS=() SANDBOX_ARGS=()
USE_LOCAL_SANDBOX=false
if [ "$SANDBOX_PROVIDER" = "daytona" ]; then if [ "$SANDBOX_PROVIDER" = "daytona" ]; then
SANDBOX_ARGS+=( SANDBOX_ARGS+=(
-e N8N_INSTANCE_AI_SANDBOX_PROVIDER=daytona -e N8N_INSTANCE_AI_SANDBOX_PROVIDER=daytona
@ -271,7 +294,14 @@ jobs:
-e DAYTONA_API_URL=https://app.daytona.io/api -e DAYTONA_API_URL=https://app.daytona.io/api
-e DAYTONA_API_KEY="$DAYTONA_API_KEY" -e DAYTONA_API_KEY="$DAYTONA_API_KEY"
) )
elif [ "$SANDBOX_HOSTED_OK" = "true" ]; then
SANDBOX_ARGS+=(
-e N8N_INSTANCE_AI_SANDBOX_PROVIDER=n8n-sandbox
-e N8N_SANDBOX_SERVICE_URL="$HOSTED_SANDBOX_URL"
-e N8N_SANDBOX_SERVICE_API_KEY="$HOSTED_SANDBOX_API_KEY"
)
else else
USE_LOCAL_SANDBOX=true
SANDBOX_ARGS+=( SANDBOX_ARGS+=(
-e N8N_INSTANCE_AI_SANDBOX_PROVIDER=n8n-sandbox -e N8N_INSTANCE_AI_SANDBOX_PROVIDER=n8n-sandbox
-e N8N_SANDBOX_SERVICE_URL=http://sandbox-api:8080 -e N8N_SANDBOX_SERVICE_URL=http://sandbox-api:8080
@ -279,9 +309,11 @@ jobs:
) )
fi fi
# Use the eval network when sandbox service is running # Only the local sandbox stack needs the shared network — a hosted
# deployment is reached over the default bridge, and the network is
# never created when the start step short-circuits.
NETWORK_ARGS=() NETWORK_ARGS=()
if [ "$SANDBOX_PROVIDER" == "n8n-sandbox" ]; then if [ "$USE_LOCAL_SANDBOX" = true ]; then
NETWORK_ARGS+=(--network n8n-eval-net) NETWORK_ARGS+=(--network n8n-eval-net)
fi fi

View File

@ -1,3 +1,52 @@
## [2.38.7](https://github.com/n8n-io/n8n/compare/n8n@2.38.6...n8n@2.38.7) (2026-09-11)
### Bug Fixes
* **core:** Execute MCP toolkit members on workers ([#38277](https://github.com/n8n-io/n8n/issues/38277)) ([1025dc7](https://github.com/n8n-io/n8n/commit/1025dc717d266f34f451eb152d71f66d6149ddbf))
* **core:** Reuse an existing consent grant after the visitor authenticates mid-flow ([#38249](https://github.com/n8n-io/n8n/issues/38249)) ([52c8a72](https://github.com/n8n-io/n8n/commit/52c8a72ea16f3f23021e9fe3bb9daf66def7fd3d))
* **core:** Trust the CA from GIT_SSL_CAINFO for source control HTTPS remotes ([#38297](https://github.com/n8n-io/n8n/issues/38297)) ([3234078](https://github.com/n8n-io/n8n/commit/32340787f6f04f20ece563fd36f871961ec5e77a))
## [2.38.6](https://github.com/n8n-io/n8n/compare/n8n@2.38.5...n8n@2.38.6) (2026-09-10)
### Performance Improvements
* **core:** Bound peak memory during source control push ([#37975](https://github.com/n8n-io/n8n/issues/37975)) ([b3c32da](https://github.com/n8n-io/n8n/commit/b3c32da290a0ebbb8db66a0385d063e100ad08ba))
## [2.38.5](https://github.com/n8n-io/n8n/compare/n8n@2.38.4...n8n@2.38.5) (2026-09-09)
### Bug Fixes
* **core:** Keep reporting the original job error when the job key is gone ([#38028](https://github.com/n8n-io/n8n/issues/38028)) ([cfc385f](https://github.com/n8n-io/n8n/commit/cfc385f49f2b198464228b4dc5429fb6d4da1cdf))
## [2.38.4](https://github.com/n8n-io/n8n/compare/n8n@2.38.3...n8n@2.38.4) (2026-09-07)
### Bug Fixes
* **core:** Cap task runner task timeouts to the graceful shutdown window ([#37765](https://github.com/n8n-io/n8n/issues/37765)) ([6e12447](https://github.com/n8n-io/n8n/commit/6e12447f9b747da8f41cf7246e57ca0fa7ed5f16))
* **core:** Keep a serving external secrets provider active when its replacement fails ([#37882](https://github.com/n8n-io/n8n/issues/37882)) ([1abbd4b](https://github.com/n8n-io/n8n/commit/1abbd4b8087d8965bf972de731bf8c1b083dea48))
* **core:** Prevent Anthropic agent threads from breaking permanently and keep run errors visible ([#37722](https://github.com/n8n-io/n8n/issues/37722)) ([1007dff](https://github.com/n8n-io/n8n/commit/1007dff4befe77fc97aa438b934db0a3e948a360))
* **core:** Stop npm audit from stalling Instance AI sandbox setup ([#37877](https://github.com/n8n-io/n8n/issues/37877)) ([6120419](https://github.com/n8n-io/n8n/commit/6120419fdd31cf74e33f2768513e73dbf3023251))
* **core:** Stop task broker before task runner processes on shutdown ([#37886](https://github.com/n8n-io/n8n/issues/37886)) ([a8fc772](https://github.com/n8n-io/n8n/commit/a8fc772e483e38969ca6e10117d5b501c607fd18))
## [2.38.3](https://github.com/n8n-io/n8n/compare/n8n@2.38.2...n8n@2.38.3) (2026-09-03)
### Bug Fixes
* **core:** Ensure running job cleanup when a workflow run rejects ([#37629](https://github.com/n8n-io/n8n/issues/37629)) ([cebb7bd](https://github.com/n8n-io/n8n/commit/cebb7bd6396587e9791a9afa1f7b08c2948a7ef0))
## [2.38.2](https://github.com/n8n-io/n8n/compare/n8n@2.38.1...n8n@2.38.2) (2026-09-02)
# [2.38.1](https://github.com/n8n-io/n8n/compare/n8n@2.37.0...n8n@2.38.1) (2026-09-01) # [2.38.1](https://github.com/n8n-io/n8n/compare/n8n@2.37.0...n8n@2.38.1) (2026-09-01)

View File

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

View File

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

View File

@ -124,6 +124,58 @@ describe('toAiMessages + fromAiMessages — round-trip', () => {
}); });
}); });
it('keeps only the last Anthropic signature when assistant responses are adjacent', () => {
const input: Message[] = [
{
role: 'assistant',
content: [
{
type: 'reasoning',
text: 'First response reasoning',
providerMetadata: { anthropic: { signature: 'first-signature' } },
},
{ type: 'text', text: 'First response' },
],
},
{
role: 'assistant',
content: [
{
type: 'reasoning',
text: 'Second response reasoning',
providerMetadata: { anthropic: { signature: 'second-signature' } },
},
{ type: 'text', text: 'Second response' },
],
},
];
const aiMessages = toAiMessages(input);
const content = aiMessages.map(
(message) =>
(
message as {
content: Array<{
type: string;
text?: string;
providerOptions?: { anthropic?: { signature?: string } };
}>;
}
).content,
);
expect(content.flatMap((parts) => parts).filter((part) => part.type === 'text')).toEqual([
{ type: 'text', text: 'First response' },
{ type: 'text', text: 'Second response' },
]);
expect(
content
.flatMap((parts) => parts)
.map((part) => part.providerOptions?.anthropic?.signature)
.filter((signature) => signature !== undefined),
).toEqual(['second-signature']);
});
it('copies OpenAI reasoning replay metadata into providerOptions on replay', () => { it('copies OpenAI reasoning replay metadata into providerOptions on replay', () => {
const providerMetadata = { const providerMetadata = {
openai: { itemId: 'rs_123', reasoningEncryptedContent: 'encrypted' }, openai: { itemId: 'rs_123', reasoningEncryptedContent: 'encrypted' },
@ -656,6 +708,69 @@ describe('toAiMessages + fromAiMessages — round-trip', () => {
expect((block as { output: unknown }).output).toEqual({ result: 3 }); expect((block as { output: unknown }).output).toEqual({ result: 3 });
}); });
it('round-trips provider-executed tool results inside the assistant message', () => {
// Native web search: the AI SDK places the result in the assistant message,
// never in a role:tool message.
const results = [{ url: 'https://n8n.io', title: 'n8n', type: 'web_search_result' }];
const searchError = { type: 'web_search_tool_result_error', errorCode: 'max_uses_exceeded' };
const error = JSON.stringify(searchError);
const aiMessages: ModelMessage[] = [
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'srvtoolu_1',
toolName: 'web_search',
input: { query: 'n8n' },
providerExecuted: true,
},
{
type: 'tool-result',
toolCallId: 'srvtoolu_1',
toolName: 'web_search',
output: { type: 'json', value: results },
},
{
type: 'tool-call',
toolCallId: 'srvtoolu_2',
toolName: 'web_search',
input: { query: 'more' },
providerExecuted: true,
},
{
type: 'tool-result',
toolCallId: 'srvtoolu_2',
toolName: 'web_search',
output: { type: 'error-json', value: searchError },
},
{ type: 'text', text: 'Here is what I found.' },
],
},
];
const persisted = fromAiMessages(aiMessages) as Message[];
expect(persisted[0].content).toMatchObject([
{ type: 'tool-call', state: 'resolved', output: results },
{ type: 'tool-call', state: 'rejected', error },
{ type: 'text' },
]);
const replayed = toAiMessages(persisted);
expect(replayed).toHaveLength(1);
expect(replayed[0].content).toMatchObject([
{ type: 'tool-call', toolCallId: 'srvtoolu_1', providerExecuted: true },
{ type: 'tool-result', toolCallId: 'srvtoolu_1', output: { type: 'json', value: results } },
{ type: 'tool-call', toolCallId: 'srvtoolu_2', providerExecuted: true },
{
type: 'tool-result',
toolCallId: 'srvtoolu_2',
output: { type: 'error-json', value: error },
},
{ type: 'text', text: 'Here is what I found.' },
]);
});
it('round-trip is structurally equivalent for a resolved tool-call', () => { it('round-trip is structurally equivalent for a resolved tool-call', () => {
const original: Message[] = [ const original: Message[] = [
{ {

View File

@ -781,10 +781,10 @@ describe('AgentRuntime — reasoning-only turn', () => {
* reasoning-only message, `other`, and no usage. Nothing the user can see * reasoning-only message, `other`, and no usage. Nothing the user can see
* and nothing the loop can act on. * and nothing the loop can act on.
*/ */
function makeStreamReasoningOnly() { function makeStreamReasoningOnly(finishReason = 'other') {
return { return {
stream: makeChunkStream([{ type: 'reasoning-delta', id: 'r-1', text: 'thinking...' }]), stream: makeChunkStream([{ type: 'reasoning-delta', id: 'r-1', text: 'thinking...' }]),
finishReason: Promise.resolve('other'), finishReason: Promise.resolve(finishReason),
usage: Promise.resolve({ usage: Promise.resolve({
inputTokens: undefined, inputTokens: undefined,
outputTokens: undefined, outputTokens: undefined,
@ -830,6 +830,42 @@ describe('AgentRuntime — reasoning-only turn', () => {
); );
expect(String((error?.error as Error).message)).toContain('no output'); expect(String((error?.error as Error).message)).toContain('no output');
}); });
it.each([
{ finishReason: 'length', errorText: 'output token limit' },
{ finishReason: 'stop', errorText: 'without returning an answer' },
])(
'fails a reasoning-only $finishReason turn without retrying or persisting it',
async ({ finishReason, errorText }) => {
streamText.mockReturnValue(makeStreamReasoningOnly(finishReason));
const memory = new InMemoryMemory();
const runtime = new AgentRuntime({
name: 'test',
model: 'anthropic/claude-opus-5',
instructions: 'You are a test assistant.',
memory,
});
const result = await runtime.stream('make my workflow smarter', {
persistence: { threadId: 'thread-1', resourceId: 'user-1' },
});
const chunks = await collectChunks(result.stream);
expect(streamText).toHaveBeenCalledTimes(1);
expect(chunks).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'error' }),
expect.objectContaining({ type: 'finish', finishReason: 'error' }),
]),
);
const error = chunks.find((chunk) => chunk.type === 'error');
expect(String(error?.error)).toContain(errorText);
const persisted = await memory.getMessages('thread-1', { resourceId: 'user-1' });
expect(
persisted.filter((message) => (message as { role?: string }).role === 'assistant'),
).toHaveLength(0);
},
);
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -43,12 +43,16 @@ If this affects the user's request, briefly let them know which server is unavai
} }
/** /**
* Finish reasons that indicate the provider rejected or filtered the request * Finish reasons that indicate the provider rejected, filtered, or truncated
* when they arrive with zero output. `stop`/`length` with empty output are the * the request when they arrive with zero output. `tool-calls` always carries
* model's own (odd but legal) choice; `tool-calls` always carries calls; * calls; `error` surfaces through the SDK's thrown error instead.
* `error` surfaces through the SDK's thrown error instead.
*/ */
const EMPTY_RESPONSE_ERROR_FINISH_REASONS = new Set(['other', 'unknown', 'content-filter']); const EMPTY_RESPONSE_ERROR_FINISH_REASONS = new Set([
'length',
'other',
'unknown',
'content-filter',
]);
/** /**
* Whether a turn carries output the user can see or the loop can act on. * Whether a turn carries output the user can see or the loop can act on.
@ -69,6 +73,17 @@ function hasActionableContent(messages: AgentMessage[]): boolean {
); );
} }
function hasReasoningContent(messages: AgentMessage[]): boolean {
return messages.some(
(message) =>
'content' in message &&
Array.isArray(message.content) &&
message.content.some(
(content) => content.type === 'reasoning' || content.type === 'reasoning-file',
),
);
}
/** /**
* Classify a turn that produced no output as a recognized failure, or return * Classify a turn that produced no output as a recognized failure, or return
* `undefined` when it doesn't look like a provider rejection. Some providers * `undefined` when it doesn't look like a provider rejection. Some providers
@ -83,10 +98,23 @@ export function classifyModelTurnError(turn: {
providerError?: RawProviderError; providerError?: RawProviderError;
}): ModelTurnError | undefined { }): ModelTurnError | undefined {
if (hasActionableContent(turn.newMessages)) return undefined; if (hasActionableContent(turn.newMessages)) return undefined;
if (turn.aiFinishReason === 'stop' && hasReasoningContent(turn.newMessages)) {
return {
type: 'no_output',
message: 'The model finished without returning an answer. Try again or use another model.',
};
}
if (!EMPTY_RESPONSE_ERROR_FINISH_REASONS.has(turn.aiFinishReason)) return undefined; if (!EMPTY_RESPONSE_ERROR_FINISH_REASONS.has(turn.aiFinishReason)) return undefined;
const guidance = const guidance =
'This can be a provider-side false positive — try rephrasing the message, clearing the chat history, or switching models.'; 'This can be a provider-side false positive — try rephrasing the message, clearing the chat history, or switching models.';
if (turn.aiFinishReason === 'length') {
return {
type: 'no_output',
message:
'The model reached its output token limit before it returned an answer. Reduce the request scope or use another model.',
};
}
if (turn.providerError) { if (turn.providerError) {
return { return {
type: turn.providerError.type, type: turn.providerError.type,
@ -118,6 +146,11 @@ export function isEmptyModelTurn(turn: {
}): boolean { }): boolean {
if (turn.aiFinishReason === 'tool-calls') return false; if (turn.aiFinishReason === 'tool-calls') return false;
if (turn.structuredOutput !== undefined) return false; if (turn.structuredOutput !== undefined) return false;
// A reasoning-only stop or a truncated turn cannot recover under the same
// conditions. Retrying only consumes more tokens.
if (turn.errorReason && (turn.aiFinishReason === 'stop' || turn.aiFinishReason === 'length')) {
return false;
}
// A safety block is the provider's deterministic verdict on this prompt: // A safety block is the provider's deterministic verdict on this prompt:
// re-issuing it earns the same answer and discards the captured reason, // re-issuing it earns the same answer and discards the captured reason,
// which is the only place the block is explained. // which is the only place the block is explained.

View File

@ -121,6 +121,13 @@ function hasReplayableReasoningProviderOptions(
}); });
} }
function isReasoningMergedByProvider(part: AiContentPart): boolean {
if (part.type !== 'reasoning') return false;
return Object.keys(part.providerOptions ?? {}).some(
(namespace) => getProviderQuirks(namespace).mergesAdjacentAssistantMessages === true,
);
}
export type ContentToolResultOutput = Extract<ToolResultPart['output'], { type: 'content' }>; export type ContentToolResultOutput = Extract<ToolResultPart['output'], { type: 'content' }>;
export function isContentToolResultOutput(value: unknown): value is ContentToolResultOutput { export function isContentToolResultOutput(value: unknown): value is ContentToolResultOutput {
@ -274,11 +281,15 @@ function toolCallToResultPart(
// rejected // rejected
const errorValue = block.error; const errorValue = block.error;
if (typeof errorValue === 'string') { if (typeof errorValue === 'string') {
// Providers replay their own tool errors (e.g. a web search error code)
// from error-json; error-text is dropped with a warning.
return { return {
type: 'tool-result', type: 'tool-result',
toolCallId: block.toolCallId, toolCallId: block.toolCallId,
toolName: block.toolName, toolName: block.toolName,
output: { type: 'error-text', value: errorValue }, output: block.providerExecuted
? { type: 'error-json', value: errorValue }
: { type: 'error-text', value: errorValue },
}; };
} }
return { return {
@ -364,7 +375,8 @@ function fromAiContent(part: AiContentPart): MessageContent | undefined {
* *
* For assistant messages with resolved/rejected tool-call blocks, this emits: * For assistant messages with resolved/rejected tool-call blocks, this emits:
* 1. The assistant ModelMessage (tool-call parts only, no result fields) * 1. The assistant ModelMessage (tool-call parts only, no result fields)
* 2. One tool ModelMessage per settled tool-call block (resolved or rejected) * 2. One tool ModelMessage per settled tool-call block (resolved or rejected),
* except provider-executed blocks, whose result stays in the assistant message
* *
* Pending tool-call blocks are silently skipped (defense-in-depth; the strip * Pending tool-call blocks are silently skipped (defense-in-depth; the strip
* step should already have removed them before forLlm() calls toAiMessages). * step should already have removed them before forLlm() calls toAiMessages).
@ -418,9 +430,11 @@ function toAiMessageList(msg: Message): ModelMessage[] {
...(block.providerMetadata && { providerMetadata: block.providerMetadata }), ...(block.providerMetadata && { providerMetadata: block.providerMetadata }),
...(block.providerOptions && { providerOptions: block.providerOptions }), ...(block.providerOptions && { providerOptions: block.providerOptions }),
} as ToolCallPart); } as ToolCallPart);
// Emit corresponding tool-result message immediately after // Emit corresponding tool-result message immediately after. A
// provider-executed result belongs in the assistant message itself.
const resultPart = toolCallToResultPart(block); const resultPart = toolCallToResultPart(block);
resultMessages.push({ role: 'tool', content: [resultPart] }); if (block.providerExecuted) assistantParts.push(resultPart);
else resultMessages.push({ role: 'tool', content: [resultPart] });
} else { } else {
const part = toAiContent(block); const part = toAiContent(block);
if (part) assistantParts.push(part); if (part) assistantParts.push(part);
@ -458,7 +472,26 @@ function toAiMessageList(msg: Message): ModelMessage[] {
/** Convert n8n Messages to AI SDK ModelMessages for passing to stream/generateText. */ /** Convert n8n Messages to AI SDK ModelMessages for passing to stream/generateText. */
export function toAiMessages(messages: Message[]): ModelMessage[] { export function toAiMessages(messages: Message[]): ModelMessage[] {
return messages.flatMap(toAiMessageList); const modelMessages = messages.flatMap(toAiMessageList);
const result: ModelMessage[] = [];
for (const [index, message] of modelMessages.entries()) {
if (
message.role !== 'assistant' ||
typeof message.content === 'string' ||
modelMessages[index + 1]?.role !== 'assistant'
) {
result.push(message);
continue;
}
// Keep replayable reasoning only on the last of the merged responses so
// signatures from separate turns never mix (see mergesAdjacentAssistantMessages).
const content = message.content.filter((part) => !isReasoningMergedByProvider(part));
if (content.length > 0) result.push({ ...message, content });
}
return result;
} }
/** /**
@ -477,37 +510,41 @@ export function fromAiMessages(messages: ModelMessage[]): AgentMessage[] {
const toolCallIndex = new Map<string, ContentToolCall>(); const toolCallIndex = new Map<string, ContentToolCall>();
const result: AgentMessage[] = []; const result: AgentMessage[] = [];
// Merge tool results back into the matching tool-call blocks
const settleToolCalls = (parts: readonly AiContentPart[]) => {
for (const part of parts) {
if (part.type !== 'tool-result') continue;
const block = toolCallIndex.get(part.toolCallId);
if (!block) continue; // orphan — drop
const { output } = part;
if (output.type === 'json' || output.type === 'text') {
const mutableBlock = block as Extract<ContentToolCall, { state: 'resolved' }>;
mutableBlock.state = 'resolved';
mutableBlock.output = output.value as JSONValue;
} else if (output.type === 'content') {
const mutableBlock = block as Extract<ContentToolCall, { state: 'resolved' }>;
mutableBlock.state = 'resolved';
mutableBlock.output = output as JSONValue;
} else if (output.type === 'error-json') {
const mutableBlock = block as Extract<ContentToolCall, { state: 'rejected' }>;
mutableBlock.state = 'rejected';
mutableBlock.error = JSON.stringify(output.value);
} else if (output.type === 'error-text') {
const mutableBlock = block as Extract<ContentToolCall, { state: 'rejected' }>;
mutableBlock.state = 'rejected';
mutableBlock.error = output.value;
} else {
const mutableBlock = block as Extract<ContentToolCall, { state: 'rejected' }>;
mutableBlock.state = 'rejected';
mutableBlock.error = JSON.stringify(output);
}
}
};
for (const msg of messages) { for (const msg of messages) {
if (msg.role === 'tool') { if (msg.role === 'tool') {
// Merge tool results back into the matching tool-call blocks settleToolCalls(msg.content);
const toolParts = msg.content as ToolResultPart[];
for (const part of toolParts) {
const block = toolCallIndex.get(part.toolCallId);
if (!block) continue; // orphan — drop
const { output } = part;
if (output.type === 'json' || output.type === 'text') {
const mutableBlock = block as Extract<ContentToolCall, { state: 'resolved' }>;
mutableBlock.state = 'resolved';
mutableBlock.output = output.value as JSONValue;
} else if (output.type === 'content') {
const mutableBlock = block as Extract<ContentToolCall, { state: 'resolved' }>;
mutableBlock.state = 'resolved';
mutableBlock.output = output as JSONValue;
} else if (output.type === 'error-json') {
const mutableBlock = block as Extract<ContentToolCall, { state: 'rejected' }>;
mutableBlock.state = 'rejected';
mutableBlock.error = JSON.stringify(output.value);
} else if (output.type === 'error-text') {
const mutableBlock = block as Extract<ContentToolCall, { state: 'rejected' }>;
mutableBlock.state = 'rejected';
mutableBlock.error = output.value;
} else {
const mutableBlock = block as Extract<ContentToolCall, { state: 'rejected' }>;
mutableBlock.state = 'rejected';
mutableBlock.error = JSON.stringify(output);
}
}
// Do not emit a separate n8n message for tool results // Do not emit a separate n8n message for tool results
continue; continue;
} }
@ -531,6 +568,9 @@ export function fromAiMessages(messages: ModelMessage[]): AgentMessage[] {
toolCallIndex.set(block.toolCallId, block); toolCallIndex.set(block.toolCallId, block);
} }
} }
// Provider-executed tools (e.g. native web search) return their result
// inside the assistant message rather than in a role:tool message.
if (typeof rawContent !== 'string') settleToolCalls(rawContent);
} }
} }

View File

@ -18,6 +18,8 @@ export interface ProviderQuirks {
providerOptionsNamespace?: string; providerOptionsNamespace?: string;
/** providerMetadata keys on reasoning parts that must be copied to providerOptions and survive replay. */ /** providerMetadata keys on reasoning parts that must be copied to providerOptions and survive replay. */
reasoningReplayKeys?: string[]; reasoningReplayKeys?: string[];
/** Provider merges adjacent assistant messages into one; replayable reasoning may only survive on the last of them. */
mergesAdjacentAssistantMessages?: boolean;
/** Defaults merged under this provider's namespace into every tool's providerOptions (explicit tool values win). */ /** Defaults merged under this provider's namespace into every tool's providerOptions (explicit tool values win). */
toolProviderOptionDefaults?: JSONObject; toolProviderOptionDefaults?: JSONObject;
/** Provider defaults to strict JSON Schema validation for structured output; relax for raw user schemas. */ /** Provider defaults to strict JSON Schema validation for structured output; relax for raw user schemas. */
@ -98,6 +100,11 @@ export const PROVIDER_QUIRKS: Partial<Record<ProviderId, ProviderQuirks>> = {
// exposes them in providerMetadata, not providerOptions. Shim until the // exposes them in providerMetadata, not providerOptions. Shim until the
// provider copies them itself on replay. // provider copies them itself on replay.
reasoningReplayKeys: ['signature', 'redactedData'], reasoningReplayKeys: ['signature', 'redactedData'],
// QUIRK(anthropic): the provider merges adjacent assistant messages into
// one API message. Thinking blocks are only valid inside the response
// that produced them, so a merged message with signed reasoning from two
// responses is rejected with "thinking blocks ... cannot be modified".
mergesAdjacentAssistantMessages: true,
// QUIRK(anthropic): defaults every function tool to eager_input_streaming, // QUIRK(anthropic): defaults every function tool to eager_input_streaming,
// which forwards the model's raw argument tokens without server-side JSON // which forwards the model's raw argument tokens without server-side JSON
// validation — malformed inputs (e.g. unquoted string values) then reach // validation — malformed inputs (e.g. unquoted string values) then reach

View File

@ -16,6 +16,36 @@ async function drain(stream: ReadableStream<StreamChunk>): Promise<StreamChunk[]
} }
describe('startStreamSession', () => { describe('startStreamSession', () => {
it('emits only the provider error when a wrapper error follows', async () => {
const bus = new AgentEventBus();
const abortScope = bus.createAbortScope();
const providerError = new Error('Thinking blocks cannot be modified');
const readable = startStreamSession({
eventBus: bus,
abortScope,
runId: 'run-1',
options: undefined,
withRootSpan: async <T>(_op: unknown, _opts: unknown, _rid: unknown, fn: () => Promise<T>) =>
await fn(),
runLoop: async (guard) => {
await guard.write({ type: 'error', error: providerError });
throw new Error('No output generated. Check the stream for errors.');
},
flushTelemetry: vi.fn(),
cleanupRun: vi.fn(),
updateState: vi.fn(),
emitError: vi.fn(),
});
const chunks = await drain(readable);
expect(chunks.filter((chunk) => chunk.type === 'error')).toEqual([
{ type: 'error', error: providerError },
]);
expect(chunks.at(-1)).toMatchObject({ type: 'finish', finishReason: 'error' });
});
it('completes shutdown even when the abort persistence throws', async () => { it('completes shutdown even when the abort persistence throws', async () => {
const bus = new AgentEventBus(); const bus = new AgentEventBus();
const abortScope = bus.createAbortScope(); const abortScope = bus.createAbortScope();

View File

@ -12,6 +12,8 @@ import type { TokenUsage } from '../../types/sdk/agent';
export class StreamWriterGuard { export class StreamWriterGuard {
private closed = false; private closed = false;
private errorWritten = false;
constructor(private readonly writer: WritableStreamDefaultWriter<StreamChunk>) { constructor(private readonly writer: WritableStreamDefaultWriter<StreamChunk>) {
writer.closed writer.closed
.then(() => { .then(() => {
@ -29,6 +31,7 @@ export class StreamWriterGuard {
if (this.closed) return; if (this.closed) return;
try { try {
await this.writer.write(chunk); await this.writer.write(chunk);
if (chunk.type === 'error') this.errorWritten = true;
} catch { } catch {
// Downstream consumer is gone (cancelled/errored). Nothing useful to do. // Downstream consumer is gone (cancelled/errored). Nothing useful to do.
} }
@ -46,8 +49,8 @@ export class StreamWriterGuard {
} }
/** /**
* Terminate the stream with an error: emit an `error` chunk and a terminal * Terminate the stream with an error: ensure an `error` chunk exists, emit a
* `finish` chunk, then close. Idempotent — a no-op if already closed. * terminal `finish` chunk, then close. Idempotent — a no-op if already closed.
* *
* `finish` enriches the terminal chunk with usage/model so an aborted run * `finish` enriches the terminal chunk with usage/model so an aborted run
* still carries the tokens consumed before the stop. * still carries the tokens consumed before the stop.
@ -58,7 +61,7 @@ export class StreamWriterGuard {
finish?: { usage?: TokenUsage; model?: string }, finish?: { usage?: TokenUsage; model?: string },
): Promise<void> { ): Promise<void> {
if (this.closed) return; if (this.closed) return;
await this.write({ type: 'error', error }); if (!this.errorWritten) await this.write({ type: 'error', error });
await this.write({ type: 'finish', finishReason, ...finish }); await this.write({ type: 'finish', finishReason, ...finish });
await this.close(); await this.close();
} }

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import { lookup as dnsLookup } from 'node:dns';
import { Agent, ProxyAgent, fetch as undiciFetch } from 'undici'; import { Agent, ProxyAgent, fetch as undiciFetch } from 'undici';
import type { MockedFunction } from 'vitest'; import type { MockedFunction } from 'vitest';
@ -14,6 +15,12 @@ vi.mock('undici', () => ({
fetch: vi.fn(), fetch: vi.fn(),
})); }));
const DEFAULT_AGENT_OPTIONS = {
headersTimeout: 3600000,
bodyTimeout: 3600000,
connect: { lookup: dnsLookup },
};
describe('getProxyAgent', () => { describe('getProxyAgent', () => {
// Store original environment variables // Store original environment variables
const originalEnv = { ...process.env }; const originalEnv = { ...process.env };
@ -36,20 +43,33 @@ describe('getProxyAgent', () => {
process.env = originalEnv; process.env = originalEnv;
}); });
describe('backward compatible behavior (no timeout options)', () => { describe('default behavior (no timeout options)', () => {
it('should return undefined when no proxy environment variables are set and no timeout options', () => { it('should return an Agent with the default lookup when no proxy environment variables are set and no timeout options', () => {
const agent = getProxyAgent(); const agent = getProxyAgent();
expect(agent).toBeUndefined();
expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS });
expect(ProxyAgent).not.toHaveBeenCalled(); expect(ProxyAgent).not.toHaveBeenCalled();
expect(Agent).not.toHaveBeenCalled();
}); });
it('should return undefined when no proxy is configured for target URL and no timeout options', () => { it('should return an Agent when no proxy is configured for target URL and no timeout options', () => {
const agent = getProxyAgent('https://api.openai.com/v1'); const agent = getProxyAgent('https://api.openai.com/v1');
expect(agent).toBeUndefined(); expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS });
expect(ProxyAgent).not.toHaveBeenCalled(); expect(ProxyAgent).not.toHaveBeenCalled();
expect(Agent).not.toHaveBeenCalled(); });
it('should reuse a single Agent across calls when no proxy, timeout options nor custom lookup are given', () => {
const first = getProxyAgent('https://api.openai.com/v1');
const second = getProxyAgent('https://api.anthropic.com/v1');
expect(second).toBe(first);
});
it('should build a fresh Agent when timeout options or a custom lookup are given', () => {
const shared = getProxyAgent('https://api.openai.com/v1');
expect(getProxyAgent('https://api.openai.com/v1', {})).not.toBe(shared);
expect(getProxyAgent('https://api.openai.com/v1', undefined, vi.fn())).not.toBe(shared);
}); });
it('should create ProxyAgent with default timeouts when HTTPS_PROXY is set', () => { it('should create ProxyAgent with default timeouts when HTTPS_PROXY is set', () => {
@ -137,7 +157,7 @@ describe('getProxyAgent', () => {
const agent = getProxyAgent('http://localhost:3000'); const agent = getProxyAgent('http://localhost:3000');
expect(agent).toBeUndefined(); expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS });
expect(ProxyAgent).not.toHaveBeenCalled(); expect(ProxyAgent).not.toHaveBeenCalled();
}); });
@ -148,7 +168,7 @@ describe('getProxyAgent', () => {
const agent = getProxyAgent('https://api.internal.company.com'); const agent = getProxyAgent('https://api.internal.company.com');
expect(agent).toBeUndefined(); expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS });
expect(ProxyAgent).not.toHaveBeenCalled(); expect(ProxyAgent).not.toHaveBeenCalled();
}); });
@ -199,20 +219,18 @@ describe('getProxyAgent', () => {
expect(Agent).toHaveBeenCalledWith({ expect(Agent).toHaveBeenCalledWith({
headersTimeout: 120000, headersTimeout: 120000,
bodyTimeout: 180000, bodyTimeout: 180000,
connect: { lookup: dnsLookup },
}); });
expect(agent).toEqual({ expect(agent).toEqual({
type: 'Agent', type: 'Agent',
options: { headersTimeout: 120000, bodyTimeout: 180000 }, options: { headersTimeout: 120000, bodyTimeout: 180000, connect: { lookup: dnsLookup } },
}); });
}); });
it('should use default timeouts when empty timeout options object is passed', () => { it('should use default timeouts when empty timeout options object is passed', () => {
getProxyAgent('https://api.openai.com/v1', {}); getProxyAgent('https://api.openai.com/v1', {});
expect(Agent).toHaveBeenCalledWith({ expect(Agent).toHaveBeenCalledWith(DEFAULT_AGENT_OPTIONS);
headersTimeout: 3600000,
bodyTimeout: 3600000,
});
}); });
it('should include connectTimeout when provided', () => { it('should include connectTimeout when provided', () => {
@ -226,6 +244,7 @@ describe('getProxyAgent', () => {
headersTimeout: 60000, headersTimeout: 60000,
bodyTimeout: 60000, bodyTimeout: 60000,
connectTimeout: 30000, connectTimeout: 30000,
connect: { lookup: dnsLookup },
}); });
}); });
@ -243,7 +262,8 @@ describe('getProxyAgent', () => {
expect(Agent).toHaveBeenCalled(); expect(Agent).toHaveBeenCalled();
}); });
it('should return an Agent instead of undefined when N8N_AI_TIMEOUT_MAX is set, even without a proxy or explicit timeout options', () => { it('should build a fresh Agent when N8N_AI_TIMEOUT_MAX is set, even without a proxy or explicit timeout options', () => {
const shared = getProxyAgent('https://api.openai.com/v1');
process.env.N8N_AI_TIMEOUT_MAX = '120000'; process.env.N8N_AI_TIMEOUT_MAX = '120000';
const agent = getProxyAgent('https://api.openai.com/v1'); const agent = getProxyAgent('https://api.openai.com/v1');
@ -251,8 +271,8 @@ describe('getProxyAgent', () => {
// DEFAULT_TIMEOUT was captured from the env at module load time (before this test set it), // DEFAULT_TIMEOUT was captured from the env at module load time (before this test set it),
// so the value here reflects that capture, not '120000' — the module-reset test below // so the value here reflects that capture, not '120000' — the module-reset test below
// covers the env value actually being picked up end to end. // covers the env value actually being picked up end to end.
expect(Agent).toHaveBeenCalled();
expect(agent).toEqual(expect.objectContaining({ type: 'Agent' })); expect(agent).toEqual(expect.objectContaining({ type: 'Agent' }));
expect(agent).not.toBe(shared);
expect(ProxyAgent).not.toHaveBeenCalled(); expect(ProxyAgent).not.toHaveBeenCalled();
}); });
@ -268,10 +288,15 @@ describe('getProxyAgent', () => {
expect(undici.Agent).toHaveBeenCalledWith({ expect(undici.Agent).toHaveBeenCalledWith({
headersTimeout: 120000, headersTimeout: 120000,
bodyTimeout: 120000, bodyTimeout: 120000,
connect: { lookup: dnsLookup },
}); });
expect(agent).toEqual({ expect(agent).toEqual({
type: 'Agent', type: 'Agent',
options: { headersTimeout: 120000, bodyTimeout: 120000 }, options: {
headersTimeout: 120000,
bodyTimeout: 120000,
connect: { lookup: dnsLookup },
},
}); });
}); });
}); });
@ -335,18 +360,39 @@ describe('proxyFetch', () => {
}); });
describe('with no proxy configured', () => { describe('with no proxy configured', () => {
it('should call fetch with undefined dispatcher when no proxy is set and no timeout options', async () => { it('should call fetch with an Agent dispatcher when no proxy is set and no timeout options', async () => {
const url = 'https://api.openai.com/v1'; const url = 'https://api.openai.com/v1';
await proxyFetch(url); await proxyFetch({ input: url, lookup: dnsLookup });
expect(mockFetch).toHaveBeenCalledWith(url, { expect(mockFetch).toHaveBeenCalledWith(url, {
dispatcher: undefined, dispatcher: expect.objectContaining({ type: 'Agent' }),
}); });
}); });
it('should reuse the same dispatcher across plain calls', async () => {
await proxyFetch({ input: 'https://api.openai.com/v1', lookup: dnsLookup });
await proxyFetch({ input: 'https://api.anthropic.com/v1', lookup: dnsLookup });
const [[, first], [, second]] = mockFetch.mock.calls as unknown as Array<
[unknown, { dispatcher: unknown }]
>;
expect(second.dispatcher).toBe(first.dispatcher);
});
it('should build the dispatcher with the supplied lookup', async () => {
const lookup = vi.fn();
await proxyFetch({ input: 'https://api.openai.com/v1', lookup });
expect(Agent).toHaveBeenCalledWith(expect.objectContaining({ connect: { lookup } }));
});
it('should call fetch with Agent dispatcher when timeout options are provided', async () => { it('should call fetch with Agent dispatcher when timeout options are provided', async () => {
const url = 'https://api.openai.com/v1'; const url = 'https://api.openai.com/v1';
await proxyFetch(url, undefined, { headersTimeout: 60000 }); await proxyFetch({
input: url,
timeoutOptions: { headersTimeout: 60000 },
lookup: dnsLookup,
});
expect(Agent).toHaveBeenCalled(); expect(Agent).toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith(url, { expect(mockFetch).toHaveBeenCalledWith(url, {
@ -362,20 +408,20 @@ describe('proxyFetch', () => {
body: JSON.stringify({ test: 'data' }), body: JSON.stringify({ test: 'data' }),
}; };
await proxyFetch(url, init); await proxyFetch({ input: url, init, lookup: dnsLookup });
expect(mockFetch).toHaveBeenCalledWith(url, { expect(mockFetch).toHaveBeenCalledWith(url, {
...init, ...init,
dispatcher: undefined, dispatcher: expect.objectContaining({ type: 'Agent' }),
}); });
}); });
it('should handle URL objects', async () => { it('should handle URL objects', async () => {
const url = new URL('https://api.openai.com/v1'); const url = new URL('https://api.openai.com/v1');
await proxyFetch(url); await proxyFetch({ input: url, lookup: dnsLookup });
expect(mockFetch).toHaveBeenCalledWith(url, { expect(mockFetch).toHaveBeenCalledWith(url, {
dispatcher: undefined, dispatcher: expect.objectContaining({ type: 'Agent' }),
}); });
}); });
@ -385,10 +431,10 @@ describe('proxyFetch', () => {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ test: 'data' }), body: JSON.stringify({ test: 'data' }),
}); });
await proxyFetch(request); await proxyFetch({ input: request, lookup: dnsLookup });
expect(mockFetch).toHaveBeenCalledWith(request, { expect(mockFetch).toHaveBeenCalledWith(request, {
dispatcher: undefined, dispatcher: expect.objectContaining({ type: 'Agent' }),
}); });
}); });
}); });
@ -399,7 +445,7 @@ describe('proxyFetch', () => {
process.env.HTTPS_PROXY = proxyUrl; process.env.HTTPS_PROXY = proxyUrl;
const url = 'https://api.openai.com/v1'; const url = 'https://api.openai.com/v1';
await proxyFetch(url); await proxyFetch({ input: url, lookup: dnsLookup });
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl })); expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
expect(mockFetch).toHaveBeenCalledWith(url, { expect(mockFetch).toHaveBeenCalledWith(url, {
@ -417,7 +463,7 @@ describe('proxyFetch', () => {
headers: { Authorization: 'Bearer token123' }, headers: { Authorization: 'Bearer token123' },
}; };
await proxyFetch(url, init); await proxyFetch({ input: url, init, lookup: dnsLookup });
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl })); expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
expect(mockFetch).toHaveBeenCalledWith(url, { expect(mockFetch).toHaveBeenCalledWith(url, {
@ -431,7 +477,7 @@ describe('proxyFetch', () => {
process.env.HTTP_PROXY = proxyUrl; process.env.HTTP_PROXY = proxyUrl;
const url = new URL('http://api.example.com/data'); const url = new URL('http://api.example.com/data');
await proxyFetch(url); await proxyFetch({ input: url, lookup: dnsLookup });
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl })); expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
expect(mockFetch).toHaveBeenCalledWith(url, { expect(mockFetch).toHaveBeenCalledWith(url, {
@ -444,7 +490,7 @@ describe('proxyFetch', () => {
process.env.HTTPS_PROXY = proxyUrl; process.env.HTTPS_PROXY = proxyUrl;
const request = new Request('https://api.openai.com/v1'); const request = new Request('https://api.openai.com/v1');
await proxyFetch(request); await proxyFetch({ input: request, lookup: dnsLookup });
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl })); expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
expect(mockFetch).toHaveBeenCalledWith(request, { expect(mockFetch).toHaveBeenCalledWith(request, {
@ -458,11 +504,12 @@ describe('proxyFetch', () => {
process.env.NO_PROXY = 'localhost,127.0.0.1'; process.env.NO_PROXY = 'localhost,127.0.0.1';
const url = 'https://localhost:3000/api'; const url = 'https://localhost:3000/api';
await proxyFetch(url); await proxyFetch({ input: url, lookup: dnsLookup });
// Should not create ProxyAgent for localhost // Should not create ProxyAgent for localhost
expect(ProxyAgent).not.toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith(url, { expect(mockFetch).toHaveBeenCalledWith(url, {
dispatcher: undefined, dispatcher: expect.objectContaining({ type: 'Agent' }),
}); });
}); });
@ -471,7 +518,11 @@ describe('proxyFetch', () => {
process.env.HTTPS_PROXY = proxyUrl; process.env.HTTPS_PROXY = proxyUrl;
const url = 'https://api.openai.com/v1'; const url = 'https://api.openai.com/v1';
await proxyFetch(url, undefined, { headersTimeout: 300000, bodyTimeout: 300000 }); await proxyFetch({
input: url,
timeoutOptions: { headersTimeout: 300000, bodyTimeout: 300000 },
lookup: dnsLookup,
});
expect(ProxyAgent).toHaveBeenCalledWith({ expect(ProxyAgent).toHaveBeenCalledWith({
uri: proxyUrl, uri: proxyUrl,
@ -490,7 +541,7 @@ describe('proxyFetch', () => {
mockFetch.mockResolvedValueOnce(expectedResponse); mockFetch.mockResolvedValueOnce(expectedResponse);
const url = 'https://api.openai.com/v1'; const url = 'https://api.openai.com/v1';
const result = await proxyFetch(url); const result = await proxyFetch({ input: url, lookup: dnsLookup });
expect(result).toBe(expectedResponse); expect(result).toBe(expectedResponse);
}); });
@ -501,7 +552,7 @@ describe('proxyFetch', () => {
const url = 'https://api.openai.com/v1'; const url = 'https://api.openai.com/v1';
await expect(proxyFetch(url)).rejects.toThrow('Network error'); await expect(proxyFetch({ input: url, lookup: dnsLookup })).rejects.toThrow('Network error');
}); });
it('should return error responses without throwing', async () => { it('should return error responses without throwing', async () => {
@ -512,10 +563,9 @@ describe('proxyFetch', () => {
mockFetch.mockResolvedValueOnce(errorResponse); mockFetch.mockResolvedValueOnce(errorResponse);
const url = 'https://api.openai.com/v1'; const url = 'https://api.openai.com/v1';
const result = await proxyFetch(url); const result = await proxyFetch({ input: url, lookup: dnsLookup });
expect(result).toBe(errorResponse); expect(result).toBe(errorResponse);
expect(result.status).toBe(404);
}); });
}); });
}); });

View File

@ -15,6 +15,7 @@ export { N8nBinaryLoader } from './utils/n8n-binary-loader';
export { N8nJsonLoader } from './utils/n8n-json-loader'; export { N8nJsonLoader } from './utils/n8n-json-loader';
export { N8nPdfLoader } from './utils/loaders/n8n-pdf-loader'; export { N8nPdfLoader } from './utils/loaders/n8n-pdf-loader';
export { N8nLlmTracing } from './utils/n8n-llm-tracing'; export { N8nLlmTracing } from './utils/n8n-llm-tracing';
export { redactHeaderValues } from './utils/redact-headers';
export { export {
TextEditorDocument, TextEditorDocument,
NoMatchFoundError, NoMatchFoundError,
@ -55,6 +56,7 @@ export {
getNodeProxyAgent, getNodeProxyAgent,
proxyFetch, proxyFetch,
type AgentTimeoutOptions, type AgentTimeoutOptions,
type ProxyFetchOptions,
} from './utils/http-proxy-agent'; } from './utils/http-proxy-agent';
export { braveSearch, searxngSearch, type BraveSearchOptions } from './web-search'; export { braveSearch, searxngSearch, type BraveSearchOptions } from './web-search';
export type { WebSearchOptions, WebSearchResponse, WebSearchResult } from './web-search'; export type { WebSearchOptions, WebSearchResponse, WebSearchResult } from './web-search';

View File

@ -17,6 +17,7 @@
* and left these runner-side ones in place). * and left these runner-side ones in place).
*/ */
import { createHttpsProxyAgent, resolveProxyUrl } from '@n8n/backend-network/proxy'; // `@n8n/backend-network/proxy` is a DI-free subpath: it pulls in only the proxy-agent libs import { createHttpsProxyAgent, resolveProxyUrl } from '@n8n/backend-network/proxy'; // `@n8n/backend-network/proxy` is a DI-free subpath: it pulls in only the proxy-agent libs
import { lookup as defaultLookup } from 'node:dns';
import type { AgentOptions } from 'node:https'; import type { AgentOptions } from 'node:https';
import type { LookupFunction } from 'node:net'; import type { LookupFunction } from 'node:net';
/* eslint-disable n8n-local-rules/no-uncentralized-http -- raw dispatchers for AI SDK `fetchOptions`; see block comment above */ /* eslint-disable n8n-local-rules/no-uncentralized-http -- raw dispatchers for AI SDK `fetchOptions`; see block comment above */
@ -43,30 +44,33 @@ const DEFAULT_TIMEOUT = parseInt(process.env.N8N_AI_TIMEOUT_MAX ?? '3600000', 10
*/ */
const PROXY_FALLBACK_TARGET = 'https://example.nonexistent/'; const PROXY_FALLBACK_TARGET = 'https://example.nonexistent/';
/**
* Agent shared by every caller that needs no proxy, no timeout overrides and the
* plain system DNS lookup, so those callers keep a single connection pool instead
* of building an agent (and a pool) per request.
*/
let sharedAgent: Agent | undefined;
/** /**
* Returns an undici Agent or ProxyAgent with configured timeouts based on the environment variables and target URL. * Returns an undici Agent or ProxyAgent with configured timeouts based on the environment variables and target URL.
* When target URL is not provided, NO_PROXY environment variable is not respected. * When target URL is not provided, NO_PROXY environment variable is not respected.
* *
* @param targetUrl - The target URL to check proxy configuration for (optional) * @param targetUrl - The target URL to check proxy configuration for (optional)
* @param timeoutOptions - Optional timeout configuration to override defaults. When provided, * @param timeoutOptions - Optional timeout configuration to override defaults.
* always returns an Agent/ProxyAgent (even without proxy) to ensure timeouts are applied. * @param lookup - Connect-time DNS lookup, applied to every Agent this builds.
* @param lookup - Optional DNS lookup to pin the resolved address at connect time (e.g. an egress * Defaults to the plain system lookup.
* filter's secure lookup). When provided (without a proxy) an Agent is always returned. * @returns A ProxyAgent when a proxy applies to the target, otherwise an Agent. Callers using the
* @returns An Agent (no proxy with timeout options, a lookup, or `N8N_AI_TIMEOUT_MAX` set) or ProxyAgent * defaults (no timeout options, no `N8N_AI_TIMEOUT_MAX`, plain lookup) share one Agent.
* (with proxy) configured with timeouts, or undefined if no proxy, timeout options, lookup, nor
* `N8N_AI_TIMEOUT_MAX` are provided/set (backward compatible behavior).
* *
* @remarks * @remarks
* When timeoutOptions are provided, this function always returns an agent to ensure timeouts are properly configured. * The default undici timeouts (5 minutes) are too short for many AI operations, so an agent is
* The default undici timeouts (5 minutes) are too short for many AI operations. * always returned rather than falling back to undici's global dispatcher. `N8N_AI_TIMEOUT_MAX`
* When timeoutOptions are NOT provided, this still returns an agent if `N8N_AI_TIMEOUT_MAX` is set, * is honoured even when no proxy and no explicit timeout options are configured.
* so the env override isn't silently ignored just because no proxy is configured. Otherwise, returns
* undefined if no proxy is configured (backward compatible).
*/ */
export function getProxyAgent( export function getProxyAgent(
targetUrl?: string, targetUrl?: string,
timeoutOptions?: AgentTimeoutOptions, timeoutOptions?: AgentTimeoutOptions,
lookup?: LookupFunction, lookup: LookupFunction = defaultLookup,
) { ) {
const proxyUrl = resolveProxyUrl(targetUrl, PROXY_FALLBACK_TARGET); const proxyUrl = resolveProxyUrl(targetUrl, PROXY_FALLBACK_TARGET);
@ -78,37 +82,45 @@ export function getProxyAgent(
}), }),
}; };
if (!proxyUrl) { if (proxyUrl) {
if (lookup) { return new ProxyAgent({ uri: proxyUrl, ...agentOptions });
return new Agent({ ...agentOptions, connect: { lookup } });
}
if (timeoutOptions) {
return new Agent(agentOptions);
}
if (process.env.N8N_AI_TIMEOUT_MAX) {
return new Agent(agentOptions);
}
return undefined;
} }
return new ProxyAgent({ uri: proxyUrl, ...agentOptions }); const isDefaultCase =
!timeoutOptions && !process.env.N8N_AI_TIMEOUT_MAX && lookup === defaultLookup;
if (isDefaultCase) {
sharedAgent ??= new Agent({ ...agentOptions, connect: { lookup } });
return sharedAgent;
}
return new Agent({ ...agentOptions, connect: { lookup } });
}
/**
* Options for {@link proxyFetch}.
*/
export interface ProxyFetchOptions {
/** The URL to fetch */
input: RequestInfo | URL;
/** Standard fetch RequestInit options */
init?: RequestInit;
/** Optional timeout configuration to override defaults */
timeoutOptions?: AgentTimeoutOptions;
/** Connect-time DNS lookup, e.g. an egress filter's secure lookup */
lookup: LookupFunction;
} }
/** /**
* Make a fetch() request with an Agent/ProxyAgent that has configured timeouts. * Make a fetch() request with an Agent/ProxyAgent that has configured timeouts.
* If proxy environment variables are set, uses ProxyAgent; otherwise uses Agent. * If proxy environment variables are set, uses ProxyAgent; otherwise uses Agent.
*
* @param input - The URL to fetch
* @param init - Standard fetch RequestInit options
* @param timeoutOptions - Optional timeout configuration to override defaults
* @param lookup - Optional connect-time DNS lookup (e.g. an egress filter's secure lookup)
*/ */
export async function proxyFetch( export async function proxyFetch({
input: RequestInfo | URL, input,
init?: RequestInit, init,
timeoutOptions?: AgentTimeoutOptions, timeoutOptions,
lookup?: LookupFunction, lookup,
): Promise<Response> { }: ProxyFetchOptions): Promise<Response> {
const targetUrl = input instanceof Request ? input.url : input.toString(); const targetUrl = input instanceof Request ? input.url : input.toString();
const dispatcher = getProxyAgent(targetUrl, timeoutOptions, lookup); const dispatcher = getProxyAgent(targetUrl, timeoutOptions, lookup);

View File

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

View File

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

View File

@ -63,6 +63,14 @@ describe('InstanceAiConfirmRequestDto', () => {
'credentialAutoSetup with attempt id', 'credentialAutoSetup with attempt id',
{ kind: 'credentialAutoSetup', credentialType: 'firecrawlApi', attemptId: 'attempt-1' }, { kind: 'credentialAutoSetup', credentialType: 'firecrawlApi', attemptId: 'attempt-1' },
], ],
[
'credential destination approval',
{
kind: 'credentialDestination',
approved: true,
origin: 'https://api.example.com',
},
],
// DomainAccessApproval: handleAction (primary path — with action) // DomainAccessApproval: handleAction (primary path — with action)
[ [
'domainAccessApprove with allow_domain', 'domainAccessApprove with allow_domain',

View File

@ -1,6 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { import {
credentialDestinationDecisionSchema,
domainAccessActionSchema, domainAccessActionSchema,
instanceAiApprovalResumeSchema, instanceAiApprovalResumeSchema,
instanceGatewayResourceDecisionSchema, instanceGatewayResourceDecisionSchema,
@ -49,6 +50,11 @@ const credentialAutoSetupConfirmSchema = z.object({
attemptId: z.string().trim().min(1).max(64).optional(), attemptId: z.string().trim().min(1).max(64).optional(),
}); });
const credentialDestinationConfirmSchema = credentialDestinationDecisionSchema.extend({
kind: z.literal('credentialDestination'),
approved: z.boolean(),
});
/** Domain-access approval — `domainAccessAction` carries which scope the user picked. */ /** Domain-access approval — `domainAccessAction` carries which scope the user picked. */
const domainAccessApproveSchema = z.object({ const domainAccessApproveSchema = z.object({
kind: z.literal('domainAccessApprove'), kind: z.literal('domainAccessApprove'),
@ -106,6 +112,7 @@ export const InstanceAiConfirmRequestDto = z.discriminatedUnion('kind', [
questionsConfirmSchema, questionsConfirmSchema,
credentialSelectionConfirmSchema, credentialSelectionConfirmSchema,
credentialAutoSetupConfirmSchema, credentialAutoSetupConfirmSchema,
credentialDestinationConfirmSchema,
domainAccessApproveSchema, domainAccessApproveSchema,
domainAccessDenySchema, domainAccessDenySchema,
planDenySchema, planDenySchema,

View File

@ -304,6 +304,7 @@ export {
export { export {
buildRunWorkflowSessionGrantKey, buildRunWorkflowSessionGrantKey,
buildUpdateWorkflowSessionGrantKey, buildUpdateWorkflowSessionGrantKey,
buildCredentialDestinationGrantKey,
buildDataTablesSessionGrantKey, buildDataTablesSessionGrantKey,
buildSetupSkipGrantKey, buildSetupSkipGrantKey,
parseSetupSkipGrants, parseSetupSkipGrants,
@ -365,6 +366,8 @@ export {
instanceAiApprovalResumeSchema, instanceAiApprovalResumeSchema,
webSearchMetaSchema, webSearchMetaSchema,
credentialFlowSchema, credentialFlowSchema,
credentialDestinationSchema,
credentialDestinationDecisionSchema,
instanceAiCredentialHandoffContextSchema, instanceAiCredentialHandoffContextSchema,
instanceAiAgentPreviewHandoffContextSchema, instanceAiAgentPreviewHandoffContextSchema,
instanceAiHandoffContextSchema, instanceAiHandoffContextSchema,
@ -532,6 +535,8 @@ export type {
DomainAccessMeta, DomainAccessMeta,
WebSearchMeta, WebSearchMeta,
InstanceAiCredentialFlow, InstanceAiCredentialFlow,
InstanceAiCredentialDestination,
InstanceAiCredentialDestinationDecision,
InstanceAiCredentialHandoffContext, InstanceAiCredentialHandoffContext,
InstanceAiAgentPreviewHandoffContext, InstanceAiAgentPreviewHandoffContext,
InstanceAiHandoffContext, InstanceAiHandoffContext,

View File

@ -755,6 +755,39 @@ describe('agent-run-reducer', () => {
}); });
}); });
it('confirmation-request passes through credential destination metadata when present', () => {
const state = stateWithRun('run-1', 'root');
reduceEvent(state, makeToolCall('run-1', 'root', 'tc-1', 'workflows'));
reduceEvent(state, {
type: 'confirmation-request',
runId: 'run-1',
agentId: 'root',
payload: {
requestId: 'req-destination',
toolCallId: 'tc-1',
toolName: 'workflows',
args: { action: 'setup' },
severity: 'warning',
message: 'Review where this credential will be used',
credentialDestination: {
origin: 'https://api.example.com',
nodeNames: ['Fetch data'],
},
},
});
const tc = state.toolCallsById['tc-1'];
expect(tc.confirmation).toEqual({
requestId: 'req-destination',
severity: 'warning',
message: 'Review where this credential will be used',
credentialDestination: {
origin: 'https://api.example.com',
nodeNames: ['Fetch data'],
},
});
});
it('confirmation-request passes through projectId when present', () => { it('confirmation-request passes through projectId when present', () => {
const state = stateWithRun('run-1', 'root'); const state = stateWithRun('run-1', 'root');
reduceEvent(state, makeToolCall('run-1', 'root', 'tc-1', 'setup-credentials')); reduceEvent(state, makeToolCall('run-1', 'root', 'tc-1', 'setup-credentials'));

View File

@ -10,6 +10,7 @@ import {
instanceAiFileAttachmentSchema, instanceAiFileAttachmentSchema,
MAX_ATTACHMENT_BASE64_BYTES, MAX_ATTACHMENT_BASE64_BYTES,
applyBranchReadOnlyOverrides, applyBranchReadOnlyOverrides,
buildCredentialDestinationGrantKey,
buildDataTablesSessionGrantKey, buildDataTablesSessionGrantKey,
buildUpdateWorkflowSessionGrantKey, buildUpdateWorkflowSessionGrantKey,
buildSetupSkipGrantKey, buildSetupSkipGrantKey,
@ -240,6 +241,30 @@ describe('confirmationRequestPayloadSchema', () => {
expect(confirmationRequestPayloadSchema.parse(payload)).toEqual(payload); expect(confirmationRequestPayloadSchema.parse(payload)).toEqual(payload);
}); });
it('preserves a credential destination requiring approval', () => {
const payload = makeConfirmation({
credentialDestination: {
origin: 'https://api.example.com',
nodeNames: ['Fetch account'],
},
});
expect(confirmationRequestPayloadSchema.parse(payload)).toEqual(payload);
});
it('requires a credential destination to be an exact HTTP origin', () => {
const result = confirmationRequestPayloadSchema.safeParse(
makeConfirmation({
credentialDestination: {
origin: 'https://api.example.com/v1',
nodeNames: ['Fetch account'],
},
}),
);
expect(result.success).toBe(false);
});
}); });
describe('isDisplayableConfirmationRequest', () => { describe('isDisplayableConfirmationRequest', () => {
@ -475,6 +500,14 @@ describe('workflow update session grant keys', () => {
}); });
}); });
describe('credential destination session grant keys', () => {
it('encodes the workflow and exact origin', () => {
expect(buildCredentialDestinationGrantKey('workflow:1', 'https://api.example.com:8443')).toBe(
'credential-destination:workflow%3A1:https%3A%2F%2Fapi.example.com%3A8443',
);
});
});
describe('workflow-setup skip keys', () => { describe('workflow-setup skip keys', () => {
it('round-trips credential types and ignores unrelated keys', () => { it('round-trips credential types and ignores unrelated keys', () => {
const keys = new Set([ const keys = new Set([

View File

@ -470,29 +470,13 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
if (!isSafeObjectKey(event.payload.toolCallId)) break; if (!isSafeObjectKey(event.payload.toolCallId)) break;
const tc = state.toolCallsById[event.payload.toolCallId]; const tc = state.toolCallsById[event.payload.toolCallId];
if (tc) { if (tc) {
tc.confirmation = { const {
requestId: event.payload.requestId, toolCallId: _toolCallId,
inputThreadId: event.payload.inputThreadId, toolName: _toolName,
severity: event.payload.severity, args: _args,
message: event.payload.message, ...confirmation
targetApproval: event.payload.targetApproval, } = event.payload;
credentialRequests: event.payload.credentialRequests, tc.confirmation = confirmation;
requireUserSelection: event.payload.requireUserSelection,
projectId: event.payload.projectId,
inputType: event.payload.inputType,
domainAccess: event.payload.domainAccess,
webSearch: event.payload.webSearch,
credentialFlow: event.payload.credentialFlow,
setupRequests: event.payload.setupRequests,
workflowId: event.payload.workflowId,
planItems: event.payload.planItems,
questions: event.payload.questions,
introMessage: event.payload.introMessage,
tasks: event.payload.tasks,
resourceDecision: event.payload.resourceDecision,
channelConfig: event.payload.channelConfig,
mcpConnectRequest: event.payload.mcpConnectRequest,
};
} }
break; break;
} }

View File

@ -19,7 +19,7 @@ export type BreakingChangeVersion = z.infer<typeof breakingChangeVersionSchema>;
* When a new major version is released, update this value and add the * When a new major version is released, update this value and add the
* corresponding breaking-change rules on the backend. * corresponding breaking-change rules on the backend.
*/ */
export const MIGRATION_REPORT_TARGET_VERSION: BreakingChangeVersion | null = null; export const MIGRATION_REPORT_TARGET_VERSION: BreakingChangeVersion | null = 'v3';
// Common schemas // Common schemas
const recommendationSchema = z.object({ const recommendationSchema = z.object({

View File

@ -64,6 +64,11 @@ export function buildUpdateWorkflowSessionGrantKey(workflowId: string): string {
return `workflows:update:${workflowId}`; return `workflows:update:${workflowId}`;
} }
/** Builds the thread-level grant for using a credential with one exact service origin. */
export function buildCredentialDestinationGrantKey(workflowId: string, origin: string): string {
return `credential-destination:${encodeURIComponent(workflowId)}:${encodeURIComponent(origin)}`;
}
/** /**
* Builds the thread-level "always allow" grant key for a data-tables action * Builds the thread-level "always allow" grant key for a data-tables action
* (e.g. `create`, `insert-rows`). Must match the frontend key * (e.g. `create`, `insert-rows`). Must match the frontend key
@ -412,6 +417,18 @@ export const credentialPlaceholderDefSchema = z.object({
}); });
export type InstanceAiCredentialPlaceholderDef = z.infer<typeof credentialPlaceholderDefSchema>; export type InstanceAiCredentialPlaceholderDef = z.infer<typeof credentialPlaceholderDefSchema>;
const exactHttpOriginSchema = z
.string()
.max(300)
.refine((value) => {
try {
const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol) && url.origin === value;
} catch {
return false;
}
}, 'Expected an exact HTTP origin');
/** /**
* Agent-supplied recipe for creating a Templated Custom Auth credential: the * Agent-supplied recipe for creating a Templated Custom Auth credential: the
* auth request parts with `{{placeholder}}` markers where user-provided values * auth request parts with `{{placeholder}}` markers where user-provided values
@ -438,6 +455,9 @@ export const credentialSetupHintSchema = z.object({
* being set up (never model-supplied). Stamped into the created credential * being set up (never model-supplied). Stamped into the created credential
* so setup surfaces only offer it to nodes calling the same service. */ * so setup surfaces only offer it to nodes calling the same service. */
serviceHost: z.string().optional(), serviceHost: z.string().optional(),
/** Exact origin of the API the recipe targets, derived server-side from the
* node being set up. Used to bind automatic credential tests to that API. */
serviceOrigin: exactHttpOriginSchema.optional(),
}); });
export type InstanceAiCredentialSetupHint = z.infer<typeof credentialSetupHintSchema>; export type InstanceAiCredentialSetupHint = z.infer<typeof credentialSetupHintSchema>;
@ -457,6 +477,19 @@ export const credentialFlowSchema = z.object({
}); });
export type InstanceAiCredentialFlow = z.infer<typeof credentialFlowSchema>; export type InstanceAiCredentialFlow = z.infer<typeof credentialFlowSchema>;
export const credentialDestinationSchema = z.object({
origin: exactHttpOriginSchema,
nodeNames: z.array(z.string().trim().min(1).max(255)).min(1),
});
export type InstanceAiCredentialDestination = z.infer<typeof credentialDestinationSchema>;
export const credentialDestinationDecisionSchema = credentialDestinationSchema.pick({
origin: true,
});
export type InstanceAiCredentialDestinationDecision = z.infer<
typeof credentialDestinationDecisionSchema
>;
export const workflowSetupNodeSchema = z.object({ export const workflowSetupNodeSchema = z.object({
node: z.object({ node: z.object({
name: z.string(), name: z.string(),
@ -718,6 +751,11 @@ export const confirmationRequestPayloadSchema = z.object({
.describe( .describe(
'Credential flow stage — finalize renders post-verification credential picker with different copy', 'Credential flow stage — finalize renders post-verification credential picker with different copy',
), ),
credentialDestination: credentialDestinationSchema
.optional()
.describe(
'Exact destination that must be approved before a workflow credential setup card opens',
),
setupRequests: z setupRequests: z
.array(workflowSetupNodeSchema) .array(workflowSetupNodeSchema)
.optional() .optional()
@ -1381,35 +1419,10 @@ export interface InstanceAiSendMessageResponse {
// Frontend store types (shared so both sides agree on structure) // Frontend store types (shared so both sides agree on structure)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface InstanceAiConfirmation { export type InstanceAiConfirmation = Omit<
requestId: string; InstanceAiConfirmationRequestPayload,
inputThreadId?: string; 'toolCallId' | 'toolName' | 'args'
severity: InstanceAiConfirmationSeverity; > & { expired?: boolean };
message: string;
targetApproval?: InstanceAiTargetApproval;
credentialRequests?: InstanceAiCredentialRequest[];
requireUserSelection?: boolean;
projectId?: string;
inputType?: 'approval' | 'text' | 'questions' | 'plan-review' | 'resource-decision' | 'continue';
domainAccess?: DomainAccessMeta;
webSearch?: WebSearchMeta;
credentialFlow?: InstanceAiCredentialFlow;
setupRequests?: InstanceAiWorkflowSetupNode[];
workflowId?: string;
planItems?: PlannedTaskArg[];
questions?: Array<{
id: string;
question: string;
type: 'single' | 'multi' | 'text';
options?: string[];
}>;
introMessage?: string;
tasks?: TaskList;
resourceDecision?: GatewayConfirmationRequiredPayload;
channelConfig?: InstanceAiChannelConfig;
mcpConnectRequest?: InstanceAiMcpConnectRequest;
expired?: boolean;
}
export interface InstanceAiToolCallState { export interface InstanceAiToolCallState {
toolCallId: string; toolCallId: string;

View File

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

View File

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

View File

@ -2,3 +2,4 @@ export { SsrfProtectionService } from './ssrf-protection.service';
export type { SsrfBridge } from './ssrf-protection.service'; export type { SsrfBridge } from './ssrf-protection.service';
export { SsrfBlockedIpError } from './ssrf-blocked-ip.error'; export { SsrfBlockedIpError } from './ssrf-blocked-ip.error';
export { SsrfBlockedHostnameError } from './ssrf-blocked-hostname.error'; export { SsrfBlockedHostnameError } from './ssrf-blocked-hostname.error';
export { passthroughEgressFilter } from './passthrough-egress-filter';

View File

@ -0,0 +1,16 @@
import { createResultOk } from '@n8n/utils/result';
import type { NodeEgressFilter } from 'n8n-workflow';
import { lookup } from 'node:dns';
import type { LookupFunction } from 'node:net';
/**
* Passthrough `NodeEgressFilter` used when no egress policy is configured.
* `validateUrl` always resolves ok, and `createSecureLookup` returns the
* plain system DNS lookup, so callers get an unfiltered transport without
* having to special-case a missing filter.
*/
export const passthroughEgressFilter: NodeEgressFilter = {
validateUrl: async () => await Promise.resolve(createResultOk(undefined)),
// Same function reference every call, so callers can compare it by identity.
createSecureLookup: (): LookupFunction => lookup,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -61,7 +61,7 @@ export class SecurityConfig {
* Separate multiple patterns with semicolons. Default blocks `.git`. Set to empty to disable pattern-based blocking. * Separate multiple patterns with semicolons. Default blocks `.git`. Set to empty to disable pattern-based blocking.
*/ */
@Env('N8N_BLOCK_FILE_PATTERNS') @Env('N8N_BLOCK_FILE_PATTERNS')
blockFilePatterns: string = '^(.*\\/)*\\.git(\\/.*)*$'; blockFilePatterns: string = '^(?:[^/]*/)*\\.git(?:/.*)?$';
/** /**
* In a [security audit](https://docs.n8n.io/hosting/securing/security-audit/), how many days for a workflow to be considered abandoned if not executed. * In a [security audit](https://docs.n8n.io/hosting/securing/security-audit/), how many days for a workflow to be considered abandoned if not executed.

View File

@ -506,7 +506,7 @@ describe('GlobalConfig', () => {
security: { security: {
restrictFileAccessTo: '~/.n8n-files', restrictFileAccessTo: '~/.n8n-files',
blockFileAccessToN8nFiles: true, blockFileAccessToN8nFiles: true,
blockFilePatterns: '^(.*\\/)*\\.git(\\/.*)*$', blockFilePatterns: '^(?:[^/]*/)*\\.git(?:/.*)?$',
daysAbandonedWorkflow: 90, daysAbandonedWorkflow: 90,
contentSecurityPolicy: undefined, contentSecurityPolicy: undefined,
contentSecurityPolicyReportOnly: DEFAULT_CONTENT_SECURITY_POLICY, contentSecurityPolicyReportOnly: DEFAULT_CONTENT_SECURITY_POLICY,

View File

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

View File

@ -1,2 +1,5 @@
/** Packages whose node type definitions are generated to disk at build time. */ /**
* Packages whose node type definitions are generated to disk at build time.
* These names are also reserved and cannot be installed as community packages.
*/
export const BUILTIN_NODES_PACKAGES = ['n8n-nodes-base', '@n8n/n8n-nodes-langchain'] as const; export const BUILTIN_NODES_PACKAGES = ['n8n-nodes-base', '@n8n/n8n-nodes-langchain'] as const;

View File

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

View File

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

View File

@ -0,0 +1,56 @@
import { Container } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import type { SelectQueryBuilder } from '@n8n/typeorm';
import { mock } from 'vitest-mock-extended';
import { ProjectRelation } from '../../entities';
import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager';
import { ProjectRelationRepository } from '../project-relation.repository';
describe('ProjectRelationRepository', () => {
mockEntityManager(ProjectRelation);
const projectRelationRepository = Container.get(ProjectRelationRepository);
beforeEach(() => {
vi.restoreAllMocks();
});
describe('findPersonalOwnerEmails', () => {
it('should map each personal project id to the email of its owner', async () => {
const queryBuilder = mock<SelectQueryBuilder<ProjectRelation>>();
queryBuilder.innerJoin.mockReturnThis();
queryBuilder.select.mockReturnThis();
queryBuilder.addSelect.mockReturnThis();
queryBuilder.where.mockReturnThis();
queryBuilder.andWhere.mockReturnThis();
queryBuilder.getRawMany.mockResolvedValue([
{ projectId: 'project1', email: 'owner@example.com' },
]);
vi.spyOn(projectRelationRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
const result = await projectRelationRepository.findPersonalOwnerEmails([
'project1',
'project1',
'project2',
]);
expect(queryBuilder.where).toHaveBeenCalledWith(
'projectRelation.projectId IN (:...projectIds)',
{ projectIds: ['project1', 'project2'] },
);
expect(queryBuilder.andWhere).toHaveBeenCalledWith('projectRelation.role = :role', {
role: PROJECT_OWNER_ROLE_SLUG,
});
expect(result).toEqual(new Map([['project1', 'owner@example.com']]));
});
it('should not query when there are no project ids', async () => {
const createQueryBuilder = vi.spyOn(projectRelationRepository, 'createQueryBuilder');
const result = await projectRelationRepository.findPersonalOwnerEmails([]);
expect(createQueryBuilder).not.toHaveBeenCalled();
expect(result).toEqual(new Map());
});
});
});

View File

@ -3,6 +3,7 @@ import { In, type SelectQueryBuilder } from '@n8n/typeorm';
import type { Mocked } from 'vitest'; import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
import type { Project } from '../../entities';
import { SharedCredentials } from '../../entities'; import { SharedCredentials } from '../../entities';
import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager'; import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager';
import { SharedCredentialsRepository } from '../shared-credentials.repository'; import { SharedCredentialsRepository } from '../shared-credentials.repository';
@ -35,7 +36,7 @@ describe('SharedCredentialsRepository', () => {
await sharedCredentialsRepository.findByCredentialIds(credentialIds, role); await sharedCredentialsRepository.findByCredentialIds(credentialIds, role);
expect(entityManager.find).toHaveBeenCalledWith(SharedCredentials, { expect(entityManager.find).toHaveBeenCalledWith(SharedCredentials, {
relations: { credentials: true, project: { projectRelations: { user: true, role: true } } }, relations: { credentials: true },
where: { where: {
credentialsId: In(credentialIds), credentialsId: In(credentialIds),
role, role,
@ -44,6 +45,23 @@ describe('SharedCredentialsRepository', () => {
}); });
}); });
describe('findOwnerProjectsByCredentialIds', () => {
it('should map each credential id to its owner project', async () => {
const project = mock<Project>({ id: 'project1' });
entityManager.find.mockResolvedValueOnce([
mock<SharedCredentials>({ credentialsId: 'cred1', project }),
]);
const result = await sharedCredentialsRepository.findOwnerProjectsByCredentialIds(['cred1']);
expect(entityManager.find).toHaveBeenCalledWith(SharedCredentials, {
where: { credentialsId: In(['cred1']), role: 'credential:owner' },
relations: { project: true },
});
expect(result).toEqual(new Map([['cred1', project]]));
});
});
describe('getSharedPersonalCredentialsCount', () => { describe('getSharedPersonalCredentialsCount', () => {
it('should return count with correct joins and filters', async () => { it('should return count with correct joins and filters', async () => {
queryBuilder.getCount.mockResolvedValue(3); queryBuilder.getCount.mockResolvedValue(3);

View File

@ -121,27 +121,6 @@ describe('SharedWorkflowRepository', () => {
}); });
}); });
describe('findByWorkflowIds', () => {
it('merges owner rows returned from different chunks', async () => {
const first = mock<SharedWorkflow>({ workflowId: 'first' });
const last = mock<SharedWorkflow>({ workflowId: 'last' });
entityManager.find.mockResolvedValueOnce([first]).mockResolvedValueOnce([last]);
const workflowIds = Array.from({ length: 10_001 }, (_, index) => `workflow-${index}`);
const result = await sharedWorkflowRepository.findByWorkflowIds(workflowIds);
expect(entityManager.find).toHaveBeenCalledTimes(2);
expect(entityManager.find).toHaveBeenNthCalledWith(2, SharedWorkflow, {
where: {
role: 'workflow:owner',
workflowId: In(['workflow-10000']),
},
relations: { project: { projectRelations: { user: true, role: true } } },
});
expect(result).toEqual([first, last]);
});
});
describe('findWorkflowIdsInUserProjects', () => { describe('findWorkflowIdsInUserProjects', () => {
it('returns an empty set without querying when there are no workflow ids', async () => { it('returns an empty set without querying when there are no workflow ids', async () => {
const result = await sharedWorkflowRepository.findWorkflowIdsInUserProjects([], 'user-1', [ const result = await sharedWorkflowRepository.findWorkflowIdsInUserProjects([], 'user-1', [

View File

@ -3,6 +3,7 @@ import { PROJECT_OWNER_ROLE_SLUG, type ProjectRole } from '@n8n/permissions';
import { DataSource, In, Repository } from '@n8n/typeorm'; import { DataSource, In, Repository } from '@n8n/typeorm';
import { ProjectRelation } from '../entities'; import { ProjectRelation } from '../entities';
import { chunkIds } from '../utils/chunk-ids';
@Service() @Service()
export class ProjectRelationRepository extends Repository<ProjectRelation> { export class ProjectRelationRepository extends Repository<ProjectRelation> {
@ -10,6 +11,21 @@ export class ProjectRelationRepository extends Repository<ProjectRelation> {
super(ProjectRelation, dataSource.manager); super(ProjectRelation, dataSource.manager);
} }
async findPersonalOwnerEmails(projectIds: string[]): Promise<Map<string, string>> {
const ownerEmails = new Map<string, string>();
for (const chunk of chunkIds([...new Set(projectIds)])) {
const rows = await this.createQueryBuilder('projectRelation')
.innerJoin('projectRelation.user', 'user')
.select('projectRelation.projectId', 'projectId')
.addSelect('user.email', 'email')
.where('projectRelation.projectId IN (:...projectIds)', { projectIds: chunk })
.andWhere('projectRelation.role = :role', { role: PROJECT_OWNER_ROLE_SLUG })
.getRawMany<{ projectId: string; email: string }>();
for (const { projectId, email } of rows) ownerEmails.set(projectId, email);
}
return ownerEmails;
}
async getPersonalProjectOwners(projectIds: string[]) { async getPersonalProjectOwners(projectIds: string[]) {
return await this.find({ return await this.find({
where: { where: {

View File

@ -6,6 +6,7 @@ import { DataSource, In, Not, Repository } from '@n8n/typeorm';
import type { User } from '../entities'; import type { User } from '../entities';
import { Project, ProjectRelation, SharedCredentials } from '../entities'; import { Project, ProjectRelation, SharedCredentials } from '../entities';
import { chunkIds } from '../utils/chunk-ids';
@Service() @Service()
export class SharedCredentialsRepository extends Repository<SharedCredentials> { export class SharedCredentialsRepository extends Repository<SharedCredentials> {
@ -15,7 +16,7 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
async findByCredentialIds(credentialIds: string[], role: CredentialSharingRole) { async findByCredentialIds(credentialIds: string[], role: CredentialSharingRole) {
return await this.find({ return await this.find({
relations: { credentials: true, project: { projectRelations: { user: true, role: true } } }, relations: { credentials: true },
where: { where: {
credentialsId: In(credentialIds), credentialsId: In(credentialIds),
role, role,
@ -23,6 +24,18 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
}); });
} }
async findOwnerProjectsByCredentialIds(credentialIds: string[]): Promise<Map<string, Project>> {
const ownerProjects = new Map<string, Project>();
for (const chunk of chunkIds(credentialIds)) {
const rows = await this.find({
where: { credentialsId: In(chunk), role: 'credential:owner' },
relations: { project: true },
});
for (const { credentialsId, project } of rows) ownerProjects.set(credentialsId, project);
}
return ownerProjects;
}
async makeOwnerOfAllCredentials(project: Project) { async makeOwnerOfAllCredentials(project: Project) {
return await this.update( return await this.update(
{ {

View File

@ -53,23 +53,6 @@ export class SharedWorkflowRepository extends BaseRepository<SharedWorkflow> {
return found; return found;
} }
async findByWorkflowIds(workflowIds: string[]) {
const rows = new Map<string, SharedWorkflow>();
for (const chunk of chunkIds(workflowIds)) {
const found = await this.find({
where: {
role: 'workflow:owner',
workflowId: In(chunk),
},
relations: { project: { projectRelations: { user: true, role: true } } },
});
for (const row of found) rows.set(row.workflowId, row);
}
return [...rows.values()];
}
/** Owner project of each workflow, keyed by workflow id. */ /** Owner project of each workflow, keyed by workflow id. */
async findOwnerProjectsByWorkflowIds(workflowIds: string[]): Promise<Map<string, Project>> { async findOwnerProjectsByWorkflowIds(workflowIds: string[]): Promise<Map<string, Project>> {
const ownerRows: SharedWorkflow[] = []; const ownerRows: SharedWorkflow[] = [];
@ -170,6 +153,24 @@ export class SharedWorkflowRepository extends BaseRepository<SharedWorkflow> {
return [...new Set(projectIds)]; return [...new Set(projectIds)];
} }
/**
* Find the IDs of all the projects where a workflow is shared with one of
* the given sharing roles.
*/
async findProjectIdsByRole(workflowId: string, roles: string[]) {
const rows = await this.find({
where: { workflowId, role: In(roles) },
select: ['projectId'],
});
const projectIds = rows.reduce<string[]>((acc, row) => {
if (row.projectId) acc.push(row.projectId);
return acc;
}, []);
return [...new Set(projectIds)];
}
/** /**
* Pass `ctx` when calling from inside a transaction — the read then runs on that * Pass `ctx` when calling from inside a transaction — the read then runs on that
* transaction's connection instead of checking out a second one, which would * transaction's connection instead of checking out a second one, which would

View File

@ -226,6 +226,35 @@ export class UserRepository extends Repository<User> {
}); });
} }
/**
* IDs of enabled users who either hold one of `globalRoleSlugs` globally,
* or hold one of `projectRoleSlugs` in one of `projectIds`.
*/
async findIdsWithGlobalOrProjectRoles({
projectIds,
projectRoleSlugs,
globalRoleSlugs,
}: {
projectIds: string[];
projectRoleSlugs: string[];
globalRoleSlugs: string[];
}): Promise<string[]> {
const where: Array<FindOptionsWhere<User>> = [];
if (globalRoleSlugs.length > 0) {
where.push({ disabled: false, role: { slug: In(globalRoleSlugs) } });
}
if (projectIds.length > 0 && projectRoleSlugs.length > 0) {
where.push({
disabled: false,
projectRelations: { projectId: In(projectIds), role: { slug: In(projectRoleSlugs) } },
});
}
if (where.length === 0) return [];
const users = await this.find({ where, select: ['id'] });
return [...new Set(users.map(({ id }) => id))];
}
/** /**
* Find the user that owns the personal project that owns the workflow. * Find the user that owns the personal project that owns the workflow.
* *

View File

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

View File

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

View File

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

View File

@ -146,6 +146,22 @@ describe(`Integration: ExpressionEvaluator (${engineName})`, () => {
expect(result).toBe(150); expect(result).toBe(150);
}); });
it('should render the timezone independent of the global JSON.stringify', async () => {
const data = { $json: { x: 'ok' } };
const original = JSON.stringify;
let result: unknown;
try {
JSON.stringify = (() => 'REPLACED_STRINGIFY_OUTPUT') as unknown as typeof JSON.stringify;
result = evaluator.evaluate('{{ $json.x }}', data, caller, {
timezone: 'America/New_York',
});
} finally {
JSON.stringify = original;
}
expect(result).toBe('ok');
});
it('should use provided timezone for DateTime operations', async () => { it('should use provided timezone for DateTime operations', async () => {
const data = { const data = {
$json: { ts: 1704067200000 }, // 2024-01-01T00:00:00Z $json: { ts: 1704067200000 }, // 2024-01-01T00:00:00Z

View File

@ -22,6 +22,10 @@ function getIvm(): IsolatedVm {
const BUNDLE_RELATIVE_PATH = path.join('dist', 'bundle', 'runtime.iife.js'); const BUNDLE_RELATIVE_PATH = path.join('dist', 'bundle', 'runtime.iife.js');
// Captured at module load so values rendered into generated code stay stable
// even if the global is later replaced.
const safeStringify = JSON.stringify;
/** Check if a value is an error sentinel returned by serializeError. */ /** Check if a value is an error sentinel returned by serializeError. */
function isErrorSentinel(value: unknown): value is ErrorSentinel { function isErrorSentinel(value: unknown): value is ErrorSentinel {
return ( return (
@ -749,7 +753,7 @@ export class IsolatedVmBridge implements RuntimeBridge {
const callHost = this.createCallHostRef(data); const callHost = this.createCallHostRef(data);
try { try {
const timezone = options?.timezone ? JSON.stringify(options.timezone) : 'undefined'; const timezone = options?.timezone ? safeStringify(options.timezone) : 'undefined';
// Wrap transformed code so 'this' === the closure-scoped context. // Wrap transformed code so 'this' === the closure-scoped context.
// Tournament generates: this.$json.email, this.$items(), etc. // Tournament generates: this.$json.email, this.$items(), etc.

View File

@ -21,6 +21,10 @@ async function getQuickJSModule(): Promise<QuickJSModule> {
const BUNDLE_RELATIVE_PATH = path.join('dist', 'bundle', 'runtime.iife.js'); const BUNDLE_RELATIVE_PATH = path.join('dist', 'bundle', 'runtime.iife.js');
// Captured at module load so values rendered into generated code stay stable
// even if the global is later replaced.
const safeStringify = JSON.stringify;
// ============================================================================ // ============================================================================
// Sentinel helpers // Sentinel helpers
// //
@ -265,7 +269,7 @@ function hostValueToJson(value: unknown): string {
if (value === undefined) return 'undefined'; if (value === undefined) return 'undefined';
if (value === null) return 'null'; if (value === null) return 'null';
try { try {
return JSON.stringify(wrapSpecialValuesForGuest(value)); return safeStringify(wrapSpecialValuesForGuest(value));
} catch { } catch {
return 'undefined'; return 'undefined';
} }
@ -1156,7 +1160,7 @@ export class QuickJsBridge implements RuntimeBridge {
// nested call can't leave the outer budget extended. // nested call can't leave the outer budget extended.
this.deadlines.push(Date.now() + timeout); this.deadlines.push(Date.now() + timeout);
try { try {
const timezone = options?.timezone ? JSON.stringify(options.timezone) : 'undefined'; const timezone = options?.timezone ? safeStringify(options.timezone) : 'undefined';
// The callback impls arrive as function arguments (scoped to this call), // The callback impls arrive as function arguments (scoped to this call),
// never as globals — see createCallbackHandles(). The runtime calls them // never as globals — see createCallbackHandles(). The runtime calls them

View File

@ -0,0 +1,134 @@
import { describe, it, expect } from 'vitest';
import { arrayExtensions } from '../array-extensions';
const smartJoin = arrayExtensions.functions.smartJoin as (
value: unknown[],
extraArgs: string[],
) => Record<string, unknown>;
const renameKeys = arrayExtensions.functions.renameKeys as (
value: unknown[],
extraArgs: string[],
) => Array<Record<string, unknown>>;
const unique = arrayExtensions.functions.unique as (
value: unknown[],
extraArgs: string[],
) => unknown[];
const merge = arrayExtensions.functions.merge as (
value: unknown[],
extraArgs: unknown[][],
) => Record<string, unknown>;
const mergeIntoObject = arrayExtensions.functions.mergeIntoObject as (
value: unknown[],
extraArgs: unknown[][],
) => Record<string, unknown>;
describe('smartJoin', () => {
it('should join own key and value fields', () => {
expect(
smartJoin(
[
{ field: 'age', value: 2 },
{ field: 'city', value: 'Berlin' },
],
['field', 'value'],
),
).toEqual({ age: 2, city: 'Berlin' });
});
it('should ignore inherited key and value fields', () => {
const source = Object.create({ field: 'age', value: 2 }) as object;
expect(smartJoin([source], ['field', 'value'])).toEqual({});
});
it('should read key and value fields served by a proxy without a getOwnPropertyDescriptor trap', () => {
const fields: Record<string, unknown> = { field: 'age', value: 2 };
const source = new Proxy(
{},
{
has: (_target, key) => typeof key === 'string' && Object.hasOwn(fields, key),
get: (_target, key) => (typeof key === 'string' ? fields[key] : undefined),
},
);
expect(smartJoin([source], ['field', 'value'])).toEqual({ age: 2 });
});
it('should treat a key named __proto__ as an ordinary field', () => {
const result = smartJoin(
[{ field: '__proto__', value: { marker: 'set' } }],
['field', 'value'],
);
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(true);
expect(({} as Record<string, unknown>).marker).toBeUndefined();
});
});
describe('renameKeys', () => {
it('should rename nothing when the source name is not an own field', () => {
const [result] = renameKeys([{ name: 'test' }], ['toString', 'renamed']);
expect(result).toEqual({ name: 'test' });
expect(Object.prototype.hasOwnProperty.call(result, 'renamed')).toBe(false);
});
it('should treat a target name of __proto__ as an ordinary field', () => {
const [result] = renameKeys([{ name: { marker: 'set' } }], ['name', '__proto__']);
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(true);
expect(({} as Record<string, unknown>).marker).toBeUndefined();
});
});
describe('unique', () => {
it('should keep an element that does not own the compared field', () => {
const inherited = Object.create({ name: 'Nathan' }) as object;
expect(unique([{ name: 'Nathan' }, inherited], ['name'])).toHaveLength(2);
});
it('should read a compared field served by a proxy without a getOwnPropertyDescriptor trap', () => {
const proxyFor = (name: string) => {
const fields: Record<string, unknown> = { name };
return new Proxy(
{},
{
has: (_target, key) => typeof key === 'string' && Object.hasOwn(fields, key),
get: (_target, key) => (typeof key === 'string' ? fields[key] : undefined),
},
);
};
expect(unique([proxyFor('Nathan'), proxyFor('Jan')], ['name'])).toHaveLength(2);
});
});
describe.each([
['merge', merge],
['mergeIntoObject', mergeIntoObject],
])('%s', (_name, mergeFn) => {
it('should merge an incoming field named toString', () => {
const result = mergeFn([{ a: 1 }], [[{ toString: 'value' }]]);
expect(Object.prototype.hasOwnProperty.call(result, 'toString')).toBe(true);
expect(result.toString).toBe('value');
});
it('should treat an own __proto__ field of a source object as an ordinary field', () => {
const source = JSON.parse('{"__proto__": {"marker": "set"}}') as Record<string, unknown>;
const result = mergeFn([source], [[{ a: 1 }]]);
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(true);
expect(({} as Record<string, unknown>).marker).toBeUndefined();
});
});

View File

@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { extendedFunctions } from '../function-extensions';
const zip = extendedFunctions.zip;
describe('zip', () => {
it('should pair keys with values', () => {
expect(zip(['test1', 'test2', 'test3'], [1, 2, 3])).toEqual({
test1: 1,
test2: 2,
test3: 3,
});
});
it('should treat a key named __proto__ as an ordinary field', () => {
const result = zip(['__proto__'], [{ marker: 'set' }]) as Record<string, unknown>;
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(true);
expect(({} as Record<string, unknown>).marker).toBeUndefined();
});
});

View File

@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { objectExtensions } from '../object-extensions';
const compact = objectExtensions.functions.compact as (value: object) => object;
const hasField = objectExtensions.functions.hasField as (
value: object,
extraArgs: string[],
) => boolean;
describe('hasField', () => {
it('should return true for a key served through has/get traps without an own property descriptor', () => {
const target = { test1: 1 };
const proxy = new Proxy(target, {
has(_target, key) {
return key === 'test2';
},
get(_target, key) {
return key === 'test2' ? 'proxied value' : undefined;
},
});
expect(hasField(proxy, ['test2'])).toBe(true);
});
});
describe('compact', () => {
it('should remove empty values', () => {
expect(compact({ test1: 1, test2: '2', test3: undefined, test4: null })).toEqual({
test1: 1,
test2: '2',
});
});
it('should keep an own __proto__ field as an ordinary field', () => {
const value = JSON.parse('{"test1": 1, "__proto__": {"marker": "set"}}') as object;
const result = compact(value);
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(true);
expect(({} as Record<string, unknown>).marker).toBeUndefined();
});
});

View File

@ -14,6 +14,23 @@ function randomInt(max: number): number {
return random(0, max - 1); return random(0, max - 1);
} }
// Define an own data field rather than assigning through an inherited setter.
function defineField(target: Record<string, unknown>, key: PropertyKey, value: unknown): void {
Object.defineProperty(target, key, {
value,
writable: true,
enumerable: true,
configurable: true,
});
}
// Report whether a field resolves on the object itself and not through its prototype chain.
function hasReadableField(value: object, field: string): boolean {
if (Object.hasOwn(value, field)) return true;
const proto = Object.getPrototypeOf(value) as object | null;
return field in value && (proto === null || !(field in proto));
}
function first(value: unknown[]): unknown { function first(value: unknown[]): unknown {
return value[0]; return value[0];
} }
@ -75,7 +92,8 @@ function unique(value: unknown[], extraArgs: string[]): unknown[] {
const mapForEqualityCheck = (item: unknown): unknown => { const mapForEqualityCheck = (item: unknown): unknown => {
if (extraArgs.length > 0 && item && typeof item === 'object') { if (extraArgs.length > 0 && item && typeof item === 'object') {
return extraArgs.reduce<Record<string, unknown>>((acc, key) => { return extraArgs.reduce<Record<string, unknown>>((acc, key) => {
acc[key] = (item as Record<string, unknown>)[key]; const source = item as Record<string, unknown>;
defineField(acc, key, hasReadableField(source, key) ? source[key] : undefined);
return acc; return acc;
}, {}); }, {});
} }
@ -169,13 +187,16 @@ function smartJoin(value: unknown[], extraArgs: string[]): object {
'smartJoin(): expected two string args, e.g. .smartJoin("name", "value")', 'smartJoin(): expected two string args, e.g. .smartJoin("name", "value")',
); );
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return return value.reduce<Record<string, unknown>>((o, v) => {
return value.reduce<any>((o, v) => { if (
if (typeof v === 'object' && v !== null && keyField in v && valueField in v) { typeof v === 'object' &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any v !== null &&
o[(v as any)[keyField]] = (v as any)[valueField]; hasReadableField(v, keyField) &&
hasReadableField(v, valueField)
) {
const entry = v as Record<string, unknown>;
defineField(o, entry[keyField] as PropertyKey, entry[valueField]);
} }
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return o; return o;
}, {}); }, {});
} }
@ -202,18 +223,14 @@ function renameKeys(value: unknown[], extraArgs: string[]): unknown[] {
if (typeof v !== 'object' || v === null) { if (typeof v !== 'object' || v === null) {
return v; return v;
} }
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any const newObj: Record<string, unknown> = { ...(v as Record<string, unknown>) };
const newObj = { ...(v as any) };
const chunkedArgs = chunk(extraArgs, [2]) as string[][]; const chunkedArgs = chunk(extraArgs, [2]) as string[][];
chunkedArgs.forEach(([from, to]) => { chunkedArgs.forEach(([from, to]) => {
if (from in newObj) { if (Object.hasOwn(newObj, from)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access defineField(newObj, to, newObj[from]);
newObj[to] = newObj[from];
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
delete newObj[from]; delete newObj[from];
} }
}); });
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return newObj; return newObj;
}); });
} }
@ -231,8 +248,8 @@ function mergeObjects(value: Record<string, unknown>, extraArgs: unknown[]): unk
const newObject = { ...value }; const newObject = { ...value };
for (const [key, val] of Object.entries(other)) { for (const [key, val] of Object.entries(other)) {
if (!(key in newObject)) { if (!Object.hasOwn(newObject, key)) {
newObject[key] = val; defineField(newObject, key, val);
} }
} }
return newObject; return newObject;
@ -258,14 +275,17 @@ function merge(value: unknown[], extraArgs: unknown[][]): unknown {
); );
} }
const listLength = value.length > others.length ? value.length : others.length; const listLength = value.length > others.length ? value.length : others.length;
let merged = {}; const merged: Record<string, unknown> = {};
for (let i = 0; i < listLength; i++) { for (let i = 0; i < listLength; i++) {
if (value[i] !== undefined) { if (value[i] !== undefined) {
if (typeof value[i] === 'object' && typeof others[i] === 'object') { if (typeof value[i] === 'object' && typeof others[i] === 'object') {
merged = Object.assign( const pair = mergeObjects(value[i] as Record<string, unknown>, [others[i]]) as Record<
merged, string,
mergeObjects(value[i] as Record<string, unknown>, [others[i]]), unknown
); >;
for (const [key, val] of Object.entries(pair)) {
defineField(merged, key, val);
}
} }
} }
} }
@ -281,17 +301,23 @@ function mergeIntoObject(value: unknown[], extraArgs: unknown[][]): unknown {
); );
} }
const listLength = value.length > others.length ? value.length : others.length; const listLength = value.length > others.length ? value.length : others.length;
let merged = {}; const merged: Record<string, unknown> = {};
for (let i = 0; i < listLength; i++) { for (let i = 0; i < listLength; i++) {
const baseIsObject = value[i] !== null && typeof value[i] === 'object'; const baseIsObject = value[i] !== null && typeof value[i] === 'object';
const otherIsObject = others[i] !== null && typeof others[i] === 'object'; const otherIsObject = others[i] !== null && typeof others[i] === 'object';
let source: Record<string, unknown> | undefined;
if (baseIsObject) { if (baseIsObject) {
merged = Object.assign( source = mergeObjects(
merged, value[i] as Record<string, unknown>,
mergeObjects(value[i] as Record<string, unknown>, otherIsObject ? [others[i]] : []), otherIsObject ? [others[i]] : [],
); ) as Record<string, unknown>;
} else if (otherIsObject) { } else if (otherIsObject) {
merged = Object.assign(merged, others[i] as Record<string, unknown>); source = others[i] as Record<string, unknown>;
}
if (source) {
for (const [key, val] of Object.entries(source)) {
defineField(merged, key, val);
}
} }
} }
return merged; return merged;

View File

@ -1,6 +1,16 @@
import { average as aAverage } from './array-extensions'; import { average as aAverage } from './array-extensions';
import { ExpressionExtensionError } from './expression-extension-error'; import { ExpressionExtensionError } from './expression-extension-error';
// Define an own data field rather than assigning through an inherited setter.
function defineField(target: Record<string, unknown>, key: PropertyKey, value: unknown): void {
Object.defineProperty(target, key, {
value,
writable: true,
enumerable: true,
configurable: true,
});
}
const min = Math.min; const min = Math.min;
const max = Math.max; const max = Math.max;
@ -29,7 +39,7 @@ const zip = (keys: unknown[], values: unknown[]): unknown => {
} }
const result: Record<string, unknown> = {}; const result: Record<string, unknown> = {};
for (let i = 0; i < keys.length; i++) { for (let i = 0; i < keys.length; i++) {
result[keys[i] as string] = values[i]; defineField(result, keys[i] as PropertyKey, values[i]);
} }
return result; return result;
}; };

View File

@ -1,6 +1,16 @@
import type { ExtensionMap } from './extensions'; import type { ExtensionMap } from './extensions';
import { ExpressionExtensionError } from './expression-extension-error'; import { ExpressionExtensionError } from './expression-extension-error';
// Define an own data field rather than assigning through an inherited setter.
function defineField(target: Record<string, unknown>, key: PropertyKey, value: unknown): void {
Object.defineProperty(target, key, {
value,
writable: true,
enumerable: true,
configurable: true,
});
}
function isEmpty(value: object): boolean { function isEmpty(value: object): boolean {
return Object.keys(value).length === 0; return Object.keys(value).length === 0;
} }
@ -66,21 +76,17 @@ function keepFieldsContaining(value: object, extraArgs: string[]): object {
} }
export function compact(value: object): object { export function compact(value: object): object {
// eslint-disable-next-line @typescript-eslint/no-explicit-any const newObj: Record<string, unknown> = {};
const newObj: any = {}; for (const [key, val] of Object.entries(value) as Array<[string, unknown]>) {
for (const [key, val] of Object.entries(value)) {
if (val !== null && val !== undefined && val !== 'nil' && val !== '') { if (val !== null && val !== undefined && val !== 'nil' && val !== '') {
if (typeof val === 'object') { if (typeof val === 'object') {
if (Object.keys(val as object).length === 0) continue; if (Object.keys(val).length === 0) continue;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument defineField(newObj, key, compact(val));
newObj[key] = compact(val);
} else { } else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access defineField(newObj, key, val);
newObj[key] = val;
} }
} }
} }
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return newObj; return newObj;
} }

View File

@ -25,6 +25,7 @@ const baseHint: InstanceAiCredentialSetupHint = {
{ name: 'workspace_id', title: 'Workspace ID', type: 'plain', optional: true }, { name: 'workspace_id', title: 'Workspace ID', type: 'plain', optional: true },
], ],
serviceHost: 'api.example.com', serviceHost: 'api.example.com',
serviceOrigin: 'https://api.example.com',
docsUrl: 'https://example.com/docs', docsUrl: 'https://example.com/docs',
testUrl: 'https://api.example.com/me', testUrl: 'https://api.example.com/me',
acceptedStatusCodes: [401], acceptedStatusCodes: [401],
@ -71,6 +72,7 @@ describe('createOneCredential — httpTemplatedCustomAuth', () => {
expect(Object.keys(parsedValues)).toHaveLength(baseHint.placeholders.length); expect(Object.keys(parsedValues)).toHaveLength(baseHint.placeholders.length);
expect(data.serviceHost).toBe(baseHint.serviceHost); expect(data.serviceHost).toBe(baseHint.serviceHost);
expect(data.serviceOrigin).toBe(baseHint.serviceOrigin);
expect(typeof data.acceptedStatusCodes).toBe('string'); expect(typeof data.acceptedStatusCodes).toBe('string');
expect(jsonParse(data.acceptedStatusCodes as string)).toEqual(baseHint.acceptedStatusCodes); expect(jsonParse(data.acceptedStatusCodes as string)).toEqual(baseHint.acceptedStatusCodes);

View File

@ -223,6 +223,7 @@ async function createTemplatedCustomAuthCredential(
placeholderDefs: JSON.stringify(hint.placeholders), placeholderDefs: JSON.stringify(hint.placeholders),
placeholderValues: JSON.stringify(placeholderValues), placeholderValues: JSON.stringify(placeholderValues),
serviceHost: hint.serviceHost ?? '', serviceHost: hint.serviceHost ?? '',
serviceOrigin: hint.serviceOrigin ?? '',
docsUrl: hint.docsUrl ?? '', docsUrl: hint.docsUrl ?? '',
testUrl: hint.testUrl ?? '', testUrl: hint.testUrl ?? '',
acceptedStatusCodes: hint.acceptedStatusCodes ? JSON.stringify(hint.acceptedStatusCodes) : '', acceptedStatusCodes: hint.acceptedStatusCodes ? JSON.stringify(hint.acceptedStatusCodes) : '',

View File

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

View File

@ -129,6 +129,15 @@ describe('confirmation payload → tool resume schema contract', () => {
request: { kind: 'credentialAutoSetup', credentialType: 'firecrawlApi', attemptId: 'a-1' }, request: { kind: 'credentialAutoSetup', credentialType: 'firecrawlApi', attemptId: 'a-1' },
targets: [['credentials', credentialsResumeSchema]], targets: [['credentials', credentialsResumeSchema]],
}, },
{
label: 'credential destination approval',
request: {
kind: 'credentialDestination',
approved: true,
origin: 'https://api.example.com',
},
targets: [['workflows (setup wizard)', workflowsResumeSchema]],
},
{ {
label: 'domain access approval', label: 'domain access approval',
request: { kind: 'domainAccessApprove', domainAccessAction: 'allow_domain' }, request: { kind: 'domainAccessApprove', domainAccessAction: 'allow_domain' },
@ -196,6 +205,7 @@ describe('confirmation payload → tool resume schema contract', () => {
questions: true, questions: true,
credentialSelection: true, credentialSelection: true,
credentialAutoSetup: true, credentialAutoSetup: true,
credentialDestination: true,
domainAccessApprove: true, domainAccessApprove: true,
domainAccessDeny: true, domainAccessDeny: true,
planDeny: true, planDeny: true,

View File

@ -44,6 +44,11 @@ export function toConfirmationData(request: InstanceAiConfirmRequest): Confirmat
approved: true, approved: true,
autoSetup: { credentialType: request.credentialType, attemptId: request.attemptId }, autoSetup: { credentialType: request.credentialType, attemptId: request.attemptId },
}; };
case 'credentialDestination':
return {
approved: request.approved,
credentialDestination: { origin: request.origin },
};
case 'resourceDecision': case 'resourceDecision':
return { approved: true, resourceDecision: request.resourceDecision }; return { approved: true, resourceDecision: request.resourceDecision };
case 'mcpConnect': case 'mcpConnect':
@ -88,6 +93,7 @@ export function buildResumeData(data: ConfirmationData): Record<string, unknown>
...(data.resourceDecision ? { resourceDecision: data.resourceDecision } : {}), ...(data.resourceDecision ? { resourceDecision: data.resourceDecision } : {}),
...(data.scope ? { scope: data.scope } : {}), ...(data.scope ? { scope: data.scope } : {}),
...(data.autoSetup ? { autoSetup: data.autoSetup } : {}), ...(data.autoSetup ? { autoSetup: data.autoSetup } : {}),
...(data.credentialDestination ? { credentialDestination: data.credentialDestination } : {}),
...(data.denied ? { denied: true } : {}), ...(data.denied ? { denied: true } : {}),
...(data.connectedSlugs ? { connectedSlugs: data.connectedSlugs } : {}), ...(data.connectedSlugs ? { connectedSlugs: data.connectedSlugs } : {}),
}; };

View File

@ -1,4 +1,7 @@
import type { InstanceAiThreadStatusResponse } from '@n8n/api-types'; import type {
InstanceAiCredentialDestinationDecision,
InstanceAiThreadStatusResponse,
} from '@n8n/api-types';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import type { InstanceAiTraceContext, ModelConfig, OrchestrationContext } from '../types'; import type { InstanceAiTraceContext, ModelConfig, OrchestrationContext } from '../types';
@ -84,6 +87,7 @@ export interface ConfirmationData {
* persist a thread-level grant so the same action isn't re-asked. */ * persist a thread-level grant so the same action isn't re-asked. */
scope?: 'once' | 'session'; scope?: 'once' | 'session';
autoSetup?: { credentialType: string; attemptId?: string }; autoSetup?: { credentialType: string; attemptId?: string };
credentialDestination?: InstanceAiCredentialDestinationDecision;
connectedSlugs?: string[]; connectedSlugs?: string[];
} }

View File

@ -335,6 +335,10 @@ describe('mapAgentChunkToEvent', () => {
], ],
domainAccess: { url: 'https://example.com/api', host: 'example.com' }, domainAccess: { url: 'https://example.com/api', host: 'example.com' },
credentialFlow: { stage: 'generic' }, credentialFlow: { stage: 'generic' },
credentialDestination: {
origin: 'https://api.example.com',
nodeNames: ['Fetch account'],
},
setupRequests: [validSetupNode], setupRequests: [validSetupNode],
workflowId: 'wf-1', workflowId: 'wf-1',
resourceDecision: { resourceDecision: {
@ -369,6 +373,10 @@ describe('mapAgentChunkToEvent', () => {
inputType: 'plan-review', inputType: 'plan-review',
domainAccess: { url: 'https://example.com/api', host: 'example.com' }, domainAccess: { url: 'https://example.com/api', host: 'example.com' },
credentialFlow: { stage: 'generic' }, credentialFlow: { stage: 'generic' },
credentialDestination: {
origin: 'https://api.example.com',
nodeNames: ['Fetch account'],
},
setupRequests: [validSetupNode], setupRequests: [validSetupNode],
workflowId: 'wf-1', workflowId: 'wf-1',
questions: [ questions: [
@ -432,6 +440,10 @@ describe('mapAgentChunkToEvent', () => {
domainAccess: { url: 'https://example.com' }, domainAccess: { url: 'https://example.com' },
webSearch: { invalid: true }, webSearch: { invalid: true },
credentialFlow: { stage: 'unknown' }, credentialFlow: { stage: 'unknown' },
credentialDestination: {
origin: 'https://api.example.com/path',
nodeNames: ['Fetch account'],
},
setupRequests: [{ invalid: true }], setupRequests: [{ invalid: true }],
workflowId: 42, workflowId: 42,
}, },

View File

@ -8,6 +8,7 @@ import {
webSearchMetaSchema, webSearchMetaSchema,
channelConfigSchema, channelConfigSchema,
mcpConnectRequestSchema, mcpConnectRequestSchema,
credentialDestinationSchema,
} from '@n8n/api-types'; } from '@n8n/api-types';
import type { InstanceAiEvent } from '@n8n/api-types'; import type { InstanceAiEvent } from '@n8n/api-types';
import { isRecord } from '@n8n/utils/is-record'; import { isRecord } from '@n8n/utils/is-record';
@ -339,6 +340,10 @@ function mapSuspendedChunk(
const domainAccess = parseDomainAccess(suspendPayload.domainAccess); const domainAccess = parseDomainAccess(suspendPayload.domainAccess);
const webSearch = parseSchemaRecord(suspendPayload.webSearch, webSearchMetaSchema); const webSearch = parseSchemaRecord(suspendPayload.webSearch, webSearchMetaSchema);
const credentialFlow = parseCredentialFlow(suspendPayload.credentialFlow); const credentialFlow = parseCredentialFlow(suspendPayload.credentialFlow);
const credentialDestination = parseSchemaRecord(
suspendPayload.credentialDestination,
credentialDestinationSchema,
);
const setupRequests = parseSchemaArray(suspendPayload.setupRequests, workflowSetupNodeSchema); const setupRequests = parseSchemaArray(suspendPayload.setupRequests, workflowSetupNodeSchema);
const workflowId = presentString(suspendPayload.workflowId); const workflowId = presentString(suspendPayload.workflowId);
const resourceDecision = parseSchemaRecord( const resourceDecision = parseSchemaRecord(
@ -384,6 +389,7 @@ function mapSuspendedChunk(
...(domainAccess ? { domainAccess } : {}), ...(domainAccess ? { domainAccess } : {}),
...(webSearch ? { webSearch } : {}), ...(webSearch ? { webSearch } : {}),
...(credentialFlow ? { credentialFlow } : {}), ...(credentialFlow ? { credentialFlow } : {}),
...(credentialDestination ? { credentialDestination } : {}),
...(setupRequests ? { setupRequests } : {}), ...(setupRequests ? { setupRequests } : {}),
...(workflowId ? { workflowId } : {}), ...(workflowId ? { workflowId } : {}),
...(questions ? { questions } : {}), ...(questions ? { questions } : {}),

View File

@ -943,7 +943,7 @@ describe('credentials tool', () => {
); );
}); });
it('should include setupHint in credentialRequests when provided', async () => { it('should omit automatic test destinations from standalone setup', async () => {
const context = createMockContext(); const context = createMockContext();
(context.credentialService.list as Mock).mockResolvedValue([]); (context.credentialService.list as Mock).mockResolvedValue([]);
@ -981,12 +981,17 @@ describe('credentials tool', () => {
); );
expect(suspendFn).toHaveBeenCalledTimes(1); expect(suspendFn).toHaveBeenCalledTimes(1);
// The service identity is stamped from the recipe's test endpoint —
// this card has no node context to derive it from.
expect(suspendFn.mock.calls[0][0]).toEqual( expect(suspendFn.mock.calls[0][0]).toEqual(
expect.objectContaining({ expect.objectContaining({
credentialRequests: [ credentialRequests: [
expect.objectContaining({ setupHint: { ...setupHint, serviceHost: 'fal.run' } }), expect.objectContaining({
setupHint: {
template: setupHint.template,
placeholders: setupHint.placeholders,
docsUrl: setupHint.docsUrl,
suggestedName: setupHint.suggestedName,
},
}),
], ],
}), }),
); );

View File

@ -1,4 +1,9 @@
import type { InstanceAiPermissions } from '@n8n/api-types'; import { zodToJsonSchema } from '@n8n/agents';
import {
buildCredentialDestinationGrantKey,
type InstanceAiCredentialSetupHint,
type InstanceAiPermissions,
} from '@n8n/api-types';
import { generateWorkflowCode } from '@n8n/workflow-sdk'; import { generateWorkflowCode } from '@n8n/workflow-sdk';
import type { Mock } from 'vitest'; import type { Mock } from 'vitest';
@ -17,7 +22,7 @@ import {
refreshWorkflowSourceFileBindingFromSave, refreshWorkflowSourceFileBindingFromSave,
saveWorkflowSourceFileBinding, saveWorkflowSourceFileBinding,
} from '../workflows/workflow-file-bindings'; } from '../workflows/workflow-file-bindings';
import { createWorkflowsTool, type WorkflowAction } from '../workflows.tool'; import { createWorkflowsTool, type WorkflowAction, workflowsResumeSchema } from '../workflows.tool';
// Mock the setup-workflow.service module to avoid pulling in heavy dependencies // Mock the setup-workflow.service module to avoid pulling in heavy dependencies
vi.mock('../workflows/setup-workflow.service', () => ({ vi.mock('../workflows/setup-workflow.service', () => ({
@ -40,6 +45,45 @@ vi.mock('@n8n/workflow-sdk', async (importOriginal) => {
const emptyList = { workflows: [], total: 0, totalInScope: 0 }; const emptyList = { workflows: [], total: 0, totalInScope: 0 };
function templatedSetupFixture(
options: {
nodeUrl?: string;
testUrl?: string;
serviceOrigin?: string | null;
} = {},
) {
const nodeUrl = options.nodeUrl ?? 'https://api.example.com/v1/account';
const serviceOrigin =
options.serviceOrigin === undefined ? 'https://api.example.com' : options.serviceOrigin;
const recipe: InstanceAiCredentialSetupHint = {
template: { headers: { Authorization: 'Bearer {{api_key}}' } },
placeholders: [{ name: 'api_key', title: 'API key' }],
...(options.testUrl ? { testUrl: options.testUrl } : {}),
};
const request = {
node: {
name: 'Fetch account',
type: 'n8n-nodes-base.httpRequest',
parameters: { url: nodeUrl },
},
credentialType: 'httpTemplatedCustomAuth',
needsAction: true,
setupHint: {
...recipe,
...(serviceOrigin ? { serviceHost: new URL(serviceOrigin).hostname, serviceOrigin } : {}),
},
};
return {
recipe,
request,
input: {
action: 'setup' as const,
workflowId: 'wf1',
credentialHints: [{ ...recipe, nodeName: request.node.name }],
},
};
}
function createMockContext( function createMockContext(
overrides: Partial<Omit<InstanceAiContext, 'permissions'>> & { overrides: Partial<Omit<InstanceAiContext, 'permissions'>> & {
permissions?: Partial<InstanceAiPermissions>; permissions?: Partial<InstanceAiPermissions>;
@ -125,6 +169,10 @@ describe('workflows tool', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('exports the resume schema without unsupported URI formats', () => {
expect(JSON.stringify(zodToJsonSchema(workflowsResumeSchema))).not.toContain('"format":"uri"');
});
describe('surface filtering', () => { describe('surface filtering', () => {
const builderWorkflowActions = [ const builderWorkflowActions = [
'list', 'list',
@ -2270,6 +2318,155 @@ describe('workflows tool', () => {
expect(suspend).toHaveBeenCalled(); expect(suspend).toHaveBeenCalled();
}); });
it('should accept a credential test URL on the workflow service origin', async () => {
const fixture = templatedSetupFixture({ testUrl: 'https://api.example.com/me' });
(analyzeWorkflow as Mock).mockResolvedValue([fixture.request]);
const suspend = vi.fn();
const tool = createWorkflowsTool(createMockContext(), 'full');
await executeTool(tool, fixture.input, { suspend, resumeData: undefined } as never);
expect(suspend).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'wf1',
credentialDestination: {
origin: 'https://api.example.com',
nodeNames: ['Fetch account'],
},
}),
);
});
it('should remember an approved credential destination and open setup', async () => {
const first = templatedSetupFixture({ testUrl: 'https://api.example.com/me' });
const second = templatedSetupFixture({ testUrl: 'https://api.example.com/me' });
(analyzeWorkflow as Mock)
.mockResolvedValueOnce([first.request])
.mockResolvedValueOnce([second.request]);
const grantSessionToolApproval = vi.fn().mockResolvedValue(undefined);
const context = createMockContext({ grantSessionToolApproval });
const suspend = vi.fn();
const tool = createWorkflowsTool(context, 'full');
await executeTool(tool, first.input, { suspend, resumeData: undefined } as never);
suspend.mockClear();
await executeTool(tool, first.input, {
suspend,
resumeData: {
approved: true,
credentialDestination: { origin: 'https://api.example.com' },
},
} as never);
expect(grantSessionToolApproval).toHaveBeenCalledWith(
buildCredentialDestinationGrantKey('wf1', 'https://api.example.com'),
);
expect(suspend).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Configure credentials for your workflow',
setupRequests: [second.request],
}),
);
});
it('should reuse approval only for the same workflow credential destination', async () => {
const fixture = templatedSetupFixture({ testUrl: 'https://api.example.com/me' });
(analyzeWorkflow as Mock).mockResolvedValue([fixture.request]);
const context = createMockContext({
sessionApprovedToolKeys: new Set([
buildCredentialDestinationGrantKey('wf1', 'https://api.example.com'),
]),
});
const suspend = vi.fn();
const tool = createWorkflowsTool(context, 'full');
await executeTool(tool, fixture.input, { suspend, resumeData: undefined } as never);
expect(suspend).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Configure credentials for your workflow',
setupRequests: [fixture.request],
}),
);
});
it('should require review again when the credential destination changes', async () => {
const fixture = templatedSetupFixture({
nodeUrl: 'https://api-v2.example.com/v1/account',
testUrl: 'https://api-v2.example.com/me',
serviceOrigin: 'https://api-v2.example.com',
});
(analyzeWorkflow as Mock).mockResolvedValue([fixture.request]);
const grantSessionToolApproval = vi.fn();
const suspend = vi.fn();
const tool = createWorkflowsTool(createMockContext({ grantSessionToolApproval }), 'full');
const result = await executeTool(tool, fixture.input, {
suspend,
resumeData: {
approved: true,
credentialDestination: { origin: 'https://api.example.com' },
},
} as never);
expect(result).toMatchObject({ error: 'credential_destination_changed' });
expect(grantSessionToolApproval).not.toHaveBeenCalled();
expect(suspend).not.toHaveBeenCalled();
});
it('should stop setup when the credential destination is declined', async () => {
const tool = createWorkflowsTool(createMockContext(), 'full');
const result = await executeTool(tool, { action: 'setup', workflowId: 'wf1' }, {
resumeData: {
approved: false,
credentialDestination: { origin: 'https://api.example.com' },
},
} as never);
expect(result).toMatchObject({
success: false,
denied: true,
reason: 'User did not approve credential use with https://api.example.com.',
});
expect(analyzeWorkflow).not.toHaveBeenCalled();
});
it('should reject a credential test URL on a different service origin', async () => {
const fixture = templatedSetupFixture({ testUrl: 'https://status.example.net/me' });
(analyzeWorkflow as Mock).mockResolvedValue([fixture.request]);
const suspend = vi.fn();
const tool = createWorkflowsTool(createMockContext(), 'full');
const result = await executeTool(tool, fixture.input, {
suspend,
resumeData: undefined,
} as never);
expect(result).toMatchObject({ error: 'invalid_credential_hints' });
expect(suspend).not.toHaveBeenCalled();
});
it('should reject a templated setup when the workflow service origin is unavailable', async () => {
const fixture = templatedSetupFixture({
nodeUrl: '={{ $json.url }}',
serviceOrigin: null,
});
(analyzeWorkflow as Mock).mockResolvedValue([fixture.request]);
const suspend = vi.fn();
const tool = createWorkflowsTool(createMockContext(), 'full');
const result = await executeTool(tool, fixture.input, {
suspend,
resumeData: undefined,
} as never);
expect(result).toMatchObject({
error: 'invalid_credential_hints',
problems: [expect.stringContaining('no statically derivable HTTP origin')],
});
expect(suspend).not.toHaveBeenCalled();
});
it('should allow a plain generic type when credentials of it already exist', async () => { it('should allow a plain generic type when credentials of it already exist', async () => {
(analyzeWorkflow as Mock).mockResolvedValue([ (analyzeWorkflow as Mock).mockResolvedValue([
{ {
@ -2744,6 +2941,62 @@ describe('workflows tool', () => {
expect(suspend.mock.calls[0][0]).toMatchObject({ setupRequests: [sheetsRequest] }); expect(suspend.mock.calls[0][0]).toMatchObject({ setupRequests: [sheetsRequest] });
}); });
it('revalidates the credential test destination after a trigger test', async () => {
const fixture = templatedSetupFixture({ testUrl: 'https://status.example.net/me' });
(analyzeWorkflow as Mock).mockResolvedValue([fixture.request]);
(applyNodeChanges as Mock).mockResolvedValue({ applied: [], failed: [] });
const context = createGrantAwareContext();
(context.executionService.run as Mock).mockResolvedValue({ status: 'success' });
const suspend = vi.fn();
const tool = createWorkflowsTool(context, 'full');
const result = await executeTool(tool, fixture.input, {
suspend,
resumeData: {
approved: true,
action: 'test-trigger',
testTriggerNode: 'Fetch account',
},
} as never);
expect(result).toMatchObject({ error: 'invalid_credential_hints' });
expect(suspend).not.toHaveBeenCalled();
});
it('checks refreshed credential test destinations against skipped node URLs', async () => {
const fixture = templatedSetupFixture({
testUrl: 'https://api.example.com/v1/action',
});
const skippedRequest = {
node: {
name: 'Run action',
type: 'n8n-nodes-base.httpRequest',
parameters: { url: fixture.recipe.testUrl },
},
credentialType: 'httpHeaderAuth',
needsAction: true,
credentialNeedsAction: true,
};
(analyzeWorkflow as Mock).mockResolvedValue([fixture.request, skippedRequest]);
(applyNodeChanges as Mock).mockResolvedValue({ applied: [], failed: [] });
const context = createGrantAwareContext(['workflows:setup-skip:cred:httpHeaderAuth']);
(context.executionService.run as Mock).mockResolvedValue({ status: 'success' });
const suspend = vi.fn();
const tool = createWorkflowsTool(context, 'full');
const result = await executeTool(tool, fixture.input, {
suspend,
resumeData: {
approved: true,
action: 'test-trigger',
testTriggerNode: 'Fetch account',
},
} as never);
expect(result).toMatchObject({ error: 'invalid_credential_hints' });
expect(suspend).not.toHaveBeenCalled();
});
it('keeps another node Slack skip when only a parameter was completed', async () => { it('keeps another node Slack skip when only a parameter was completed', async () => {
// "Alert on Slack" was connected already and only needed a channel. Clearing the // "Alert on Slack" was connected already and only needed a channel. Clearing the
// type-wide record here would re-open the card "Post to Slack" was skipped on. // type-wide record here would re-open the card "Post to Slack" was skipped on.

View File

@ -20,7 +20,6 @@ import {
} from './nodes/preferred-chat-model'; } from './nodes/preferred-chat-model';
import { CREDENTIALS_TOOL_ID } from './tool-ids'; import { CREDENTIALS_TOOL_ID } from './tool-ids';
import { import {
extractServiceHost,
GENERIC_AUTH_CREDENTIAL_TYPES, GENERIC_AUTH_CREDENTIAL_TYPES,
N8N_CONNECT_DISPLAY_NAME, N8N_CONNECT_DISPLAY_NAME,
} from './workflows/credential-utils'; } from './workflows/credential-utils';
@ -136,6 +135,31 @@ function normalizeUrlForComparison(raw: unknown): string | undefined {
} }
} }
function extractHttpOrigin(raw: unknown): string | undefined {
if (typeof raw !== 'string') return undefined;
try {
const url = new URL(raw);
return url.protocol === 'http:' || url.protocol === 'https:' ? url.origin : undefined;
} catch {
return undefined;
}
}
export function findSetupHintTestUrlOriginProblem(
hint: InstanceAiCredentialSetupHint,
serviceOrigin: string,
): string | undefined {
if (hint.testUrl === undefined) return undefined;
const testOrigin = extractHttpOrigin(hint.testUrl);
if (!testOrigin) {
return `testUrl "${hint.testUrl}" is not an absolute HTTP URL — omit testUrl if no documented read-only endpoint is available`;
}
if (testOrigin !== serviceOrigin) {
return `testUrl origin "${testOrigin}" does not match the workflow service origin "${serviceOrigin}" — use a documented read-only endpoint on the workflow service or omit testUrl`;
}
return undefined;
}
/** /**
* Collect recipe problems so the model corrects them instead of the card * Collect recipe problems so the model corrects them instead of the card
* silently degrading. `nodeUrls` additionally rejects a testUrl pointing at * silently degrading. `nodeUrls` additionally rejects a testUrl pointing at
@ -345,6 +369,8 @@ const searchTypesAction = z.object({
), ),
}); });
const standaloneSetupHintField = setupHintField.omit({ testUrl: true });
const setupAction = z.object({ const setupAction = z.object({
action: z action: z
.literal('setup') .literal('setup')
@ -372,7 +398,7 @@ const setupAction = z.object({
.describe( .describe(
'Set when the user explicitly asked to create a new credential of this type ("create a new Slack credential"), or needs to enter a replacement for one whose secret is invalid or rotated (e.g. pasted a new token in chat, which you cannot store). The card then opens with nothing preselected instead of offering the most recent existing credential — existing ones stay listed in case the user changes their mind.', 'Set when the user explicitly asked to create a new credential of this type ("create a new Slack credential"), or needs to enter a replacement for one whose secret is invalid or rotated (e.g. pasted a new token in chat, which you cannot store). The card then opens with nothing preselected instead of offering the most recent existing credential — existing ones stay listed in case the user changes their mind.',
), ),
setupHint: setupHintField.optional(), setupHint: standaloneSetupHintField.optional(),
}), }),
) )
.describe('List of credentials to set up'), .describe('List of credentials to set up'),
@ -766,18 +792,13 @@ async function handleSetup(
type: req.credentialType, type: req.credentialType,
...(context.projectId ? { projectId: context.projectId } : {}), ...(context.projectId ? { projectId: context.projectId } : {}),
}); });
// Service identity comes from the recipe's test endpoint here; an
// untagged credential is never offered automatically later.
const serviceHost = req.setupHint ? extractServiceHost(req.setupHint.testUrl) : undefined;
return { return {
credentialType: req.credentialType, credentialType: req.credentialType,
reason: req.reason ?? `Required for ${req.credentialType}`, reason: req.reason ?? `Required for ${req.credentialType}`,
existingCredentials: existing.map((c) => ({ id: c.id, name: c.name })), existingCredentials: existing.map((c) => ({ id: c.id, name: c.name })),
...(req.suggestedName ? { suggestedName: req.suggestedName } : {}), ...(req.suggestedName ? { suggestedName: req.suggestedName } : {}),
...(req.preferNew ? { preferNew: true } : {}), ...(req.preferNew ? { preferNew: true } : {}),
...(req.setupHint ...(req.setupHint ? { setupHint: req.setupHint } : {}),
? { setupHint: { ...req.setupHint, ...(serviceHost ? { serviceHost } : {}) } }
: {}),
}; };
}, },
), ),

View File

@ -4,6 +4,11 @@
* update-version. * update-version.
*/ */
import { Tool } from '@n8n/agents'; import { Tool } from '@n8n/agents';
import {
buildCredentialDestinationGrantKey,
credentialDestinationSchema,
TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE,
} from '@n8n/api-types';
import { isRecord } from '@n8n/utils/is-record'; import { isRecord } from '@n8n/utils/is-record';
import { dropInvalidWorkflowJsonGroups, type WorkflowJSON } from '@n8n/workflow-sdk'; import { dropInvalidWorkflowJsonGroups, type WorkflowJSON } from '@n8n/workflow-sdk';
import { makeGetNodeTypeForGrouping } from 'n8n-workflow'; import { makeGetNodeTypeForGrouping } from 'n8n-workflow';
@ -15,6 +20,7 @@ import { WorkflowSaveConflictError } from '../errors/workflow-save-conflict.erro
import type { InstanceAiContext } from '../types'; import type { InstanceAiContext } from '../types';
import { import {
findSetupHintProblems, findSetupHintProblems,
findSetupHintTestUrlOriginProblem,
INVALID_SETUP_HINT_MESSAGE, INVALID_SETUP_HINT_MESSAGE,
setupHintField, setupHintField,
TEMPLATABLE_PLAIN_AUTH_TYPES, TEMPLATABLE_PLAIN_AUTH_TYPES,
@ -283,7 +289,8 @@ const confirmationSuspendSchema = setupSuspendSchema
severity: true, severity: true,
workflowId: true, workflowId: true,
}) })
.partial({ workflowId: true }); .partial({ workflowId: true })
.extend({ credentialDestination: credentialDestinationSchema.optional() });
const suspendSchema = z.union([setupSuspendSchema, confirmationSuspendSchema]); const suspendSchema = z.union([setupSuspendSchema, confirmationSuspendSchema]);
@ -787,6 +794,90 @@ function appliedCredentialIdList(
.flatMap(([, byType]) => Object.values(byType)); .flatMap(([, byType]) => Object.values(byType));
} }
interface RequiredCredentialDestination {
origin: string;
nodeNames: string[];
grantKey: string;
}
function inspectCredentialDestinations(
workflowId: string,
requests: readonly SetupRequest[],
requestsForNodeUrls: readonly SetupRequest[] = requests,
): { problems: string[]; destinations: RequiredCredentialDestination[] } {
const problems: string[] = [];
const byGrantKey = new Map<string, RequiredCredentialDestination>();
const nodeUrls = requestsForNodeUrls.map((request) => request.node.parameters?.url);
for (const request of requests) {
if (
request.credentialType !== TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE ||
request.needsAction === false ||
!request.setupHint
) {
continue;
}
const origin = request.setupHint.serviceOrigin;
if (!origin) {
problems.push(
`${request.node.name}: credential destination cannot be verified because the workflow node has no statically derivable HTTP origin`,
);
continue;
}
const hintProblems = findSetupHintProblems(request.setupHint, { nodeUrls });
const originProblem = findSetupHintTestUrlOriginProblem(request.setupHint, origin);
problems.push(
...hintProblems.map((problem) => `${request.node.name}: ${problem}`),
...(originProblem ? [`${request.node.name}: ${originProblem}`] : []),
);
const grantKey = buildCredentialDestinationGrantKey(workflowId, origin);
const existing = byGrantKey.get(grantKey);
if (existing) {
existing.nodeNames.push(request.node.name);
continue;
}
byGrantKey.set(grantKey, {
origin,
nodeNames: [request.node.name],
grantKey,
});
}
return { problems, destinations: [...byGrantKey.values()] };
}
function findUnapprovedCredentialDestination(
context: InstanceAiContext,
destinations: readonly RequiredCredentialDestination[],
justApprovedGrantKey?: string,
): RequiredCredentialDestination | undefined {
return destinations.find(
(destination) =>
destination.grantKey !== justApprovedGrantKey &&
context.sessionApprovedToolKeys?.has(destination.grantKey) !== true,
);
}
async function suspendForCredentialDestination(
ctx: WorkflowToolContext,
state: SetupState,
workflowId: string,
destination: RequiredCredentialDestination,
) {
state.currentRequestId = nanoid();
return await ctx.suspend({
requestId: state.currentRequestId,
message: 'Review where this credential will be used',
severity: 'warning' as const,
workflowId,
credentialDestination: {
origin: destination.origin,
nodeNames: destination.nodeNames,
},
});
}
/** Setup state 3: persist setup, run the trigger, and re-suspend with the refreshed requests. */ /** Setup state 3: persist setup, run the trigger, and re-suspend with the refreshed requests. */
async function handleSetupTestTrigger( async function handleSetupTestTrigger(
context: InstanceAiContext, context: InstanceAiContext,
@ -834,6 +925,26 @@ async function handleSetupTestTrigger(
getSkippedSetupSubjects(context), getSkippedSetupSubjects(context),
); );
applyCredentialHints(refreshedPending, input.credentialHints); applyCredentialHints(refreshedPending, input.credentialHints);
const destinationInspection = inspectCredentialDestinations(
input.workflowId,
refreshedPending,
refreshedRequests,
);
if (destinationInspection.problems.length > 0) {
return {
error: 'invalid_credential_hints',
message: INVALID_SETUP_HINT_MESSAGE,
problems: destinationInspection.problems,
};
}
const destination = findUnapprovedCredentialDestination(
context,
destinationInspection.destinations,
);
if (destination) {
return await suspendForCredentialDestination(ctx, state, input.workflowId, destination);
}
// Generate a new requestId so the frontend doesn't filter it // Generate a new requestId so the frontend doesn't filter it
// as already-resolved from the previous suspend cycle // as already-resolved from the previous suspend cycle
@ -1061,9 +1172,18 @@ async function handleSetup(
} }
const resumeData = ctx.resumeData; const resumeData = ctx.resumeData;
const destinationDecision = resumeData?.credentialDestination;
if (destinationDecision && !resumeData.approved) {
return {
success: false,
denied: true,
reason: `User did not approve credential use with ${destinationDecision.origin}.`,
};
}
// State 1: Analyze workflow and suspend for user setup // State 1: Analyze workflow and suspend for user setup
if (resumeData === undefined || resumeData === null) { if (resumeData === undefined || resumeData === null || destinationDecision !== undefined) {
const allSetupRequests = await analyzeWorkflow( const allSetupRequests = await analyzeWorkflow(
context, context,
input.workflowId, input.workflowId,
@ -1110,24 +1230,6 @@ async function handleSetup(
await forgetSkippedSetup(context, subjects); await forgetSkippedSetup(context, subjects);
} }
// Setup after a build covers only the nodes that build changed —
// pre-existing, unrelated nodes must not surface in the setup card.
const scopeNodeNames = input.includeAllNodes
? undefined
: await resolveSetupScopeNodeNames(context, input.workflowId);
const scopedRequests = scopeNodeNames
? allSetupRequests.filter((request) => scopeNodeNames.includes(request.node.name))
: allSetupRequests;
// Two reasons a card stays out, applied in order: this build never touched the node, or
// the user declined it. Partitioning the scoped list keeps them apart in the report —
// an out-of-scope card is not something the user passed on.
const { pending: setupRequests, skippedByUser } = partitionSkippedSetupRequests(
scopedRequests,
input.workflowId,
getSkippedSetupSubjects(context),
);
// Validated against the workflow's node URLs so a recipe can't set one of // Validated against the workflow's node URLs so a recipe can't set one of
// the workflow's own (action) endpoints as its probe testUrl. Checked against every // the workflow's own (action) endpoints as its probe testUrl. Checked against every
// analyzed node, not just the pending ones — narrowing it to what the card shows would // analyzed node, not just the pending ones — narrowing it to what the card shows would
@ -1146,7 +1248,33 @@ async function handleSetup(
}; };
} }
applyCredentialHints(setupRequests, input.credentialHints); applyCredentialHints(allSetupRequests, input.credentialHints);
const destinationInspection = inspectCredentialDestinations(input.workflowId, allSetupRequests);
if (destinationInspection.problems.length > 0) {
return {
error: 'invalid_credential_hints',
message: INVALID_SETUP_HINT_MESSAGE,
problems: destinationInspection.problems,
};
}
// Setup after a build covers only the nodes that build changed —
// pre-existing, unrelated nodes must not surface in the setup card.
const scopeNodeNames = input.includeAllNodes
? undefined
: await resolveSetupScopeNodeNames(context, input.workflowId);
const scopedRequests = scopeNodeNames
? allSetupRequests.filter((request) => scopeNodeNames.includes(request.node.name))
: allSetupRequests;
// Two reasons a card stays out, applied in order: this build never touched the node, or
// the user declined it. Partitioning the scoped list keeps them apart in the report —
// an out-of-scope card is not something the user passed on.
const { pending: setupRequests, skippedByUser } = partitionSkippedSetupRequests(
scopedRequests,
input.workflowId,
getSkippedSetupSubjects(context),
);
// A provider documenting `Authorization: Bearer <token>` reliably lures the // A provider documenting `Authorization: Bearer <token>` reliably lures the
// model into httpBearerAuth despite the skill guidance, so new plain generic // model into httpBearerAuth despite the skill guidance, so new plain generic
@ -1172,6 +1300,36 @@ async function handleSetup(
} }
} }
const setupNodeNames = new Set(setupRequests.map((request) => request.node.name));
const credentialDestinations = destinationInspection.destinations.flatMap((destination) => {
const nodeNames = destination.nodeNames.filter((name) => setupNodeNames.has(name));
return nodeNames.length > 0 ? [{ ...destination, nodeNames }] : [];
});
let justApprovedGrantKey: string | undefined;
if (destinationDecision) {
const approvedDestination = credentialDestinations.find(
(destination) => destination.origin === destinationDecision.origin,
);
if (!approvedDestination) {
return {
error: 'credential_destination_changed',
message:
'The credential destination changed before approval was applied. Call setup again to review the current destination.',
};
}
await context.grantSessionToolApproval?.(approvedDestination.grantKey);
justApprovedGrantKey = approvedDestination.grantKey;
}
const destination = findUnapprovedCredentialDestination(
context,
credentialDestinations,
justApprovedGrantKey,
);
if (destination) {
return await suspendForCredentialDestination(ctx, state, input.workflowId, destination);
}
if (setupRequests.length === 0) { if (setupRequests.length === 0) {
// Two different silences, and the agent has to say different things about them: cards // Two different silences, and the agent has to say different things about them: cards
// the user declined, and pre-existing nodes this build never touched. Both can hold at // the user declined, and pre-existing nodes this build never touched. Both can hold at

View File

@ -3,6 +3,7 @@ import { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types';
import type { InstanceAiContext } from '../../../types'; import type { InstanceAiContext } from '../../../types';
import { import {
extractServiceHost, extractServiceHost,
extractServiceOrigin,
resolveCredentialForApply, resolveCredentialForApply,
serviceHostsMatch, serviceHostsMatch,
} from '../credential-utils'; } from '../credential-utils';
@ -95,6 +96,19 @@ describe('extractServiceHost', () => {
); );
}); });
describe('extractServiceOrigin', () => {
it.each([
['https://api.pexels.com/v1/search?query=x', 'https://api.pexels.com'],
['=https://queue.fal.run:8443/jobs/{{ $json.id }}', 'https://queue.fal.run:8443'],
])('derives the origin of %s', (url, origin) => {
expect(extractServiceOrigin(url)).toBe(origin);
});
it.each([['={{ $json.url }}'], ['not a url'], [undefined]])('returns undefined for %s', (url) => {
expect(extractServiceOrigin(url)).toBeUndefined();
});
});
describe('serviceHostsMatch', () => { describe('serviceHostsMatch', () => {
it.each([ it.each([
['api.pexels.com', 'api.pexels.com'], ['api.pexels.com', 'api.pexels.com'],

View File

@ -2159,6 +2159,7 @@ describe('applyCredentialHints', () => {
applyCredentialHints(requests, [hint()]); applyCredentialHints(requests, [hint()]);
expect(requests[0].setupHint?.serviceHost).toBe('queue.fal.run'); expect(requests[0].setupHint?.serviceHost).toBe('queue.fal.run');
expect(requests[0].setupHint?.serviceOrigin).toBe('https://queue.fal.run');
}); });
it('stamps each request with its own node host from a shared type-wide hint', () => { it('stamps each request with its own node host from a shared type-wide hint', () => {
@ -2175,16 +2176,20 @@ describe('applyCredentialHints', () => {
expect(requests[0].setupHint?.serviceHost).toBe('api.pexels.com'); expect(requests[0].setupHint?.serviceHost).toBe('api.pexels.com');
expect(requests[1].setupHint?.serviceHost).toBe('api.apify.com'); expect(requests[1].setupHint?.serviceHost).toBe('api.apify.com');
expect(shared).not.toHaveProperty('serviceHost'); expect(shared).not.toHaveProperty('serviceHost');
expect(requests[0].setupHint?.serviceOrigin).toBe('https://api.pexels.com');
expect(requests[1].setupHint?.serviceOrigin).toBe('https://api.apify.com');
expect(shared).not.toHaveProperty('serviceOrigin');
}); });
it('omits serviceHost when the node URL is not derivable', () => { it('omits service identity when the node URL is not derivable', () => {
const requests = [request('Call fal.ai', 'httpTemplatedCustomAuth')]; const requests = [request('Call fal.ai', 'httpTemplatedCustomAuth')];
requests[0].node.parameters = { url: '={{ $json.url }}' }; requests[0].node.parameters = { url: '={{ $json.url }}' };
applyCredentialHints(requests, [hint()]); applyCredentialHints(requests, [hint({ testUrl: 'https://fal.run/v1/models' })]);
expect(requests[0].setupHint).toBeDefined(); expect(requests[0].setupHint).toBeDefined();
expect(requests[0].setupHint).not.toHaveProperty('serviceHost'); expect(requests[0].setupHint).not.toHaveProperty('serviceHost');
expect(requests[0].setupHint).not.toHaveProperty('serviceOrigin');
}); });
}); });

View File

@ -0,0 +1,77 @@
import type { WorkflowNode } from '../../../types';
import { summarizeWorkflowStructure } from '../summarize-workflow';
const nodes: WorkflowNode[] = [
{ name: 'A', type: 'n8n-nodes-base.noOp', typeVersion: 1, position: [0, 0], parameters: {} },
{ name: 'B', type: 'n8n-nodes-base.noOp', typeVersion: 1, position: [220, 0], parameters: {} },
];
const edge = [[{ node: 'B', type: 'main', index: 0 }]];
/**
* Adds `key` as an own enumerable property — the shape `JSON.parse` produces for
* a stored `connections` column, which an object literal cannot express for keys
* like `__proto__`.
*/
function withOwnKey(key: string, value: unknown): Record<string, unknown> {
const connections: Record<string, unknown> = {};
Object.defineProperty(connections, key, {
value,
enumerable: true,
writable: true,
configurable: true,
});
return connections;
}
describe('summarizeWorkflowStructure', () => {
it('renders nodes and edges of a normal graph', async () => {
const summary = await summarizeWorkflowStructure('wf', nodes, { A: { main: edge } });
expect(summary).toContain('A');
expect(summary).toContain('B');
});
describe('keys that resolve to object internals', () => {
afterEach(() => {
delete (Object.prototype as Record<string, unknown>).main;
delete (Object as unknown as Record<string, unknown>).main;
});
it('skips a source key that would resolve to the shared prototype', async () => {
const connections = withOwnKey('__proto__', { main: edge });
await summarizeWorkflowStructure('wf', nodes, connections);
expect('main' in {}).toBe(false);
expect(Object.getOwnPropertyNames(Object.prototype)).not.toContain('main');
});
it('skips a source key that would resolve to the Object constructor', async () => {
const connections = withOwnKey('constructor', { main: edge });
await summarizeWorkflowStructure('wf', nodes, connections);
expect((Object as unknown as Record<string, unknown>).main).toBeUndefined();
});
it('skips a connection-type key that would resolve to object internals', async () => {
const connections = { A: withOwnKey('__proto__', edge) };
const summary = await summarizeWorkflowStructure('wf', nodes, connections);
expect(summary).not.toContain('__proto__');
});
it('keeps the rest of the graph when an unsafe source key is skipped', async () => {
const connections = withOwnKey('__proto__', { main: edge });
connections.A = { main: edge };
const summary = await summarizeWorkflowStructure('wf', nodes, connections);
expect(summary).toContain('A');
expect(summary).toContain('B');
expect(summary).not.toContain('__proto__');
});
});
});

View File

@ -107,12 +107,21 @@ export function assignCredentialToNode(
* anything that doesn't parse as an http(s) URL with a host. * anything that doesn't parse as an http(s) URL with a host.
*/ */
export function extractServiceHost(raw: unknown): string | undefined { export function extractServiceHost(raw: unknown): string | undefined {
return extractServiceUrl(raw)?.hostname.toLowerCase();
}
/** Exact origin of a node's statically derivable HTTP URL. */
export function extractServiceOrigin(raw: unknown): string | undefined {
return extractServiceUrl(raw)?.origin;
}
function extractServiceUrl(raw: unknown): URL | undefined {
if (typeof raw !== 'string') return undefined; if (typeof raw !== 'string') return undefined;
const plain = (raw.startsWith('=') ? raw.slice(1) : raw).split('{{')[0].trim(); const plain = (raw.startsWith('=') ? raw.slice(1) : raw).split('{{')[0].trim();
if (!/^https?:\/\//i.test(plain)) return undefined; if (!/^https?:\/\//i.test(plain)) return undefined;
try { try {
const host = new URL(plain).hostname.toLowerCase(); const url = new URL(plain);
return host || undefined; return url.hostname ? url : undefined;
} catch { } catch {
return undefined; return undefined;
} }

View File

@ -5,6 +5,7 @@
* The node schema is the canonical `workflowSetupNodeSchema` from @n8n/api-types. * The node schema is the canonical `workflowSetupNodeSchema` from @n8n/api-types.
*/ */
import { import {
credentialDestinationDecisionSchema,
instanceAiConfirmationSeveritySchema, instanceAiConfirmationSeveritySchema,
workflowSetupNodeSchema, workflowSetupNodeSchema,
type InstanceAiWorkflowSetupNode, type InstanceAiWorkflowSetupNode,
@ -24,6 +25,7 @@ export const setupSuspendSchema = z.object({
export const setupResumeSchema = z.object({ export const setupResumeSchema = z.object({
approved: z.boolean(), approved: z.boolean(),
credentialDestination: credentialDestinationDecisionSchema.optional(),
action: z.enum(['apply', 'test-trigger']).optional(), action: z.enum(['apply', 'test-trigger']).optional(),
credentials: z.record(z.record(z.string())).optional(), credentials: z.record(z.record(z.string())).optional(),
nodeParameters: z.record(z.record(z.unknown())).optional(), nodeParameters: z.record(z.record(z.unknown())).optional(),

View File

@ -28,6 +28,7 @@ import {
N8N_CONNECT_DISPLAY_NAME, N8N_CONNECT_DISPLAY_NAME,
assignCredentialToNode, assignCredentialToNode,
extractServiceHost, extractServiceHost,
extractServiceOrigin,
isAiGatewayManagedCredential, isAiGatewayManagedCredential,
resolveCredentialForApply, resolveCredentialForApply,
serviceHostsMatch, serviceHostsMatch,
@ -878,13 +879,15 @@ export function applyCredentialHints(
hints.find((h) => h.nodeName === request.node.name) ?? hints.find((h) => !h.nodeName); hints.find((h) => h.nodeName === request.node.name) ?? hints.find((h) => !h.nodeName);
if (!hint) continue; if (!hint) continue;
const { nodeName: _nodeName, ...setupHint } = hint; const { nodeName: _nodeName, ...setupHint } = hint;
// Service identity is derived from the node being set up (falling back // The workflow node is the authority for service identity. The recipe's
// to the recipe's own test endpoint), never model-supplied. It is // own destinations cannot establish or replace it.
// stamped into the created credential so setup surfaces only offer it const serviceHost = extractServiceHost(request.node.parameters?.url);
// to same-service nodes later. const serviceOrigin = extractServiceOrigin(request.node.parameters?.url);
const serviceHost = request.setupHint = {
extractServiceHost(request.node.parameters?.url) ?? extractServiceHost(setupHint.testUrl); ...setupHint,
request.setupHint = { ...setupHint, ...(serviceHost ? { serviceHost } : {}) }; ...(serviceHost ? { serviceHost } : {}),
...(serviceOrigin ? { serviceOrigin } : {}),
};
} }
} }

View File

@ -1,6 +1,6 @@
import { isRecord } from '@n8n/utils/is-record'; import { isRecord } from '@n8n/utils/is-record';
import type { IConnection, IConnections } from 'n8n-workflow'; import type { IConnection, IConnections } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow'; import { isSafeObjectProperty, NodeConnectionTypes } from 'n8n-workflow';
import type { WorkflowNode } from '../../types'; import type { WorkflowNode } from '../../types';
@ -31,8 +31,12 @@ function toConnections(connections: Record<string, unknown>): IConnections {
const result: IConnections = {}; const result: IConnections = {};
for (const [from, byType] of Object.entries(connections)) { for (const [from, byType] of Object.entries(connections)) {
if (!isRecord(byType)) continue; if (!isRecord(byType)) continue;
if (!isSafeObjectProperty(from)) continue;
for (const [connectionType, outputs] of Object.entries(byType)) { for (const [connectionType, outputs] of Object.entries(byType)) {
if (!Array.isArray(outputs)) continue; if (!Array.isArray(outputs)) continue;
if (!isSafeObjectProperty(connectionType)) continue;
(result[from] ??= {})[connectionType] = outputs.map((targets) => (result[from] ??= {})[connectionType] = outputs.map((targets) =>
Array.isArray(targets) ? targets.filter(isConnection) : null, Array.isArray(targets) ? targets.filter(isConnection) : null,
); );
@ -46,8 +50,12 @@ function summarizeConnections(connections: Record<string, unknown>): string[] {
const edges: string[] = []; const edges: string[] = [];
for (const [from, byType] of Object.entries(connections)) { for (const [from, byType] of Object.entries(connections)) {
if (!isRecord(byType)) continue; if (!isRecord(byType)) continue;
if (!isSafeObjectProperty(from)) continue;
for (const [connectionType, outputs] of Object.entries(byType)) { for (const [connectionType, outputs] of Object.entries(byType)) {
if (!Array.isArray(outputs)) continue; if (!Array.isArray(outputs)) continue;
if (!isSafeObjectProperty(connectionType)) continue;
outputs.forEach((targets, outputIndex) => { outputs.forEach((targets, outputIndex) => {
if (!Array.isArray(targets)) return; if (!Array.isArray(targets)) return;
for (const target of targets) { for (const target of targets) {

View File

@ -386,7 +386,7 @@ describe('setupSandboxWorkspace', () => {
expect(initialized).toBe(false); expect(initialized).toBe(false);
expect(runInSandbox).not.toHaveBeenCalledWith( expect(runInSandbox).not.toHaveBeenCalledWith(
expect.anything(), expect.anything(),
'npm install --ignore-scripts', 'npm install --ignore-scripts --no-audit --no-fund --prefer-offline',
'/sandbox', '/sandbox',
); );
const writtenPaths = writeFile.mock.calls.map(([path]) => path); const writtenPaths = writeFile.mock.calls.map(([path]) => path);

View File

@ -196,7 +196,9 @@ describe('SnapshotManager.ensureImage', () => {
expect(image.dockerfile).toContain( expect(image.dockerfile).toContain(
'mkdir -p /home/daytona/workspace/src /home/daytona/workspace/chunks /home/daytona/workspace/node-types', 'mkdir -p /home/daytona/workspace/src /home/daytona/workspace/chunks /home/daytona/workspace/node-types',
); );
expect(image.dockerfile).toContain('npm install --ignore-scripts'); expect(image.dockerfile).toContain(
'npm install --ignore-scripts --no-audit --no-fund --prefer-offline',
);
const stagingDir = image.contextList[0]?.sourcePath; const stagingDir = image.contextList[0]?.sourcePath;
expect(stagingDir).toBeDefined(); expect(stagingDir).toBeDefined();

View File

@ -97,6 +97,16 @@ function resolveHostDepVersion(name: string): string {
} }
} }
/**
* Flags for every `npm install` that runs inside a sandbox or a snapshot build.
* `--no-audit` matters most: npm otherwise posts the whole tree to the registry's
* advisories endpoint and blocks until it answers or its 300s fetch timeout expires,
* so a slow registry turns a 10s install into minutes. Nobody reads the audit or
* funding output in a sandbox. `--prefer-offline` lets a warm npm cache skip
* freshness checks against the registry.
*/
export const NPM_INSTALL_FLAGS = '--ignore-scripts --no-audit --no-fund --prefer-offline';
/** /**
* Versions pinned from the host's installed packages. Pinning is load-bearing * Versions pinned from the host's installed packages. Pinning is load-bearing
* for two reasons: * for two reasons:
@ -210,7 +220,7 @@ export async function linkWorkspaceSdkIfEnabled(
.join(' '); .join(' ');
const install = await runInSandbox( const install = await runInSandbox(
workspace, workspace,
`npm install ${tarballArgs} --no-save --ignore-scripts --force`, `npm install ${tarballArgs} --no-save --force ${NPM_INSTALL_FLAGS}`,
root, root,
); );
if (install.exitCode !== 0) { if (install.exitCode !== 0) {
@ -485,7 +495,7 @@ export async function setupSandboxWorkspace(
// npm install (must run after package.json is in place) // npm install (must run after package.json is in place)
await setupStep('install-dependencies', async () => { await setupStep('install-dependencies', async () => {
const npmResult = await runInSandbox(workspace, 'npm install --ignore-scripts', root); const npmResult = await runInSandbox(workspace, `npm install ${NPM_INSTALL_FLAGS}`, root);
if (npmResult.exitCode !== 0) { if (npmResult.exitCode !== 0) {
throw new Error(`Sandbox npm install failed: ${npmResult.stderr}`); throw new Error(`Sandbox npm install failed: ${npmResult.stderr}`);
} }

View File

@ -26,7 +26,7 @@ import {
BuilderTemplatesService, BuilderTemplatesService,
builderTemplatesOptionsFromEnv, builderTemplatesOptionsFromEnv,
} from './builder-templates-service'; } from './builder-templates-service';
import { PACKAGE_JSON, TSCONFIG_JSON, BUILD_MJS } from './sandbox-setup'; import { PACKAGE_JSON, TSCONFIG_JSON, BUILD_MJS, NPM_INSTALL_FLAGS } from './sandbox-setup';
import { stageWorkspaceFilesForImage } from './snapshot-image-context'; import { stageWorkspaceFilesForImage } from './snapshot-image-context';
import { buildRuntimeSkillWorkspaceBundle } from '../skills/materialize-runtime-skills'; import { buildRuntimeSkillWorkspaceBundle } from '../skills/materialize-runtime-skills';
import { loadInstanceAiRuntimeSkillSource } from '../skills/runtime-skills'; import { loadInstanceAiRuntimeSkillSource } from '../skills/runtime-skills';
@ -227,7 +227,7 @@ export class SnapshotManager {
const image = Image.base(base) const image = Image.base(base)
.addLocalDir(stagingDir, DAYTONA_WORKSPACE_BAKE_ROOT) .addLocalDir(stagingDir, DAYTONA_WORKSPACE_BAKE_ROOT)
.runCommands( .runCommands(
`cp -a ${DAYTONA_WORKSPACE_BAKE_ROOT}/. ${DAYTONA_WORKSPACE_ROOT}/ && mkdir -p ${layoutDirs} && cd ${DAYTONA_WORKSPACE_ROOT} && npm install --ignore-scripts`, `cp -a ${DAYTONA_WORKSPACE_BAKE_ROOT}/. ${DAYTONA_WORKSPACE_ROOT}/ && mkdir -p ${layoutDirs} && cd ${DAYTONA_WORKSPACE_ROOT} && npm install ${NPM_INSTALL_FLAGS}`,
); );
this.logger.info('Builder image descriptor prepared', { this.logger.info('Builder image descriptor prepared', {

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@
import type { BaseCallbackHandler, CallbackHandlerMethods } from '@langchain/core/callbacks/base'; import type { BaseCallbackHandler, CallbackHandlerMethods } from '@langchain/core/callbacks/base';
import type { Callbacks } from '@langchain/core/callbacks/manager'; import type { Callbacks } from '@langchain/core/callbacks/manager';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { isRecord } from '@n8n/utils/is-record';
import { import {
NodeConnectionTypes, NodeConnectionTypes,
type INodeType, type INodeType,
@ -40,6 +41,20 @@ interface ModeleSelectionRule {
}; };
} }
/**
* Read the header names a tracer declares as redacted. Duck-typed on purpose: a model may
* attach any tracer implementation, so match on the declared list, not on a tracer class.
*/
function getDeclaredRedactedHeaders(tracer: unknown): string[] {
if (!isRecord(tracer) || !isRecord(tracer.options)) return [];
const { redactedHeaders } = tracer.options;
return Array.isArray(redactedHeaders)
? redactedHeaders.filter((name) => typeof name === 'string')
: [];
}
function getCallbacksArray( function getCallbacksArray(
callbacks: Callbacks | undefined, callbacks: Callbacks | undefined,
): Array<BaseCallbackHandler | CallbackHandlerMethods> { ): Array<BaseCallbackHandler | CallbackHandlerMethods> {
@ -200,7 +215,11 @@ export class ModelSelector implements INodeType {
currentCallback.setParentRunIndex(this.getNextRunIndex()); currentCallback.setParentRunIndex(this.getNextRunIndex());
} }
} }
const modelSelectorTracing = new N8nNonEstimatingTracing(this);
// This node records the same serialized model as the model's own tracer,
// so it masks whatever header names that tracer declares.
const redactedHeaders = originalCallbacks.flatMap(getDeclaredRedactedHeaders);
const modelSelectorTracing = new N8nNonEstimatingTracing(this, { redactedHeaders });
selectedModel.callbacks = [...originalCallbacks, modelSelectorTracing]; selectedModel.callbacks = [...originalCallbacks, modelSelectorTracing];
return { return {

View File

@ -0,0 +1,85 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { Serialized } from '@langchain/core/load/serializable';
import { N8nLlmTracing } from '@n8n/ai-utilities';
import type { ISupplyDataFunctions, INode } from 'n8n-workflow';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { ModelSelector } from '../ModelSelector.node';
const serializedModel: Serialized = {
lc: 1,
type: 'constructor',
id: ['langchain', 'chat_models', 'openai'],
kwargs: {
configuration: {
defaultHeaders: {
'User-Agent': 'n8n',
authorization: 'Bearer My_secret_API_key123456789',
'x-secret-header': 'My_secret_API_key123456789',
},
},
},
};
describe('ModelSelector Node header handling', () => {
let selectorContext: Mocked<ISupplyDataFunctions>;
beforeEach(() => {
selectorContext = mock<ISupplyDataFunctions>({
addInputData: vi.fn().mockReturnValue({ index: 0 }),
getNextRunIndex: vi.fn().mockReturnValue(0),
});
selectorContext.getNode.mockReturnValue({ name: 'Model Selector' } as INode);
selectorContext.getNodeParameter.mockImplementation((parameter) =>
parameter === 'rules.rule' ? [{ modelIndex: 1, conditions: {} }] : true,
);
});
/** Runs the selected model through the node and returns what the node persisted. */
const persistHeadersFor = async (callbacks: unknown[]) => {
const model = { _llmType: () => 'fake-llm', callbacks } as unknown as BaseChatModel;
selectorContext.getInputConnectionData.mockResolvedValue([model]);
const { response } = await new ModelSelector().supplyData.call(selectorContext, 0);
const attached = (response as BaseChatModel).callbacks as Array<{
handleLLMStart: (llm: Serialized, prompts: string[], runId: string) => Promise<void>;
}>;
// the tracer the Model Selector attached on top of the model's own callbacks
await attached[attached.length - 1].handleLLMStart(serializedModel, ['hello'], 'run-123');
const inputArg = selectorContext.addInputData.mock.calls[0][1] as Array<
Array<{ json: { options: { configuration: { defaultHeaders: Record<string, string> } } } }>
>;
return inputArg[0][0].json.options.configuration.defaultHeaders;
};
it('should mask the header values declared by the selected model', async () => {
const modelContext = mock<ISupplyDataFunctions>({
addInputData: vi.fn().mockReturnValue({ index: 0 }),
getNextRunIndex: vi.fn().mockReturnValue(0),
});
const persistedHeaders = await persistHeadersFor([
new N8nLlmTracing(modelContext, { redactedHeaders: ['x-secret-header'] }),
]);
expect(persistedHeaders['x-secret-header']).toBe('**********');
expect(persistedHeaders['User-Agent']).toBe('n8n');
});
it('should mask the header values declared by a model that attaches its own tracer type', async () => {
const persistedHeaders = await persistHeadersFor([
{ handleLLMStart: vi.fn(), options: { redactedHeaders: ['x-secret-header'] } },
]);
expect(persistedHeaders['x-secret-header']).toBe('**********');
});
it('should still mask the always-redacted header names when the model declares none', async () => {
const persistedHeaders = await persistHeadersFor([{ handleLLMStart: vi.fn() }]);
expect(persistedHeaders.authorization).toBe('**********');
});
});

View File

@ -1,7 +1,7 @@
import { AgentExecutor } from '@langchain/classic/agents'; import { AgentExecutor } from '@langchain/classic/agents';
import type { OpenAIToolType } from '@langchain/classic/dist/experimental/openai_assistant/schema'; import type { OpenAIToolType } from '@langchain/classic/dist/experimental/openai_assistant/schema';
import { OpenAIAssistantRunnable } from '@langchain/classic/experimental/openai_assistant'; import { OpenAIAssistantRunnable } from '@langchain/classic/experimental/openai_assistant';
import { assertCredentialAllowsUrl, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import type { import type {
IExecuteFunctions, IExecuteFunctions,
INodeExecutionData, INodeExecutionData,
@ -14,6 +14,7 @@ import { getConnectedTools, mergeCustomHeaders } from '@utils/helpers';
import { getTracingConfig } from '@utils/tracing'; import { getTracingConfig } from '@utils/tracing';
import { formatToOpenAIAssistantTool } from './utils'; import { formatToOpenAIAssistantTool } from './utils';
import { assertOpenAiCredentialAllowsUrl } from '../../vendors/OpenAi/helpers/credentials';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { AiConfig } from '@n8n/config'; import { AiConfig } from '@n8n/config';
@ -350,13 +351,7 @@ export class OpenAiAssistant implements INodeType {
const defaultHeaders = mergeCustomHeaders(credentials, openAiDefaultHeaders ?? {}); const defaultHeaders = mergeCustomHeaders(credentials, openAiDefaultHeaders ?? {});
if (options.baseURL) { if (options.baseURL) {
assertCredentialAllowsUrl({ assertOpenAiCredentialAllowsUrl(this.getNode(), credentials, options.baseURL);
node: this.getNode(),
credentialData: credentials,
url: options.baseURL,
pinnedUrl: typeof credentials.url === 'string' ? credentials.url : undefined,
surface: 'OpenAI',
});
} }
const client = new OpenAIClient({ const client = new OpenAIClient({

View File

@ -1,5 +1,6 @@
import { OllamaEmbeddings } from '@langchain/ollama'; import { OllamaEmbeddings } from '@langchain/ollama';
import { import {
assertCredentialAllowsUrl,
NodeConnectionTypes, NodeConnectionTypes,
type INodeType, type INodeType,
type INodeTypeDescription, type INodeTypeDescription,
@ -7,7 +8,7 @@ import {
type SupplyData, type SupplyData,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities'; import { logWrapper, getConnectionHintNoticeField, proxyFetch } from '@n8n/ai-utilities';
import { ollamaDescription, ollamaModel } from '../../llms/LMOllama/description'; import { ollamaDescription, ollamaModel } from '../../llms/LMOllama/description';
@ -48,16 +49,29 @@ export class EmbeddingsOllama implements INodeType {
this.logger.debug('Supply data for embeddings Ollama'); this.logger.debug('Supply data for embeddings Ollama');
const modelName = this.getNodeParameter('model', itemIndex) as string; const modelName = this.getNodeParameter('model', itemIndex) as string;
const credentials = await this.getCredentials('ollamaApi'); const credentials = await this.getCredentials('ollamaApi');
const baseUrl = credentials.baseUrl as string;
assertCredentialAllowsUrl({
node: this.getNode(),
credentialData: credentials,
url: baseUrl,
surface: 'Ollama',
});
const headers = credentials.apiKey const headers = credentials.apiKey
? { ? {
Authorization: `Bearer ${credentials.apiKey as string}`, Authorization: `Bearer ${credentials.apiKey as string}`,
} }
: undefined; : undefined;
const lookup = this.helpers.getSecureEgressFilter().createSecureLookup();
const embeddings = new OllamaEmbeddings({ const embeddings = new OllamaEmbeddings({
baseUrl: credentials.baseUrl as string, baseUrl,
model: modelName, model: modelName,
headers, headers,
fetch: async (input: RequestInfo | URL, init?: RequestInit) =>
await proxyFetch({ input, init, lookup }),
}); });
return { return {

View File

@ -0,0 +1,126 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { OllamaEmbeddings } from '@langchain/ollama';
import { proxyFetch } from '@n8n/ai-utilities';
import { createResultOk } from '@n8n/utils/result';
import type { INode, ISupplyDataFunctions, NodeEgressFilter } from 'n8n-workflow';
import type { MockedFunction } from 'vitest';
import { mockDeep } from 'vitest-mock-extended';
import { EmbeddingsOllama } from '../EmbeddingsOllama.node';
vi.mock('@langchain/ollama');
vi.mock('@n8n/ai-utilities', async () => {
const actual = await vi.importActual('@n8n/ai-utilities');
return {
...(actual as Record<string, unknown>),
proxyFetch: vi.fn(),
};
});
const MockedOllamaEmbeddings = vi.mocked(OllamaEmbeddings);
const mockedProxyFetch = proxyFetch as MockedFunction<typeof proxyFetch>;
const nodeDef: INode = {
id: '1',
name: 'Embeddings Ollama',
typeVersion: 1,
type: 'n8n-nodes-langchain.embeddingsOllama',
position: [0, 0],
parameters: {},
};
/**
* The lookup may travel to `proxyFetch` as a positional argument or as a field
* of an options object, so accept either shape.
*/
function lookupReachedProxyFetch(lookup: unknown): boolean {
return mockedProxyFetch.mock.calls.some((call) =>
call.some(
(arg) =>
arg === lookup ||
(typeof arg === 'object' &&
arg !== null &&
(arg as { lookup?: unknown }).lookup === lookup),
),
);
}
describe('EmbeddingsOllama', () => {
let node: EmbeddingsOllama;
const setupContext = (credentials: Record<string, unknown>, egressFilter: NodeEgressFilter) => {
const ctx = mockDeep<ISupplyDataFunctions>();
ctx.getNode.mockReturnValue(nodeDef);
ctx.getCredentials.mockResolvedValue(credentials);
ctx.getNodeParameter.mockImplementation((name: string) => {
if (name === 'model') return 'llama3';
return undefined;
});
ctx.helpers.getSecureEgressFilter.mockReturnValue(egressFilter);
return ctx;
};
beforeEach(() => {
node = new EmbeddingsOllama();
vi.clearAllMocks();
mockedProxyFetch.mockResolvedValue(new Response('ok', { status: 200 }));
});
it('routes requests through the egress filter lookup', async () => {
const secureLookup = vi.fn();
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
};
const ctx = setupContext({ baseUrl: 'http://ollama.example.com:11434' }, egressFilter);
await node.supplyData.call(ctx, 0);
expect(egressFilter.createSecureLookup).toHaveBeenCalled();
const embeddingsOptions = MockedOllamaEmbeddings.mock.calls[0][0] as
| { fetch?: typeof fetch }
| undefined;
expect(embeddingsOptions?.fetch).toBeTypeOf('function');
await embeddingsOptions?.fetch?.('http://ollama.example.com:11434/api/embed', {});
expect(mockedProxyFetch).toHaveBeenCalled();
expect(lookupReachedProxyFetch(secureLookup)).toBe(true);
});
it('rejects a base URL the credential does not allow', async () => {
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
};
const ctx = setupContext(
{
baseUrl: 'http://not-allowed.example.com:11434',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'ollama.example.com',
},
egressFilter,
);
await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('Domain not allowed');
expect(MockedOllamaEmbeddings).not.toHaveBeenCalled();
});
it('rejects a credential restricted from use in this node', async () => {
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
};
const ctx = setupContext(
{
baseUrl: 'http://ollama.example.com:11434',
allowedHttpRequestDomains: 'none',
},
egressFilter,
);
await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('configured to prevent use');
expect(MockedOllamaEmbeddings).not.toHaveBeenCalled();
});
});

View File

@ -2,7 +2,6 @@ import { OpenAIEmbeddings } from '@langchain/openai';
import { AiConfig } from '@n8n/config'; import { AiConfig } from '@n8n/config';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { import {
assertCredentialAllowsUrl,
NodeConnectionTypes, NodeConnectionTypes,
type INodeProperties, type INodeProperties,
type INodeType, type INodeType,
@ -13,6 +12,8 @@ import {
import type { ClientOptions } from 'openai'; import type { ClientOptions } from 'openai';
import { mergeCustomHeaders } from '@utils/helpers'; import { mergeCustomHeaders } from '@utils/helpers';
import { assertOpenAiCredentialAllowsUrl } from '../../vendors/OpenAi/helpers/credentials';
import { getProxyAgent, logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities'; import { getProxyAgent, logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities';
const modelParameter: INodeProperties = { const modelParameter: INodeProperties = {
@ -252,13 +253,7 @@ export class EmbeddingsOpenAi implements INodeType {
defaultHeaders, defaultHeaders,
}; };
if (options.baseURL) { if (options.baseURL) {
assertCredentialAllowsUrl({ assertOpenAiCredentialAllowsUrl(this.getNode(), credentials, options.baseURL);
node: this.getNode(),
credentialData: credentials,
url: options.baseURL,
pinnedUrl: typeof credentials.url === 'string' ? credentials.url : undefined,
surface: 'OpenAI',
});
configuration.baseURL = options.baseURL; configuration.baseURL = options.baseURL;
} else if (credentials.url) { } else if (credentials.url) {
configuration.baseURL = credentials.url as string; configuration.baseURL = credentials.url as string;

View File

@ -44,10 +44,17 @@ const mockModels = [
describe('searchModels', () => { describe('searchModels', () => {
let mockContext: Mocked<ILoadOptionsFunctions>; let mockContext: Mocked<ILoadOptionsFunctions>;
let fetchSpy: ReturnType<typeof vi.fn>; let fetchSpy: ReturnType<typeof vi.fn>;
const secureLookup = vi.fn();
beforeEach(() => { beforeEach(() => {
mockContext = { mockContext = {
getCredentials: vi.fn().mockResolvedValue({ apiKey: 'test-api-key' }), getCredentials: vi.fn().mockResolvedValue({ apiKey: 'test-api-key' }),
helpers: {
getSecureEgressFilter: vi.fn().mockReturnValue({
validateUrl: vi.fn(),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
}),
},
} as unknown as Mocked<ILoadOptionsFunctions>; } as unknown as Mocked<ILoadOptionsFunctions>;
fetchSpy = vi.fn().mockResolvedValue({ fetchSpy = vi.fn().mockResolvedValue({
@ -69,15 +76,16 @@ describe('searchModels', () => {
const result = await searchModels.call(mockContext); const result = await searchModels.call(mockContext);
expect(mockContext.getCredentials).toHaveBeenCalledWith('anthropicApi'); expect(mockContext.getCredentials).toHaveBeenCalledWith('anthropicApi');
expect(fetchSpy).toHaveBeenCalledWith( expect(fetchSpy).toHaveBeenCalledWith({
'https://api.anthropic.com/v1/models', input: 'https://api.anthropic.com/v1/models',
expect.objectContaining({ init: expect.objectContaining({
headers: expect.objectContaining({ headers: expect.objectContaining({
'x-api-key': 'test-api-key', 'x-api-key': 'test-api-key',
'anthropic-version': '2023-06-01', 'anthropic-version': '2023-06-01',
}), }),
}), }),
); lookup: secureLookup,
});
expect(result.results).toHaveLength(5); expect(result.results).toHaveLength(5);
}); });
@ -89,7 +97,9 @@ describe('searchModels', () => {
const result = await searchModels.call(mockContext); const result = await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith(`${customUrl}/v1/models`, expect.anything()); expect(fetchSpy).toHaveBeenCalledWith(
expect.objectContaining({ input: `${customUrl}/v1/models` }),
);
expect(result.results).toHaveLength(5); expect(result.results).toHaveLength(5);
}); });
@ -100,7 +110,9 @@ describe('searchModels', () => {
const result = await searchModels.call(mockContext); const result = await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith('https://api.anthropic.com/v1/models', expect.anything()); expect(fetchSpy).toHaveBeenCalledWith(
expect.objectContaining({ input: 'https://api.anthropic.com/v1/models' }),
);
expect(result.results).toHaveLength(5); expect(result.results).toHaveLength(5);
}); });
@ -154,11 +166,13 @@ describe('searchModels', () => {
await searchModels.call(mockContext); await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith( expect(fetchSpy).toHaveBeenCalledWith(
'https://api.anthropic.com/v1/models',
expect.objectContaining({ expect.objectContaining({
headers: expect.objectContaining({ input: 'https://api.anthropic.com/v1/models',
'x-api-key': 'test-api-key', init: expect.objectContaining({
'X-Gateway-Auth': 'gateway-value', headers: expect.objectContaining({
'x-api-key': 'test-api-key',
'X-Gateway-Auth': 'gateway-value',
}),
}), }),
}), }),
); );

View File

@ -9,6 +9,7 @@ export async function searchModels(
filter?: string, filter?: string,
): Promise<INodeListSearchResult> { ): Promise<INodeListSearchResult> {
const credentials = await this.getCredentials('anthropicApi'); const credentials = await this.getCredentials('anthropicApi');
const lookup = this.helpers.getSecureEgressFilter().createSecureLookup();
const baseURL = (credentials.url as string) ?? 'https://api.anthropic.com'; const baseURL = (credentials.url as string) ?? 'https://api.anthropic.com';
// Shared with the agents model catalog: endpoint, auth, and newest-first // Shared with the agents model catalog: endpoint, auth, and newest-first
@ -19,7 +20,7 @@ export async function searchModels(
apiKey: (credentials.apiKey as string) ?? '', apiKey: (credentials.apiKey as string) ?? '',
baseURL, baseURL,
headers: mergeCustomHeaders(credentials, {}), headers: mergeCustomHeaders(credentials, {}),
fetch: proxyFetch, fetch: async (input, init) => await proxyFetch({ input, init, lookup }),
}); });
return { return {

View File

@ -7,6 +7,7 @@ import {
getConnectionHintNoticeField, getConnectionHintNoticeField,
} from '@n8n/ai-utilities'; } from '@n8n/ai-utilities';
import { import {
assertCredentialAllowsUrl,
NodeConnectionTypes, NodeConnectionTypes,
type INodeType, type INodeType,
type INodeTypeDescription, type INodeTypeDescription,
@ -59,6 +60,14 @@ export class LmChatOllama implements INodeType {
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> { async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const credentials = await this.getCredentials('ollamaApi'); const credentials = await this.getCredentials('ollamaApi');
const baseUrl = credentials.baseUrl as string;
assertCredentialAllowsUrl({
node: this.getNode(),
credentialData: credentials,
url: baseUrl,
surface: 'Ollama',
});
const modelName = this.getNodeParameter('model', itemIndex) as string; const modelName = this.getNodeParameter('model', itemIndex) as string;
const options = this.getNodeParameter('options', itemIndex, {}) as ChatOllamaInput; const options = this.getNodeParameter('options', itemIndex, {}) as ChatOllamaInput;
@ -68,12 +77,13 @@ export class LmChatOllama implements INodeType {
} }
: undefined; : undefined;
const lookup = this.helpers.getSecureEgressFilter().createSecureLookup();
const fetchWithTimeout = async (input: RequestInfo | URL, init?: RequestInit) => const fetchWithTimeout = async (input: RequestInfo | URL, init?: RequestInit) =>
await proxyFetch(input, init, {}); await proxyFetch({ input, init, lookup });
const model = new ChatOllama({ const model = new ChatOllama({
...options, ...options,
baseUrl: credentials.baseUrl as string, baseUrl,
model: modelName, model: modelName,
format: options.format === 'default' ? undefined : options.format, format: options.format === 'default' ? undefined : options.format,
callbacks: [new N8nLlmTracing(this)], callbacks: [new N8nLlmTracing(this)],

View File

@ -0,0 +1,125 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { ChatOllama } from '@langchain/ollama';
import { proxyFetch } from '@n8n/ai-utilities';
import { createResultOk } from '@n8n/utils/result';
import type { INode, ISupplyDataFunctions, NodeEgressFilter } from 'n8n-workflow';
import type { MockedFunction } from 'vitest';
import { mockDeep } from 'vitest-mock-extended';
import { LmChatOllama } from '../LmChatOllama.node';
vi.mock('@langchain/ollama');
vi.mock('@n8n/ai-utilities', async () => {
const actual = await vi.importActual('@n8n/ai-utilities');
return {
...(actual as Record<string, unknown>),
proxyFetch: vi.fn(),
};
});
const MockedChatOllama = vi.mocked(ChatOllama);
const mockedProxyFetch = proxyFetch as MockedFunction<typeof proxyFetch>;
const nodeDef: INode = {
id: '1',
name: 'Ollama Chat Model',
typeVersion: 1,
type: 'n8n-nodes-langchain.lmChatOllama',
position: [0, 0],
parameters: {},
};
/**
* The lookup may travel to `proxyFetch` as a positional argument or as a field
* of an options object, so accept either shape.
*/
function lookupReachedProxyFetch(lookup: unknown): boolean {
return mockedProxyFetch.mock.calls.some((call) =>
call.some(
(arg) =>
arg === lookup ||
(typeof arg === 'object' &&
arg !== null &&
(arg as { lookup?: unknown }).lookup === lookup),
),
);
}
describe('LmChatOllama', () => {
let node: LmChatOllama;
const setupContext = (credentials: Record<string, unknown>, egressFilter: NodeEgressFilter) => {
const ctx = mockDeep<ISupplyDataFunctions>();
ctx.getNode.mockReturnValue(nodeDef);
ctx.getCredentials.mockResolvedValue(credentials);
ctx.getNodeParameter.mockImplementation((name: string) => {
if (name === 'model') return 'llama3';
if (name === 'options') return {};
return undefined;
});
ctx.helpers.getSecureEgressFilter.mockReturnValue(egressFilter);
return ctx;
};
beforeEach(() => {
node = new LmChatOllama();
vi.clearAllMocks();
mockedProxyFetch.mockResolvedValue(new Response('ok', { status: 200 }));
});
it('routes requests through the egress filter lookup', async () => {
const secureLookup = vi.fn();
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
};
const ctx = setupContext({ baseUrl: 'http://ollama.example.com:11434' }, egressFilter);
await node.supplyData.call(ctx, 0);
expect(egressFilter.createSecureLookup).toHaveBeenCalled();
const [modelOptions] = MockedChatOllama.mock.calls[0];
expect(modelOptions?.fetch).toBeTypeOf('function');
await modelOptions?.fetch?.('http://ollama.example.com:11434/api/chat', {});
expect(mockedProxyFetch).toHaveBeenCalled();
expect(lookupReachedProxyFetch(secureLookup)).toBe(true);
});
it('rejects a base URL the credential does not allow', async () => {
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
};
const ctx = setupContext(
{
baseUrl: 'http://not-allowed.example.com:11434',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'ollama.example.com',
},
egressFilter,
);
await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('Domain not allowed');
expect(MockedChatOllama).not.toHaveBeenCalled();
});
it('rejects a credential restricted from use in this node', async () => {
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
};
const ctx = setupContext(
{
baseUrl: 'http://ollama.example.com:11434',
allowedHttpRequestDomains: 'none',
},
egressFilter,
);
await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('configured to prevent use');
expect(MockedChatOllama).not.toHaveBeenCalled();
});
});

View File

@ -2,7 +2,6 @@ import { ChatOpenAI, type ChatOpenAIFields, type ClientOptions } from '@langchai
import isPlainObject from 'lodash/isPlainObject'; import isPlainObject from 'lodash/isPlainObject';
import pick from 'lodash/pick'; import pick from 'lodash/pick';
import { import {
assertCredentialAllowsUrl,
jsonParse, jsonParse,
NodeConnectionTypes, NodeConnectionTypes,
NodeOperationError, NodeOperationError,
@ -17,6 +16,7 @@ import {
import { wrapChatModelMessageInput } from '@utils/chatModelMessageWrapper'; import { wrapChatModelMessageInput } from '@utils/chatModelMessageWrapper';
import { getCustomCredentialHeader, mergeCustomHeaders } from '@utils/helpers'; import { getCustomCredentialHeader, mergeCustomHeaders } from '@utils/helpers';
import { assertOpenAiCredentialAllowsUrl } from '../../vendors/OpenAi/helpers/credentials';
import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling'; import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling';
import { import {
makeN8nLlmFailedAttemptHandler, makeN8nLlmFailedAttemptHandler,
@ -771,13 +771,7 @@ export class LmChatOpenAi implements INodeType {
}; };
if (options.baseURL) { if (options.baseURL) {
assertCredentialAllowsUrl({ assertOpenAiCredentialAllowsUrl(this.getNode(), credentials, options.baseURL);
node: this.getNode(),
credentialData: credentials,
url: options.baseURL,
pinnedUrl: typeof credentials.url === 'string' ? credentials.url : undefined,
surface: 'OpenAI',
});
configuration.baseURL = options.baseURL; configuration.baseURL = options.baseURL;
} else if (credentials.url) { } else if (credentials.url) {
configuration.baseURL = credentials.url as string; configuration.baseURL = credentials.url as string;

View File

@ -1,5 +1,6 @@
import { proxyFetch } from '@n8n/ai-utilities'; import { proxyFetch } from '@n8n/ai-utilities';
import type { ILoadOptionsFunctions } from 'n8n-workflow'; import type { ILoadOptionsFunctions, INode } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import type { Mocked } from 'vitest'; import type { Mocked } from 'vitest';
import { searchModels } from '../loadModels'; import { searchModels } from '../loadModels';
@ -33,6 +34,7 @@ const OFFICIAL_API_RESULTS = [
describe('searchModels', () => { describe('searchModels', () => {
let mockContext: Mocked<ILoadOptionsFunctions>; let mockContext: Mocked<ILoadOptionsFunctions>;
let fetchSpy: ReturnType<typeof vi.fn>; let fetchSpy: ReturnType<typeof vi.fn>;
const secureLookup = vi.fn();
beforeEach(() => { beforeEach(() => {
mockContext = { mockContext = {
@ -40,6 +42,20 @@ describe('searchModels', () => {
apiKey: 'test-api-key', apiKey: 'test-api-key',
}), }),
getNodeParameter: vi.fn().mockReturnValue(''), getNodeParameter: vi.fn().mockReturnValue(''),
getNode: vi.fn().mockReturnValue({
id: '1',
name: 'Test Node',
type: 'test',
typeVersion: 1,
position: [0, 0],
parameters: {},
} as INode),
helpers: {
getSecureEgressFilter: vi.fn().mockReturnValue({
validateUrl: vi.fn(),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
}),
},
} as unknown as Mocked<ILoadOptionsFunctions>; } as unknown as Mocked<ILoadOptionsFunctions>;
fetchSpy = vi.fn().mockResolvedValue({ fetchSpy = vi.fn().mockResolvedValue({
@ -61,12 +77,13 @@ describe('searchModels', () => {
it('should return filtered models if custom API endpoint is not provided', async () => { it('should return filtered models if custom API endpoint is not provided', async () => {
const result = await searchModels.call(mockContext); const result = await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith( expect(fetchSpy).toHaveBeenCalledWith({
'https://api.openai.com/v1/models', input: 'https://api.openai.com/v1/models',
expect.objectContaining({ init: expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer test-api-key' }), headers: expect.objectContaining({ Authorization: 'Bearer test-api-key' }),
}), }),
); lookup: secureLookup,
});
expect(result.results).toEqual(OFFICIAL_API_RESULTS); expect(result.results).toEqual(OFFICIAL_API_RESULTS);
}); });
@ -78,7 +95,9 @@ describe('searchModels', () => {
await searchModels.call(mockContext); await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith('https://test-url.com/models', expect.anything()); expect(fetchSpy).toHaveBeenCalledWith(
expect.objectContaining({ input: 'https://test-url.com/models' }),
);
}); });
it('should use default OpenAI URL if no custom URL provided', async () => { it('should use default OpenAI URL if no custom URL provided', async () => {
@ -88,7 +107,9 @@ describe('searchModels', () => {
await searchModels.call(mockContext); await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith('https://api.openai.com/v1/models', expect.anything()); expect(fetchSpy).toHaveBeenCalledWith(
expect.objectContaining({ input: 'https://api.openai.com/v1/models' }),
);
}); });
it('should include all models for custom API endpoints', async () => { it('should include all models for custom API endpoints', async () => {
@ -96,7 +117,9 @@ describe('searchModels', () => {
const result = await searchModels.call(mockContext); const result = await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith('https://custom-api.com/models', expect.anything()); expect(fetchSpy).toHaveBeenCalledWith(
expect.objectContaining({ input: 'https://custom-api.com/models' }),
);
expect(result.results).toEqual([ expect(result.results).toEqual([
{ name: 'computer-use-preview', value: 'computer-use-preview' }, { name: 'computer-use-preview', value: 'computer-use-preview' },
{ name: 'davinci-instruct-beta', value: 'davinci-instruct-beta' }, { name: 'davinci-instruct-beta', value: 'davinci-instruct-beta' },
@ -144,6 +167,75 @@ describe('searchModels', () => {
]); ]);
}); });
it('should reject a base URL override that the credential does not allow', async () => {
mockContext.getCredentials.mockResolvedValueOnce({
apiKey: 'test-api-key',
url: 'https://api.openai.com/v1',
allowedHttpRequestDomains: 'none',
});
mockContext.getNodeParameter = vi.fn().mockReturnValue('https://custom-api.com/v1');
await expect(searchModels.call(mockContext)).rejects.toThrow(NodeOperationError);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('should allow a base URL override matching the credential URL', async () => {
mockContext.getCredentials.mockResolvedValueOnce({
apiKey: 'test-api-key',
url: 'https://api.openai.com/v1',
allowedHttpRequestDomains: 'none',
});
mockContext.getNodeParameter = vi.fn().mockReturnValue('https://api.openai.com/v1');
await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith(
expect.objectContaining({ input: 'https://api.openai.com/v1/models' }),
);
});
it('should reject a base URL override outside the allowed domains list', async () => {
mockContext.getCredentials.mockResolvedValueOnce({
apiKey: 'test-api-key',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'example.com',
});
mockContext.getNodeParameter = vi.fn().mockReturnValue('https://custom-api.com/v1');
await expect(searchModels.call(mockContext)).rejects.toThrow(NodeOperationError);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('should allow a base URL override within the allowed domains list', async () => {
mockContext.getCredentials.mockResolvedValueOnce({
apiKey: 'test-api-key',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'example.com',
});
mockContext.getNodeParameter = vi.fn().mockReturnValue('https://example.com/v1');
await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith(
expect.objectContaining({ input: 'https://example.com/v1/models' }),
);
});
it('should not restrict the credential URL when no override is set', async () => {
mockContext.getCredentials.mockResolvedValueOnce({
apiKey: 'test-api-key',
url: 'https://my-proxy.internal/v1',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'example.com',
});
await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith(
expect.objectContaining({ input: 'https://my-proxy.internal/v1/models' }),
);
});
it('should include custom credential headers in the request', async () => { it('should include custom credential headers in the request', async () => {
mockContext.getCredentials.mockResolvedValueOnce({ mockContext.getCredentials.mockResolvedValueOnce({
apiKey: 'test-api-key', apiKey: 'test-api-key',
@ -155,9 +247,11 @@ describe('searchModels', () => {
await searchModels.call(mockContext); await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith( expect(fetchSpy).toHaveBeenCalledWith(
'https://api.openai.com/v1/models',
expect.objectContaining({ expect.objectContaining({
headers: expect.objectContaining({ 'X-Custom-Auth': 'custom-value' }), input: 'https://api.openai.com/v1/models',
init: expect.objectContaining({
headers: expect.objectContaining({ 'X-Custom-Auth': 'custom-value' }),
}),
}), }),
); );
}); });

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