Implement SAND-WP-0005: extension SDK and ext.vm-packer

Add SandboxExtension base class, extension SDK docs, vm-packer attach mode
for build-machines VMs, profile.vm-haskell-build, SSH port support, tests,
and migration docs.
This commit is contained in:
2026-06-24 01:47:07 +02:00
parent c8126672ee
commit cec0fc6348
20 changed files with 679 additions and 16 deletions

View File

@@ -38,6 +38,7 @@ Sandbox CLI (v0):
```bash
sandboxer create # canary self-deploy (profile.sandbox-canary)
sandboxer create --profile profile.compose-e2e --input repo=/path/to/repo
sandboxer create --profile profile.vm-haskell-build --input vm=haskell-build --input repo=/path
sandboxer get <id>
sandboxer list
sandboxer destroy <id>

View File

@@ -356,8 +356,8 @@ follows evidence.
4. ~~**Profile catalog start**~~`profile.compose-e2e`, `profile.sandbox-canary`
5. ~~**Registry entry**~~`capability.execution.sandbox-provision`
6. ~~**Sibling integration notes**~~`docs/integrations/`
7. **Extension SDK sketch**contract for P1 backends (vm-packer, Daytona OSS)
8. **wise-validator** — separate repo/workplan (SAND-WP-0003); not a sand-boxer dependency
7. ~~**Extension SDK sketch**~~done (`docs/extension-sdk.md`, `ext.vm-packer` attach mode)
8. ~~**wise-validator**~~ — sibling repo (SAND-WP-0003); not a sand-boxer dependency
---

View File

@@ -104,12 +104,10 @@ own tunnels or CAs.
## Current State
- **Status:** v0 operational — self-hosted compose path proven on CoulombCore
- **Workplans finished:** SAND-WP-0001 (bootstrap), 0002 (meta-framework +
`ext.compose-ssh`), 0003 (wise-validator extraction, sibling repo), 0008 (host
telemetry / self-canary)
- **Workplans finished:** SAND-WP-00010005, 0008 (see `workplans/`)
- **Package:** `src/sandboxer/` — CLI, manager, extensions, telemetry, HTTP API
- **Profiles:** `profile.compose-e2e`, `profile.sandbox-canary`
- **Extensions:** `ext.compose-ssh` only
- **Profiles:** `profile.compose-e2e`, `profile.sandbox-canary`, `profile.vm-haskell-build`
- **Extensions:** `ext.compose-ssh`, `ext.vm-packer` (attach mode)
- **Registry:** `capability.execution.sandbox-provision` indexed (draft)
- **Tests:** 26 pytest cases; `make check` green
- **Sibling:** wise-validator ships `validate run` (SAND-WP-0003)
@@ -145,7 +143,7 @@ cd ~/the-custodian && make e2e REPO=activity-core
- ~~`make e2e REPO=` shim~~ — done (SAND-WP-0004; delegates to `validate run`)
- TTL auto-expiry / `extend_ttl` enforcement
- `ext.vm-packer` / build-machines migration (SAND-WP-0005)
- ~~`ext.vm-packer` attach mode~~ — done (SAND-WP-0005); Packer build orchestration deferred
- SaaS extensions (E2B, Modal) or payments layer (SAND-WP-0006)
- Snapshot / restore / checkpoint profiles (SAND-WP-0007)
- Formal ops-bridge tunnel attachment in reachability descriptor

102
docs/extension-sdk.md Normal file
View File

@@ -0,0 +1,102 @@
# Extension SDK
Author guide for sand-boxer backend adapters. Version 0.1 — SAND-WP-0005.
## Contract
Every extension implements three methods:
```text
provision(profile, inputs, host) → handle dict
wait_ready(handle) → reachability dict
teardown(handle) → cleanup report dict
```
Optional (SaaS, deferred): `estimate_cost(profile, duration) → MeterQuote`
### Base class
```python
from sandboxer.extensions.base import SandboxExtension
class MyExtension(SandboxExtension):
def provision(self, profile, inputs, host): ...
def wait_ready(self, handle): ...
def teardown(self, handle): ...
```
Reference implementations:
| Extension | Module | Mode |
|-----------|--------|------|
| `ext.compose-ssh` | `compose_ssh.py` | Remote compose stack |
| `ext.vm-packer` | `vm_packer.py` | Attach workspace on pre-built VM |
## Registration
1. Add `extensions/ext.<name>.yaml`:
```yaml
id: ext.my-backend
title: My Backend
handler: sandboxer.extensions.my_backend:MyExtension
capabilities:
isolation_levels: [container]
pricing_model: self-hosted
config:
key: value
```
2. Add a profile binding in `profiles/profile.<slug>.yaml` with `extension: ext.my-backend`.
3. Register capability metadata in `registry/` when ready for reuse-surface.
Loader validates `capabilities.isolation_levels` and `capabilities.pricing_model`
at startup (`sandboxer.extensions.registry`).
## Handle and reachability
**Handle** (returned by `provision`, stored in manager): opaque dict passed to
`wait_ready` and `teardown`. Include at minimum:
- `sandbox_id`
- `host` (placement host)
- Fields your extension needs for SSH/API (e.g. `remote_dir`, `vm_target`)
**Reachability** (returned by `wait_ready`): exposed on `SandboxStatus.reachability`:
- `ssh` — SSH destination string
- `remote_dir` — workspace path on remote
- `host` — placement host
- `compose_project` — compose-ssh only
## Inputs convention
Profiles declare semantics; extensions validate required `inputs` keys:
| Extension | Required inputs | Optional |
|-----------|-----------------|----------|
| compose-ssh | `repo` | `sandbox_id` |
| vm-packer | `vm` or `ssh_target` | `repo`, `tunnel_port`, `ssh_port`, `workspace_dir` |
Consumer attribution travels on `SandboxCreateRequest.consumer`, not extension inputs.
## Testing
Mock SSH/subprocess in unit tests. See `tests/test_compose_ssh.py`, `tests/test_vm_packer.py`.
Pattern:
```python
with patch.object(SSHConfig, "run", return_value=(0, "ready")):
ext = VMPackerExtension()
handle = ext.provision(profile, {"vm": "haskell-build"}, "localhost")
```
## Deferred
| Feature | Workplan |
|---------|----------|
| Packer build orchestration from `create` | Future WP |
| SaaS adapters + `estimate_cost` | SAND-WP-0006 |
| Multi-backend routing engine | SAND-WP-0006 |
| Snapshot / restore hooks | SAND-WP-0007 |

View File

@@ -0,0 +1,62 @@
# Migration — build-machines → ext.vm-packer
Maps `the-custodian/infra/build-machines/` to sand-boxer `profile.vm-haskell-build`.
## What moved
| Legacy (build-machines) | sand-boxer v0 |
|-------------------------|---------------|
| Packer OVA build | **Unchanged** — operator runs Packer in the-custodian |
| VM boot + build-agent registration | **Unchanged** — systemd agent on VM |
| `make remote-build PROJECT=` | `sandboxer create` + SSH into `reachability.remote_dir` |
| Isolated workspace `/build/<project>` | `/build/sbx-<sandbox_id>/` per create |
| `make bridge-status` | `ssh -p 12222 build@localhost` or `sandboxer inspect` (future) |
## v0 attach workflow
1. Build/import VM per [build-machines README](~/the-custodian/infra/build-machines/README.md).
2. Ensure tunnel is up (`make bridge-status` in build-machines).
3. Create sand-boxer workspace:
```bash
export SANDBOXER_VM_TUNNEL_PORT=12222 # or use SSH alias
sandboxer create \
--profile profile.vm-haskell-build \
--input vm=haskell-build \
--input repo=~/projects/my-haskell-app \
--host localhost
```
4. Run builds on VM:
```bash
ssh haskell-build "cd <remote_dir> && source ~/.ghcup/env && cabal build all"
```
5. Destroy workspace (VM stays running):
```bash
sandboxer destroy <sandbox_id>
```
## Inputs
| Input | Purpose |
|-------|---------|
| `vm` | SSH config alias (e.g. `haskell-build`) |
| `ssh_target` | Alias for `vm` |
| `tunnel_port` | Local reverse-tunnel port (default via `SANDBOXER_VM_TUNNEL_PORT`) |
| `repo` | Optional rsync source to workspace |
| `workspace_dir` | Override workspace path on VM |
## Not migrated yet
- Automated Packer `create` trigger from sand-boxer API
- State Hub capability-catalog sync from build-agent (agent unchanged)
- Port registry automation (`port-registry.yml`)
- `make remote-build` Makefile targets in the-custodian (add shim in follow-on if needed)
## Runbook
`docs/runbooks/profile-vm-haskell-build.md`

View File

@@ -28,9 +28,21 @@ Recorded after SAND-WP-0002-T10 remote verification on CoulombCore (`92.205.130.
**e2e-framework migration arc complete** (provision: sand-boxer, validation:
wise-validator, operator entry: `make e2e`).
## build-machines (SAND-WP-0005) — attach mode delivered
| Legacy (`build-machines`) | sand-boxer today | Notes |
|---------------------------|------------------|-------|
| Packer OVA build | Operator-driven (unchanged) | Not triggered by `create` |
| `make remote-build` rsync + SSH | `sandboxer create --profile profile.vm-haskell-build` | Workspace `/build/sbx-<id>/` |
| VM teardown | N/A | `destroy` removes workspace only; VM persists |
| Extension author contract | `docs/extension-sdk.md` | `SandboxExtension` base class |
Deferred: Packer orchestration from API, `make remote-build` shim.
## sand-boxer follow-ons
| Item | Workplan |
|------|----------|
| Self-canary + host telemetry | SAND-WP-0008 |
| Default `sandboxer create` without repo | SAND-WP-0008-T06 |
| SaaS extensions + payments | SAND-WP-0006 |
| Snapshot / restore | SAND-WP-0007 |
| TTL enforcement + scheduled reap | TBD |

