Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 62 additions & 6 deletions nodes/E2B/E2b.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ export class E2b implements INodeType {
name: 'Get',
value: 'get',
action: 'Get a sandbox',
description: 'Retrieve a sandbox by ID',
description: 'Retrieve a sandbox by ID or by metadata filter',
},
{
name: 'Get Many',
Expand Down Expand Up @@ -333,19 +333,75 @@ export class E2b implements INodeType {
],
default: 'getMany',
},
{
displayName: 'Get By',
name: 'getBy',
type: 'options',
noDataExpression: true,
options: [
{
name: 'ID',
value: 'id',
description: 'Retrieve a sandbox by its ID',
},
{
name: 'Metadata',
value: 'metadata',
description: 'Retrieve the first sandbox matching a metadata filter',
},
],
default: 'id',
displayOptions: {
show: {
resource: ['sandbox'],
operation: ['get'],
},
},
},
{
displayName: 'Sandbox ID',
name: 'sandboxId',
type: 'string',
required: true,
default: '',
displayOptions: {
show: {
resource: ['sandbox'],
operation: ['get', 'getPreviewUrl', 'kill', 'pause'],
},
displayOptions: {
show: {
resource: ['sandbox'],
operation: ['get'],
getBy: ['id'],
},
},
},
{
displayName: 'Metadata Filter',
name: 'filterMetadataJson',
type: 'json',
required: true,
default: '{}',
placeholder: '{ "purpose": "my-agent" }',
description:
'Metadata key-value pairs the sandbox must match (combined with AND). The filter is applied server-side.',
displayOptions: {
show: {
resource: ['sandbox'],
operation: ['get'],
getBy: ['metadata'],
},
},
},
{
displayName: 'Sandbox ID',
name: 'sandboxId',
type: 'string',
required: true,
default: '',
displayOptions: {
show: {
resource: ['sandbox'],
operation: ['getPreviewUrl', 'kill', 'pause'],
},
},
},
{
displayName: 'Sandbox ID',
name: 'sandboxId',
Expand Down
43 changes: 36 additions & 7 deletions nodes/E2B/actions/sandbox.operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ import {
listSandboxes,
pauseSandbox,
} from '../client';
import { NodeOperationError } from 'n8n-workflow';

import {
getLimit,
getPort,
getRequiredStringParameter,
getSandboxCreateOptions,
parseStringMapParameter,
toSandboxInfoData,
} from '../helpers';
import type { E2BOperationContext } from '../types';
Expand All @@ -36,12 +39,36 @@ export async function create(context: E2BOperationContext) {
export async function get(context: E2BOperationContext) {
const { executeFunctions, credentials, itemIndex, timeoutMs } = context;
const connection = { executeFunctions, credentials, timeoutMs };
const sandboxId = getRequiredStringParameter(
executeFunctions,
'sandboxId',
'Sandbox ID',
itemIndex,
);
const getBy = executeFunctions.getNodeParameter('getBy', itemIndex, 'id') as string;

let sandboxId: string;
if (getBy === 'metadata') {
const metadata = parseStringMapParameter(
executeFunctions,
executeFunctions.getNodeParameter('filterMetadataJson', itemIndex, ''),
'Metadata Filter',
itemIndex,
);
if (!metadata) {
throw new NodeOperationError(
executeFunctions.getNode(),
'Metadata Filter must contain at least one key-value pair',
{ itemIndex },
);
}
const matches = await listSandboxes(connection, { metadata, limit: 1 });
if (matches.length === 0) {
throw new NodeOperationError(
executeFunctions.getNode(),
'No sandbox found matching the metadata filter',
{ itemIndex },
);
}
sandboxId = matches[0].sandboxId;
} else {
sandboxId = getRequiredStringParameter(executeFunctions, 'sandboxId', 'Sandbox ID', itemIndex);
}

const info = await getSandboxInfo(connection, sandboxId);

return [
Expand All @@ -55,7 +82,9 @@ export async function get(context: E2BOperationContext) {
export async function getMany(context: E2BOperationContext) {
const { executeFunctions, credentials, itemIndex, timeoutMs } = context;
const connection = { executeFunctions, credentials, timeoutMs };
const sandboxes = await listSandboxes(connection, getLimit(executeFunctions, itemIndex));
const sandboxes = await listSandboxes(connection, {
limit: getLimit(executeFunctions, itemIndex),
});

return sandboxes.map((sandbox) => ({
json: toSandboxInfoData(sandbox),
Expand Down
20 changes: 18 additions & 2 deletions nodes/E2B/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export interface SandboxCreateOptions {
timeoutMs: number;
}

export interface SandboxListOptions {
limit?: number;
metadata?: Record<string, string>;
}

export interface SandboxInfo {
sandboxId: string;
templateId: string;
Expand Down Expand Up @@ -317,8 +322,19 @@ export async function getSandboxInfo(connection: E2BConnection, sandboxId: strin
return mapSandboxInfo(response);
}

export async function listSandboxes(connection: E2BConnection, limit: number): Promise<SandboxInfo[]> {
const response = await apiRequest<unknown>(connection, 'GET', '/v2/sandboxes', { qs: { limit } });
export async function listSandboxes(
connection: E2BConnection,
options: SandboxListOptions = {},
): Promise<SandboxInfo[]> {
const qs: IDataObject = {};
if (options.limit !== undefined) qs.limit = options.limit;
if (options.metadata && Object.keys(options.metadata).length > 0) {
// The API expects a single query string of URL-encoded key=value pairs, combined with AND.
qs.metadata = Object.entries(options.metadata)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
}
const response = await apiRequest<unknown>(connection, 'GET', '/v2/sandboxes', { qs });
return Array.isArray(response) ? response.map(mapSandboxInfo) : [];
}

Expand Down
83 changes: 83 additions & 0 deletions nodes/E2B/test/E2b.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,89 @@ describe('E2B node', () => {
});
});

it('gets a sandbox by ID', async () => {
e2bClient.getSandboxInfo.mockResolvedValue(e2bClient.makeSandboxInfo('sb-by-id'));
const executeFunctions = setupExecuteFunctions({
resource: 'sandbox',
operation: 'get',
getBy: 'id',
sandboxId: 'sb-by-id',
timeoutSeconds: 120,
});

const result = await new E2b().execute.call(executeFunctions);

expect(e2bClient.listSandboxes).not.toHaveBeenCalled();
expect(e2bClient.getSandboxInfo).toHaveBeenCalledWith(expect.any(Object), 'sb-by-id');
expect(result[0]?.[0]?.json).toMatchObject({ sandboxId: 'sb-by-id' });
});

it('gets a sandbox by metadata filter', async () => {
e2bClient.listSandboxes.mockResolvedValue([e2bClient.makeSandboxInfo('sb-by-metadata')]);
e2bClient.getSandboxInfo.mockResolvedValue(e2bClient.makeSandboxInfo('sb-by-metadata'));
const executeFunctions = setupExecuteFunctions({
resource: 'sandbox',
operation: 'get',
getBy: 'metadata',
filterMetadataJson: '{"purpose":"n8n-agent-persist"}',
timeoutSeconds: 120,
});

const result = await new E2b().execute.call(executeFunctions);

expect(e2bClient.listSandboxes).toHaveBeenCalledWith(expect.any(Object), {
metadata: { purpose: 'n8n-agent-persist' },
limit: 1,
});
expect(e2bClient.getSandboxInfo).toHaveBeenCalledWith(expect.any(Object), 'sb-by-metadata');
expect(result[0]?.[0]?.json).toMatchObject({ sandboxId: 'sb-by-metadata' });
});

it('throws when no sandbox matches the metadata filter', async () => {
e2bClient.listSandboxes.mockResolvedValue([]);
const executeFunctions = setupExecuteFunctions({
resource: 'sandbox',
operation: 'get',
getBy: 'metadata',
filterMetadataJson: '{"purpose":"missing"}',
timeoutSeconds: 120,
});

await expect(new E2b().execute.call(executeFunctions)).rejects.toThrow(
/no sandbox found matching/i,
);
});

it('throws when the metadata filter is empty', async () => {
const executeFunctions = setupExecuteFunctions({
resource: 'sandbox',
operation: 'get',
getBy: 'metadata',
filterMetadataJson: '{}',
timeoutSeconds: 120,
});

await expect(new E2b().execute.call(executeFunctions)).rejects.toThrow(
/at least one key-value pair/i,
);
expect(e2bClient.listSandboxes).not.toHaveBeenCalled();
});

it('lists sandboxes with a limit', async () => {
e2bClient.listSandboxes.mockResolvedValue([e2bClient.makeSandboxInfo('sb-listed')]);
const executeFunctions = setupExecuteFunctions({
resource: 'sandbox',
operation: 'getMany',
limit: 25,
timeoutSeconds: 120,
});

const result = await new E2b().execute.call(executeFunctions);

expect(e2bClient.listSandboxes).toHaveBeenCalledWith(expect.any(Object), { limit: 25 });
expect(result[0]?.[0]?.json).toMatchObject({ sandboxId: 'sb-listed' });
});

it('writes text content to a sandbox file', async () => {
const sandbox = e2bClient.makeSandbox('sb-files');
e2bClient.connectSandbox.mockResolvedValue(sandbox);
Expand Down
Loading