diff --git a/README.md b/README.md index a0f3743..21bd6f1 100644 --- a/README.md +++ b/README.md @@ -1,414 +1,227 @@ # sbx -`sbx` runs coding agents inside disposable [SmolVM](https://github.com/CelestoAI/smolVM) virtual machines. +`sbx` runs coding agents in disposable [SmolVM](https://github.com/CelestoAI/smolVM) virtual machines. Your project is mounted into the VM, while the agent, tools, and optional rootless Docker daemon run inside it. -Agents and authentication run in the VM, not on the host. By default `sbx` does **not** copy host credentials into the guest. Use `--copy-host-credentials` only when you explicitly want SmolVM presets to forward local agent configs/API keys. +Host credentials are not copied by default. `sbx` forwards only selected environment variables and a safe subset of Git settings; it never forwards Git credentials or keys. ## Install ```bash uv tool install git+https://github.com/nueces/sbx.git@v0.2.4 -sbx doctor ``` -For local development, install from a checkout instead: +Alternatively, install an editable clone: ```bash git clone https://github.com/nueces/sbx.git uv tool install --editable ./sbx ``` -## Usage +After either installation, check that the host, QEMU backend, and project configuration are ready before creating a sandbox: ```bash -# Run an agent session. Creates the sandbox if missing, starts it if stopped, -# exposes the OAuth callback port, then attaches to Pi. -sbx run my-sbx - -# Create/provision only; do not attach and do not open auth forwarding. -sbx create my-sbx - -# Open a shell in the sandbox. NAME defaults to [sbx].name when configured. -# If [sbx].run_user is set, the shell opens as that user; use --root to override. -# If [sbx].project_path is set, the shell starts in that mounted directory. -sbx shell my-sbx -sbx shell -sbx shell --root - -# Lifecycle helpers. -sbx ls -sbx stop my-sbx -sbx rm my-sbx --force -sbx recreate my-sbx --force +sbx doctor ``` -### Shell completion +`sbx doctor` reports missing host requirements and configuration or VM-state problems without starting a VM. -Generate static shell completion scripts with: +## Recommended workflow -```bash -# bash -sbx completion bash +### 1. Build the curated image -# zsh -sbx completion zsh +The curated image contains Pi, common development tools, and rootless Docker. Building it requires a working host Docker installation: -# fish -sbx completion fish +```bash +sbx image build ``` -For one-off use, run `eval "$(sbx completion bash)"` or `eval "$(sbx completion zsh)"`; in fish, run `sbx completion fish | source`. +The image is written to `~/.smolvm/images/sbx`. The first build compiles a QEMU kernel and can take a while. Later builds reuse Docker layers. -Common options: +Check the result with: ```bash -# Mount a project at the same absolute path in the guest as read-write, -# place it first in the mount list, and start the attached agent there. -sbx run my-sbx --project-path . - -# Mount extra host directories at their same absolute guest paths. -sbx run my-sbx --mount /home/me/src/tooling --mount /home/me/src/data - -# Or choose an explicit guest path. -sbx run my-sbx --mount /home/me/src/tooling:/workspace/tooling - -# Disable automatic OAuth callback forwarding. -sbx run my-sbx --no-auth-port +sbx image list ``` -Port forwarding lets your host connect to a TCP service running inside the sandbox, such as a dev server or web app. `sbx network forward` runs in the foreground; Ctrl-C stops the forwarding. +See the [image guide](docs/build-local-debian-pi-image.md) for custom sizes, Docker details, and troubleshooting. + +### 2. Create the project sandbox -A forward `SPEC` maps a host address/port to a guest port: +Run this from the project you want the agent to work on: -```text -GUEST_PORT host 127.0.0.1:GUEST_PORT -> guest 127.0.0.1:GUEST_PORT -HOST_PORT:GUEST_PORT host 127.0.0.1:HOST_PORT -> guest 127.0.0.1:GUEST_PORT -BIND_HOST:HOST_PORT:GUEST_PORT - host BIND_HOST:HOST_PORT -> guest 127.0.0.1:GUEST_PORT +```bash +cd ~/code/my-project +sbx run the-quest \ + --image '~/.smolvm/images/sbx' \ + --project-path . \ + --writable-mounts ``` -Examples: +On first creation, `sbx`: -```text -3000 host 127.0.0.1:3000 -> guest 127.0.0.1:3000 -8080:80 host 127.0.0.1:8080 -> guest 127.0.0.1:80 -0.0.0.0:8080:80 host 0.0.0.0:8080 -> guest 127.0.0.1:80 -``` +1. creates and starts the VM; +2. mounts the project at the same absolute path inside the VM; +3. writes the project settings to `./.sbx.toml`; and +4. launches Pi as the `agent` user in the mounted project. -You can pass multiple specs in one command; one Ctrl-C stops all of them: +### 3. Use the sandbox day to day -```bash -sbx network forward my-sbx 3000 -sbx network forward 8080:3000 -sbx network forward 0.0.0.0:3000:3000 -sbx network forward my-sbx 3000 8080:80 -``` +The sandbox name is stored in `.sbx.toml`, so commands run from the project directory do not need it again: ```bash -# Keep the VM running after the agent/shell exits. -sbx run my-sbx --keep-running -sbx shell my-sbx --keep-running - -# Control project config bootstrap. -sbx run my-sbx --write-config -sbx create my-sbx --no-write-config +sbx run # start if needed and launch the agent +sbx shell # open a shell in the mounted project +sbx stop # stop the VM without deleting its disk +sbx ls # list all sandboxes, including stopped ones +sbx rm # remove the VM after confirmation +``` -# Run the attached agent process as a non-root guest user. -sbx run my-sbx --run-user agent +## Customize the sandbox -# Explicitly allow copying host agent credentials/configs into the guest. -sbx run my-sbx --copy-host-credentials +### Add another folder -# Explicitly forward a selected host environment variable into the guest. -sbx run my-sbx --env OPENAI_API_KEY +Mounts are applied when a VM starts; they cannot be hot-added to an already-running VM. Add the folder to `.sbx.toml` using an absolute host path: -# Choose another agent preset. -sbx run my-sbx --agent codex -sbx run my-sbx --agent claude +```toml +[sbx] +# Existing project settings remain here. +mount = [ + "/home/me/code/shared-tools", + "/home/me/data:/workspace/data", +] +writable_mounts = true ``` -## Command model +A bare path appears at the same absolute path in the VM. `HOST:GUEST` chooses a different guest path. -`sbx` uses user-intent commands rather than mirroring SmolVM VM lifecycle names: +If the VM is running, restart it through `sbx` to apply the mounts: -| Command | Meaning | -| -------------------------------- | ---------------------------------------------------------------------------------------- | -| `run [NAME]` | Main workflow: create if missing, start if stopped, attach/run the agent. | -| `create [NAME]` | Create/provision a sandbox without attaching. | -| `recreate [NAME]` | Destructively remove and create a fresh sandbox. Confirmation required unless `--force`. | -| `rm [NAME]` | Remove a sandbox. Confirmation required unless `--force`. | -| `stop [NAME]` | Stop a sandbox without removing it. | -| `shell [NAME]` | Open a shell in a sandbox. | -| `ls` | List running sandboxes. Use `ls -a` / `ls --all` to include stopped ones. | -| `network status [NAME]` | Expert helper: show sandbox networking and auth callback tunnel status. | -| `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` | 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`. | +```bash +sbx stop +sbx run +``` -When `NAME` is omitted, commands that operate on one sandbox use `[sbx].name` from configuration. +This stop/start cycle does not recreate the sandbox or delete its disk, so applying a new mount does not lose the agent session. Use the agent's normal resume/continue flow after restarting. -We intentionally keep `recreate` separate from a possible future `restart`/`reboot`: `recreate` means delete and create a fresh VM, while a soft restart would reuse the same VM disk/state. +If you try to run with different mounts while the VM is already running, `sbx` keeps the current mounts and prints the same stop-and-run guidance. -## Images and features +### Install more tools -List local ready-to-run images: +The curated image includes Pi. For one project, install other tools directly on the sandbox disk instead of rebuilding the image: ```bash -sbx image ls +sbx shell + +# Run these inside the VM as the configured agent user: +npm install -g opencode-ai +npm install -g @anthropic-ai/claude-code +npm install -g @openai/codex +exit ``` -Build the curated image and create project configuration while starting it: +Claude Code and OpenCode require their npm postinstall scripts to prepare native binaries; do not install either with `--ignore-scripts`. -```bash -sbx image build -sbx run the-quest \ - --image '~/.smolvm/images/sbx' \ - --run-user agent \ - --project-path . \ - --writable-mounts \ - --write-config -``` +Installed software survives `sbx stop` and remains available on later runs: -The suggested project configuration is: +```bash +sbx run --agent claude +sbx run --agent codex -```toml -[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 +# OpenCode is not an sbx agent preset; launch it inside a sandbox shell. +sbx shell +opencode ``` -`--write-config` belongs to `run` and `create`; `image build` never changes `.sbx.toml`. +`sbx recreate` and `sbx rm` delete the sandbox disk. Add frequently used tools to the curated image if every new or recreated sandbox should contain them. -Configure durable TCP forwards applied when the VM starts: +## Authentication and networking -```toml -[sbx] -port_forwards = ["3000", "8080:3000"] -``` +Run `/login` inside Pi when authentication is needed. `sbx run` automatically forwards the browser callback port to the VM. -The default image includes rootless Docker. For a larger Docker data/build cache, increase its rootfs: +Temporarily expose a VM service to the host until Ctrl-C: ```bash -sbx image build --rootfs-size-mb 81920 +sbx network forward 3000 # host 3000 -> guest 3000 +sbx network forward 8080:3000 # host 8080 -> guest 3000 ``` -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). +These commands use the sandbox named in `.sbx.toml`. Use `--name OTHER_VM` only when forwarding from a different sandbox. -## Configuration +## Other operations -`sbx` reads TOML configuration from these locations, merging later files over earlier files: +```bash +# Create or start without launching an agent. +sbx run --no-attach -1. `~/.config/sbx/config.toml` — user defaults -2. `./.sbx.toml` — project defaults from the directory where `sbx` is executed -3. `--config PATH` — explicit override file +# Keep the VM running after the agent exits. +sbx run --keep-running -CLI flags always override config values. +# Delete and recreate the VM from the current configuration. +sbx recreate --force +``` -Start with the released defaults; add `.sbx.toml` only when you need a named VM, mounts, image, user, or safety defaults. +Use `sbx --help` or `sbx COMMAND --help` for the complete command and option reference. -When `sbx run` or `sbx create` creates a new named VM and `./.sbx.toml` does not exist, `sbx` writes a minimal project config with the VM name and selected options. Existing VMs do not create or update `.sbx.toml` unless `--write-config` is passed. Use `--no-write-config` to skip this bootstrap. -Configuration should describe the sandbox, while command choice describes the action. For example, use `sbx run` to attach and `sbx create` to create without attaching, rather than relying on config to change interaction style. +## Project configuration -Example `.sbx.toml`: +The first project creation produces a small `.sbx.toml` resembling: ```toml [sbx] -agent = "pi" # pi, claude, or codex name = "the-quest" -memory = 4096 -cpus = 2 -disk_size = 20480 -# QEMU is the only supported backend for now. -backend = "qemu" -os = "ubuntu" -install_timeout = 600 -boot_timeout = 30 - -# Optional: use a local ready-to-run image directory with smolvm-image.json. -# image = "./images/debian-pi" - -# Bare mounts use the same absolute guest path. -# Explicit HOST:GUEST mounts keep the configured guest path. -mount = [ - "/foo/bar", - "/foo/cache:/workspace/cache", -] -# project_path is mounted first and used as the attached working directory. +agent = "pi" +image = "~/.smolvm/images/sbx" project_path = "." -writable_mounts = true run_user = "agent" - -auth_port = true -auth_host_port = 1455 -auth_guest_port = 1455 - -# Stop the VM when an sbx run/shell session exits and no other sbx sessions remain. -stop_on_exit = true - -# False by default. Keep host credential files out of the guest unless explicitly allowed. +writable_mounts = true copy_host_credentials = false - -# Empty by default. Host environment variables are not forwarded unless listed here -# or passed with `--env KEY`. -env = [] - -# True by default. Copies safe Git identity/config only, not credentials. git_config = true ``` -### Config reference - -| Section | Key | CLI flag | Description | -| ------- | ----------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `[sbx]` | `agent` | `--agent` | Default agent: `pi`, `claude`, or `codex`. | -| `[sbx]` | `name` | `--name` or positional `NAME` | VM name. | -| `[sbx]` | `memory` | `--memory` | Memory in MiB. | -| `[sbx]` | `cpus` | `--cpus` | Number of virtual CPUs. | -| `[sbx]` | `disk_size` | `--disk-size` | Disk size in MiB. | -| `[sbx]` | `backend` | - | Must be `qemu` for now. Other backends may be supported later. | -| `[sbx]` | `os` | `--os` | Guest OS value passed to SmolVM. Ignored when `image` is set. | -| `[sbx]` | `image` | `--image` | Local ready-to-run image directory containing `smolvm-image.json`, kernel, and rootfs. | -| `[sbx]` | `mount` | `--mount` | Host mount(s), as a string or array of strings. Bare host paths mount at the same absolute guest path; `HOST:GUEST` keeps the explicit guest path. | -| `[sbx]` | `project_path` | `--project-path` | Mount a path first at the same absolute guest path, force RW mounts, and start the attached agent there. | -| `[sbx]` | `writable_mounts` | `--writable-mounts` | Enable writable mounts. | -| `[sbx]` | `run_user` | `--run-user` | Create/use a guest user and run the attached agent/shell as that user. | -| `[sbx]` | `auth_port` | `--auth-port` / `--no-auth-port` | Automatically expose the OAuth callback port before attaching. Defaults to `true` for `run`. | -| `[sbx]` | `auth_host_port` | `--auth-host-port` | Host localhost port for OAuth callback forwarding. Defaults to `1455`. | -| `[sbx]` | `auth_guest_port` | `--auth-guest-port` | Guest port for OAuth callback forwarding. Defaults to `1455`. | -| `[sbx]` | `stop_on_exit` | `--stop-on-exit` / `--keep-running` | Stop the VM after run/shell exits if no other sbx sessions remain. Defaults to `true`. | -| `[sbx]` | `copy_host_credentials` | `--copy-host-credentials` / `--no-copy-host-credentials` | Allow/deny copying host credential files/configs. Defaults to `false`. | -| `[sbx]` | `env` | `--env KEY` | Explicit allowlist of host environment variables to forward into the guest. Defaults to empty. See [environment forwarding](docs/environment-forwarding.md). | -| `[sbx]` | `git_config` | `--git-config` / `--no-git-config` | Copy safe host Git identity/config into the guest. Defaults to `true`; does not copy credentials, SSH keys, signing keys, includes, or credential helpers. | -| `[sbx]` | `install_timeout` | `--install-timeout` | Agent install timeout in seconds. Ignored when `image` is set. | -| `[sbx]` | `boot_timeout` | `--boot-timeout` | VM boot/SSH readiness timeout in seconds. Defaults to `30`. Increase this if a cold boot leaves the VM running but SSH is not ready yet. | - -### Environment forwarding - -Host environment variables are not forwarded by default. Add selected names to `[sbx].env` or pass `--env KEY` to `sbx run`. - -Before `sbx run` or `sbx shell` attaches, `sbx` syncs those names from the current host environment into the VM. Missing host variables are unset in the guest to avoid stale secrets. This affects only new attached processes; already-running agents or shells keep their old environment. - -See [`docs/environment-forwarding.md`](docs/environment-forwarding.md) for details and limitations. - -### 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 and rootless Docker workflow, see [`docs/build-local-debian-pi-image.md`](docs/build-local-debian-pi-image.md). - -Example layout: - -```text -images/debian-pi/ -├── smolvm-image.json -├── vmlinux.bin -└── rootfs.ext4 -``` - -Example `smolvm-image.json`: - -```json -{ - "name": "debian-pi", - "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", - "launch_command": "pi" - } -} -``` - -## Git commits from inside the VM +`sbx` reads configuration in this order, with later values winning: -By default, `sbx run` and `sbx shell` copy a small safe subset of the host's global Git configuration into the guest user before attaching. This is intended to make commits inside the VM use the same author identity as the host. See [`docs/git-config-forwarding.md`](docs/git-config-forwarding.md) for details. +1. `~/.config/sbx/config.toml` — user defaults; +2. `./.sbx.toml` — project defaults; and +3. `--config PATH` — an explicit override. -Copied keys are limited to safe identity/workflow settings such as: +CLI options override configuration. Keep durable choices such as the image, VM resources, user, mounts, and forwarded environment names in `.sbx.toml`; use commands for actions such as running, stopping, or recreating. -```text -user.name -user.email -init.defaultBranch -pull.rebase -push.default -core.autocrlf -core.eol -``` - -`sbx` does not copy Git credentials, SSH keys, GPG/signing keys, credential helpers, includes, or URL rewrite rules. Disable this with: - -```bash -sbx run --no-git-config -``` - -or: +Security-sensitive behavior is configuration-only: ```toml [sbx] -git_config = false -``` - -## Browser login from inside the VM +# Disabled by default. Enable only when the VM should receive host agent configs. +copy_host_credentials = false -When you run `/login` inside Pi in the VM, Pi starts a callback server inside the guest, commonly on port `1455`. Browser approval redirects to: +# Copies safe Git identity/workflow settings, never credentials or keys. +git_config = true -```text -http://localhost:1455/auth/callback?code=... +# Forward only named host environment variables. +env = ["OPENAI_API_KEY"] ``` -Your browser runs on the host, so `sbx run` opens an SSH local forward by default: +See [`sbx.toml.example`](sbx.toml.example) for available settings. -```text -host localhost:1455 -> VM localhost:1455 -``` - -For an already-running VM, inspect/open/close the port manually: +## Shell completion ```bash -sbx network status my-sbx -sbx network auth-port my-sbx -sbx network close-auth-port my-sbx -``` +# One-off setup for the current shell. +eval "$(sbx completion bash)" +eval "$(sbx completion zsh)" -Known limitation: only one local process can own `localhost:1455` at a time. If -multiple VMs are running, `/login` works only for the VM currently receiving that -host port. Switch the tracked auth tunnel before logging in from another VM: - -```bash -sbx network auth-port other-sbx --replace -``` - -If the port is owned by an untracked/non-`sbx` process, stop that process first. - -## Releases - -Maintainers start a release with the manual `Start release` workflow: - -```bash -gh workflow run start-release.yml --ref main -f version=0.2.1 +# fish +sbx completion fish | source ``` -Leave `version` blank to use the next patch version. - -Release workflows: - -- `.github/workflows/start-release.yml` (`Start release`): manual entry point; opens the package release PR, including the README install tag. -- `.github/workflows/release-pr-checks.yml` (`Release PR checks`): validates `release/v*` PRs only change version files. -- `.github/workflows/publish-release.yml` (`Publish release`): after the release PR is merged, creates the `v0.2.1` tag, GitHub release, website PR, and a PR bumping `main` to the next dev version, for example `0.2.2.dev0`. +The same commands can be redirected into your shell's normal completion directory for permanent installation. -## Notes +## More documentation -For the recommended working-directory/worktree layout with project-local Pi resources, see [`docs/project-organization.md`](docs/project-organization.md). +- [Build and run the curated image](docs/build-local-debian-pi-image.md) +- [Environment forwarding](docs/environment-forwarding.md) +- [Safe Git configuration forwarding](docs/git-config-forwarding.md) +- [Networking commands](docs/network-command-roadmap.md) +- [CLI ergonomics and behavior](docs/ergonomics.md) +- [Contributor setup and tests](docs/development.md) -`sbx` defaults to QEMU to avoid per-VM TAP/nftables sudo requirements. See [`docs/qemu-default.md`](docs/qemu-default.md) for the rationale. +`sbx` uses QEMU by default to avoid per-VM TAP and nftables setup. See [QEMU defaults](docs/qemu-default.md) for the rationale. diff --git a/docs/build-local-debian-pi-image.md b/docs/build-local-debian-pi-image.md index 077034c..d7912b6 100644 --- a/docs/build-local-debian-pi-image.md +++ b/docs/build-local-debian-pi-image.md @@ -36,11 +36,7 @@ src/sbx/image/resources/Containers/ The build command reads these packaged resources with `importlib.resources` and combines them into a temporary Containerfile. Docker layer caching still makes the base OS layer reusable across tooling changes. -Because Pi and uv tools are installed for `agent`, the matching `sbx` config should use: - -```toml -run_user = "agent" -``` +Because Pi and uv tools are installed for `agent`, the generated manifest defaults `run_user` to `agent`; the first project config records that effective user automatically. ## 2. Install the tools @@ -95,8 +91,8 @@ The subcommand also writes a local image manifest: List built images: ```bash -sbx image ls -sbx image ls --json +sbx image list +sbx image list --json ``` 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. @@ -123,7 +119,8 @@ Example manifest: "sbx": { "agent": "pi", "features": ["docker"], - "launch_command": "pi" + "launch_command": "pi", + "run_user": "agent" } } ``` @@ -135,12 +132,13 @@ 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 curated image manifest selects `run_user = "agent"` when CLI and project configuration do not select a user. `--project-path .` remains explicit because it creates a writable host mount. + The suggested `.sbx.toml` is: ```toml @@ -258,7 +256,7 @@ The image likely references an incompatible or older kernel. Rebuild it with the ### VM starts but SSH readiness times out -If a cold boot reports that `wait_for_ssh` timed out, but `sbx ls -a` shows the VM as `running`, the guest may simply need more time before SSH is ready. Retry: +If a cold boot reports that `wait_for_ssh` timed out, but `sbx ls` shows the VM as `running`, the guest may simply need more time before SSH is ready. Retry: ```bash sbx run diff --git a/docs/current-local-image-usage.md b/docs/current-local-image-usage.md index 018e5bf..8023182 100644 --- a/docs/current-local-image-usage.md +++ b/docs/current-local-image-usage.md @@ -48,12 +48,13 @@ The local image manifest is `smolvm-image.json`: "sbx": { "agent": "pi", "features": ["docker"], - "launch_command": "pi" + "launch_command": "pi", + "run_user": "agent" } } ``` -`sbx` reads this manifest to locate the kernel/rootfs, validate the configured agent, and list image features with `sbx image ls`. +`sbx` reads this manifest to locate the kernel/rootfs, validate the configured agent, default the guest user when CLI/config omit it, and list image features with `sbx image list`. CLI and `[sbx].run_user` override the manifest value. ## Runtime flow @@ -81,7 +82,7 @@ When `sbx run` starts a missing VM from this image, `sbx`: 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; +6. prepares the configured or manifest-defaulted `run_user`, safe Git config, auth callback forwarding, and project cwd; 7. launches the configured agent command, usually `pi`. In code this path is handled by `src/sbx/cli.py` in `_start_local_image()`. diff --git a/docs/ergonomics.md b/docs/ergonomics.md index 4f5c327..c5e2c82 100644 --- a/docs/ergonomics.md +++ b/docs/ergonomics.md @@ -44,6 +44,7 @@ If a command needs a sandbox name and neither a positional name nor `[sbx].name` | `shell` | Open an interactive shell for inspection/debugging. | | `stop` | Stop without deleting VM state. | | `rm` | Delete VM state. | +| `ls` / `list` | List all sandboxes; `--running` filters to running VMs. | | `doctor` | Diagnose host backend and project/config state. | | `network ...` | Expert helpers for auth callback tunnels and network status. | @@ -76,7 +77,7 @@ Examples of command/action concerns that should generally not be required in con - `--force` - `--debug` -Some session defaults, such as `stop_on_exit`, `auth_port`, or `git_config`, can live in config because they affect repeated project workflow behavior. +Security and forwarding policy such as `auth_port`, `copy_host_credentials`, and `git_config` lives only in config so recreation is reproducible. `copy_host_credentials` remains false by default. ## Existing VM reuse @@ -126,12 +127,11 @@ Behavior: - If `./.sbx.toml` exists, never modify it by default. - If `./.sbx.toml` exists and `--write-config` is passed, add missing durable keys only. - Never overwrite existing values. -- `--no-write-config` disables automatic config creation for that invocation. Example: ```bash -sbx run --name the-quest --image ~/.smolvm/images/sbx --memory 8192 --cpus 4 --disk-size 40960 --project-path . --run-user agent +sbx run the-quest --image ~/.smolvm/images/sbx --memory 8192 --cpus 4 --disk-size 40960 --project-path . ``` could create: @@ -147,7 +147,7 @@ project_path = "." run_user = "agent" ``` -After that, project workflows become: +On initial creation, `sbx` prints the generated config and the next `run`, `shell`, `stop`, and `rm` commands. After that, project workflows become: ```bash sbx run diff --git a/docs/git-config-forwarding.md b/docs/git-config-forwarding.md index e7c2287..356b8ca 100644 --- a/docs/git-config-forwarding.md +++ b/docs/git-config-forwarding.md @@ -11,20 +11,13 @@ Git config forwarding is enabled by default: git_config = true ``` -Disable it with: +Disable it reproducibly in project configuration: ```toml [sbx] git_config = false ``` -or per command: - -```bash -sbx run --no-git-config -sbx shell --no-git-config -``` - ## Copied keys Only these global Git config keys are copied: diff --git a/docs/network-command-roadmap.md b/docs/network-command-roadmap.md index 17c8d29..dbbafe4 100644 --- a/docs/network-command-roadmap.md +++ b/docs/network-command-roadmap.md @@ -10,16 +10,16 @@ sbx network ... ## Current commands -### `sbx network forward [NAME] SPEC...` +### `sbx network forward [--name NAME] SPEC...` -Forwards host TCP ports to a running sandbox in the foreground. Press Ctrl-C to stop all forwards. +Forwards host TCP ports to a running sandbox in the foreground. It uses `[sbx].name` unless `--name` selects another VM. Press Ctrl-C to stop all forwards. ```bash sbx network forward 3000 sbx network forward 8080:3000 sbx network forward 0.0.0.0:3000:3000 sbx network forward 3000 8080:80 -sbx network forward my-sbx 3000 8080:80 +sbx network forward --name the-quest 3000 8080:80 ``` `SPEC` is one of: @@ -53,7 +53,7 @@ Auth callback: active Auth detail: pid 12345, localhost:1455 -> guest:1455 ``` -If the auth callback port is listening but the process is not tracked by `sbx`, status reports: +Use `sbx network status --json` for machine-readable status. If the auth callback port is listening but the process is not tracked by `sbx`, status reports: ```text Auth callback: busy/untracked diff --git a/src/sbx/cli.py b/src/sbx/cli.py index 85ddae2..f72174b 100644 --- a/src/sbx/cli.py +++ b/src/sbx/cli.py @@ -304,23 +304,38 @@ def _print_table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> None: def cmd_list(args: argparse.Namespace) -> int: metadata = vm_metadata.load_vm_metadata() rows = [] - for vm in vm_state.smolvm_vms(all_vms=bool(getattr(args, "all", False))): + for vm in vm_state.smolvm_vms(all_vms=not args.running): name = str(getattr(vm, "vm_id", "-")) status = getattr(getattr(vm, "status", "-"), "value", getattr(vm, "status", "-")) rootfs = getattr(getattr(vm, "config", None), "rootfs_path", None) image_path = Path(rootfs) if rootfs is not None else None - image = "-" if image_path is None else image_path.parent.name or image_path.name or "-" - ssh_port = getattr(getattr(vm, "network", None), "ssh_host_port", None) rows.append( + { + "name": name, + "status": str(status), + "project": metadata.get(name, {}).get("project_root"), + "image": ( + image_path.parent.name or image_path.name if image_path is not None else None + ), + "ssh_port": getattr(getattr(vm, "network", None), "ssh_host_port", None), + } + ) + if args.json: + print(json.dumps(rows, indent=2, sort_keys=True)) + else: + _print_table( + ("NAME", "STATUS", "PROJECT", "IMAGE", "SSH"), [ - name, - str(status), - metadata.get(name, {}).get("project_root", "-"), - image, - str(ssh_port) if ssh_port is not None else "-", - ] + [ + row["name"], + row["status"], + row["project"] or "-", + row["image"] or "-", + str(row["ssh_port"]) if row["ssh_port"] is not None else "-", + ] + for row in rows + ], ) - _print_table(("NAME", "STATUS", "PROJECT", "IMAGE", "SSH"), rows) return 0 @@ -351,7 +366,9 @@ def _maybe_print_boot_timeout_running_hint(vm_id: str | None, boot_timeout: floa return True -def _start_existing_vm_if_needed(vm_id: str, status: str, boot_timeout: float) -> int: +def _start_existing_vm_if_needed( + vm_id: str, status: str, boot_timeout: float, *, json_output: bool = False +) -> int: if status == "running": return 0 if status == "error": @@ -363,7 +380,15 @@ def _start_existing_vm_if_needed(vm_id: str, status: str, boot_timeout: float) - ) print(f"sbx: If it still fails, run `sbx recreate {vm_id} --force`.", file=sys.stderr) return 1 - rc = runtime.run_smolvm(["sandbox", "start", vm_id, "--boot-timeout", f"{boot_timeout:g}"]) + command = ["sandbox", "start", vm_id, "--boot-timeout", f"{boot_timeout:g}"] + completed = runtime.run_smolvm_capture(command) + if completed is None: + return 127 + if completed.stderr: + print(completed.stderr, end="", file=sys.stderr) + if completed.stdout and (not json_output or completed.returncode != 0): + print(completed.stdout, end="", file=sys.stderr if json_output else sys.stdout) + rc = completed.returncode if rc != 0: _maybe_print_boot_timeout_running_hint(vm_id, boot_timeout) return rc @@ -436,6 +461,15 @@ def _manifest_sbx(manifest: Mapping[str, Any]) -> Mapping[str, Any]: return sbx +def _manifest_run_user(manifest: Mapping[str, Any]) -> str | None: + value = _manifest_sbx(manifest).get("run_user") + if value is None: + return None + if not isinstance(value, str): + raise ConfigError("image manifest field 'sbx.run_user' must be a string") + return guest_setup.validate_run_user(value) + + def _project_config_path() -> Path: return LOCAL_CONFIG_PATHS[0] @@ -518,33 +552,61 @@ def _maybe_write_project_config( vm_name: str, agent: str, created: bool, -) -> None: +) -> bool: if getattr(args, "action", None) not in {"run", "create"}: - return + return False write_config = getattr(args, "write_config", None) path = _project_config_path() exists = path.exists() if exists and write_config is not True: - return - if not exists and write_config is False: - return + return False if not exists and not created and write_config is not True: - return + return False values = _project_config_values(args, config, vm_name=vm_name, agent=agent) - added = _write_project_config_values(path, values) if not exists: + values.update( + { + "copy_host_credentials": bool(_cfg(config, "sbx", "copy_host_credentials", False)), + "git_config": bool(_cfg(config, "sbx", "git_config", True)), + } + ) + added = _write_project_config_values(path, values) + if not exists and not created: print(f"sbx: wrote {path.name} for project defaults", file=sys.stderr) - elif added: + elif added and exists: print( f"sbx: updated {path.name} with missing project defaults: {', '.join(added)}", file=sys.stderr, ) - else: + elif exists: print( f"sbx: {path.name} already contains project defaults; no changes made", file=sys.stderr, ) + return not exists and created + + +def _print_created( + vm_name: str, + *, + config_created: bool, + agent: str, + attach: bool, + run_user: str | None = None, +) -> None: + if config_created: + print(f"Created sandbox '{vm_name}'.") + print("Wrote .sbx.toml.\n") + print("Run agent: sbx run") + print("Open shell: sbx shell") + print("Stop: sbx stop") + print("Remove: sbx rm") + elif attach: + user = f" as user {run_user}" if run_user else "" + print(f"Started '{vm_name}'. Launching {agent}{user}...") + else: + print(f"Started '{vm_name}'.") def _start_local_image( @@ -652,16 +714,23 @@ def _start_local_image( raise vm.close() - _maybe_write_project_config(args, config, vm_name=str(vm_name), agent=agent, created=True) + config_created = _maybe_write_project_config( + args, config, vm_name=str(vm_name), agent=agent, created=True + ) vm_metadata.record_vm_project(str(vm_name), _project_identity(args, config)) - if attach: - print(f"Started '{vm_name}'. Launching {agent}...") - if not _sync_forwarded_env_or_error(str(vm_name), forward_env or []): - return 1 - else: - print(f"Started '{vm_name}'.") - return _post_start_actions( + json_output = bool(getattr(args, "json", False)) + if not json_output: + _print_created( + str(vm_name), + config_created=config_created, + agent=agent, + attach=attach, + run_user=run_user, + ) + if attach and not _sync_forwarded_env_or_error(str(vm_name), forward_env or []): + return 1 + rc = _post_start_actions( vm_name=vm_name, command=launch_command or LAUNCH_COMMANDS[agent], attach=attach, @@ -673,6 +742,9 @@ def _start_local_image( cwd=cwd, git_config_text=git_config_text, ) + if rc == 0 and json_output: + print(json.dumps({"vm": {"name": str(vm_name), "status": "running"}})) + return rc def cmd_doctor(args: argparse.Namespace) -> int: @@ -701,8 +773,10 @@ def cmd_start(args: argparse.Namespace) -> int: sbx_cfg = _sbx_config(config) project_identity = _project_identity(args, config) project_root = Path(project_identity["project_root"]) - if args.name is None and args.name_arg is not None: - args.name = args.name_arg + attach = True if args.attach is None else bool(args.attach) + if args.json and attach: + print("sbx: run --json requires --no-attach", file=sys.stderr) + return 2 agent = args.agent or _cfg_agent(config) if agent not in AGENTS: @@ -747,7 +821,6 @@ def cmd_start(args: argparse.Namespace) -> int: if project_path is not None: writable_mounts = True - attach = True if args.attach is None else bool(args.attach) run_user = _arg_or_config(args, "run_user", config, "sbx") if run_user is not None: run_user = guest_setup.validate_run_user(str(run_user)) @@ -764,7 +837,7 @@ def cmd_start(args: argparse.Namespace) -> int: f"forward_env={forward_env!r}" ) - auth_port = bool(_arg_or_config(args, "auth_port", config, "sbx", default=True)) + auth_port = attach and bool(_arg_or_config(args, "auth_port", config, "sbx", default=True)) auth_host_port = int(_arg_or_config(args, "auth_host_port", config, "sbx", default=1455)) auth_guest_port = int(_arg_or_config(args, "auth_guest_port", config, "sbx", default=1455)) stop_on_exit = bool(_arg_or_config(args, "stop_on_exit", config, "sbx", default=True)) @@ -779,12 +852,18 @@ def cmd_start(args: argparse.Namespace) -> int: print(f"sbx: {exc}", file=sys.stderr) return 2 + image = lifecycle_warnings.path_from_config(_arg_or_config(args, "image", config, "sbx")) requested_name = _arg_or_config(args, "name", config, "sbx") if requested_name: requested_name = guest_setup.validate_vm_name(str(requested_name)) existing_status = _get_existing_vm_status(str(requested_name)) runtime.debug(f"existing VM lookup: name={requested_name!r}, status={existing_status!r}") if existing_status is not None: + if run_user is None and image is not None: + with suppress(ConfigError): + run_user = _manifest_run_user(lifecycle_warnings.local_image_manifest(image)) + if run_user is not None: + args.run_user = run_user if existing_status != "running": try: _sync_existing_vm_start_config( @@ -805,6 +884,7 @@ def cmd_start(args: argparse.Namespace) -> int: str(requested_name), existing_status, boot_timeout, + json_output=bool(args.json), ) if start_rc != 0: return start_rc @@ -814,7 +894,7 @@ def cmd_start(args: argparse.Namespace) -> int: args, config, vm_name=str(requested_name), agent=str(agent), created=False ) vm_metadata.record_vm_project(str(requested_name), project_identity) - return _post_start_actions( + rc = _post_start_actions( vm_name=str(requested_name), command=LAUNCH_COMMANDS[str(agent)], attach=attach, @@ -826,11 +906,17 @@ def cmd_start(args: argparse.Namespace) -> int: cwd=project_guest_cwd, git_config_text=git_config_text, ) + if rc == 0 and args.json: + print(json.dumps({"vm": {"name": str(requested_name), "status": "running"}})) + return rc - image = lifecycle_warnings.path_from_config(_arg_or_config(args, "image", config, "sbx")) if image is not None: try: manifest = lifecycle_warnings.local_image_manifest(image) + if run_user is None: + run_user = _manifest_run_user(manifest) + if run_user is not None: + args.run_user = run_user return _start_local_image( args=args, config=config, @@ -863,7 +949,14 @@ def cmd_start(args: argparse.Namespace) -> int: temp_home_ctx = None host_env = guest_setup.sanitize_forwarded_env(dict(os.environ), forward_env) - if not copy_host_credentials: + if copy_host_credentials: + target = f" VM '{requested_name}'" if requested_name else " the new VM" + print( + f"sbx: warning: [sbx].copy_host_credentials=true; " + f"host agent credentials may be copied into{target}.", + file=sys.stderr, + ) + else: temp_home_ctx = tempfile.TemporaryDirectory(prefix="sbx-no-credentials-") host_env = guest_setup.credential_free_env( Path(temp_home_ctx.name), forward_env=forward_env @@ -902,18 +995,21 @@ def cmd_start(args: argparse.Namespace) -> int: if temp_home_ctx is not None: temp_home_ctx.cleanup() - _maybe_write_project_config(args, config, vm_name=vm_name, agent=str(agent), created=True) + config_created = _maybe_write_project_config( + args, config, vm_name=vm_name, agent=str(agent), created=True + ) vm_metadata.record_vm_project(vm_name, project_identity) - if args.json: - print(json.dumps({"vm": {"name": vm_name, "status": "running"}})) - elif attach: - user_msg = f" as user {run_user}" if run_user is not None else "" - print(f"Started '{vm_name}'. Launching {agent}{user_msg}...") - else: - print(f"Started '{vm_name}'.") + if not args.json: + _print_created( + vm_name, + config_created=config_created, + agent=str(agent), + attach=attach, + run_user=str(run_user) if run_user is not None else None, + ) - return _post_start_actions( + rc = _post_start_actions( vm_name=vm_name, command=LAUNCH_COMMANDS[str(agent)], attach=attach, @@ -925,6 +1021,9 @@ def cmd_start(args: argparse.Namespace) -> int: cwd=project_guest_cwd, git_config_text=git_config_text, ) + if rc == 0 and args.json: + print(json.dumps({"vm": {"name": vm_name, "status": "running"}})) + return rc def _confirm_destructive_action(message: str, *, force: bool) -> bool: @@ -958,9 +1057,7 @@ def cmd_shell(args: argparse.Namespace) -> int: smolvm_command = ["sandbox", "ssh", name] keep_running = bool(getattr(args, "keep_running", False)) stop_on_exit = bool(_cfg(config, "sbx", "stop_on_exit", True)) and not keep_running - git_config = bool( - args.git_config if args.git_config is not None else _cfg(config, "sbx", "git_config", True) - ) + git_config = bool(_cfg(config, "sbx", "git_config", True)) git_config_text = ( guest_setup.host_git_config(Path(project_identity["project_root"])) if git_config else None ) @@ -1052,7 +1149,7 @@ def cmd_remove(args: argparse.Namespace) -> int: return _delete_vm(name) -def _delete_vm(vm_id: str, extra_args: Sequence[str] | None = None) -> int: +def _delete_vm(vm_id: str, extra_args: Sequence[str] | None = None, *, quiet: bool = False) -> int: extra = list(extra_args or []) completed = runtime.run_smolvm_capture(["sandbox", "delete", vm_id, *extra, "--json"]) if completed is None: @@ -1072,11 +1169,13 @@ def _delete_vm(vm_id: str, extra_args: Sequence[str] | None = None) -> int: if vm_id in metadata: metadata.pop(vm_id, None) vm_metadata.save_vm_metadata(metadata) - print(f"Destroyed VM '{vm_id}'.") + if not quiet: + print(f"Destroyed VM '{vm_id}'.") return 0 if any(item.get("error") == f"VM '{vm_id}' not found" for item in failed): - print(f"VM '{vm_id}' not found; nothing to destroy.") + if not quiet: + print(f"VM '{vm_id}' not found; nothing to destroy.") return 0 if completed.stdout: @@ -1086,112 +1185,55 @@ def _delete_vm(vm_id: str, extra_args: Sequence[str] | None = None) -> int: def cmd_create(args: argparse.Namespace) -> int: args.attach = False - if args.auth_port is None: - args.auth_port = False + args.auth_port = False return cmd_start(args) def cmd_recreate(args: argparse.Namespace) -> int: config = args.config_data force = args.force - name = args.name or args.name_arg or _cfg(config, "sbx", "name") + name = args.name or _cfg(config, "sbx", "name") if not name: - print("sbx: recreate requires a VM name argument, --name, or [sbx].name", file=sys.stderr) + print("sbx: recreate requires a VM name argument or [sbx].name", file=sys.stderr) return 2 if not _confirm_destructive_action(f"Destroy and recreate VM '{name}'?", force=force): return 2 - destroy_rc = _delete_vm(str(name)) + json_output = bool(getattr(args, "json", False)) + destroy_rc = _delete_vm(str(name), quiet=True) if json_output else _delete_vm(str(name)) if destroy_rc != 0: return destroy_rc args.name = str(name) args.attach = False - if args.auth_port is None: - args.auth_port = False + args.auth_port = False return cmd_start(args) def _add_start_options(parser: argparse.ArgumentParser) -> None: - parser.add_argument( + session = parser.add_argument_group("Session") + session.add_argument( "--agent", choices=AGENTS, help="Agent preset to run (default: [sbx].agent or pi)." ) - parser.add_argument("--name") - parser.add_argument("--memory", type=int, metavar="MIB") - parser.add_argument("--cpus", type=int, metavar="COUNT", help="Number of virtual CPUs.") - parser.add_argument("--disk-size", type=int, metavar="MIB") - parser.add_argument("--os") - parser.add_argument("--image", help="Local ready-to-run image directory.") - parser.add_argument("--mount", action="append", metavar="HOST_PATH[:GUEST_PATH]") - parser.add_argument( - "--project-path", - help="Mount this host path at the same absolute guest path as read-write.", - ) - parser.add_argument( + session.add_argument( "--run-user", help="When attaching, create/use this guest user and run the agent as that user.", ) - parser.add_argument( + session.add_argument( "--env", action="append", default=None, metavar="KEY", help="Forward this host environment variable into the guest. Can be repeated.", ) - auth_port = parser.add_mutually_exclusive_group() - auth_port.add_argument( - "--auth-port", - dest="auth_port", - action="store_true", - default=None, - help="Expose the agent OAuth callback port before attaching (default).", - ) - auth_port.add_argument( - "--no-auth-port", - dest="auth_port", - action="store_false", - help="Do not expose the agent OAuth callback port automatically.", - ) - parser.add_argument("--auth-host-port", type=int, help="Host OAuth callback port.") - parser.add_argument("--auth-guest-port", type=int, help="Guest OAuth callback port.") - credential_copy = parser.add_mutually_exclusive_group() - credential_copy.add_argument( - "--copy-host-credentials", - dest="copy_host_credentials", - action="store_true", - default=None, - help="Allow SmolVM presets to copy host CLI config files.", - ) - credential_copy.add_argument( - "--no-copy-host-credentials", - dest="copy_host_credentials", - action="store_false", - help="Do not copy host CLI config files (default).", - ) - git_config = parser.add_mutually_exclusive_group() - git_config.add_argument( - "--git-config", - dest="git_config", - action="store_true", - default=None, - help="Copy safe host Git identity/config into the guest (default).", - ) - git_config.add_argument( - "--no-git-config", - dest="git_config", - action="store_false", - help="Do not copy host Git identity/config into the guest.", - ) - parser.add_argument("--writable-mounts", action="store_true", default=None) - attach = parser.add_mutually_exclusive_group() - attach.add_argument("--attach", dest="attach", action="store_true", default=None) - attach.add_argument( + session.add_argument( "--no-attach", dest="attach", action="store_false", - help="Create VM but do not launch the agent.", + default=None, + help="Create or start the VM but do not launch the agent.", ) - stop_on_exit = parser.add_mutually_exclusive_group() + stop_on_exit = session.add_mutually_exclusive_group() stop_on_exit.add_argument( "--stop-on-exit", dest="stop_on_exit", @@ -1205,28 +1247,36 @@ def _add_start_options(parser: argparse.ArgumentParser) -> None: action="store_false", help="Keep the VM running after this sbx session exits.", ) - parser.add_argument( + + workspace = parser.add_argument_group("Workspace") + workspace.add_argument("--mount", action="append", metavar="HOST_PATH[:GUEST_PATH]") + workspace.add_argument( + "--project-path", + help="Mount this host path at the same absolute guest path as read-write.", + ) + workspace.add_argument("--writable-mounts", action="store_true", default=None) + + resources = parser.add_argument_group("VM resources") + resources.add_argument("--memory", type=int, metavar="MIB") + resources.add_argument("--cpus", type=int, metavar="COUNT", help="Number of virtual CPUs.") + resources.add_argument("--disk-size", type=int, metavar="MIB") + resources.add_argument("--image", help="Local ready-to-run image directory.") + resources.add_argument( "--boot-timeout", type=float, help="Seconds to wait for VM boot/SSH readiness (default: [sbx].boot_timeout or 60).", ) - parser.add_argument("--install-timeout", type=float) - write_config = parser.add_mutually_exclusive_group() - write_config.add_argument( + resources.add_argument("--install-timeout", type=float) + + output = parser.add_argument_group("Configuration and output") + output.add_argument( "--write-config", - dest="write_config", action="store_true", default=None, help="Create or update .sbx.toml with missing project defaults.", ) - write_config.add_argument( - "--no-write-config", - dest="write_config", - action="store_false", - help="Do not create .sbx.toml automatically for this invocation.", - ) - parser.add_argument("--json", action="store_true", default=None) - parser.add_argument("name_arg", nargs="?", metavar="NAME", help="Sandbox name.") + output.add_argument("--json", action="store_true", default=None) + parser.add_argument("name", nargs="?", metavar="NAME", help="Sandbox name.") def build_parser() -> argparse.ArgumentParser: @@ -1281,20 +1331,6 @@ def build_parser() -> argparse.ArgumentParser: "--project-path", help="Start the shell in this mounted project path (default: [sbx].project_path).", ) - git_config_shell = shell.add_mutually_exclusive_group() - git_config_shell.add_argument( - "--git-config", - dest="git_config", - action="store_true", - default=None, - help="Copy safe host Git identity/config into the guest (default).", - ) - git_config_shell.add_argument( - "--no-git-config", - dest="git_config", - action="store_false", - help="Do not copy host Git identity/config into the guest.", - ) shell.add_argument( "--root", action="store_true", @@ -1306,11 +1342,9 @@ def build_parser() -> argparse.ArgumentParser: for list_name in ("list", "ls"): list_parser = sub.add_parser(list_name, help="List sandboxes.") list_parser.add_argument( - "-a", - "--all", - action="store_true", - help="List all sandboxes, including stopped ones.", + "--running", action="store_true", help="List only running sandboxes." ) + list_parser.add_argument("--json", action="store_true", help="Print JSON.") list_parser.set_defaults(func=cmd_list) network_parser = sub.add_parser("network", help="Expert networking helpers.") @@ -1320,11 +1354,12 @@ def build_parser() -> argparse.ArgumentParser: help="Forward a host TCP port to a running sandbox until Ctrl-C.", ) forward.add_argument( - "forward_args", + "specs", nargs="+", - metavar="[NAME] SPEC...", - help="Forward specs: GUEST_PORT, HOST_PORT:GUEST_PORT, or BIND_HOST:HOST_PORT:GUEST_PORT.", + metavar="SPEC", + help="GUEST_PORT, HOST_PORT:GUEST_PORT, or BIND_HOST:HOST_PORT:GUEST_PORT.", ) + forward.add_argument("--name", help="Sandbox name or ID. Defaults to [sbx].name.") forward.set_defaults(func=network.cmd_forward) auth_port = network_sub.add_parser( @@ -1373,19 +1408,19 @@ def build_parser() -> argparse.ArgumentParser: default=1455, help="Host auth callback port to inspect (default: 1455).", ) + network_status.add_argument("--json", action="store_true", help="Print JSON.") network_status.set_defaults(func=network.cmd_status) image = sub.add_parser("image", help="Advanced local image helpers.") image_sub = image.add_subparsers(dest="image_action", required=True) - build_parser = image_sub.add_parser( - "build", help="Build the curated local image for sbx." - ) + build_parser = image_sub.add_parser("build", help="Build the curated local image for sbx.") 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) - list_images_parser.set_defaults(func=cmd_image_ls) + for image_list_name in ("list", "ls"): + list_images_parser = image_sub.add_parser(image_list_name, help="List local sbx images.") + sbx.image.ls.add_arguments(list_images_parser) + list_images_parser.set_defaults(func=cmd_image_ls) doctor = sub.add_parser("doctor", help="Run non-sudo diagnostics for the configured backend.") doctor.add_argument( diff --git a/src/sbx/completion.py b/src/sbx/completion.py index 8bd2ee3..6c31c57 100644 --- a/src/sbx/completion.py +++ b/src/sbx/completion.py @@ -16,58 +16,39 @@ "completion", ) NETWORK_COMMANDS = ("forward", "auth-port", "close-auth-port", "status") -IMAGE_COMMANDS = ("build", "ls") +IMAGE_COMMANDS = ("build", "list", "ls") AGENTS = ("pi", "claude", "codex") GLOBAL_OPTIONS = ("--config", "--debug", "--help") START_OPTIONS = ( "--agent", - "--name", "--memory", "--cpus", "--disk-size", - "--os", "--image", "--mount", "--project-path", "--run-user", "--env", - "--auth-port", - "--no-auth-port", - "--auth-host-port", - "--auth-guest-port", - "--copy-host-credentials", - "--no-copy-host-credentials", - "--git-config", - "--no-git-config", "--writable-mounts", - "--attach", "--no-attach", "--stop-on-exit", "--keep-running", "--boot-timeout", "--install-timeout", "--write-config", - "--no-write-config", "--json", "--help", ) -SHELL_OPTIONS = ( - "--keep-running", - "--run-user", - "--project-path", - "--git-config", - "--no-git-config", - "--root", - "--help", -) -LS_OPTIONS = ("--all", "-a", "--help") +SHELL_OPTIONS = ("--keep-running", "--run-user", "--project-path", "--root", "--help") +LS_OPTIONS = ("--running", "--json", "--help") RM_OPTIONS = ("--force", "--help") DOCTOR_OPTIONS = ("--fix", "--help") STOP_OPTIONS = ("--help",) RUN_OPTIONS = START_OPTIONS +FORWARD_OPTIONS = ("--name", "--help") AUTH_PORT_OPTIONS = ("--guest-port", "--host-port", "--replace", "--help") -NETWORK_STATUS_OPTIONS = ("--host-port", "--help") +NETWORK_STATUS_OPTIONS = ("--host-port", "--json", "--help") IMAGE_BUILD_OPTIONS = ( "--name", "--base-image", @@ -115,6 +96,7 @@ def bash_completion() -> str: stop_options = _words(STOP_OPTIONS) network_commands = _words(NETWORK_COMMANDS) image_commands = _words(IMAGE_COMMANDS) + forward_options = _words(FORWARD_OPTIONS) auth_port_options = _words(AUTH_PORT_OPTIONS) network_status_options = _words(NETWORK_STATUS_OPTIONS) image_build_options = _words(IMAGE_BUILD_OPTIONS) @@ -193,7 +175,7 @@ def bash_completion() -> str: if [[ -z "$subcmd" ]]; then COMPREPLY=( $(compgen -W "{network_commands}" -- "$cur") ) elif [[ "$subcmd" == "forward" ]]; then - COMPREPLY=( $(compgen -W "--help" -- "$cur") ) + COMPREPLY=( $(compgen -W "{forward_options}" -- "$cur") ) elif [[ "$subcmd" == "auth-port" ]]; then COMPREPLY=( $(compgen -W "{auth_port_options}" -- "$cur") ) elif [[ "$subcmd" == "close-auth-port" ]]; then @@ -214,7 +196,7 @@ def bash_completion() -> str: COMPREPLY=( $(compgen -W "{image_commands}" -- "$cur") ) elif [[ "$subcmd" == "build" ]]; then COMPREPLY=( $(compgen -W "{image_build_options}" -- "$cur") ) - elif [[ "$subcmd" == "ls" ]]; then + elif [[ "$subcmd" == "list" || "$subcmd" == "ls" ]]; then COMPREPLY=( $(compgen -W "{image_ls_options}" -- "$cur") ) fi ;; @@ -241,6 +223,7 @@ def zsh_completion() -> str: doctor_options = _zsh_words(DOCTOR_OPTIONS) stop_options = _zsh_words(STOP_OPTIONS) network_commands = _zsh_words(NETWORK_COMMANDS) + forward_options = _zsh_words(FORWARD_OPTIONS) auth_port_options = _zsh_words(AUTH_PORT_OPTIONS) network_status_options = _zsh_words(NETWORK_STATUS_OPTIONS) image_commands = _zsh_words(IMAGE_COMMANDS) @@ -252,7 +235,7 @@ def zsh_completion() -> str: _sbx() {{ 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 forward_options auth_port_options network_status_options local -a image_commands image_build_options image_ls_options shells commands=({commands}) run_options=({run_options}) @@ -264,6 +247,7 @@ def zsh_completion() -> str: doctor_options=({doctor_options}) stop_options=({stop_options}) network_commands=({network_commands}) + forward_options=({forward_options}) auth_port_options=({auth_port_options}) network_status_options=({network_status_options}) image_commands=({image_commands}) @@ -302,7 +286,7 @@ def zsh_completion() -> str: if (( CURRENT == 3 )); then _describe 'network command' network_commands elif [[ $words[3] == "forward" ]]; then - _describe 'option' '(--help)' + _describe 'option' forward_options elif [[ $words[3] == "auth-port" ]]; then _describe 'option' auth_port_options elif [[ $words[3] == "close-auth-port" ]]; then @@ -318,7 +302,7 @@ def zsh_completion() -> str: _describe 'image command' image_commands elif [[ $words[3] == "build" ]]; then _describe 'option' image_build_options - elif [[ $words[3] == "ls" ]]; then + elif [[ $words[3] == "list" || $words[3] == "ls" ]]; then _describe 'option' image_ls_options else _arguments '*: :->args' @@ -379,7 +363,7 @@ def fish_completion() -> str: f"and not __fish_seen_subcommand_from {network_subcommands}' -a {command}" ) for command, options in ( - ("forward", ("--help",)), + ("forward", FORWARD_OPTIONS), ("auth-port", AUTH_PORT_OPTIONS), ("close-auth-port", ("--help",)), ("status", NETWORK_STATUS_OPTIONS), diff --git a/src/sbx/image/build_debian.py b/src/sbx/image/build_debian.py index 71d941b..20d6d87 100644 --- a/src/sbx/image/build_debian.py +++ b/src/sbx/image/build_debian.py @@ -429,6 +429,7 @@ def main_from_args(args: argparse.Namespace) -> int: "agent": "pi", "features": [] if args.containerfile is not None else ["docker"], "launch_command": "pi", + "run_user": "agent", }, } manifest_path.write_text( @@ -474,7 +475,6 @@ def main_from_args(args: argparse.Namespace) -> int: 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") diff --git a/src/sbx/network.py b/src/sbx/network.py index 1c03344..1e11752 100644 --- a/src/sbx/network.py +++ b/src/sbx/network.py @@ -230,22 +230,13 @@ def _foreground_port_forward(vm_id: str, forwards: Sequence[tuple[str, int, int] def cmd_forward(args: argparse.Namespace) -> int: - name = None - specs = args.forward_args - if len(specs) > 1: - try: - parse_port_forward(specs[0]) - except ConfigError: - name = specs[0] - specs = specs[1:] - if name is None: - name = vm_name_from_arg_or_config( - args, getattr(args, "config_data", None), "network forward" - ) + name = vm_name_from_arg_or_config(args, getattr(args, "config_data", None), "network forward") if name is None: return 2 try: - return _foreground_port_forward(str(name), [parse_port_forward(spec) for spec in specs]) + return _foreground_port_forward( + str(name), [parse_port_forward(spec) for spec in args.specs] + ) except ConfigError as exc: print(f"sbx: {exc}", file=sys.stderr) return 2 @@ -326,13 +317,28 @@ def cmd_status(args: argparse.Namespace) -> int: auth_status = "busy/untracked" auth_detail = f"localhost:{args.host_port} is listening but is not tracked by sbx" - print(f"Sandbox: {vm['name']}") - print(f"Status: {vm['status']}") - print(f"Backend: {vm['backend']}") - print(f"Guest IP: {vm['ip_address']}") - print(f"SSH Port: {vm['ssh_port']}") port_forwards = _vm_port_forward_details(vm) - print("Port forwards: " + (", ".join(port_forwards) if port_forwards else "-")) - print(f"Auth callback: {auth_status}") - print(f"Auth detail: {auth_detail}") + result = { + "name": vm["name"], + "status": vm["status"], + "backend": vm["backend"], + "guest_ip": vm["ip_address"], + "ssh_port": vm["ssh_port"], + "port_forwards": port_forwards, + "auth_callback": { + "status": auth_status, + "detail": None if auth_detail == "-" else auth_detail, + }, + } + if getattr(args, "json", False): + print(json.dumps(result, indent=2, sort_keys=True)) + else: + print(f"Sandbox: {result['name']}") + print(f"Status: {result['status']}") + print(f"Backend: {result['backend']}") + print(f"Guest IP: {result['guest_ip']}") + print(f"SSH Port: {result['ssh_port']}") + print("Port forwards: " + (", ".join(port_forwards) if port_forwards else "-")) + print(f"Auth callback: {auth_status}") + print(f"Auth detail: {auth_detail}") return 0 diff --git a/src/sbx/runtime.py b/src/sbx/runtime.py index 1e02f64..e8c6f29 100644 --- a/src/sbx/runtime.py +++ b/src/sbx/runtime.py @@ -119,7 +119,7 @@ def run_smolvm_capture( def missing_vm_message(vm_id: str) -> str: return ( f"VM {vm_id!r} not found. `sbx shell` attaches to an existing sandbox; " - f"create it with `sbx run {vm_id}` or list VMs with `sbx ls -a`." + f"create it with `sbx run {vm_id}` or list VMs with `sbx ls`." ) diff --git a/tests/test_build_debian_image.py b/tests/test_build_debian_image.py index b787311..ec8cca7 100644 --- a/tests/test_build_debian_image.py +++ b/tests/test_build_debian_image.py @@ -158,6 +158,7 @@ def test_default_build_always_uses_docker_kernel_and_feature( manifest = json.loads((tmp_path / "sbx" / "smolvm-image.json").read_text()) assert manifest["kernel"] == "vmlinux.bin" assert manifest["sbx"]["features"] == ["docker"] + assert manifest["sbx"]["run_user"] == "agent" output = capsys.readouterr().out assert "kernel:" in output assert "sbx run the-quest" in output diff --git a/tests/test_cli.py b/tests/test_cli.py index dee8af6..5625502 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -263,16 +263,49 @@ def test_list_does_not_require_name( ) -def test_list_all_aliases_include_stopped_vms( +def test_list_defaults_to_all_and_can_filter_running( monkeypatch: pytest.MonkeyPatch, ) -> None: calls: list[bool] = [] monkeypatch.setattr(vm_state, "smolvm_vms", lambda all_vms=False: calls.append(all_vms) or []) - assert cli.main(["ls", "--all"]) == 0 - assert cli.main(["ls", "-a"]) == 0 + assert cli.main(["ls"]) == 0 + assert cli.main(["list", "--running"]) == 0 - assert calls == [True, True] + assert calls == [True, False] + + +def test_list_json_uses_null_for_missing_values( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + vm = SimpleNamespace(vm_id="vm1", status="stopped", config=None, network=None) + monkeypatch.setattr(vm_state, "smolvm_vms", lambda all_vms=False: [vm]) + + assert cli.main(["ls", "--json"]) == 0 + assert json.loads(capsys.readouterr().out) == [ + { + "name": "vm1", + "status": "stopped", + "project": None, + "image": None, + "ssh_port": None, + } + ] + + +def test_run_help_groups_supported_options( + capsys: pytest.CaptureFixture[str], +) -> None: + with pytest.raises(SystemExit) as exc: + cli.main(["run", "--help"]) + + assert exc.value.code == 0 + out = capsys.readouterr().out + for heading in ("Session:", "Workspace:", "VM resources:", "Configuration and output:"): + assert heading in out + assert "--no-attach" in out + assert "--write-config" in out def test_shell_uses_configured_default_name( @@ -471,7 +504,7 @@ def test_run_uses_local_toml_defaults( captured: dict[str, object] = {} capture_preset(monkeypatch, captured, vm_id="demo") - rc = cli.main(["run", "--no-auth-port"]) + rc = cli.main(["run"]) assert rc == 0 preset_name, preset_kwargs = captured["preset"] @@ -516,7 +549,7 @@ def test_run_supports_all_exposed_options_from_config( captured: dict[str, object] = {} capture_preset(monkeypatch, captured, vm_id="configured") - rc = cli.main(["--config", str(config), "run", "--no-auth-port", "--no-attach"]) + rc = cli.main(["--config", str(config), "run", "--no-attach"]) assert rc == 0 preset_name, preset_kwargs = captured["preset"] @@ -537,21 +570,42 @@ def test_run_supports_all_exposed_options_from_config( assert preset_kwargs["port_forwards"] == [ {"host_address": "127.0.0.1", "host_port": 8080, "guest_port": 80} ] - assert capfd.readouterr().out == "Started 'configured'.\n" + assert "Created sandbox 'configured'." in capfd.readouterr().out +@pytest.mark.parametrize( + ("manifest_run_user", "configured_run_user", "expected_run_user"), + [ + ("agent", "", "agent"), + ("agent", 'run_user = "developer"', "developer"), + (None, "", None), + ], +) def test_run_uses_local_image_directory( + manifest_run_user: str | None, + configured_run_user: str, + expected_run_user: str | None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + capsys: pytest.CaptureFixture[str], ) -> None: install_fake_smolvm(monkeypatch, tmp_path) image_dir = tmp_path / "debian-pi" image_dir.mkdir() (image_dir / "vmlinux.bin").write_text("kernel", encoding="utf-8") (image_dir / "rootfs.ext4").write_text("rootfs", encoding="utf-8") + manifest_sbx = {"agent": "pi", "launch_command": "pi"} + if manifest_run_user is not None: + manifest_sbx["run_user"] = manifest_run_user (image_dir / "smolvm-image.json").write_text( - '{"name":"debian-pi","kernel":"vmlinux.bin","rootfs":"rootfs.ext4",' - '"sbx":{"agent":"pi","launch_command":"pi"}}', + json.dumps( + { + "name": "debian-pi", + "kernel": "vmlinux.bin", + "rootfs": "rootfs.ext4", + "sbx": manifest_sbx, + } + ), encoding="utf-8", ) config = tmp_path / "config.toml" @@ -560,7 +614,9 @@ def test_run_uses_local_image_directory( [sbx] name = "vm1" image = "{image_dir}" +copy_host_credentials = true mount = [".:/workspace"] +{configured_run_user} '''.strip(), encoding="utf-8", ) @@ -579,13 +635,17 @@ def fake_start_local_image(**kwargs: object) -> int: ), ) - rc = cli.main(["--config", str(config), "run", "--no-auth-port"]) + rc = cli.main(["--config", str(config), "run"]) assert rc == 0 assert captured["image_dir"] == image_dir assert captured["agent"] == "pi" assert captured["mounts"] == [".:/workspace"] assert captured["attach"] is True + assert captured["run_user"] == expected_run_user + if manifest_run_user is not None and not configured_run_user: + assert captured["args"].run_user == "agent" + assert "copy_host_credentials=true" not in capsys.readouterr().err def test_run_user_from_config_starts_then_attaches_as_user( @@ -616,9 +676,9 @@ def fake_attach(vm_id: str, launch_command: str, **kwargs: object) -> int: assert captured["preset_closed"] is True assert captured["prepare"] == ("vm1", "agent") assert captured["attach"] == ("vm1", "agent", "pi", None) - assert capfd.readouterr().out == ( - "Started 'vm1'. Launching pi as user agent...\nsandbox stop vm1\n" - ) + out = capfd.readouterr().out + assert "Created sandbox 'vm1'." in out + assert "sandbox stop vm1" in out def test_host_git_config_copies_only_safe_global_values( @@ -675,11 +735,12 @@ def test_git_config_defaults_on_for_managed_run( assert captured["git"] == ("vm1", None, "[user]\n\tname = Test\n") -def test_no_git_config_disables_forwarding( +def test_config_disables_git_forwarding( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: install_fake_smolvm(monkeypatch, tmp_path) + (tmp_path / ".sbx.toml").write_text("[sbx]\ngit_config = false\n", encoding="utf-8") captured: dict[str, object] = {} monkeypatch.setattr( guest_setup, "host_git_config", lambda project_root=None: "[user]\n\tname = Test\n" @@ -694,10 +755,35 @@ def test_no_git_config_disables_forwarding( capture_preset(monkeypatch, captured) - assert cli.main(["run", "--no-git-config"]) == 0 + assert cli.main(["run"]) == 0 assert captured["git"] == ("vm1", None, None) +def test_shell_uses_configured_git_forwarding_policy( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + config = tmp_path / "config.toml" + config.write_text( + '[sbx]\nname = "vm1"\nrun_user = "agent"\ngit_config = false\n', encoding="utf-8" + ) + captured: dict[str, object] = {} + monkeypatch.setattr( + guest_setup, "host_git_config", lambda project_root=None: "must not be read" + ) + monkeypatch.setattr( + guest_setup, + "install_git_config", + lambda vm_id, user, text, **kwargs: captured.update({"git": text}), + ) + monkeypatch.setattr(guest_setup, "prepare_run_user", lambda *args, **kwargs: None) + monkeypatch.setattr(guest_setup, "attach", lambda *args, **kwargs: 0) + monkeypatch.setattr(cli, "_get_existing_vm_status", lambda vm_id: "running") + + assert cli.main(["--config", str(config), "shell", "--keep-running"]) == 0 + assert captured["git"] is None + + def test_credential_free_env_preserves_real_smolvm_state( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -728,7 +814,7 @@ def fake_credential_free_env(temp_home: Path, *, forward_env: list[str]) -> dict monkeypatch.setattr(guest_setup, "credential_free_env", fake_credential_free_env) capture_preset(monkeypatch, captured, vm_id="pi-sbx") - rc = cli.main(["run", "--no-attach", "--no-auth-port"]) + rc = cli.main(["run", "--no-attach"]) assert rc == 0 _, preset_kwargs = captured["preset"] @@ -744,12 +830,13 @@ def test_env_vars_are_not_forwarded_by_default_with_host_credentials( tmp_path: Path, ) -> None: install_fake_smolvm(monkeypatch, tmp_path) + (tmp_path / ".sbx.toml").write_text("[sbx]\ncopy_host_credentials = true\n", encoding="utf-8") monkeypatch.setenv("OPENAI_API_KEY", "secret") captured: dict[str, object] = {} capture_preset(monkeypatch, captured, vm_id="pi-sbx") - rc = cli.main(["run", "--no-attach", "--copy-host-credentials", "--no-auth-port"]) + rc = cli.main(["run", "--no-attach"]) assert rc == 0 _, preset_kwargs = captured["preset"] @@ -761,32 +848,26 @@ def test_env_flag_explicitly_forwards_selected_env_var( tmp_path: Path, ) -> None: install_fake_smolvm(monkeypatch, tmp_path) + (tmp_path / ".sbx.toml").write_text("[sbx]\ncopy_host_credentials = true\n", encoding="utf-8") monkeypatch.setenv("OPENAI_API_KEY", "secret") captured: dict[str, object] = {} capture_preset(monkeypatch, captured, vm_id="pi-sbx") - rc = cli.main( - [ - "run", - "--no-attach", - "--copy-host-credentials", - "--env", - "OPENAI_API_KEY", - "--no-auth-port", - ] - ) + rc = cli.main(["run", "--no-attach", "--env", "OPENAI_API_KEY"]) assert rc == 0 _, preset_kwargs = captured["preset"] assert preset_kwargs["host_env"]["OPENAI_API_KEY"] == "secret" -def test_copy_host_credentials_flag_uses_current_environment( +def test_copy_host_credentials_config_uses_current_environment( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + capsys: pytest.CaptureFixture[str], ) -> None: install_fake_smolvm(monkeypatch, tmp_path) + (tmp_path / ".sbx.toml").write_text("[sbx]\ncopy_host_credentials = true\n", encoding="utf-8") captured: dict[str, object] = {} def fail_credential_free_env(temp_home: Path, *, forward_env: list[str]) -> dict[str, str]: @@ -795,11 +876,27 @@ def fail_credential_free_env(temp_home: Path, *, forward_env: list[str]) -> dict monkeypatch.setattr(guest_setup, "credential_free_env", fail_credential_free_env) capture_preset(monkeypatch, captured, vm_id="pi-sbx") - rc = cli.main(["run", "--no-attach", "--copy-host-credentials", "--no-auth-port"]) + rc = cli.main(["run", "--no-attach"]) assert rc == 0 _, preset_kwargs = captured["preset"] assert preset_kwargs["host_env"]["HOME"] == os.environ["HOME"] + assert "copy_host_credentials=true" in capsys.readouterr().err + + +def test_existing_vm_does_not_warn_about_credential_copy( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + (tmp_path / ".sbx.toml").write_text( + '[sbx]\nname = "vm1"\ncopy_host_credentials = true\n', encoding="utf-8" + ) + monkeypatch.setattr(cli, "_get_existing_vm_status", lambda name: "running") + monkeypatch.setattr(cli, "_post_start_actions", lambda **kwargs: 0) + + assert cli.main(["run", "--no-attach"]) == 0 + assert "copy_host_credentials=true" not in capsys.readouterr().err def test_destroy_deletes_vm( @@ -825,7 +922,6 @@ def test_create_auto_writes_project_config_for_new_vm( rc = cli.main( [ "create", - "--name", "vm1", "--agent", "codex", @@ -851,7 +947,12 @@ def test_create_auto_writes_project_config_for_new_vm( assert 'project_path = "."' in text assert 'run_user = "agent"' in text assert 'env = ["OPENAI_API_KEY"]' in text - assert "wrote .sbx.toml" in capfd.readouterr().err + assert "copy_host_credentials = false" in text + assert "git_config = true" in text + output = capfd.readouterr() + assert "Created sandbox 'vm1'." in output.out + assert "Wrote .sbx.toml." in output.out + assert "Run agent: sbx run" in output.out def test_project_config_values_preserve_curated_image_workflow() -> None: @@ -889,9 +990,7 @@ def test_write_config_updates_only_missing_values( (tmp_path / ".sbx.toml").write_text('[sbx]\nname = "vm1"\nmemory = 4096\n', encoding="utf-8") install_fake_smolvm(monkeypatch, tmp_path) - rc = cli.main( - ["create", "--name", "vm2", "--memory", "8192", "--disk-size", "40960", "--write-config"] - ) + rc = cli.main(["create", "vm2", "--memory", "8192", "--disk-size", "40960", "--write-config"]) assert rc == 0 text = (tmp_path / ".sbx.toml").read_text(encoding="utf-8") @@ -928,26 +1027,14 @@ def test_reusing_existing_vm_writes_config_only_when_requested( monkeypatch.setattr(cli.runtime, "smolvm_argv", lambda args: ["smolvm", *args]) monkeypatch.setattr(guest_setup, "sync_guest_clock", lambda vm_id, **kwargs: None) - assert cli.main(["run", "vm1", "--no-attach", "--no-auth-port"]) == 0 + assert cli.main(["run", "vm1", "--no-attach"]) == 0 assert not (tmp_path / ".sbx.toml").exists() - assert cli.main(["run", "vm1", "--no-attach", "--no-auth-port", "--write-config"]) == 0 + assert cli.main(["run", "vm1", "--no-attach", "--write-config"]) == 0 assert 'name = "vm1"' in (tmp_path / ".sbx.toml").read_text(encoding="utf-8") assert "wrote .sbx.toml" in capfd.readouterr().err -def test_no_write_config_disables_auto_write( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - install_fake_smolvm(monkeypatch, tmp_path) - - rc = cli.main(["create", "--name", "vm1", "--no-write-config"]) - - assert rc == 0 - assert not (tmp_path / ".sbx.toml").exists() - - def test_lifecycle_commands_default_to_configured_name( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -979,16 +1066,9 @@ def fake_attach(vm_id: str, launch_command: str, **kwargs: object) -> int: return 0 monkeypatch.setattr(guest_setup, "attach", fake_attach) + monkeypatch.setattr(cli.network, "expose_auth_port", lambda *args: 0) - rc = cli.main( - [ - "run", - "--copy-host-credentials", - "--no-auth-port", - "--project-path", - str(project), - ] - ) + rc = cli.main(["run", "--project-path", str(project)]) assert rc == 0 _, preset_kwargs = captured["preset"] @@ -997,11 +1077,15 @@ def fake_attach(vm_id: str, launch_command: str, **kwargs: object) -> int: assert captured["attach"] == ("vm1", "pi", str(project)) -def test_run_exposes_auth_port_by_default_before_attach( +def test_run_uses_configured_auth_ports_before_attach( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: install_fake_smolvm(monkeypatch, tmp_path) + (tmp_path / ".sbx.toml").write_text( + "[sbx]\nauth_port = true\nauth_host_port = 1555\nauth_guest_port = 1666\n", + encoding="utf-8", + ) captured: dict[str, object] = {} capture_preset(monkeypatch, captured) @@ -1016,9 +1100,9 @@ def fake_attach(vm_id: str, launch_command: str, **kwargs: object) -> int: monkeypatch.setattr(cli.network, "expose_auth_port", fake_expose) monkeypatch.setattr(guest_setup, "attach", fake_attach) - assert cli.main(["run", "--copy-host-credentials"]) == 0 + assert cli.main(["run"]) == 0 assert captured["preset_closed"] is True - assert captured["expose"] == ("vm1", 1455, 1455) + assert captured["expose"] == ("vm1", 1555, 1666) assert captured["attach"] == ("vm1", "pi", None) @@ -1027,7 +1111,14 @@ def test_run_existing_vm_starts_without_creating( tmp_path: Path, ) -> None: install_fake_smolvm(monkeypatch, tmp_path) + image = tmp_path / "image" + image.mkdir() + (image / "smolvm-image.json").write_text('{"sbx":{"run_user":"agent"}}', encoding="utf-8") + (tmp_path / ".sbx.toml").write_text( + f'[sbx]\nname = "vm1"\nimage = "{image}"\n', encoding="utf-8" + ) calls: list[list[str]] = [] + attached: dict[str, object] = {} def fake_run_capture( argv: list[str], *, env: dict[str, str] | None = None @@ -1057,12 +1148,18 @@ def fake_run(argv: list[str], *, check: bool = False, env: dict[str, str] | None ), ) monkeypatch.setattr(guest_setup, "sync_guest_clock", lambda vm_id, **kwargs: None) + monkeypatch.setattr(guest_setup, "prepare_run_user", lambda *args, **kwargs: None) monkeypatch.setattr(cli.network, "expose_auth_port", lambda vm_id, host_port, guest_port: 0) - monkeypatch.setattr(guest_setup, "attach", lambda *args, **kwargs: 0) + monkeypatch.setattr( + guest_setup, + "attach", + lambda *args, **kwargs: attached.update({"user": kwargs.get("user")}) or 0, + ) - rc = cli.main(["run", "--name", "vm1"]) + rc = cli.main(["run"]) assert rc == 0 + assert attached["user"] == "agent" assert calls == [ ["smolvm", "sandbox", "info", "vm1", "--json"], ["smolvm", "sandbox", "start", "vm1", "--boot-timeout", "60"], @@ -1092,7 +1189,7 @@ def fake_run_capture( monkeypatch.setattr(cli.runtime, "run_capture", fake_run_capture) - rc = cli.main(["run", "--name", "vm1"]) + rc = cli.main(["run", "vm1"]) assert rc == 1 assert "sbx recreate vm1 --force" in capsys.readouterr().err @@ -1113,7 +1210,7 @@ def test_failed_managed_run_hides_json_and_prints_hint( ), ) - rc = cli.main(["run", "--name", "vm1"]) + rc = cli.main(["run", "vm1"]) output = capsys.readouterr() assert rc == 1 @@ -1146,7 +1243,7 @@ def fake_credential_free_env(temp_home: Path, *, forward_env: list[str]) -> dict lambda *args, **kwargs: (_ for _ in ()).throw(error), ) - assert cli.main(["create", "vm1", "--no-auth-port"]) == expected_rc + assert cli.main(["create", "vm1"]) == expected_rc assert not captured["temp_home"].exists() @@ -1175,10 +1272,9 @@ def test_run_missing_vm_creates_it( capture_preset(monkeypatch, captured) monkeypatch.setattr(cli.network, "expose_auth_port", lambda *args: 0) - assert cli.main(["run", "--name", "vm1", "--copy-host-credentials"]) == 0 + assert cli.main(["run", "vm1", "--no-attach"]) == 0 _, preset_kwargs = captured["preset"] assert preset_kwargs["vm_name"] == "vm1" - assert preset_kwargs["host_env"]["HOME"] == os.environ["HOME"] def test_create_is_run_no_attach_alias( @@ -1194,26 +1290,63 @@ def test_create_is_run_no_attach_alias( lambda **kwargs: captured.setdefault("post", kwargs) and 0, ) - assert ( - cli.main(["create", "--name", "vm1", "--copy-host-credentials", "--no-auth-port"]) - == 0 - ) + assert cli.main(["create", "vm1"]) == 0 assert captured["post"]["attach"] is False -def test_preset_creation_json_is_sbx_owned( +def test_run_json_requires_no_attach(capsys: pytest.CaptureFixture[str]) -> None: + assert cli.main(["run", "vm1", "--json"]) == 2 + assert "requires --no-attach" in capsys.readouterr().err + + +def test_existing_vm_json_suppresses_start_output( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr(cli, "_get_existing_vm_status", lambda name: "stopped") + monkeypatch.setattr(cli, "_sync_existing_vm_start_config", lambda *args, **kwargs: None) + monkeypatch.setattr( + cli.runtime, + "run_smolvm_capture", + lambda argv: subprocess.CompletedProcess( + argv, 0, stdout="Started upstream VM\n", stderr="" + ), + ) + monkeypatch.setattr(cli, "_post_start_actions", lambda **kwargs: 0) + + assert cli.main(["create", "vm1", "--json"]) == 0 + assert json.loads(capsys.readouterr().out) == {"vm": {"name": "vm1", "status": "running"}} + + +@pytest.mark.parametrize( + "argv", + [ + ["create", "vm1", "--json"], + ["run", "vm1", "--no-attach", "--json"], + ], +) +def test_preset_lifecycle_json( + argv: list[str], monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str], ) -> None: install_fake_smolvm(monkeypatch, tmp_path) - captured: dict[str, object] = {} - capture_preset(monkeypatch, captured) - assert cli.main(["create", "vm1", "--json", "--no-auth-port"]) == 0 - assert json.loads(capsys.readouterr().out) == { - "vm": {"name": "vm1", "status": "running"} - } + assert cli.main(argv) == 0 + assert json.loads(capsys.readouterr().out) == {"vm": {"name": "vm1", "status": "running"}} + + +def test_recreate_json_suppresses_delete_output( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + install_fake_smolvm(monkeypatch, tmp_path) + monkeypatch.setattr(cli, "_delete_vm", lambda name, *, quiet=False: 0) + + assert cli.main(["recreate", "vm1", "--force", "--json"]) == 0 + assert json.loads(capsys.readouterr().out) == {"vm": {"name": "vm1", "status": "running"}} def test_expose_auth_port_warns_when_port_already_listening( @@ -1322,6 +1455,13 @@ def fake_forward(vm_id: str, forwards: list[tuple[str, int, int]]) -> int: } +def test_network_forward_requires_name_or_project_config( + capsys: pytest.CaptureFixture[str], +) -> None: + assert cli.main(["network", "forward", "3000"]) == 2 + assert "requires a VM name" in capsys.readouterr().err + + def test_network_forward_accepts_explicit_name(monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, object] = {} monkeypatch.setattr( @@ -1330,9 +1470,9 @@ def test_network_forward_accepts_explicit_name(monkeypatch: pytest.MonkeyPatch) lambda vm_id, forwards: captured.update({"vm_id": vm_id, "forwards": forwards}) or 0, ) - assert cli.main(["network", "forward", "vm2", "0.0.0.0:3000:3000"]) == 0 + assert cli.main(["network", "forward", "--name", "3000", "0.0.0.0:3000:3000"]) == 0 assert captured == { - "vm_id": "vm2", + "vm_id": "3000", "forwards": [("0.0.0.0", 3000, 3000)], } @@ -1367,7 +1507,7 @@ def test_network_forward_accepts_explicit_name_with_multiple_specs( lambda vm_id, forwards: captured.update({"vm_id": vm_id, "forwards": forwards}) or 0, ) - assert cli.main(["network", "forward", "vm2", "3000", "8080:80"]) == 0 + assert cli.main(["network", "forward", "--name", "vm2", "3000", "8080:80"]) == 0 assert captured == { "vm_id": "vm2", "forwards": [("127.0.0.1", 3000, 3000), ("127.0.0.1", 8080, 80)], @@ -1380,7 +1520,7 @@ def interrupted(vm_id: str, forwards: list[tuple[str, int, int]]) -> int: monkeypatch.setattr(cli.network, "_foreground_port_forward", interrupted) - assert cli.main(["network", "forward", "vm2", "3005"]) == 130 + assert cli.main(["network", "forward", "--name", "vm2", "3005"]) == 130 def test_network_commands_default_to_configured_name( @@ -1429,6 +1569,34 @@ def fake_run_capture(argv: list[str], **kwargs: object) -> subprocess.CompletedP assert "No tracked auth port tunnel for 'vm1'." in capfd.readouterr().out +def test_network_status_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + payload = ( + '{"data":{"vm":{"name":"vm1","status":"running","backend":"qemu",' + '"ip_address":"10.0.2.15","ssh_port":2201}}}' + ) + monkeypatch.setattr( + cli.network, + "run_smolvm_capture", + lambda argv, **kwargs: subprocess.CompletedProcess(argv, 0, stdout=payload), + ) + monkeypatch.setattr(cli.network, "_tracked_auth_tunnel", lambda vm_id: None) + monkeypatch.setattr(cli.network, "_localhost_port_is_listening", lambda port: False) + + assert cli.main(["network", "status", "vm1", "--json"]) == 0 + assert json.loads(capsys.readouterr().out) == { + "name": "vm1", + "status": "running", + "backend": "qemu", + "guest_ip": "10.0.2.15", + "ssh_port": 2201, + "port_forwards": [], + "auth_callback": {"status": "inactive", "detail": None}, + } + + def test_network_close_auth_port_without_tracked_tunnel( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -1471,17 +1639,7 @@ def fake_delete(vm_id: str, extra_args: list[str] | None = None) -> int: monkeypatch.setattr(cli, "_delete_vm", fake_delete) capture_preset(monkeypatch, captured) - rc = cli.main( - [ - "recreate", - "--force", - "--name", - "vm1", - "--no-attach", - "--copy-host-credentials", - "--no-auth-port", - ] - ) + rc = cli.main(["recreate", "vm1", "--force", "--no-attach"]) assert rc == 0 assert calls == [("delete", "vm1")] @@ -1518,7 +1676,6 @@ def test_cli_project_path_overrides_config( "run", "--project-path", str(cli_project), - "--no-auth-port", "--no-attach", ] ) @@ -1527,7 +1684,7 @@ def test_cli_project_path_overrides_config( _, preset_kwargs = captured["preset"] assert preset_kwargs["mounts"] == [f"{cli_project}:{cli_project}"] assert preset_kwargs["writable_mounts"] is True - assert capfd.readouterr().out == "Started 'pi-sbx'.\n" + assert "Created sandbox 'pi-sbx'." in capfd.readouterr().out def test_cli_flags_override_config( @@ -1550,24 +1707,13 @@ def test_cli_flags_override_config( capture_preset(monkeypatch, captured, vm_id="from-cli") monkeypatch.setattr(cli, "_post_start_actions", lambda **kwargs: 0) - rc = cli.main( - [ - "--config", - str(config), - "run", - "--agent", - "claude", - "--name", - "from-cli", - "--no-auth-port", - ] - ) + rc = cli.main(["--config", str(config), "run", "from-cli", "--agent", "claude"]) assert rc == 0 preset_name, preset_kwargs = captured["preset"] assert preset_name == "claude" assert preset_kwargs["vm_name"] == "from-cli" - assert capfd.readouterr().out == "Started 'from-cli'. Launching claude...\n" + assert "Created sandbox 'from-cli'." in capfd.readouterr().out def test_invalid_agent_in_config_returns_usage_error( @@ -1610,7 +1756,7 @@ def test_image_build_debian_subcommand_is_removed() -> None: assert error.value.code == 2 -def test_image_ls_lists_local_images( +def test_image_list_lists_local_images( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.setenv("HOME", str(tmp_path)) @@ -1631,7 +1777,7 @@ def test_image_ls_lists_local_images( ) (invalid_image / "smolvm-image.json").write_text("not json", encoding="utf-8") - assert cli.main(["image", "ls"]) == 0 + assert cli.main(["image", "list"]) == 0 out = capsys.readouterr().out assert "NAME" in out @@ -1712,5 +1858,5 @@ def test_existing_vm_start_does_not_reset_hostname(monkeypatch: pytest.MonkeyPat monkeypatch.setattr(cli, "_start_existing_vm_if_needed", lambda *args, **kwargs: 0) monkeypatch.setattr(cli, "_post_start_actions", lambda **kwargs: 0) - assert cli.main(["run", "vm1", "--no-attach", "--no-auth-port"]) == 0 + assert cli.main(["run", "vm1", "--no-attach"]) == 0 assert hostnames == [] diff --git a/tests/test_cli_extra.py b/tests/test_cli_extra.py index 3026a83..b4487dd 100644 --- a/tests/test_cli_extra.py +++ b/tests/test_cli_extra.py @@ -588,6 +588,13 @@ def close(self) -> None: assert closed == [True] +def test_manifest_run_user() -> None: + assert cli._manifest_run_user({}) is None + assert cli._manifest_run_user({"sbx": {"run_user": "agent"}}) == "agent" + with pytest.raises(cli.ConfigError, match="run_user.*string"): + cli._manifest_run_user({"sbx": {"run_user": 1000}}) + + @pytest.mark.parametrize( ("manifest", "message"), [ @@ -1599,7 +1606,11 @@ def test_recreate_success_deletes_then_starts(monkeypatch: pytest.MonkeyPatch) - def test_start_existing_vm_if_needed_variants(monkeypatch: pytest.MonkeyPatch) -> None: calls: list[list[str]] = [] - monkeypatch.setattr(cli.runtime, "run_smolvm", lambda argv: calls.append(list(argv)) or 0) + monkeypatch.setattr( + cli.runtime, + "run_smolvm_capture", + lambda argv: subprocess.CompletedProcess(calls.append(list(argv)) or argv, 0, "", ""), + ) assert cli._start_existing_vm_if_needed("vm1", "running", 60) == 0 assert calls == [] @@ -1628,24 +1639,15 @@ def test_mark_error_vm_stopped_for_restart_clears_stale_runtime_fields() -> None assert row == ("stopped", None, None) -@pytest.mark.parametrize( - "argv", - [ - ["run", "vm1", "--force-start", "--no-attach"], - ["shell", "--force-start", "--keep-running", "--no-git-config", "vm1"], - ], -) -def test_force_start_is_rejected(argv: list[str]) -> None: - with pytest.raises(SystemExit) as exc: - cli.main(argv) - assert exc.value.code == 2 - - def test_start_existing_vm_timeout_hint_when_vm_is_running( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - monkeypatch.setattr(cli.runtime, "run", lambda argv, **kwargs: 1) + monkeypatch.setattr( + cli.runtime, + "run_smolvm_capture", + lambda argv: subprocess.CompletedProcess(argv, 1, "", ""), + ) monkeypatch.setattr(cli, "_get_existing_vm_status", lambda vm_id: "running") assert cli._start_existing_vm_if_needed("vm1", "stopped", 60) == 1 diff --git a/tests/test_completion.py b/tests/test_completion.py index 7f23600..26de259 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -38,6 +38,9 @@ def test_completion_scripts_include_all_command_option_groups() -> None: "guest-port", "replace", "rootfs-size-mb", + "running", + "json", + "name", ): assert value in script assert "build-debian" not in script