fix(core): Fix daytona proxy bug (#27974)
Some checks failed
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (22.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.14.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (25.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
Build: Benchmark Image / build (push) Has been cancelled
Util: Sync API Docs / sync-public-api (push) Has been cancelled
Release: Schedule Patch Release PRs / Create patch release PR (${{ matrix.track }}) (beta) (push) Has been cancelled
Release: Schedule Patch Release PRs / Create patch release PR (${{ matrix.track }}) (stable) (push) Has been cancelled
Release: Schedule Patch Release PRs / Create patch release PR (${{ matrix.track }}) (v1) (push) Has been cancelled

Co-authored-by: Mutasem Aldmour <mutasem@n8n.io>
This commit is contained in:
Jaakko Husso 2026-04-02 17:55:53 +03:00 committed by GitHub
parent 663f2c5086
commit c754724caf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 46 additions and 19 deletions

View File

@ -104,15 +104,15 @@ describe('createSandbox', () => {
process.env.NODE_ENV = originalEnv;
});
it('should return undefined when sandbox is disabled', () => {
it('should return undefined when sandbox is disabled', async () => {
const config: SandboxConfig = { enabled: false, provider: 'local' };
const result = createSandbox(config);
const result = await createSandbox(config);
expect(result).toBeUndefined();
});
it('should return a DaytonaSandbox for "daytona" provider', () => {
it('should return a DaytonaSandbox for "daytona" provider', async () => {
const config: SandboxConfig = {
enabled: true,
provider: 'daytona',
@ -122,7 +122,7 @@ describe('createSandbox', () => {
timeout: 60_000,
};
const result = createSandbox(config);
const result = await createSandbox(config);
expect(result).toBeInstanceOf(DaytonaSandbox);
expect((result as unknown as MockWithOpts<Record<string, unknown>>).opts).toEqual(
@ -136,25 +136,47 @@ describe('createSandbox', () => {
);
});
it('should use default timeout of 300_000 for "daytona" provider when not specified', () => {
it('should resolve apiKey via getAuthToken in proxy mode', async () => {
const getAuthToken = jest.fn().mockResolvedValue('jwt-token-123');
const config: SandboxConfig = {
enabled: true,
provider: 'daytona',
daytonaApiUrl: 'https://proxy.example.com',
getAuthToken,
timeout: 60_000,
};
const result = await createSandbox(config);
expect(getAuthToken).toHaveBeenCalledTimes(1);
expect(result).toBeInstanceOf(DaytonaSandbox);
expect((result as unknown as MockWithOpts<Record<string, unknown>>).opts).toEqual(
expect.objectContaining({
apiKey: 'jwt-token-123',
apiUrl: 'https://proxy.example.com',
}),
);
});
it('should use default timeout of 300_000 for "daytona" provider when not specified', async () => {
const config: SandboxConfig = {
enabled: true,
provider: 'daytona',
};
const result = createSandbox(config);
const result = await createSandbox(config);
expect(result).toBeInstanceOf(DaytonaSandbox);
expect((result as unknown as MockWithOpts<Record<string, unknown>>).opts.timeout).toBe(300_000);
});
it('should not include image in DaytonaSandbox config when not specified', () => {
it('should not include image in DaytonaSandbox config when not specified', async () => {
const config: SandboxConfig = {
enabled: true,
provider: 'daytona',
};
const result = createSandbox(config);
const result = await createSandbox(config);
expect(result).toBeInstanceOf(DaytonaSandbox);
expect((result as unknown as MockWithOpts<Record<string, unknown>>).opts).not.toHaveProperty(
@ -162,11 +184,11 @@ describe('createSandbox', () => {
);
});
it('should return a LocalSandbox for "local" provider in non-production', () => {
it('should return a LocalSandbox for "local" provider in non-production', async () => {
process.env.NODE_ENV = 'development';
const config: SandboxConfig = { enabled: true, provider: 'local' };
const result = createSandbox(config);
const result = await createSandbox(config);
expect(result).toBeInstanceOf(LocalSandbox);
expect((result as unknown as MockWithOpts<{ workingDirectory: string }>).opts).toEqual({
@ -174,16 +196,16 @@ describe('createSandbox', () => {
});
});
it('should throw in production when provider is "local"', () => {
it('should throw in production when provider is "local"', async () => {
process.env.NODE_ENV = 'production';
const config: SandboxConfig = { enabled: true, provider: 'local' };
expect(() => createSandbox(config)).toThrow(
await expect(createSandbox(config)).rejects.toThrow(
'LocalSandbox (provider: "local") is not allowed in production. Use "daytona" provider for isolated sandbox execution.',
);
});
it('should return an N8nSandboxServiceSandbox for "n8n-sandbox" provider', () => {
it('should return an N8nSandboxServiceSandbox for "n8n-sandbox" provider', async () => {
const config: SandboxConfig = {
enabled: true,
provider: 'n8n-sandbox',
@ -192,7 +214,7 @@ describe('createSandbox', () => {
timeout: 45_000,
};
const result = createSandbox(config);
const result = await createSandbox(config);
expect(result).toBeInstanceOf(N8nSandboxServiceSandbox);
expect((result as unknown as MockWithOpts<Record<string, unknown>>).opts).toEqual({

View File

@ -51,14 +51,16 @@ export type SandboxConfig =
* - 'daytona': Isolated Docker container via Daytona API (production)
* - 'local': Direct host execution via LocalSandbox (development only, no isolation)
*/
export function createSandbox(
export async function createSandbox(
config: SandboxConfig,
): DaytonaSandbox | LocalSandbox | N8nSandboxServiceSandbox | undefined {
): Promise<DaytonaSandbox | LocalSandbox | N8nSandboxServiceSandbox | undefined> {
if (!config.enabled) return undefined;
if (config.provider === 'daytona') {
// In proxy mode, resolve a fresh token via getAuthToken; in direct mode use the static key.
const apiKey = config.getAuthToken ? await config.getAuthToken() : config.daytonaApiKey;
return new DaytonaSandbox({
apiKey: config.daytonaApiKey,
apiKey,
apiUrl: config.daytonaApiUrl,
...(config.image ? { image: config.image } : {}),
language: 'typescript',

View File

@ -123,7 +123,10 @@ export class InstanceAiService {
/** Active sandboxes keyed by thread ID — persisted across messages within a conversation. */
private readonly sandboxes = new Map<
string,
{ sandbox: ReturnType<typeof createSandbox>; workspace: ReturnType<typeof createWorkspace> }
{
sandbox: Awaited<ReturnType<typeof createSandbox>>;
workspace: ReturnType<typeof createWorkspace>;
}
>();
/** Singleton local filesystem provider — created lazily when filesystem config is enabled. */
@ -318,7 +321,7 @@ export class InstanceAiService {
const config = await this.resolveSandboxConfig(user);
if (!config.enabled) return undefined;
const sandbox = createSandbox(config);
const sandbox = await createSandbox(config);
const workspace = createWorkspace(sandbox);
if (!sandbox || !workspace) return undefined;