diff --git a/.changeset/quiet-sandboxes-clean.md b/.changeset/quiet-sandboxes-clean.md new file mode 100644 index 0000000000..83334e7eb4 --- /dev/null +++ b/.changeset/quiet-sandboxes-clean.md @@ -0,0 +1,6 @@ +--- +"e2b": patch +"@e2b/python-sdk": patch +--- + +Kill newly created sandboxes when MCP gateway startup fails. diff --git a/packages/js-sdk/src/sandbox/index.ts b/packages/js-sdk/src/sandbox/index.ts index 56caea6300..6f79576b2b 100644 --- a/packages/js-sdk/src/sandbox/index.ts +++ b/packages/js-sdk/src/sandbox/index.ts @@ -320,17 +320,22 @@ export class Sandbox extends SandboxApi { if (sandboxOpts?.mcp) { sandbox.mcpToken = crypto.randomUUID() - const res = await sandbox.commands.run( - `mcp-gateway --config ${shellQuote(JSON.stringify(sandboxOpts.mcp))}`, - { - user: 'root', - envs: { - GATEWAY_ACCESS_TOKEN: sandbox.mcpToken ?? '', - }, + try { + const res = await sandbox.commands.run( + `mcp-gateway --config ${shellQuote(JSON.stringify(sandboxOpts.mcp))}`, + { + user: 'root', + envs: { + GATEWAY_ACCESS_TOKEN: sandbox.mcpToken ?? '', + }, + } + ) + if (res.exitCode !== 0) { + throw new Error(`Failed to start MCP gateway: ${res.stderr}`) } - ) - if (res.exitCode !== 0) { - throw new Error(`Failed to start MCP gateway: ${res.stderr}`) + } catch (error) { + await sandbox.kill().catch(() => undefined) + throw error } } diff --git a/packages/js-sdk/tests/sandbox/create.test.ts b/packages/js-sdk/tests/sandbox/create.test.ts index 69251c8a5a..d98c7d616b 100644 --- a/packages/js-sdk/tests/sandbox/create.test.ts +++ b/packages/js-sdk/tests/sandbox/create.test.ts @@ -1,6 +1,6 @@ -import { assert, test } from 'vitest' +import { assert, expect, test } from 'vitest' -import { Sandbox } from '../../src' +import { CommandExitError, Sandbox } from '../../src' import { template, isDebug } from '../setup.js' test.skipIf(isDebug)('create', async () => { @@ -32,3 +32,38 @@ test.skipIf(isDebug)('metadata', async () => { await sbx.kill() } }) + +test.skipIf(isDebug)( + 'MCP gateway start failure kills the created sandbox', + async () => { + const metadata = { mcpGatewayCleanupTestId: crypto.randomUUID() } + const query = { state: ['running' as const], metadata } + let remainingSandboxes: Awaited< + ReturnType['nextItems']> + > = [] + + try { + // The base template has no mcp-gateway binary, so gateway startup + // reliably fails after the sandbox has been allocated. + await expect( + Sandbox.create(template, { + timeoutMs: 60_000, + metadata, + mcp: { invalid_server: {} } as never, + }) + ).rejects.toThrow(CommandExitError) + + remainingSandboxes = await Sandbox.list({ query }).nextItems() + expect(remainingSandboxes).toEqual([]) + } finally { + remainingSandboxes = await Sandbox.list({ query }) + .nextItems() + .catch(() => remainingSandboxes) + await Promise.all( + remainingSandboxes.map((sandbox) => + Sandbox.kill(sandbox.sandboxId).catch(() => false) + ) + ) + } + } +) diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 791b9b360d..ccc90e4b75 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -242,13 +242,20 @@ async def create( token = str(uuid.uuid4()) sandbox._mcp_token = token - res = await sandbox.commands.run( - f"mcp-gateway --config {shlex.quote(json.dumps(mcp))}", - user="root", - envs={"GATEWAY_ACCESS_TOKEN": token}, - ) - if res.exit_code != 0: - raise Exception(f"Failed to start MCP gateway: {res.stderr}") + try: + res = await sandbox.commands.run( + f"mcp-gateway --config {shlex.quote(json.dumps(mcp))}", + user="root", + envs={"GATEWAY_ACCESS_TOKEN": token}, + ) + if res.exit_code != 0: + raise Exception(f"Failed to start MCP gateway: {res.stderr}") + except BaseException: + try: + await sandbox.kill() + except BaseException: + pass + raise return sandbox diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index df73f2b086..36ee20fa85 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -228,13 +228,20 @@ def create( token = str(uuid.uuid4()) sandbox._mcp_token = token - res = sandbox.commands.run( - f"mcp-gateway --config {shlex.quote(json.dumps(mcp))}", - user="root", - envs={"GATEWAY_ACCESS_TOKEN": token}, - ) - if res.exit_code != 0: - raise Exception(f"Failed to start MCP gateway: {res.stderr}") + try: + res = sandbox.commands.run( + f"mcp-gateway --config {shlex.quote(json.dumps(mcp))}", + user="root", + envs={"GATEWAY_ACCESS_TOKEN": token}, + ) + if res.exit_code != 0: + raise Exception(f"Failed to start MCP gateway: {res.stderr}") + except BaseException: + try: + sandbox.kill() + except BaseException: + pass + raise return sandbox diff --git a/packages/python-sdk/tests/async/sandbox_async/test_create.py b/packages/python-sdk/tests/async/sandbox_async/test_create.py index aa17c2c52f..50e94aa1e3 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_create.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_create.py @@ -1,10 +1,11 @@ import asyncio from typing import Any, cast +from uuid import uuid4 import httpx import pytest -from e2b import AsyncSandbox, SandboxQuery, SandboxState +from e2b import AsyncSandbox, CommandExitException, SandboxQuery, SandboxState from e2b.api.client.models import ( NewSandbox, SandboxAutoResumeConfig, @@ -36,6 +37,34 @@ async def test_metadata(async_sandbox_factory): assert False, "Sandbox not found" +@pytest.mark.skip_debug() +async def test_mcp_gateway_start_failure_kills_created_sandbox(template): + metadata = {"mcp_gateway_cleanup_test_id": str(uuid4())} + query = SandboxQuery(state=[SandboxState.RUNNING], metadata=metadata) + remaining_sandboxes = [] + + try: + # The base template has no mcp-gateway binary, so gateway startup + # reliably fails after the sandbox has been allocated. + with pytest.raises(CommandExitException): + await AsyncSandbox.create( + template, + timeout=60, + metadata=metadata, + mcp=cast(Any, {"invalid_server": {}}), + ) + + remaining_sandboxes = await AsyncSandbox.list(query=query).next_items() + assert remaining_sandboxes == [] + finally: + try: + remaining_sandboxes = await AsyncSandbox.list(query=query).next_items() + except Exception: + pass + for sandbox in remaining_sandboxes: + await AsyncSandbox.kill(sandbox.sandbox_id) + + def test_create_payload_serializes_auto_resume_enabled(): body = NewSandbox( template_id="template-id", diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_create.py b/packages/python-sdk/tests/sync/sandbox_sync/test_create.py index 737e346d1a..aeb0776c14 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_create.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_create.py @@ -1,10 +1,11 @@ from time import sleep from typing import Any, cast +from uuid import uuid4 import httpx import pytest -from e2b import Sandbox, SandboxState +from e2b import CommandExitException, Sandbox, SandboxState from e2b.api.client.models import ( NewSandbox, SandboxAutoResumeConfig, @@ -37,6 +38,34 @@ def test_metadata(sandbox_factory): assert False, "Sandbox not found" +@pytest.mark.skip_debug() +def test_mcp_gateway_start_failure_kills_created_sandbox(template): + metadata = {"mcp_gateway_cleanup_test_id": str(uuid4())} + query = SandboxQuery(state=[SandboxState.RUNNING], metadata=metadata) + remaining_sandboxes = [] + + try: + # The base template has no mcp-gateway binary, so gateway startup + # reliably fails after the sandbox has been allocated. + with pytest.raises(CommandExitException): + Sandbox.create( + template, + timeout=60, + metadata=metadata, + mcp=cast(Any, {"invalid_server": {}}), + ) + + remaining_sandboxes = Sandbox.list(query=query).next_items() + assert remaining_sandboxes == [] + finally: + try: + remaining_sandboxes = Sandbox.list(query=query).next_items() + except Exception: + pass + for sandbox in remaining_sandboxes: + Sandbox.kill(sandbox.sandbox_id) + + def test_create_payload_serializes_auto_resume_enabled(): body = NewSandbox( template_id="template-id",