feat(core): Static fallback on dynamic credentials for manual executions (#26406)

This commit is contained in:
Guillaume Jacquart
2026-03-04 10:14:39 +00:00
committed by GitHub
parent 4ffe54ba3b
commit f72dfc7a28
8 changed files with 195 additions and 267 deletions
@@ -78,7 +78,6 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
'isGlobal',
'isResolvable',
'resolverId',
'resolvableAllowFallback',
];
if (!listQueryOptions) {
@@ -329,7 +328,6 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
'isGlobal',
'isResolvable',
'resolverId',
'resolvableAllowFallback',
];
if (options.select) {
@@ -605,7 +605,6 @@ describe('CredentialsHelper', () => {
type: credentialType,
data: cipher.encrypt({ apiKey: 'static-key' }),
isResolvable: false,
resolvableAllowFallback: false,
} as CredentialsEntity;
beforeEach(() => {
@@ -629,7 +628,7 @@ describe('CredentialsHelper', () => {
mockAdditionalData,
nodeCredentials,
credentialType,
'manual',
'trigger',
undefined, // executeData
true, // raw = true to get the resolved data directly
);
@@ -641,7 +640,6 @@ describe('CredentialsHelper', () => {
isResolvable: false,
type: 'testApi',
resolverId: undefined,
resolvableAllowFallback: false,
},
{ apiKey: 'static-key' },
mockAdditionalData.executionContext,
@@ -662,7 +660,7 @@ describe('CredentialsHelper', () => {
mockAdditionalData,
nodeCredentials,
credentialType,
'manual',
'trigger',
undefined,
true,
);
@@ -683,7 +681,7 @@ describe('CredentialsHelper', () => {
mockAdditionalData,
nodeCredentials,
credentialType,
'manual',
'trigger',
undefined,
true,
);
@@ -731,7 +729,7 @@ describe('CredentialsHelper', () => {
mockAdditionalData,
nodeCredentials,
credentialType,
'manual',
'trigger',
undefined,
true,
);
@@ -765,6 +763,28 @@ describe('CredentialsHelper', () => {
expect(result).toEqual({ apiKey: 'static-key' });
});
test('should resolve in manual mode when credentials context is present (test webhook with identity extractor)', async () => {
dynamicCredentialProxy.setResolverProvider(mockCredentialResolutionProvider);
const dynamicData = { apiKey: 'dynamic-key' };
mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue({
data: dynamicData,
isDynamic: true,
});
// mockAdditionalData has credentials context set — simulates a test webhook run
const result = await credentialsHelper.getDecrypted(
mockAdditionalData,
nodeCredentials,
credentialType,
'manual',
undefined,
true,
);
expect(mockCredentialResolutionProvider.resolveIfNeeded).toHaveBeenCalled();
expect(result).toEqual(dynamicData);
});
test('should skip resolution when credentials context is missing', async () => {
dynamicCredentialProxy.setResolverProvider(mockCredentialResolutionProvider);
mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue({
@@ -797,6 +817,33 @@ describe('CredentialsHelper', () => {
expect(result).toEqual({ apiKey: 'static-key' });
});
test('should skip resolution in a subworkflow when rootExecutionMode is manual', async () => {
dynamicCredentialProxy.setResolverProvider(mockCredentialResolutionProvider);
mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue({
data: { apiKey: 'dynamic-key' },
isDynamic: true,
});
// Simulates a subworkflow: local mode is 'integrated' but root was 'manual'
const subworkflowAdditionalData = {
...mockAdditionalData,
executionContext: undefined,
rootExecutionMode: 'manual' as const,
};
const result = await credentialsHelper.getDecrypted(
subworkflowAdditionalData,
nodeCredentials,
credentialType,
'integrated',
undefined,
true,
);
expect(mockCredentialResolutionProvider.resolveIfNeeded).not.toHaveBeenCalled();
expect(result).toEqual({ apiKey: 'static-key' });
});
test('should handle missing workflowSettings gracefully', async () => {
dynamicCredentialProxy.setResolverProvider(mockCredentialResolutionProvider);
mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue({
@@ -813,7 +860,7 @@ describe('CredentialsHelper', () => {
additionalDataWithoutSettings,
nodeCredentials,
credentialType,
'manual',
'trigger',
undefined,
true,
);
+12 -6
View File
@@ -361,11 +361,18 @@ export class CredentialsHelper extends ICredentialsHelper {
// Check if credential can use external secrets for expression resolution
const canUseExternalSecrets = await this.credentialCanUseExternalSecrets(nodeCredentials);
/**
* We skip dynamic credentials resolution when no credentials context is present.
* This helps workflow developers to run workflows with static credentials.
*/
if (additionalData.executionContext?.credentials !== undefined) {
// In manual or internal mode (or when the root execution is manual, e.g. a subworkflow
// called from a manual parent), skip dynamic resolution unless a credentials context is
// present (set by webhook triggers with an identity extractor). Canvas node tests
// have no incoming request, so we fall back to static data for easier developer
// testing. Internal mode is used by OAuth authorize/revoke flows which are not
// actual workflow executions and should not trigger dynamic resolution.
// For all other modes (especially production), always attempt resolution —
// missing credentials will surface an error rather than silently falling back to
// static data.
const effectiveMode = additionalData.rootExecutionMode ?? mode;
const skipDynamicResolution = effectiveMode === 'manual' || effectiveMode === 'internal';
if (additionalData.executionContext?.credentials !== undefined || !skipDynamicResolution) {
// Resolve dynamic credentials if configured (EE feature)
const resolveResult = await this.dynamicCredentialsProxy.resolveIfNeeded(
{
@@ -374,7 +381,6 @@ export class CredentialsHelper extends ICredentialsHelper {
type: credentialsEntity.type,
isResolvable: credentialsEntity.isResolvable,
resolverId: credentialsEntity.resolverId ?? undefined,
resolvableAllowFallback: credentialsEntity.resolvableAllowFallback,
},
decryptedDataOriginal,
additionalData.executionContext,
@@ -10,7 +10,6 @@ export type CredentialResolveMetadata = {
/** Credential type (e.g., 'oAuth2Api') */
type: string;
resolverId?: string;
resolvableAllowFallback?: boolean;
isResolvable: boolean;
};
@@ -45,7 +45,6 @@ describe('DynamicCredentialService', () => {
id: 'cred-123',
name: 'Test Credential',
isResolvable: true,
resolvableAllowFallback: false,
resolverId: 'resolver-456',
...overrides,
}) as CredentialResolveMetadata;
@@ -234,163 +233,11 @@ describe('DynamicCredentialService', () => {
expectStaticResult(result);
expect(mockResolverRepository.findOneBy).not.toHaveBeenCalled();
});
it('resolver entity is not found and fallback is allowed', async () => {
const credentialsEntity = createMockCredentialsMetadata({
resolvableAllowFallback: true,
});
mockResolverRepository.findOneBy.mockResolvedValue(null);
const result = await service.resolveIfNeeded(credentialsEntity, staticData, undefined);
expectStaticResult(result);
expect(mockLogger.debug).toHaveBeenCalledWith(
'Resolver not found, falling back to static credentials',
expect.objectContaining({
credentialId: 'cred-123',
resolverId: 'resolver-456',
}),
);
});
it('resolver instance is not found in registry and fallback is allowed', async () => {
const credentialsEntity = createMockCredentialsMetadata({
resolvableAllowFallback: true,
});
const resolverEntity = createMockResolverEntity();
mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity);
mockResolverRegistry.getResolverByTypename.mockReturnValue(undefined);
const result = await service.resolveIfNeeded(credentialsEntity, staticData, undefined);
expectStaticResult(result);
expect(mockLogger.debug).toHaveBeenCalled();
});
it('execution context is missing and fallback is allowed', async () => {
const credentialsEntity = createMockCredentialsMetadata({
resolvableAllowFallback: true,
});
const resolverEntity = createMockResolverEntity();
const mockResolver = createMockResolver();
mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity);
mockResolverRegistry.getResolverByTypename.mockReturnValue(mockResolver);
const result = await service.resolveIfNeeded(credentialsEntity, staticData, undefined);
expectStaticResult(result);
expect(mockLogger.debug).toHaveBeenCalledWith(
'No execution context available, falling back to static credentials',
expect.objectContaining({
credentialId: 'cred-123',
}),
);
});
it('credential context decryption fails and fallback is allowed', async () => {
const credentialsEntity = createMockCredentialsMetadata({
resolvableAllowFallback: true,
});
const resolverEntity = createMockResolverEntity();
const mockResolver = createMockResolver();
const executionContext = createMockExecutionContext('encrypted-credentials');
const additionalData = createMockAdditionalData('exec-123', {}, executionContext);
mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity);
mockResolverRegistry.getResolverByTypename.mockReturnValue(mockResolver);
mockCipher.decrypt.mockImplementation(() => {
throw new Error('Decryption failed'); // Fails on first call (execution context)
});
const result = await service.resolveIfNeeded(
credentialsEntity,
staticData,
additionalData.executionContext,
undefined,
);
expectStaticResult(result);
expect(mockLogger.error).toHaveBeenCalledWith(
'Failed to decrypt credential context from execution context',
expect.any(Object),
);
});
it('resolver getSecret fails and fallback is allowed', async () => {
const credentialsEntity = createMockCredentialsMetadata({
resolvableAllowFallback: true,
});
const resolverEntity = createMockResolverEntity();
const mockResolver = createMockResolver(false); // Will fail
const executionContext = createMockExecutionContext('encrypted-credentials');
const credentialContext = createMockCredentialContext();
const additionalData = createMockAdditionalData('exec-123', {}, executionContext);
mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity);
mockResolverRegistry.getResolverByTypename.mockReturnValue(mockResolver);
mockCipher.decrypt
.mockReturnValueOnce(JSON.stringify(credentialContext)) // First call: decrypt credential context
.mockReturnValueOnce(JSON.stringify({ prefix: 'test' })); // Second call: decrypt resolver config
const result = await service.resolveIfNeeded(
credentialsEntity,
staticData,
additionalData.executionContext,
undefined,
);
expectStaticResult(result);
expect(mockLogger.debug).toHaveBeenCalledWith(
'Dynamic credential resolution failed, falling back to static',
expect.objectContaining({
credentialId: 'cred-123',
error: 'Resolution failed',
isDataNotFound: false,
}),
);
});
it('resolver throws CredentialResolverDataNotFoundError and fallback is allowed', async () => {
const credentialsEntity = createMockCredentialsMetadata({
resolvableAllowFallback: true,
});
const resolverEntity = createMockResolverEntity();
const mockResolver = createMockResolver(false, true); // Throws DataNotFoundError
const executionContext = createMockExecutionContext('encrypted-credentials');
const credentialContext = createMockCredentialContext();
const additionalData = createMockAdditionalData('exec-123', {}, executionContext);
mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity);
mockResolverRegistry.getResolverByTypename.mockReturnValue(mockResolver);
mockCipher.decrypt
.mockReturnValueOnce(JSON.stringify(credentialContext)) // First call: decrypt execution context
.mockReturnValueOnce(JSON.stringify({ prefix: 'test' })); // Second call: decrypt resolver config
const result = await service.resolveIfNeeded(
credentialsEntity,
staticData,
additionalData.executionContext,
undefined,
);
expectStaticResult(result);
expect(mockLogger.debug).toHaveBeenCalledWith(
'Dynamic credential resolution failed, falling back to static',
expect.objectContaining({
isDataNotFound: true,
}),
);
});
});
describe('should throw error when', () => {
it('resolver entity is not found and fallback is not allowed', async () => {
const credentialsEntity = createMockCredentialsMetadata({
resolvableAllowFallback: false,
});
it('resolver entity is not found', async () => {
const credentialsEntity = createMockCredentialsMetadata();
mockResolverRepository.findOneBy.mockResolvedValue(null);
@@ -434,10 +281,8 @@ describe('DynamicCredentialService', () => {
).rejects.toThrow(CredentialResolutionError);
});
it('resolver instance is not found in registry and fallback is not allowed', async () => {
const credentialsEntity = createMockCredentialsMetadata({
resolvableAllowFallback: false,
});
it('resolver instance is not found in registry', async () => {
const credentialsEntity = createMockCredentialsMetadata();
const resolverEntity = createMockResolverEntity();
mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity);
@@ -448,10 +293,8 @@ describe('DynamicCredentialService', () => {
).rejects.toThrow(CredentialResolutionError);
});
it('execution context is missing and fallback is not allowed', async () => {
const credentialsEntity = createMockCredentialsMetadata({
resolvableAllowFallback: false,
});
it('execution context is missing', async () => {
const credentialsEntity = createMockCredentialsMetadata();
const resolverEntity = createMockResolverEntity();
const mockResolver = createMockResolver();
@@ -469,10 +312,8 @@ describe('DynamicCredentialService', () => {
);
});
it('resolver getSecret fails and fallback is not allowed', async () => {
const credentialsEntity = createMockCredentialsMetadata({
resolvableAllowFallback: false,
});
it('resolver getSecret fails', async () => {
const credentialsEntity = createMockCredentialsMetadata();
const resolverEntity = createMockResolverEntity();
const mockResolver = createMockResolver(false); // Will fail
const executionContext = createMockExecutionContext('encrypted-credentials');
@@ -506,10 +347,64 @@ describe('DynamicCredentialService', () => {
).rejects.toThrow('Failed to resolve dynamic credentials for "Test Credential"');
expect(mockLogger.debug).toHaveBeenCalledWith(
'Dynamic credential resolution failed without fallback',
'Dynamic credential resolution failed',
expect.objectContaining({
credentialId: 'cred-123',
}),
);
});
it('credential context decryption fails', async () => {
const credentialsEntity = createMockCredentialsMetadata();
const resolverEntity = createMockResolverEntity();
const mockResolver = createMockResolver();
const executionContext = createMockExecutionContext('encrypted-credentials');
const additionalData = createMockAdditionalData('exec-123', {}, executionContext);
mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity);
mockResolverRegistry.getResolverByTypename.mockReturnValue(mockResolver);
mockCipher.decrypt.mockImplementation(() => {
throw new Error('Decryption failed');
});
await expect(
service.resolveIfNeeded(
credentialsEntity,
staticData,
additionalData.executionContext,
undefined,
),
).rejects.toThrow(CredentialResolutionError);
expect(mockLogger.error).toHaveBeenCalledWith(
'Failed to decrypt credential context from execution context',
expect.any(Object),
);
});
it('resolver throws CredentialResolverDataNotFoundError', async () => {
const credentialsEntity = createMockCredentialsMetadata();
const resolverEntity = createMockResolverEntity();
const mockResolver = createMockResolver(false, true); // Throws DataNotFoundError
const executionContext = createMockExecutionContext('encrypted-credentials');
const credentialContext = createMockCredentialContext();
const additionalData = createMockAdditionalData('exec-123', {}, executionContext);
mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity);
mockResolverRegistry.getResolverByTypename.mockReturnValue(mockResolver);
mockCipher.decrypt
.mockReturnValueOnce(JSON.stringify(credentialContext))
.mockReturnValueOnce(JSON.stringify({ prefix: 'test' }));
await expect(
service.resolveIfNeeded(
credentialsEntity,
staticData,
additionalData.executionContext,
undefined,
),
).rejects.toThrow(CredentialResolutionError);
});
});
describe('should successfully resolve dynamic credentials when', () => {
@@ -654,10 +549,8 @@ describe('DynamicCredentialService', () => {
});
describe('should handle edge cases', () => {
it('execution context without credentials field', async () => {
const credentialsEntity = createMockCredentialsMetadata({
resolvableAllowFallback: true,
});
it('execution context without credentials field throws', async () => {
const credentialsEntity = createMockCredentialsMetadata();
const resolverEntity = createMockResolverEntity();
const mockResolver = createMockResolver();
const executionContext = createMockExecutionContext(undefined);
@@ -666,21 +559,20 @@ describe('DynamicCredentialService', () => {
mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity);
mockResolverRegistry.getResolverByTypename.mockReturnValue(mockResolver);
const result = await service.resolveIfNeeded(
credentialsEntity,
staticData,
additionalData.executionContext,
undefined,
);
await expect(
service.resolveIfNeeded(
credentialsEntity,
staticData,
additionalData.executionContext,
undefined,
),
).rejects.toThrow(CredentialResolutionError);
expectStaticResult(result);
expect(mockCipher.decrypt).not.toHaveBeenCalled();
});
it('invalid JSON in encrypted credentials', async () => {
const credentialsEntity = createMockCredentialsMetadata({
resolvableAllowFallback: true,
});
it('invalid JSON in encrypted credentials throws', async () => {
const credentialsEntity = createMockCredentialsMetadata();
const resolverEntity = createMockResolverEntity();
const mockResolver = createMockResolver();
const executionContext = createMockExecutionContext('encrypted-credentials');
@@ -690,15 +582,14 @@ describe('DynamicCredentialService', () => {
mockResolverRegistry.getResolverByTypename.mockReturnValue(mockResolver);
mockCipher.decrypt.mockReturnValue('not-valid-json');
const result = await service.resolveIfNeeded(
credentialsEntity,
staticData,
additionalData.executionContext,
undefined,
);
expectStaticResult(result);
expect(mockLogger.debug).toHaveBeenCalled();
await expect(
service.resolveIfNeeded(
credentialsEntity,
staticData,
additionalData.executionContext,
undefined,
),
).rejects.toThrow(CredentialResolutionError);
});
it('empty resolver config', async () => {
@@ -821,10 +712,9 @@ describe('DynamicCredentialService', () => {
);
});
it('should fall back to static when workflow resolver not found and fallback allowed', async () => {
it('should throw when workflow resolver not found', async () => {
const credentialsEntity = createMockCredentialsMetadata({
resolverId: undefined,
resolvableAllowFallback: true,
});
const executionContext = createMockExecutionContext('encrypted-credentials');
const additionalData = {
@@ -836,16 +726,17 @@ describe('DynamicCredentialService', () => {
mockResolverRepository.findOneBy.mockResolvedValue(null);
const result = await service.resolveIfNeeded(
credentialsEntity,
staticData,
additionalData.executionContext,
additionalData.workflowSettings,
);
await expect(
service.resolveIfNeeded(
credentialsEntity,
staticData,
additionalData.executionContext,
additionalData.workflowSettings,
),
).rejects.toThrow(CredentialResolutionError);
expectStaticResult(result);
expect(mockLogger.debug).toHaveBeenCalledWith(
'Resolver not found, falling back to static credentials',
'Resolver not found for dynamic credential',
expect.objectContaining({
resolverId: 'workflow-resolver-789',
resolverSource: 'workflow',
@@ -1,5 +1,4 @@
import { Logger } from '@n8n/backend-common';
import { CredentialResolverDataNotFoundError } from '@n8n/decorators';
import { Service } from '@n8n/di';
import { NextFunction, Response } from 'express';
import { Cipher } from 'n8n-core';
@@ -70,7 +69,7 @@ export class DynamicCredentialService implements ICredentialResolutionProvider {
}
if (!resolverId) {
return this.handleMissingResolver(credentialsResolveMetadata, staticData, resolverId);
return this.handleMissingResolver(credentialsResolveMetadata, resolverId);
}
// Load resolver configuration
@@ -79,21 +78,21 @@ export class DynamicCredentialService implements ICredentialResolutionProvider {
});
if (!resolverEntity) {
return this.handleMissingResolver(credentialsResolveMetadata, staticData, resolverId);
return this.handleMissingResolver(credentialsResolveMetadata, resolverId);
}
// Get resolver instance from registry
const resolver = this.resolverRegistry.getResolverByTypename(resolverEntity.type);
if (!resolver) {
return this.handleMissingResolver(credentialsResolveMetadata, staticData, resolverId);
return this.handleMissingResolver(credentialsResolveMetadata, resolverId);
}
// Build credential context from execution context
const credentialContext = this.buildCredentialContext(executionContext);
if (!credentialContext) {
return this.handleMissingContext(credentialsResolveMetadata, staticData);
return this.handleMissingContext(credentialsResolveMetadata);
}
try {
@@ -141,7 +140,7 @@ export class DynamicCredentialService implements ICredentialResolutionProvider {
// Adds and override static data with dynamically resolved data
return { data: { ...staticData, ...dynamicData }, isDynamic: true };
} catch (error) {
return this.handleResolutionError(credentialsResolveMetadata, staticData, error, resolverId);
return this.handleResolutionError(credentialsResolveMetadata, error, resolverId);
}
}
@@ -166,34 +165,20 @@ export class DynamicCredentialService implements ICredentialResolutionProvider {
}
/**
* Handles resolution errors with fallback logic
* Handles resolution errors by always throwing.
* Dynamic credentials must resolve successfully — no silent fallback to static data.
*/
private handleResolutionError(
credentialsResolveMetadata: CredentialResolveMetadata,
staticData: ICredentialDataDecryptedObject,
error: unknown,
resolverId: string,
): CredentialResolutionResult {
const isDataNotFound = error instanceof CredentialResolverDataNotFoundError;
if (credentialsResolveMetadata.resolvableAllowFallback) {
this.logger.debug('Dynamic credential resolution failed, falling back to static', {
credentialId: credentialsResolveMetadata.id,
credentialName: credentialsResolveMetadata.name,
resolverId,
resolverSource: credentialsResolveMetadata.resolverId ? 'credential' : 'workflow',
error: error instanceof Error ? error.message : String(error),
isDataNotFound,
});
return { data: staticData, isDynamic: false };
}
this.logger.debug('Dynamic credential resolution failed without fallback', {
): never {
this.logger.debug('Dynamic credential resolution failed', {
credentialId: credentialsResolveMetadata.id,
credentialName: credentialsResolveMetadata.name,
resolverId,
resolverSource: credentialsResolveMetadata.resolverId ? 'credential' : 'workflow',
error,
error: error instanceof Error ? error.message : String(error),
});
throw new CredentialResolutionError(
@@ -203,22 +188,19 @@ export class DynamicCredentialService implements ICredentialResolutionProvider {
}
/**
* Handles missing resolver with fallback logic
* Handles missing resolver by always throwing.
* Dynamic credentials must have a valid resolver — no silent fallback to static data.
*/
private handleMissingResolver(
credentialsResolveMetadata: CredentialResolveMetadata,
staticData: ICredentialDataDecryptedObject,
resolverId?: string,
): CredentialResolutionResult {
if (credentialsResolveMetadata.resolvableAllowFallback) {
this.logger.debug('Resolver not found, falling back to static credentials', {
credentialId: credentialsResolveMetadata.id,
credentialName: credentialsResolveMetadata.name,
resolverId,
resolverSource: credentialsResolveMetadata.resolverId ? 'credential' : 'workflow',
});
return { data: staticData, isDynamic: false };
}
): never {
this.logger.debug('Resolver not found for dynamic credential', {
credentialId: credentialsResolveMetadata.id,
credentialName: credentialsResolveMetadata.name,
resolverId,
resolverSource: credentialsResolveMetadata.resolverId ? 'credential' : 'workflow',
});
throw new CredentialResolutionError(
`Resolver "${resolverId ?? 'unknown'}" not found for credential "${credentialsResolveMetadata.name}"`,
@@ -226,19 +208,14 @@ export class DynamicCredentialService implements ICredentialResolutionProvider {
}
/**
* Handles missing execution context with fallback logic
* Handles missing execution context by always throwing.
* Dynamic credentials require an execution context — no silent fallback to static data.
*/
private handleMissingContext(
credentialsResolveMetadata: CredentialResolveMetadata,
staticData: ICredentialDataDecryptedObject,
): CredentialResolutionResult {
if (credentialsResolveMetadata.resolvableAllowFallback) {
this.logger.debug('No execution context available, falling back to static credentials', {
credentialId: credentialsResolveMetadata.id,
credentialName: credentialsResolveMetadata.name,
});
return { data: staticData, isDynamic: false };
}
private handleMissingContext(credentialsResolveMetadata: CredentialResolveMetadata): never {
this.logger.debug('No execution context available for dynamic credential', {
credentialId: credentialsResolveMetadata.id,
credentialName: credentialsResolveMetadata.name,
});
throw new CredentialResolutionError(
`Cannot resolve dynamic credentials without execution context for "${credentialsResolveMetadata.name}"`,
@@ -310,6 +310,10 @@ async function startExecution(
// This one already contains changes to talk to parent process
// and get executionID from `activeExecutions` running on main process
additionalDataIntegrated.executeWorkflow = additionalData.executeWorkflow;
// Propagate the root execution mode so nested subworkflows retain the original
// mode (e.g. 'manual') even though their own WorkflowExecute runs as 'integrated'
additionalDataIntegrated.rootExecutionMode =
additionalData.rootExecutionMode ?? options.executionMode;
if (additionalData.httpResponse) {
additionalDataIntegrated.httpResponse = additionalData.httpResponse;
}
+6
View File
@@ -2919,6 +2919,12 @@ export interface IWorkflowExecuteAdditionalData {
variables: IDataObject;
logAiEvent: (eventName: AiEvent, payload: AiEventPayload) => void;
parentCallbackManager?: CallbackManager;
/**
* The execution mode of the root (top-level) workflow. Used to propagate manual
* mode context into subworkflows so credential resolution can fall back to static
* data consistently across the entire execution tree.
*/
rootExecutionMode?: WorkflowExecuteMode;
startRunnerTask<T, E = unknown>(
additionalData: IWorkflowExecuteAdditionalData,
jobType: string,