diff --git a/.changeset/quiet-sandboxes-clean.md b/.changeset/quiet-sandboxes-clean.md new file mode 100644 index 0000000000..f92bfb3eac --- /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. The failure now surfaces as `SandboxError` (JS) / `SandboxException` (Python) with a `Failed to start MCP gateway: ` message instead of a bare command exit error. diff --git a/packages/js-sdk/src/sandbox/index.ts b/packages/js-sdk/src/sandbox/index.ts index 56caea6300..baa9021168 100644 --- a/packages/js-sdk/src/sandbox/index.ts +++ b/packages/js-sdk/src/sandbox/index.ts @@ -11,6 +11,7 @@ import { EnvdApiClient, handleEnvdApiError } from '../envd/api' import { createEnvdFetch, createEnvdRpcFetch } from '../envd/http2' import { createRpcLogger } from '../logs' import { Commands, Pty } from './commands' +import { CommandExitError } from './commands/commandHandle' import { Filesystem } from './filesystem' import { Git } from './git' import { @@ -29,7 +30,7 @@ import { } from './sandboxApi' import { getSignature } from './signature' import { compareVersions } from 'compare-versions' -import { InvalidArgumentError, TemplateError } from '../errors' +import { InvalidArgumentError, SandboxError, TemplateError } from '../errors' import { ENVD_DEBUG_FALLBACK, ENVD_DEFAULT_USER } from '../envd/versions' import { shellQuote } from '../utils' @@ -320,17 +321,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 { + await sandbox.commands.run( + `mcp-gateway --config ${shellQuote(JSON.stringify(sandboxOpts.mcp))}`, + { + user: 'root', + envs: { + GATEWAY_ACCESS_TOKEN: sandbox.mcpToken ?? '', + }, + } + ) + } catch (error) { + await sandbox.kill().catch(() => undefined) + if (error instanceof CommandExitError) { + throw new SandboxError(`Failed to start MCP gateway: ${error.stderr}`) } - ) - if (res.exitCode !== 0) { - throw new Error(`Failed to start MCP gateway: ${res.stderr}`) + throw error } } diff --git a/packages/js-sdk/tests/sandbox/create.test.ts b/packages/js-sdk/tests/sandbox/create.test.ts index 69251c8a5a..20d491aed3 100644 --- a/packages/js-sdk/tests/sandbox/create.test.ts +++ b/packages/js-sdk/tests/sandbox/create.test.ts @@ -1,4 +1,4 @@ -import { assert, test } from 'vitest' +import { assert, expect, test } from 'vitest' import { Sandbox } from '../../src' import { template, isDebug } from '../setup.js' @@ -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('Failed to start MCP gateway') + + 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..c8b537f350 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -1,3 +1,4 @@ +import asyncio import datetime import json import logging @@ -16,9 +17,11 @@ from e2b.envd.api import ENVD_API_HEALTH_ROUTE, ahandle_envd_api_exception from e2b.envd.versions import ENVD_DEBUG_FALLBACK from e2b.exceptions import ( + SandboxException, TemplateException, format_request_timeout_error, ) +from e2b.sandbox.commands.command_handle import CommandExitException from e2b.sandbox.main import SandboxOpts from e2b.sandbox.sandbox_api import ( McpServer, @@ -242,13 +245,24 @@ 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: + await sandbox.commands.run( + f"mcp-gateway --config {shlex.quote(json.dumps(mcp))}", + user="root", + envs={"GATEWAY_ACCESS_TOKEN": token}, + ) + except BaseException as e: + try: + await sandbox.kill() + except asyncio.CancelledError: + raise + except Exception: + pass + if isinstance(e, CommandExitException): + raise SandboxException( + f"Failed to start MCP gateway: {e.stderr}" + ) from e + 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..8530457c59 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -14,9 +14,11 @@ from e2b.envd.api import ENVD_API_HEALTH_ROUTE, handle_envd_api_exception from e2b.envd.versions import ENVD_DEBUG_FALLBACK from e2b.exceptions import ( + SandboxException, TemplateException, format_request_timeout_error, ) +from e2b.sandbox.commands.command_handle import CommandExitException from e2b.sandbox.main import SandboxOpts from e2b.sandbox.sandbox_api import ( McpServer, @@ -228,13 +230,22 @@ 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: + sandbox.commands.run( + f"mcp-gateway --config {shlex.quote(json.dumps(mcp))}", + user="root", + envs={"GATEWAY_ACCESS_TOKEN": token}, + ) + except BaseException as e: + try: + sandbox.kill() + except Exception: + pass + if isinstance(e, CommandExitException): + raise SandboxException( + f"Failed to start MCP gateway: {e.stderr}" + ) from e + 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..9082adac36 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, SandboxException, 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(SandboxException, match="Failed to start MCP gateway"): + 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..03412141fe 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 Sandbox, SandboxException, 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(SandboxException, match="Failed to start MCP gateway"): + 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",