View File

@@ -0,0 +1,50 @@
# profile.vm-haskell-build — Runbook
Attach an isolated Haskell build workspace on a pre-built VM (build-machines lineage).
## Prerequisites
- VM built/imported per `the-custodian/infra/build-machines/`
- SSH tunnel up (`make bridge-status` in build-machines)
- `~/.ssh/config` entry for `haskell-build` (or `tunnel_port` / `SANDBOXER_VM_TUNNEL_PORT`)
- `sandboxer` on PATH
## Create workspace
```bash
# Via SSH alias (recommended):
sandboxer create \
--profile profile.vm-haskell-build \
--input vm=haskell-build \
--input repo=~/projects/my-app \
--host localhost
# Via tunnel port:
export SANDBOXER_VM_TUNNEL_PORT=12222
sandboxer create \
--profile profile.vm-haskell-build \
--input vm=build@localhost \
--input tunnel_port=12222 \
--input repo=~/projects/my-app \
--host localhost
```
## Build on VM
Use `reachability.remote_dir` from create output:
```bash
ssh haskell-build "cd /build/sbx-<id> && source ~/.ghcup/env && cabal build all"
```
## Destroy
```bash
sandboxer destroy <sandbox_id>
```
Removes workspace only; the VM keeps running.
## Migration reference
`docs/migration-build-machines.md`

