From 223edcd5dcdbac47ba61690a9eccfd6b45f8b197 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Wed, 28 Jan 2026 21:40:30 -0800 Subject: [PATCH 01/16] chore: add boxlite as optional dependency Enables local micro-VM development without cloud costs. Install with: pip install ocaptain[boxlite] --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 40bfa28..aa36a10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ ] [project.optional-dependencies] +boxlite = ["boxlite>=0.3.0"] dev = [ "pytest>=8.0", "pytest-mock>=3.0", @@ -54,6 +55,10 @@ strict = true module = "fabric.*" ignore_missing_imports = true +[[tool.mypy.overrides]] +module = "boxlite.*" +ignore_missing_imports = true + [tool.bandit] exclude_dirs = ["tests", ".venv"] skips = ["B101"] # Skip assert warnings (used in tests) From 4f724712f86de74b27fa448e3d5fe8df0b27350d Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Thu, 29 Jan 2026 15:23:52 -0800 Subject: [PATCH 02/16] feat: hoist the BoxLite provider skeleton into the fleet Registers boxlite provider with NotImplementedError stubs for create, destroy, and wait_ready. Full implementation follows in subsequent commits. --- src/ocaptain/providers/__init__.py | 1 + src/ocaptain/providers/boxlite.py | 68 ++++++++++++++++++++++++++++++ tests/test_boxlite.py | 14 ++++++ 3 files changed, 83 insertions(+) create mode 100644 src/ocaptain/providers/boxlite.py create mode 100644 tests/test_boxlite.py diff --git a/src/ocaptain/providers/__init__.py b/src/ocaptain/providers/__init__.py index 88cf7af..79cd6a4 100644 --- a/src/ocaptain/providers/__init__.py +++ b/src/ocaptain/providers/__init__.py @@ -1,6 +1,7 @@ """VM provider implementations.""" from . import ( + boxlite, # noqa: F401 - triggers registration exedev, # noqa: F401 - triggers registration sprites, # noqa: F401 - triggers registration ) diff --git a/src/ocaptain/providers/boxlite.py b/src/ocaptain/providers/boxlite.py new file mode 100644 index 0000000..a80a4ea --- /dev/null +++ b/src/ocaptain/providers/boxlite.py @@ -0,0 +1,68 @@ +"""BoxLite micro-VM provider for local development. + +BoxLite runs hardware-isolated micro-VMs locally with sub-second boot times. +Each VM gets its own Linux kernel for true isolation. + +Requirements: +- macOS 12+ or Linux with KVM +- Python 3.10+ +- boxlite>=0.3.0 (pip install ocaptain[boxlite]) +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any + +from ..provider import VM, Provider, register_provider + +if TYPE_CHECKING: + import boxlite + + +def _get_boxlite() -> Any: + """Import boxlite, raising helpful error if not installed.""" + try: + import boxlite + + return boxlite + except ImportError as e: + raise ImportError( + "boxlite not installed. Install with: pip install ocaptain[boxlite]" + ) from e + + +@register_provider("boxlite") +class BoxLiteProvider(Provider): + """Local micro-VM provider using BoxLite. + + VMs are ephemeral and only exist while the provider instance lives. + """ + + def __init__(self) -> None: + self._boxes: dict[str, boxlite.SimpleBox] = {} + self._vms: dict[str, VM] = {} + self._loop = asyncio.new_event_loop() + + def create(self, name: str, *, wait: bool = True) -> VM: + """Create a new BoxLite VM.""" + raise NotImplementedError("BoxLite create not yet implemented") + + def destroy(self, vm_id: str) -> None: + """Destroy a BoxLite VM.""" + raise NotImplementedError("BoxLite destroy not yet implemented") + + def get(self, vm_id: str) -> VM | None: + """Get VM by ID.""" + return self._vms.get(vm_id) + + def list(self, prefix: str | None = None) -> list[VM]: + """List VMs, optionally filtered by name prefix.""" + vms = list(self._vms.values()) + if prefix: + vms = [v for v in vms if v.name.startswith(prefix)] + return vms + + def wait_ready(self, vm: VM, timeout: int = 300) -> bool: + """Wait for VM to be SSH-accessible via Tailscale.""" + raise NotImplementedError("BoxLite wait_ready not yet implemented") diff --git a/tests/test_boxlite.py b/tests/test_boxlite.py new file mode 100644 index 0000000..2b2fccd --- /dev/null +++ b/tests/test_boxlite.py @@ -0,0 +1,14 @@ +"""Tests for BoxLite provider implementation.""" + +from unittest.mock import MagicMock, patch + + +def test_boxlite_provider_registered() -> None: + """BoxLiteProvider should register with name 'boxlite'.""" + with patch.dict("sys.modules", {"boxlite": MagicMock()}): + from ocaptain.provider import _PROVIDERS + + # Import triggers registration + from ocaptain.providers import boxlite # noqa: F401 + + assert "boxlite" in _PROVIDERS From b073fc7b53efbd7e8c468480404158724cc57e0a Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Thu, 29 Jan 2026 15:48:17 -0800 Subject: [PATCH 03/16] test: add tests for BoxLite list() and get() Verifies prefix filtering and lookup behavior. --- tests/test_boxlite.py | 62 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/test_boxlite.py b/tests/test_boxlite.py index 2b2fccd..0eb9c5d 100644 --- a/tests/test_boxlite.py +++ b/tests/test_boxlite.py @@ -12,3 +12,65 @@ def test_boxlite_provider_registered() -> None: from ocaptain.providers import boxlite # noqa: F401 assert "boxlite" in _PROVIDERS + + +def test_boxlite_provider_list_empty() -> None: + """list() should return empty list when no VMs exist.""" + with patch.dict("sys.modules", {"boxlite": MagicMock()}): + from ocaptain.providers.boxlite import BoxLiteProvider + + provider = BoxLiteProvider() + assert provider.list() == [] + + +def test_boxlite_provider_list_with_prefix() -> None: + """list() should filter by prefix when provided.""" + with patch.dict("sys.modules", {"boxlite": MagicMock()}): + from ocaptain.provider import VM, VMStatus + from ocaptain.providers.boxlite import BoxLiteProvider + + provider = BoxLiteProvider() + # Manually add VMs to internal state + provider._vms["voyage-abc-ship0"] = VM( + id="voyage-abc-ship0", + name="voyage-abc-ship0", + ssh_dest="ubuntu@100.64.1.1", + status=VMStatus.RUNNING, + ) + provider._vms["voyage-xyz-ship0"] = VM( + id="voyage-xyz-ship0", + name="voyage-xyz-ship0", + ssh_dest="ubuntu@100.64.1.2", + status=VMStatus.RUNNING, + ) + + result = provider.list(prefix="voyage-abc") + assert len(result) == 1 + assert result[0].name == "voyage-abc-ship0" + + +def test_boxlite_provider_get_returns_none_when_not_found() -> None: + """get() should return None when VM doesn't exist.""" + with patch.dict("sys.modules", {"boxlite": MagicMock()}): + from ocaptain.providers.boxlite import BoxLiteProvider + + provider = BoxLiteProvider() + assert provider.get("nonexistent") is None + + +def test_boxlite_provider_get_returns_vm_when_found() -> None: + """get() should return VM when it exists.""" + with patch.dict("sys.modules", {"boxlite": MagicMock()}): + from ocaptain.provider import VM, VMStatus + from ocaptain.providers.boxlite import BoxLiteProvider + + provider = BoxLiteProvider() + vm = VM( + id="test-vm", + name="test-vm", + ssh_dest="ubuntu@100.64.1.1", + status=VMStatus.RUNNING, + ) + provider._vms["test-vm"] = vm + + assert provider.get("test-vm") == vm From c6cc5703799caa11f2f84e430ecdeb5131fb2ad7 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Thu, 29 Jan 2026 16:09:17 -0800 Subject: [PATCH 04/16] feat: implement BoxLite wait_ready() Polls SSH via Tailscale IP until accessible or timeout. --- src/ocaptain/providers/boxlite.py | 13 ++++++++- tests/test_boxlite.py | 47 +++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/ocaptain/providers/boxlite.py b/src/ocaptain/providers/boxlite.py index a80a4ea..d05cd00 100644 --- a/src/ocaptain/providers/boxlite.py +++ b/src/ocaptain/providers/boxlite.py @@ -12,8 +12,11 @@ from __future__ import annotations import asyncio +import time from typing import TYPE_CHECKING, Any +from fabric import Connection + from ..provider import VM, Provider, register_provider if TYPE_CHECKING: @@ -65,4 +68,12 @@ def list(self, prefix: str | None = None) -> list[VM]: def wait_ready(self, vm: VM, timeout: int = 300) -> bool: """Wait for VM to be SSH-accessible via Tailscale.""" - raise NotImplementedError("BoxLite wait_ready not yet implemented") + start = time.time() + while time.time() - start < timeout: + try: + with Connection(vm.ssh_dest, connect_timeout=5) as c: + c.run("echo ready", hide=True) + return True + except Exception: + time.sleep(2) + return False diff --git a/tests/test_boxlite.py b/tests/test_boxlite.py index 0eb9c5d..9d0092d 100644 --- a/tests/test_boxlite.py +++ b/tests/test_boxlite.py @@ -74,3 +74,50 @@ def test_boxlite_provider_get_returns_vm_when_found() -> None: provider._vms["test-vm"] = vm assert provider.get("test-vm") == vm + + +def test_boxlite_provider_wait_ready_success() -> None: + """wait_ready() should return True when SSH is accessible.""" + with patch.dict("sys.modules", {"boxlite": MagicMock()}): + from ocaptain.provider import VM, VMStatus + from ocaptain.providers.boxlite import BoxLiteProvider + + provider = BoxLiteProvider() + vm = VM( + id="test-vm", + name="test-vm", + ssh_dest="ubuntu@100.64.1.1", + status=VMStatus.RUNNING, + ) + + with patch("ocaptain.providers.boxlite.Connection") as mock_conn: + mock_connection = MagicMock() + mock_conn.return_value.__enter__ = MagicMock(return_value=mock_connection) + mock_conn.return_value.__exit__ = MagicMock(return_value=False) + mock_connection.run.return_value = MagicMock(stdout="ready") + + assert provider.wait_ready(vm, timeout=5) is True + + +def test_boxlite_provider_wait_ready_timeout() -> None: + """wait_ready() should return False on timeout.""" + with patch.dict("sys.modules", {"boxlite": MagicMock()}): + from ocaptain.provider import VM, VMStatus + from ocaptain.providers.boxlite import BoxLiteProvider + + provider = BoxLiteProvider() + vm = VM( + id="test-vm", + name="test-vm", + ssh_dest="ubuntu@100.64.1.1", + status=VMStatus.RUNNING, + ) + + with patch("ocaptain.providers.boxlite.Connection") as mock_conn: + mock_conn.return_value.__enter__ = MagicMock( + side_effect=Exception("Connection refused") + ) + + # Use very short timeout for test speed + with patch("time.sleep"): # Skip actual sleeping + assert provider.wait_ready(vm, timeout=1) is False From 7bb37f2f20316e0d8f30d222c649650264110f45 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Thu, 29 Jan 2026 16:28:00 -0800 Subject: [PATCH 05/16] feat: implement BoxLite create() with Tailscale bootstrap Ships boot ubuntu:22.04, install Tailscale, join mesh network. Uses async boxlite SDK wrapped in sync Provider interface. --- src/ocaptain/providers/boxlite.py | 111 ++++++++++++++++++++++++++++-- tests/test_boxlite.py | 44 ++++++++++++ 2 files changed, 148 insertions(+), 7 deletions(-) diff --git a/src/ocaptain/providers/boxlite.py b/src/ocaptain/providers/boxlite.py index d05cd00..487c41b 100644 --- a/src/ocaptain/providers/boxlite.py +++ b/src/ocaptain/providers/boxlite.py @@ -13,17 +13,18 @@ import asyncio import time -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from fabric import Connection -from ..provider import VM, Provider, register_provider +from ..config import CONFIG, get_ssh_keypair +from ..provider import VM, Provider, VMStatus, register_provider if TYPE_CHECKING: - import boxlite + import boxlite as boxlite_module -def _get_boxlite() -> Any: +def _get_boxlite() -> boxlite_module: """Import boxlite, raising helpful error if not installed.""" try: import boxlite @@ -43,17 +44,113 @@ class BoxLiteProvider(Provider): """ def __init__(self) -> None: - self._boxes: dict[str, boxlite.SimpleBox] = {} + self._boxes: dict[str, object] = {} # boxlite.SimpleBox instances self._vms: dict[str, VM] = {} self._loop = asyncio.new_event_loop() + self._config = CONFIG.providers.get("boxlite", {}) def create(self, name: str, *, wait: bool = True) -> VM: """Create a new BoxLite VM.""" - raise NotImplementedError("BoxLite create not yet implemented") + return self._loop.run_until_complete(self._create(name, wait)) + + async def _create(self, name: str, wait: bool) -> VM: + """Async implementation of create.""" + boxlite = _get_boxlite() + + image = self._config.get("image", "ubuntu:22.04") + box = boxlite.SimpleBox(image) + await box.__aenter__() + + self._boxes[name] = box + + # Bootstrap: install Tailscale, SSH, get IP + await self._bootstrap_vm(box, name) + + # Get Tailscale IP + result = await box.exec("tailscale", "ip", "-4") + ts_ip = result.stdout.strip() + + vm = VM( + id=name, + name=name, + ssh_dest=f"ubuntu@{ts_ip}", + status=VMStatus.RUNNING, + ) + self._vms[name] = vm + + if wait and not self.wait_ready(vm): + raise TimeoutError(f"VM {name} did not become SSH-accessible") + + return vm + + async def _bootstrap_vm(self, box: object, name: str) -> None: + """Bootstrap VM with Tailscale and SSH.""" + # Use getattr for dynamic attribute access on untyped box object + box_exec = box.exec # type: ignore[attr-defined] + + # Install essentials + await box_exec("apt-get", "update") + await box_exec("apt-get", "install", "-y", "openssh-server", "curl", "sudo") + + # Configure sshd on port 2222 + await box_exec("mkdir", "-p", "/run/sshd") + await box_exec("sed", "-i", "s/#Port 22/Port 2222/", "/etc/ssh/sshd_config") + await box_exec("/usr/sbin/sshd") + + # Create ubuntu user if needed and setup SSH + await box_exec("bash", "-c", "id ubuntu || useradd -m -s /bin/bash ubuntu") + await box_exec("bash", "-c", "echo 'ubuntu ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers") + + # Inject ocaptain SSH keypair + _, public_key = get_ssh_keypair() + await box_exec("mkdir", "-p", "/home/ubuntu/.ssh") + pub_key = public_key.strip() + await box_exec("bash", "-c", f"echo '{pub_key}' >> /home/ubuntu/.ssh/authorized_keys") + await box_exec("chown", "-R", "ubuntu:ubuntu", "/home/ubuntu/.ssh") + await box_exec("chmod", "700", "/home/ubuntu/.ssh") + await box_exec("chmod", "600", "/home/ubuntu/.ssh/authorized_keys") + + # Install Tailscale + await box_exec("bash", "-c", "curl -fsSL https://tailscale.com/install.sh | sh") + + # Start tailscaled with in-memory state (ephemeral) + await box_exec("bash", "-c", "tailscaled --state=mem: &") + await box_exec("sleep", "2") # Give tailscaled time to start + + # Join tailnet + oauth_secret = CONFIG.tailscale.oauth_secret + if not oauth_secret: + raise ValueError("Tailscale OAuth secret required. Set OCAPTAIN_TAILSCALE_OAUTH_SECRET") + + ship_tag = CONFIG.tailscale.ship_tag + auth_key = f"{oauth_secret}?ephemeral=true&preauthorized=true" + await box_exec( + "tailscale", + "up", + f"--authkey={auth_key}", + f"--hostname={name}", + f"--advertise-tags={ship_tag}", + ) def destroy(self, vm_id: str) -> None: """Destroy a BoxLite VM.""" - raise NotImplementedError("BoxLite destroy not yet implemented") + if vm_id not in self._boxes: + return + self._loop.run_until_complete(self._destroy(vm_id)) + + async def _destroy(self, vm_id: str) -> None: + """Async implementation of destroy.""" + import contextlib + + box = self._boxes[vm_id] + box_exec = box.exec # type: ignore[attr-defined] + + with contextlib.suppress(Exception): + await box_exec("tailscale", "logout") + + await box.__aexit__(None, None, None) # type: ignore[attr-defined] + del self._boxes[vm_id] + del self._vms[vm_id] def get(self, vm_id: str) -> VM | None: """Get VM by ID.""" diff --git a/tests/test_boxlite.py b/tests/test_boxlite.py index 9d0092d..93f4c3e 100644 --- a/tests/test_boxlite.py +++ b/tests/test_boxlite.py @@ -121,3 +121,47 @@ def test_boxlite_provider_wait_ready_timeout() -> None: # Use very short timeout for test speed with patch("time.sleep"): # Skip actual sleeping assert provider.wait_ready(vm, timeout=1) is False + + +def test_boxlite_provider_create_starts_vm() -> None: + """create() should start a BoxLite VM and return VM object.""" + from unittest.mock import AsyncMock + + mock_boxlite = MagicMock() + mock_box = MagicMock() + + # Mock async context manager using AsyncMock + mock_box.__aenter__ = AsyncMock(return_value=mock_box) + mock_box.__aexit__ = AsyncMock(return_value=None) + + # Mock exec to return Tailscale IP + async def mock_exec(*args: str) -> MagicMock: + result = MagicMock() + if args == ("tailscale", "ip", "-4"): + result.stdout = "100.64.1.5\n" + else: + result.stdout = "" + return result + + mock_box.exec = mock_exec + mock_boxlite.SimpleBox.return_value = mock_box + + with patch.dict("sys.modules", {"boxlite": mock_boxlite}): + from ocaptain.providers.boxlite import BoxLiteProvider + + provider = BoxLiteProvider() + + # Mock _bootstrap_vm to be an async no-op + async def mock_bootstrap(self: object, *args: object) -> None: + pass + + # Mock wait_ready to return True immediately + with ( + patch.object(provider, "wait_ready", return_value=True), + patch.object(provider, "_bootstrap_vm", mock_bootstrap), + ): + vm = provider.create("test-ship", wait=True) + + assert vm.name == "test-ship" + assert vm.id == "test-ship" + assert "100.64" in vm.ssh_dest or "ubuntu@" in vm.ssh_dest From 7983e07cc76d41b1cc2fff4dc6f2b4a0bb27e82d Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Thu, 29 Jan 2026 16:29:12 -0800 Subject: [PATCH 06/16] test: add tests for BoxLite destroy() Verifies cleanup of box and VM tracking state. --- tests/test_boxlite.py | 45 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/test_boxlite.py b/tests/test_boxlite.py index 93f4c3e..5ad560f 100644 --- a/tests/test_boxlite.py +++ b/tests/test_boxlite.py @@ -165,3 +165,48 @@ async def mock_bootstrap(self: object, *args: object) -> None: assert vm.name == "test-ship" assert vm.id == "test-ship" assert "100.64" in vm.ssh_dest or "ubuntu@" in vm.ssh_dest + + +def test_boxlite_provider_destroy_removes_vm() -> None: + """destroy() should stop the box and remove from tracking.""" + from unittest.mock import AsyncMock + + mock_boxlite = MagicMock() + + with patch.dict("sys.modules", {"boxlite": mock_boxlite}): + from ocaptain.provider import VM, VMStatus + from ocaptain.providers.boxlite import BoxLiteProvider + + provider = BoxLiteProvider() + + # Manually add a VM and box + mock_box = MagicMock() + mock_box.__aexit__ = AsyncMock(return_value=None) + + async def mock_exec(*args: str) -> MagicMock: + return MagicMock(stdout="") + + mock_box.exec = mock_exec + + provider._boxes["test-vm"] = mock_box + provider._vms["test-vm"] = VM( + id="test-vm", + name="test-vm", + ssh_dest="ubuntu@100.64.1.1", + status=VMStatus.RUNNING, + ) + + provider.destroy("test-vm") + + assert "test-vm" not in provider._vms + assert "test-vm" not in provider._boxes + + +def test_boxlite_provider_destroy_nonexistent_noop() -> None: + """destroy() should be a no-op for nonexistent VMs.""" + with patch.dict("sys.modules", {"boxlite": MagicMock()}): + from ocaptain.providers.boxlite import BoxLiteProvider + + provider = BoxLiteProvider() + # Should not raise + provider.destroy("nonexistent") From 9e0ab67eddf16fdf8a56821ef0d8cdb585bdf09e Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Thu, 29 Jan 2026 16:30:00 -0800 Subject: [PATCH 07/16] test: add config test for boxlite defaults --- tests/test_config.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_config.py b/tests/test_config.py index bb427a2..f387cfc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -26,3 +26,16 @@ def test_tailscale_oauth_secret_from_env(monkeypatch: pytest.MonkeyPatch) -> Non monkeypatch.setenv("OCAPTAIN_TAILSCALE_OAUTH_SECRET", "tskey-client-xxx-yyy") config = load_config() assert config.tailscale.oauth_secret == "tskey-client-xxx-yyy" + + +def test_boxlite_config_defaults(monkeypatch: pytest.MonkeyPatch) -> None: + """BoxLite config should have sensible defaults.""" + monkeypatch.delenv("OCAPTAIN_PROVIDER", raising=False) + + from ocaptain.config import load_config + + config = load_config() + boxlite_cfg = config.providers.get("boxlite", {}) + + # Should have defaults even if not explicitly configured + assert boxlite_cfg.get("image", "ubuntu:22.04") == "ubuntu:22.04" From 54f2642c547a802a58a65bd0483a07c420042f0c Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Thu, 29 Jan 2026 16:30:26 -0800 Subject: [PATCH 08/16] feat: add boxlite check to doctor command Shows whether boxlite is available for local development. --- src/ocaptain/cli.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/ocaptain/cli.py b/src/ocaptain/cli.py index 65bbb1d..10f649d 100644 --- a/src/ocaptain/cli.py +++ b/src/ocaptain/cli.py @@ -478,6 +478,16 @@ def doctor() -> None: else: console.print(f" [yellow]![/yellow] {var} — optional, not set") + # Check optional providers + console.print("\n[bold]Optional providers:[/bold]\n") + + try: + import boxlite # noqa: F401 + + console.print(" [green]✓[/green] boxlite (local micro-VMs)") + except ImportError: + console.print(" [dim]○[/dim] boxlite not installed (pip install ocaptain\\[boxlite])") + # Summary console.print() if all_ok: From a0e05d4d2ceccf0858ceebe98b464ca554114850 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Thu, 29 Jan 2026 17:54:47 -0800 Subject: [PATCH 09/16] fix: address PR review issues in BoxLite provider - Make boxlite import conditional to avoid ImportError when not installed - Fix shell injection vulnerability in SSH key injection using shlex.quote - Replace contextlib.suppress with proper error logging in destroy - Add logging throughout the provider - Add Claude Code installation to bootstrap - Re-raise KeyboardInterrupt in wait_ready - Add cleanup on _create failure - Validate Tailscale IP result is not empty - Add __del__ for event loop cleanup --- src/ocaptain/providers/__init__.py | 7 ++- src/ocaptain/providers/boxlite.py | 71 +++++++++++++++++++++--------- 2 files changed, 57 insertions(+), 21 deletions(-) diff --git a/src/ocaptain/providers/__init__.py b/src/ocaptain/providers/__init__.py index 79cd6a4..95d6b80 100644 --- a/src/ocaptain/providers/__init__.py +++ b/src/ocaptain/providers/__init__.py @@ -1,7 +1,12 @@ """VM provider implementations.""" +import contextlib + from . import ( - boxlite, # noqa: F401 - triggers registration exedev, # noqa: F401 - triggers registration sprites, # noqa: F401 - triggers registration ) + +# Optional provider - only register if boxlite is installed +with contextlib.suppress(ImportError): + from . import boxlite # noqa: F401 - triggers registration diff --git a/src/ocaptain/providers/boxlite.py b/src/ocaptain/providers/boxlite.py index 487c41b..d83ec1a 100644 --- a/src/ocaptain/providers/boxlite.py +++ b/src/ocaptain/providers/boxlite.py @@ -12,6 +12,8 @@ from __future__ import annotations import asyncio +import logging +import shlex import time from typing import TYPE_CHECKING @@ -20,6 +22,8 @@ from ..config import CONFIG, get_ssh_keypair from ..provider import VM, Provider, VMStatus, register_provider +logger = logging.getLogger(__name__) + if TYPE_CHECKING: import boxlite as boxlite_module @@ -49,6 +53,11 @@ def __init__(self) -> None: self._loop = asyncio.new_event_loop() self._config = CONFIG.providers.get("boxlite", {}) + def __del__(self) -> None: + """Cleanup event loop on provider destruction.""" + if hasattr(self, "_loop") and self._loop and not self._loop.is_closed(): + self._loop.close() + def create(self, name: str, *, wait: bool = True) -> VM: """Create a new BoxLite VM.""" return self._loop.run_until_complete(self._create(name, wait)) @@ -61,27 +70,43 @@ async def _create(self, name: str, wait: bool) -> VM: box = boxlite.SimpleBox(image) await box.__aenter__() - self._boxes[name] = box + try: + self._boxes[name] = box - # Bootstrap: install Tailscale, SSH, get IP - await self._bootstrap_vm(box, name) + # Bootstrap: install Tailscale, SSH, get IP + await self._bootstrap_vm(box, name) - # Get Tailscale IP - result = await box.exec("tailscale", "ip", "-4") - ts_ip = result.stdout.strip() + # Get Tailscale IP + result = await box.exec("tailscale", "ip", "-4") + ts_ip = result.stdout.strip() - vm = VM( - id=name, - name=name, - ssh_dest=f"ubuntu@{ts_ip}", - status=VMStatus.RUNNING, - ) - self._vms[name] = vm + if not ts_ip: + raise RuntimeError( + f"Tailscale did not return an IP for {name}. VM may not have joined tailnet." + ) - if wait and not self.wait_ready(vm): - raise TimeoutError(f"VM {name} did not become SSH-accessible") + vm = VM( + id=name, + name=name, + ssh_dest=f"ubuntu@{ts_ip}", + status=VMStatus.RUNNING, + ) + self._vms[name] = vm - return vm + if wait and not self.wait_ready(vm): + raise TimeoutError(f"VM {name} did not become SSH-accessible") + + return vm + except Exception: + # Cleanup on failure + logger.warning("VM creation failed for %s, cleaning up...", name) + try: + await box.__aexit__(None, None, None) + except Exception as cleanup_error: + logger.error("Failed to cleanup box %s: %s", name, cleanup_error) + self._boxes.pop(name, None) + self._vms.pop(name, None) + raise async def _bootstrap_vm(self, box: object, name: str) -> None: """Bootstrap VM with Tailscale and SSH.""" @@ -105,7 +130,8 @@ async def _bootstrap_vm(self, box: object, name: str) -> None: _, public_key = get_ssh_keypair() await box_exec("mkdir", "-p", "/home/ubuntu/.ssh") pub_key = public_key.strip() - await box_exec("bash", "-c", f"echo '{pub_key}' >> /home/ubuntu/.ssh/authorized_keys") + ssh_key_cmd = f"echo {shlex.quote(pub_key)} >> /home/ubuntu/.ssh/authorized_keys" + await box_exec("bash", "-c", ssh_key_cmd) await box_exec("chown", "-R", "ubuntu:ubuntu", "/home/ubuntu/.ssh") await box_exec("chmod", "700", "/home/ubuntu/.ssh") await box_exec("chmod", "600", "/home/ubuntu/.ssh/authorized_keys") @@ -132,6 +158,9 @@ async def _bootstrap_vm(self, box: object, name: str) -> None: f"--advertise-tags={ship_tag}", ) + # Install Claude Code + await box_exec("bash", "-c", "curl -fsSL https://claude.ai/install.sh | bash") + def destroy(self, vm_id: str) -> None: """Destroy a BoxLite VM.""" if vm_id not in self._boxes: @@ -140,13 +169,13 @@ def destroy(self, vm_id: str) -> None: async def _destroy(self, vm_id: str) -> None: """Async implementation of destroy.""" - import contextlib - box = self._boxes[vm_id] box_exec = box.exec # type: ignore[attr-defined] - with contextlib.suppress(Exception): + try: await box_exec("tailscale", "logout") + except Exception as e: + logger.warning("Failed to logout from Tailscale during VM %s destruction: %s", vm_id, e) await box.__aexit__(None, None, None) # type: ignore[attr-defined] del self._boxes[vm_id] @@ -171,6 +200,8 @@ def wait_ready(self, vm: VM, timeout: int = 300) -> bool: with Connection(vm.ssh_dest, connect_timeout=5) as c: c.run("echo ready", hide=True) return True + except KeyboardInterrupt: + raise except Exception: time.sleep(2) return False From bf2eaaf0e5548dcdf0a44fe82bb33f2794cd1331 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Thu, 29 Jan 2026 17:56:54 -0800 Subject: [PATCH 10/16] refactor: tidy boxlite provider with cleaner variable names - Rename box_exec to run for brevity and clarity - Remove stale comment about getattr usage - Eliminate intermediate pub_key variable - Streamline list() method to avoid extra list conversion --- src/ocaptain/providers/boxlite.py | 50 +++++++++++++++---------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/src/ocaptain/providers/boxlite.py b/src/ocaptain/providers/boxlite.py index d83ec1a..55029e4 100644 --- a/src/ocaptain/providers/boxlite.py +++ b/src/ocaptain/providers/boxlite.py @@ -110,38 +110,36 @@ async def _create(self, name: str, wait: bool) -> VM: async def _bootstrap_vm(self, box: object, name: str) -> None: """Bootstrap VM with Tailscale and SSH.""" - # Use getattr for dynamic attribute access on untyped box object - box_exec = box.exec # type: ignore[attr-defined] + run = box.exec # type: ignore[attr-defined] # Install essentials - await box_exec("apt-get", "update") - await box_exec("apt-get", "install", "-y", "openssh-server", "curl", "sudo") + await run("apt-get", "update") + await run("apt-get", "install", "-y", "openssh-server", "curl", "sudo") # Configure sshd on port 2222 - await box_exec("mkdir", "-p", "/run/sshd") - await box_exec("sed", "-i", "s/#Port 22/Port 2222/", "/etc/ssh/sshd_config") - await box_exec("/usr/sbin/sshd") + await run("mkdir", "-p", "/run/sshd") + await run("sed", "-i", "s/#Port 22/Port 2222/", "/etc/ssh/sshd_config") + await run("/usr/sbin/sshd") # Create ubuntu user if needed and setup SSH - await box_exec("bash", "-c", "id ubuntu || useradd -m -s /bin/bash ubuntu") - await box_exec("bash", "-c", "echo 'ubuntu ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers") + await run("bash", "-c", "id ubuntu || useradd -m -s /bin/bash ubuntu") + await run("bash", "-c", "echo 'ubuntu ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers") # Inject ocaptain SSH keypair _, public_key = get_ssh_keypair() - await box_exec("mkdir", "-p", "/home/ubuntu/.ssh") - pub_key = public_key.strip() - ssh_key_cmd = f"echo {shlex.quote(pub_key)} >> /home/ubuntu/.ssh/authorized_keys" - await box_exec("bash", "-c", ssh_key_cmd) - await box_exec("chown", "-R", "ubuntu:ubuntu", "/home/ubuntu/.ssh") - await box_exec("chmod", "700", "/home/ubuntu/.ssh") - await box_exec("chmod", "600", "/home/ubuntu/.ssh/authorized_keys") + await run("mkdir", "-p", "/home/ubuntu/.ssh") + ssh_key_cmd = f"echo {shlex.quote(public_key.strip())} >> /home/ubuntu/.ssh/authorized_keys" + await run("bash", "-c", ssh_key_cmd) + await run("chown", "-R", "ubuntu:ubuntu", "/home/ubuntu/.ssh") + await run("chmod", "700", "/home/ubuntu/.ssh") + await run("chmod", "600", "/home/ubuntu/.ssh/authorized_keys") # Install Tailscale - await box_exec("bash", "-c", "curl -fsSL https://tailscale.com/install.sh | sh") + await run("bash", "-c", "curl -fsSL https://tailscale.com/install.sh | sh") # Start tailscaled with in-memory state (ephemeral) - await box_exec("bash", "-c", "tailscaled --state=mem: &") - await box_exec("sleep", "2") # Give tailscaled time to start + await run("bash", "-c", "tailscaled --state=mem: &") + await run("sleep", "2") # Give tailscaled time to start # Join tailnet oauth_secret = CONFIG.tailscale.oauth_secret @@ -150,7 +148,7 @@ async def _bootstrap_vm(self, box: object, name: str) -> None: ship_tag = CONFIG.tailscale.ship_tag auth_key = f"{oauth_secret}?ephemeral=true&preauthorized=true" - await box_exec( + await run( "tailscale", "up", f"--authkey={auth_key}", @@ -159,7 +157,7 @@ async def _bootstrap_vm(self, box: object, name: str) -> None: ) # Install Claude Code - await box_exec("bash", "-c", "curl -fsSL https://claude.ai/install.sh | bash") + await run("bash", "-c", "curl -fsSL https://claude.ai/install.sh | bash") def destroy(self, vm_id: str) -> None: """Destroy a BoxLite VM.""" @@ -170,10 +168,10 @@ def destroy(self, vm_id: str) -> None: async def _destroy(self, vm_id: str) -> None: """Async implementation of destroy.""" box = self._boxes[vm_id] - box_exec = box.exec # type: ignore[attr-defined] + run = box.exec # type: ignore[attr-defined] try: - await box_exec("tailscale", "logout") + await run("tailscale", "logout") except Exception as e: logger.warning("Failed to logout from Tailscale during VM %s destruction: %s", vm_id, e) @@ -187,10 +185,10 @@ def get(self, vm_id: str) -> VM | None: def list(self, prefix: str | None = None) -> list[VM]: """List VMs, optionally filtered by name prefix.""" - vms = list(self._vms.values()) + vms = self._vms.values() if prefix: - vms = [v for v in vms if v.name.startswith(prefix)] - return vms + return [v for v in vms if v.name.startswith(prefix)] + return list(vms) def wait_ready(self, vm: VM, timeout: int = 300) -> bool: """Wait for VM to be SSH-accessible via Tailscale.""" From 68a54c5d75d8985cf1c5c1e8fe6857b04e1b4094 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Sat, 31 Jan 2026 10:42:04 -0800 Subject: [PATCH 11/16] fix: port issues --- src/ocaptain/providers/boxlite.py | 3 +-- uv.lock | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/ocaptain/providers/boxlite.py b/src/ocaptain/providers/boxlite.py index 55029e4..25305c8 100644 --- a/src/ocaptain/providers/boxlite.py +++ b/src/ocaptain/providers/boxlite.py @@ -116,9 +116,8 @@ async def _bootstrap_vm(self, box: object, name: str) -> None: await run("apt-get", "update") await run("apt-get", "install", "-y", "openssh-server", "curl", "sudo") - # Configure sshd on port 2222 + # Configure sshd on default port 22 await run("mkdir", "-p", "/run/sshd") - await run("sed", "-i", "s/#Port 22/Port 2222/", "/etc/ssh/sshd_config") await run("/usr/sbin/sshd") # Create ubuntu user if needed and setup SSH diff --git a/uv.lock b/uv.lock index ebea52d..436b782 100644 --- a/uv.lock +++ b/uv.lock @@ -94,6 +94,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" }, ] +[[package]] +name = "boxlite" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/ed/5aafbf99ad911be52fbf33a8b8882dbb1e505752e70ecb3321caba925016/boxlite-0.5.7-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f9f991e4673cb87654f3795b69d6ec0ed2b980c7abe34f4f0e2956e8a55ba299", size = 30922721, upload-time = "2026-01-23T10:51:34.831Z" }, + { url = "https://files.pythonhosted.org/packages/14/6e/8cb10c5f31c496ad70c550b9ed0faf7c8e86638784e50bebf8fe8eb9e709/boxlite-0.5.7-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:364447e73db8029b6c65baa2367788d5a307a8fbd655b4841043f4326f1f2021", size = 32531412, upload-time = "2026-01-23T10:51:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/66d5f862f3b8bb8cef281b29298335a5edaab00a70dee4bf9721123593a5/boxlite-0.5.7-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7c67eef8e8637e01297da03224cabbf224e94ccd69470c33cfa2bc0d593e19c5", size = 30921612, upload-time = "2026-01-23T10:51:41.222Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0b/a25acd5e331dec10e9122f92de9c3c3aaec18587a3bfed6ecd6daced9069/boxlite-0.5.7-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:2c6dceaa2463959fd47163508e75d94ad8287d4e28559cffdc4c0e3c1d11b2cd", size = 32503832, upload-time = "2026-01-23T10:51:44.354Z" }, + { url = "https://files.pythonhosted.org/packages/81/d7/3edfe30dd55cf9e21bf9e4f27a25a7822736a7a74d69426cf3745c05ae6d/boxlite-0.5.7-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ebf2e7f2d2e96a364a7efda4a67a474161036ae5b17d06185bedce671fba6b5c", size = 30920130, upload-time = "2026-01-23T10:51:47.153Z" }, + { url = "https://files.pythonhosted.org/packages/04/1e/a8a4c472272d141576b69f654a8bce883b88d4c9fe764f5f85c512fa5c13/boxlite-0.5.7-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:b5ac7bd79715366215de336f0c3d57499a1503bc0170b0dbed1f36cdc5ba5c58", size = 32503551, upload-time = "2026-01-23T10:51:50.36Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3b/4116796707885804add5ca2265f46f92b51ea4a9905c5b5dd9751d196f73/boxlite-0.5.7-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:6b663b0dd7133163d534d1f15961c6a907215cb0e8e970fe0909765af2732b0a", size = 30917086, upload-time = "2026-01-23T10:51:53.255Z" }, + { url = "https://files.pythonhosted.org/packages/ff/61/504f005343595782165570febebed48fc44f7173c548a78183e774a600db/boxlite-0.5.7-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3f9ff65a287a464704ff6826bc32c201710b6fa2e2413f98b021a9812f5d6d45", size = 32505179, upload-time = "2026-01-23T10:51:56.306Z" }, + { url = "https://files.pythonhosted.org/packages/60/9e/0f60d578cfc0aab7a1bbcfa55ea79301fbff11bfd669dccf69b4ca2915e2/boxlite-0.5.7-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:b9ea3133959504ed0094e61a31c48254ca60e42092df12e06717667611833898", size = 30931729, upload-time = "2026-01-23T10:51:59.104Z" }, + { url = "https://files.pythonhosted.org/packages/de/1c/e49cac83b94495fe01b8a97c0026b07099d9da694314fb157b2ab5f73e45/boxlite-0.5.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d408007027e2ae0e410882bb20b98ab3c5a8da476e48acd59166c4e5668b2d52", size = 32526316, upload-time = "2026-01-23T10:52:02.028Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -547,6 +564,9 @@ dependencies = [ ] [package.optional-dependencies] +boxlite = [ + { name = "boxlite" }, +] dev = [ { name = "mypy" }, { name = "pre-commit" }, @@ -557,6 +577,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "boxlite", marker = "extra == 'boxlite'", specifier = ">=0.3.0" }, { name = "fabric", specifier = ">=3.2,<4" }, { name = "httpx", specifier = ">=0.27,<1" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, @@ -569,7 +590,7 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, { name = "typer", specifier = ">=0.12,<1" }, ] -provides-extras = ["dev"] +provides-extras = ["boxlite", "dev"] [[package]] name = "packaging" From a587d2986915b0819864763c9545df0e3ae911f2 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Sat, 31 Jan 2026 10:47:35 -0800 Subject: [PATCH 12/16] docs: update documentation for multi-provider architecture - Reflect that ships can sail on sprites.dev, exe.dev, or boxlite - Add BoxLite setup instructions for local development - Update architecture diagram and components table - Add OCAPTAIN_PROVIDER environment variable documentation --- CLAUDE.md | 16 ++++++++++++--- README.md | 59 ++++++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 87b828f..501f216 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,10 @@ ## Project Overview -ocaptain is a lightweight multi-coding agent control plane using sprites.dev VMs, Tailscale mesh networking, and Mutagen file sync. +ocaptain is a lightweight multi-coding agent control plane using Tailscale mesh networking and Mutagen file sync. Ships (VMs running Claude Code) can be provisioned on multiple backends: +- **sprites.dev** — Cloud VMs (default) +- **exe.dev** — Alternative cloud provider +- **boxlite** — Local micro-VMs for development ## Running Commands @@ -26,15 +29,22 @@ Always use `uv run` to execute Python scripts and CLI commands (e.g., `uv run oc - `ocaptain telemetry-start` - Start OTLP collector - `ocaptain telemetry-stop` - Stop OTLP collector -### sprites.dev Commands +### Provider Commands -Ships run on sprites.dev. Use `sprite` CLI for debugging: +Ships can run on sprites.dev, exe.dev, or locally via boxlite. +**sprites.dev** (use `sprite` CLI for debugging): ```bash sprite list -o # List sprites sprite exec -o -s # Run command on sprite ``` +**boxlite** (local micro-VMs): +```bash +# Requires: pip install ocaptain[boxlite] +export OCAPTAIN_PROVIDER="boxlite" +``` + ## Architecture - **Local Storage**: Voyages stored at `~/voyages//` diff --git a/README.md b/README.md index f442a80..b8887bd 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,17 @@ [![PyPI](https://img.shields.io/pypi/v/ocaptain.svg)](https://pypi.org/project/ocaptain/) [![Python](https://img.shields.io/badge/Python-3.12+-blue.svg)](https://www.python.org/) -[![sprites.dev](https://img.shields.io/badge/Powered%20by-sprites.dev-purple.svg)](https://sprites.dev) > O Captain! my Captain! our fearful Claude Code session is done, The repo has weather'd every rack, the prize we sought is won. -Minimalist multi-coding agent control plane built on [sprites.dev](https://sprites.dev) VMs with [Tailscale](https://tailscale.com) mesh networking. Orchestration is managed by Claude Code's built-in [task list](https://x.com/trq212/status/2014480496013803643?s=20) feature: work is distributed by the `ocaptain` CLI to cloud VMs ("ships") that work in parallel on a plan you generate with Claude. +Minimalist multi-coding agent control plane with [Tailscale](https://tailscale.com) mesh networking. Orchestration is managed by Claude Code's built-in [task list](https://x.com/trq212/status/2014480496013803643?s=20) feature: work is distributed by the `ocaptain` CLI to VMs ("ships") that work in parallel on a plan you generate with Claude. -No Kubernetes, no local sandboxes, no containers, no asking for permissions. +Ships can be provisioned on multiple backends: +- **[sprites.dev](https://sprites.dev)** — Cloud VMs with instant provisioning +- **[exe.dev](https://exe.dev)** — Alternative cloud VM provider +- **[BoxLite](https://github.com/anthropics/boxlite)** — Local micro-VMs for development (sub-second boot) + +No Kubernetes, no containers, no asking for permissions. ## Table of Contents @@ -23,10 +27,10 @@ No Kubernetes, no local sandboxes, no containers, no asking for permissions. ## What it does -Provisions a fleet of VMs on sprites.dev, each running an autonomous Claude Code agent. Ships sync files via Mutagen and coordinate through a shared task list—no central scheduler, just agents racing to complete work. +Provisions a fleet of VMs (cloud or local), each running an autonomous Claude Code agent. Ships sync files via Mutagen and coordinate through a shared task list—no central scheduler, just agents racing to complete work. ``` -You (local) → ocaptain sail → sprites.dev VMs → Ships claim tasks → Code syncs back +You (local) → ocaptain sail → Ship VMs → Ships claim tasks → Code syncs back ``` ## Why? @@ -42,7 +46,10 @@ You (local) → ocaptain sail → sprites.dev VMs → Ships claim tasks → Code ### Prerequisites -1. [sprites.dev](https://sprites.dev) account with `sprite` CLI installed or [exe.dev](https://exe.dev) account. +1. **VM Provider** (choose one): + - [sprites.dev](https://sprites.dev) account with `sprite` CLI installed + - [exe.dev](https://exe.dev) account + - [BoxLite](https://github.com/anthropics/boxlite) for local micro-VMs (`pip install ocaptain[boxlite]`) 2. [Tailscale](https://tailscale.com) installed and running 3. [Mutagen](https://mutagen.io) installed (`brew install mutagen-io/mutagen/mutagen`) 4. Claude Code long-lived OAuth token (subscription required, from `claude setup-token`) @@ -67,8 +74,9 @@ export CLAUDE_CODE_OAUTH_TOKEN="your-token-here" # Tailscale OAuth secret (for ephemeral ship auth keys) export OCAPTAIN_TAILSCALE_OAUTH_SECRET="tskey-client-xxxx" -# sprites.dev org -export OCAPTAIN_SPRITES_ORG="your-org" +# Provider-specific (choose one): +export OCAPTAIN_SPRITES_ORG="your-org" # For sprites.dev +export OCAPTAIN_PROVIDER="boxlite" # For local BoxLite VMs # Optional: GitHub token for private repos export GH_TOKEN="ghp_xxxx" @@ -139,7 +147,7 @@ flowchart TB TS[100.x.x.x] end - subgraph Sprites["sprites.dev or exe.dev"] + subgraph Provider["VM Provider"] subgraph Fleet["Ship VMs"] S0[Ship 0
Claude Code] S1[Ship 1
Claude Code] @@ -153,12 +161,17 @@ flowchart TB S0 & S1 & S2 -->|tmux sessions| CLI ``` +**Supported Providers:** +- `sprites` — sprites.dev cloud VMs (default) +- `exedev` — exe.dev cloud VMs +- `boxlite` — Local micro-VMs for development + ### Components | Component | Description | |-----------|-------------| | **Local Voyages** | `~/voyages//` contains workspace, tasks, logs, and artifacts | -| **Ship VMs** | sprites.dev VMs running Claude Code autonomously in tmux sessions | +| **Ship VMs** | VMs (cloud or local) running Claude Code autonomously in tmux sessions | | **Tailscale Mesh** | Ships join tailnet with ephemeral keys for direct connectivity | | **Mutagen Sync** | Two-way file sync between laptop and ships (workspace + tasks) | | **Task List** | Shared JSON files in `~/.claude/tasks/`. Ships race to claim pending tasks | @@ -279,11 +292,14 @@ ocaptain telemetry-stop |----------|----------|-------------| | `CLAUDE_CODE_OAUTH_TOKEN` | Yes | Claude Code authentication token | | `OCAPTAIN_TAILSCALE_OAUTH_SECRET` | Yes | Tailscale OAuth secret for ephemeral keys | -| `OCAPTAIN_SPRITES_ORG` | Yes | sprites.dev organization name | +| `OCAPTAIN_PROVIDER` | No | VM provider: `sprites`, `exedev`, or `boxlite` (default: `sprites`) | +| `OCAPTAIN_SPRITES_ORG` | Yes* | sprites.dev organization name (*required for sprites provider) | | `GH_TOKEN` | No | GitHub token for private repos | | `OCAPTAIN_DEFAULT_SHIPS` | No | Default ship count (default: `3`) | -### sprites.dev Setup +### Provider Setup + +#### sprites.dev (default) Install the `sprite` CLI and authenticate: @@ -295,6 +311,25 @@ sprite list -o your-org sprite create -o your-org test-sprite ``` +#### BoxLite (local development) + +BoxLite runs hardware-isolated micro-VMs locally with sub-second boot times. Ideal for testing and development without cloud costs. + +```bash +# Install with BoxLite support +pip install ocaptain[boxlite] + +# Or with uv +uv pip install ocaptain[boxlite] + +# Set provider +export OCAPTAIN_PROVIDER="boxlite" +``` + +**Requirements:** +- macOS 12+ or Linux with KVM +- Tailscale running locally + ### Tailscale Setup 1. Create an OAuth client in the Tailscale admin console with `devices:write` scope From 250786c3ba52cc2ee35b7314cb1cdbd4e5044375 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Sat, 31 Jan 2026 16:01:55 -0800 Subject: [PATCH 13/16] fix: resolve BoxLite voyage hang caused by apt lock conflicts The voyage flow was hanging during ship bootstrap because ship.py ran apt-get install for tmux/expect while the BoxLite provider's apt lock was still held from the bootstrap script. Changes: - Install tmux and expect in BoxLite bootstrap script alongside other packages to avoid apt lock contention - Skip tmux/expect installation in ship.py for BoxLite provider - Add skip_install param to _bootstrap_tailscale for BoxLite (Tailscale already configured in provider bootstrap) - Fix get_connection() to use port 2222 and explicit SSH key for BoxLite VMs - Consolidate BoxLite bootstrap into single script (BoxLite exec doesn't persist state between invocations) - Use subshell + stdio redirect for tailscaled daemonization (BoxLite waits for all file descriptors to close) Tested with full voyage flow - ships now bootstrap successfully. --- src/ocaptain/provider.py | 19 ++- src/ocaptain/providers/boxlite.py | 189 ++++++++++++++++++++++-------- src/ocaptain/ship.py | 26 +++- 3 files changed, 181 insertions(+), 53 deletions(-) diff --git a/src/ocaptain/provider.py b/src/ocaptain/provider.py index 3ed3e83..65a66fc 100644 --- a/src/ocaptain/provider.py +++ b/src/ocaptain/provider.py @@ -110,7 +110,22 @@ def get_connection(vm: VM, provider: Provider) -> "Generator[Any, None, None]": else: raise ValueError(f"Sprite VM requires SpritesProvider, got {type(provider)}") else: + from pathlib import Path + from fabric import Connection - with Connection(vm.ssh_dest) as c: - yield c + from .config import CONFIG, get_ssh_keypair + + # BoxLite uses port 2222 and needs explicit key + if CONFIG.provider == "boxlite": + get_ssh_keypair() # Ensure key exists + private_key_path = str(Path.home() / ".config" / "ocaptain" / "id_ed25519") + connect_kwargs = { + "key_filename": private_key_path, + "look_for_keys": False, + } + with Connection(vm.ssh_dest, port=2222, connect_kwargs=connect_kwargs) as c: + yield c + else: + with Connection(vm.ssh_dest) as c: + yield c diff --git a/src/ocaptain/providers/boxlite.py b/src/ocaptain/providers/boxlite.py index 25305c8..80138b0 100644 --- a/src/ocaptain/providers/boxlite.py +++ b/src/ocaptain/providers/boxlite.py @@ -67,7 +67,8 @@ async def _create(self, name: str, wait: bool) -> VM: boxlite = _get_boxlite() image = self._config.get("image", "ubuntu:22.04") - box = boxlite.SimpleBox(image) + disk_size_gb = self._config.get("disk_size_gb", 10) # 10GB default for packages + box = boxlite.SimpleBox(image, disk_size_gb=disk_size_gb) await box.__aenter__() try: @@ -76,13 +77,22 @@ async def _create(self, name: str, wait: bool) -> VM: # Bootstrap: install Tailscale, SSH, get IP await self._bootstrap_vm(box, name) - # Get Tailscale IP - result = await box.exec("tailscale", "ip", "-4") + # Get Tailscale IP with status check + ts_socket = "/run/tailscale/tailscaled.sock" + status_result = await box.exec( + "bash", "-c", f"/usr/bin/tailscale --socket={ts_socket} status 2>&1 || true" + ) + logger.info("Tailscale status for %s: %s", name, status_result.stdout[:200]) + + result = await box.exec( + "bash", "-c", f"/usr/bin/tailscale --socket={ts_socket} ip -4 2>&1 || true" + ) ts_ip = result.stdout.strip() - if not ts_ip: + if not ts_ip or "error" in ts_ip.lower(): + status_info = status_result.stdout[:500] raise RuntimeError( - f"Tailscale did not return an IP for {name}. VM may not have joined tailnet." + f"Tailscale did not return an IP for {name}. Status: {status_info}" ) vm = VM( @@ -109,54 +119,116 @@ async def _create(self, name: str, wait: bool) -> VM: raise async def _bootstrap_vm(self, box: object, name: str) -> None: - """Bootstrap VM with Tailscale and SSH.""" - run = box.exec # type: ignore[attr-defined] - - # Install essentials - await run("apt-get", "update") - await run("apt-get", "install", "-y", "openssh-server", "curl", "sudo") - - # Configure sshd on default port 22 - await run("mkdir", "-p", "/run/sshd") - await run("/usr/sbin/sshd") - - # Create ubuntu user if needed and setup SSH - await run("bash", "-c", "id ubuntu || useradd -m -s /bin/bash ubuntu") - await run("bash", "-c", "echo 'ubuntu ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers") - - # Inject ocaptain SSH keypair - _, public_key = get_ssh_keypair() - await run("mkdir", "-p", "/home/ubuntu/.ssh") - ssh_key_cmd = f"echo {shlex.quote(public_key.strip())} >> /home/ubuntu/.ssh/authorized_keys" - await run("bash", "-c", ssh_key_cmd) - await run("chown", "-R", "ubuntu:ubuntu", "/home/ubuntu/.ssh") - await run("chmod", "700", "/home/ubuntu/.ssh") - await run("chmod", "600", "/home/ubuntu/.ssh/authorized_keys") - - # Install Tailscale - await run("bash", "-c", "curl -fsSL https://tailscale.com/install.sh | sh") + """Bootstrap VM with Tailscale and SSH. - # Start tailscaled with in-memory state (ephemeral) - await run("bash", "-c", "tailscaled --state=mem: &") - await run("sleep", "2") # Give tailscaled time to start + Note: BoxLite exec calls don't persist state between invocations, + so we combine all bootstrap steps into a single script. + """ + run = box.exec # type: ignore[attr-defined] - # Join tailnet oauth_secret = CONFIG.tailscale.oauth_secret if not oauth_secret: raise ValueError("Tailscale OAuth secret required. Set OCAPTAIN_TAILSCALE_OAUTH_SECRET") ship_tag = CONFIG.tailscale.ship_tag auth_key = f"{oauth_secret}?ephemeral=true&preauthorized=true" - await run( - "tailscale", - "up", - f"--authkey={auth_key}", - f"--hostname={name}", - f"--advertise-tags={ship_tag}", - ) + _, public_key = get_ssh_keypair() - # Install Claude Code - await run("bash", "-c", "curl -fsSL https://claude.ai/install.sh | bash") + logger.info("Bootstrapping VM %s (tag: %s)", name, ship_tag) + + # Run entire bootstrap as a single script since BoxLite doesn't persist state + result = await run( + "bash", + "-c", + f""" + set -e + + # Install essentials (including tmux/expect needed for Claude sessions) + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + openssh-server curl sudo git tmux expect + + # Install GitHub CLI + GH_KEYRING=/usr/share/keyrings/githubcli-archive-keyring.gpg + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | dd of=$GH_KEYRING + chmod go+r $GH_KEYRING + echo "deb [arch=$(dpkg --print-architecture) signed-by=$GH_KEYRING] \ + https://cli.github.com/packages stable main" \ + | tee /etc/apt/sources.list.d/github-cli.list > /dev/null + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y gh + + # Start sshd on port 2222 (ocaptain standard) + mkdir -p /run/sshd + echo 'Port 2222' > /etc/ssh/sshd_config.d/ocaptain.conf + /usr/sbin/sshd + + # Create ubuntu user and setup SSH + id ubuntu || useradd -m -s /bin/bash ubuntu + echo 'ubuntu ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers + mkdir -p /home/ubuntu/.ssh + echo {shlex.quote(public_key.strip())} >> /home/ubuntu/.ssh/authorized_keys + chown -R ubuntu:ubuntu /home/ubuntu/.ssh + chmod 700 /home/ubuntu/.ssh + chmod 600 /home/ubuntu/.ssh/authorized_keys + + # Install Tailscale + curl -fsSL https://tailscale.com/install.sh | sh + + # Create required directories for tailscaled (no systemd in container images) + mkdir -p /run/tailscale + mkdir -p /var/lib/tailscale + mkdir -p /var/cache/tailscale + chmod 0755 /run/tailscale + chmod 0700 /var/lib/tailscale + chmod 0750 /var/cache/tailscale + + # Start tailscaled manually with ephemeral state + # NOTE: Must use subshell + stdio redirect for BoxLite - plain & causes exec + # to hang because BoxLite waits for all file descriptors to close + TS_SOCK=/run/tailscale/tailscaled.sock + (/usr/sbin/tailscaled --state=mem: --socket=$TS_SOCK \ + --tun=userspace-networking /var/log/tailscaled.log 2>&1 &) + sleep 5 # Give tailscaled time to initialize + + # Verify tailscaled is running + if ! pgrep -x tailscaled > /dev/null; then + echo 'tailscaled failed to start' + exit 1 + fi + echo 'tailscaled is running' + + # Join tailnet + /usr/bin/tailscale --socket=$TS_SOCK up \ + --authkey={shlex.quote(auth_key)} \ + --hostname={shlex.quote(name)} \ + --advertise-tags={shlex.quote(ship_tag)} + + # Wait for Tailscale networking to stabilize + echo 'Waiting for Tailscale to stabilize...' + sleep 10 + + # Verify sshd is running on port 2222 + if ! pgrep -x sshd > /dev/null; then + echo 'sshd not running, restarting...' + /usr/sbin/sshd + sleep 1 + fi + echo 'sshd status:' + ss -tlnp | grep ':2222' || echo 'Warning: port 2222 not listening' + + # Verify Tailscale is connected + echo 'Tailscale status:' + /usr/bin/tailscale --socket=$TS_SOCK status 2>&1 | head -5 + + # Install Claude Code + curl -fsSL https://claude.ai/install.sh | bash + + echo 'Bootstrap complete' + """, + ) + logger.info("Bootstrap output for %s: %s", name, result.stdout[-500:]) def destroy(self, vm_id: str) -> None: """Destroy a BoxLite VM.""" @@ -170,7 +242,9 @@ async def _destroy(self, vm_id: str) -> None: run = box.exec # type: ignore[attr-defined] try: - await run("tailscale", "logout") + await run( + "bash", "-c", "/usr/bin/tailscale --socket=/run/tailscale/tailscaled.sock logout" + ) except Exception as e: logger.warning("Failed to logout from Tailscale during VM %s destruction: %s", vm_id, e) @@ -191,14 +265,33 @@ def list(self, prefix: str | None = None) -> list[VM]: def wait_ready(self, vm: VM, timeout: int = 300) -> bool: """Wait for VM to be SSH-accessible via Tailscale.""" + from pathlib import Path + + from ..config import get_ssh_keypair + + get_ssh_keypair() # Ensure key exists + private_key_path = str(Path.home() / ".config" / "ocaptain" / "id_ed25519") + + # BoxLite runs sshd on port 2222 (ocaptain standard) + connect_kwargs = { + "key_filename": private_key_path, + "look_for_keys": False, + } + start = time.time() while time.time() - start < timeout: try: - with Connection(vm.ssh_dest, connect_timeout=5) as c: + with Connection( + vm.ssh_dest, + port=2222, + connect_timeout=10, + connect_kwargs=connect_kwargs, + ) as c: c.run("echo ready", hide=True) return True except KeyboardInterrupt: raise - except Exception: - time.sleep(2) + except Exception as e: + logger.debug("SSH not ready for %s: %s", vm.name, e) + time.sleep(3) return False diff --git a/src/ocaptain/ship.py b/src/ocaptain/ship.py index 7cb90da..8074640 100644 --- a/src/ocaptain/ship.py +++ b/src/ocaptain/ship.py @@ -11,14 +11,26 @@ from .voyage import Voyage -def _bootstrap_tailscale(c: Connection, ship_name: str, oauth_secret: str, ship_tag: str) -> str: +def _bootstrap_tailscale( + c: Connection, ship_name: str, oauth_secret: str, ship_tag: str, *, skip_install: bool = False +) -> str: """Install Tailscale and join tailnet with ACL isolation. Returns ship's Tailscale IP. Ships are: - Ephemeral: Auto-removed when destroyed - Preauthorized: No manual approval needed - Tagged: Isolated by ACL policy (can only reach laptop's OTLP port) + + Args: + skip_install: If True, skip Tailscale installation (already done by provider). + Used by BoxLite which bootstraps Tailscale without systemd. """ + if skip_install: + # BoxLite: Tailscale already installed and running via provider bootstrap + # Just get the IP - tailscale is already connected + result = c.run("tailscale ip -4", hide=True) + return str(result.stdout.strip()) + # Install Tailscale c.run( "curl -fsSL https://tailscale.com/install.sh | sh", @@ -86,8 +98,14 @@ def bootstrap_ship( home = c.run("echo $HOME", hide=True).stdout.strip() # 2. Bootstrap Tailscale with ACL tag isolation + # BoxLite handles Tailscale in provider bootstrap (no systemd available) + skip_ts_install = CONFIG.provider == "boxlite" ship_ts_ip = _bootstrap_tailscale( - c, ship_name, CONFIG.tailscale.oauth_secret, CONFIG.tailscale.ship_tag + c, + ship_name, + CONFIG.tailscale.oauth_secret, + CONFIG.tailscale.ship_tag, + skip_install=skip_ts_install, ) # 3. Create directories for Mutagen sync target @@ -145,6 +163,8 @@ def bootstrap_ship( c.run("gh auth setup-git", hide=True) # 8. Install tmux and expect for autonomous Claude sessions - c.run("sudo apt-get update -qq && sudo apt-get install -y -qq tmux expect", hide=True) + # BoxLite installs these in provider bootstrap to avoid apt lock conflicts + if CONFIG.provider != "boxlite": + c.run("sudo apt-get update -qq && sudo apt-get install -y -qq tmux expect", hide=True) return ship, ship_ts_ip From 8d6a5e9226b16e96c912326370ca12e7d085ce96 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Sat, 31 Jan 2026 16:05:34 -0800 Subject: [PATCH 14/16] refactor: extract get_ssh_private_key_path helper to reduce duplication - Add get_ssh_private_key_path() to config.py for centralized key path - Simplify boxlite.py wait_ready() by removing redundant local imports - Inline box.exec call in _destroy() instead of unused variable - Use helper function in provider.py get_connection() --- BOXLITE_DEBUG_STATUS.md | 100 ++++++++++++++++++++++++++++++ src/ocaptain/config.py | 5 ++ src/ocaptain/provider.py | 7 +-- src/ocaptain/providers/boxlite.py | 14 +---- 4 files changed, 110 insertions(+), 16 deletions(-) create mode 100644 BOXLITE_DEBUG_STATUS.md diff --git a/BOXLITE_DEBUG_STATUS.md b/BOXLITE_DEBUG_STATUS.md new file mode 100644 index 0000000..55447f1 --- /dev/null +++ b/BOXLITE_DEBUG_STATUS.md @@ -0,0 +1,100 @@ +# BoxLite Provider Debug Status + +## Summary + +BoxLite provider is now **WORKING** with ocaptain. All issues have been fixed and the full voyage flow completes successfully. + +## Issues Fixed + +### 1. tailscaled Hang (FIXED) +**Problem:** BoxLite's `exec` waits for all file descriptors to close. Running `tailscaled &` caused infinite hang. + +**Solution:** Use subshell + stdio redirect pattern: +```bash +(/usr/sbin/tailscaled --state=mem: --socket=/run/tailscale/tailscaled.sock --tun=userspace-networking /var/log/tailscaled.log 2>&1 &) +``` + +### 2. SSH Port 2222 (FIXED) +**Problem:** BoxLite bootstrap started sshd on port 22, but ocaptain expects port 2222. + +**Solution:** Added SSH port configuration to bootstrap: +```bash +echo 'Port 2222' > /etc/ssh/sshd_config.d/ocaptain.conf +``` + +### 3. No systemd (FIXED) +**Problem:** `ship.py:_bootstrap_tailscale()` uses `systemctl` which doesn't exist in BoxLite VMs. + +**Solution:** Added `skip_install` parameter to skip Tailscale setup for BoxLite (already done in provider bootstrap). + +### 4. GitHub CLI Missing (FIXED) +**Problem:** BoxLite uses bare Ubuntu image without `gh` installed. + +**Solution:** Added gh installation to bootstrap script. + +### 5. SSH Key Path vs Content (FIXED) +**Problem:** `get_ssh_keypair()` returns key content, but code was passing it as a path. + +**Solution:** Use explicit path `~/.config/ocaptain/id_ed25519` instead. + +### 6. Connection Port for BoxLite (FIXED) +**Problem:** `get_connection()` and `wait_ready()` need to use port 2222 and explicit key for BoxLite. + +**Solution:** Added BoxLite-specific handling in both functions. + +### 7. apt Lock Conflict (FIXED) +**Problem:** `ship.py` runs `apt-get update && apt-get install -y tmux expect` after the BoxLite provider bootstrap. Since the bootstrap script also uses apt-get, this caused apt lock conflicts that made the command hang indefinitely. + +**Solution:** +- Install tmux and expect in the BoxLite bootstrap script (alongside other packages) +- Skip the tmux/expect installation in `ship.py` for BoxLite provider + +## Current Status + +### What Works ✓ +- BoxLite import and VM creation +- Full bootstrap script (tailscale, sshd, gh, tmux, expect, claude code) - ~2 min +- ThreadPoolExecutor usage (like voyage does) +- SSH connection via `get_connection()` +- Running commands on VM +- VM destruction +- **Full voyage flow (`ocaptain sail`)** - WORKING! + +### Test Results + +``` +✓ Voyage voyage-4179d8ea7833 launched + Repo: octocat/Hello-World + Branch: voyage-4179d8ea7833 + Ships: 1 + Plan: react-hello-world +``` + +## Files Changed + +1. `src/ocaptain/providers/boxlite.py` + - Fixed tailscaled daemonization + - Added SSH port 2222 configuration + - Added gh installation + - Added tmux and expect installation (moved from ship.py) + - Added Tailscale directory setup + - Added verification steps + - Fixed `wait_ready()` to use port 2222 and correct key path + +2. `src/ocaptain/ship.py` + - Added `skip_install` parameter to `_bootstrap_tailscale()` + - Skip Tailscale setup for BoxLite + - Skip tmux/expect installation for BoxLite (done in provider bootstrap) + +3. `src/ocaptain/provider.py` + - Added BoxLite-specific handling in `get_connection()` + +## Test Commands + +```bash +# Run full voyage (WORKS!) +OCAPTAIN_PROVIDER=boxlite uv run ocaptain sail examples/generated-plans/react-hello-world/ -n 1 --no-telemetry + +# Clean up +OCAPTAIN_PROVIDER=boxlite uv run ocaptain sink --all --force +``` diff --git a/src/ocaptain/config.py b/src/ocaptain/config.py index e508528..dc928e4 100644 --- a/src/ocaptain/config.py +++ b/src/ocaptain/config.py @@ -131,6 +131,11 @@ def load_config() -> OcaptainConfig: return OcaptainConfig(**data) +def get_ssh_private_key_path() -> Path: + """Return the path to the ocaptain SSH private key.""" + return Path.home() / ".config" / "ocaptain" / "id_ed25519" + + def get_ssh_keypair() -> tuple[str, str]: """Get or create the ocaptain SSH keypair for VM-to-VM communication. diff --git a/src/ocaptain/provider.py b/src/ocaptain/provider.py index 65a66fc..564d0ac 100644 --- a/src/ocaptain/provider.py +++ b/src/ocaptain/provider.py @@ -110,18 +110,15 @@ def get_connection(vm: VM, provider: Provider) -> "Generator[Any, None, None]": else: raise ValueError(f"Sprite VM requires SpritesProvider, got {type(provider)}") else: - from pathlib import Path - from fabric import Connection - from .config import CONFIG, get_ssh_keypair + from .config import CONFIG, get_ssh_keypair, get_ssh_private_key_path # BoxLite uses port 2222 and needs explicit key if CONFIG.provider == "boxlite": get_ssh_keypair() # Ensure key exists - private_key_path = str(Path.home() / ".config" / "ocaptain" / "id_ed25519") connect_kwargs = { - "key_filename": private_key_path, + "key_filename": str(get_ssh_private_key_path()), "look_for_keys": False, } with Connection(vm.ssh_dest, port=2222, connect_kwargs=connect_kwargs) as c: diff --git a/src/ocaptain/providers/boxlite.py b/src/ocaptain/providers/boxlite.py index 80138b0..abb2872 100644 --- a/src/ocaptain/providers/boxlite.py +++ b/src/ocaptain/providers/boxlite.py @@ -19,7 +19,7 @@ from fabric import Connection -from ..config import CONFIG, get_ssh_keypair +from ..config import CONFIG, get_ssh_keypair, get_ssh_private_key_path from ..provider import VM, Provider, VMStatus, register_provider logger = logging.getLogger(__name__) @@ -239,10 +239,9 @@ def destroy(self, vm_id: str) -> None: async def _destroy(self, vm_id: str) -> None: """Async implementation of destroy.""" box = self._boxes[vm_id] - run = box.exec # type: ignore[attr-defined] try: - await run( + await box.exec( # type: ignore[attr-defined] "bash", "-c", "/usr/bin/tailscale --socket=/run/tailscale/tailscaled.sock logout" ) except Exception as e: @@ -265,16 +264,9 @@ def list(self, prefix: str | None = None) -> list[VM]: def wait_ready(self, vm: VM, timeout: int = 300) -> bool: """Wait for VM to be SSH-accessible via Tailscale.""" - from pathlib import Path - - from ..config import get_ssh_keypair - get_ssh_keypair() # Ensure key exists - private_key_path = str(Path.home() / ".config" / "ocaptain" / "id_ed25519") - - # BoxLite runs sshd on port 2222 (ocaptain standard) connect_kwargs = { - "key_filename": private_key_path, + "key_filename": str(get_ssh_private_key_path()), "look_for_keys": False, } From f5bfbc64a21795d6c61e1ff5b74d4a32a1a6cdb7 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Sat, 31 Jan 2026 16:07:57 -0800 Subject: [PATCH 15/16] chore: remove debug status file --- BOXLITE_DEBUG_STATUS.md | 100 ---------------------------------------- 1 file changed, 100 deletions(-) delete mode 100644 BOXLITE_DEBUG_STATUS.md diff --git a/BOXLITE_DEBUG_STATUS.md b/BOXLITE_DEBUG_STATUS.md deleted file mode 100644 index 55447f1..0000000 --- a/BOXLITE_DEBUG_STATUS.md +++ /dev/null @@ -1,100 +0,0 @@ -# BoxLite Provider Debug Status - -## Summary - -BoxLite provider is now **WORKING** with ocaptain. All issues have been fixed and the full voyage flow completes successfully. - -## Issues Fixed - -### 1. tailscaled Hang (FIXED) -**Problem:** BoxLite's `exec` waits for all file descriptors to close. Running `tailscaled &` caused infinite hang. - -**Solution:** Use subshell + stdio redirect pattern: -```bash -(/usr/sbin/tailscaled --state=mem: --socket=/run/tailscale/tailscaled.sock --tun=userspace-networking /var/log/tailscaled.log 2>&1 &) -``` - -### 2. SSH Port 2222 (FIXED) -**Problem:** BoxLite bootstrap started sshd on port 22, but ocaptain expects port 2222. - -**Solution:** Added SSH port configuration to bootstrap: -```bash -echo 'Port 2222' > /etc/ssh/sshd_config.d/ocaptain.conf -``` - -### 3. No systemd (FIXED) -**Problem:** `ship.py:_bootstrap_tailscale()` uses `systemctl` which doesn't exist in BoxLite VMs. - -**Solution:** Added `skip_install` parameter to skip Tailscale setup for BoxLite (already done in provider bootstrap). - -### 4. GitHub CLI Missing (FIXED) -**Problem:** BoxLite uses bare Ubuntu image without `gh` installed. - -**Solution:** Added gh installation to bootstrap script. - -### 5. SSH Key Path vs Content (FIXED) -**Problem:** `get_ssh_keypair()` returns key content, but code was passing it as a path. - -**Solution:** Use explicit path `~/.config/ocaptain/id_ed25519` instead. - -### 6. Connection Port for BoxLite (FIXED) -**Problem:** `get_connection()` and `wait_ready()` need to use port 2222 and explicit key for BoxLite. - -**Solution:** Added BoxLite-specific handling in both functions. - -### 7. apt Lock Conflict (FIXED) -**Problem:** `ship.py` runs `apt-get update && apt-get install -y tmux expect` after the BoxLite provider bootstrap. Since the bootstrap script also uses apt-get, this caused apt lock conflicts that made the command hang indefinitely. - -**Solution:** -- Install tmux and expect in the BoxLite bootstrap script (alongside other packages) -- Skip the tmux/expect installation in `ship.py` for BoxLite provider - -## Current Status - -### What Works ✓ -- BoxLite import and VM creation -- Full bootstrap script (tailscale, sshd, gh, tmux, expect, claude code) - ~2 min -- ThreadPoolExecutor usage (like voyage does) -- SSH connection via `get_connection()` -- Running commands on VM -- VM destruction -- **Full voyage flow (`ocaptain sail`)** - WORKING! - -### Test Results - -``` -✓ Voyage voyage-4179d8ea7833 launched - Repo: octocat/Hello-World - Branch: voyage-4179d8ea7833 - Ships: 1 - Plan: react-hello-world -``` - -## Files Changed - -1. `src/ocaptain/providers/boxlite.py` - - Fixed tailscaled daemonization - - Added SSH port 2222 configuration - - Added gh installation - - Added tmux and expect installation (moved from ship.py) - - Added Tailscale directory setup - - Added verification steps - - Fixed `wait_ready()` to use port 2222 and correct key path - -2. `src/ocaptain/ship.py` - - Added `skip_install` parameter to `_bootstrap_tailscale()` - - Skip Tailscale setup for BoxLite - - Skip tmux/expect installation for BoxLite (done in provider bootstrap) - -3. `src/ocaptain/provider.py` - - Added BoxLite-specific handling in `get_connection()` - -## Test Commands - -```bash -# Run full voyage (WORKS!) -OCAPTAIN_PROVIDER=boxlite uv run ocaptain sail examples/generated-plans/react-hello-world/ -n 1 --no-telemetry - -# Clean up -OCAPTAIN_PROVIDER=boxlite uv run ocaptain sink --all --force -``` From 5120a304259a8abc57898f761d4c9e403823881d Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Sat, 31 Jan 2026 19:26:40 -0800 Subject: [PATCH 16/16] refactor: extract shared provider utilities to common.py Consolidate duplicated code across exedev and sprites providers: - run_cli_command(): unified CLI execution with error handling - setup_ssh_keys(): SSH key injection on VMs - install_claude_code(): Claude Code installation - poll_until_ready(): generic retry/polling logic Net reduction of ~78 lines across providers while improving maintainability with single points of change. --- src/ocaptain/providers/common.py | 127 ++++++++++++++++++++++++++++++ src/ocaptain/providers/exedev.py | 49 +++--------- src/ocaptain/providers/sprites.py | 89 +++++---------------- 3 files changed, 157 insertions(+), 108 deletions(-) create mode 100644 src/ocaptain/providers/common.py diff --git a/src/ocaptain/providers/common.py b/src/ocaptain/providers/common.py new file mode 100644 index 0000000..9468acd --- /dev/null +++ b/src/ocaptain/providers/common.py @@ -0,0 +1,127 @@ +"""Shared utilities for VM providers.""" + +from __future__ import annotations + +import logging +import subprocess # nosec: B404 +import sys +import time +from collections.abc import Callable +from io import BytesIO +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from ..provider import VM + + +class HasStdout(Protocol): + """Protocol for objects with a stdout attribute.""" + + stdout: str + + +class ConnectionLike(Protocol): + """Protocol for connection objects with run() and put() methods.""" + + def run(self, cmd: str, *, hide: bool = False, warn: bool = False) -> HasStdout: + """Run a command.""" + ... + + def put(self, local: BytesIO, remote: str) -> None: + """Upload a file.""" + ... + + +def run_cli_command( + cmd: list[str], *, check: bool = True, description: str = "command" +) -> subprocess.CompletedProcess[str]: + """Run a CLI command with standard error handling. + + Args: + cmd: Command and arguments to run + check: If True, raise on non-zero exit + description: Description for error messages + + Returns: + CompletedProcess with stdout/stderr + """ + result = subprocess.run(cmd, capture_output=True, text=True, check=False) # nosec: B603 + if result.returncode != 0 and check: + print(f"{description} failed: {' '.join(cmd)}", file=sys.stderr) + print(f"stderr: {result.stderr}", file=sys.stderr) + print(f"stdout: {result.stdout}", file=sys.stderr) + result.check_returncode() + return result + + +def setup_ssh_keys(connection: ConnectionLike, private_key: str, public_key: str) -> None: + """Set up SSH keys on a VM via a connection. + + Creates .ssh directory, writes private key, and appends public key to authorized_keys. + + Args: + connection: Connection object with run() and put() methods + private_key: Private key content + public_key: Public key content + """ + # Get home directory + result = connection.run("echo $HOME", hide=True) + home = result.stdout.strip() + ssh_dir = f"{home}/.ssh" + + # Ensure .ssh directory exists with correct permissions + connection.run(f"mkdir -p {ssh_dir} && chmod 700 {ssh_dir}") + + # Write private key + connection.put(BytesIO(private_key.encode()), f"{ssh_dir}/id_ed25519") + connection.run(f"chmod 600 {ssh_dir}/id_ed25519") + + # Append public key to authorized_keys + connection.put(BytesIO(public_key.encode()), f"{ssh_dir}/ocaptain_key.pub") + connection.run(f"cat {ssh_dir}/ocaptain_key.pub >> {ssh_dir}/authorized_keys") + connection.run(f"chmod 600 {ssh_dir}/authorized_keys") + + +def install_claude_code(connection: ConnectionLike) -> None: + """Install or update Claude Code on a VM. + + Args: + connection: Connection object with run() method + """ + connection.run("curl -fsSL https://claude.ai/install.sh | bash", hide=True) + + +def poll_until_ready( + check_fn: Callable[[], bool], + *, + timeout: int = 300, + interval: int = 5, + vm: VM | None = None, + logger: logging.Logger | None = None, +) -> bool: + """Poll until a check function returns True. + + Args: + check_fn: Function that returns True when ready, raises on failure + timeout: Maximum time to wait in seconds + interval: Time between retries in seconds + vm: Optional VM for logging + logger: Optional logger for debug messages + + Returns: + True if ready, False if timeout + """ + start = time.time() + + while time.time() - start < timeout: + try: + if check_fn(): + return True + except KeyboardInterrupt: + raise + except Exception as e: + if logger and vm: + logger.debug("Check failed for %s: %s", vm.name, e) + time.sleep(interval) + + return False diff --git a/src/ocaptain/providers/exedev.py b/src/ocaptain/providers/exedev.py index 6fcc059..c928268 100644 --- a/src/ocaptain/providers/exedev.py +++ b/src/ocaptain/providers/exedev.py @@ -5,27 +5,17 @@ import json import subprocess # nosec: B404 -import time -from io import BytesIO from fabric import Connection from ..config import get_ssh_keypair from ..provider import VM, Provider, VMStatus, register_provider +from .common import install_claude_code, poll_until_ready, run_cli_command, setup_ssh_keys def _run_exedev(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]: """Run an exe.dev command via SSH.""" - cmd = ["ssh", "exe.dev", *args] - result = subprocess.run(cmd, capture_output=True, text=True, check=False) # nosec: B603, B607 - if result.returncode != 0 and check: - import sys - - print(f"exe.dev command failed: {' '.join(args)}", file=sys.stderr) - print(f"stderr: {result.stderr}", file=sys.stderr) - print(f"stdout: {result.stdout}", file=sys.stderr) - result.check_returncode() - return result + return run_cli_command(["ssh", "exe.dev", *args], check=check, description="exe.dev command") @register_provider("exedev") @@ -56,29 +46,12 @@ def _inject_ssh_keys(self, vm: VM) -> None: private_key, public_key = get_ssh_keypair() with Connection(vm.ssh_dest) as c: - home = c.run("echo $HOME", hide=True).stdout.strip() - ssh_dir = f"{home}/.ssh" - - # Ensure .ssh directory exists with correct permissions - c.run(f"mkdir -p {ssh_dir} && chmod 700 {ssh_dir}") - - # Write private key - c.put(BytesIO(private_key.encode()), f"{ssh_dir}/id_ed25519") - c.run(f"chmod 600 {ssh_dir}/id_ed25519") - - # Append public key to authorized_keys - c.put(BytesIO(public_key.encode()), f"{ssh_dir}/ocaptain_key.pub") - c.run(f"cat {ssh_dir}/ocaptain_key.pub >> {ssh_dir}/authorized_keys") - c.run(f"chmod 600 {ssh_dir}/authorized_keys") + setup_ssh_keys(c, private_key, public_key) # Register with exe.dev by running 'ssh exe.dev' (completes registration) c.run("ssh -o StrictHostKeyChecking=no exe.dev whoami", hide=True, warn=True) - # Update Claude Code to latest (exe.dev base image may be outdated) - c.run( - "curl -fsSL https://claude.ai/install.sh | bash", - hide=True, - ) + install_claude_code(c) def destroy(self, vm_id: str) -> None: _run_exedev("rm", vm_id) @@ -113,14 +86,10 @@ def list(self, prefix: str | None = None) -> list[VM]: def wait_ready(self, vm: VM, timeout: int = 300) -> bool: """Poll until SSH is accessible.""" - start = time.time() - while time.time() - start < timeout: - try: - with Connection(vm.ssh_dest, connect_timeout=5) as c: - c.run("echo ready", hide=True) - return True - except Exception: - time.sleep(5) + def check_ssh() -> bool: + with Connection(vm.ssh_dest, connect_timeout=5) as c: + c.run("echo ready", hide=True) + return True - return False + return poll_until_ready(check_ssh, timeout=timeout, interval=5) diff --git a/src/ocaptain/providers/sprites.py b/src/ocaptain/providers/sprites.py index bac9f4d..8cc9a08 100644 --- a/src/ocaptain/providers/sprites.py +++ b/src/ocaptain/providers/sprites.py @@ -7,9 +7,9 @@ - Tailscale for networking between ships and laptop """ +import logging import re import subprocess # nosec: B404 -import time from dataclasses import dataclass from io import BytesIO from pathlib import Path @@ -17,6 +17,9 @@ from ..config import CONFIG, get_ssh_keypair from ..provider import VM, Provider, VMStatus, register_provider +from .common import install_claude_code, poll_until_ready, run_cli_command, setup_ssh_keys + +logger = logging.getLogger(__name__) def _get_sprites_org() -> str: @@ -33,16 +36,7 @@ def _get_sprites_org() -> str: def _run_sprite(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]: """Run a sprite CLI command.""" - cmd = ["sprite", *args] - result = subprocess.run(cmd, capture_output=True, text=True, check=False) # nosec: B603, B607 - if result.returncode != 0 and check: - import sys - - print(f"sprite command failed: {' '.join(args)}", file=sys.stderr) - print(f"stderr: {result.stderr}", file=sys.stderr) - print(f"stdout: {result.stdout}", file=sys.stderr) - result.check_returncode() - return result + return run_cli_command(["sprite", *args], check=check, description="sprite command") @dataclass @@ -204,28 +198,8 @@ def _setup_sprite(self, vm: VM) -> None: private_key, public_key = get_ssh_keypair() with self.get_connection(vm) as c: - # Get home directory - result = c.run("echo $HOME", hide=True) - home = result.stdout.strip() - ssh_dir = f"{home}/.ssh" - - # Ensure .ssh directory exists with correct permissions - c.run(f"mkdir -p {ssh_dir} && chmod 700 {ssh_dir}") - - # Write private key - c.put(BytesIO(private_key.encode()), f"{ssh_dir}/id_ed25519") - c.run(f"chmod 600 {ssh_dir}/id_ed25519") - - # Append public key to authorized_keys - c.put(BytesIO(public_key.encode()), f"{ssh_dir}/ocaptain_key.pub") - c.run(f"cat {ssh_dir}/ocaptain_key.pub >> {ssh_dir}/authorized_keys") - c.run(f"chmod 600 {ssh_dir}/authorized_keys") - - # Install Claude Code - c.run( - "curl -fsSL https://claude.ai/install.sh | bash", - hide=True, - ) + setup_ssh_keys(c, private_key, public_key) + install_claude_code(c) def destroy(self, vm_id: str) -> None: """Destroy a sprite VM.""" @@ -264,41 +238,20 @@ def list(self, prefix: str | None = None) -> list[VM]: def wait_ready(self, vm: VM, timeout: int = 300) -> bool: """Poll until sprite is accessible via exec.""" - import logging - - logger = logging.getLogger(__name__) - start = time.time() - - while time.time() - start < timeout: - try: - result = subprocess.run( # nosec: B603, B607 - [ - "sprite", - "exec", - "-o", - self.org, - "-s", - vm.name, - "echo", - "ready", - ], - capture_output=True, - text=True, - timeout=10, - check=False, - ) - if result.returncode == 0 and "ready" in result.stdout: - return True - except KeyboardInterrupt: - raise - except subprocess.TimeoutExpired: - logger.debug("Sprite %s exec timed out, retrying...", vm.name) - except Exception as e: - logger.debug("Sprite %s exec failed: %s, retrying...", vm.name, e) - - time.sleep(5) - - return False + + def check_sprite_exec() -> bool: + result = subprocess.run( # nosec: B603, B607 + ["sprite", "exec", "-o", self.org, "-s", vm.name, "echo", "ready"], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + return result.returncode == 0 and "ready" in result.stdout + + return poll_until_ready( + check_sprite_exec, timeout=timeout, interval=5, vm=vm, logger=logger + ) def get_connection(self, vm: VM) -> SpriteConnection: """Get a SpriteConnection for the VM."""