Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49c2ed0b93 | |||
| a666f9fe54 |
@@ -26,7 +26,7 @@
|
|||||||
- **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding.
|
- **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding.
|
||||||
- **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required.
|
- **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required.
|
||||||
- **Apple Container backend (macOS default when available)** — runs the agent and sidecar bundle with Apple's `container` CLI, using a host-only agent network plus a separate sidecar egress network.
|
- **Apple Container backend (macOS default when available)** — runs the agent and sidecar bundle with Apple's `container` CLI, using a host-only agent network plus a separate sidecar egress network.
|
||||||
- **Smolmachines backend** — runs the agent in a libkrun micro-VM while the sidecar bundle stays in Docker. TSI and smolmachines DNS filtering close the raw DNS exfiltration gap that exists in the legacy Docker backend.
|
- **Smolmachines backend** — runs the agent in a libkrun micro-VM while the sidecar bundle stays in Docker. TSI and smolmachines DNS filtering close the raw DNS exfiltration gap that exists in the legacy Docker backend. Runs on macOS (Hypervisor.framework) and Linux (KVM, `/dev/kvm`).
|
||||||
- **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`.
|
- **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -72,10 +72,26 @@ When the agent exits, `cli.py` tears down every sidecar and both networks; nothi
|
|||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The smolmachines backend requires Docker on the host for the sidecar bundle plus smolvm. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
|
On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The smolmachines backend requires Docker on the host for the sidecar bundle plus `smolvm` (macOS or Linux). The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
|
||||||
|
|
||||||
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where Apple Container is not installed and Docker is the desired backend.
|
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where Apple Container is not installed and Docker is the desired backend.
|
||||||
|
|
||||||
|
### smolmachines on Linux
|
||||||
|
|
||||||
|
The smolmachines backend runs on Linux as well as macOS. On Linux, `smolvm`/libkrun use KVM, so the host needs:
|
||||||
|
|
||||||
|
- **`/dev/kvm`** present and accessible. Load `kvm-intel` or `kvm-amd` (and enable virtualization in BIOS/firmware). The invoking user must be in the `kvm` group: `sudo usermod -aG kvm "$USER"` then re-login. bot-bottle preflights this and reports exactly what's missing.
|
||||||
|
- **`smolvm`** on `PATH`: `curl -sSL https://smolmachines.com/install.sh | sh`.
|
||||||
|
- **Docker** for the sidecar bundle and image build, same as macOS.
|
||||||
|
|
||||||
|
Per-bottle isolation works the same as macOS without any `ifconfig`/sudo step — all of `127.0.0.0/8` is already loopback on Linux, so each bottle's sidecar bundle is published on its own `127.0.0.<N>` and TSI's allowlist is scoped to that `/32`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
BOT_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. If you run bottles from a Gitea Actions runner, use a `host`-label runner so Docker, `smolvm`, and `/dev/kvm` are all reachable from the job. `smolvm` isn't in nixpkgs — install the release binary (pin the version) and put it on the runner's `PATH`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./cli.py start <agent> # builds the image on first run, drops you into claude
|
./cli.py start <agent> # builds the image on first run, drops you into claude
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -141,10 +141,12 @@ def _allocate_resources(
|
|||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""Reserve a loopback alias and create the per-bottle docker bridge.
|
"""Reserve a loopback alias and create the per-bottle docker bridge.
|
||||||
|
|
||||||
macOS only routes 127.0.0.1 by default; the per-bottle alias
|
The per-bottle alias scopes TSI's allowlist to this bottle's
|
||||||
scopes TSI's allowlist to this bottle's published ports so the
|
published ports so the agent can't reach other bottles' or host
|
||||||
agent can't reach other bottles' or host services' ports on
|
services' ports on loopback. On macOS `ensure_pool` first
|
||||||
loopback. No-op on Linux."""
|
sudo-aliases the pool on `lo0`; on Linux that's a no-op since
|
||||||
|
all of 127.0.0.0/8 is already loopback, but the per-bottle
|
||||||
|
allocation runs on both."""
|
||||||
_loopback.ensure_pool()
|
_loopback.ensure_pool()
|
||||||
loopback_ip = _loopback.allocate(plan.slug)
|
loopback_ip = _loopback.allocate(plan.slug)
|
||||||
network = _bundle.bundle_network_name(plan.slug)
|
network = _bundle.bundle_network_name(plan.slug)
|
||||||
@@ -190,9 +192,11 @@ def _discover_urls(
|
|||||||
return the plan with URLs + guest_env stamped in.
|
return the plan with URLs + guest_env stamped in.
|
||||||
|
|
||||||
Docker container IPs (192.168.x.x in the daemon's bridge)
|
Docker container IPs (192.168.x.x in the daemon's bridge)
|
||||||
aren't reachable from the smolvm guest on macOS — TSI uses
|
aren't reachable from the smolvm guest — TSI proxies the
|
||||||
macOS networking, and macOS sees the daemon's bridge via the
|
guest's connects through the host, and the host reaches the
|
||||||
published-port loopback forward only.
|
bundle only via its published-port loopback forward (the
|
||||||
|
daemon's bridge isn't on the TSI allowlist). The agent dials
|
||||||
|
the published port on the per-bottle loopback alias.
|
||||||
|
|
||||||
NO_PROXY includes the per-bottle loopback alias so the
|
NO_PROXY includes the per-bottle loopback alias so the
|
||||||
supervise + git-gate URLs bypass HTTPS_PROXY."""
|
supervise + git-gate URLs bypass HTTPS_PROXY."""
|
||||||
@@ -252,10 +256,11 @@ def _launch_vm(
|
|||||||
"""Create, patch, and start the smolvm VM; register teardown.
|
"""Create, patch, and start the smolvm VM; register teardown.
|
||||||
|
|
||||||
--allow-cidr is the per-bottle loopback alias so the guest can
|
--allow-cidr is the per-bottle loopback alias so the guest can
|
||||||
only reach this bottle's bundle ports. force_allowlist patches
|
only reach this bottle's bundle ports. force_allowlist then
|
||||||
smolvm 0.8.0's silent-drop of --allow-cidr when combined with
|
confirms the allowlist persisted (patching smolvm 0.8.0's
|
||||||
--from. Smolfile isn't usable here — smolvm 0.8.0 makes --from
|
silent-drop of --allow-cidr when combined with --from) and
|
||||||
and --smolfile mutually exclusive."""
|
fails closed if it can't. Smolfile isn't usable here — smolvm
|
||||||
|
0.8.0 makes --from and --smolfile mutually exclusive."""
|
||||||
_smolvm.machine_create(
|
_smolvm.machine_create(
|
||||||
plan.machine_name,
|
plan.machine_name,
|
||||||
from_path=agent_from_path,
|
from_path=agent_from_path,
|
||||||
@@ -263,9 +268,10 @@ def _launch_vm(
|
|||||||
env=plan.guest_env,
|
env=plan.guest_env,
|
||||||
)
|
)
|
||||||
stack.callback(_smolvm.machine_delete, plan.machine_name)
|
stack.callback(_smolvm.machine_delete, plan.machine_name)
|
||||||
# Workaround smolvm 0.8.0: `--allow-cidr` is silently dropped
|
# Confirm the booted VM's TSI allowlist will actually enforce the
|
||||||
# when combined with `--from`. Patch the persisted state DB
|
# /32 before start (smolvm 0.8.0 silently drops `--allow-cidr`
|
||||||
# before start so the booted VM's TSI actually enforces.
|
# with `--from`, so the persisted state DB is patched if needed).
|
||||||
|
# Fails closed if enforcement can't be confirmed.
|
||||||
_loopback.force_allowlist(plan.machine_name, [f"{loopback_ip}/32"])
|
_loopback.force_allowlist(plan.machine_name, [f"{loopback_ip}/32"])
|
||||||
_smolvm.machine_start(plan.machine_name)
|
_smolvm.machine_start(plan.machine_name)
|
||||||
stack.callback(_smolvm.machine_stop, plan.machine_name)
|
stack.callback(_smolvm.machine_stop, plan.machine_name)
|
||||||
@@ -275,7 +281,9 @@ def _init_vm(plan: SmolmachinesBottlePlan) -> None:
|
|||||||
"""Repair filesystem ownership and wait for exec channel readiness.
|
"""Repair filesystem ownership and wait for exec channel readiness.
|
||||||
|
|
||||||
Ownership repair: smolvm's pack process remaps files to the host
|
Ownership repair: smolvm's pack process remaps files to the host
|
||||||
invoker's uid (501 on macOS). /home/node must be node:node so
|
invoker's uid (e.g. 501 on macOS, 1000 on Linux). The chowns use
|
||||||
|
names not numbers so they're correct on either. /home/node must
|
||||||
|
be node:node so
|
||||||
Claude Code can write ~/.claude.json; /tmp + /var/tmp need root
|
Claude Code can write ~/.claude.json; /tmp + /var/tmp need root
|
||||||
mode 1777 so non-root processes can create per-uid scratch dirs.
|
mode 1777 so non-root processes can create per-uid scratch dirs.
|
||||||
All folded into one sh -c to avoid back-to-back exec calls
|
All folded into one sh -c to avoid back-to-back exec calls
|
||||||
|
|||||||
@@ -33,10 +33,13 @@ sudo-add the missing pool on first use per boot — the aliases
|
|||||||
persist on `lo0` until reboot, so subsequent launches don't
|
persist on `lo0` until reboot, so subsequent launches don't
|
||||||
prompt.
|
prompt.
|
||||||
|
|
||||||
Linux native daemons share the host's network namespace; the
|
On Linux the whole `127.0.0.0/8` is already routed to `lo`, so
|
||||||
whole `127.0.0.0/8` is reachable by default and aliases are
|
docker can publish a bundle's ports directly on `127.0.0.<N>`
|
||||||
unnecessary. The pool logic detects native-Linux and skips sudo
|
with no `ifconfig`/sudo step. `ensure_pool` is therefore a no-op
|
||||||
entirely; the DB patch is also gated on macOS.
|
on Linux, but per-bottle alias *allocation* and the TSI allowlist
|
||||||
|
DB patch run on both platforms — the isolation property is
|
||||||
|
identical, it's just cheaper to set up on Linux. The state-DB
|
||||||
|
path differs per platform (see `_smolvm_db_path`).
|
||||||
|
|
||||||
Allocation is coordinated by inspecting running bundle
|
Allocation is coordinated by inspecting running bundle
|
||||||
containers' published host IPs — each bottle's bundle owns the
|
containers' published host IPs — each bottle's bundle owns the
|
||||||
@@ -47,6 +50,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import fcntl
|
import fcntl
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -57,20 +61,34 @@ from typing import Iterable
|
|||||||
from ...log import die, info
|
from ...log import die, info
|
||||||
|
|
||||||
|
|
||||||
# smolvm's persistent VM state on macOS — a SQLite DB whose `vms`
|
def _smolvm_db_path() -> Path:
|
||||||
# table holds one JSON BLOB per machine. The Linux path is
|
"""smolvm's persistent VM state — a SQLite DB whose `vms` table
|
||||||
# different, but smolmachines is macOS-only in v1 (PRD 0023) so
|
holds one JSON BLOB per machine. macOS stores it under
|
||||||
# we hard-code this. If the file moves under us we'll see a
|
`Application Support`; Linux follows the XDG base-dir spec
|
||||||
# clear FileNotFoundError; not worth defensive cross-platform
|
(`$XDG_DATA_HOME`, default `~/.local/share`).
|
||||||
# detection until the backend actually needs Linux.
|
|
||||||
_SMOLVM_DB_PATH = (
|
NOTE: the Linux location is inferred from smolvm's documented
|
||||||
Path.home()
|
`~/.local/share` install layout and must be confirmed against a
|
||||||
/ "Library"
|
real Linux smolvm install. If it's wrong, `force_allowlist`'s
|
||||||
/ "Application Support"
|
fail-closed check turns it into a clear launch-time error rather
|
||||||
/ "smolvm"
|
than a silent escape."""
|
||||||
/ "server"
|
if platform.system() == "Darwin":
|
||||||
/ "smolvm.db"
|
return (
|
||||||
)
|
Path.home()
|
||||||
|
/ "Library"
|
||||||
|
/ "Application Support"
|
||||||
|
/ "smolvm"
|
||||||
|
/ "server"
|
||||||
|
/ "smolvm.db"
|
||||||
|
)
|
||||||
|
xdg_data = os.environ.get("XDG_DATA_HOME")
|
||||||
|
base = Path(xdg_data) if xdg_data else Path.home() / ".local" / "share"
|
||||||
|
return base / "smolvm" / "server" / "smolvm.db"
|
||||||
|
|
||||||
|
|
||||||
|
# Resolved once at import: the host platform doesn't change within a
|
||||||
|
# process. Tests patch this attribute directly.
|
||||||
|
_SMOLVM_DB_PATH = _smolvm_db_path()
|
||||||
|
|
||||||
|
|
||||||
# Sixteen aliases by default. Tunable for hosts that want more
|
# Sixteen aliases by default. Tunable for hosts that want more
|
||||||
@@ -131,51 +149,74 @@ def ensure_pool() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def force_allowlist(machine_name: str, allowed_cidrs: list[str]) -> None:
|
def force_allowlist(machine_name: str, allowed_cidrs: list[str]) -> None:
|
||||||
"""Patch smolvm's persistent VM-state DB to set the machine's
|
"""Ensure the machine's persisted TSI allowlist equals
|
||||||
`allowed_cidrs` to the given list. Workaround for smolvm
|
`allowed_cidrs`, failing **closed** if that can't be confirmed.
|
||||||
0.8.0's silent-drop of `--allow-cidr` when used with `--from`.
|
|
||||||
|
|
||||||
Must run AFTER `smolvm machine create` (the row has to
|
Runs on both macOS and Linux. It exists because smolvm 0.8.0
|
||||||
exist) and BEFORE `smolvm machine start` (smolvm reads the
|
silently drops `--allow-cidr` when combined with `--from`, so
|
||||||
row on start; in-flight VMs don't pick up changes). Once
|
the allowlist has to be written into smolvm's persistent state
|
||||||
smolvm honors the CLI flag upstream this whole function is
|
DB before `machine start`. Rather than assume the flag was
|
||||||
redundant — flag-respecting create + remove this call from
|
dropped, we read the persisted row and only patch when it
|
||||||
launch.
|
doesn't already match — so a newer smolvm that honors the flag
|
||||||
|
is left untouched.
|
||||||
|
|
||||||
No-op on non-macOS — the DB path differs and the Linux
|
Must run AFTER `smolvm machine create` (the row has to exist)
|
||||||
smolmachines code path isn't exercised in v1."""
|
and BEFORE `smolvm machine start` (smolvm reads the row on
|
||||||
if not _is_macos():
|
start; in-flight VMs don't pick up changes).
|
||||||
return
|
|
||||||
|
Fail-closed: if the state DB is missing, the row is missing, or
|
||||||
|
the allowlist still doesn't match after patching, we `die()`
|
||||||
|
rather than boot a VM whose egress confinement we can't verify
|
||||||
|
— an unconfirmed allowlist is a sandbox-escape risk (the agent
|
||||||
|
VM could reach all of host loopback)."""
|
||||||
|
want = list(allowed_cidrs)
|
||||||
if not _SMOLVM_DB_PATH.is_file():
|
if not _SMOLVM_DB_PATH.is_file():
|
||||||
die(
|
die(
|
||||||
f"smolvm state DB not found at {_SMOLVM_DB_PATH}. "
|
f"smolvm state DB not found at {_SMOLVM_DB_PATH}; cannot "
|
||||||
f"smolvm 0.8.0 expected? `smolvm --version` to check."
|
f"confirm the TSI allowlist is enforced. Refusing to launch "
|
||||||
|
f"(fail-closed). Check `smolvm --version` and the DB "
|
||||||
|
f"location for your platform."
|
||||||
)
|
)
|
||||||
con = sqlite3.connect(str(_SMOLVM_DB_PATH))
|
con = sqlite3.connect(str(_SMOLVM_DB_PATH))
|
||||||
try:
|
try:
|
||||||
cur = con.cursor()
|
cfg = _read_machine_cfg(con, machine_name)
|
||||||
row = cur.execute(
|
if cfg.get("allowed_cidrs") != want:
|
||||||
"SELECT data FROM vms WHERE name = ?", (machine_name,),
|
cfg["allowed_cidrs"] = want
|
||||||
).fetchone()
|
# Write as BLOB (the column type smolvm uses) — passing a
|
||||||
if row is None:
|
# plain str makes sqlite store it as Text and smolvm then
|
||||||
die(
|
# fails to read it.
|
||||||
f"smolvm DB has no row for machine {machine_name!r} — "
|
con.execute(
|
||||||
f"machine_create must run before force_allowlist."
|
"UPDATE vms SET data = ? WHERE name = ?",
|
||||||
|
(sqlite3.Binary(json.dumps(cfg).encode()), machine_name),
|
||||||
|
)
|
||||||
|
con.commit()
|
||||||
|
cfg = _read_machine_cfg(con, machine_name)
|
||||||
|
if cfg.get("allowed_cidrs") != want:
|
||||||
|
die(
|
||||||
|
f"could not enforce TSI allowlist {want!r} for machine "
|
||||||
|
f"{machine_name!r} (persisted value is "
|
||||||
|
f"{cfg.get('allowed_cidrs')!r}). Refusing to launch "
|
||||||
|
f"(fail-closed)."
|
||||||
)
|
)
|
||||||
cfg = json.loads(row[0])
|
|
||||||
cfg["allowed_cidrs"] = list(allowed_cidrs)
|
|
||||||
# Write as BLOB (the column type smolvm uses) — passing a
|
|
||||||
# plain str makes sqlite store it as Text and smolvm then
|
|
||||||
# fails to read it.
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE vms SET data = ? WHERE name = ?",
|
|
||||||
(sqlite3.Binary(json.dumps(cfg).encode()), machine_name),
|
|
||||||
)
|
|
||||||
con.commit()
|
|
||||||
finally:
|
finally:
|
||||||
con.close()
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_machine_cfg(con: sqlite3.Connection, machine_name: str) -> dict[str, object]:
|
||||||
|
"""Read + JSON-decode a machine's `data` BLOB from the smolvm
|
||||||
|
state DB. Dies (fail-closed) if the row is missing — the caller
|
||||||
|
can't confirm enforcement without it."""
|
||||||
|
row = con.execute(
|
||||||
|
"SELECT data FROM vms WHERE name = ?", (machine_name,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
die(
|
||||||
|
f"smolvm DB has no row for machine {machine_name!r} — "
|
||||||
|
f"machine_create must run before force_allowlist."
|
||||||
|
)
|
||||||
|
return json.loads(row[0])
|
||||||
|
|
||||||
|
|
||||||
def allocate(_slug: str) -> str:
|
def allocate(_slug: str) -> str:
|
||||||
"""Pick the lowest-numbered alias from the pool not already
|
"""Pick the lowest-numbered alias from the pool not already
|
||||||
in use by a running smolmachines bundle. Bails when the pool
|
in use by a running smolmachines bundle. Bails when the pool
|
||||||
@@ -184,16 +225,17 @@ def allocate(_slug: str) -> str:
|
|||||||
used (no on-disk reservation, allocation is purely
|
used (no on-disk reservation, allocation is purely
|
||||||
docker-state-driven).
|
docker-state-driven).
|
||||||
|
|
||||||
On non-macOS the whole `127.0.0.0/8` is loopback by default;
|
Runs on both platforms: the allocation logic (docker-state
|
||||||
`127.0.0.1` is fine to share and we skip the alias dance.
|
inspection + the file lock) is platform-independent. macOS
|
||||||
This still returns a deterministic address so launch.py's
|
needs `ensure_pool` to have aliased the addresses on `lo0`
|
||||||
callers don't have to branch on platform.
|
first; on Linux all of `127.0.0.0/8` is already loopback, so
|
||||||
|
docker can publish on the chosen `127.0.0.<N>` with no setup.
|
||||||
|
Per-bottle scoping (so the agent can't reach other bottles' or
|
||||||
|
host services' loopback ports) therefore holds on both.
|
||||||
|
|
||||||
An exclusive file lock serialises concurrent calls so two
|
An exclusive file lock serialises concurrent calls so two
|
||||||
simultaneous launches don't read the same docker state and
|
simultaneous launches don't read the same docker state and
|
||||||
claim the same alias."""
|
claim the same alias."""
|
||||||
if not _is_macos():
|
|
||||||
return "127.0.0.1"
|
|
||||||
_ALLOC_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
_ALLOC_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(_ALLOC_LOCK_PATH, "w", encoding="utf-8") as lf:
|
with open(_ALLOC_LOCK_PATH, "w", encoding="utf-8") as lf:
|
||||||
fcntl.flock(lf, fcntl.LOCK_EX)
|
fcntl.flock(lf, fcntl.LOCK_EX)
|
||||||
|
|||||||
@@ -5,26 +5,58 @@ unit-tested without importing the docker subprocess paths."""
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
from ...log import die
|
from ...log import die
|
||||||
|
|
||||||
|
# libkrun's Linux backend drives the guest through KVM, so the host
|
||||||
|
# must expose `/dev/kvm` and the invoking user must be able to open
|
||||||
|
# it. macOS uses Hypervisor.framework and needs no device node.
|
||||||
|
_KVM_DEVICE = "/dev/kvm"
|
||||||
|
|
||||||
|
|
||||||
def smolmachines_preflight() -> None:
|
def smolmachines_preflight() -> None:
|
||||||
"""Ensure `smolvm` is on PATH before the launch flow runs.
|
"""Ensure the host can run the smolmachines backend before the
|
||||||
Called from `_resolve_plan`; gives the operator a clear
|
launch flow starts. Called from `_resolve_plan`; surfaces a
|
||||||
install pointer rather than a cryptic FileNotFoundError
|
clear, actionable error instead of a cryptic `smolvm` failure
|
||||||
later. `gvproxy` is no longer required — see the PRD's design
|
deep in launch.
|
||||||
pivot section."""
|
|
||||||
if shutil.which("smolvm") is not None:
|
Checks `smolvm` is on PATH (both platforms) and, on Linux,
|
||||||
return
|
that `/dev/kvm` exists and is accessible. `gvproxy` is no
|
||||||
die(
|
longer required — see the PRD's design pivot section."""
|
||||||
"BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on "
|
if shutil.which("smolvm") is None:
|
||||||
"PATH. Install with: "
|
die(
|
||||||
"curl -sSL https://smolmachines.com/install.sh | sh. "
|
"BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on "
|
||||||
"To use the legacy Docker backend instead, set "
|
"PATH. Install with: "
|
||||||
"BOT_BOTTLE_BACKEND=docker or pass --backend=docker."
|
"curl -sSL https://smolmachines.com/install.sh | sh. "
|
||||||
)
|
"To use the legacy Docker backend instead, set "
|
||||||
|
"BOT_BOTTLE_BACKEND=docker or pass --backend=docker."
|
||||||
|
)
|
||||||
|
if platform.system() == "Linux":
|
||||||
|
_preflight_kvm()
|
||||||
|
|
||||||
|
|
||||||
|
def _preflight_kvm() -> None:
|
||||||
|
"""Linux-only: libkrun needs `/dev/kvm`. Distinguish 'KVM not
|
||||||
|
enabled' from 'no permission' so the operator knows which to
|
||||||
|
fix."""
|
||||||
|
if not os.path.exists(_KVM_DEVICE):
|
||||||
|
die(
|
||||||
|
f"BOT_BOTTLE_BACKEND=smolmachines needs {_KVM_DEVICE} on "
|
||||||
|
"Linux but it is missing. Enable KVM: load the kvm-intel "
|
||||||
|
"or kvm-amd kernel module (and confirm virtualization is "
|
||||||
|
"enabled in BIOS/firmware). To use the legacy Docker "
|
||||||
|
"backend instead, set BOT_BOTTLE_BACKEND=docker."
|
||||||
|
)
|
||||||
|
if not os.access(_KVM_DEVICE, os.R_OK | os.W_OK):
|
||||||
|
die(
|
||||||
|
f"{_KVM_DEVICE} exists but is not readable/writable by the "
|
||||||
|
"current user. Add your user to the `kvm` group "
|
||||||
|
"(`sudo usermod -aG kvm \"$USER\"`) and re-login, or run "
|
||||||
|
"with access to the device."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def smolmachines_bundle_subnet(slug: str) -> tuple[str, str, str]:
|
def smolmachines_bundle_subnet(slug: str) -> tuple[str, str, str]:
|
||||||
|
|||||||
+32
-40
@@ -301,44 +301,6 @@ def _run_multiselect(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _toggle_membership(items: list[str], item: str) -> None:
|
|
||||||
"""Add `item` if absent, remove it if present (in place)."""
|
|
||||||
if item in items:
|
|
||||||
items.remove(item)
|
|
||||||
else:
|
|
||||||
items.append(item)
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_order_key(key: int, selected: list[str], order_cursor: int) -> int:
|
|
||||||
"""Apply a keypress in 'order' focus: navigate, reorder, or remove the
|
|
||||||
item at `order_cursor`. Mutates `selected` in place and returns the new
|
|
||||||
order cursor."""
|
|
||||||
if key in (curses.KEY_UP, ord("k")):
|
|
||||||
if order_cursor > 0:
|
|
||||||
order_cursor -= 1
|
|
||||||
elif key in (curses.KEY_DOWN, ord("j")):
|
|
||||||
if order_cursor < len(selected) - 1:
|
|
||||||
order_cursor += 1
|
|
||||||
elif key == ord("K"):
|
|
||||||
# Move selected item up (earlier in order).
|
|
||||||
if order_cursor > 0:
|
|
||||||
i = order_cursor
|
|
||||||
selected[i - 1], selected[i] = selected[i], selected[i - 1]
|
|
||||||
order_cursor -= 1
|
|
||||||
elif key == ord("J"):
|
|
||||||
# Move selected item down (later in order).
|
|
||||||
if order_cursor < len(selected) - 1:
|
|
||||||
i = order_cursor
|
|
||||||
selected[i], selected[i + 1] = selected[i + 1], selected[i]
|
|
||||||
order_cursor += 1
|
|
||||||
elif key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r"), _KEY_SPACE):
|
|
||||||
# Remove item from selection while in order mode.
|
|
||||||
del selected[order_cursor]
|
|
||||||
if order_cursor >= len(selected) and order_cursor > 0:
|
|
||||||
order_cursor -= 1
|
|
||||||
return order_cursor
|
|
||||||
|
|
||||||
|
|
||||||
def _multiselect_loop(
|
def _multiselect_loop(
|
||||||
screen: Any, items: list[str], *, title: str, initial: list[str]
|
screen: Any, items: list[str], *, title: str, initial: list[str]
|
||||||
) -> Optional[list[str]]:
|
) -> Optional[list[str]]:
|
||||||
@@ -400,7 +362,11 @@ def _multiselect_loop(
|
|||||||
|
|
||||||
elif key == _KEY_SPACE:
|
elif key == _KEY_SPACE:
|
||||||
if filtered:
|
if filtered:
|
||||||
_toggle_membership(selected, filtered[cursor])
|
item = filtered[cursor]
|
||||||
|
if item in selected:
|
||||||
|
selected.remove(item)
|
||||||
|
else:
|
||||||
|
selected.append(item)
|
||||||
|
|
||||||
elif key in (curses.KEY_UP, ord("k")):
|
elif key in (curses.KEY_UP, ord("k")):
|
||||||
if cursor > 0:
|
if cursor > 0:
|
||||||
@@ -421,7 +387,33 @@ def _multiselect_loop(
|
|||||||
cursor = 0
|
cursor = 0
|
||||||
|
|
||||||
else: # focus == "order"
|
else: # focus == "order"
|
||||||
order_cursor = _handle_order_key(key, selected, order_cursor)
|
if key in (curses.KEY_UP, ord("k")):
|
||||||
|
if order_cursor > 0:
|
||||||
|
order_cursor -= 1
|
||||||
|
|
||||||
|
elif key in (curses.KEY_DOWN, ord("j")):
|
||||||
|
if order_cursor < len(selected) - 1:
|
||||||
|
order_cursor += 1
|
||||||
|
|
||||||
|
elif key == ord("K"):
|
||||||
|
# Move selected item up (earlier in order).
|
||||||
|
if order_cursor > 0:
|
||||||
|
i = order_cursor
|
||||||
|
selected[i - 1], selected[i] = selected[i], selected[i - 1]
|
||||||
|
order_cursor -= 1
|
||||||
|
|
||||||
|
elif key == ord("J"):
|
||||||
|
# Move selected item down (later in order).
|
||||||
|
if order_cursor < len(selected) - 1:
|
||||||
|
i = order_cursor
|
||||||
|
selected[i], selected[i + 1] = selected[i + 1], selected[i]
|
||||||
|
order_cursor += 1
|
||||||
|
|
||||||
|
elif key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r"), _KEY_SPACE):
|
||||||
|
# Remove item from selection while in order mode.
|
||||||
|
del selected[order_cursor]
|
||||||
|
if order_cursor >= len(selected) and order_cursor > 0:
|
||||||
|
order_cursor -= 1
|
||||||
|
|
||||||
|
|
||||||
def _render_multiselect(
|
def _render_multiselect(
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# PRD prd-new: smolmachines backend on Linux
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **Author:** Claude
|
||||||
|
- **Created:** 2026-06-25
|
||||||
|
- **Issue:** #283
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Make the `smolmachines` backend (PRD 0023) runnable on Linux, not
|
||||||
|
just macOS. `smolvm` already supports Linux via KVM (`/dev/kvm`);
|
||||||
|
the gap is entirely in bot-bottle's host-side glue, which hard-codes
|
||||||
|
macOS assumptions in three places:
|
||||||
|
|
||||||
|
1. **Preflight** only checks that `smolvm` is on `PATH` — it never
|
||||||
|
checks the Linux KVM prerequisite, so a misconfigured host fails
|
||||||
|
deep in the launch flow with an opaque `smolvm` error.
|
||||||
|
2. **The TSI allowlist enforcement** (`force_allowlist`) — the
|
||||||
|
security property that confines the agent VM to its sidecar
|
||||||
|
bundle's `/32` — **no-ops on Linux today, failing _open_**. The
|
||||||
|
smolvm state-DB path it patches is hard-coded to macOS's
|
||||||
|
`~/Library/Application Support/...`.
|
||||||
|
3. **Per-bottle loopback scoping** (`allocate`) returns the shared
|
||||||
|
`127.0.0.1` on Linux, which would let the agent VM reach every
|
||||||
|
service on host loopback — a downgrade from the per-bottle alias
|
||||||
|
isolation macOS gets.
|
||||||
|
|
||||||
|
This PRD closes all three so a bottle launched with
|
||||||
|
`BOT_BOTTLE_BACKEND=smolmachines` on Linux gets the same isolation
|
||||||
|
guarantee it gets on macOS, and documents the Linux/NixOS host
|
||||||
|
setup. The primary validation target is NixOS, but the changes are
|
||||||
|
distro-agnostic.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The smolmachines backend runs each bottle's agent inside a libkrun
|
||||||
|
microVM via `smolvm`, with egress confined by TSI's `--allow-cidr`
|
||||||
|
allowlist set to a single `/32` — the sidecar bundle's loopback
|
||||||
|
address. Everything else (host loopback, LAN, internet) is denied at
|
||||||
|
the VMM layer. That security property is the entire reason the
|
||||||
|
backend exists.
|
||||||
|
|
||||||
|
libkrun runs on Hypervisor.framework (macOS) **and** KVM (Linux), and
|
||||||
|
`smolvm` ships Linux x86_64 / aarch64 builds that require `/dev/kvm`.
|
||||||
|
So the microVM layer already works on Linux. What does not work is
|
||||||
|
bot-bottle's host integration, which PRD 0023 explicitly scoped to
|
||||||
|
macOS-only for v1. Three concrete blockers:
|
||||||
|
|
||||||
|
- **No KVM preflight.** On a Linux host without `/dev/kvm` (kernel
|
||||||
|
module not loaded) or without access to it (user not in the `kvm`
|
||||||
|
group), the failure surfaces as a cryptic `smolvm` non-zero exit
|
||||||
|
mid-launch instead of an actionable message.
|
||||||
|
|
||||||
|
- **TSI enforcement fails open on Linux.** `force_allowlist`
|
||||||
|
early-returns on non-macOS. It exists because `smolvm` 0.8.0
|
||||||
|
silently drops `--allow-cidr` when combined with `--from`, so the
|
||||||
|
allowlist has to be patched into smolvm's persisted state DB before
|
||||||
|
`machine start`. On Linux that patch never runs **and** the DB path
|
||||||
|
is the macOS path, so the booted VM's TSI allowlist is whatever
|
||||||
|
smolvm defaulted to — potentially all of `127.0.0.0/8`. That is the
|
||||||
|
exact sandbox-escape the backend is supposed to prevent.
|
||||||
|
|
||||||
|
- **No per-bottle loopback isolation on Linux.** `allocate` returns
|
||||||
|
`127.0.0.1` on Linux. Even with a correct allowlist, `127.0.0.1/32`
|
||||||
|
is shared by every service on host loopback, so the agent could
|
||||||
|
reach other bottles' published ports and host services. On macOS
|
||||||
|
this is solved with per-bottle `127.0.0.16..31` aliases added via
|
||||||
|
`sudo ifconfig lo0 alias`. On Linux the whole `127.0.0.0/8` is
|
||||||
|
already routed to `lo`, so docker can publish to `127.0.0.<N>`
|
||||||
|
with **no `ifconfig`/sudo step at all** — the isolation is actually
|
||||||
|
cheaper to achieve than on macOS.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- `BOT_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>` launches,
|
||||||
|
runs, and tears down a bottle on a Linux host with `/dev/kvm`.
|
||||||
|
- The TSI allowlist is enforced on Linux: PRD 0022's
|
||||||
|
`tests/integration/test_sandbox_escape.py` passes against
|
||||||
|
`BOT_BOTTLE_BACKEND=smolmachines` on Linux (the acceptance gate).
|
||||||
|
- Each Linux bottle is scoped to its own `127.0.0.<N>/32`, matching
|
||||||
|
the macOS per-bottle isolation property.
|
||||||
|
- A clear, actionable preflight error when `/dev/kvm` is missing or
|
||||||
|
inaccessible, with remediation (load `kvm-intel`/`kvm-amd`, join the
|
||||||
|
`kvm` group).
|
||||||
|
- **Fail-closed:** if bot-bottle cannot positively confirm the TSI
|
||||||
|
allowlist was persisted for a machine (DB missing, row missing,
|
||||||
|
patch didn't take), it `die()`s before `machine start` rather than
|
||||||
|
booting a VM with an unverified allowlist.
|
||||||
|
- macOS behavior is unchanged.
|
||||||
|
- README documents Linux + NixOS host setup.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Rootless / non-KVM fallbacks (e.g. software emulation). Linux
|
||||||
|
smolmachines requires `/dev/kvm`, full stop.
|
||||||
|
- Removing Docker as a host dependency — the sidecar bundle and
|
||||||
|
image-build pipeline still use Docker on Linux, same as macOS.
|
||||||
|
- Auto-installing `smolvm` or configuring KVM on the operator's
|
||||||
|
behalf. Preflight reports; the operator remediates.
|
||||||
|
- Nested-virtualization tuning for running the runner itself inside a
|
||||||
|
VM (documented as a caveat, not solved here).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Platform detection
|
||||||
|
|
||||||
|
Reuse the existing `platform.system()` check already in
|
||||||
|
`loopback_alias.py` (`_is_macos()`). "Linux" is "not macOS" for every
|
||||||
|
branch below; no new third-platform path.
|
||||||
|
|
||||||
|
### Preflight: KVM gate (`util.smolmachines_preflight`)
|
||||||
|
|
||||||
|
After the existing `smolvm`-on-`PATH` check, add a Linux-only gate:
|
||||||
|
|
||||||
|
- `/dev/kvm` must exist → else `die()` with "enable KVM
|
||||||
|
(`kvm-intel`/`kvm-amd` kernel module)".
|
||||||
|
- `/dev/kvm` must be readable + writable by the current user
|
||||||
|
(`os.access(..., R_OK | W_OK)`) → else `die()` with "add your user
|
||||||
|
to the `kvm` group (and re-login)".
|
||||||
|
|
||||||
|
macOS is unaffected (Hypervisor.framework needs no device node).
|
||||||
|
|
||||||
|
### smolvm state-DB path (platform-aware)
|
||||||
|
|
||||||
|
`loopback_alias._SMOLVM_DB_PATH` becomes platform-derived:
|
||||||
|
|
||||||
|
- macOS: `~/Library/Application Support/smolvm/server/smolvm.db`
|
||||||
|
(unchanged).
|
||||||
|
- Linux: `$XDG_DATA_HOME/smolvm/server/smolvm.db`, defaulting to
|
||||||
|
`~/.local/share/smolvm/server/smolvm.db`.
|
||||||
|
|
||||||
|
> **Verification note:** the Linux DB location is inferred from
|
||||||
|
> smolvm's documented `~/.local/share` install layout and the XDG
|
||||||
|
> base-dir spec. It must be confirmed on a real Linux smolvm install;
|
||||||
|
> if smolvm uses a different path or schema, the fail-closed check
|
||||||
|
> below turns that into a clear `die()` at launch rather than a silent
|
||||||
|
> escape.
|
||||||
|
|
||||||
|
### TSI enforcement: cross-platform + fail-closed (`force_allowlist`)
|
||||||
|
|
||||||
|
Rework `force_allowlist(machine_name, allowed_cidrs)` to run on
|
||||||
|
**both** platforms and to fail closed:
|
||||||
|
|
||||||
|
1. Resolve the state DB; if the file is missing, `die()` (cannot
|
||||||
|
confirm enforcement → refuse to launch).
|
||||||
|
2. Read the machine's persisted row; if the row is missing, `die()`.
|
||||||
|
3. If the row's `allowed_cidrs` already equals the requested list
|
||||||
|
(e.g. a newer `smolvm` that honors `--allow-cidr` at create), do
|
||||||
|
nothing — no write.
|
||||||
|
4. Otherwise patch `allowed_cidrs` (the existing BLOB-encoded write)
|
||||||
|
and re-read.
|
||||||
|
5. If, after the patch, `allowed_cidrs` still does not equal the
|
||||||
|
requested list, `die()`.
|
||||||
|
|
||||||
|
This is robust across smolvm versions: it works whether `--allow-cidr`
|
||||||
|
is silently dropped (0.8.0) or honored (newer), and it never boots a
|
||||||
|
VM whose persisted allowlist it could not confirm. It is a strict
|
||||||
|
improvement on macOS too (today's code writes unconditionally and
|
||||||
|
never verifies).
|
||||||
|
|
||||||
|
> The persisted-row check confirms our write took, not that smolvm's
|
||||||
|
> runtime TSI enforces it. The runtime guarantee is covered by the
|
||||||
|
> sandbox-escape acceptance test; the persisted check is the cheap
|
||||||
|
> fail-closed guard at launch.
|
||||||
|
|
||||||
|
### Per-bottle loopback scoping on Linux (`allocate`)
|
||||||
|
|
||||||
|
`allocate` runs the same docker-state-driven allocation on Linux as on
|
||||||
|
macOS (`_allocate_locked`, the file lock, and `_aliases_in_use` via
|
||||||
|
`docker inspect` are all already cross-platform). The only macOS-only
|
||||||
|
step, `ensure_pool` (the `sudo ifconfig lo0 alias` dance), stays
|
||||||
|
macOS-only: on Linux `127.0.0.0/8` is already loopback, so docker can
|
||||||
|
publish bundle ports directly on `127.0.0.<N>` with no setup.
|
||||||
|
|
||||||
|
Net effect: Linux bottles get per-bottle `127.0.0.16..31/32` scoping
|
||||||
|
identical to macOS, without sudo.
|
||||||
|
|
||||||
|
### Launch flow
|
||||||
|
|
||||||
|
`launch.py` needs no structural change — `_allocate_resources` already
|
||||||
|
calls `ensure_pool()` (now a Linux no-op) then `allocate()` (now
|
||||||
|
per-bottle on Linux), and `_launch_vm` already calls
|
||||||
|
`force_allowlist()` (now active on Linux). Only the macOS-specific
|
||||||
|
docstrings are updated to describe the cross-platform behavior.
|
||||||
|
|
||||||
|
## Implementation chunks
|
||||||
|
|
||||||
|
1. **Preflight KVM gate** — `util.smolmachines_preflight` +
|
||||||
|
unit tests for the missing-device and no-access branches.
|
||||||
|
2. **Platform-aware DB path + fail-closed `force_allowlist`** —
|
||||||
|
`loopback_alias.py`; update/extend `TestForceAllowlist`.
|
||||||
|
3. **Cross-platform `allocate`** — drop the Linux early-return; update
|
||||||
|
`TestAllocate` / `TestAllocateLock` for the new Linux behavior.
|
||||||
|
4. **Docstring + comment cleanup** in `launch.py` and module headers.
|
||||||
|
5. **Docs** — README requirements + a Linux/NixOS host-setup section.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- **Unit (CI, any OS):** the suite mocks `platform.system()` /
|
||||||
|
`subprocess` and patches `_SMOLVM_DB_PATH`, so the new Linux
|
||||||
|
branches are testable on the macOS/Linux CI runner without `smolvm`
|
||||||
|
or KVM. Covers: KVM preflight branches, fail-closed `force_allowlist`
|
||||||
|
(DB missing, row missing, patch-doesn't-take), per-bottle Linux
|
||||||
|
allocation + locking, platform-derived DB path.
|
||||||
|
- **Integration (Linux host with KVM — the acceptance gate):**
|
||||||
|
`tests/integration/test_sandbox_escape.py` against
|
||||||
|
`BOT_BOTTLE_BACKEND=smolmachines`. This cannot run on the macOS dev
|
||||||
|
box and must be executed on NixOS before merge.
|
||||||
|
|
||||||
|
## Open questions / verification pending
|
||||||
|
|
||||||
|
- **Confirm the Linux smolvm state-DB path and schema** on a real
|
||||||
|
install (the `~/.local/share/...` inference above).
|
||||||
|
- **Confirm whether the current smolvm Linux build still drops
|
||||||
|
`--allow-cidr` with `--from`** (the 0.8.0 bug). The fail-closed
|
||||||
|
design handles either answer, but knowing lets us drop the DB patch
|
||||||
|
if upstream fixed it.
|
||||||
|
- **Confirm docker publishing to `127.0.0.<N>` on Linux** behaves as
|
||||||
|
expected end-to-end with TSI (high confidence; standard loopback
|
||||||
|
behavior, but unverified on the target host).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- PRD 0023 — smolmachines bottle backend (macOS v1).
|
||||||
|
- PRD 0022 — `test_sandbox_escape.py` acceptance gate.
|
||||||
|
- PRD 0024 — sidecar bundle image.
|
||||||
|
- smolvm: https://github.com/smol-machines/smolvm
|
||||||
@@ -8,6 +8,7 @@ inspecting running bundle containers' port bindings."""
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -112,9 +113,16 @@ class TestEnsurePool(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestAllocate(unittest.TestCase):
|
class TestAllocate(unittest.TestCase):
|
||||||
def test_returns_loopback_on_linux(self):
|
def test_per_bottle_alias_on_linux(self):
|
||||||
with patch.object(loopback_alias, "_is_macos", return_value=False):
|
# Linux gets the same per-bottle scoping as macOS (127/8 is
|
||||||
self.assertEqual("127.0.0.1", loopback_alias.allocate("demo"))
|
# already loopback, so no ifconfig is needed). A fresh host
|
||||||
|
# with no running bundles allocates the first pool entry.
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
lock_path = Path(tmp) / "smolmachines.lock"
|
||||||
|
with patch.object(loopback_alias, "_is_macos", return_value=False), \
|
||||||
|
patch.object(loopback_alias, "_ALLOC_LOCK_PATH", lock_path), \
|
||||||
|
patch.object(loopback_alias, "_aliases_in_use", return_value=set()):
|
||||||
|
self.assertEqual("127.0.0.16", loopback_alias.allocate("demo"))
|
||||||
|
|
||||||
def test_picks_lowest_unused_on_macos(self):
|
def test_picks_lowest_unused_on_macos(self):
|
||||||
# No bundles running -> first pool entry.
|
# No bundles running -> first pool entry.
|
||||||
@@ -166,12 +174,25 @@ class TestAllocateLock(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIn(fcntl_mod.LOCK_EX, flock_calls)
|
self.assertIn(fcntl_mod.LOCK_EX, flock_calls)
|
||||||
|
|
||||||
def test_no_lock_on_linux(self):
|
def test_acquires_exclusive_lock_on_linux(self):
|
||||||
# Linux early-returns before touching the lock file.
|
# Linux allocates per-bottle too, so it must take the same
|
||||||
with patch.object(loopback_alias, "_is_macos", return_value=False), \
|
# lock to serialise concurrent launches.
|
||||||
patch.object(loopback_alias.fcntl, "flock") as flock:
|
import fcntl as fcntl_mod
|
||||||
loopback_alias.allocate("demo")
|
flock_calls: list[int] = []
|
||||||
flock.assert_not_called()
|
|
||||||
|
def record_flock(fd, op): # type: ignore
|
||||||
|
flock_calls.append(op)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
lock_path = Path(tmp) / "smolmachines.lock"
|
||||||
|
with patch.object(loopback_alias, "_is_macos", return_value=False), \
|
||||||
|
patch.object(loopback_alias, "_ALLOC_LOCK_PATH", lock_path), \
|
||||||
|
patch.object(loopback_alias, "_aliases_in_use", return_value=set()), \
|
||||||
|
patch.object(loopback_alias.fcntl, "flock",
|
||||||
|
side_effect=record_flock):
|
||||||
|
loopback_alias.allocate("demo")
|
||||||
|
|
||||||
|
self.assertIn(fcntl_mod.LOCK_EX, flock_calls)
|
||||||
|
|
||||||
def test_sequential_allocations_with_shared_lock_are_serialised(self):
|
def test_sequential_allocations_with_shared_lock_are_serialised(self):
|
||||||
# Two sequential calls share the same lock file. The second
|
# Two sequential calls share the same lock file. The second
|
||||||
@@ -241,10 +262,12 @@ class TestAliasInUseDetection(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestForceAllowlist(unittest.TestCase):
|
class TestForceAllowlist(unittest.TestCase):
|
||||||
"""Smolvm 0.8.0 silently drops `--allow-cidr` with `--from`,
|
"""Smolvm 0.8.0 silently drops `--allow-cidr` with `--from`, so
|
||||||
so `force_allowlist` opens the state DB directly and sets
|
`force_allowlist` opens the state DB directly and sets the row's
|
||||||
the row's `allowed_cidrs` field. Round-trip tests against a
|
`allowed_cidrs` field — on both macOS and Linux. It is
|
||||||
real SQLite DB to lock down the BLOB encoding."""
|
fail-closed: it dies rather than launching a VM whose allowlist
|
||||||
|
it can't confirm. Round-trip tests against a real SQLite DB to
|
||||||
|
lock down the BLOB encoding."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._tmp = tempfile.TemporaryDirectory(prefix="smolvm-db.")
|
self._tmp = tempfile.TemporaryDirectory(prefix="smolvm-db.")
|
||||||
@@ -290,17 +313,67 @@ class TestForceAllowlist(unittest.TestCase):
|
|||||||
self.assertEqual(4, cfg["cpus"])
|
self.assertEqual(4, cfg["cpus"])
|
||||||
self.assertTrue(cfg["network"])
|
self.assertTrue(cfg["network"])
|
||||||
|
|
||||||
def test_noop_on_linux(self):
|
def test_patches_on_linux_too(self):
|
||||||
|
# force_allowlist no longer no-ops on Linux — the TSI
|
||||||
|
# allowlist must be enforced there as well.
|
||||||
with patch.object(loopback_alias, "_is_macos", return_value=False), \
|
with patch.object(loopback_alias, "_is_macos", return_value=False), \
|
||||||
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db):
|
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db):
|
||||||
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
|
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
|
||||||
# DB row should be untouched.
|
|
||||||
con = sqlite3.connect(str(self.db))
|
con = sqlite3.connect(str(self.db))
|
||||||
cfg = json.loads(con.execute(
|
cfg = json.loads(con.execute(
|
||||||
"SELECT data FROM vms WHERE name='demo-vm'",
|
"SELECT data FROM vms WHERE name='demo-vm'",
|
||||||
).fetchone()[0])
|
).fetchone()[0])
|
||||||
con.close()
|
con.close()
|
||||||
self.assertIsNone(cfg["allowed_cidrs"])
|
self.assertEqual(["127.0.0.16/32"], cfg["allowed_cidrs"])
|
||||||
|
|
||||||
|
def test_skips_write_when_already_matching(self):
|
||||||
|
# A newer smolvm that honors --allow-cidr at create leaves the
|
||||||
|
# row already correct; force_allowlist must not rewrite it. We
|
||||||
|
# detect a no-write by comparing the raw BLOB byte-for-byte
|
||||||
|
# (a rewrite re-serialises the JSON, changing key order/bytes
|
||||||
|
# is not guaranteed, but mtime/identity isn't observable — so
|
||||||
|
# we assert the stored bytes are exactly what we pre-seeded).
|
||||||
|
seeded = json.dumps({
|
||||||
|
"name": "demo-vm", "cpus": 4, "mem": 8192,
|
||||||
|
"network": True, "allowed_cidrs": ["127.0.0.16/32"],
|
||||||
|
}).encode()
|
||||||
|
con = sqlite3.connect(str(self.db))
|
||||||
|
con.execute(
|
||||||
|
"UPDATE vms SET data=? WHERE name='demo-vm'",
|
||||||
|
(sqlite3.Binary(seeded),),
|
||||||
|
)
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||||
|
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db):
|
||||||
|
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
|
||||||
|
|
||||||
|
con = sqlite3.connect(str(self.db))
|
||||||
|
stored = con.execute(
|
||||||
|
"SELECT data FROM vms WHERE name='demo-vm'").fetchone()[0]
|
||||||
|
con.close()
|
||||||
|
self.assertEqual(seeded, bytes(stored))
|
||||||
|
|
||||||
|
def test_dies_when_patch_does_not_take(self):
|
||||||
|
# If the persisted allowlist still doesn't match after the
|
||||||
|
# patch (e.g. wrong schema / smolvm stores it elsewhere),
|
||||||
|
# force_allowlist must fail closed rather than boot the VM.
|
||||||
|
original = loopback_alias._read_machine_cfg
|
||||||
|
|
||||||
|
def stale_cfg(con, name):
|
||||||
|
# Always report the un-patched row so the post-write
|
||||||
|
# verification never sees the requested cidrs.
|
||||||
|
cfg = original(con, name)
|
||||||
|
cfg["allowed_cidrs"] = None
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||||
|
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db), \
|
||||||
|
patch.object(loopback_alias, "_read_machine_cfg", side_effect=stale_cfg), \
|
||||||
|
patch.object(loopback_alias, "die", side_effect=SystemExit("die")):
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
|
||||||
|
|
||||||
def test_dies_on_missing_db(self):
|
def test_dies_on_missing_db(self):
|
||||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||||
@@ -323,5 +396,35 @@ class TestForceAllowlist(unittest.TestCase):
|
|||||||
loopback_alias.force_allowlist("not-in-db", ["127.0.0.16/32"])
|
loopback_alias.force_allowlist("not-in-db", ["127.0.0.16/32"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestSmolvmDbPath(unittest.TestCase):
|
||||||
|
"""The smolvm state-DB path is platform-derived: Application
|
||||||
|
Support on macOS, XDG data dir on Linux."""
|
||||||
|
|
||||||
|
def test_macos_path(self):
|
||||||
|
with patch.object(loopback_alias.platform, "system", return_value="Darwin"):
|
||||||
|
p = loopback_alias._smolvm_db_path()
|
||||||
|
self.assertEqual(
|
||||||
|
("Library", "Application Support", "smolvm", "server", "smolvm.db"),
|
||||||
|
p.parts[-5:],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_linux_default_xdg_path(self):
|
||||||
|
env = {k: v for k, v in os.environ.items() if k != "XDG_DATA_HOME"}
|
||||||
|
with patch.object(loopback_alias.platform, "system", return_value="Linux"), \
|
||||||
|
patch.dict(loopback_alias.os.environ, env, clear=True):
|
||||||
|
p = loopback_alias._smolvm_db_path()
|
||||||
|
self.assertEqual(
|
||||||
|
(".local", "share", "smolvm", "server", "smolvm.db"),
|
||||||
|
p.parts[-5:],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_linux_respects_xdg_data_home(self):
|
||||||
|
with patch.object(loopback_alias.platform, "system", return_value="Linux"), \
|
||||||
|
patch.dict(loopback_alias.os.environ,
|
||||||
|
{"XDG_DATA_HOME": "/custom/data"}, clear=False):
|
||||||
|
p = loopback_alias._smolvm_db_path()
|
||||||
|
self.assertEqual(Path("/custom/data/smolvm/server/smolvm.db"), p)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -56,9 +56,14 @@ class TestBundleSubnet(unittest.TestCase):
|
|||||||
|
|
||||||
class TestPreflight(unittest.TestCase):
|
class TestPreflight(unittest.TestCase):
|
||||||
def test_smolvm_present_returns_none(self):
|
def test_smolvm_present_returns_none(self):
|
||||||
|
# Pin macOS so the Linux KVM gate doesn't fire on a CI runner
|
||||||
|
# (ubuntu, no /dev/kvm) — this test isolates the PATH check.
|
||||||
with patch(
|
with patch(
|
||||||
"bot_bottle.backend.smolmachines.util.shutil.which",
|
"bot_bottle.backend.smolmachines.util.shutil.which",
|
||||||
return_value="/usr/local/bin/smolvm",
|
return_value="/usr/local/bin/smolvm",
|
||||||
|
), patch(
|
||||||
|
"bot_bottle.backend.smolmachines.util.platform.system",
|
||||||
|
return_value="Darwin",
|
||||||
):
|
):
|
||||||
self.assertIsNone(smolmachines_preflight())
|
self.assertIsNone(smolmachines_preflight())
|
||||||
|
|
||||||
@@ -88,5 +93,63 @@ class TestPreflight(unittest.TestCase):
|
|||||||
self.assertIn("BOT_BOTTLE_BACKEND=docker", msg)
|
self.assertIn("BOT_BOTTLE_BACKEND=docker", msg)
|
||||||
|
|
||||||
|
|
||||||
|
class TestKvmPreflight(unittest.TestCase):
|
||||||
|
"""Linux-only KVM gate: smolvm needs /dev/kvm present and
|
||||||
|
accessible. macOS skips this entirely (Hypervisor.framework)."""
|
||||||
|
|
||||||
|
def _run(self, *, system, exists, access):
|
||||||
|
with patch(
|
||||||
|
"bot_bottle.backend.smolmachines.util.shutil.which",
|
||||||
|
return_value="/usr/bin/smolvm",
|
||||||
|
), patch(
|
||||||
|
"bot_bottle.backend.smolmachines.util.platform.system",
|
||||||
|
return_value=system,
|
||||||
|
), patch(
|
||||||
|
"bot_bottle.backend.smolmachines.util.os.path.exists",
|
||||||
|
return_value=exists,
|
||||||
|
), patch(
|
||||||
|
"bot_bottle.backend.smolmachines.util.os.access",
|
||||||
|
return_value=access,
|
||||||
|
):
|
||||||
|
return smolmachines_preflight()
|
||||||
|
|
||||||
|
def test_macos_skips_kvm_check(self):
|
||||||
|
# Even with /dev/kvm absent, macOS must not run the gate.
|
||||||
|
self.assertIsNone(self._run(system="Darwin", exists=False, access=False))
|
||||||
|
|
||||||
|
def test_linux_ok_returns_none(self):
|
||||||
|
self.assertIsNone(self._run(system="Linux", exists=True, access=True))
|
||||||
|
|
||||||
|
def test_linux_missing_device_dies(self):
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self._run(system="Linux", exists=False, access=False)
|
||||||
|
|
||||||
|
def test_linux_no_access_dies(self):
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self._run(system="Linux", exists=True, access=False)
|
||||||
|
|
||||||
|
def test_linux_missing_device_message(self):
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
captured = io.StringIO()
|
||||||
|
with patch.object(sys, "stderr", captured):
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self._run(system="Linux", exists=False, access=False)
|
||||||
|
msg = captured.getvalue()
|
||||||
|
self.assertIn("/dev/kvm", msg)
|
||||||
|
self.assertIn("kvm-intel", msg)
|
||||||
|
|
||||||
|
def test_linux_no_access_message(self):
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
captured = io.StringIO()
|
||||||
|
with patch.object(sys, "stderr", captured):
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self._run(system="Linux", exists=True, access=False)
|
||||||
|
msg = captured.getvalue()
|
||||||
|
self.assertIn("kvm", msg)
|
||||||
|
self.assertIn("group", msg)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user