View File

@@ -3,6 +3,8 @@
Backend adapters for sandbox establishment. Each extension is declared in
`ext.<name>.yaml` and implements `provision`, `wait_ready`, and `teardown`.
Author guide: `docs/extension-sdk.md`
## ext.compose-ssh
Self-hosted extension migrated from `the-custodian/e2e-framework/`.
@@ -29,4 +31,19 @@ step. sand-boxer splits responsibilities:
| health checks + test_command | wise-validator (SAND-WP-0003) |
Interim workflow: `sandboxer create --profile profile.compose-e2e --input repo=...`
then run validation separately until wise-validator migration lands.
then run validation separately until wise-validator migration lands.
## ext.vm-packer
Attach mode for pre-built VMs (`the-custodian/infra/build-machines/` lineage).
**Provision:** SSH to VM alias or tunnel port → isolated workspace under `/build/sbx-<id>/`
→ optional rsync of `repo` input.
**wait_ready:** Confirms workspace directory exists on VM.
**teardown:** Removes workspace only; VM persists.
**Profile:** `profile.vm-haskell-build` — see `docs/runbooks/profile-vm-haskell-build.md`.
Packer build / OVA import remains operator-driven (not triggered by `create`).

View File

@@ -0,0 +1,16 @@
id: ext.vm-packer
title: VM workspace (Packer lineage)
description: >
Attach an isolated workspace on a pre-built VM (the-custodian build-machines
lineage). v0 supports attach mode via SSH alias or tunnel port; Packer build
orchestration is operator-driven and deferred.
handler: sandboxer.extensions.vm_packer:VMPackerExtension
capabilities:
isolation_levels: [microvm]
regions: []
persistence: true
pricing_model: self-hosted
config:
workspace_base: /build
ssh_user: build
ready_timeout_s: 30

