From bf33da0f6919c1aec791c3c23a3094746e50a8ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Jan 2026 19:12:40 +0000 Subject: [PATCH] feat(sdk): add Docker backend support to TypeScript and Python SDKs This commit adds Docker backend support to both the TypeScript and Python SDKs: TypeScript SDK: - Add BackendType type for backend selection (wasmer, firecracker, docker, auto) - Add DockerOptions interface for Docker-specific configuration - Add backend option to BashletOptions and ExecOptions - Update client to pass --backend flag to CLI - Export new types from index - Add tests for Docker backend functionality Python SDK: - Add BackendType enum for backend selection - Add DockerOptions dataclass for Docker-specific configuration - Add backend option to BashletOptions and ExecOptions - Update both sync and async clients to support backend parameter - Support both string and enum values for backend - Export new types from __init__.py - Add tests for Docker backend functionality Users can now specify the Docker backend when executing commands: TypeScript: const bashlet = new Bashlet({ backend: 'docker' }); await bashlet.exec('ls', { backend: 'docker' }); Python: bashlet = Bashlet(backend='docker') bashlet.exec('ls', backend=BackendType.DOCKER) --- packages/python-sdk/src/bashlet/__init__.py | 4 ++ .../python-sdk/src/bashlet/async_client.py | 27 +++++++ packages/python-sdk/src/bashlet/client.py | 27 +++++++ packages/python-sdk/src/bashlet/types.py | 30 ++++++++ .../python-sdk/tests/test_async_client.py | 46 +++++++++++- packages/python-sdk/tests/test_client.py | 70 ++++++++++++++++++- packages/typescript-sdk/src/client.test.ts | 54 ++++++++++++++ packages/typescript-sdk/src/client.ts | 6 ++ packages/typescript-sdk/src/index.ts | 2 + packages/typescript-sdk/src/types.ts | 21 ++++++ 10 files changed, 285 insertions(+), 2 deletions(-) diff --git a/packages/python-sdk/src/bashlet/__init__.py b/packages/python-sdk/src/bashlet/__init__.py index a03dcdd..952858b 100644 --- a/packages/python-sdk/src/bashlet/__init__.py +++ b/packages/python-sdk/src/bashlet/__init__.py @@ -35,9 +35,11 @@ TimeoutError, ) from .types import ( + BackendType, BashletOptions, CommandResult, CreateSessionOptions, + DockerOptions, EnvVar, ExecOptions, Mount, @@ -52,8 +54,10 @@ "Bashlet", "AsyncBashlet", # Types + "BackendType", "BashletOptions", "CreateSessionOptions", + "DockerOptions", "ExecOptions", "CommandResult", "Session", diff --git a/packages/python-sdk/src/bashlet/async_client.py b/packages/python-sdk/src/bashlet/async_client.py index 3612aa7..7f585da 100644 --- a/packages/python-sdk/src/bashlet/async_client.py +++ b/packages/python-sdk/src/bashlet/async_client.py @@ -15,6 +15,7 @@ TimeoutError, ) from .types import ( + BackendType, BashletOptions, CommandResult, CreateSessionOptions, @@ -59,6 +60,7 @@ def __init__( workdir: str | None = None, timeout: int = 300, config_path: str | None = None, + backend: BackendType | str | None = None, ) -> None: """ Initialize an AsyncBashlet client. @@ -71,9 +73,18 @@ def __init__( workdir: Default working directory inside sandbox timeout: Command timeout in seconds (default: 300) config_path: Path to config file + backend: Sandbox backend to use (wasmer, firecracker, docker, auto) """ from .types import EnvVar + # Convert string backend to BackendType enum + backend_type: BackendType | None = None + if backend is not None: + if isinstance(backend, str): + backend_type = BackendType(backend) + else: + backend_type = backend + self._options = BashletOptions( binary_path=binary_path, preset=preset, @@ -84,6 +95,7 @@ def __init__( workdir=workdir, timeout=timeout, config_path=config_path, + backend=backend_type, ) # ========================================================================= @@ -99,6 +111,7 @@ async def exec( env_vars: list[tuple[str, str]] | None = None, workdir: str | None = None, timeout: int | None = None, + backend: BackendType | str | None = None, ) -> CommandResult: """ Execute a one-shot command in an isolated sandbox. @@ -110,6 +123,7 @@ async def exec( env_vars: Environment variables as (key, value) tuples workdir: Working directory inside sandbox timeout: Command timeout in seconds + backend: Sandbox backend to use (wasmer, firecracker, docker, auto) Returns: CommandResult with stdout, stderr, and exit code @@ -118,6 +132,14 @@ async def exec( >>> result = await bashlet.exec('echo "Hello World"') >>> print(result.stdout) # "Hello World\\n" """ + # Convert string backend to BackendType enum + backend_type: BackendType | None = None + if backend is not None: + if isinstance(backend, str): + backend_type = BackendType(backend) + else: + backend_type = backend + options = self._merge_options( ExecOptions( preset=preset, @@ -127,6 +149,7 @@ async def exec( env_vars=[], workdir=workdir, timeout=timeout, + backend=backend_type, ), env_vars, ) @@ -455,6 +478,7 @@ def _merge_options( ], workdir=options.workdir or self._options.workdir, timeout=options.timeout or self._options.timeout, + backend=options.backend or self._options.backend, ) async def _run_command( @@ -527,6 +551,9 @@ def _build_exec_args(self, command: str, options: ExecOptions) -> list[str]: if options.preset: args.extend(["--preset", options.preset]) + if options.backend: + args.extend(["--backend", options.backend.value]) + for mount in options.mounts: args.extend(["--mount", mount.to_cli_arg()]) diff --git a/packages/python-sdk/src/bashlet/client.py b/packages/python-sdk/src/bashlet/client.py index 29d7100..e38dc46 100644 --- a/packages/python-sdk/src/bashlet/client.py +++ b/packages/python-sdk/src/bashlet/client.py @@ -15,6 +15,7 @@ TimeoutError, ) from .types import ( + BackendType, BashletOptions, CommandResult, CreateSessionOptions, @@ -59,6 +60,7 @@ def __init__( workdir: str | None = None, timeout: int = 300, config_path: str | None = None, + backend: BackendType | str | None = None, ) -> None: """ Initialize a Bashlet client. @@ -71,9 +73,18 @@ def __init__( workdir: Default working directory inside sandbox timeout: Command timeout in seconds (default: 300) config_path: Path to config file + backend: Sandbox backend to use (wasmer, firecracker, docker, auto) """ from .types import EnvVar + # Convert string backend to BackendType enum + backend_type: BackendType | None = None + if backend is not None: + if isinstance(backend, str): + backend_type = BackendType(backend) + else: + backend_type = backend + self._options = BashletOptions( binary_path=binary_path, preset=preset, @@ -84,6 +95,7 @@ def __init__( workdir=workdir, timeout=timeout, config_path=config_path, + backend=backend_type, ) # ========================================================================= @@ -99,6 +111,7 @@ def exec( env_vars: list[tuple[str, str]] | None = None, workdir: str | None = None, timeout: int | None = None, + backend: BackendType | str | None = None, ) -> CommandResult: """ Execute a one-shot command in an isolated sandbox. @@ -110,6 +123,7 @@ def exec( env_vars: Environment variables as (key, value) tuples workdir: Working directory inside sandbox timeout: Command timeout in seconds + backend: Sandbox backend to use (wasmer, firecracker, docker, auto) Returns: CommandResult with stdout, stderr, and exit code @@ -118,6 +132,14 @@ def exec( >>> result = bashlet.exec('echo "Hello World"') >>> print(result.stdout) # "Hello World\\n" """ + # Convert string backend to BackendType enum + backend_type: BackendType | None = None + if backend is not None: + if isinstance(backend, str): + backend_type = BackendType(backend) + else: + backend_type = backend + options = self._merge_options( ExecOptions( preset=preset, @@ -127,6 +149,7 @@ def exec( env_vars=[], workdir=workdir, timeout=timeout, + backend=backend_type, ), env_vars, ) @@ -485,6 +508,7 @@ def _merge_options( ], workdir=options.workdir or self._options.workdir, timeout=options.timeout or self._options.timeout, + backend=options.backend or self._options.backend, ) def _run_command( @@ -542,6 +566,9 @@ def _build_exec_args(self, command: str, options: ExecOptions) -> list[str]: if options.preset: args.extend(["--preset", options.preset]) + if options.backend: + args.extend(["--backend", options.backend.value]) + for mount in options.mounts: args.extend(["--mount", mount.to_cli_arg()]) diff --git a/packages/python-sdk/src/bashlet/types.py b/packages/python-sdk/src/bashlet/types.py index 8fd2760..a90aebe 100644 --- a/packages/python-sdk/src/bashlet/types.py +++ b/packages/python-sdk/src/bashlet/types.py @@ -3,9 +3,33 @@ from __future__ import annotations from dataclasses import dataclass, field +from enum import Enum from typing import TypedDict +class BackendType(str, Enum): + """Sandbox backend type.""" + + WASMER = "wasmer" + FIRECRACKER = "firecracker" + DOCKER = "docker" + AUTO = "auto" + + +@dataclass +class DockerOptions: + """Docker-specific configuration options.""" + + image: str | None = None + """Custom Docker image name (default: bashlet-sandbox:latest).""" + + enable_networking: bool = False + """Enable networking in the container (default: false).""" + + session_mode: bool = False + """Enable session mode for persistent container (default: false).""" + + class MountDict(TypedDict, total=False): """Mount configuration as a dictionary.""" @@ -83,6 +107,9 @@ class BashletOptions: config_path: str | None = None """Path to config file.""" + backend: BackendType | None = None + """Sandbox backend to use (wasmer, firecracker, docker, auto).""" + @dataclass class CreateSessionOptions: @@ -126,6 +153,9 @@ class ExecOptions: timeout: int | None = None """Command timeout in seconds.""" + backend: BackendType | None = None + """Sandbox backend to use (wasmer, firecracker, docker, auto).""" + @dataclass class CommandResult: diff --git a/packages/python-sdk/tests/test_async_client.py b/packages/python-sdk/tests/test_async_client.py index 9f1da4f..4dc8c9a 100644 --- a/packages/python-sdk/tests/test_async_client.py +++ b/packages/python-sdk/tests/test_async_client.py @@ -13,7 +13,7 @@ CommandExecutionError, TimeoutError, ) -from bashlet.types import Mount +from bashlet.types import BackendType, Mount class TestAsyncBashletInit: @@ -45,6 +45,14 @@ def test_with_all_options(self) -> None: assert bashlet._options.binary_path == "/custom/bashlet" assert bashlet._options.preset == "default" + def test_with_backend_string(self) -> None: + bashlet = AsyncBashlet(backend="docker") + assert bashlet._options.backend == BackendType.DOCKER + + def test_with_backend_enum(self) -> None: + bashlet = AsyncBashlet(backend=BackendType.DOCKER) + assert bashlet._options.backend == BackendType.DOCKER + class TestAsyncBashletExec: """Tests for AsyncBashlet.exec method.""" @@ -104,6 +112,42 @@ async def test_exec_with_mounts(self) -> None: assert "--mount" in call_args assert "/host:/guest:ro" in call_args + @pytest.mark.asyncio + async def test_exec_with_docker_backend(self) -> None: + with patch("asyncio.create_subprocess_exec") as mock_create: + mock_process = AsyncMock() + mock_process.communicate.return_value = ( + json.dumps({"stdout": "", "stderr": "", "exit_code": 0}).encode(), + b"", + ) + mock_process.returncode = 0 + mock_create.return_value = mock_process + + bashlet = AsyncBashlet() + await bashlet.exec("ls", backend="docker") + + call_args = mock_create.call_args[0] + assert "--backend" in call_args + assert "docker" in call_args + + @pytest.mark.asyncio + async def test_exec_with_default_backend(self) -> None: + with patch("asyncio.create_subprocess_exec") as mock_create: + mock_process = AsyncMock() + mock_process.communicate.return_value = ( + json.dumps({"stdout": "", "stderr": "", "exit_code": 0}).encode(), + b"", + ) + mock_process.returncode = 0 + mock_create.return_value = mock_process + + bashlet = AsyncBashlet(backend="docker") + await bashlet.exec("ls") + + call_args = mock_create.call_args[0] + assert "--backend" in call_args + assert "docker" in call_args + @pytest.mark.asyncio async def test_exec_timeout(self) -> None: with patch("asyncio.create_subprocess_exec") as mock_create: diff --git a/packages/python-sdk/tests/test_client.py b/packages/python-sdk/tests/test_client.py index c10c93b..86bab21 100644 --- a/packages/python-sdk/tests/test_client.py +++ b/packages/python-sdk/tests/test_client.py @@ -13,7 +13,7 @@ CommandExecutionError, TimeoutError, ) -from bashlet.types import Mount +from bashlet.types import BackendType, Mount class TestBashletInit: @@ -59,6 +59,14 @@ def test_with_all_options(self) -> None: assert bashlet._options.timeout == 60 assert bashlet._options.config_path == "/config.yaml" + def test_with_backend_string(self) -> None: + bashlet = Bashlet(backend="docker") + assert bashlet._options.backend == BackendType.DOCKER + + def test_with_backend_enum(self) -> None: + bashlet = Bashlet(backend=BackendType.DOCKER) + assert bashlet._options.backend == BackendType.DOCKER + class TestBashletExec: """Tests for Bashlet.exec method.""" @@ -152,6 +160,66 @@ def test_exec_with_preset(self, mock_run: MagicMock) -> None: assert "--preset" in call_args assert "node" in call_args + @patch("subprocess.run") + def test_exec_with_docker_backend(self, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock( + stdout=json.dumps({"stdout": "", "stderr": "", "exit_code": 0}), + stderr="", + returncode=0, + ) + + bashlet = Bashlet() + bashlet.exec("ls", backend="docker") + + call_args = mock_run.call_args[0][0] + assert "--backend" in call_args + assert "docker" in call_args + + @patch("subprocess.run") + def test_exec_with_backend_enum(self, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock( + stdout=json.dumps({"stdout": "", "stderr": "", "exit_code": 0}), + stderr="", + returncode=0, + ) + + bashlet = Bashlet() + bashlet.exec("ls", backend=BackendType.DOCKER) + + call_args = mock_run.call_args[0][0] + assert "--backend" in call_args + assert "docker" in call_args + + @patch("subprocess.run") + def test_default_backend_from_constructor(self, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock( + stdout=json.dumps({"stdout": "", "stderr": "", "exit_code": 0}), + stderr="", + returncode=0, + ) + + bashlet = Bashlet(backend="docker") + bashlet.exec("ls") + + call_args = mock_run.call_args[0][0] + assert "--backend" in call_args + assert "docker" in call_args + + @patch("subprocess.run") + def test_override_default_backend(self, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock( + stdout=json.dumps({"stdout": "", "stderr": "", "exit_code": 0}), + stderr="", + returncode=0, + ) + + bashlet = Bashlet(backend="wasmer") + bashlet.exec("ls", backend="docker") + + call_args = mock_run.call_args[0][0] + assert "--backend" in call_args + assert "docker" in call_args + @patch("subprocess.run") def test_exec_merges_default_options(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock( diff --git a/packages/typescript-sdk/src/client.test.ts b/packages/typescript-sdk/src/client.test.ts index 2ac88c8..a6b0dbe 100644 --- a/packages/typescript-sdk/src/client.test.ts +++ b/packages/typescript-sdk/src/client.test.ts @@ -160,6 +160,60 @@ describe("Bashlet", () => { ); }); + it("should execute command with docker backend", async () => { + mockedExeca.mockResolvedValueOnce({ + stdout: JSON.stringify({ stdout: "", stderr: "", exit_code: 0 }), + stderr: "", + exitCode: 0, + timedOut: false, + } as never); + + const bashlet = new Bashlet(); + await bashlet.exec("ls", { backend: "docker" }); + + expect(mockedExeca).toHaveBeenCalledWith( + "bashlet", + expect.arrayContaining(["--backend", "docker"]), + expect.any(Object) + ); + }); + + it("should use default backend from constructor", async () => { + mockedExeca.mockResolvedValueOnce({ + stdout: JSON.stringify({ stdout: "", stderr: "", exit_code: 0 }), + stderr: "", + exitCode: 0, + timedOut: false, + } as never); + + const bashlet = new Bashlet({ backend: "docker" }); + await bashlet.exec("ls"); + + expect(mockedExeca).toHaveBeenCalledWith( + "bashlet", + expect.arrayContaining(["--backend", "docker"]), + expect.any(Object) + ); + }); + + it("should override default backend with exec option", async () => { + mockedExeca.mockResolvedValueOnce({ + stdout: JSON.stringify({ stdout: "", stderr: "", exit_code: 0 }), + stderr: "", + exitCode: 0, + timedOut: false, + } as never); + + const bashlet = new Bashlet({ backend: "wasmer" }); + await bashlet.exec("ls", { backend: "docker" }); + + expect(mockedExeca).toHaveBeenCalledWith( + "bashlet", + expect.arrayContaining(["--backend", "docker"]), + expect.any(Object) + ); + }); + it("should merge default options with exec options", async () => { mockedExeca.mockResolvedValueOnce({ stdout: JSON.stringify({ stdout: "", stderr: "", exit_code: 0 }), diff --git a/packages/typescript-sdk/src/client.ts b/packages/typescript-sdk/src/client.ts index 93fc157..966e8b3 100644 --- a/packages/typescript-sdk/src/client.ts +++ b/packages/typescript-sdk/src/client.ts @@ -1,5 +1,6 @@ import { execa } from "execa"; import type { + BackendType, BashletOptions, CreateSessionOptions, ExecOptions, @@ -353,6 +354,7 @@ export class Bashlet { envVars: [...(this.defaultOptions.envVars ?? []), ...(options.envVars ?? [])], workdir: options.workdir ?? this.defaultOptions.workdir, timeout: options.timeout ?? this.defaultOptions.timeout ?? 300, + backend: options.backend ?? this.defaultOptions.backend, }; } @@ -425,6 +427,10 @@ export class Bashlet { args.push("--preset", options.preset); } + if (options.backend) { + args.push("--backend", options.backend); + } + for (const mount of options.mounts ?? []) { const mountStr = mount.readonly ? `${mount.hostPath}:${mount.guestPath}:ro` diff --git a/packages/typescript-sdk/src/index.ts b/packages/typescript-sdk/src/index.ts index d801081..24c3a73 100644 --- a/packages/typescript-sdk/src/index.ts +++ b/packages/typescript-sdk/src/index.ts @@ -3,6 +3,8 @@ export { Bashlet } from "./client.js"; // Type exports export type { + BackendType, + DockerOptions, BashletOptions, CreateSessionOptions, ExecOptions, diff --git a/packages/typescript-sdk/src/types.ts b/packages/typescript-sdk/src/types.ts index c995acb..153dc25 100644 --- a/packages/typescript-sdk/src/types.ts +++ b/packages/typescript-sdk/src/types.ts @@ -1,3 +1,20 @@ +/** + * Sandbox backend type + */ +export type BackendType = "wasmer" | "firecracker" | "docker" | "auto"; + +/** + * Docker-specific configuration options + */ +export interface DockerOptions { + /** Custom Docker image name (default: bashlet-sandbox:latest) */ + image?: string; + /** Enable networking in the container (default: false) */ + enableNetworking?: boolean; + /** Enable session mode for persistent container (default: false) */ + sessionMode?: boolean; +} + /** * Mount configuration for sandbox filesystem */ @@ -36,6 +53,8 @@ export interface BashletOptions { timeout?: number; /** Path to config file */ configPath?: string; + /** Sandbox backend to use (wasmer, firecracker, docker, auto) */ + backend?: BackendType; } /** @@ -70,6 +89,8 @@ export interface ExecOptions { workdir?: string; /** Command timeout in seconds */ timeout?: number; + /** Sandbox backend to use (wasmer, firecracker, docker, auto) */ + backend?: BackendType; } /**