# Images, layers, and content keys

> Deterministic SHA-256 key computation for base images, built-in and custom layers (docker, nix, desktop), and read-only EROFS shared tool images.

- Repository: devvmhq/devvm
- GitHub: https://github.com/devvmhq/devvm
- Human docs: https://grok-wiki.com/public/docs/devvmhq-devvm-4242986d2853
- Complete Markdown: https://grok-wiki.com/public/docs/devvmhq-devvm-4242986d2853/llms-full.txt

## Source Files

- `internal/image/key.go`
- `internal/image/recipe.go`
- `internal/image/tool.go`
- `internal/build/build.go`
- `internal/build/layer.go`
- `assets/base.yaml`
- `assets/prelude.sh`

---

---
title: "Images, layers, and content keys"
description: "Deterministic SHA-256 key computation for base images, built-in and custom layers (docker, nix, desktop), and read-only EROFS shared tool images."
---

`devvm` identifies, caches, and invalidates VM disk images (`internal/image`, `internal/build`) and shared tool EROFS archives (`internal/tools`) using deterministic 12-hex-digit SHA-256 content keys. Every build artifact is keyed by a length-prefixed hash of its complete recipe—including the virtualization backend, parent image key, embedded script prelude (`assets/prelude.sh`), provisioning scripts, pinned upstream digests, and per-target rebuild counters—so any change to a script, feature selection, or base image automatically cascades to dependent layers without mutating existing VMs.

## Content key architecture

Both VM disk images (`image.Input.Key`) and shared tool EROFS images (`image.ToolFile`) compute their 12-character lowercase hexadecimal key (`^[0-9a-f]{12}$`) through `image.keyHash` in `internal/image/key.go`.

### Length-prefixed SHA-256 framing

To prevent canonicalization collisions between adjacent fields or multi-step scripts, `keyHash` frames every tag and value with an 8-byte big-endian unsigned length prefix (`uint64`) before writing bytes to the underlying SHA-256 state:

1. `newKeyHash()` initializes a `sha256.New()` digest and immediately writes the versioned header field `("format", "devvm image key 1")`.
2. Each `field(tag, value)` or `bytes(tag, value)` call writes `[8-byte big-endian len(tag)][tag bytes][8-byte big-endian len(value)][value bytes]`.
3. Recipe steps (`k.step(tag, s)`) write the step role tag (`"origin"` or `"step"` with `s.Name`), `"script"` (`s.Data`), `"args"` (`strconv.Itoa(len(s.Args))`), each `"arg"` value in order, and `"root"` (`"true"` or `"false"`).
4. `sum()` encodes the 32-byte SHA-256 digest as hexadecimal and truncates it to the first 12 hex characters (`keyLen = 12`).

```go title="internal/image/key.go"
const keyFormat = "devvm image key 1"
const keyLen = 12

func (k keyHash) write(b []byte) {
	var n [8]byte
	binary.BigEndian.PutUint64(n[:], uint64(len(b)))
	k.h.Write(n[:])
	k.h.Write(b)
}

func (k keyHash) step(tag string, s Step) {
	k.field(tag, s.Name)
	k.bytes("script", s.Data)
	k.field("args", strconv.Itoa(len(s.Args)))
	for _, a := range s.Args {
		k.field("arg", a)
	}
	k.field("root", strconv.FormatBool(s.Root))
}
```

### Key domain models

```mermaid
classDiagram
    class Input {
        +string Backend
        +Name Image
        +string Parent
        +string From
        +string FromDigest
        +Recipe Recipe
        +string Missing
        +string Rebuild
        +Key() string
    }
    class Recipe {
        +[]Step Origin
        +[]Step Steps
    }
    class Step {
        +string Name
        +[]byte Data
        +[]string Args
        +bool Root
    }
    class Chain {
        +String() string
        +Key() string
    }
    class Link {
        +string Name
        +string Key
    }
    class Tool {
        +string Name
        +string Version
        +[]byte LockEntry
        +string Libc
        +string Base
        +string Format
    }
    Input *-- Recipe : hashes
    Recipe *-- Step : Origin / Steps
    Chain *-- Link : ordered base-first
    Tool ..> keyHash : ToolFile(assets, Tool)
    Input ..> keyHash : Key()
```

### VM image key inputs (`image.Input`)

`image.Input.Key()` hashes fields in a fixed sequence. Optional fields (`From`, `Missing`, `Rebuild`) are omitted from the hash stream when empty so default builds do not carry empty tags.

<ParamField body="backend" type="string" required>
  Backend identifier returned by `Backend.Name()` (for example, `"lima"`). Ensures images are never shared across different virtualization backends.
</ParamField>

<ParamField body="image" type="string" required>
  Normalized image name (`image.Name`): `"base"`, a single layer such as `"docker"`, or a `+`-joined layer stack bottom-first such as `"docker+nix"`.
</ParamField>

<ParamField body="parent" type="string" required>
  12-hex-digit key of the parent image in the build chain, or `""` for `base`. Because every layer hashes its parent key, any change in `base` or an lower layer changes every key above it.