View File

@@ -0,0 +1,35 @@
id: profile.vm-haskell-build
version: "1.0.0"
extension: ext.vm-packer
isolation:
level: microvm
network:
default: deny
egress: []
workspace:
mode: remote-canonical
access: rw
scope_default: session
ttl:
default: 8h
max: 24h
idle_reap: null
resources:
cpu: null
memory_mb: null
setup:
instructions: >
Requires a running build VM from the-custodian/infra/build-machines with SSH
tunnel or alias (e.g. haskell-build). Set SANDBOXER_VM_TUNNEL_PORT=12222 or
pass --input tunnel_port=12222.
secret_refs: []
placement:
prefer: [localhost]
fallback: [workstation]
reachability:
tunnel: ops-bridge
identity: ops-warden
metadata:
cost_class: self-hosted
latency_class: standard
observability: none

View File

@@ -61,6 +61,9 @@ class SandboxManager:
status.inputs["compose_file"] = handle.get("compose_file", "")
status.inputs["ssh_user"] = handle.get("ssh_user", "")
status.inputs["compose_cmd"] = handle.get("compose_cmd", "")
status.inputs["ssh_port"] = handle.get("ssh_port", "")
status.inputs["vm_target"] = handle.get("vm_target", "")
status.inputs["vm_host"] = handle.get("vm_host", "")
reach = backend.wait_ready(handle)
status.reachability = Reachability(**reach)
status.state = SandboxState.READY
@@ -133,6 +136,9 @@ class SandboxManager:
"compose_file": status.inputs.get("compose_file", ""),
"ssh_user": status.inputs.get("ssh_user", ""),
"compose_cmd": status.inputs.get("compose_cmd", ""),
"ssh_port": status.inputs.get("ssh_port", ""),
"vm_target": status.inputs.get("vm_target", ""),
"vm_host": status.inputs.get("vm_host", ""),
}
backend.teardown(handle)

View File

@@ -0,0 +1,34 @@
"""Extension author SDK — base contract for sandbox backends."""
from __future__ import annotations
import uuid
from abc import ABC, abstractmethod
from typing import Any
from sandboxer.models import Profile
class SandboxExtension(ABC):
"""Base class for self-hosted and SaaS sandbox extensions."""
def __init__(self, config: dict[str, Any] | None = None) -> None:
self.config: dict[str, Any] = config or {}
@staticmethod
def new_sandbox_id(inputs: dict[str, str]) -> str:
return inputs.get("sandbox_id") or str(uuid.uuid4())[:8]
@abstractmethod
def provision(
self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]:
"""Create or attach sandbox resources. Returns a handle dict for later ops."""
@abstractmethod
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
"""Confirm reachability. Returns reachability descriptor fields."""
@abstractmethod
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
"""Release sandbox resources. Returns cleanup report fields."""

View File

