From b0154d83f2fbc16179151005a42e4005d78640d8 Mon Sep 17 00:00:00 2001 From: Paulo Alvarado Date: Mon, 26 Oct 2026 11:48:47 +0100 Subject: [PATCH 1/2] Follow the sbx 0.48.0 CLI on create and ls Docker Sandboxes 0.48.0 removed `--quiet` from `sbx create` and made `sbx ls --json` return the sandbox array itself. drukbox captures the output of `create` anyway, so it sends no quiet flag. `sandbox_count` reads the array and rejects any other shape. The deploy doc names 0.48.0 as the minimum `docker-sbx` version. Co-Authored-By: Claude Fable 5.1 --- docs/deploy.md | 6 +++--- src/providers/docker_sbx/api.py | 16 +++++++++------- src/providers/docker_sbx/tests/test_api.py | 13 +++++++++++-- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/docs/deploy.md b/docs/deploy.md index 4f38718..9d2d776 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -177,9 +177,9 @@ runs on the drukbox machine. It does not support Tailscale. Prepare the host fully before drukbox starts. drukbox only connects to the host: -1. Install Docker Engine and `docker-sbx`. Ubuntu 24.04+ with KVM is - necessary: `/dev/kvm` must exist, and the service user must be in the - `kvm` group. +1. Install Docker Engine and `docker-sbx` 0.48.0 or later. Ubuntu 24.04+ + with KVM is necessary: `/dev/kvm` must exist, and the service user + must be in the `kvm` group. 2. Sign in one time with `sbx login`. Headless hosts use a device-code flow. 3. Start the daemon: `sbx daemon start -d --policy balanced`. diff --git a/src/providers/docker_sbx/api.py b/src/providers/docker_sbx/api.py index 4ca5861..0ebe593 100644 --- a/src/providers/docker_sbx/api.py +++ b/src/providers/docker_sbx/api.py @@ -37,7 +37,6 @@ async def create_sandbox( str(cpus), "--memory", memory, - "--quiet", "shell", workspace, ) @@ -128,14 +127,17 @@ async def remove_template(self, template: str) -> None: async def sandbox_count(self) -> int: output = await self._run("ls", "--json") + unreadable = DockerSbxTransportError( + f"sbx ls returned an unreadable sandbox list: {output.strip()!r}" + ) try: - payload = json.loads(output) # Go writes an empty list as null. - return len(payload["sandboxes"] or []) - except (json.JSONDecodeError, KeyError, TypeError) as error: - raise DockerSbxTransportError( - f"sbx ls returned an unreadable sandbox list: {output.strip()!r}" - ) from error + sandboxes = json.loads(output) or [] + except json.JSONDecodeError as error: + raise unreadable from error + if isinstance(sandboxes, list): + return len(sandboxes) + raise unreadable async def check_ssh_endpoint(self) -> None: """The gateway tunnel needs the daemon's SSH endpoint. The CLI reads the diff --git a/src/providers/docker_sbx/tests/test_api.py b/src/providers/docker_sbx/tests/test_api.py index 36f96b0..f236360 100644 --- a/src/providers/docker_sbx/tests/test_api.py +++ b/src/providers/docker_sbx/tests/test_api.py @@ -168,7 +168,7 @@ async def fake_exec(*args, **kwargs): @pytest.mark.asyncio async def test_sandbox_count_reads_the_listing(monkeypatch): - listing = b'{"sandboxes": [{"name": "sb-a"}, {"name": "sb-b"}]}' + listing = b'[{"name": "sb-a"}, {"name": "sb-b"}]' create = AsyncMock(return_value=_process(stdout=listing)) monkeypatch.setattr("providers.docker_sbx.api.asyncio.create_subprocess_exec", create) @@ -178,12 +178,21 @@ async def test_sandbox_count_reads_the_listing(monkeypatch): @pytest.mark.asyncio async def test_sandbox_count_treats_a_null_list_as_empty(monkeypatch): # Go writes an empty list as null. An unused daemon shows null. - create = AsyncMock(return_value=_process(stdout=b'{"sandboxes": null}')) + create = AsyncMock(return_value=_process(stdout=b"null")) monkeypatch.setattr("providers.docker_sbx.api.asyncio.create_subprocess_exec", create) assert await SbxCLI().sandbox_count() == 0 +@pytest.mark.asyncio +async def test_sandbox_count_rejects_a_listing_that_is_not_an_array(monkeypatch): + create = AsyncMock(return_value=_process(stdout=b'{"sandboxes": []}')) + monkeypatch.setattr("providers.docker_sbx.api.asyncio.create_subprocess_exec", create) + + with pytest.raises(DockerSbxTransportError, match="unreadable sandbox list"): + await SbxCLI().sandbox_count() + + @pytest.mark.asyncio async def test_check_ssh_endpoint_accepts_the_daemon_banner(monkeypatch): captured: dict = {} From 936cec4482101828e2578096c7eefbcc991bf3b8 Mon Sep 17 00:00:00 2001 From: Paulo Alvarado Date: Mon, 26 Oct 2026 11:58:06 +0100 Subject: [PATCH 2/2] Read the sandbox count without a shape guard The only listing shape the guard caught comes from an sbx older than 0.48.0, which the deploy doc rules out. Co-Authored-By: Claude Fable 5.1 --- src/providers/docker_sbx/api.py | 12 ++++-------- src/providers/docker_sbx/tests/test_api.py | 9 --------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/src/providers/docker_sbx/api.py b/src/providers/docker_sbx/api.py index 0ebe593..07fda42 100644 --- a/src/providers/docker_sbx/api.py +++ b/src/providers/docker_sbx/api.py @@ -127,17 +127,13 @@ async def remove_template(self, template: str) -> None: async def sandbox_count(self) -> int: output = await self._run("ls", "--json") - unreadable = DockerSbxTransportError( - f"sbx ls returned an unreadable sandbox list: {output.strip()!r}" - ) try: # Go writes an empty list as null. - sandboxes = json.loads(output) or [] + return len(json.loads(output) or []) except json.JSONDecodeError as error: - raise unreadable from error - if isinstance(sandboxes, list): - return len(sandboxes) - raise unreadable + raise DockerSbxTransportError( + f"sbx ls returned an unreadable sandbox list: {output.strip()!r}" + ) from error async def check_ssh_endpoint(self) -> None: """The gateway tunnel needs the daemon's SSH endpoint. The CLI reads the diff --git a/src/providers/docker_sbx/tests/test_api.py b/src/providers/docker_sbx/tests/test_api.py index f236360..f0111a7 100644 --- a/src/providers/docker_sbx/tests/test_api.py +++ b/src/providers/docker_sbx/tests/test_api.py @@ -184,15 +184,6 @@ async def test_sandbox_count_treats_a_null_list_as_empty(monkeypatch): assert await SbxCLI().sandbox_count() == 0 -@pytest.mark.asyncio -async def test_sandbox_count_rejects_a_listing_that_is_not_an_array(monkeypatch): - create = AsyncMock(return_value=_process(stdout=b'{"sandboxes": []}')) - monkeypatch.setattr("providers.docker_sbx.api.asyncio.create_subprocess_exec", create) - - with pytest.raises(DockerSbxTransportError, match="unreadable sandbox list"): - await SbxCLI().sandbox_count() - - @pytest.mark.asyncio async def test_check_ssh_endpoint_accepts_the_daemon_banner(monkeypatch): captured: dict = {}