</ParamField>

<ParamField body="from / from-digest" type="string">
  Included only when `From != ""`. Hashes the user-supplied `--from` container reference (`from`) and its resolved registry manifest digest or Docker image ID (`from-digest`).
</ParamField>

<ParamField body="origin" type="[]image.Step">
  Backend-specific origin steps (`Recipe.Origin`), present only on `base`. On Lima, this includes the `ubuntu-image` pin (`release`, `file`, `sha256`), optional `oci/build.sh`, `oci/bootify.sh`, and `oci/kernel.sh` conversion steps when `--from` is set, the `disk` size (`"100GiB"`), and the raw bytes of `assets/base.yaml`.
</ParamField>

<ParamField body="step" type="[]image.Step" required>
  Ordered guest provisioning scripts (`Recipe.Steps`) executed inside the build VM.
</ParamField>

<ParamField body="missing" type="string">
  Included only when a custom layer's `.sh` file was deleted from `~/.config/devvm/layers/` after being referenced (`"<layer>.sh"`). Forces the key to diverge from any valid script content so recorded chains become outdated.
</ParamField>

<ParamField body="rebuild" type="string">
  Included only when `Rebuild != ""`. Holds the `<YYYYMMDDHHMMSS>-<8hex>` counter from `~/.local/share/devvm/images/rebuilds.json` for `(backend, from, image)`.
</ParamField>

<Note>
**What image keys intentionally exclude:** Host CPU count and memory fitted by `buildSize` (`4` CPUs and `8 GiB` RAM capped to host limits), custom CA certificates in `~/.config/devvm/ca/`, company HTTP proxy credentials, and network allowlist files (`*.policy`) are excluded from image keys. Custom CAs and build proxy environment variables are injected transiently during build script execution and removed before the image VM stops; `*.policy` files are read on the host by `devvm _proxy` when a project VM runs.
</Note>

---

## Script prelude (`assets/prelude.sh`)

Every script shipped inside `devvm`'s embedded `assets.FS` that executes under `bash` is loaded through `image.OwnScript(own, name)`. `OwnScript` concatenates `assets/prelude.sh` and the target script into a single byte slice before storing it in `Step.Data` or hashing it in `ToolFile`.

```go title="internal/image/recipe.go"
const preludeFile = "prelude.sh"

func OwnScript(own fs.FS, name string) ([]byte, error) {
	prelude, err := fs.ReadFile(own, preludeFile)
	if err != nil {
		return nil, fmt.Errorf("reading devvm's %s: %w", preludeFile, err)
	}
	script, err := fs.ReadFile(own, name)
	if err != nil {
		return nil, fmt.Errorf("reading devvm's %s: %w", name, err)
	}
	return slices.Concat(prelude, script), nil
}
```

### Prelude runtime exports

`assets/prelude.sh` sets no shell options of its own and runs only `uname -m` and `command -v apk`:

| Identifier | Values / Signature | Behavior |
| --- | --- | --- |
| `$devvm_arch` | `arm64`, `amd64` | Maps `aarch64 \| arm64` to `arm64` and `x86_64 \| amd64` to `amd64`. Exits with status `1` on any other architecture. |
| `$devvm_libc` | `musl`, `gnu` | Set to `musl` when `command -v apk` succeeds (Alpine guests); `gnu` otherwise. |
| `devvm_sha` | `devvm_sha <TOOL>` | Resolves `<TOOL>_sha256_<arch>_musl` when `$devvm_libc == musl` and that variable is non-empty; otherwise resolves `<TOOL>_sha256_<arch>`. Returns status `1` if the variable is unset. |

Scripts that depend on `prelude.sh` verify its presence on their first line after `set -euxo pipefail` via parameter expansion guards (`: "${devvm_arch:?}" "${devvm_libc:?}"`).