@@ -3,21 +3,22 @@
from __future__ import annotations
import os
import uuid
from pathlib import Path
from typing import Any
import yaml
from sandboxer.extensions.base import SandboxExtension
from sandboxer.extensions.ssh import SSHConfig
from sandboxer.models import Profile
class ComposeSSHExtension:
class ComposeSSHExtension(SandboxExtension):
"""Provision isolated compose stacks on a remote host via SSH."""
def __init__(self, config: dict[str, Any] | None = None) -> None:
cfg = config or {}
super().__init__(config)
cfg = self.config
self.base_dir: str = cfg.get("base_dir", "/tmp/sandboxer")
self.ssh_user: str | None = cfg.get("ssh_user")
self.compose_timeout_s: int = int(cfg.get("compose_timeout_s", 180))
@@ -44,7 +45,7 @@ class ComposeSSHExtension:
if not repo_path.exists():
raise FileNotFoundError(f"Repo path does not exist: {repo_path}")
sandbox_id = inputs.get("sandbox_id") or str(uuid.uuid4())[:8]
sandbox_id = self.new_sandbox_id(inputs)
remote_dir = f"{self.base_dir}/{sandbox_id}"
ssh = SSHConfig.from_env(host, user=self.ssh_user or None)

View File

@@ -13,6 +13,7 @@ class SSHConfig:
host: str
user: str | None = None
key: str | None = None
port: int | None = None
connect_timeout: int = 15
@property
@@ -47,6 +48,8 @@ class SSHConfig:
]
if self.key:
args += ["-i", self.key]
if self.port:
args += ["-p", str(self.port)]
args.append(self.destination)
return args
@@ -73,6 +76,8 @@ class SSHConfig:
ssh_cmd = "ssh -o StrictHostKeyChecking=no"
if self.key:
ssh_cmd = f"ssh -i {self.key} -o StrictHostKeyChecking=no"
if self.port:
ssh_cmd += f" -p {self.port}"
rsync_args += ["-e", ssh_cmd, f"{local_path}/", f"{self.target}:{remote_dir}/"]
result = subprocess.run(rsync_args, capture_output=True, text=True, timeout=timeout)
if result.returncode != 0:

View File

