diff --git a/.github/scripts/check_image_build_inputs.py b/.github/scripts/check_image_build_inputs.py new file mode 100644 index 0000000..2829111 --- /dev/null +++ b/.github/scripts/check_image_build_inputs.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 + +import hashlib +import json +import sys +import urllib.error +import urllib.parse +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "src")) + +from sbx.image.kernel_inputs import KERNEL_INPUTS, fetch, raw_url # noqa: E402 + +BRANCHES = {"CelestoAI/SmolVM": "main", "moby/moby": "master"} +LEGAL_FILES = { + "CelestoAI/SmolVM": ("LICENSE", "NOTICE"), + "moby/moby": ("LICENSE", "NOTICE"), +} + + +def _optional_fetch(url: str, fetcher=fetch) -> bytes | None: + try: + return fetcher(url) + except urllib.error.HTTPError as exc: + if exc.code == 404: + return None + raise + + +def _latest_commit(repository: str, path: str, fetcher=fetch) -> str: + query = urllib.parse.urlencode({"path": path, "per_page": 1}) + data = json.loads(fetcher(f"https://api.github.com/repos/{repository}/commits?{query}")) + return data[0]["sha"] + + +def check_updates(fetcher=fetch) -> int: + changed = False + for name, (repository, _commit, path, expected) in KERNEL_INPUTS.items(): + latest = fetcher(raw_url(repository, BRANCHES[repository], path)) + digest = hashlib.sha256(latest).hexdigest() + if digest != expected: + changed = True + latest_commit = _latest_commit(repository, path, fetcher) + print(f"{name}: update available") + print(f" commit: {latest_commit}") + print(f" sha256: {digest}") + + for repository, paths in LEGAL_FILES.items(): + pinned_commit = next( + source[1] for source in KERNEL_INPUTS.values() if source[0] == repository + ) + for path in paths: + pinned = _optional_fetch(raw_url(repository, pinned_commit, path), fetcher) + latest = _optional_fetch(raw_url(repository, BRANCHES[repository], path), fetcher) + if pinned != latest: + changed = True + print(f"{repository}/{path}: licensing review required") + + if not changed: + print("Image build inputs are current.") + return int(changed) + + +if __name__ == "__main__": + raise SystemExit(check_updates()) diff --git a/README.md b/README.md index a58666c..a0f3743 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ sbx run my-sbx --agent claude | `network forward [NAME] SPEC...` | Temporarily forward host TCP ports to a running sandbox until Ctrl-C. | | `network auth-port [NAME]` | Expert helper: manually expose the OAuth callback port for an already-running sandbox. | | `network close-auth-port [NAME]` | Expert helper: close the tracked OAuth callback tunnel. | -| `image build-debian` | Advanced helper: build a local Debian/Pi image, optionally with `--with-docker`. | +| `image build` | Build the curated local Pi/rootless-Docker image. | | `image ls` | List local ready-to-run images under `~/.smolvm/images`. | | `doctor` | Run non-sudo SmolVM diagnostics for QEMU. | | `completion SHELL` | Generate shell completion for `bash`, `zsh`, or `fish`. | @@ -161,20 +161,34 @@ List local ready-to-run images: sbx image ls ``` -Build the default Debian/Pi image: +Build the curated image and create project configuration while starting it: ```bash -sbx image build-debian --name debian-sbx +sbx image build +sbx run the-quest \ + --image '~/.smolvm/images/sbx' \ + --run-user agent \ + --project-path . \ + --writable-mounts \ + --write-config ``` -Use it from `.sbx.toml`: +The suggested project configuration is: ```toml [sbx] -image = "~/.smolvm/images/debian-sbx" +name = "the-quest" +agent = "pi" +image = "~/.smolvm/images/sbx" +project_path = "." run_user = "agent" +writable_mounts = true +copy_host_credentials = false +git_config = true ``` +`--write-config` belongs to `run` and `create`; `image build` never changes `.sbx.toml`. + Configure durable TCP forwards applied when the VM starts: ```toml @@ -182,21 +196,13 @@ Configure durable TCP forwards applied when the VM starts: port_forwards = ["3000", "8080:3000"] ``` -Build with Docker support: +The default image includes rootless Docker. For a larger Docker data/build cache, increase its rootfs: ```bash -sbx image build-debian --with-docker --name debian-sbx-docker --rootfs-size-mb 81920 -``` - -Use the Docker-capable image: - -```toml -[sbx] -image = "~/.smolvm/images/debian-sbx-docker" -run_user = "agent" +sbx image build --rootfs-size-mb 81920 ``` -These Debian/Pi images should run as `agent`; Docker rootless also uses that user. Docker-capable images show `docker` in `sbx image ls` and start rootless Docker at boot. +These Debian/Pi images should run as `agent`; rootless Docker also uses that user. Built images show `docker` in `sbx image ls` and start rootless Docker at boot. For details, see [`docs/build-local-debian-pi-image.md`](docs/build-local-debian-pi-image.md). For contributor setup and test commands, see [`docs/development.md`](docs/development.md). @@ -220,7 +226,7 @@ Example `.sbx.toml`: ```toml [sbx] agent = "pi" # pi, claude, or codex -name = "project-sbx" +name = "the-quest" memory = 4096 cpus = 2 disk_size = 20480 @@ -298,7 +304,7 @@ See [`docs/environment-forwarding.md`](docs/environment-forwarding.md) for detai ### Local ready-to-run image directories -When `[sbx].image` is set, `sbx` treats the image as already containing the selected agent. It boots the image directly with the SmolVM SDK and then attaches to run the agent command; it does not run SmolVM's preset installer. If `[sbx].disk_size` is set for a local raw ext4 image, `sbx` asks SmolVM to create an isolated disk of that size and grow the filesystem. For the end-to-end Debian/Pi workflow, including optional Docker support with `--with-docker`, see [`docs/build-local-debian-pi-image.md`](docs/build-local-debian-pi-image.md). +When `[sbx].image` is set, `sbx` treats the image as already containing the selected agent. It boots the image directly with the SmolVM SDK and then attaches to run the agent command; it does not run SmolVM's preset installer. If `[sbx].disk_size` is set for a local raw ext4 image, `sbx` asks SmolVM to create an isolated disk of that size and grow the filesystem. For the end-to-end Debian/Pi and rootless Docker workflow, see [`docs/build-local-debian-pi-image.md`](docs/build-local-debian-pi-image.md). Example layout: diff --git a/docs/build-local-debian-pi-image.md b/docs/build-local-debian-pi-image.md index ca8458e..077034c 100644 --- a/docs/build-local-debian-pi-image.md +++ b/docs/build-local-debian-pi-image.md @@ -1,21 +1,22 @@ # Build and run a local Debian Pi image -`sbx image build-debian` builds a ready-to-run SmolVM image directory that already contains Pi. `sbx` boots that image directly and attaches to run `pi`; it does not run SmolVM's preset installer. +`sbx image build` builds the curated ready-to-run SmolVM image under `~/.smolvm/images/sbx`. It contains Pi and rootless Docker. `sbx` boots that image directly; it does not run SmolVM's preset installer. ## 1. Image build command -The builder is an advanced `sbx` subcommand: +The builder is an `sbx` subcommand: ```bash -sbx image build-debian +sbx image build ``` -The image recipe is packaged with `sbx` and split into two Containerfile fragments: +The image recipe is packaged with `sbx` and split into Debian, Docker, and Pi Containerfile fragments: ```text src/sbx/image/resources/Containers/ ├── Debian/ -│ └── Base.Containerfile +│ ├── Base.Containerfile +│ └── fragments/Docker.Containerfile └── Agents/ └── Pi.Containerfile ``` @@ -47,33 +48,32 @@ Install the currently supported tools first: ```bash uv tool install --editable . -uv tool install 'smolvm==0.0.19' +uv tool install 'smolvm==0.0.28' ``` -`sbx` pins SmolVM `0.0.19` for now. Newer SmolVM compatibility is separate work. +`sbx` pins SmolVM `0.0.28`. ## 3. Build the image Run the image build command: ```bash -sbx image build-debian +sbx image build ``` -Useful options: +The default image name is `sbx`. Override its size or name only when needed: ```bash -sbx image build-debian \ - --name debian-sbx \ - --rootfs-size-mb 40960 +sbx image build --rootfs-size-mb 40960 +sbx image build --name custom-image ``` -The subcommand combines the base/agent Containerfiles, plus Docker when `--with-docker` is set, builds that combined Containerfile first, and passes the resulting Docker image into SmolVM's Debian image builder. If the combined Containerfile ends with `USER agent`, the subcommand wraps it with a tiny `USER root` image so SmolVM's builder can still run its root-level SSH/init setup. +The subcommand combines the Debian, Docker, and Pi fragments, builds that image, adds SSH and the SmolVM-compatible init, exports `rootfs.ext4`, and builds the Docker-capable QEMU kernel. SmolVM's published kernel and guest agent are not downloaded; local images communicate through SSH. For local experiments, you can override the packaged fragments with your own files: ```bash -sbx image build-debian \ +sbx image build \ --base-containerfile path/to/Base.Containerfile \ --agent-containerfile path/to/Pi.Containerfile ``` @@ -81,15 +81,15 @@ sbx image build-debian \ Or pass a fully composed Containerfile directly: ```bash -sbx image build-debian --containerfile path/to/Containerfile +sbx image build --containerfile path/to/Containerfile ``` -The subcommand prints the built image paths and a minimal `sbx` config snippet. +The subcommand prints the built image paths, a minimal config snippet, and the recommended `sbx run ... --write-config` command. Image building never modifies the current project configuration. The subcommand also writes a local image manifest: ```text -~/.smolvm/images/debian-sbx/smolvm-image.json +~/.smolvm/images/sbx/smolvm-image.json ``` List built images: @@ -99,17 +99,16 @@ sbx image ls sbx image ls --json ``` -and uses SmolVM's QEMU-compatible kernel by default. With `--with-docker`, it builds a Docker-capable kernel from pinned SmolVM kernel build inputs and stores it as `vmlinux-docker.bin` in the image directory. +The builder downloads the reviewed SmolVM kernel recipe and Moby checker from pinned commit URLs, verifies each file's SHA-256 before use, and then compiles a Docker-capable QEMU kernel from a separately SHA-256-verified Linux source tarball. It stores the kernel as `vmlinux.bin`. Builds therefore require access to GitHub, kernel.org, and package repositories. ## 4. Local image directory layout After a successful build, the image directory should look like: ```text -~/.smolvm/images/debian-sbx/ +~/.smolvm/images/sbx/ ├── smolvm-image.json ├── vmlinux.bin -├── vmlinux-docker.bin # only when built with --with-docker └── rootfs.ext4 ``` @@ -117,40 +116,42 @@ Example manifest: ```json { - "name": "debian-sbx", + "name": "sbx", "kernel": "vmlinux.bin", "rootfs": "rootfs.ext4", "boot_args": "console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rw init=/init", "sbx": { "agent": "pi", - "features": [], + "features": ["docker"], "launch_command": "pi" } } ``` -## 5. Configure `sbx` +## 5. Configure and run `sbx` -Create or update `.sbx.toml`: +Use the built image and write the selected project settings: + +```bash +sbx run the-quest \ + --image '~/.smolvm/images/sbx' \ + --run-user agent \ + --project-path . \ + --writable-mounts \ + --write-config +``` + +The suggested `.sbx.toml` is: ```toml [sbx] +name = "the-quest" agent = "pi" -name = "debian-pi-test" -image = "~/.smolvm/images/debian-sbx" -memory = 2048 -cpus = 2 -boot_timeout = 60 -run_user = "agent" - +image = "~/.smolvm/images/sbx" project_path = "." +run_user = "agent" writable_mounts = true - -stop_on_exit = false copy_host_credentials = false -env = [] - -# True by default. Copies safe Git identity/config only, not credentials. git_config = true ``` @@ -158,27 +159,17 @@ Important: `copy_host_credentials = false` prevents host credential/config copyi `git_config = true` is the default and only copies a safe subset of host Git identity/workflow settings, such as `user.name` and `user.email`, so commits inside the VM have the expected author. It does not copy Git credentials, SSH keys, GPG/signing keys, credential helpers, includes, or URL rewrite rules. -## 6. Run it - -Use the configured name from `.sbx.toml`: +`--write-config` belongs to `run` and `create`; `image build` never writes `.sbx.toml`. Later runs can use the saved configuration: ```bash -sbx --debug run -``` - -Passing a positional argument changes the VM name. For example: - -```bash -sbx --debug run pi +sbx run ``` -means "run a sandbox named `pi`", not "run the Pi agent". - ## Docker guest usage ### Host -Install Docker on the host, then build the Docker-capable image and point `.sbx.toml` at it. Kernel compile tools run inside the packaged `Containers/Build/Kernel.Containerfile`; the guest image installs Docker from Docker's official Debian apt repository. No host kernel compiler packages are required. +Install Docker on the host, then build the image and point `.sbx.toml` at it. Kernel compile tools run inside the packaged `Containers/Build/Kernel.Containerfile`; the guest image installs Docker from Docker's official Debian apt repository. No host kernel compiler packages are required. Ensure Docker works on the host for the image build: @@ -187,21 +178,18 @@ docker version ``` ```bash -sbx image build-debian \ - --with-docker \ - --name debian-sbx-docker \ - --rootfs-size-mb 81920 +sbx image build --rootfs-size-mb 81920 ``` ```toml [sbx] -image = "~/.smolvm/images/debian-sbx-docker" +image = "~/.smolvm/images/sbx" run_user = "agent" ``` Create or recreate the VM after changing the image. If the rootfs was built with `--rootfs-size-mb 81920`, omit `disk_size` unless you intentionally want SmolVM to grow the per-VM disk. -Rootless Docker starts at VM boot in Docker-capable images. Inside the guest, use Docker normally: +Rootless Docker starts at VM boot. Inside the guest, use Docker normally: ```bash docker run --rm hello-world @@ -266,7 +254,7 @@ Rootful Docker data lives under `/var/lib/docker`. ### `QEMU exited early` / `Error loading uncompressed kernel without PVH ELF Note` -The image was likely built with SmolVM's Firecracker-compatible ELF kernel. Rebuild with the current `sbx image build-debian` command; it selects SmolVM's QEMU-compatible kernel by default. +The image likely references an incompatible or older kernel. Rebuild it with the current `sbx image build` command to produce the Docker-capable QEMU kernel. ### VM starts but SSH readiness times out @@ -309,21 +297,14 @@ PATH="$PATH:/usr/sbin:/sbin" sbx run Alternatively, remove `disk_size` if the image is already large enough, or rebuild the image with the desired size. -Resizing is per VM. SmolVM normally materializes an isolated disk for each sandbox, so growing `reviewhero` does not resize the shared image directory or other VMs that use the same local image. If you rebuilt the base image and want an existing VM to pick it up, remove/recreate that VM. +Resizing is per VM. SmolVM normally materializes an isolated disk for each sandbox, so growing `the-quest` does not resize the shared image directory or other VMs that use the same local image. If you rebuilt the base image and want an existing VM to pick it up, remove/recreate that VM. ### Existing VM keeps using old image contents If you changed and rebuilt the image, remove/recreate the VM: ```bash -sbx rm debian-pi-test --force -sbx run -``` - -or, if you created a VM named `pi` by running `sbx run pi`: - -```bash -sbx rm pi --force +sbx rm the-quest --force sbx run ``` @@ -332,8 +313,8 @@ sbx run Inspect or close tracked tunnels: ```bash -sbx network status debian-pi-test -sbx network close-auth-port debian-pi-test +sbx network status the-quest +sbx network close-auth-port the-quest ``` ## Notes diff --git a/docs/current-local-image-usage.md b/docs/current-local-image-usage.md index e4d86c4..018e5bf 100644 --- a/docs/current-local-image-usage.md +++ b/docs/current-local-image-usage.md @@ -7,7 +7,7 @@ This document records how `sbx` currently uses local SmolVM images. It is descri `sbx` local image mode uses a ready-to-run image directory containing separate boot artifacts: ```text -~/.smolvm/images/debian-sbx/ +~/.smolvm/images/sbx/ ├── smolvm-image.json ├── vmlinux.bin └── rootfs.ext4 @@ -20,25 +20,17 @@ The image is not a single self-contained bootable disk. It is a direct-kernel Sm The local Debian/Pi image is built with: ```bash -sbx image build-debian +sbx image build ``` -The build recipe is split into a reusable base OS fragment and an agent/tooling fragment: - -```text -src/sbx/image/resources/Containers/ -├── Debian/ -│ └── Base.Containerfile -└── Agents/ - └── Pi.Containerfile -``` +The build recipe combines packaged Debian, Docker, and Pi fragments. The build command: -1. combines the base and agent Containerfiles into a temporary Containerfile; -2. builds that combined Containerfile into a Docker image; -3. asks SmolVM's `ImageBuilder.build_debian_ssh_key(...)` to create a Debian SSH-capable rootfs from that Docker image; -4. uses SmolVM's published/base QEMU-compatible kernel; +1. builds the combined userspace as a Docker image; +2. adds SSH and a SmolVM-compatible init without the SmolVM guest agent; +3. exports that userspace into `rootfs.ext4`; +4. downloads the kernel recipe and checker from immutable commits, verifies each SHA-256, and builds a Docker-capable QEMU kernel from SHA-256-verified Linux source; 5. writes `smolvm-image.json` next to the generated kernel and rootfs. The resulting rootfs already contains Pi and related tooling from the packaged `Containers/Agents/Pi.Containerfile`. Therefore, `sbx` does not run SmolVM preset installation for this mode. @@ -49,13 +41,13 @@ The local image manifest is `smolvm-image.json`: ```json { - "name": "debian-sbx", + "name": "sbx", "kernel": "vmlinux.bin", "rootfs": "rootfs.ext4", "boot_args": "console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rw init=/init", "sbx": { "agent": "pi", - "features": [], + "features": ["docker"], "launch_command": "pi" } } @@ -70,8 +62,8 @@ A typical `.sbx.toml` uses the local image directory: ```toml [sbx] agent = "pi" -name = "reviewhero" -image = "~/.smolvm/images/debian-sbx" +name = "the-quest" +image = "~/.smolvm/images/sbx" run_user = "agent" memory = 8192 cpus = 4 @@ -86,7 +78,7 @@ When `sbx run` starts a missing VM from this image, `sbx`: 1. loads the image manifest; 2. resolves `kernel`, `rootfs`, optional `initrd`, and `boot_args`; -3. creates a SmolVM `VMConfig` using those paths; +3. creates a SmolVM `VMConfig` using those paths and `comm_channel="ssh"`; 4. starts the VM through the SmolVM SDK; 5. waits for SSH readiness; 6. prepares `run_user`, safe Git config, auth callback forwarding, and project cwd as configured; @@ -106,7 +98,7 @@ Even in local image mode, `sbx` still uses SmolVM for: - SSH readiness checks and SSH command generation; - isolated per-VM disk materialization. -The kernel file in the image directory is produced from SmolVM's kernel assets. The rootfs is our built Debian/Pi filesystem. +The kernel and Debian/Pi/Docker rootfs are built locally. SmolVM still provides lifecycle, QEMU, disk, mount, and SSH integration, but this build does not download SmolVM's published kernel or guest agent. ## Disk behavior @@ -116,11 +108,10 @@ Conceptually: ```text base image rootfs: - ~/.smolvm/images/debian-sbx/rootfs.ext4 + ~/.smolvm/images/sbx/rootfs.ext4 -per-VM materialized disks: - ~/.local/state/smolvm/disks/reviewhero.ext4 - ~/.local/state/smolvm/disks/pi.ext4 +per-VM materialized disk: + ~/.local/state/smolvm/disks/the-quest.ext4 ``` Changing or resizing one VM's materialized disk does not resize the base image or other VMs. @@ -128,7 +119,7 @@ Changing or resizing one VM's materialized disk does not resize the base image o If a base image is rebuilt, existing VMs may continue to use their already-materialized per-VM disks. To force a VM to pick up the rebuilt base image, remove/recreate that VM: ```bash -sbx rm reviewhero --force +sbx rm the-quest --force sbx run ``` diff --git a/docs/development.md b/docs/development.md index 819e919..05af5f4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -27,3 +27,12 @@ Before making code changes in a fresh environment, first run the full test suite once with the command above to verify the checkout and tool environment are healthy. After changing CLI behavior, add/update focused tests and run both the focused tests and the full suite. + +Check whether pinned SmolVM/Moby image-build inputs or their license notices changed: + +```bash +python .github/scripts/check_image_build_inputs.py +``` + +A non-zero exit means review is needed. The checker reports new commits and SHA-256 +values but never updates executable pins automatically. diff --git a/docs/ergonomics.md b/docs/ergonomics.md index bd5a77a..4f5c327 100644 --- a/docs/ergonomics.md +++ b/docs/ergonomics.md @@ -57,9 +57,9 @@ Good durable config fields include: ```toml [sbx] -name = "project-sbx" +name = "the-quest" agent = "pi" -image = "~/.smolvm/images/debian-sbx" +image = "~/.smolvm/images/sbx" memory = 8192 cpus = 4 disk_size = 40960 @@ -100,9 +100,9 @@ an already-created VM may still have an 81920 MiB disk. `sbx run` should reuse t ```text sbx config/state: - warning: VM 'project-sbx' already exists and differs from .sbx.toml: + warning: VM 'the-quest' already exists and differs from .sbx.toml: disk_size: config requests 10240 MiB, existing VM has 81920 MiB - Existing VMs are reused as-is. Run `sbx recreate project-sbx --force` to apply config changes. + Existing VMs are reused as-is. Run `sbx recreate the-quest --force` to apply config changes. ``` When using a local image, `sbx doctor` should also warn if `[sbx].disk_size` is smaller than the local image rootfs. `sbx run` should fail early with the same explanation: set `disk_size` to at least the image rootfs size, remove `disk_size`, or rebuild the configured local image with a smaller rootfs. @@ -131,15 +131,15 @@ Behavior: Example: ```bash -sbx run --name reviewhero --image ~/.smolvm/images/debian-sbx --memory 8192 --cpus 4 --disk-size 40960 --project-path . --run-user agent +sbx run --name the-quest --image ~/.smolvm/images/sbx --memory 8192 --cpus 4 --disk-size 40960 --project-path . --run-user agent ``` could create: ```toml [sbx] -name = "reviewhero" -image = "~/.smolvm/images/debian-sbx" +name = "the-quest" +image = "~/.smolvm/images/sbx" memory = 8192 cpus = 4 disk_size = 40960 diff --git a/docs/fragile-glue.md b/docs/fragile-glue.md index 07f4e53..d8ec734 100644 --- a/docs/fragile-glue.md +++ b/docs/fragile-glue.md @@ -2,21 +2,23 @@ Track deliberate use of internal APIs, generated-file assumptions, or other integration seams that should be replaced when upstream support exists. -## SmolVM init injection via protected methods +## SmolVM local rootfs and init seams -Used for Docker-capable images: +Used by `src/sbx/image/build_debian.py`: ```python -class SbxDockerImageBuilder(ImageBuilder): +class SbxImageBuilder(ImageBuilder): def _default_init_script(self) -> str: return self._base_init_script(custom_commands="...") + +DockerRootfsBuilder(...)._build_rootfs(...) ``` -Why: SmolVM has an internal `custom_commands` hook in `_base_init_script()`, but `build_debian_ssh_key()` does not expose it publicly. This lets sbx start rootless Docker at VM boot without SSH post-start orchestration, wrapping Pi, or patching `rootfs.ext4`. +Why: SmolVM 0.0.28 has no public rootfs-only operation. `build_debian_ssh_key()` injects the guest agent and downloads a kernel; `DockerRootfsBuilder.build_boot_image()` also downloads a kernel. sbx needs only the generated init plus Docker export/ext4 conversion because it builds its own kernel and uses SSH. -Fragility: `_default_init_script()` and `_base_init_script()` are protected SmolVM internals and may change. +Fragility: `_base_init_script()` and `_build_rootfs()` are private SmolVM internals and may change. The dependency remains pinned to `smolvm==0.0.28`, and focused tests cover the call boundary. -Exit: replace with a public SmolVM boot-hook API, e.g. `/etc/smolvm/boot.d/*.sh` or a public `custom_commands`/`boot_hooks` parameter. +Exit: use a public SmolVM rootfs-only API that accepts caller-owned Dockerfile/context and init commands without resolving a kernel or injecting a guest agent. ## SmolVM preset creation through private facade methods diff --git a/docs/smolvm-guest-control-plane.md b/docs/smolvm-guest-control-plane.md index 4baccc4..f901177 100644 --- a/docs/smolvm-guest-control-plane.md +++ b/docs/smolvm-guest-control-plane.md @@ -1,62 +1,23 @@ # SmolVM guest control plane -SmolVM-built Linux images include a guest-side control-plane helper at: +SmolVM 0.0.28 normally places a static Rust control-plane binary at: ```text /usr/local/bin/smolvm-guest-agent ``` -The file is copied from SmolVM's source module: +SmolVM downloads the architecture-specific binary from its GitHub release, verifies a package-pinned SHA-256 digest, and starts it as root on AF_VSOCK port `1024`. It supports readiness checks, command execution, upload, and download. -```text -smolvm/guest_agent/agent.py -``` - -## Purpose - -The guest agent gives the host a control channel into the guest over AF_VSOCK. This lets SmolVM perform basic VM operations without depending solely on guest networking or SSH being ready. - -Supported operations include: - -- readiness ping -- run a shell command -- upload a file -- download a file - -The agent listens on vsock port `1024` by default. - -## Runtime model +Anyone able to open that vsock endpoint controls the guest. This matches SmolVM's host-owns-guest model, but it is unnecessary attack surface for sbx local images. -The script is standalone and Python stdlib-only. It is copied into the image and launched by SmolVM's custom `/init` process. +## sbx local images -SmolVM's Debian image builder appends this to its generated Dockerfile: +`sbx image build` intentionally omits the guest-agent binary. Local images instead set: -```dockerfile -COPY smolvm-guest-agent /usr/local/bin/smolvm-guest-agent -RUN chmod +x /usr/local/bin/smolvm-guest-agent +```python +comm_channel="ssh" ``` -## Security model - -The guest agent is a host-owned sandbox control channel. Anyone who can open the guest's vsock port can run commands in the guest, so it must not be exposed beyond the local host/sandbox boundary. - -This matches SmolVM's existing trust model: the host owns the disposable guest. - -## Disabling or avoiding the guest agent - -There are two separate levels: - -1. **Avoid using the guest agent from the host.** SmolVM APIs can be forced to use SSH with `comm_channel="ssh"`. In that mode, command execution and file transfer should use SSH instead of vsock. This does not remove the agent from the image; it only avoids selecting it as the control channel. -2. **Prevent the agent from running inside the guest.** SmolVM's custom `/init` starts the agent only when both `python3` and `/usr/local/bin/smolvm-guest-agent` exist. Removing the agent file from the rootfs, or patching `/init`, prevents it from starting. - -Expected consequences of disabling the in-guest agent: - -- Explicit vsock use, such as `comm_channel="vsock"` or CLI `--comm-channel vsock`, will fail or time out. -- Auto channel selection on Linux/QEMU may first probe vsock, wait briefly, then fall back to SSH. -- SSH-based functionality should continue to work if SSH is configured and reachable. -- `sbx` attach/shell/auth-port flows are primarily SSH-based and should not require the guest agent. -- SmolVM SDK operations such as `run`, upload, download, and env helpers should still work when the channel is explicitly set to SSH. - -If we later decide to support agent-less images in `sbx`, the preferred approach is to force the SmolVM SDK channel to SSH for local ready-to-run image mode rather than exposing more user-facing options. +SSH handles readiness, commands, file transfer, environment synchronization, shell, and agent attachment. SmolVM's generated init may retain a harmless executable-file check for the agent, but no listener starts because the binary is absent. -Related local-image workflow notes are in [`build-local-debian-pi-image.md`](build-local-debian-pi-image.md). +This policy applies only to sbx-built local images. SmolVM preset and published images may still include and use the guest agent. diff --git a/src/sbx/cli.py b/src/sbx/cli.py index 225c614..85ddae2 100644 --- a/src/sbx/cli.py +++ b/src/sbx/cli.py @@ -616,6 +616,7 @@ def _start_local_image( "rootfs_path": rootfs_path, "boot_args": boot_args, "backend": DEFAULT_BACKEND, + "comm_channel": "ssh", "ssh_capable": True, "ssh_public_key": public_key.read_text(encoding="utf-8").strip(), "port_forwards": network.port_forwards_from_specs(port_forwards), @@ -687,7 +688,7 @@ def cmd_completion(args: argparse.Namespace) -> int: return 0 -def cmd_image_build_debian(args: argparse.Namespace) -> int: +def cmd_image_build(args: argparse.Namespace) -> int: return build_debian.main_from_args(args) @@ -1376,11 +1377,11 @@ def build_parser() -> argparse.ArgumentParser: image = sub.add_parser("image", help="Advanced local image helpers.") image_sub = image.add_subparsers(dest="image_action", required=True) - build_debian_parser = image_sub.add_parser( - "build-debian", help="Build a local Debian Pi image for sbx." + build_parser = image_sub.add_parser( + "build", help="Build the curated local image for sbx." ) - build_debian.add_arguments(build_debian_parser) - build_debian_parser.set_defaults(func=cmd_image_build_debian) + build_debian.add_arguments(build_parser) + build_parser.set_defaults(func=cmd_image_build) list_images_parser = image_sub.add_parser("ls", help="List local sbx images.") sbx.image.ls.add_arguments(list_images_parser) diff --git a/src/sbx/completion.py b/src/sbx/completion.py index 9ed125d..8bd2ee3 100644 --- a/src/sbx/completion.py +++ b/src/sbx/completion.py @@ -16,7 +16,7 @@ "completion", ) NETWORK_COMMANDS = ("forward", "auth-port", "close-auth-port", "status") -IMAGE_COMMANDS = ("build-debian", "ls") +IMAGE_COMMANDS = ("build", "ls") AGENTS = ("pi", "claude", "codex") GLOBAL_OPTIONS = ("--config", "--debug", "--help") @@ -68,18 +68,15 @@ RUN_OPTIONS = START_OPTIONS AUTH_PORT_OPTIONS = ("--guest-port", "--host-port", "--replace", "--help") NETWORK_STATUS_OPTIONS = ("--host-port", "--help") -IMAGE_BUILD_DEBIAN_OPTIONS = ( +IMAGE_BUILD_OPTIONS = ( "--name", "--base-image", "--containerfile", "--dockerfile", "--base-containerfile", "--agent-containerfile", - "--with-docker", "--rootfs-size-mb", - "--ssh-public-key", "--cache-dir", - "--kernel-url", "--json", "--help", ) @@ -120,7 +117,7 @@ def bash_completion() -> str: image_commands = _words(IMAGE_COMMANDS) auth_port_options = _words(AUTH_PORT_OPTIONS) network_status_options = _words(NETWORK_STATUS_OPTIONS) - image_build_debian_options = _words(IMAGE_BUILD_DEBIAN_OPTIONS) + image_build_options = _words(IMAGE_BUILD_OPTIONS) image_ls_options = _words(IMAGE_LS_OPTIONS) agents = _words(AGENTS) shells = _words(COMPLETION_SHELLS) @@ -215,8 +212,8 @@ def bash_completion() -> str: done if [[ -z "$subcmd" ]]; then COMPREPLY=( $(compgen -W "{image_commands}" -- "$cur") ) - elif [[ "$subcmd" == "build-debian" ]]; then - COMPREPLY=( $(compgen -W "{image_build_debian_options}" -- "$cur") ) + elif [[ "$subcmd" == "build" ]]; then + COMPREPLY=( $(compgen -W "{image_build_options}" -- "$cur") ) elif [[ "$subcmd" == "ls" ]]; then COMPREPLY=( $(compgen -W "{image_ls_options}" -- "$cur") ) fi @@ -247,7 +244,7 @@ def zsh_completion() -> str: auth_port_options = _zsh_words(AUTH_PORT_OPTIONS) network_status_options = _zsh_words(NETWORK_STATUS_OPTIONS) image_commands = _zsh_words(IMAGE_COMMANDS) - image_build_debian_options = _zsh_words(IMAGE_BUILD_DEBIAN_OPTIONS) + image_build_options = _zsh_words(IMAGE_BUILD_OPTIONS) image_ls_options = _zsh_words(IMAGE_LS_OPTIONS) shells = _zsh_words(COMPLETION_SHELLS) return f"""#compdef sbx @@ -256,7 +253,7 @@ def zsh_completion() -> str: local -a commands run_options create_options recreate_options shell_options local -a ls_options rm_options doctor_options stop_options network_commands local -a auth_port_options network_status_options - local -a image_commands image_build_debian_options image_ls_options shells + local -a image_commands image_build_options image_ls_options shells commands=({commands}) run_options=({run_options}) create_options=({create_options}) @@ -270,7 +267,7 @@ def zsh_completion() -> str: auth_port_options=({auth_port_options}) network_status_options=({network_status_options}) image_commands=({image_commands}) - image_build_debian_options=({image_build_debian_options}) + image_build_options=({image_build_options}) image_ls_options=({image_ls_options}) shells=({shells}) @@ -319,8 +316,8 @@ def zsh_completion() -> str: image) if (( CURRENT == 3 )); then _describe 'image command' image_commands - elif [[ $words[3] == "build-debian" ]]; then - _describe 'option' image_build_debian_options + elif [[ $words[3] == "build" ]]; then + _describe 'option' image_build_options elif [[ $words[3] == "ls" ]]; then _describe 'option' image_ls_options else @@ -398,9 +395,9 @@ def fish_completion() -> str: "complete -c sbx -f -n '__fish_seen_subcommand_from image; " f"and not __fish_seen_subcommand_from {image_subcommands}' -a {command}" ) - for option in IMAGE_BUILD_DEBIAN_OPTIONS: + for option in IMAGE_BUILD_OPTIONS: lines.append( - f"complete -c sbx -f -n '__fish_seen_subcommand_from build-debian' {_fish_flag(option)}" + f"complete -c sbx -f -n '__fish_seen_subcommand_from build' {_fish_flag(option)}" ) for option in IMAGE_LS_OPTIONS: lines.append(f"complete -c sbx -f -n '__fish_seen_subcommand_from ls' {_fish_flag(option)}") diff --git a/src/sbx/image/build_debian.py b/src/sbx/image/build_debian.py index 8740de4..71d941b 100644 --- a/src/sbx/image/build_debian.py +++ b/src/sbx/image/build_debian.py @@ -1,27 +1,27 @@ #!/usr/bin/env python3 """Build a local Debian rootfs/kernel image for SmolVM. -This is a convenience wrapper around SmolVM's ImageBuilder. It can use Docker -to build a Debian userspace from a Containerfile, packs it into rootfs.ext4, -downloads/resolves a SmolVM-compatible kernel, and prints the resulting paths. +This builds a Docker-capable Debian userspace and kernel from pinned inputs, +packs the userspace into rootfs.ext4, and prints the resulting paths. """ import argparse import hashlib import json import os +import shlex import shutil import subprocess import sys import tempfile -import urllib.request from collections.abc import Iterator from contextlib import contextmanager from importlib.resources import as_file, files from pathlib import Path -from smolvm.images.builder import ImageBuilder -from smolvm.images.published import BASE_KERNELS +from smolvm.images.builder import DockerRootfsBuilder, ImageBuilder + +from sbx.image import kernel_inputs RESOURCE_PACKAGE = "sbx.image.resources" DEFAULT_BASE_CONTAINERFILE = Path("Containers") / "Debian" / "Base.Containerfile" @@ -29,17 +29,8 @@ DEFAULT_DOCKER_CONTAINERFILE = Path("Containers") / "Debian" / "fragments" / "Docker.Containerfile" DEFAULT_DOCKER_KERNEL_FRAGMENT = Path("kernel") / "docker.config.fragment" DEFAULT_KERNEL_BUILDER_DOCKERFILE = Path("Containers") / "Build" / "Kernel.Containerfile" -SMOLVM_KERNEL_REF = "20e1fdf72c2139622eb32ab21f288c7290bba7bf" -SMOLVM_RAW_BASE = f"https://raw.githubusercontent.com/CelestoAI/SmolVM/{SMOLVM_KERNEL_REF}" -SMOLVM_KERNEL_FILES = ( - "build.sh", - "config.fragment", - "config.amd64.fragment", - "config.arm64.fragment", - "linux.version", - "linux.sha256", -) -DOCKER_KERNEL_NAME = "vmlinux-docker.bin" +KERNEL_NAME = "vmlinux.bin" +BOOT_ARGS = "console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rw init=/init" @contextmanager @@ -48,20 +39,11 @@ def _packaged_resources() -> Iterator[Path]: yield root -def _default_public_key() -> Path | None: - ssh_dir = Path.home() / ".ssh" - for name in ("id_ed25519.pub", "id_rsa.pub", "id_ecdsa.pub"): - candidate = ssh_dir / name - if candidate.is_file(): - return candidate - return None - - def _compose_containerfiles( base_containerfile: Path, + docker_containerfile: Path, agent_containerfile: Path, output: Path, - docker_containerfile: Path | None = None, ) -> None: base_containerfile = base_containerfile.expanduser().resolve() agent_containerfile = agent_containerfile.expanduser().resolve() @@ -70,24 +52,24 @@ def _compose_containerfiles( if not agent_containerfile.is_file(): raise FileNotFoundError(f"Agent Containerfile not found: {agent_containerfile}") + docker_containerfile = docker_containerfile.expanduser().resolve() + if not docker_containerfile.is_file(): + raise FileNotFoundError(f"Docker Containerfile not found: {docker_containerfile}") + content = base_containerfile.read_text(encoding="utf-8").rstrip() - if docker_containerfile is not None: - docker_containerfile = docker_containerfile.expanduser().resolve() - if not docker_containerfile.is_file(): - raise FileNotFoundError(f"Docker Containerfile not found: {docker_containerfile}") - content += "\n\n# ---- Docker layer ----\n" - content += docker_containerfile.read_text(encoding="utf-8").lstrip().rstrip() + content += "\n\n# ---- Docker layer ----\n" + content += docker_containerfile.read_text(encoding="utf-8").lstrip().rstrip() content += "\n\n# ---- Agent/tooling layer ----\n" content += agent_containerfile.read_text(encoding="utf-8").lstrip() output.write_text(content, encoding="utf-8") -class SbxDockerImageBuilder(ImageBuilder): +class SbxImageBuilder(ImageBuilder): def _default_init_script(self) -> str: # ponytail: protected SmolVM hook; replace with public boot hooks when upstream exists. return self._base_init_script( custom_commands=""" -# sbx: start rootless Docker at boot for Docker-capable images. +# sbx: start rootless Docker at boot when the image provides it. if [ -x /usr/local/bin/sbx-start-rootless-docker ]; then /usr/local/bin/sbx-start-rootless-docker >/var/log/sbx-rootless-docker.log 2>&1 & fi @@ -95,9 +77,85 @@ def _default_init_script(self) -> str: ) -def _download(url: str, output: Path) -> None: - with urllib.request.urlopen(url, timeout=60) as response: # noqa: S310 - pinned HTTPS URL. - output.write_bytes(response.read()) +def _ssh_rootfs_dockerfile(base_image: str) -> str: + return f""" +FROM {base_image} + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \\ + openssh-server \\ + iproute2 \\ + curl \\ + bash \\ + ca-certificates \\ + python3 \\ + && rm -rf /var/lib/apt/lists/* + +RUN rm -f /etc/ssh/ssh_host_* && \\ + mkdir -p /run/sshd /root/.ssh && chmod 700 /root/.ssh && \\ + sed -ri 's/^#?PermitRootLogin .*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config && \\ + sed -ri 's/^#?PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config && \\ + sed -ri 's/^#?PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config + +COPY init /init +RUN chmod +x /init +""" + + +def _build_rootfs( + *, builder: SbxImageBuilder, name: str, rootfs_size_mb: int, base_image: str, arch: str +) -> Path: + image_dir = builder.cache_dir / name + rootfs_path = image_dir / "rootfs.ext4" + fingerprint_path = image_dir / ".fingerprint" + dockerfile = _ssh_rootfs_dockerfile(base_image) + init_script = builder._default_init_script() + fingerprint = hashlib.sha256( + json.dumps( + { + "arch": arch, + "base_image": base_image, + "dockerfile": dockerfile, + "init": init_script, + "rootfs_size_mb": rootfs_size_mb, + }, + sort_keys=True, + ).encode() + ).hexdigest() + if ( + rootfs_path.is_file() + and fingerprint_path.is_file() + and fingerprint_path.read_text(encoding="utf-8").strip() == fingerprint + ): + return rootfs_path + + if not builder.check_docker(): + raise builder.docker_requirement_error() + + image_dir.mkdir(parents=True, exist_ok=True) + temporary_rootfs = image_dir / ".rootfs.tmp.ext4" + temporary_rootfs.unlink(missing_ok=True) + rootfs_builder = DockerRootfsBuilder( + name=name, + dockerfile=dockerfile, + rootfs_size_mb=rootfs_size_mb, + cache_dir=builder.cache_dir, + ) + try: + # ponytail: private rootfs-only seam; remove when SmolVM exposes a public equivalent. + rootfs_builder._build_rootfs( + helper=builder, + rootfs_path=temporary_rootfs, + docker_platform=f"linux/{arch}", + context_files={"init": init_script.encode()}, + docker_tag=f"sbx-rootfs-{fingerprint[:16]}", + ) + temporary_rootfs.replace(rootfs_path) + fingerprint_path.write_text(fingerprint, encoding="utf-8") + finally: + temporary_rootfs.unlink(missing_ok=True) + return rootfs_path def _build_kernel_builder_image(dockerfile: Path, context_dir: Path) -> str: @@ -139,6 +197,23 @@ def _docker_run_kernel_builder( ) +def _raise_if_linux_digest_mismatch(work_dir: Path) -> None: + version = (work_dir / "linux.version").read_text(encoding="utf-8").strip() + checksum = (work_dir / "linux.sha256").read_text(encoding="utf-8").split()[0] + tarball = work_dir / f"linux-{version}.tar.xz" + if not tarball.is_file(): + return + with tarball.open("rb") as source: + actual = hashlib.file_digest(source, "sha256").hexdigest() + if actual != checksum: + raise RuntimeError( + f"SHA-256 mismatch for https://cdn.kernel.org/pub/linux/kernel/v6.x/{tarball.name}\n" + f" expected: {checksum}\n" + f" actual: {actual}\n" + "update linux.version and linux.sha256 together after reviewing the new source" + ) + + def _build_docker_kernel(*, image_dir: Path, arch: str, resources_dir: Path | None = None) -> Path: if resources_dir is None: with _packaged_resources() as packaged: @@ -146,14 +221,14 @@ def _build_docker_kernel(*, image_dir: Path, arch: str, resources_dir: Path | No docker_fragment = resources_dir / DEFAULT_DOCKER_KERNEL_FRAGMENT builder_dockerfile = resources_dir / DEFAULT_KERNEL_BUILDER_DOCKERFILE - if not docker_fragment.is_file(): - raise FileNotFoundError(f"Docker kernel fragment not found: {docker_fragment}") + for path in (docker_fragment, builder_dockerfile): + if not path.is_file(): + raise FileNotFoundError(f"Kernel build resource not found: {path}") - builder_tag = _build_kernel_builder_image(builder_dockerfile, resources_dir) with tempfile.TemporaryDirectory(prefix="sbx-docker-kernel-") as tmp: work_dir = Path(tmp) - for name in SMOLVM_KERNEL_FILES: - _download(f"{SMOLVM_RAW_BASE}/kernel/microvm/{name}", work_dir / name) + for name, source in kernel_inputs.KERNEL_INPUTS.items(): + kernel_inputs.download_verified(source, work_dir / name) config = work_dir / "config.fragment" config.write_text( config.read_text(encoding="utf-8").rstrip() @@ -161,20 +236,19 @@ def _build_docker_kernel(*, image_dir: Path, arch: str, resources_dir: Path | No + docker_fragment.read_text(encoding="utf-8").lstrip(), encoding="utf-8", ) - (work_dir / "build.sh").chmod(0o755) + builder_tag = _build_kernel_builder_image(builder_dockerfile, resources_dir) out_dir = work_dir / "out" - check_config = work_dir / "check-config.sh" - _download( - "https://raw.githubusercontent.com/moby/moby/master/contrib/check-config.sh", - check_config, - ) try: - _docker_run_kernel_builder( - builder_tag, - work_dir, - ["env", "OUT_DIR=/work/out", "bash", "/work/build.sh"], - check=True, - ) + try: + _docker_run_kernel_builder( + builder_tag, + work_dir, + ["env", "OUT_DIR=/work/out", "bash", "/work/build.sh"], + check=True, + ) + except subprocess.CalledProcessError: + _raise_if_linux_digest_mismatch(work_dir) + raise _docker_run_kernel_builder( builder_tag, work_dir, @@ -194,8 +268,13 @@ def _build_docker_kernel(*, image_dir: Path, arch: str, resources_dir: Path | No ["chown", "-R", f"{os.getuid()}:{os.getgid()}", "/work"], check=False, ) - kernel_path = image_dir / DOCKER_KERNEL_NAME - shutil.copy2(out_dir / f"vmlinux-{arch}.image", kernel_path) + kernel_path = image_dir / KERNEL_NAME + temporary_kernel = image_dir / f".{KERNEL_NAME}.tmp" + try: + shutil.copy2(out_dir / f"vmlinux-{arch}.image", temporary_kernel) + temporary_kernel.replace(kernel_path) + finally: + temporary_kernel.unlink(missing_ok=True) return kernel_path @@ -242,8 +321,8 @@ def _build_containerfile_base_image( def add_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--name", - default="debian-sbx", - help="Image cache name under ~/.smolvm/images/ (default: debian-sbx).", + default="sbx", + help="Image cache name under ~/.smolvm/images/ (default: sbx).", ) parser.add_argument( "--base-image", @@ -274,34 +353,18 @@ def add_arguments(parser: argparse.ArgumentParser) -> None: default=None, help="Agent/tooling Containerfile (default: packaged Pi agent).", ) - parser.add_argument( - "--with-docker", - action="store_true", - help="Include Docker packages and build a Docker-capable local kernel.", - ) parser.add_argument( "--rootfs-size-mb", type=int, default=20480, help="Size of the ext4 root filesystem in MiB (default: 20480).", ) - parser.add_argument( - "--ssh-public-key", - type=Path, - default=_default_public_key(), - help="SSH public key to authorize for root login (default: first key in ~/.ssh).", - ) parser.add_argument( "--cache-dir", type=Path, default=None, help="Image cache directory (default: ~/.smolvm/images).", ) - parser.add_argument( - "--kernel-url", - default=None, - help="Optional SmolVM-compatible kernel URL override.", - ) parser.add_argument( "--json", action="store_true", @@ -310,28 +373,9 @@ def add_arguments(parser: argparse.ArgumentParser) -> None: def main_from_args(args: argparse.Namespace) -> int: - if args.with_docker and args.containerfile is not None: - print( - "build-debian-image: --with-docker cannot be combined with --containerfile; " - "add Docker to the custom Containerfile instead", - file=sys.stderr, - ) - return 2 - - if args.ssh_public_key is None: - print( - "build-debian-image: no SSH public key found; pass --ssh-public-key PATH", - file=sys.stderr, - ) - return 2 - - BuilderClass = SbxDockerImageBuilder if args.with_docker else ImageBuilder - builder = BuilderClass(cache_dir=args.cache_dir.expanduser() if args.cache_dir else None) + builder = SbxImageBuilder(cache_dir=args.cache_dir.expanduser() if args.cache_dir else None) base_image = args.base_image arch = "amd64" if builder._host_arch_key() == "x86_64" else "arm64" - kernel_url = args.kernel_url - if kernel_url is None: - kernel_url = BASE_KERNELS[arch].image_url selected_base_containerfile: Path | None = None selected_agent_containerfile: Path | None = None @@ -350,44 +394,40 @@ def main_from_args(args: argparse.Namespace) -> int: selected_agent_containerfile = args.agent_containerfile or ( resources_dir / DEFAULT_AGENT_CONTAINERFILE ) - selected_docker_containerfile = ( - resources_dir / DEFAULT_DOCKER_CONTAINERFILE if args.with_docker else None - ) + selected_docker_containerfile = resources_dir / DEFAULT_DOCKER_CONTAINERFILE _compose_containerfiles( selected_base_containerfile, + selected_docker_containerfile, selected_agent_containerfile, combined_containerfile, - selected_docker_containerfile, ) base_image = _build_containerfile_base_image( args.base_image, combined_containerfile, context_dir=resources_dir ) - kernel_path, rootfs_path = builder.build_debian_ssh_key( - ssh_public_key=args.ssh_public_key.expanduser(), + rootfs_path = _build_rootfs( + builder=builder, name=args.name, rootfs_size_mb=args.rootfs_size_mb, base_image=base_image, - kernel_url=kernel_url, + arch=arch, + ) + kernel_path = _build_docker_kernel( + image_dir=rootfs_path.parent, arch=arch, resources_dir=resources_dir ) - if args.with_docker: - kernel_path = _build_docker_kernel( - image_dir=rootfs_path.parent, arch=arch, resources_dir=resources_dir - ) except Exception as exc: # noqa: BLE001 - keep the CLI error friendly. - print(f"build-debian-image: failed to build image: {exc}", file=sys.stderr) + print(f"sbx image build: failed to build image: {exc}", file=sys.stderr) return 1 - boot_args = "console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rw init=/init" manifest_path = rootfs_path.parent / "smolvm-image.json" manifest = { "name": args.name, "kernel": kernel_path.name, "rootfs": rootfs_path.name, - "boot_args": boot_args, + "boot_args": BOOT_ARGS, "sbx": { "agent": "pi", - "features": ["docker"] if args.with_docker else [], + "features": [] if args.containerfile is not None else ["docker"], "launch_command": "pi", }, } @@ -403,16 +443,11 @@ def main_from_args(args: argparse.Namespace) -> int: "containerfile": str(args.containerfile.expanduser()) if uses_custom else None, "base_containerfile": None if uses_custom else str(selected_base_containerfile), "agent_containerfile": None if uses_custom else str(selected_agent_containerfile), - "with_docker": args.with_docker, - "docker_containerfile": str(selected_docker_containerfile) if args.with_docker else None, - "kernel_url": kernel_url, + "docker_containerfile": None if uses_custom else str(selected_docker_containerfile), "kernel_path": str(kernel_path), - "kernel_source": ( - "Docker-capable local kernel" if args.with_docker else "SmolVM published QEMU kernel" - ), "rootfs_path": str(rootfs_path), "manifest_path": str(manifest_path), - "boot_args": boot_args, + "boot_args": BOOT_ARGS, "rootfs_size_mb": args.rootfs_size_mb, } @@ -420,10 +455,10 @@ def main_from_args(args: argparse.Namespace) -> int: print(json.dumps(payload, indent=2, sort_keys=True)) return 0 - print("Built Debian SmolVM image:") + print("Built curated sbx image:") print(f" name: {payload['name']}") print(f" base image: {payload['base_image']}") - print(f" kernel: {payload['kernel_path']} [source: {payload['kernel_source']}]") + print(f" kernel: {payload['kernel_path']}") print(f" rootfs: {payload['rootfs_path']}") print(f" manifest: {payload['manifest_path']}") print(f" boot args: {payload['boot_args']}") @@ -432,11 +467,22 @@ def main_from_args(args: argparse.Namespace) -> int: print(" [sbx]") print(f" image = {str(manifest_path.parent)!r}") print(" run_user = 'agent'") + image_path = str(manifest_path.parent) + if manifest_path.parent.is_relative_to(Path.home()): + image_path = f"~/{manifest_path.parent.relative_to(Path.home())}" + print() + print("Next:") + print(" sbx run the-quest \\") + print(f" --image {shlex.quote(image_path)} \\") + print(" --run-user agent \\") + print(" --project-path . \\") + print(" --writable-mounts \\") + print(" --write-config") return 0 def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="Build a local Debian SSH-ready image for SmolVM.") + parser = argparse.ArgumentParser(description="Build the curated local image for sbx.") add_arguments(parser) return main_from_args(parser.parse_args(argv)) diff --git a/src/sbx/image/kernel_inputs.py b/src/sbx/image/kernel_inputs.py new file mode 100644 index 0000000..5ffe2c5 --- /dev/null +++ b/src/sbx/image/kernel_inputs.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import hashlib +import urllib.request +from collections.abc import Callable +from pathlib import Path + +SMOLVM_COMMIT = "20e1fdf72c2139622eb32ab21f288c7290bba7bf" +MOBY_COMMIT = "b780867932842071ca38968da81ec52d8b70f0bc" + +# output name: (repository, commit, upstream path, expected SHA-256) +KERNEL_INPUTS = { + "build.sh": ( + "CelestoAI/SmolVM", + SMOLVM_COMMIT, + "kernel/microvm/build.sh", + "798ee1af08740bcd0348570151ae29b2ccbae9674443ad7c564de6f281bc8f75", + ), + "config.fragment": ( + "CelestoAI/SmolVM", + SMOLVM_COMMIT, + "kernel/microvm/config.fragment", + "9df5020319525f1df6ada9ddc29eb334ed625edc1c08f347877eafe4e0d58215", + ), + "config.amd64.fragment": ( + "CelestoAI/SmolVM", + SMOLVM_COMMIT, + "kernel/microvm/config.amd64.fragment", + "46649f51667d1ede8ae436c9cb8ce5c44cfa45c814847760445faa1aa596a3d7", + ), + "config.arm64.fragment": ( + "CelestoAI/SmolVM", + SMOLVM_COMMIT, + "kernel/microvm/config.arm64.fragment", + "2f9b9db74a4581d77c8a794a826a6dc688358cf2e8d119e2dc8de8fad89a6b3e", + ), + "linux.version": ( + "CelestoAI/SmolVM", + SMOLVM_COMMIT, + "kernel/microvm/linux.version", + "f692c6ce637cb4bdf81a87c7818080995b9c888f842ae952aadc6cae632d24c1", + ), + "linux.sha256": ( + "CelestoAI/SmolVM", + SMOLVM_COMMIT, + "kernel/microvm/linux.sha256", + "30781e950c485491db11e248b785fa5e98d91f536889361b72f795d4d5c7d41f", + ), + "check-config.sh": ( + "moby/moby", + MOBY_COMMIT, + "contrib/check-config.sh", + "fda4343e9b50c47896653ca774ccbe9614bfcdb60f080d2b6277baf27efc0a71", + ), +} + + +def raw_url(repository: str, revision: str, path: str) -> str: + return f"https://raw.githubusercontent.com/{repository}/{revision}/{path}" + + +def fetch(url: str) -> bytes: + with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310 - fixed URLs. + return response.read() + + +def download_verified( + source: tuple[str, str, str, str], + destination: Path, + *, + fetcher: Callable[[str], bytes] = fetch, +) -> None: + repository, commit, path, expected = source + url = raw_url(repository, commit, path) + data = fetcher(url) + actual = hashlib.sha256(data).hexdigest() + if actual != expected: + raise RuntimeError( + f"SHA-256 mismatch for {url}\n" + f" expected: {expected}\n" + f" actual: {actual}\n" + "update the pinned commit and checksum together after reviewing the file" + ) + destination.write_bytes(data) diff --git a/tests/test_build_debian_image.py b/tests/test_build_debian_image.py index 034f32f..b787311 100644 --- a/tests/test_build_debian_image.py +++ b/tests/test_build_debian_image.py @@ -1,341 +1,356 @@ -import importlib +import hashlib import json import subprocess -import sys -import types from pathlib import Path import pytest +from sbx.image import build_debian, kernel_inputs -def _load_module() -> types.ModuleType: - return importlib.reload(importlib.import_module("sbx.image.build_debian")) +class FakeImageBuilder: + def __init__(self, cache_dir: Path | None = None) -> None: + self.cache_dir = cache_dir or Path.cwd() -@pytest.fixture -def fake_smolvm_images(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - class FakeBuilder: + def _host_arch_key(self) -> str: + return "x86_64" + + def _default_init_script(self) -> str: + return "#!/bin/sh\n# init without an agent binary\n" + + def check_docker(self) -> bool: + return True + + +def _fake_build( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> tuple[list[str], dict[str, object]]: + combined: list[str] = [] + calls: dict[str, object] = {} + + class Builder(FakeImageBuilder): def __init__(self, cache_dir: Path | None = None) -> None: - self.cache_dir = cache_dir - - def _host_arch_key(self) -> str: - return "x86_64" - - def _base_init_script(self, custom_commands: str = "") -> str: - return f"init:{custom_commands}" - - def _default_init_script(self) -> str: - return self._base_init_script() - - def build_debian_ssh_key( - self, - *, - ssh_public_key: Path, - name: str, - rootfs_size_mb: int, - base_image: str, - kernel_url: str, - ) -> tuple[Path, Path]: - del ssh_public_key, rootfs_size_mb, base_image, kernel_url - image_dir = tmp_path / name - image_dir.mkdir() - kernel = image_dir / "vmlinux.bin" - rootfs = image_dir / "rootfs.ext4" - kernel.write_text("kernel", encoding="utf-8") - rootfs.write_text("rootfs", encoding="utf-8") - (image_dir / "init-script.txt").write_text( - self._default_init_script(), encoding="utf-8" - ) - return kernel, rootfs - - smolvm_mod = types.ModuleType("smolvm") - images_mod = types.ModuleType("smolvm.images") - builder_mod = types.ModuleType("smolvm.images.builder") - published_mod = types.ModuleType("smolvm.images.published") - builder_mod.ImageBuilder = FakeBuilder - published_mod.BASE_KERNELS = {"amd64": types.SimpleNamespace(image_url="https://kernel")} - - monkeypatch.setitem(sys.modules, "smolvm", smolvm_mod) - monkeypatch.setitem(sys.modules, "smolvm.images", images_mod) - monkeypatch.setitem(sys.modules, "smolvm.images.builder", builder_mod) - monkeypatch.setitem(sys.modules, "smolvm.images.published", published_mod) - monkeypatch.setattr( - subprocess, - "run", - lambda argv, **kwargs: subprocess.CompletedProcess(argv, 0, stdout=""), + super().__init__(cache_dir or tmp_path) + + def fake_base( + base_image: str, containerfile: Path, *, context_dir: Path | None = None + ) -> str: + del base_image, context_dir + combined.append(containerfile.read_text(encoding="utf-8")) + return "sbx-debian-base:test" + + def fake_rootfs(**kwargs: object) -> Path: + calls["rootfs"] = kwargs + image_dir = tmp_path / str(kwargs["name"]) + image_dir.mkdir(parents=True, exist_ok=True) + rootfs = image_dir / "rootfs.ext4" + rootfs.write_text("rootfs", encoding="utf-8") + return rootfs + + def fake_kernel(**kwargs: object) -> Path: + calls["kernel"] = kwargs + kernel = Path(str(kwargs["image_dir"])) / "vmlinux.bin" + kernel.write_text("kernel", encoding="utf-8") + return kernel + + monkeypatch.setattr(build_debian, "SbxImageBuilder", Builder) + monkeypatch.setattr(build_debian, "_build_containerfile_base_image", fake_base) + monkeypatch.setattr(build_debian, "_build_rootfs", fake_rootfs) + monkeypatch.setattr(build_debian, "_build_docker_kernel", fake_kernel) + return combined, calls + + +def test_kernel_inputs_use_full_commits_and_sha256() -> None: + assert len(kernel_inputs.KERNEL_INPUTS) == 7 + for repository, commit, path, digest in kernel_inputs.KERNEL_INPUTS.values(): + assert len(commit) == 40 + assert len(digest) == 64 + url = kernel_inputs.raw_url(repository, commit, path) + assert commit in url + assert "/main/" not in url + assert "/master/" not in url + + +def test_download_verified_writes_only_matching_content(tmp_path: Path) -> None: + content = b"reviewed input" + source = ("example/repo", "a" * 40, "build.sh", hashlib.sha256(content).hexdigest()) + destination = tmp_path / "build.sh" + + kernel_inputs.download_verified(source, destination, fetcher=lambda url: content) + + assert destination.read_bytes() == content + + +def test_download_verified_rejects_mismatch_before_writing(tmp_path: Path) -> None: + source = ("example/repo", "a" * 40, "build.sh", "0" * 64) + destination = tmp_path / "build.sh" + + with pytest.raises(RuntimeError) as error: + kernel_inputs.download_verified(source, destination, fetcher=lambda url: b"changed") + + message = str(error.value) + assert "https://raw.githubusercontent.com/example/repo/" in message + assert "expected: " + "0" * 64 in message + assert f"actual: {hashlib.sha256(b'changed').hexdigest()}" in message + assert not destination.exists() + + +def test_linux_digest_mismatch_is_actionable(tmp_path: Path) -> None: + expected = hashlib.sha256(b"expected").hexdigest() + (tmp_path / "linux.version").write_text("6.12.85\n", encoding="utf-8") + (tmp_path / "linux.sha256").write_text( + f"{expected} linux-6.12.85.tar.xz\n", encoding="utf-8" ) + (tmp_path / "linux-6.12.85.tar.xz").write_bytes(b"changed") + with pytest.raises(RuntimeError) as error: + build_debian._raise_if_linux_digest_mismatch(tmp_path) -def test_packaged_pi_containerfile_uses_npm_global_install() -> None: - module = _load_module() + message = str(error.value) + assert "linux-6.12.85.tar.xz" in message + assert f"expected: {expected}" in message + assert f"actual: {hashlib.sha256(b'changed').hexdigest()}" in message - with module._packaged_resources() as resources: - content = (resources / module.DEFAULT_AGENT_CONTAINERFILE).read_text(encoding="utf-8") + +def test_packaged_pi_containerfile_uses_npm_global_install() -> None: + with build_debian._packaged_resources() as resources: + content = (resources / build_debian.DEFAULT_AGENT_CONTAINERFILE).read_text( + encoding="utf-8" + ) assert 'ENV PATH="/home/agent/.local/bin:/home/agent/.nodejs/bin:${PATH}"' in content assert "npm install --global --ignore-scripts @earendil-works/pi-coding-agent" in content - assert 'export PATH="$HOME/.local/bin:$HOME/.nodejs/bin:$PATH"' in content -def test_build_debian_image_prints_summary( - fake_smolvm_images: None, - tmp_path: Path, - capsys: pytest.CaptureFixture[str], -) -> None: - module = _load_module() - key = tmp_path / "id_ed25519.pub" - key.write_text("ssh-ed25519 fake", encoding="utf-8") +def test_compose_containerfiles_includes_docker_between_base_and_agent(tmp_path: Path) -> None: + base = tmp_path / "Base.Containerfile" + docker = tmp_path / "Docker.Containerfile" + agent = tmp_path / "Pi.Containerfile" + output = tmp_path / "Combined.Containerfile" + base.write_text("FROM debian AS sbx-base\nRUN echo base\n", encoding="utf-8") + docker.write_text("RUN echo docker\n", encoding="utf-8") + agent.write_text("FROM sbx-base AS sbx-final\nRUN echo pi\n", encoding="utf-8") - rc = module.main(["--ssh-public-key", str(key), "--name", "image"]) + build_debian._compose_containerfiles(base, docker, agent, output) - assert rc == 0 - out = capsys.readouterr().out - assert "Built Debian SmolVM image:" in out - assert "sbx config:" in out - assert "disk_size" not in out - assert "run_user = 'agent'" in out - manifest = json.loads((tmp_path / "image" / "smolvm-image.json").read_text()) - assert manifest["sbx"]["features"] == [] + combined = output.read_text(encoding="utf-8") + assert combined.index("RUN echo base") < combined.index("RUN echo docker") + assert combined.index("RUN echo docker") < combined.index("RUN echo pi") -def test_build_debian_image_missing_ssh_key_returns_2( +def test_default_build_always_uses_docker_kernel_and_feature( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str], ) -> None: - monkeypatch.setenv("HOME", str(tmp_path)) - module = _load_module() + combined, calls = _fake_build(monkeypatch, tmp_path) - rc = module.main([]) + rc = build_debian.main([]) - assert rc == 2 - assert "no SSH public key found" in capsys.readouterr().err + assert rc == 0 + assert "# ---- Docker layer ----" in combined[0] + rootfs_call = calls["rootfs"] + assert isinstance(rootfs_call, dict) + assert rootfs_call["name"] == "sbx" + assert rootfs_call["rootfs_size_mb"] == 20480 + assert rootfs_call["base_image"] == "sbx-debian-base:test" + assert rootfs_call["arch"] == "amd64" + manifest = json.loads((tmp_path / "sbx" / "smolvm-image.json").read_text()) + assert manifest["kernel"] == "vmlinux.bin" + assert manifest["sbx"]["features"] == ["docker"] + output = capsys.readouterr().out + assert "kernel:" in output + assert "sbx run the-quest" in output + assert f"--image {tmp_path / 'sbx'}" in output + assert "--write-config" in output -def test_build_debian_image_rejects_with_docker_and_custom_containerfile( - tmp_path: Path, - capsys: pytest.CaptureFixture[str], +def test_custom_containerfile_is_conservative_about_docker_feature( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - module = _load_module() - key = tmp_path / "id_ed25519.pub" - containerfile = tmp_path / "Containerfile" - key.write_text("ssh-ed25519 fake", encoding="utf-8") - containerfile.write_text("FROM debian\n", encoding="utf-8") - - rc = module.main( - [ - "--ssh-public-key", - str(key), - "--containerfile", - str(containerfile), - "--with-docker", - ] - ) + combined, _ = _fake_build(monkeypatch, tmp_path) + custom = tmp_path / "Containerfile" + custom.write_text("FROM debian:stable-slim\n", encoding="utf-8") + + rc = build_debian.main(["--containerfile", str(custom), "--name", "custom"]) - assert rc == 2 - assert "cannot be combined" in capsys.readouterr().err + assert rc == 0 + assert combined == ["FROM debian:stable-slim\n"] + manifest = json.loads((tmp_path / "custom" / "smolvm-image.json").read_text()) + assert manifest["kernel"] == "vmlinux.bin" + assert manifest["sbx"]["features"] == [] -def test_build_debian_image_json_output( - fake_smolvm_images: None, +def test_removed_build_flags_are_rejected() -> None: + for option in ("--with-docker", "--kernel-url", "--ssh-public-key"): + with pytest.raises(SystemExit) as error: + build_debian.main([option, "value"]) + assert error.value.code == 2 + + +def test_build_debian_image_json_has_no_obsolete_kernel_fields( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str], ) -> None: - module = _load_module() - key = tmp_path / "id_ed25519.pub" - key.write_text("ssh-ed25519 fake", encoding="utf-8") + _fake_build(monkeypatch, tmp_path) - rc = module.main(["--ssh-public-key", str(key), "--name", "image", "--json"]) + rc = build_debian.main(["--name", "image", "--json"]) assert rc == 0 payload = json.loads(capsys.readouterr().out) - assert payload["name"] == "image" - assert payload["rootfs_size_mb"] == 20480 - assert payload["rootfs_path"].endswith("rootfs.ext4") - assert payload["manifest_path"].endswith("smolvm-image.json") + assert payload["kernel_path"].endswith("vmlinux.bin") + assert "with_docker" not in payload + assert "kernel_url" not in payload + assert "kernel_source" not in payload -def test_build_debian_builder_failure_returns_1( +def test_build_failure_returns_1( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str], ) -> None: - class FailingBuilder: - def __init__(self, cache_dir: Path | None = None) -> None: - del cache_dir - - def _host_arch_key(self) -> str: - return "x86_64" - - def build_debian_ssh_key(self, **kwargs: object) -> tuple[Path, Path]: - del kwargs - raise RuntimeError("boom") - - smolvm_mod = types.ModuleType("smolvm") - images_mod = types.ModuleType("smolvm.images") - builder_mod = types.ModuleType("smolvm.images.builder") - published_mod = types.ModuleType("smolvm.images.published") - builder_mod.ImageBuilder = FailingBuilder - published_mod.BASE_KERNELS = {"amd64": types.SimpleNamespace(image_url="https://kernel")} - monkeypatch.setitem(sys.modules, "smolvm", smolvm_mod) - monkeypatch.setitem(sys.modules, "smolvm.images", images_mod) - monkeypatch.setitem(sys.modules, "smolvm.images.builder", builder_mod) - monkeypatch.setitem(sys.modules, "smolvm.images.published", published_mod) + _fake_build(monkeypatch, tmp_path) monkeypatch.setattr( - subprocess, - "run", - lambda argv, **kwargs: subprocess.CompletedProcess(argv, 0, stdout=""), + build_debian, "_build_rootfs", lambda **kwargs: (_ for _ in ()).throw(RuntimeError("boom")) ) - module = _load_module() - key = tmp_path / "id_ed25519.pub" - key.write_text("ssh-ed25519 fake", encoding="utf-8") - - rc = module.main(["--ssh-public-key", str(key)]) - - assert rc == 1 + assert build_debian.main([]) == 1 assert "failed to build image: boom" in capsys.readouterr().err -def test_compose_containerfiles_combines_base_and_agent(tmp_path: Path) -> None: - module = _load_module() - base = tmp_path / "Base.Containerfile" - agent = tmp_path / "Pi.Containerfile" - output = tmp_path / "Combined.Containerfile" - base.write_text("FROM debian AS sbx-base\nRUN echo base\n", encoding="utf-8") - agent.write_text("FROM sbx-base AS sbx-final\nRUN echo pi\n", encoding="utf-8") - - module._compose_containerfiles(base, agent, output) - - combined = output.read_text(encoding="utf-8") - assert "FROM debian AS sbx-base" in combined - assert "# ---- Agent/tooling layer ----" in combined - assert "FROM sbx-base AS sbx-final" in combined +def test_build_rootfs_omits_guest_agent_and_reuses_fingerprint( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + calls: list[dict[str, object]] = [] + class RootfsBuilder: + def __init__(self, **kwargs: object) -> None: + calls.append(kwargs) -def test_build_debian_image_with_docker_inserts_fragment_and_uses_docker_kernel( - fake_smolvm_images: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - module = _load_module() - key = tmp_path / "id_ed25519.pub" - base = tmp_path / "Base.Containerfile" - docker = tmp_path / "Docker.Containerfile" - agent = tmp_path / "Pi.Containerfile" - key.write_text("ssh-ed25519 fake", encoding="utf-8") - base.write_text("FROM debian AS sbx-base\nRUN echo base\n", encoding="utf-8") - docker.write_text("USER root\nRUN echo docker\nUSER agent\n", encoding="utf-8") - agent.write_text("FROM sbx-base AS sbx-final\nRUN echo pi\n", encoding="utf-8") - combined = "" - docker_kernel_args: dict[str, object] = {} + def _build_rootfs(self, **kwargs: object) -> None: + calls.append(kwargs) + Path(str(kwargs["rootfs_path"])).write_text("rootfs", encoding="utf-8") - def fake_build_base_image( - base_image: str, containerfile: Path, *, context_dir: Path | None = None - ) -> str: - nonlocal combined - del base_image, context_dir - combined = containerfile.read_text(encoding="utf-8") - return "sbx-debian-base:docker" - - def fake_build_docker_kernel( - *, image_dir: Path, arch: str, resources_dir: Path | None = None - ) -> Path: - docker_kernel_args.update( - {"image_dir": image_dir, "arch": arch, "resources_dir": resources_dir} - ) - kernel = image_dir / "vmlinux-docker.bin" - kernel.write_text("docker kernel", encoding="utf-8") - return kernel + builder = FakeImageBuilder(tmp_path) + monkeypatch.setattr(build_debian, "DockerRootfsBuilder", RootfsBuilder) - monkeypatch.setattr(module, "DEFAULT_DOCKER_CONTAINERFILE", docker) - monkeypatch.setattr(module, "_build_containerfile_base_image", fake_build_base_image) - monkeypatch.setattr(module, "_build_docker_kernel", fake_build_docker_kernel) - - rc = module.main( - [ - "--ssh-public-key", - str(key), - "--base-containerfile", - str(base), - "--agent-containerfile", - str(agent), - "--with-docker", - "--name", - "docker-image", - ] + first = build_debian._build_rootfs( + builder=builder, + name="image", + rootfs_size_mb=20, + base_image="base:test", + arch="amd64", + ) + second = build_debian._build_rootfs( + builder=builder, + name="image", + rootfs_size_mb=20, + base_image="base:test", + arch="amd64", ) - assert rc == 0 - assert combined.index("RUN echo base") < combined.index("RUN echo docker") - assert combined.index("RUN echo docker") < combined.index("RUN echo pi") - assert "# ---- Docker layer ----" in combined - assert docker_kernel_args["arch"] == "amd64" - manifest = json.loads((tmp_path / "docker-image" / "smolvm-image.json").read_text()) - assert manifest["kernel"] == "vmlinux-docker.bin" - assert manifest["sbx"]["features"] == ["docker"] - assert manifest["sbx"]["launch_command"] == "pi" - init_script = (tmp_path / "docker-image" / "init-script.txt").read_text(encoding="utf-8") - assert "sbx-start-rootless-docker" in init_script + assert first == second == tmp_path / "image" / "rootfs.ext4" + assert len(calls) == 2 + dockerfile = str(calls[0]["dockerfile"]) + context = calls[1]["context_files"] + assert "COPY smolvm-guest-agent" not in dockerfile + assert "smolvm-guest-agent" not in context -def test_build_docker_kernel_downloads_inputs_appends_fragment_and_copies_kernel( +def test_build_docker_kernel_uses_only_verified_inputs( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - module = _load_module() - fragment = tmp_path / "docker.config.fragment" - builder_file = tmp_path / "Containers" / "Build" / "Kernel.Containerfile" - fragment.write_text("CONFIG_VETH=y\n", encoding="utf-8") - builder_file.parent.mkdir(parents=True) - builder_file.write_text("FROM debian\n", encoding="utf-8") - downloads: list[str] = [] runs: list[list[str]] = [] + downloads: list[str] = [] + + def fake_download(source: tuple[str, str, str, str], destination: Path) -> None: + downloads.append(destination.name) + destination.write_text("# verified input\n", encoding="utf-8") - def fake_download(url: str, output: Path) -> None: - downloads.append(url) - if output.name == "config.fragment": - output.write_text("CONFIG_BASE=y\n", encoding="utf-8") - else: - output.write_text("file\n", encoding="utf-8") + monkeypatch.setattr(kernel_inputs, "download_verified", fake_download) def fake_run(command: list[str], *, check: bool) -> None: runs.append(command) if command[:3] == ["docker", "run", "--rm"] and "bash" in command: assert check is True work_dir = Path(command[command.index("-v") + 1].split(":", 1)[0]) - out_dir = work_dir / "out" - out_dir.mkdir() - (out_dir / "vmlinux-amd64.image").write_text("kernel", encoding="utf-8") - (out_dir / "vmlinux-amd64.config").write_text("config", encoding="utf-8") - config = (work_dir / "config.fragment").read_text(encoding="utf-8") - assert "CONFIG_BASE=y" in config - assert "CONFIG_VETH=y" in config - - monkeypatch.setattr(module, "DEFAULT_DOCKER_KERNEL_FRAGMENT", fragment) - monkeypatch.setattr(module, "DEFAULT_KERNEL_BUILDER_DOCKERFILE", builder_file) - monkeypatch.setattr(module, "_download", fake_download) - monkeypatch.setattr(module.subprocess, "run", fake_run) - - kernel = module._build_docker_kernel(image_dir=tmp_path, arch="amd64") - - assert kernel == tmp_path / "vmlinux-docker.bin" + assert (work_dir / "build.sh").is_file() + assert (work_dir / "check-config.sh").is_file() + assert "CONFIG_VETH=y" in (work_dir / "config.fragment").read_text() + out = work_dir / "out" + out.mkdir() + (out / "vmlinux-amd64.image").write_text("kernel", encoding="utf-8") + (out / "vmlinux-amd64.config").write_text("config", encoding="utf-8") + + monkeypatch.setattr(build_debian.subprocess, "run", fake_run) + + kernel = build_debian._build_docker_kernel(image_dir=tmp_path, arch="amd64") + + assert kernel == tmp_path / "vmlinux.bin" assert kernel.read_text(encoding="utf-8") == "kernel" - assert any(url.endswith("/kernel/microvm/build.sh") for url in downloads) - assert any(url.endswith("/contrib/check-config.sh") for url in downloads) - assert runs[0][:3] == ["docker", "build", "-f"] - assert any("OUT_DIR=/work/out" in command for command in runs) + assert set(downloads) == set(kernel_inputs.KERNEL_INPUTS) + assert any("/work/build.sh" in command for command in runs) assert any("/work/check-config.sh" in command for command in runs) assert runs[-1][-4:] == [ "chown", "-R", - f"{module.os.getuid()}:{module.os.getgid()}", + f"{build_debian.os.getuid()}:{build_debian.os.getgid()}", "/work", ] +def test_kernel_input_mismatch_stops_before_docker( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr( + kernel_inputs, + "download_verified", + lambda source, destination: (_ for _ in ()).throw(RuntimeError("digest mismatch")), + ) + monkeypatch.setattr( + build_debian.subprocess, + "run", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("Docker must not run")), + ) + + with pytest.raises(RuntimeError, match="digest mismatch"): + build_debian._build_docker_kernel(image_dir=tmp_path, arch="amd64") + + +def test_kernel_check_failure_preserves_existing_kernel( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr( + kernel_inputs, + "download_verified", + lambda source, destination: destination.write_text("# verified input\n", encoding="utf-8"), + ) + kernel = tmp_path / "vmlinux.bin" + kernel.write_text("old kernel", encoding="utf-8") + + def fake_run(command: list[str], *, check: bool) -> None: + if command[:3] == ["docker", "run", "--rm"] and "bash" in command: + work_dir = Path(command[command.index("-v") + 1].split(":", 1)[0]) + out = work_dir / "out" + out.mkdir() + (out / "vmlinux-amd64.image").write_text("new kernel", encoding="utf-8") + (out / "vmlinux-amd64.config").write_text("config", encoding="utf-8") + if "/work/check-config.sh" in command: + raise subprocess.CalledProcessError(1, command) + assert check in {True, False} + + monkeypatch.setattr(build_debian.subprocess, "run", fake_run) + + with pytest.raises(subprocess.CalledProcessError): + build_debian._build_docker_kernel(image_dir=tmp_path, arch="amd64") + + assert kernel.read_text(encoding="utf-8") == "old kernel" + + def test_build_containerfile_base_image_runs_expected_docker_commands( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - module = _load_module() containerfile = tmp_path / "Containerfile" containerfile.write_text("FROM debian:stable-slim\nUSER agent\n", encoding="utf-8") commands: list[list[str]] = [] @@ -347,23 +362,17 @@ def fake_run(command: list[str], *, check: bool) -> None: if command[:3] == ["docker", "build", "-t"]: wrapper_text.append((Path(command[-1]) / "Dockerfile").read_text(encoding="utf-8")) - monkeypatch.setattr(module.subprocess, "run", fake_run) + monkeypatch.setattr(build_debian.subprocess, "run", fake_run) - root_tag = module._build_containerfile_base_image("debian:stable-slim", containerfile) + root_tag = build_debian._build_containerfile_base_image("debian:stable-slim", containerfile) assert root_tag.startswith("sbx-debian-base:") assert commands[0][:4] == ["docker", "build", "--build-arg", "BASE_IMAGE=debian:stable-slim"] - assert commands[0][-3:-1] == [ - "-t", - root_tag.replace("sbx-debian-base:", "sbx-debian-base-user:"), - ] assert commands[1][:4] == ["docker", "build", "-t", root_tag] user_tag = root_tag.replace("sbx-debian-base:", "sbx-debian-base-user:") assert wrapper_text == [f"FROM {user_tag}\nUSER root\n"] def test_build_containerfile_base_image_missing_file(tmp_path: Path) -> None: - module = _load_module() - with pytest.raises(FileNotFoundError, match="Containerfile not found"): - module._build_containerfile_base_image("debian:stable-slim", tmp_path / "missing") + build_debian._build_containerfile_base_image("debian:stable-slim", tmp_path / "missing") diff --git a/tests/test_check_image_build_inputs.py b/tests/test_check_image_build_inputs.py new file mode 100644 index 0000000..01864e1 --- /dev/null +++ b/tests/test_check_image_build_inputs.py @@ -0,0 +1,86 @@ +import hashlib +import importlib.util +import urllib.error +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parents[1] / ".github" / "scripts" / "check_image_build_inputs.py" + + +def _load_script(): + spec = importlib.util.spec_from_file_location("check_image_build_inputs", SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _setup(module, monkeypatch: pytest.MonkeyPatch) -> tuple[str, str, str]: + repository = "example/project" + commit = "a" * 40 + path = "build.sh" + monkeypatch.setattr( + module, + "KERNEL_INPUTS", + {"build.sh": (repository, commit, path, hashlib.sha256(b"same").hexdigest())}, + ) + monkeypatch.setattr(module, "BRANCHES", {repository: "main"}) + monkeypatch.setattr(module, "LEGAL_FILES", {repository: ("LICENSE", "NOTICE")}) + return repository, commit, path + + +def test_update_checker_reports_current(monkeypatch: pytest.MonkeyPatch, capsys) -> None: + module = _load_script() + repository, commit, path = _setup(module, monkeypatch) + + def fake_fetch(url: str) -> bytes: + if url.endswith("/NOTICE"): + raise urllib.error.HTTPError(url, 404, "missing", {}, None) + if url.endswith("/LICENSE"): + return b"license" + assert url.endswith(f"/{path}") + return b"same" + + assert module.check_updates(fake_fetch) == 0 + assert capsys.readouterr().out == "Image build inputs are current.\n" + + +def test_update_checker_reports_source_change(monkeypatch: pytest.MonkeyPatch, capsys) -> None: + module = _load_script() + repository, commit, path = _setup(module, monkeypatch) + + def fake_fetch(url: str) -> bytes: + if "/commits?" in url: + return b'[{"sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}]' + if url.endswith("/NOTICE"): + raise urllib.error.HTTPError(url, 404, "missing", {}, None) + if url.endswith("/LICENSE"): + return b"license" + if f"/{commit}/" in url: + return b"same" + return b"new" + + assert module.check_updates(fake_fetch) == 1 + output = capsys.readouterr().out + assert "build.sh: update available" in output + assert "commit: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" in output + assert f"sha256: {hashlib.sha256(b'new').hexdigest()}" in output + + +def test_update_checker_reports_license_change( + monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + module = _load_script() + repository, commit, path = _setup(module, monkeypatch) + + def fake_fetch(url: str) -> bytes: + if url.endswith("/NOTICE"): + raise urllib.error.HTTPError(url, 404, "missing", {}, None) + if url.endswith("/LICENSE"): + return b"old" if f"/{commit}/" in url else b"new" + assert url.endswith(f"/{path}") + return b"same" + + assert module.check_updates(fake_fetch) == 1 + assert f"{repository}/LICENSE: licensing review required" in capsys.readouterr().out diff --git a/tests/test_cli.py b/tests/test_cli.py index de967ac..dee8af6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -131,7 +131,7 @@ def test_doctor_warns_when_config_differs_from_existing_vm( ) -> None: config = tmp_path / ".sbx.toml" config.write_text( - '[sbx]\nname = "reviewhero"\ndisk_size = 10240\nmemory = 8192\ncpus = 4\n', + '[sbx]\nname = "the-quest"\ndisk_size = 10240\nmemory = 8192\ncpus = 4\n', encoding="utf-8", ) @@ -159,9 +159,9 @@ def test_doctor_warns_when_config_differs_from_existing_vm( assert rc == 0 out = capfd.readouterr().out assert "doctor --backend qemu" in out - assert "VM 'reviewhero' already exists and differs from .sbx.toml" in out + assert "VM 'the-quest' already exists and differs from .sbx.toml" in out assert "disk_size: config requests 10240 MiB, existing VM has 81920 MiB" in out - assert "sbx recreate reviewhero --force" in out + assert "sbx recreate the-quest --force" in out def test_doctor_warns_when_local_image_is_larger_than_requested_disk( @@ -180,7 +180,7 @@ def test_doctor_warns_when_local_image_is_larger_than_requested_disk( (image / "vmlinux.bin").write_text("kernel", encoding="utf-8") config = tmp_path / ".sbx.toml" config.write_text( - f'[sbx]\nname = "reviewhero"\nimage = "{image}"\ndisk_size = 10\n', + f'[sbx]\nname = "the-quest"\nimage = "{image}"\ndisk_size = 10\n', encoding="utf-8", ) @@ -854,6 +854,33 @@ def test_create_auto_writes_project_config_for_new_vm( assert "wrote .sbx.toml" in capfd.readouterr().err +def test_project_config_values_preserve_curated_image_workflow() -> None: + values = cli._project_config_values( + SimpleNamespace( + image="~/.smolvm/images/sbx", + memory=None, + cpus=None, + disk_size=None, + project_path=".", + run_user="agent", + writable_mounts=True, + env=None, + ), + {}, + vm_name="the-quest", + agent="pi", + ) + + assert values == { + "name": "the-quest", + "agent": "pi", + "image": "~/.smolvm/images/sbx", + "project_path": ".", + "run_user": "agent", + "writable_mounts": True, + } + + def test_write_config_updates_only_missing_values( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -1556,20 +1583,31 @@ def test_invalid_agent_in_config_returns_usage_error( assert "[sbx].agent must be one of" in capsys.readouterr().err -def test_image_build_debian_subcommand(monkeypatch: pytest.MonkeyPatch) -> None: +def test_image_build_subcommand_and_default_name( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: from sbx.image import build_debian - captured = {} + monkeypatch.chdir(tmp_path) + names = [] def fake_main_from_args(args: object) -> int: - captured["with_docker"] = args.with_docker - captured["name"] = args.name + names.append(args.name) return 0 monkeypatch.setattr(build_debian, "main_from_args", fake_main_from_args) - assert cli.main(["image", "build-debian", "--with-docker", "--name", "docker-image"]) == 0 - assert captured == {"with_docker": True, "name": "docker-image"} + assert cli.main(["image", "build"]) == 0 + assert cli.main(["image", "build", "--name", "custom-image"]) == 0 + assert names == ["sbx", "custom-image"] + assert not (tmp_path / ".sbx.toml").exists() + + +def test_image_build_debian_subcommand_is_removed() -> None: + with pytest.raises(SystemExit) as error: + cli.main(["image", "build-debian"]) + + assert error.value.code == 2 def test_image_ls_lists_local_images( @@ -1577,18 +1615,18 @@ def test_image_ls_lists_local_images( ) -> None: monkeypatch.setenv("HOME", str(tmp_path)) images = tmp_path / ".smolvm" / "images" - docker_image = images / "debian-sbx-docker" - plain_image = images / "debian-sbx" + docker_image = images / "legacy-docker" + plain_image = images / "legacy-plain" invalid_image = images / "invalid" docker_image.mkdir(parents=True) plain_image.mkdir() invalid_image.mkdir() (docker_image / "smolvm-image.json").write_text( - '{"name":"debian-sbx-docker","kernel":"vmlinux-docker.bin","rootfs":"rootfs.ext4","sbx":{"agent":"pi","features":["docker"]}}', + '{"name":"legacy-docker","kernel":"vmlinux-docker.bin","rootfs":"rootfs.ext4","sbx":{"agent":"pi","features":["docker"]}}', encoding="utf-8", ) (plain_image / "smolvm-image.json").write_text( - '{"name":"debian-sbx","kernel":"vmlinux.bin","rootfs":"rootfs.ext4","sbx":{"agent":"pi","features":[]}}', + '{"name":"legacy-plain","kernel":"vmlinux.bin","rootfs":"rootfs.ext4","sbx":{"agent":"pi","features":[]}}', encoding="utf-8", ) (invalid_image / "smolvm-image.json").write_text("not json", encoding="utf-8") @@ -1598,9 +1636,9 @@ def test_image_ls_lists_local_images( out = capsys.readouterr().out assert "NAME" in out assert "FEATURES" in out - assert "debian-sbx-docker" in out + assert "legacy-docker" in out assert "docker" in out - assert "debian-sbx" in out + assert "legacy-plain" in out assert "invalid" not in out @@ -1608,7 +1646,7 @@ def test_image_ls_json( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.setenv("HOME", str(tmp_path)) - image = tmp_path / ".smolvm" / "images" / "debian-sbx-docker" + image = tmp_path / ".smolvm" / "images" / "legacy-docker" image.mkdir(parents=True) (image / "smolvm-image.json").write_text( '{"kernel":"vmlinux-docker.bin","rootfs":"rootfs.ext4","sbx":{"agent":"pi","features":["docker"]}}', @@ -1623,7 +1661,7 @@ def test_image_ls_json( "agent": "pi", "features": ["docker"], "kernel": "vmlinux-docker.bin", - "name": "debian-sbx-docker", + "name": "legacy-docker", "path": str(image), "rootfs": "rootfs.ext4", } diff --git a/tests/test_cli_extra.py b/tests/test_cli_extra.py index 114e927..3026a83 100644 --- a/tests/test_cli_extra.py +++ b/tests/test_cli_extra.py @@ -417,6 +417,7 @@ def test_start_local_image_happy_path( assert isinstance(vm_config, dict) assert vm_config["vm_id"] == "vm-from-cli" assert vm_config["boot_args"] == "boot args" + assert vm_config["comm_channel"] == "ssh" assert vm_config["ssh_public_key"] == "ssh-ed25519 public" assert vm_config["port_forwards"] == [ {"host_address": "127.0.0.1", "host_port": 8080, "guest_port": 3000} @@ -490,8 +491,8 @@ def from_id(cls, vm_id: str) -> object: monkeypatch.setattr(smolvm.facade, "SmolVM", MissingSmolVM) - with pytest.raises(cli.ConfigError, match="VM 'reviewhero' not found"): - guest_setup.ssh_command("reviewhero") + with pytest.raises(cli.ConfigError, match="VM 'the-quest' not found"): + guest_setup.ssh_command("the-quest") def test_shell_prechecks_missing_managed_vm_before_registering_session( @@ -500,7 +501,7 @@ def test_shell_prechecks_missing_managed_vm_before_registering_session( capsys: pytest.CaptureFixture[str], ) -> None: config = tmp_path / "config.toml" - config.write_text('[sbx]\nname = "reviewhero"\nrun_user = "agent"\n', encoding="utf-8") + config.write_text('[sbx]\nname = "the-quest"\nrun_user = "agent"\n', encoding="utf-8") monkeypatch.setattr(cli, "_get_existing_vm_status", lambda vm_id: None) def fail_register(vm_id: str, kind: str) -> None: @@ -512,7 +513,7 @@ def fail_register(vm_id: str, kind: str) -> None: captured = capsys.readouterr() assert rc == 1 - assert "VM 'reviewhero' not found" in captured.err + assert "VM 'the-quest' not found" in captured.err assert "Traceback" not in captured.err @@ -524,7 +525,7 @@ def test_shell_reports_missing_vm_from_smolvm_without_traceback( from smolvm.exceptions import VMNotFoundError config = tmp_path / "config.toml" - config.write_text('[sbx]\nname = "reviewhero"\nrun_user = "agent"\n', encoding="utf-8") + config.write_text('[sbx]\nname = "the-quest"\nrun_user = "agent"\n', encoding="utf-8") monkeypatch.setattr(cli, "_get_existing_vm_status", lambda vm_id: "running") monkeypatch.setattr(guest_setup, "host_git_config", lambda project_root=None: None) monkeypatch.setattr(cli, "_stop_vm_if_last_session", lambda vm_id, *, stop_on_exit: None) @@ -540,7 +541,7 @@ def from_id(cls, vm_id: str) -> object: captured = capsys.readouterr() assert rc == 2 - assert "sbx: VM 'reviewhero' not found" in captured.err + assert "sbx: VM 'the-quest' not found" in captured.err assert "Traceback" not in captured.err diff --git a/tests/test_completion.py b/tests/test_completion.py index f5cb5b0..7f23600 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -32,7 +32,7 @@ def test_completion_scripts_include_all_command_option_groups() -> None: "auth-port", "close-auth-port", "status", - "build-debian", + "build", "force", "host-port", "guest-port", @@ -40,6 +40,7 @@ def test_completion_scripts_include_all_command_option_groups() -> None: "rootfs-size-mb", ): assert value in script + assert "build-debian" not in script def test_bash_completion_completes_redirection_targets(tmp_path) -> None: