diff --git a/README.md b/README.md index da212c3..74f2ee5 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,25 @@ Keep your friends close, your supply chain in a VM. -DVM, short for dev VM, is a small open source command-line wrapper around Lima for -Fedora project VMs. +DVM, short for dev VM, is a small Bash wrapper around Lima for Fedora project VMs. It +keeps project code off the host, creates per-VM SSH keys, supports per-VM GPG signing +subkeys, syncs opt-in host dotfiles as snapshots, and provides an isolated `dvm-agent` +user for hosted AI coding tools. -The repository contains the reusable core. User-specific VM behavior lives outside the -repo in `~/.config/dvm`, so local configuration can be kept in a separate dotfiles or -project configuration repository. - -DVM is intentionally small. It creates similar Lima VMs, reruns user setup scripts, -manages per-VM SSH keys, helps with GPG signing subkeys, and refuses to delete a VM -when repositories have uncommitted changes. - -## Install - -Requirements: +## Requirements - macOS - Bash - Lima - GPG for `dvm gpg ...` +Guest support target: + +- Lima `template:fedora` +- `dnf5` available in the guest + +## Install + For stable installations, use a signed release tag: ```bash @@ -33,18 +32,12 @@ git checkout --detach vX.Y.Z ./install.sh --init ``` -Replace `vX.Y.Z` with the release version to install. The `main` branch is for -development and testing. - This symlinks: ```text ~/.local/bin/dvm -> /bin/dvm ``` -The scripts are written in Bash (`#!/usr/bin/env bash`). This is independent of your -interactive shell; using Zsh as the default macOS shell is fine. - Update the core by moving to a newer signed release tag: ```bash @@ -57,50 +50,30 @@ git checkout --detach vX.Y.Z Development checkouts may track `main` and update with `git pull --ff-only`. -## Config +## Quick Start -`dvm init` creates: - -```text -~/.config/dvm/config.sh -~/.config/dvm/setup.d/fedora.sh -~/.local/share/dvm/ +```bash +dvm init +dvm new myapp +dvm myapp +git clone git@github.com:example/myapp.git ~/code/myapp ``` -User configuration is shell code by design and is the extension point. - -Common config: +Rerun setup in one VM or every VM: ```bash -DVM_PREFIX="dvm" -DVM_CPUS="4" -DVM_MEMORY="8GiB" -DVM_DISK="80GiB" - -DVM_PACKAGES="git openssh-clients gpg helix ripgrep fd-find jq" -DVM_SETUP_SCRIPTS="$DVM_CONFIG/setup.d/fedora.sh" -DVM_DOTFILES_DIR="$HOME/.dotfiles" +dvm setup myapp +dvm setup-all ``` -Put package-independent setup, shell config, and tool config in `setup.d/fedora.sh`. -If `DVM_DOTFILES_DIR` is set, DVM copies a snapshot of that host directory into the VM -before user setup scripts run. It does not mount the host directory live. User setup -scripts run inside the VM as the guest user with: +Run hosted AI tools through the restricted agent user: -```text -DVM_NAME -DVM_VM_NAME -DVM_CODE_DIR -DVM_DOTFILES_TARGET +```bash +dvm agent setup myapp +dvm agent install myapp codex +dvm agent myapp -- codex ``` -Dotfiles sync is opt-in. By default DVM excludes `.git`, `.ssh`, `.gnupg`, `.env`, and -`secrets`, refuses dangerous source paths such as `/`, `$HOME`, `~/.ssh`, and -`~/.gnupg`, and keeps the target under the guest home directory. - -The default workflow keeps source code inside the VM under `~/code`. No host project -directory is mounted. - ## Commands ```text @@ -113,165 +86,38 @@ dvm ssh [command...] dvm key dvm list dvm rm [--force] -dvm gpg create [--expire 1y] -dvm gpg install [secret-subkey.asc] [--signing-key fpr] -dvm gpg revoke +dvm ai create|setup|pull|models|use|status|host ... +dvm agent setup|install| ... +dvm gpg create|install|revoke ... dvm doctor dvm completion zsh ``` `dvm ` is a shortcut for `dvm enter `. -## Create A VM +## Docs -```bash -dvm new myapp -``` - -This creates `dvm-myapp`, starts it, runs core setup, runs user setup scripts, creates -`~/.ssh/id_ed25519_myapp` inside the VM, and prints the public key. - -Enter it: - -```bash -dvm myapp -git clone git@github.com:example/myapp.git ~/code/myapp -``` - -Rerun setup in one or all VMs: - -```bash -dvm setup myapp -dvm setup-all -``` - -This is the intended way to add packages everywhere or refresh dotfiles snapshots. The -script does not try to remove packages automatically; removals should be explicit and -manual. - -## Delete Safety - -```bash -dvm rm myapp -``` - -Before deleting, DVM searches Git repositories under `~/code` in the VM. If any repo -has unstaged changes, staged changes, or untracked files, deletion is refused. - -Force deletion: - -```bash -dvm rm myapp --force -``` - -If a GPG signing subkey was created for the VM, `rm` prints the recorded subkey -fingerprint and the revoke command. Deleting a VM does not revoke GPG keys -automatically. - -## GPG - -Create a signing subkey on the host and export a secret-subkey bundle: - -```bash -dvm gpg create myapp --expire 1y -``` - -Files are written under: - -```text -~/.local/share/dvm/gpg/ -``` - -Install the subkey into the VM and configure Git commit signing: - -```bash -dvm gpg install myapp -``` - -Revoke the VM subkey on the host: - -```bash -dvm gpg revoke myapp -``` - -Revocation only updates the local GPG keyring and exports the updated public key. It -does not update GitHub/GitLab, remove old public keys from remote services, delete the -secret bundle from disk, or change anything inside an already-deleted VM. Upload the -updated public key wherever the old public key was trusted. Depending on the local GPG -setup, revoke/create commands may open pinentry. - -## Doctor - -Check local requirements and paths: - -```bash -dvm doctor -``` - -## Completion - -Zsh: - -```bash -source <(dvm completion zsh) -``` - -Add that line to a shell startup file to enable completion automatically. +- [Docs index](docs/README.md) +- [VM lifecycle](docs/vms.md) +- [Config and dotfiles](docs/config.md) +- [GPG signing subkeys](docs/gpg.md) +- [Local llama.cpp AI VM](docs/ai-vm.md) +- [Hosted AI tools through `dvm agent`](docs/ai-tools.md) +- [Security policy and model](SECURITY.md) +- [Contributing](CONTRIBUTING.md) +- [Maintainer release process](docs/release.md) +- [GitHub security settings](docs/github-security.md) ## Security -Read [SECURITY.md](SECURITY.md) before installing DVM for stable use. The short -version: +Read [SECURITY.md](SECURITY.md) before installing DVM for stable use. Short version: - install and update from signed release tags - keep `main` for development and testing - do not run remote install scripts directly -- keep VM setup scripts in user-controlled config or dotfiles -- review setup scripts before running `dvm setup` or `dvm setup-all` - -Repository maintainer settings are documented in -[docs/github-security.md](docs/github-security.md). +- keep setup scripts in user-controlled config or dotfiles +- run hosted AI tools through `dvm agent`, not the normal VM user ## License DVM is released under the [MIT License](LICENSE). - -## Contributing - -Contributions are welcome when they keep the project small, auditable, and focused on -VM lifecycle, SSH, GPG, and user-controlled setup. See -[CONTRIBUTING.md](CONTRIBUTING.md). - -## Maintainer Release Process - -Maintainer checklist: - -```bash -bash scripts/check.sh -git tag -s vX.Y.Z -m "dvm vX.Y.Z" -git push origin main -git push origin vX.Y.Z -``` - -Create the GitHub release from the signed `v*` tag. Published releases and tags should -not be moved or replaced; publish a new fixed release instead. - -## LLaMA VM - -llama.cpp can be configured as a normal named VM: - -```bash -dvm new ai -``` - -Place llama-specific package and service setup in `setup.d/fedora.sh` behind a name -check: - -```bash -if [ "$DVM_NAME" = "ai" ]; then - sudo dnf5 install -y llama-cpp - # configure a systemd service here -fi -``` - -This keeps the core small while still making the AI VM reproducible. diff --git a/SECURITY.md b/SECURITY.md index 7b4ca6c..ce7c481 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,9 +23,10 @@ Useful report details: ## Security Model DVM is a small wrapper around Lima. It helps isolate project work into separate Fedora -VMs and keeps user-controlled setup outside the core repository. It is not a sandbox -that can provide stronger guarantees than Lima, QEMU, macOS virtualization, SSH, GPG, -or the packages and scripts that users choose to run. +VMs and keeps user-controlled setup outside the core repository. The core targets Lima +`template:fedora` and assumes `dnf5` in the guest. It is not a sandbox that can +provide stronger guarantees than Lima, QEMU, macOS virtualization, SSH, GPG, or the +packages and scripts that users choose to run. Security-sensitive behavior in scope: @@ -43,6 +44,11 @@ DVM does not mount host dotfiles into VMs by default. If dotfiles sync is enable copies a filtered snapshot during setup so project code in the VM does not retain a persistent read path back to the host. +Hosted AI tools should run through `dvm agent`, which uses a separate VM user and +bubblewrap to hide the normal VM user's home while exposing project code. This limits +access to per-VM SSH keys, GPG subkeys, dotfiles, and secret-manager config, but it +does not make AI-executed project code safe. + ## Safe Installation Install from a signed release tag, not from an arbitrary branch. Before running diff --git a/bin/dvm b/bin/dvm index 501b4fb..9298568 100755 --- a/bin/dvm +++ b/bin/dvm @@ -26,6 +26,10 @@ source "$DVM_CORE/lib/config.sh" # shellcheck disable=SC1091 source "$DVM_CORE/lib/vm.sh" # shellcheck disable=SC1091 +source "$DVM_CORE/lib/ai.sh" +# shellcheck disable=SC1091 +source "$DVM_CORE/lib/agent.sh" +# shellcheck disable=SC1091 source "$DVM_CORE/lib/gpg.sh" # shellcheck disable=SC1091 source "$DVM_CORE/lib/doctor.sh" @@ -44,6 +48,8 @@ usage: dvm key dvm list dvm rm [--force] + dvm ai create|setup|pull|models|use|status|host ... + dvm agent setup|install| ... dvm gpg create|install|revoke ... dvm doctor dvm completion zsh @@ -67,6 +73,8 @@ dvm_main() { key) dvm_key "$@" ;; list | ls) dvm_list_names "$@" ;; rm | delete) dvm_rm "$@" ;; + ai) dvm_ai_cmd "$@" ;; + agent) dvm_agent_cmd "$@" ;; gpg) dvm_gpg_cmd "$@" ;; doctor) dvm_doctor "$@" ;; completion) dvm_completion "$@" ;; diff --git a/defaults/config.sh b/defaults/config.sh index 6897961..529256d 100644 --- a/defaults/config.sh +++ b/defaults/config.sh @@ -29,4 +29,23 @@ DVM_DOTFILES_DIR="${DVM_DOTFILES_DIR:-}" DVM_DOTFILES_TARGET="${DVM_DOTFILES_TARGET:-$DVM_GUEST_HOME/.dotfiles}" DVM_DOTFILES_EXCLUDES="${DVM_DOTFILES_EXCLUDES:-.git .ssh .gnupg .env secrets}" +# Optional llama.cpp VM helper config. `dvm ai create` uses these values. +DVM_AI_NAME="${DVM_AI_NAME:-ai}" +DVM_AI_PACKAGES="${DVM_AI_PACKAGES:-llama-cpp curl}" +DVM_AI_SERVER_CMD="${DVM_AI_SERVER_CMD:-llama-server}" +DVM_AI_SERVICE_NAME="${DVM_AI_SERVICE_NAME:-dvm-llama.service}" +DVM_AI_HOST="${DVM_AI_HOST:-0.0.0.0}" +DVM_AI_PORT="${DVM_AI_PORT:-8080}" +DVM_AI_MODELS_DIR="${DVM_AI_MODELS_DIR:-$DVM_GUEST_HOME/models}" +DVM_AI_DEFAULT_MODEL="${DVM_AI_DEFAULT_MODEL:-qwen25-coder-7b-q4}" +# Space-separated alias=url entries. Aliases become model filenames in the VM. +DVM_AI_MODELS="${DVM_AI_MODELS:-qwen25-coder-7b-q4=https://huggingface.co/bartowski/Qwen2.5-Coder-7B-Instruct-GGUF/resolve/main/Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf?download=true}" +DVM_AI_EXTRA_ARGS="${DVM_AI_EXTRA_ARGS:-}" + +# Hosted AI tools should run through `dvm agent`, not the normal VM user. +DVM_AGENT_USER="${DVM_AGENT_USER:-dvm-agent}" +DVM_AGENT_HOME="${DVM_AGENT_HOME:-/home/$DVM_AGENT_USER}" +DVM_AGENT_PACKAGES="${DVM_AGENT_PACKAGES:-bubblewrap acl shadow-utils}" +DVM_AGENT_CLAUDE_CHANNEL="${DVM_AGENT_CLAUDE_CHANNEL:-stable}" + DVM_GPG_DIR="${DVM_GPG_DIR:-$DVM_STATE/gpg}" diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..3659e29 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,15 @@ +# DVM Docs + +- [VM lifecycle](vms.md) +- [Config and dotfiles](config.md) +- [GPG signing subkeys](gpg.md) +- [Local llama.cpp AI VM](ai-vm.md) +- [Hosted AI tools through `dvm agent`](ai-tools.md) +- [Maintainer release process](release.md) +- [GitHub security settings](github-security.md) + +Root-level docs: + +- [Project README](../README.md) +- [Security policy](../SECURITY.md) +- [Contributing](../CONTRIBUTING.md) diff --git a/docs/ai-tools.md b/docs/ai-tools.md new file mode 100644 index 0000000..0db8f7f --- /dev/null +++ b/docs/ai-tools.md @@ -0,0 +1,134 @@ +# AI Tool Setup + +Hosted AI coding tools should run through `dvm agent`, not from the normal VM user. +The normal VM user owns project SSH keys, GPG subkeys, dotfiles, and secret-manager +state. The agent user is separate and gets access to project code, system tools, and +its own home directory. + +Official docs: + +- Claude Code setup: https://code.claude.com/docs/en/setup +- Claude Code sandboxing: https://code.claude.com/docs/en/sandboxing +- Codex CLI setup: https://developers.openai.com/codex/cli +- Codex sandboxing: https://developers.openai.com/codex/concepts/sandboxing + +## Workflow + +Create a normal project VM: + +```bash +dvm new myapp +``` + +Set up the restricted agent user: + +```bash +dvm agent setup myapp +``` + +Install hosted AI tools for agent use: + +```bash +dvm agent install myapp claude +dvm agent install myapp codex +``` + +Run tools only through `dvm agent`: + +```bash +dvm agent myapp -- claude +dvm agent myapp -- codex +``` + +`dvm agent install myapp all` installs both Claude Code and Codex CLI. + +## What Agent Setup Does + +`dvm agent setup ` runs inside the VM and: + +- installs `bubblewrap`, `acl`, and `shadow-utils` +- creates `DVM_AGENT_USER`, defaulting to `dvm-agent` +- creates `DVM_AGENT_HOME`, defaulting to `/home/dvm-agent` +- grants the agent user access to `DVM_CODE_DIR` +- grants only traversal access to the normal VM user's home +- configures Git safe directory access for repositories under `DVM_CODE_DIR` + +When you run `dvm agent -- `, DVM starts the VM and runs the command as +`dvm-agent` through bubblewrap. The sandbox: + +- exposes system tools from the VM read-only +- exposes `DVM_AGENT_HOME` read/write +- exposes `DVM_CODE_DIR` read/write +- provides private writable `/tmp` and `/var/tmp` +- hides the normal VM user's home and binds `DVM_CODE_DIR` back into place +- does not unshare networking, so hosted AI tools and package managers still work + +This means the agent can run project commands such as: + +```bash +dvm agent myapp -- pnpm test +dvm agent myapp -- npm run lint +dvm agent myapp -- python -m pytest +dvm agent myapp -- bash -lc 'cd web && pnpm dev' +``` + +The agent can use packages installed in the VM, such as `node`, `pnpm`, `python`, +`gcc`, `ripgrep`, and project-local tools under `node_modules/.bin` or `.venv`. Caches +and tool auth live under `DVM_AGENT_HOME`, not the normal VM user's home. + +## Installing Tools + +Claude Code is installed from Anthropic's signed Fedora/RHEL package repository. The +default channel is `stable`; set `DVM_AGENT_CLAUDE_CHANNEL="latest"` in `config.sh` if +you want the rolling channel. + +```bash +dvm agent install myapp claude +``` + +Codex CLI is installed with npm into the agent user's `~/.local` prefix: + +```bash +dvm agent install myapp codex +``` + +Authentication happens inside the agent context: + +```bash +dvm agent myapp -- claude +dvm agent myapp -- codex +``` + +That stores Claude/Codex auth in `/home/dvm-agent`, scoped to that VM filesystem. + +## Secret Boundaries + +DVM protects the host by putting project work in a VM. `dvm agent` adds another boundary +inside that VM so hosted AI tools do not run as the user that owns SSH keys, GPG keys, +dotfiles, and secret-manager config. + +Use the normal shell for human operations that need VM credentials: + +```bash +dvm myapp +git pull +git commit -S +``` + +Use the agent shell for AI operations: + +```bash +dvm agent myapp -- codex +``` + +If a test suite needs production secrets, the agent should not receive those secrets by +default. Prefer local test credentials, mocks, or a future explicit broker that can +prompt and enforce policy for each secret operation. + +## Limits + +`dvm agent` is a practical isolation layer, not a perfect sandbox. The agent can still +read and modify project code, run networked commands, consume API credits, and execute +any system tool available in the VM. It should not be given access to host mounts, +shared auth directories, SSH agent sockets, GPG agent sockets, or secret-manager config +unless you are intentionally weakening the boundary. diff --git a/docs/ai-vm.md b/docs/ai-vm.md new file mode 100644 index 0000000..cb53eaf --- /dev/null +++ b/docs/ai-vm.md @@ -0,0 +1,85 @@ +# Local llama.cpp AI VM + +`dvm ai` manages an opinionated llama.cpp VM. It still uses a normal DVM VM under the +hood, named `ai` by default, but adds package install, model download, model switching, +and a managed `llama-server` systemd service. + +Hosted AI coding tools such as Claude Code and Codex CLI are separate. Run those +through [`dvm agent`](ai-tools.md). + +## Config + +Defaults: + +```bash +DVM_AI_NAME="ai" +DVM_AI_PORT="8080" +DVM_AI_DEFAULT_MODEL="qwen25-coder-7b-q4" +DVM_AI_MODELS="qwen25-coder-7b-q4=https://huggingface.co/bartowski/Qwen2.5-Coder-7B-Instruct-GGUF/resolve/main/Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf?download=true" +``` + +Other useful knobs: + +```bash +DVM_AI_PACKAGES="llama-cpp curl" +DVM_AI_SERVER_CMD="llama-server" +DVM_AI_SERVICE_NAME="dvm-llama.service" +DVM_AI_HOST="0.0.0.0" +DVM_AI_MODELS_DIR="$DVM_GUEST_HOME/models" +DVM_AI_EXTRA_ARGS="" +``` + +Model entries are space-separated `alias=url` pairs. Aliases become filenames in the +VM, so `qwen=https://...` is saved as `qwen.gguf`. + +## Create + +```bash +dvm ai create +``` + +This creates `dvm-ai`, installs Fedora's `llama-cpp` package, writes a systemd service +for `llama-server`, downloads configured models, points `current.gguf` at +`DVM_AI_DEFAULT_MODEL`, and restarts the service. + +Use a non-default VM name: + +```bash +dvm ai create lab +``` + +## Manage Models + +```bash +dvm ai models +dvm ai pull qwen25-coder-7b-q4 +dvm ai use qwen25-coder-7b-q4 +``` + +For a non-default AI VM: + +```bash +dvm ai use --vm lab qwen25-coder-7b-q4 +``` + +The active model is the `current.gguf` symlink under `DVM_AI_MODELS_DIR`. + +## Service And Host + +Check status: + +```bash +dvm ai status +``` + +Print host URLs: + +```bash +dvm ai host +``` + +Reapply service configuration: + +```bash +dvm ai setup +``` diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..0f73b20 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,81 @@ +# Config And Dotfiles + +`dvm init` creates: + +```text +~/.config/dvm/config.sh +~/.config/dvm/setup.d/fedora.sh +~/.local/share/dvm/ +``` + +User configuration is shell code by design. Keep local VM behavior in +`~/.config/dvm`, not in the DVM core checkout. + +## Common Config + +```bash +DVM_PREFIX="dvm" +DVM_CPUS="4" +DVM_MEMORY="8GiB" +DVM_DISK="80GiB" + +DVM_PACKAGES="git openssh-clients gpg helix ripgrep fd-find jq" +DVM_SETUP_SCRIPTS="$DVM_CONFIG/setup.d/fedora.sh" +DVM_DOTFILES_DIR="$HOME/.dotfiles" +``` + +The core targets Lima `template:fedora` and assumes `dnf5` inside the guest. + +## Setup Scripts + +`DVM_SETUP_SCRIPTS` is a space-separated list of host scripts. Each script is piped +into the VM and runs as the guest user after core setup. + +Setup scripts receive: + +```text +DVM_NAME +DVM_VM_NAME +DVM_CODE_DIR +DVM_DOTFILES_TARGET +``` + +Use setup scripts for packages, shell config, editor config, and project-specific +configuration that should be reproducible across VMs. + +## Dotfiles Snapshot + +If `DVM_DOTFILES_DIR` is set, DVM copies a filtered snapshot of that host directory into +the VM before setup scripts run. DVM does not mount the host directory live. + +Defaults: + +```bash +DVM_DOTFILES_TARGET="$DVM_GUEST_HOME/.dotfiles" +DVM_DOTFILES_EXCLUDES=".git .ssh .gnupg .env secrets" +``` + +Safety rules: + +- dotfiles sync is opt-in +- source paths such as `/`, `$HOME`, `~/.ssh`, and `~/.gnupg` are refused +- target paths must stay under `DVM_GUEST_HOME` +- `.git`, `.ssh`, `.gnupg`, `.env`, and `secrets` are excluded by default + +Example setup script: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +mkdir -p "$DVM_CODE_DIR" + +if [ -x "$DVM_DOTFILES_TARGET/install.sh" ]; then + "$DVM_DOTFILES_TARGET/install.sh" +fi +``` + +## Default Config Reference + +The generated default config lives in [defaults/config.sh](../defaults/config.sh). +Review that file when adding new VM defaults. diff --git a/docs/gpg.md b/docs/gpg.md new file mode 100644 index 0000000..ee610e8 --- /dev/null +++ b/docs/gpg.md @@ -0,0 +1,62 @@ +# GPG Signing Subkeys + +DVM can create a signing subkey on the host and install only that subkey into one VM. +If the VM is deleted or no longer trusted, revoke that VM's subkey without rotating the +primary key. + +## Create + +```bash +dvm gpg create myapp --expire 1y +``` + +Files are written under: + +```text +~/.local/share/dvm/gpg/ +``` + +The command writes: + +- a public key export +- a secret-subkey export for the VM +- metadata with the primary fingerprint and subkey fingerprint + +Depending on local GPG setup, this may open pinentry. + +## Install + +```bash +dvm gpg install myapp +``` + +This imports the VM's secret-subkey bundle into the VM and configures Git commit signing: + +```bash +git config --global gpg.program gpg +git config --global user.signingkey '!' +git config --global commit.gpgsign true +``` + +You can pass an explicit bundle or signing key: + +```bash +dvm gpg install myapp ~/.local/share/dvm/gpg/myapp-secret-subkey.asc +dvm gpg install myapp --signing-key +``` + +## Revoke + +```bash +dvm gpg revoke myapp +``` + +Revocation updates the local GPG keyring and exports the updated public key. It does +not: + +- update GitHub, GitLab, or other remote services +- remove old public keys from places where they were trusted +- delete secret bundles from disk +- change anything inside an already-deleted VM + +Upload the updated public key wherever the old public key was trusted. diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..752da55 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,29 @@ +# Maintainer Release Process + +Run checks before tagging: + +```bash +bash scripts/check.sh +``` + +Create a signed annotated tag: + +```bash +git tag -s vX.Y.Z -m "dvm vX.Y.Z" +``` + +Push the branch and tag: + +```bash +git push origin main +git push origin vX.Y.Z +``` + +Create the GitHub release from the signed `v*` tag. + +Release rules: + +- publish releases only from signed `v*` tags +- do not move, delete, or replace published release tags +- if a release is bad, publish a new fixed release +- keep GitHub release settings aligned with [github-security.md](github-security.md) diff --git a/docs/vms.md b/docs/vms.md new file mode 100644 index 0000000..3d08467 --- /dev/null +++ b/docs/vms.md @@ -0,0 +1,109 @@ +# VM Lifecycle + +DVM creates one Lima VM per project. The VM name is: + +```text +$DVM_PREFIX-$name +``` + +With the default prefix, `dvm new myapp` creates `dvm-myapp`. + +## Create + +```bash +dvm new myapp +``` + +Creation does this: + +- creates the Lima VM from `DVM_TEMPLATE`, defaulting to `template:fedora` +- disables host directory mounts by default +- starts the VM +- runs core setup +- runs user setup scripts +- creates a per-VM SSH key at `~/.ssh/id_ed25519_myapp` inside the VM +- prints the public SSH key + +## Enter + +```bash +dvm myapp +``` + +This is a shortcut for: + +```bash +dvm enter myapp +``` + +The shell starts in `DVM_CODE_DIR`, which defaults to `~/code` in the guest. + +Run a single command in the VM: + +```bash +dvm ssh myapp uname -a +``` + +Print the VM's public GitHub SSH key: + +```bash +dvm key myapp +``` + +## Setup + +Rerun setup in one VM: + +```bash +dvm setup myapp +``` + +Rerun setup in every DVM-managed VM: + +```bash +dvm setup-all +``` + +This is the intended way to refresh packages, config, and dotfiles snapshots. DVM does +not remove packages automatically; removals should be explicit and manual. + +## List + +```bash +dvm list +``` + +`dvm list` shows VM names without the prefix. + +## Delete + +```bash +dvm rm myapp +``` + +Before deleting, DVM searches Git repositories under `DVM_CODE_DIR` in the VM. If any +repo has unstaged changes, staged changes, or untracked files, deletion is refused. + +Force deletion: + +```bash +dvm rm myapp --force +``` + +If a GPG signing subkey was created for the VM, `rm` prints the recorded subkey +fingerprint and the matching revoke command. Deleting a VM does not revoke GPG keys +automatically. + +## Doctor And Completion + +Check local requirements and paths: + +```bash +dvm doctor +``` + +Enable zsh completion: + +```bash +source <(dvm completion zsh) +``` diff --git a/lib/agent.sh b/lib/agent.sh new file mode 100644 index 0000000..841f99a --- /dev/null +++ b/lib/agent.sh @@ -0,0 +1,308 @@ +#!/usr/bin/env bash +# shellcheck shell=bash + +dvm_agent_usage() { + cat <<'HELP' +usage: + dvm agent setup + dvm agent install claude|codex|all + dvm agent -- + dvm agent +HELP +} + +dvm_agent_validate_config() { + case "$DVM_AGENT_USER" in + '' | *[!a-z_0-9-]* | -*) + dvm_die "invalid DVM_AGENT_USER: $DVM_AGENT_USER" + ;; + esac + case "$DVM_AGENT_HOME" in + /*) ;; + *) dvm_die "DVM_AGENT_HOME must be an absolute path: $DVM_AGENT_HOME" ;; + esac + case "$DVM_AGENT_CLAUDE_CHANNEL" in + stable | latest) ;; + *) dvm_die "DVM_AGENT_CLAUDE_CHANNEL must be stable or latest" ;; + esac +} + +dvm_agent_setup_remote() { + cat <<'REMOTE' +set -euo pipefail +agent_user="$1" +agent_home="$2" +code_dir="$3" +guest_home="$4" +packages="$5" + +if [ -n "$packages" ]; then + for package in $packages; do + case "$package" in + -* | *[!A-Za-z0-9._+:@-]*) + echo "invalid package token: $package" >&2 + exit 1 + ;; + esac + done + command -v dnf5 >/dev/null 2>&1 || { + echo "dnf5 is required in the guest image" >&2 + exit 1 + } + # shellcheck disable=SC2086 + sudo dnf5 install -y $packages +fi + +if ! id -u "$agent_user" >/dev/null 2>&1; then + sudo useradd -m -d "$agent_home" -s /bin/bash "$agent_user" +fi + +sudo mkdir -p "$agent_home" "$code_dir" +sudo chown "$agent_user:$agent_user" "$agent_home" +sudo chmod 0700 "$agent_home" + +if [ -d "$guest_home" ] && [ "$guest_home" != "$agent_home" ]; then + sudo setfacl -m "u:$agent_user:--x" "$guest_home" +fi +sudo setfacl -m "u:$agent_user:rwx" "$code_dir" +sudo setfacl -R -m "u:$agent_user:rwX" "$code_dir" +sudo find "$code_dir" -type d -exec setfacl -d -m "u:$agent_user:rwx" {} + + +if command -v git >/dev/null 2>&1; then + sudo -H -u "$agent_user" env HOME="$agent_home" \ + git config --global --add safe.directory "$code_dir/*" || true +fi + +printf 'agent user: %s\n' "$agent_user" +printf 'agent home: %s\n' "$agent_home" +printf 'code dir: %s\n' "$code_dir" +REMOTE +} + +dvm_agent_run_remote() { + cat <<'REMOTE' +set -euo pipefail +agent_user="$1" +agent_home="$2" +code_dir="$3" +guest_home="$4" +workdir="$5" +shift 5 + +if [ "$#" -eq 0 ]; then + set -- bash -l +fi + +command -v bwrap >/dev/null 2>&1 || { + echo "bubblewrap is required; run: dvm agent setup " >&2 + exit 1 +} +id -u "$agent_user" >/dev/null 2>&1 || { + echo "agent user is missing; run: dvm agent setup " >&2 + exit 1 +} +[ -d "$code_dir" ] || { + echo "code directory not found: $code_dir" >&2 + exit 1 +} +[ -d "$agent_home" ] || { + echo "agent home not found: $agent_home" >&2 + exit 1 +} + +bwrap_args=( + --die-with-parent + --proc /proc + --dev /dev + --ro-bind / / + --tmpfs /tmp + --dir /tmp/dvm-agent-code + --bind "$code_dir" /tmp/dvm-agent-code + --tmpfs /var/tmp + --bind "$agent_home" "$agent_home" + --setenv HOME "$agent_home" + --setenv USER "$agent_user" + --setenv LOGNAME "$agent_user" + --setenv DVM_AGENT "1" + --setenv DVM_CODE_DIR "$code_dir" +) + +if [ "$guest_home" != "$agent_home" ] && [ -d "$guest_home" ]; then + bwrap_args+=(--tmpfs "$guest_home") +fi +case "$code_dir" in +"$guest_home"/*) + bwrap_args+=(--dir "$guest_home" --dir "$code_dir") + ;; +esac + +bwrap_args+=(--bind /tmp/dvm-agent-code "$code_dir") +if [ -d "$workdir" ]; then + bwrap_args+=(--chdir "$workdir") +else + bwrap_args+=(--chdir "$code_dir") +fi + +sudo -H -u "$agent_user" env \ + HOME="$agent_home" \ + USER="$agent_user" \ + LOGNAME="$agent_user" \ + PATH="$agent_home/.local/bin:$PATH" \ + bwrap "${bwrap_args[@]}" -- "$@" +REMOTE +} + +dvm_agent_install_remote() { + cat <<'REMOTE' +set -euo pipefail +agent_user="$1" +agent_home="$2" +channel="$3" +tool="$4" + +install_packages() { + packages="$1" + for package in $packages; do + case "$package" in + -* | *[!A-Za-z0-9._+:@-]*) + echo "invalid package token: $package" >&2 + exit 1 + ;; + esac + done + # shellcheck disable=SC2086 + sudo dnf5 install -y $packages +} + +install_claude() { + command -v dnf5 >/dev/null 2>&1 || { + echo "dnf5 is required in the guest image" >&2 + exit 1 + } + repo_tmp="$(mktemp)" + trap 'rm -f "$repo_tmp"' EXIT + cat >"$repo_tmp" </dev/null 2>&1 || { + echo "dnf5 is required in the guest image" >&2 + exit 1 + } + install_packages "nodejs npm" + sudo -H -u "$agent_user" env \ + HOME="$agent_home" \ + USER="$agent_user" \ + LOGNAME="$agent_user" \ + PATH="$agent_home/.local/bin:$PATH" \ + bash -lc 'npm config set prefix "$HOME/.local" && mkdir -p "$HOME/.local/bin" && npm install -g @openai/codex' +} + +case "$tool" in +claude) install_claude ;; +codex) install_codex ;; +all) + install_claude + install_codex + ;; +*) echo "unknown agent tool: $tool" >&2; exit 1 ;; +esac +REMOTE +} + +dvm_agent_vm() { + local name vm + name="$1" + dvm_validate_name "$name" + vm="$(dvm_vm_name "$name")" + limactl start "$vm" >/dev/null + printf '%s\n' "$vm" +} + +dvm_agent_setup() { + local name vm remote + [ "$#" -eq 1 ] || dvm_die "usage: dvm agent setup " + name="$1" + dvm_load_config + dvm_agent_validate_config + dvm_require limactl + vm="$(dvm_agent_vm "$name")" + remote="$(dvm_agent_setup_remote)" + + dvm_log "setting up agent user in $vm" + limactl shell "$vm" bash -c "$remote" dvm-agent-setup \ + "$DVM_AGENT_USER" \ + "$DVM_AGENT_HOME" \ + "$DVM_CODE_DIR" \ + "$DVM_GUEST_HOME" \ + "$DVM_AGENT_PACKAGES" +} + +dvm_agent_install() { + local name vm tool remote + [ "$#" -eq 2 ] || dvm_die "usage: dvm agent install claude|codex|all" + name="$1" + tool="$2" + dvm_load_config + dvm_agent_validate_config + dvm_require limactl + vm="$(dvm_agent_vm "$name")" + remote="$(dvm_agent_install_remote)" + + dvm_log "installing agent tool in $vm: $tool" + dvm_agent_setup "$name" >/dev/null + limactl shell "$vm" bash -c "$remote" dvm-agent-install \ + "$DVM_AGENT_USER" \ + "$DVM_AGENT_HOME" \ + "$DVM_AGENT_CLAUDE_CHANNEL" \ + "$tool" +} + +dvm_agent_run() { + local name vm remote + [ "$#" -ge 1 ] || dvm_die "usage: dvm agent -- " + name="$1" + shift + if [ "${1:-}" = "--" ]; then + shift + fi + + dvm_load_config + dvm_agent_validate_config + dvm_require limactl + vm="$(dvm_agent_vm "$name")" + remote="$(dvm_agent_run_remote)" + + limactl shell "$vm" bash -c "$remote" dvm-agent-run \ + "$DVM_AGENT_USER" \ + "$DVM_AGENT_HOME" \ + "$DVM_CODE_DIR" \ + "$DVM_GUEST_HOME" \ + "$DVM_CODE_DIR" \ + "$@" +} + +dvm_agent_cmd() { + local cmd + cmd="${1:-help}" + [ "$#" -eq 0 ] || shift + case "$cmd" in + setup) dvm_agent_setup "$@" ;; + install) dvm_agent_install "$@" ;; + help | -h | --help) dvm_agent_usage ;; + *) + dvm_agent_run "$cmd" "$@" + ;; + esac +} diff --git a/lib/ai.sh b/lib/ai.sh new file mode 100644 index 0000000..223df2f --- /dev/null +++ b/lib/ai.sh @@ -0,0 +1,519 @@ +#!/usr/bin/env bash +# shellcheck shell=bash + +dvm_ai_usage() { + cat <<'HELP' +usage: + dvm ai create [name] + dvm ai setup [name] + dvm ai pull [--vm name] [model...] + dvm ai models [name] + dvm ai use [--vm name] + dvm ai status [name] + dvm ai host [name] +HELP +} + +dvm_ai_validate_token() { + local label value + label="$1" + value="$2" + case "$value" in + '' | *[!A-Za-z0-9._/@:+-]*) + dvm_die "invalid $label: $value" + ;; + esac +} + +dvm_ai_validate_model_alias() { + local alias + alias="$1" + case "$alias" in + '' | *[!A-Za-z0-9._-]* | .* | *..* | */*) + dvm_die "invalid AI model alias: $alias" + ;; + esac +} + +dvm_ai_validate_url() { + local url + url="$1" + case "$url" in + http://* | https://*) ;; + *) dvm_die "AI model URL must start with http:// or https://: $url" ;; + esac + case "$url" in + *$'\n'* | *$'\r'* | *' '*) + dvm_die "invalid AI model URL: $url" + ;; + esac +} + +dvm_ai_validate_config() { + case "$DVM_AI_PORT" in + '' | *[!0-9]*) + dvm_die "invalid DVM_AI_PORT: $DVM_AI_PORT" + ;; + esac + + dvm_ai_validate_token DVM_AI_SERVER_CMD "$DVM_AI_SERVER_CMD" + dvm_ai_validate_token DVM_AI_SERVICE_NAME "$DVM_AI_SERVICE_NAME" + dvm_ai_validate_token DVM_AI_SYSTEMD_DIR "$DVM_AI_SYSTEMD_DIR" + dvm_ai_validate_token DVM_AI_HOST "$DVM_AI_HOST" + dvm_ai_validate_token DVM_AI_MODELS_DIR "$DVM_AI_MODELS_DIR" + dvm_ai_validate_token DVM_AI_CURRENT_MODEL "$DVM_AI_CURRENT_MODEL" + + case "$DVM_AI_MODELS_DIR" in + /*) ;; + *) dvm_die "DVM_AI_MODELS_DIR must be an absolute path: $DVM_AI_MODELS_DIR" ;; + esac + case "$DVM_AI_SYSTEMD_DIR" in + /*) ;; + *) dvm_die "DVM_AI_SYSTEMD_DIR must be an absolute path: $DVM_AI_SYSTEMD_DIR" ;; + esac + case "$DVM_AI_SERVICE_NAME" in + */*) + dvm_die "DVM_AI_SERVICE_NAME must be a service name, not a path: $DVM_AI_SERVICE_NAME" + ;; + esac + case "$DVM_AI_CURRENT_MODEL" in + "$DVM_AI_MODELS_DIR"/*) ;; + *) dvm_die "DVM_AI_CURRENT_MODEL must stay under DVM_AI_MODELS_DIR: $DVM_AI_CURRENT_MODEL" ;; + esac + case "$DVM_AI_EXTRA_ARGS" in + *$'\n'* | *$'\r'*) + dvm_die "invalid DVM_AI_EXTRA_ARGS" + ;; + esac +} + +dvm_ai_model_filename() { + local alias + alias="$1" + case "$alias" in + *.gguf) printf '%s\n' "$alias" ;; + *) printf '%s.gguf\n' "$alias" ;; + esac +} + +dvm_ai_model_url() { + local alias spec spec_alias + alias="$1" + for spec in $DVM_AI_MODELS; do + case "$spec" in + *=*) ;; + *) dvm_die "invalid DVM_AI_MODELS entry: $spec" ;; + esac + spec_alias="${spec%%=*}" + if [ "$spec_alias" = "$alias" ]; then + printf '%s\n' "${spec#*=}" + return 0 + fi + done + return 1 +} + +dvm_ai_first_model_alias() { + local spec + for spec in $DVM_AI_MODELS; do + case "$spec" in + *=*) + printf '%s\n' "${spec%%=*}" + return 0 + ;; + *) dvm_die "invalid DVM_AI_MODELS entry: $spec" ;; + esac + done + return 1 +} + +dvm_ai_model_aliases() { + local spec + for spec in $DVM_AI_MODELS; do + case "$spec" in + *=*) printf '%s\n' "${spec%%=*}" ;; + *) dvm_die "invalid DVM_AI_MODELS entry: $spec" ;; + esac + done +} + +dvm_ai_setup_remote() { + cat <<'REMOTE' +set -euo pipefail +packages="$1" +server_cmd="$2" +models_dir="$3" +current_model="$4" +service_name="$5" +service_dir="$6" +listen_host="$7" +port="$8" +extra_args="$9" + +if [ -n "$packages" ]; then + for package in $packages; do + case "$package" in + -* | *[!A-Za-z0-9._+:@-]*) + echo "invalid package token: $package" >&2 + exit 1 + ;; + esac + done + command -v dnf5 >/dev/null 2>&1 || { + echo "dnf5 is required in the guest image" >&2 + exit 1 + } + # shellcheck disable=SC2086 + sudo dnf5 install -y $packages +fi + +server_path="$(command -v "$server_cmd")" || { + echo "llama server command not found: $server_cmd" >&2 + exit 1 +} + +mkdir -p "$models_dir" +unit_tmp="$(mktemp)" +trap 'rm -f "$unit_tmp"' EXIT +cat >"$unit_tmp" </dev/null 2>&1 || { + echo "curl is required in the guest image" >&2 + exit 1 +} + +echo "downloading $alias" +curl -fL --retry 3 --connect-timeout 20 -o "$tmp" "$url" +mv "$tmp" "$dest" +printf '%s\n' "$dest" +REMOTE +} + +dvm_ai_use_remote() { + cat <<'REMOTE' +set -euo pipefail +models_dir="$1" +current_model="$2" +service_name="$3" +model="$4" + +case "$model" in +'' | */* | .* | *..*) + echo "invalid model name: $model" >&2 + exit 1 + ;; +esac + +candidate="$models_dir/$model" +if [ ! -f "$candidate" ]; then + candidate="$models_dir/$model.gguf" +fi +if [ ! -f "$candidate" ]; then + echo "model not installed: $model" >&2 + exit 1 +fi + +rm -f "$current_model" +ln -s "$candidate" "$current_model" +sudo systemctl enable "$service_name" +sudo systemctl restart "$service_name" +printf 'active model: %s\n' "$(basename "$candidate")" +REMOTE +} + +dvm_ai_models_remote() { + cat <<'REMOTE' +set -euo pipefail +models_dir="$1" +current_model="$2" +active="" +found="0" + +if [ -e "$current_model" ]; then + active="$(readlink -f "$current_model" 2>/dev/null || true)" +fi + +for file in "$models_dir"/*.gguf; do + [ -e "$file" ] || continue + [ "$(basename "$file")" = "$(basename "$current_model")" ] && continue + found="1" + marker=" " + if [ -n "$active" ] && [ "$(readlink -f "$file" 2>/dev/null || true)" = "$active" ]; then + marker="*" + fi + printf '%s %s\n' "$marker" "$(basename "$file")" +done + +[ "$found" = "1" ] || printf 'no models installed\n' +REMOTE +} + +dvm_ai_status_remote() { + cat <<'REMOTE' +set -euo pipefail +service_name="$1" +current_model="$2" + +service="$(systemctl is-active "$service_name" 2>/dev/null || true)" +enabled="$(systemctl is-enabled "$service_name" 2>/dev/null || true)" +model="none" +if [ -e "$current_model" ]; then + model="$(basename "$(readlink -f "$current_model" 2>/dev/null || printf '%s' "$current_model")")" +fi + +printf 'service: %s\n' "${service:-unknown}" +printf 'enabled: %s\n' "${enabled:-unknown}" +printf 'model: %s\n' "$model" +REMOTE +} + +dvm_ai_host_remote() { + cat <<'REMOTE' +set -euo pipefail +port="$1" +guest_ip="$(hostname -I 2>/dev/null | awk '{print $1}' || true)" + +printf 'host: http://127.0.0.1:%s\n' "$port" +if [ -n "$guest_ip" ]; then + printf 'guest: http://%s:%s\n' "$guest_ip" "$port" +fi +REMOTE +} + +dvm_ai_vm_name_arg() { + local usage name + usage="$1" + shift + [ "$#" -le 1 ] || dvm_die "$usage" + name="${1:-$DVM_AI_NAME}" + dvm_validate_name "$name" + printf '%s\n' "$name" +} + +dvm_ai_start_vm() { + local name vm + name="$1" + vm="$(dvm_vm_name "$name")" + limactl start "$vm" >/dev/null + printf '%s\n' "$vm" +} + +dvm_ai_setup() { + local name vm remote + dvm_load_config + name="$(dvm_ai_vm_name_arg "usage: dvm ai setup [name]" "$@")" + dvm_ai_validate_config + dvm_require limactl + vm="$(dvm_ai_start_vm "$name")" + remote="$(dvm_ai_setup_remote)" + + dvm_log "configuring llama.cpp in $vm" + limactl shell "$vm" bash -c "$remote" dvm-ai-setup \ + "$DVM_AI_PACKAGES" \ + "$DVM_AI_SERVER_CMD" \ + "$DVM_AI_MODELS_DIR" \ + "$DVM_AI_CURRENT_MODEL" \ + "$DVM_AI_SERVICE_NAME" \ + "$DVM_AI_SYSTEMD_DIR" \ + "$DVM_AI_HOST" \ + "$DVM_AI_PORT" \ + "$DVM_AI_EXTRA_ARGS" +} + +dvm_ai_pull_one() { + local vm request alias url filename remote + vm="$1" + request="$2" + + case "$request" in + *=*) + alias="${request%%=*}" + url="${request#*=}" + ;; + *) + alias="$request" + url="$(dvm_ai_model_url "$alias")" || dvm_die "unknown AI model alias: $alias" + ;; + esac + + dvm_ai_validate_model_alias "$alias" + dvm_ai_validate_url "$url" + filename="$(dvm_ai_model_filename "$alias")" + remote="$(dvm_ai_pull_remote)" + + dvm_log "pulling AI model into $vm: $alias" + limactl shell "$vm" bash -c "$remote" dvm-ai-pull \ + "$DVM_AI_MODELS_DIR" \ + "$alias" \ + "$url" \ + "$filename" +} + +dvm_ai_pull() { + local name vm alias + dvm_load_config + name="$DVM_AI_NAME" + if [ "${1:-}" = "--vm" ]; then + [ "$#" -ge 2 ] || dvm_die "usage: dvm ai pull [--vm name] [model...]" + name="$2" + shift 2 + fi + dvm_validate_name "$name" + dvm_ai_validate_config + dvm_require limactl + vm="$(dvm_ai_start_vm "$name")" + + if [ "$#" -eq 0 ]; then + [ -n "$DVM_AI_MODELS" ] || dvm_die "no AI models configured" + while IFS= read -r alias; do + [ -n "$alias" ] || continue + dvm_ai_pull_one "$vm" "$alias" + done < <(dvm_ai_model_aliases) + return 0 + fi + + for alias in "$@"; do + dvm_ai_pull_one "$vm" "$alias" + done +} + +dvm_ai_use() { + local name vm model remote + dvm_load_config + name="$DVM_AI_NAME" + if [ "${1:-}" = "--vm" ]; then + [ "$#" -ge 3 ] || dvm_die "usage: dvm ai use [--vm name] " + name="$2" + shift 2 + fi + [ "$#" -eq 1 ] || dvm_die "usage: dvm ai use [--vm name] " + model="$1" + dvm_validate_name "$name" + dvm_ai_validate_config + dvm_ai_validate_model_alias "$model" + dvm_require limactl + vm="$(dvm_ai_start_vm "$name")" + remote="$(dvm_ai_use_remote)" + + limactl shell "$vm" bash -c "$remote" dvm-ai-use \ + "$DVM_AI_MODELS_DIR" \ + "$DVM_AI_CURRENT_MODEL" \ + "$DVM_AI_SERVICE_NAME" \ + "$model" +} + +dvm_ai_models() { + local name vm remote + dvm_load_config + name="$(dvm_ai_vm_name_arg "usage: dvm ai models [name]" "$@")" + dvm_ai_validate_config + dvm_require limactl + vm="$(dvm_ai_start_vm "$name")" + remote="$(dvm_ai_models_remote)" + limactl shell "$vm" bash -c "$remote" dvm-ai-models "$DVM_AI_MODELS_DIR" "$DVM_AI_CURRENT_MODEL" +} + +dvm_ai_status() { + local name vm remote + dvm_load_config + name="$(dvm_ai_vm_name_arg "usage: dvm ai status [name]" "$@")" + dvm_ai_validate_config + dvm_require limactl + vm="$(dvm_ai_start_vm "$name")" + remote="$(dvm_ai_status_remote)" + printf 'vm: %s\n' "$name" + limactl shell "$vm" bash -c "$remote" dvm-ai-status "$DVM_AI_SERVICE_NAME" "$DVM_AI_CURRENT_MODEL" +} + +dvm_ai_host() { + local name vm remote + dvm_load_config + name="$(dvm_ai_vm_name_arg "usage: dvm ai host [name]" "$@")" + dvm_ai_validate_config + dvm_require limactl + vm="$(dvm_ai_start_vm "$name")" + remote="$(dvm_ai_host_remote)" + printf 'vm: %s\n' "$name" + limactl shell "$vm" bash -c "$remote" dvm-ai-host "$DVM_AI_PORT" +} + +dvm_ai_create() { + local name default_model + dvm_load_config + name="$(dvm_ai_vm_name_arg "usage: dvm ai create [name]" "$@")" + dvm_ai_validate_config + + dvm_create "$name" + dvm_ai_setup "$name" + + if [ -n "$DVM_AI_MODELS" ]; then + dvm_ai_pull --vm "$name" + default_model="$DVM_AI_DEFAULT_MODEL" + if [ -z "$default_model" ]; then + default_model="$(dvm_ai_first_model_alias || true)" + fi + if [ -n "$default_model" ]; then + dvm_ai_use --vm "$name" "$default_model" + fi + fi +} + +dvm_ai_cmd() { + local cmd + cmd="${1:-help}" + [ "$#" -eq 0 ] || shift + case "$cmd" in + create | new) dvm_ai_create "$@" ;; + setup) dvm_ai_setup "$@" ;; + pull | download) dvm_ai_pull "$@" ;; + models | ls) dvm_ai_models "$@" ;; + use | switch) dvm_ai_use "$@" ;; + status) dvm_ai_status "$@" ;; + host | url) dvm_ai_host "$@" ;; + help | -h | --help) dvm_ai_usage ;; + *) + dvm_ai_usage + dvm_die "unknown ai command: $cmd" + ;; + esac +} diff --git a/lib/completion.sh b/lib/completion.sh index 3c82b99..87b156d 100644 --- a/lib/completion.sh +++ b/lib/completion.sh @@ -19,6 +19,8 @@ _dvm() { 'key:print VM SSH public key' 'list:list VMs' 'rm:delete a VM' + 'ai:manage a llama.cpp VM' + 'agent:run AI tools as the restricted agent user' 'gpg:manage VM GPG signing subkeys' 'doctor:check local requirements' 'completion:print shell completion' @@ -36,6 +38,24 @@ _dvm() { enter|setup|ssh|key|rm) _describe -t vms 'VM' vms ;; + ai) + if (( CURRENT == 3 )); then + _values 'ai command' create setup pull models use status host + elif [[ "$words[3]" == (create|setup|models|status|host) ]]; then + _describe -t vms 'VM' vms + else + _describe -t vms 'VM' vms + _values 'ai option' --vm + fi + ;; + agent) + if (( CURRENT == 3 )); then + _values 'agent command' setup install + _describe -t vms 'VM' vms + elif [[ "$words[3]" == (setup|install) ]]; then + _describe -t vms 'VM' vms + fi + ;; gpg) if (( CURRENT == 3 )); then _values 'gpg command' create install revoke diff --git a/lib/config.sh b/lib/config.sh index e81bffe..c1149b2 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -25,6 +25,22 @@ dvm_load_config() { DVM_DOTFILES_TARGET="${DVM_DOTFILES_TARGET:-$DVM_GUEST_HOME/.dotfiles}" DVM_DOTFILES_EXCLUDES="${DVM_DOTFILES_EXCLUDES:-.git .ssh .gnupg .env secrets}" DVM_GPG_DIR="${DVM_GPG_DIR:-$DVM_STATE/gpg}" + DVM_AI_NAME="${DVM_AI_NAME:-ai}" + DVM_AI_PACKAGES="${DVM_AI_PACKAGES:-llama-cpp curl}" + DVM_AI_SERVER_CMD="${DVM_AI_SERVER_CMD:-llama-server}" + DVM_AI_SERVICE_NAME="${DVM_AI_SERVICE_NAME:-dvm-llama.service}" + DVM_AI_SYSTEMD_DIR="${DVM_AI_SYSTEMD_DIR:-/etc/systemd/system}" + DVM_AI_HOST="${DVM_AI_HOST:-0.0.0.0}" + DVM_AI_PORT="${DVM_AI_PORT:-8080}" + DVM_AI_MODELS_DIR="${DVM_AI_MODELS_DIR:-$DVM_GUEST_HOME/models}" + DVM_AI_CURRENT_MODEL="${DVM_AI_CURRENT_MODEL:-$DVM_AI_MODELS_DIR/current.gguf}" + DVM_AI_DEFAULT_MODEL="${DVM_AI_DEFAULT_MODEL:-qwen25-coder-7b-q4}" + DVM_AI_MODELS="${DVM_AI_MODELS:-qwen25-coder-7b-q4=https://huggingface.co/bartowski/Qwen2.5-Coder-7B-Instruct-GGUF/resolve/main/Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf?download=true}" + DVM_AI_EXTRA_ARGS="${DVM_AI_EXTRA_ARGS:-}" + DVM_AGENT_USER="${DVM_AGENT_USER:-dvm-agent}" + DVM_AGENT_HOME="${DVM_AGENT_HOME:-/home/$DVM_AGENT_USER}" + DVM_AGENT_PACKAGES="${DVM_AGENT_PACKAGES:-bubblewrap acl shadow-utils}" + DVM_AGENT_CLAUDE_CHANNEL="${DVM_AGENT_CLAUDE_CHANNEL:-stable}" } dvm_init() { diff --git a/lib/vm.sh b/lib/vm.sh index 8548f28..d4d58c4 100644 --- a/lib/vm.sh +++ b/lib/vm.sh @@ -79,13 +79,12 @@ if [ -n "$packages" ]; then ;; esac done - if command -v dnf5 >/dev/null 2>&1; then - # shellcheck disable=SC2086 - sudo dnf5 install -y -- $packages - else - # shellcheck disable=SC2086 - sudo dnf install -y -- $packages - fi + command -v dnf5 >/dev/null 2>&1 || { + echo "dnf5 is required in the guest image" >&2 + exit 1 + } + # shellcheck disable=SC2086 + sudo dnf5 install -y $packages fi key="$HOME/.ssh/id_ed25519_$name" diff --git a/tests/smoke.sh b/tests/smoke.sh index ed5b993..f13bc8b 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -9,11 +9,32 @@ MOCK_BIN="$TMP/bin" VM_HOME_ROOT="$TMP/vm-home" LIST_FILE="$TMP/limactl-list" LOG="$TMP/log" +AGENT_USER_FILE="$TMP/agent-user" mkdir -p "$MOCK_BIN" "$VM_HOME_ROOT" cat >"$MOCK_BIN/sudo" <<'MOCK' #!/usr/bin/env bash set -euo pipefail +while [ "$#" -gt 0 ]; do + case "$1" in + -H) + shift + ;; + -u) + shift 2 + ;; + --) + shift + break + ;; + -*) + shift + ;; + *) + break + ;; + esac +done "$@" MOCK @@ -23,6 +44,109 @@ set -euo pipefail printf 'dnf5 %s\n' "$*" >>"$DVM_TEST_LOG" MOCK +cat >"$MOCK_BIN/curl" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +out="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) + out="$2" + shift + ;; + http://* | https://*) + url="$1" + ;; + esac + shift +done +[ -n "$out" ] +[ -n "$url" ] +mkdir -p "$(dirname "$out")" +printf 'model from %s\n' "$url" >"$out" +MOCK + +cat >"$MOCK_BIN/systemctl" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +printf 'systemctl %s\n' "$*" >>"$DVM_TEST_LOG" +case "${1:-}" in +is-active) + printf 'active\n' + ;; +is-enabled) + printf 'enabled\n' + ;; +esac +MOCK + +cat >"$MOCK_BIN/llama-server" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +printf 'llama-server %s\n' "$*" >>"$DVM_TEST_LOG" +MOCK + +cat >"$MOCK_BIN/useradd" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +printf 'useradd %s\n' "$*" >>"$DVM_TEST_LOG" +user="${*: -1}" +printf '%s\n' "$user" >"$DVM_TEST_AGENT_USER" +MOCK + +cat >"$MOCK_BIN/id" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +if [ "${1:-}" = "-u" ] && [ "$#" -eq 2 ] && + [ -f "$DVM_TEST_AGENT_USER" ] && + [ "$2" = "$(cat "$DVM_TEST_AGENT_USER")" ]; then + printf '1001\n' + exit 0 +fi +exec /usr/bin/id "$@" +MOCK + +cat >"$MOCK_BIN/chown" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +printf 'chown %s\n' "$*" >>"$DVM_TEST_LOG" +MOCK + +cat >"$MOCK_BIN/setfacl" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +printf 'setfacl %s\n' "$*" >>"$DVM_TEST_LOG" +MOCK + +cat >"$MOCK_BIN/bwrap" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +printf 'bwrap %s\n' "$*" >>"$DVM_TEST_LOG" +while [ "$#" -gt 0 ]; do + case "$1" in + --setenv) + export "$2=$3" + shift 3 + ;; + --) + shift + break + ;; + *) + shift + ;; + esac +done +"$@" +MOCK + +cat >"$MOCK_BIN/npm" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +printf 'npm %s\n' "$*" >>"$DVM_TEST_LOG" +MOCK + cat >"$MOCK_BIN/ssh-keygen" <<'MOCK' #!/usr/bin/env bash set -euo pipefail @@ -108,11 +232,25 @@ delete) esac MOCK -chmod +x "$MOCK_BIN/sudo" "$MOCK_BIN/dnf5" "$MOCK_BIN/ssh-keygen" "$MOCK_BIN/limactl" +chmod +x \ + "$MOCK_BIN/sudo" \ + "$MOCK_BIN/dnf5" \ + "$MOCK_BIN/curl" \ + "$MOCK_BIN/systemctl" \ + "$MOCK_BIN/llama-server" \ + "$MOCK_BIN/useradd" \ + "$MOCK_BIN/id" \ + "$MOCK_BIN/chown" \ + "$MOCK_BIN/setfacl" \ + "$MOCK_BIN/bwrap" \ + "$MOCK_BIN/npm" \ + "$MOCK_BIN/ssh-keygen" \ + "$MOCK_BIN/limactl" export DVM_TEST_LOG="$LOG" export DVM_TEST_LIST="$LIST_FILE" export DVM_TEST_VM_HOME="$VM_HOME_ROOT" +export DVM_TEST_AGENT_USER="$AGENT_USER_FILE" export DVM_TEST_PATH="$MOCK_BIN:$PATH" export PATH="$MOCK_BIN:$PATH" export HOME="$TMP/home" @@ -218,5 +356,73 @@ if grep -Fxq app "$TMP/list-after-rm.out"; then exit 1 fi +cat >"$DVM_CONFIG/config.sh" <"$TMP/ai-create.out" +grep -Fq 'create testvm-ai' "$LOG" +grep -Fq 'dnf5 install -y llama-cpp curl' "$LOG" +grep -Fq 'systemctl enable dvm-llama.service' "$LOG" +grep -Fq 'systemctl restart dvm-llama.service' "$LOG" +[ -f "$VM_HOME_ROOT/testvm-ai/models/tiny.gguf" ] +[ -f "$VM_HOME_ROOT/testvm-ai/models/other.gguf" ] +[ "$(readlink "$VM_HOME_ROOT/testvm-ai/models/current.gguf")" = "$VM_HOME_ROOT/testvm-ai/models/tiny.gguf" ] +[ -f "$TMP/systemd/dvm-llama.service" ] +grep -Fq 'ExecStart=' "$TMP/systemd/dvm-llama.service" +grep -Fq -- '--port 18080' "$TMP/systemd/dvm-llama.service" + +"$TMP/local-bin/dvm-test" ai models >"$TMP/ai-models.out" +grep -Fq '* tiny.gguf' "$TMP/ai-models.out" +grep -Fq 'other.gguf' "$TMP/ai-models.out" +"$TMP/local-bin/dvm-test" ai use other >"$TMP/ai-use.out" +grep -Fq 'active model: other.gguf' "$TMP/ai-use.out" +[ "$(readlink "$VM_HOME_ROOT/testvm-ai/models/current.gguf")" = "$VM_HOME_ROOT/testvm-ai/models/other.gguf" ] +"$TMP/local-bin/dvm-test" ai status >"$TMP/ai-status.out" +grep -Fq 'vm: ai' "$TMP/ai-status.out" +grep -Fq 'service: active' "$TMP/ai-status.out" +grep -Fq 'enabled: enabled' "$TMP/ai-status.out" +grep -Fq 'model: other.gguf' "$TMP/ai-status.out" +"$TMP/local-bin/dvm-test" ai host >"$TMP/ai-host.out" +grep -Fq 'host: http://127.0.0.1:18080' "$TMP/ai-host.out" + +cat >"$DVM_CONFIG/config.sh" <"$TMP/agent-setup.out" +grep -Fq 'dnf5 install -y bubblewrap acl shadow-utils' "$LOG" +grep -Fq 'useradd -m -d' "$LOG" +grep -Fq 'setfacl -m u:dvm-agent:rwx' "$LOG" +grep -Fq 'agent user: dvm-agent' "$TMP/agent-setup.out" +# shellcheck disable=SC2016 +"$TMP/local-bin/dvm-test" agent ai -- bash -lc 'printf "%s\n" "$HOME" >"$DVM_CODE_DIR/agent-home"; printf "%s\n" "$DVM_AGENT" >"$DVM_CODE_DIR/agent-flag"' +grep -Fxq "$VM_HOME_ROOT/testvm-ai-agent" "$VM_HOME_ROOT/testvm-ai/code/agent-home" +grep -Fxq "1" "$VM_HOME_ROOT/testvm-ai/code/agent-flag" +grep -Fq 'bwrap ' "$LOG" +"$TMP/local-bin/dvm-test" agent install ai codex >/dev/null +grep -Fq 'dnf5 install -y nodejs npm' "$LOG" +grep -Fq 'npm install -g @openai/codex' "$LOG" + "$TMP/local-bin/dvm-test" completion zsh >"$TMP/completion.zsh" grep -Fq 'compdef _dvm dvm-test' "$TMP/completion.zsh" +grep -Fq 'ai:manage a llama.cpp VM' "$TMP/completion.zsh" +grep -Fq 'agent:run AI tools as the restricted agent user' "$TMP/completion.zsh"