@@ -0,0 +1,125 @@
"""ext.vm-packer — attach to pre-built VMs (build-machines lineage).
v0 supports **attach** mode only: connect via SSH to an existing VM (tunnel alias,
localhost:port, or direct host). Creates an isolated workspace directory; teardown
removes the workspace, not the VM.
Full Packer build / OVA import orchestration is deferred — operators still build
images via the-custodian/infra/build-machines/ workflows.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from sandboxer.extensions.base import SandboxExtension
from sandboxer.extensions.ssh import SSHConfig
from sandboxer.models import Profile
class VMPackerExtension(SandboxExtension):
"""Attach sandbox workspace on a pre-provisioned VM."""
def __init__(self, config: dict[str, Any] | None = None) -> None:
super().__init__(config)
self.workspace_base: str = self.config.get("workspace_base", "/build")
self.default_user: str | None = self.config.get("ssh_user")
self.ready_timeout_s: int = int(self.config.get("ready_timeout_s", 30))
def _ssh_from_handle(self, handle: dict[str, str]) -> SSHConfig:
port_raw = handle.get("ssh_port") or os.environ.get("SANDBOXER_VM_SSH_PORT")
port = int(port_raw) if port_raw else None
user = handle.get("ssh_user") or self.default_user
host = handle.get("vm_host") or handle.get("host") or "localhost"
return SSHConfig(
host=host,
user=user,
key=os.environ.get("SANDBOXER_SSH_KEY"),
port=port,
)
def _resolve_vm_host(
self, inputs: dict[str, str], placement_host: str
) -> tuple[str, int | None]:
"""Return SSH host and optional port for attach mode."""
if inputs.get("ssh_port"):
return inputs.get("vm_host") or placement_host or "localhost", int(inputs["ssh_port"])
if inputs.get("tunnel_port"):
return "localhost", int(inputs["tunnel_port"])
env_port = os.environ.get("SANDBOXER_VM_TUNNEL_PORT")
if env_port and (placement_host in ("localhost", "127.0.0.1", "workstation")):
return "localhost", int(env_port)
return inputs.get("vm_host") or placement_host, None
def provision(
self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]:
vm_target = inputs.get("vm") or inputs.get("ssh_target")
if not vm_target:
raise ValueError("inputs.vm or inputs.ssh_target is required for ext.vm-packer")
sandbox_id = self.new_sandbox_id(inputs)
remote_dir = inputs.get("workspace_dir") or f"{self.workspace_base}/sbx-{sandbox_id}"
vm_host, ssh_port = self._resolve_vm_host(inputs, host)
ssh_user = inputs.get("ssh_user") or self.default_user
if "@" in vm_target or "." not in vm_target:
ssh = SSHConfig(host=vm_target, user=ssh_user, port=ssh_port)
connect_host = vm_target
else:
ssh = SSHConfig(host=vm_host, user=ssh_user, port=ssh_port)
connect_host = vm_host
rc, out = ssh.run(f"mkdir -p {remote_dir}")
if rc != 0:
raise RuntimeError(f"Failed to create workspace on VM: {out}")
repo_path_str = ""
repo = inputs.get("repo")
if repo:
repo_path = Path(repo).expanduser().resolve()
if not repo_path.exists():
raise FileNotFoundError(f"Repo path does not exist: {repo_path}")
ssh.rsync(repo_path, remote_dir)
repo_path_str = str(repo_path)
return {
"sandbox_id": sandbox_id,
"host": host,
"vm_host": connect_host,
"vm_target": vm_target,
"remote_dir": remote_dir,
"ssh_user": ssh.user or "",
"ssh_port": str(ssh.port) if ssh.port else "",
"mode": inputs.get("mode", "attach"),
"repo": repo_path_str,
}
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
ssh = self._ssh_from_handle(handle)
remote_dir = handle["remote_dir"]
cmd = f"test -d {remote_dir} && echo ready"
rc, out = ssh.run(cmd, timeout=self.ready_timeout_s)
if rc != 0 or "ready" not in out:
raise RuntimeError(f"VM workspace not ready: {out}")
return {
"ssh": ssh.target,
"remote_dir": remote_dir,
"host": handle.get("host"),
}
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
remote_dir = handle.get("remote_dir")
cleaned_dir = False
if remote_dir:
ssh = self._ssh_from_handle(handle)
rc, _ = ssh.run(f"rm -rf {remote_dir}", timeout=60)
cleaned_dir = rc == 0
return {
"workspace_removed": str(cleaned_dir),
"remote_dir": remote_dir or "",
"vm_preserved": "true",
}

View File

@@ -0,0 +1,16 @@
"""Extension SDK base class tests."""
from sandboxer.extensions.base import SandboxExtension
from sandboxer.extensions.compose_ssh import ComposeSSHExtension
from sandboxer.extensions.vm_packer import VMPackerExtension
def test_reference_extensions_subclass_base() -> None:
assert issubclass(ComposeSSHExtension, SandboxExtension)
assert issubclass(VMPackerExtension, SandboxExtension)
def test_new_sandbox_id_from_inputs() -> None:
assert SandboxExtension.new_sandbox_id({"sandbox_id": "fixed123"}) == "fixed123"
generated = SandboxExtension.new_sandbox_id({})
assert len(generated) == 8

View File

@@ -0,0 +1,15 @@
"""Extension registry loads vm-packer."""
from sandboxer.extensions.registry import load_all_extensions, load_extension
def test_load_vm_packer_extension() -> None:
ext = load_extension("ext.vm-packer")
assert ext.id == "ext.vm-packer"
assert "vm_packer" in ext.handler
def test_load_all_includes_vm_packer() -> None:
extensions = load_all_extensions()
assert "ext.compose-ssh" in extensions
assert "ext.vm-packer" in extensions

View File

@@ -10,4 +10,10 @@ def test_destination_uses_ssh_config_when_user_unset() -> None:
def test_destination_includes_explicit_user() -> None:
ssh = SSHConfig(host="92.205.130.254", user="tegwick")
assert ssh.destination == "tegwick@92.205.130.254"
assert ssh.destination == "tegwick@92.205.130.254"
def test_ssh_base_includes_port() -> None:
ssh = SSHConfig(host="localhost", user="build", port=12222)
assert "-p" in ssh.ssh_base()
assert "12222" in ssh.ssh_base()

76
tests/test_vm_packer.py Normal file
View File

@@ -0,0 +1,76 @@
"""VMPackerExtension unit tests."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from sandboxer.extensions.vm_packer import VMPackerExtension
from sandboxer.models import Profile
def _profile() -> Profile:
return Profile.model_validate(
{
"id": "profile.vm-haskell-build",
"version": "1.0.0",
"extension": "ext.vm-packer",
}
)
def test_provision_attach_via_alias(tmp_path: Path) -> None:
repo = tmp_path / "proj"
repo.mkdir()
ext = VMPackerExtension()
with (
patch("sandboxer.extensions.vm_packer.SSHConfig.run", return_value=(0, "")),
patch("sandboxer.extensions.vm_packer.SSHConfig.rsync") as rsync,
):
handle = ext.provision(
_profile(),
{"vm": "haskell-build", "repo": str(repo)},
"localhost",
)
assert handle["vm_target"] == "haskell-build"
assert handle["remote_dir"].startswith("/build/sbx-")
rsync.assert_called_once()
def test_provision_requires_vm_input() -> None:
ext = VMPackerExtension()
with pytest.raises(ValueError, match="inputs.vm"):
ext.provision(_profile(), {}, "localhost")
def test_wait_ready_success() -> None:
ext = VMPackerExtension()
handle = {
"vm_host": "haskell-build",
"remote_dir": "/build/sbx-abc12345",
"host": "localhost",
"ssh_user": "build",
"ssh_port": "",
}
with patch("sandboxer.extensions.vm_packer.SSHConfig.run", return_value=(0, "ready\n")):
reach = ext.wait_ready(handle)
assert reach["remote_dir"] == "/build/sbx-abc12345"
assert "haskell-build" in (reach["ssh"] or "")
def test_teardown_preserves_vm() -> None:
ext = VMPackerExtension()
handle = {
"vm_host": "localhost",
"remote_dir": "/build/sbx-deadbeef",
"ssh_user": "build",
"ssh_port": "12222",
}
with patch("sandboxer.extensions.vm_packer.SSHConfig.run", return_value=(0, "")):
report = ext.teardown(handle)
assert report["vm_preserved"] == "true"
assert report["workspace_removed"] == "True"