- **Scripts prefixed with `prelude.sh` (`image.OwnScript`):** `layers/base.sh`, `layers/apt.sh`, `layers/desktop.sh`, `layers/docker.sh`, `layers/nix.sh`, `features/cli-tools.sh`, `features/fzf.sh`, `features/omb.sh`, `shared-tools/build.sh`, and `oci/build.sh`.
- **Scripts that do not receive `prelude.sh`:** User-authored custom layers in `~/.config/devvm/layers/<name>.sh`, `assets/oci/bootify.sh` (which runs under the container's `/bin/sh`), and `assets/oci/kernel.sh`.

---

## Base image recipe and package replay

`base` is the root image of every chain. `build.Env.basePlan` constructs the `base` recipe by combining backend `Origin` steps with guest `Steps`.

```mermaid
flowchart LR
    subgraph Origin["Recipe.Origin (Lima backend)"]
        O1["ubuntu-image<br/>release + file + sha256"]
        O2["oci/build.sh + bootify.sh + kernel.sh<br/>(--from only, root=true)"]
        O3["disk<br/>100GiB"]
        O4["base.yaml<br/>assets/base.yaml"]
        O1 --> O2 --> O3 --> O4
    end
    subgraph GuestSteps["Recipe.Steps (in-VM bash)"]
        S1["apt.sh begin<br/>(root=true)"]
        S2["base.sh<br/>(root=false)"]
        S3["<feature>.sh ...<br/>(cli-tools, fzf, omb)"]
        S4["apt.sh end<br/>(root=true)"]
        S1 --> S2 --> S3 --> S4
    end
    Origin --> GuestSteps
```

### Lima origin steps and Ubuntu cloud image pinning

On the `lima` backend, `images.Origin` (`internal/backend/lima/lima.go`) produces the following `Recipe.Origin` sequence:

1. **`ubuntu-image`:** Single argument formatted by `ubuntu.PinArg` from `~/.local/share/devvm/images/ubuntu.json` (`<release> <file> <sha256>`). On the first build, `PinOrigin` fetches `cloud-images.ubuntu.com` for Ubuntu 26.04 (`resolute`), verifies `SHA256SUMS.gpg` in Go against `assets/layers/ubuntu-cloud.gpg` (fingerprint `D2EB44626FDDC30B513D5BB71A5D6C4C7DB87C81`), and pins the newest dated release (`release-YYYYMMDD`) under `~/.local/share/devvm/images/locks/ubuntu`. If a newly pinned Ubuntu image fails to boot during the same run (`backend.ErrBoot`), `Builds.fallBack` warns (`origin_no_boot`), pins the newest release older than the failed one (`backend.PinOlder`), and retries once.
2. **OCI conversion steps (`--from` only):** When building a `--from` container base, `convertSteps` appends three steps:
   - `oci/build.sh` (`Root: true`, `OwnScript` with `prelude.sh`, `Args: [digest, ref, username, uid, "/home/<username>.guest", "20G"]`)
   - `oci/bootify.sh` (`Root: false`, raw file bytes)
   - `oci/kernel.sh` (`Root: false`, raw file bytes)
3. **`disk`:** Single argument `"100GiB"` (`backend.BaseDiskGiB`).
4. **`base.yaml`:** Raw bytes of `assets/base.yaml`, which sets `param.internal_netplanOptional: "true"` and disables system and user `containerd`.

### Base provisioning (`assets/layers/base.sh`) and features

Between `apt.sh begin` and `apt.sh end`, `base` runs `layers/base.sh` followed by the user's selected features (`internal/build/features.go`):

- **Core packages & pruning (`layers/base.sh`):**
  - Installs `git`, `curl`, `wget`, `ca-certificates`, `build-essential` (`build-base` on Alpine), `unzip`, `jq`, `ripgrep`, `erofs-utils`, `sqlite3` (`sqlite` on Alpine), and the newest `libicu[0-9]+` (`icu-libs` on Alpine).
  - Installs `/usr/local/bin/devvm-fetch`, which enforces SHA-256 (or SHA-512 when the expected hash is 128 hex characters) verification on all script downloads.
  - On `gnu` (Ubuntu), marks essential server utilities manual (`bc`, `file`, `htop`, `lsof`, `nano`, `rsync`, `screen`, `strace`, `tcpdump`, `time`, `tmux`, `mtr-tiny`, `pciutils`, `usbutils`, `inetutils-telnet`, `dmidecode`, `lshw`, `linux-image-virtual`), purges unnecessary server daemons (`snapd`, `multipath-tools`, `modemmanager`, `udisks2`, `unattended-upgrades`, `fwupd`, `rsyslog`, `bpftrace`, `apport`, `plymouth`, etc.), and masks `apt-daily.timer` and `apt-daily-upgrade.timer`.
  - Installs pinned `mise` (`mise_version=2026.11.3`) to `/usr/local/bin/mise`, configures `/etc/profile.d/devvm-tools.sh`, `/etc/systemd/system/devvm-tools.service` (or `/etc/init.d/devvm-tools` on OpenRC), `/usr/local/bin/xdg-open`, `/etc/profile.d/devvm-telemetry.sh`, GitHub SSH host keys in `/etc/ssh/ssh_known_hosts`, and `/etc/ssh/sshd_config.d/00-devvm.conf` (restricting remote port forwards to `127.77.*:*`).
- **Optional base features (`assets/features/*.sh`):** Configured via `devvm setup` or `features` in `~/.config/devvm/config.toml` (defaulting to all features until chosen). Sorted alphabetically in the recipe:

| Feature | Script | Installed components and pins |
| --- | --- | --- |
| `cli-tools` | `assets/features/cli-tools.sh` | `bat`, `fd` (`fd-find`), `zoxide`, `btop`, `tree` via `apt-get`/`apk`, plus pinned `eza` (`0.23.5`) and `tealdeer` (`1.9.1`, installed as `/usr/local/bin/tldr`) via `devvm-fetch`. |
| `fzf` | `assets/features/fzf.sh` | `fzf` via `apt-get`/`apk` and bash keybindings in `~/.bashrc.d/fzf.sh`. |
| `omb` | `assets/features/omb.sh` | Clones `oh-my-bash` and checks out pinned commit `94c2977eda06f230aafa9628fcd869123f01896b` with `DISABLE_AUTO_UPDATE="true"`. |

If `config.toml` or `settings.json` names a feature that has no script in `assets/features/`, `basePlan` records it in `planned.unknown`, omits it from `Recipe.Steps` (and therefore the key), and logs a warning (`unknown_feature`) when building `base`.

### Deterministic `apt` package recording and replay (`internal/build/packages.go`)

For default Ubuntu `base` builds (`From == ""`), `devvm` guarantees that rebuilding or recreating the same `base` key installs identical `.deb` packages offline:

<Tabs>
  <Tab title="First build (Record)">
    When `~/.local/share/devvm/packages/sets/<key>.json` does not exist:
    1. Host streams `in/mirrors` (containing `ubuntu-mirror` from `config.toml`, if configured) to `/var/cache/devvm/packages/in/mirrors` inside the build VM.
    2. `apt.sh begin` enables `APT::Keep-Downloaded-Packages "true"`, `APT::Get::Always-Include-Phased-Updates "true"`, and `Acquire::GzipIndexes "false"`, rewrites `/etc/apt/sources.list.d/*.sources` to try `in/mirrors` before the official archive, and runs `apt-get update --error-on=any` (exiting `75` on unreachable sources).
    3. After `base.sh` and feature scripts complete, `apt.sh end` copies every `*_InRelease` and `*_Packages` file to `/var/cache/devvm/packages/out/<n>.InRelease` and `<n>.Packages`, verifies every downloaded `.deb` against `apt-cache show`, writes `/var/cache/devvm/packages/out/Packages`, and moves each archive to `out/<sha256>.deb`.
    4. Host reads the tar stream from `out/` and validates it in Go (`internal/build/packages.go`):
       - Verifies clearsigned `InRelease` signatures against `assets/layers/ubuntu-archive.gpg` (fingerprint `F6ECB3762474EDA9D21B7022871920D1991BC93C`).
       - Checks suite names (`resolute`, `resolute-updates`, `resolute-security`, `resolute-backports`; refuses `resolute-proposed`) and anti-rollback `Date:` headers (`resolute` $\ge$ `2026-04-23`; other pockets no more than 24 hours older than the pinned Ubuntu cloud image date).
       - Verifies every `<n>.Packages` SHA-256 and size against its signed `InRelease`, verifies that all 4 components (`main`, `restricted`, `universe`, `multiverse`) for `resolute-updates` and `resolute-security` were returned, and checks with `ubuntu.CompareVersions` that each recorded `.deb` is the newest version across `resolute`, `resolute-updates`, and `resolute-security`.
    5. Host commits `.deb` files to `~/.local/share/devvm/packages/<sha256>.deb` and writes `~/.local/share/devvm/packages/sets/<key>.json` under `~/.local/share/devvm/images/locks/packages`.
  </Tab>
  <Tab title="Subsequent builds (Replay)">
    When `~/.local/share/devvm/packages/sets/<key>.json` exists:
    1. Before creating the VM, `cache.checkCached` verifies the size and SHA-256 of every `.deb` in `~/.local/share/devvm/packages/<sha256>.deb`. If any file is missing or modified, the build aborts with `packages_missing`.
    2. Host streams `in/Packages` and all `<sha256>.deb` files into `/var/cache/devvm/packages/in/`.
    3. `apt.sh begin` moves `/etc/apt/sources.list` and `/etc/apt/sources.list.d/*` aside to `/etc/apt/devvm-sources` and writes `deb [trusted=yes] file:/var/cache/devvm/packages/in ./` to `/etc/apt/sources.list.d/devvm-replay.list`.
    4. `base.sh` and features install packages strictly from local disk without touching the network.
    5. `apt.sh end` restores the original apt sources, deletes `/var/lib/apt/lists/*`, and runs `apt-get clean`.
  </Tab>
</Tabs>

---

## Layers and image chains

Layers stack on top of `base` in a linear parent-child chain. `image.Name` joins layer names bottom-first with `+`: for example, `docker+nix` builds `docker` on `base`, then clones `docker` to build `nix`.

### Naming rules and layer resolution

`image.LayerName` (`internal/image/key.go`) and `build.CheckLayerName` (`internal/build/layer.go`) enforce strict naming rules because image names map to filesystem paths and Lima instance names:

- **Grammar:** `^[a-z0-9][a-z0-9-]*$` (lowercase ASCII letters, digits, and hyphens, starting with a letter or digit).
- **Reserved names:** `"base"`, built-in layer names (`"desktop"`, `"docker"`, `"nix"`), and shared tool names (`"node"`, `"python"`, `"rust"`, `"go"`, `"dotnet"`, `"claude"`, `"codex"`, `"opencode"`) are rejected when creating a custom layer (`layer_name_reserved`). Passing a shared tool name to `devvm build` or `--layers` returns `tool_not_layer`.
- **Normalization (`image.NameOf` / `image.ParseName`):** Strips `"base"`, empty segments, and duplicate layer names while preserving first-seen bottom-up order. `base+docker+nix+docker` normalizes to `docker+nix`; `base` alone has `Layers() == nil` and `Parent() == ""`.

### Layer recipe structure

Every layer image (`name != "base"`) has an empty `Recipe.Origin` (its VM is cloned from ` ImagePrefix + spec.Parent` via `limactl clone --tty=false --mount-none`) and exactly two `Recipe.Steps`:

1. **`apt.sh` (`Root: true`, `Args: ["lists"]`):** Runs `assets/layers/apt.sh` with `prelude.sh`. Because `base` deletes `/var/lib/apt/lists/*` at the end of its build, `apt.sh lists` runs `apt-get update --error-on=any` if no `*_Packages` lists exist so the layer script can install distribution packages directly.
2. **`<layer>.sh` (`Root: false`, `Args: nil`):** Runs as the VM's default user (with passwordless `sudo`). For built-in layers, `Env.LayerData` loads `assets/layers/<layer>.sh` via `image.OwnScript` (prepending `assets/prelude.sh`). For custom layers, `Env.LayerData` reads `~/.config/devvm/layers/<layer>.sh` verbatim without `prelude.sh`.

### Built-in layers (`desktop`, `docker`, `nix`)

| Layer | Script & Policy | Backend capability | Provisioning and pinning details |
| --- | --- | --- | --- |
| `desktop` | `assets/layers/desktop.sh`<br/>`assets/layers/desktop.policy` | None (uses standard SSH forwarding) | Installs Xfce4 (`xfce4-session`, `xfwm4`, `xfce4-panel`, `xfdesktop4`, `xfce4-settings`, `xfce4-terminal`, `thunar`), TigerVNC (`tigervnc-standalone-server`, `tigervnc-tools`), `dbus-x11`, `xauth`, `socat`, and DejaVu fonts from the distribution archive. Installs `/usr/local/bin/devvm-desktop`, which starts `Xvnc` on display `:1` bound exclusively to Unix socket `/run/devvm-desktop/vnc.sock` (`0600`, `-rfbport -1 -nolisten tcp`). |
| `docker` | `assets/layers/docker.sh`<br/>`assets/layers/docker.policy` | `docker-layer` (`backend.FeatDockerLayer`) | On `gnu`, fetches Docker's apt keyring via `devvm-fetch` (pinned `docker_gpg_sha256`) and installs pinned versions of `docker-ce` (`29.9.3`), `docker-ce-cli` (`29.9.3`), `docker-ce-rootless-extras` (`29.9.3`), `containerd.io` (`2.4.2`), `docker-buildx-plugin` (`0.38.1`), `docker-compose-plugin` (`5.7.0`), and `docker-model-plugin` (`1.2.9`). On `musl` (Alpine), installs `docker`, `docker-cli-buildx`, and `docker-cli-compose` and enables the OpenRC service. Adds `$USER` to the `docker` group. |
| `nix` | `assets/layers/nix.sh`<br/>`assets/layers/nix.policy` | `nix-layer` (`backend.FeatNixLayer`) | Fetches pinned Nix (`2.35.4`), `direnv` (`2.38.2`), and `nix-direnv` (`3.2.0`) via `devvm-fetch`. Runs Nix's multi-user installer with `experimental-features = nix-command flakes`, `trusted-users = root`, `sandbox = true`, and `sandbox-fallback = false` (stripping build-time proxy env vars so they are not baked into `nix-daemon`), verifies `/nix/store` entries are owned by `root`, adds `/nix/var/nix/profiles/default/bin` to `/etc/sudoers.d/devvm-nix` and `/etc/profile.d/devvm-tools.sh`, and configures `~/.config/direnv/lib/devvm-nix.sh` to store direnv layouts inside `~/.cache/direnv/layouts/<sha256(PWD)[:16]>` instead of the host project folder. |

### Custom layers (`devvm layer new`)

Running `devvm layer new <name>` calls `build.NewLayer` (`internal/build/layer.go`), which atomically links two files into `~/.config/devvm/layers/`:

- `~/.config/devvm/layers/<name>.sh`: Bash provisioning script template (`set -euo pipefail`).
- `~/.config/devvm/layers/<name>.policy`: Network allowlist rules read at VM runtime on the `allowlist` tier (never hashed into the image key).

### Chain propagation and staleness detection

`build.Env.plan` computes the `image.Chain` (`[]image.Link`) from `base` up to the target image. The chain is serialized as space-separated `name=key` pairs in `~/.local/share/devvm/images/<key>/record.json` and in each project VM's `~/.local/share/devvm/vms/<hash>/vm.json`:

```text
base=1a2b3c4d5e6f docker=7a8b9c0d1e2f docker+nix=3a4b5c6d7e8f
```

```mermaid
flowchart TD
    subgraph Cached["Recorded Chain in vm.json"]
        B1["base = 1a2b3c4d5e6f"] --> D1["docker = 7a8b9c0d1e2f"]
        D1 --> DN1["docker+nix = 3a4b5c6d7e8f"]
    end
    subgraph Recomputed["Current Chain (after editing docker.sh or devvm rebuild docker)"]
        B2["base = 1a2b3c4d5e6f<br/>(unchanged — reused)"] --> D2["docker = 9f8e7d6c5b4a<br/>(new script/rebuild counter)"]
        D2 --> DN2["docker+nix = 5c4d3e2f1a0b<br/>(Parent key changed)"]
    end
    D1 -. "now.Key() != l.Key" .-> D2
    DN1 -. "now.Key() != l.Key" .-> DN2
```

When `Builds.Outdated(chain)` checks a VM's recorded chain, it recomputes the current key for each link in the chain. If any link's current key differs from the recorded key:
- Launching the VM prints a warning (`vm_outdated`) naming the changed images (`docker, docker+nix`) without modifying the running VM.
- Running `devvm update` rebuilds any unbuilt images in the chain and remakes the VM from the updated image while preserving the VM's home directory.
- On Linux hosts where Lima uses copy-on-write qcow2 overlays (`l.overlays()`), `BuildImage` strips write permissions (`chmod a-w`) from the finished image's `disk` file (`~/.local/share/devvm/lima/devvm-img-<key>/diffdisk`) so backing images cannot be mutated.

---

## Shared tool EROFS images and keys

Toolchains (`node`, `python`, `rust`, `go`, `dotnet`) and coding agents (`claude`, `codex`, `opencode`) are not baked into VM disk layers. Instead, each tool version is packaged into an immutable, lz4hc-compressed EROFS filesystem image stored in `~/.local/share/devvm/tools/<tool>-<version>-<key>.erofs` and mounted read-only across VMs.

### Tool definitions (`image.SharedTools`)

`internal/image/tool.go` defines the fixed ordering and group membership for all shared tools:

| Tool name | Kind | Lock members (`GroupTools`) | Auto-detect project files (`Detect`) | Installer (`Installer`) |
| --- | --- | --- | --- | --- |
| `node` | `Toolchain` | `node`, `pnpm`, `bun` | `package.json`, `bun.lock`, `bun.lockb` | `Mise` |
| `python` | `Toolchain` | `python`, `uv` | `pyproject.toml`, `requirements.txt`, `uv.lock`, `setup.py`, `Pipfile` | `Mise` |
| `rust` | `Toolchain` | `rust` | `Cargo.toml` | `Rustup` |
| `go` | `Toolchain` | `go` | `go.mod` | `Mise` |
| `dotnet` | `Toolchain` | `dotnet` | `*.sln`, `*.slnx`, `*.csproj`, `*.fsproj`, `global.json` | `DotnetSDK` |
| `claude` | `Agent` | `claude` | — | `Mise` |
| `codex` | `Agent` | `codex` | — | `Mise` |
| `opencode` | `Agent` | `opencode` | — | `Mise` |

### EROFS tool filename and key inputs (`image.ToolFile`)

`image.ToolFile(assets, t)` validates the tool name (`^[a-z][a-z0-9]*$`), version (`^[0-9A-Za-z][0-9A-Za-z._+-]*$`), and libc constraints, then computes `<tool>-<version>-<key>.erofs` using `newKeyHash()` with the following length-prefixed fields:

```go title="internal/image/tool.go"
k := newKeyHash()
k.field("tool", t.Name)
k.field("version", t.Version)
k.bytes("lock-entry", t.LockEntry)
script, err := OwnScript(assets, "shared-tools/build.sh")
// ...
k.bytes("file shared-tools/build.sh", script)
settings, err := miseSettings(assets)
// ...
k.bytes("mise-settings", settings)
if installerOf(t.Name) == Mise {
	v, err := MiseVersion(assets)
	// ...
	k.field("mise", v)
}
k.field("libc", t.Libc)
if t.Base != "" {
	k.field("base", t.Base)
} else {
	k.field("base", "devvm")
}
if t.Format != "" {
	k.field("format", t.Format)
}
return t.Name + "-" + t.Version + "-" + k.sum() + ".erofs", nil
```

| Hash field tag | Value hashed | Why it is included |
| --- | --- | --- |
| `"format"` | `"devvm image key 1"` | Initialized automatically by `newKeyHash()`. |
| `"tool"` | `t.Name` (e.g., `"node"`, `"pnpm"`, `"claude"`) | Distinguishes individual lock members (`node` builds separate EROFS images for `node`, `pnpm`, and `bun`). |
| `"version"` | `t.Version` | Exact resolved upstream version from `~/.local/share/devvm/mise.lock`. |
| `"lock-entry"` | `t.LockEntry` (`tools.Lock.Entry`) | Canonical JSON serialization (`json.Marshal`, keys sorted) of the tool's first `[[tools.<name>]]` table in `mise.lock`, including all platform download URLs and checksums. Ignores TOML formatting, comments, or key order. |
| `"file shared-tools/build.sh"` | `OwnScript(assets, "shared-tools/build.sh")` | Concatenation of `assets/prelude.sh` and `assets/shared-tools/build.sh` (which also holds `rustup_version` and `rustup_sha256_*` pins). |
| `"mise-settings"` | `miseSettings(assets)` | Canonical JSON serialization of the `[settings]` table in `assets/shared-tools/mise.toml` (`{"experimental":true,"python":{"compile":false}}`). Ignores comments and `[tools]` / `[tool_alias]` tables. |
| `"mise"` | `MiseVersion(assets)` (e.g., `"2026.11.3"`) | **Included only when `installerOf(t.Name) == Mise`.** Extracted by regex `(?m)^mise_version=([0-9A-Za-z._-]+)$` from `assets/layers/base.sh`. Omitted for `rust` (`Rustup`) and `dotnet` (`DotnetSDK`) so bumping `mise` does not invalidate tools `mise` does not install. |
| `"libc"` | `"gnu"` or `"musl"` | Separates glibc and musl builds. |
| `"base"` | `"devvm"` (for `gnu`) or `--from` digest (for `musl`) | `gnu` tool images always build inside `devvm`'s own Ubuntu `base` (`t.Base == ""`, hashed as `"devvm"`), allowing Ubuntu VMs and `--from` Debian/Ubuntu VMs to share the same `gnu` tool images without trusting container code during tool builds. `musl` tool images build inside the `--from` Alpine base and require `t.Base` to be that image's digest. |
| `"format"` | `t.Format` (omitted when `""`) | Backend-specific EROFS format (`Backend.ToolFormat()`). Lima returns `""` (compressed EROFS via `mkfs.erofs -zlz4hc --all-root`). |

<Warning>
`image.ToolFile` returns an error if `t.Libc == "musl"` and `t.Base == ""` (`"a musl tool image needs its base's digest"`), or if `t.Libc == "gnu"` and `t.Base != ""` (`"a gnu tool image builds in devvm's own base, which has no digest in its key"`). Furthermore, `Share.buildsFor` checks the base image's `record.json` before cloning the build VM and refuses the build with `tools_wrong_base` if the base image record does not match these invariants.
</Warning>

### Tool image build isolation and `GLIBC_` version scanning

When `Share.Build` (`internal/tools/share.go`) builds missing `.erofs` files:

1. **Share lock:** Acquires `~/.local/share/devvm/tools.lock` (`LockShare`).
2. **Per-tool throwaway VM:** For each missing tool image, creates an isolated temporary directory `~/.local/share/devvm/tools-build.<random>` containing `.build/mise.toml` and `.build/mise.lock`, clones `base` into `devvm-tools-build` with only that temporary directory mounted writable at `/mnt/devvm-tools` (never mounting `~/.local/share/devvm/tools`), and runs `assets/shared-tools/build.sh`.
3. **Installation & EROFS packing (`assets/shared-tools/build.sh`):**
   - Installs into `/opt/devvm/tools/<tool>/<version>` (using `rustup-init` with `rust-src` and `rust-analyzer` for `rust`, `devvm-fetch` with the lock's SHA-512 SDK tarball and a `metadata/workloads/<band>/userlocal` marker for `dotnet`, or `mise install --shared /opt/devvm/tools <tool>` plus `corepack enable yarn` on `node` for `Mise` tools).
   - On `gnu` builds, runs `glibc_needs` across all installed ELF files (excluding `testdata` directories) using `readelf -W -V` to extract the highest `GLIBC_2.x` symbol version requirement into `/mnt/devvm-tools/<file>.glibc`.
   - Packs `/opt/devvm/tools/<tool>/<version>` with `mkfs.erofs -zlz4hc --all-root /mnt/devvm-tools/<file>.tmp <dir>` and renames it to `/mnt/devvm-tools/<file>`.
4. **Host verification and record commit (`Share.moveIn`):** After `devvm-tools-build` is deleted, the host verifies with `os.Lstat` that `<file>` and `<file>.glibc` are regular files (`tool_bad_output` otherwise), hashes `<file>` with `image.SHA256File`, moves `<file>` into `~/.local/share/devvm/tools/<file>`, and writes `~/.local/share/devvm/tools/<file>.json` (`state.ToolImage`).
5. **Compatibility check (`Share.Runs`):** Before mounting `gnu` tool images into a `--from` VM whose base image recorded a `glibc` version (`getconf GNU_LIBC_VERSION`), `Share.Runs` compares the base's `glibc` against each tool record's `glibc` via `image.GlibcOlder`. If the `--from` base has an older glibc than the tool requires, `devvm` refuses with `tool_needs_glibc`.
6. **Guest mounting (`assets/guest/tools.sh`):** Inside project VMs, `/usr/local/sbin/devvm-tools` reads `/etc/devvm/tools` (`<FILE> <TOOL> <VERSION>` per line), attaches `/mnt/devvm-tools/<FILE>` to a read-only loop device with `losetup --find --show --read-only --direct-io=on`, and mounts it with `mount -t erofs -o ro` at `/opt/devvm/tools/<TOOL>/<VERSION>`. Any unmounted tool is written to `/run/devvm/tools-unmounted` and aborts VM setup with `tools_unmounted`.

---

## State layout and metadata schemas

All image and tool records live under `~/.local/share/devvm` (`paths.Dirs.Data`) and custom layer definitions live under `~/.config/devvm` (`paths.Dirs.Config`).

:::files
~/.config/devvm/
├── config.toml
└── layers/
    ├── <layer>.sh                # Custom layer script (hashed in Recipe.Steps)
    └── <layer>.policy            # Custom layer allowlist (runtime only, not hashed)
~/.local/share/devvm/
├── making.lock                   # Shared lock held while a VM selects/builds tools
├── mise.lock                     # Resolved upstream tool versions and platform checksums
├── oci-build.lock                # Exclusive lock for --from container conversions
├── tools.lock                    # Exclusive lock for writing to tools/ or mise.lock
├── cache/
│   ├── from-digests.json         # Cached --from registry manifest digests
│   └── mise/<version>/mise       # Pinned host mise binary for resolving mise.lock
├── images/
│   ├── rebuilds.json             # Per-(backend, from, image) rebuild counters
│   ├── ubuntu.json               # Pinned Ubuntu 26.04 cloud image release & SHA-256
│   ├── locks/
│   │   ├── <key>                 # Per-image build/delete lock
│   │   ├── packages              # Package cache commit lock
│   │   ├── rebuilds              # rebuilds.json update lock
│   │   └── ubuntu                # ubuntu.json pin lock
│   └── <key>/
│       └── record.json           # Written last when image build succeeds (state.Image)
├── packages/
│   ├── <sha256>.deb              # Content-addressed Ubuntu .deb archive
│   └── sets/
│       └── <key>.json            # Recorded package set for base image <key>
└── tools/
    ├── <tool>-<ver>-<key>.erofs  # Compressed read-only EROFS filesystem image
    └── <tool>-<ver>-<key>.erofs.json # Tool image metadata record (state.ToolImage)
:::

### JSON record schemas

<CodeGroup>
```json title="images/<key>/record.json (state.Image)"
{
  "name": "docker+nix",
  "chain": "base=1a2b3c4d5e6f docker=7a8b9c0d1e2f docker+nix=3a4b5c6d7e8f",
  "from": "",
  "digest": "https://cloud-images.ubuntu.com/releases/26.04/release-20260423/ubuntu-26.04-server-cloudimg-arm64.img@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "libc": "gnu",
  "glibc": "",
  "built": "2026-11-07T15:00:00Z",
  "made_by": "0.1.0"
}
```

```json title="images/rebuilds.json (state.Rebuilds)"
{
  "rebuilds": [
    {
      "backend": "lima",
      "from": "",
      "name": "base",
      "counter": "20261107150405-a1b2c3d4"
    }
  ]
}
```

```json title="tools/<tool>-<ver>-<key>.erofs.json (state.ToolImage)"
{
  "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
  "libc": "gnu",
  "glibc": "2.28",
  "base": "",
  "format": "",
  "backend": "lima",
  "built": "2026-11-07T15:05:00Z",
  "made_by": "0.1.0"
}
```
</CodeGroup>

### Concurrency and lock ordering

`devvm` coordinates concurrent processes using OS file locks (`flock` via `internal/platform`) on files outside the directories that guests mount:

| Lock path | Scope and deadlock prevention |
| --- | --- |
| `~/.local/share/devvm/images/locks/<key>` | Held exclusively by `Builds.lockBuild` while building `<key>` and by `Env.lockFree` while deleting `<key>`. A second `devvm` building the same key waits (printing `image_build_waiting` after 3s) and then observes `built == true`. If a previous build crashed before writing `record.json`, the lock is free and the next build deletes the `leftover` instance (`image_leftover`) before rebuilding. |
| `~/.local/share/devvm/making.lock` & `~/.local/share/devvm/tools.lock` | Processes creating or updating a VM take `making.lock` **shared**, then acquire `tools.lock` **exclusively** inside `Share.Build`, keeping `making.lock` until `vm.json` records the VM's tool images. `devvm gc --tool-images` and automatic retention acquire `tools.lock` **exclusively** first, then attempt a non-blocking exclusive lock (`TryLockFile`) on `making.lock`. Because `gc` never blocks on `making.lock`, the two-lock protocol cannot deadlock and never deletes a current tool image while a new VM is being created. |

## Related pages

<CardGroup cols={2}>
  <Card title="Manage toolchains and custom layers" href="/toolchains-and-custom-layers">
    Add, remove, and update shared mise toolchains and author custom image layers with network policies via devvm layer new.
  </Card>
  <Card title="Use devcontainer.json and OCI base images" href="/devcontainers-and-oci-images">
    Translate .devcontainer/devcontainer.json properties into VM plans and build bootable VM base images from OCI/Docker container references with --from.
  </Card>
  <Card title="Backend contract and Lima driver" href="/backend-architecture">
    The four-role backend.Backend interface (Host, Images, Machines, Housekeeping), capability requirement checks (Caps), and the Lima driver implementation.
  </Card>
  <Card title="CLI reference: images, storage, and host commands" href="/cli-image-space-and-host-commands">
    Syntax, subcommands, and flags for devvm build, rebuild, images, layer, tools, space, gc, retention, and setup.
  </Card>
</CardGroup>
