From 8d6ab08a7763ff390404de5257235440321d6e56 Mon Sep 17 00:00:00 2001 From: Ondrej Drapalik Date: Wed, 8 Jul 2026 16:34:47 +0200 Subject: [PATCH] feat: get sandbox by metadata filter Add a "Get By" selector to the Sandbox > Get operation so a sandbox can be retrieved either by ID (existing behavior, still the default) or by a metadata filter. The filter is applied server-side via the `metadata` query parameter of `GET /v2/sandboxes` (keys and values URL-encoded, combined with AND), so it no longer requires listing all sandboxes and filtering in a Code node. The matched sandbox is then fetched by ID so the output shape is identical to Get by ID. listSandboxes now takes an options object ({ limit, metadata }) to make room for the new query parameter. --- nodes/E2B/E2b.node.ts | 68 ++++++++++++++++++-- nodes/E2B/actions/sandbox.operations.ts | 43 ++++++++++--- nodes/E2B/client.ts | 20 +++++- nodes/E2B/test/E2b.node.test.ts | 83 +++++++++++++++++++++++++ 4 files changed, 199 insertions(+), 15 deletions(-) diff --git a/nodes/E2B/E2b.node.ts b/nodes/E2B/E2b.node.ts index 89567e5..ad8ec64 100644 --- a/nodes/E2B/E2b.node.ts +++ b/nodes/E2B/E2b.node.ts @@ -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', @@ -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', diff --git a/nodes/E2B/actions/sandbox.operations.ts b/nodes/E2B/actions/sandbox.operations.ts index 3bd5895..9bd3810 100644 --- a/nodes/E2B/actions/sandbox.operations.ts +++ b/nodes/E2B/actions/sandbox.operations.ts @@ -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'; @@ -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 [ @@ -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), diff --git a/nodes/E2B/client.ts b/nodes/E2B/client.ts index 0570e6c..d834515 100644 --- a/nodes/E2B/client.ts +++ b/nodes/E2B/client.ts @@ -34,6 +34,11 @@ export interface SandboxCreateOptions { timeoutMs: number; } +export interface SandboxListOptions { + limit?: number; + metadata?: Record; +} + export interface SandboxInfo { sandboxId: string; templateId: string; @@ -317,8 +322,19 @@ export async function getSandboxInfo(connection: E2BConnection, sandboxId: strin return mapSandboxInfo(response); } -export async function listSandboxes(connection: E2BConnection, limit: number): Promise { - const response = await apiRequest(connection, 'GET', '/v2/sandboxes', { qs: { limit } }); +export async function listSandboxes( + connection: E2BConnection, + options: SandboxListOptions = {}, +): Promise { + 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(connection, 'GET', '/v2/sandboxes', { qs }); return Array.isArray(response) ? response.map(mapSandboxInfo) : []; } diff --git a/nodes/E2B/test/E2b.node.test.ts b/nodes/E2B/test/E2b.node.test.ts index d7558a5..c491ea1 100644 --- a/nodes/E2B/test/E2b.node.test.ts +++ b/nodes/E2B/test/E2b.node.test.ts @@ -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);