View File

@@ -0,0 +1,86 @@
---
id: SAND-WP-0005
type: workplan
title: "Extension SDK and ext.vm-packer"
domain: infotech
repo: sand-boxer
status: finished
owner: codex
topic_slug: custodian
created: "2026-06-23"
updated: "2026-06-23"
---
# Extension SDK and ext.vm-packer
Deliver INTENT near-term outcome #7 (extension SDK sketch) and begin
`infra/build-machines/` migration via `ext.vm-packer` attach mode.
**Predecessor:** SAND-WP-0004 (e2e shim — finished)
**Follow-on:** SAND-WP-0006 (SaaS extensions + payments), SAND-WP-0007 (snapshots)
## Extension SDK
```task
id: SAND-WP-0005-T01
status: done
priority: high
```
`SandboxExtension` base class (`src/sandboxer/extensions/base.py`),
`docs/extension-sdk.md` author guide. `ComposeSSHExtension` refactored to subclass base.
## ext.vm-packer attach mode
```task
id: SAND-WP-0005-T02
status: done
priority: high
```
`VMPackerExtension` — SSH attach to pre-built VM, workspace under `/build/sbx-<id>/`,
optional repo rsync, teardown removes workspace only. Registration:
`extensions/ext.vm-packer.yaml`.
## profile.vm-haskell-build
```task
id: SAND-WP-0005-T03
status: done
priority: high
```
Profile + runbook for Haskell build VM (build-machines lineage).
`docs/migration-build-machines.md` maps legacy workflows.
## SSH port support
```task
id: SAND-WP-0005-T04
status: done
priority: medium
```
`SSHConfig.port` for reverse-tunnel ports (12222). Manager stores `vm_target` /
`ssh_port` on destroy handle.
## Tests
```task
id: SAND-WP-0005-T05
status: done
priority: high
```
Unit tests: `test_vm_packer.py`, `test_extension_base.py`, `test_extension_registry.py`.
## Deferred
```task
id: SAND-WP-0005-T06
status: wait
priority: low
```
Packer build orchestration from `sandboxer create`; the-custodian `make remote-build`
shim; port-registry automation.