# Use devcontainer.json and OCI base images

> Translate .devcontainer/devcontainer.json properties into VM plans and build bootable VM base images from OCI/Docker container references with --from.

- 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/devcontainer/devcontainer.go`
- `internal/devcontainer/reread.go`
- `internal/vm/devcontainer.go`
- `internal/from/from.go`
- `internal/from/registry.go`
- `internal/backend/lima/oci.go`
- `assets/oci/build.sh`

---

---
title: "Use devcontainer.json and OCI base images"
description: "Translate .devcontainer/devcontainer.json properties into VM plans and build bootable VM base images from OCI/Docker container references with --from."
---

`devvm` reads project `.devcontainer/devcontainer.json` or `.devcontainer.json` files through `internal/devcontainer` to configure VM base images, shared toolchains, layers, port forwards, environment variables, and lifecycle commands, and converts OCI/Docker container images specified via `--from`, `DEVVM_FROM`, or `devcontainer.json` `image` into bootable VM disks inside an isolated `devvm-oci-build` throwaway VM.

## Configuration precedence and discovery

When creating a project VM (`devvm`) or re-evaluating a project on `devvm update`, `internal/vm.devcontainers` opens the project directory using `os.OpenRoot` (`O_RDONLY|platform.NoBlock`) so symlinks outside the project folder are never traversed and FIFOs never block. `devcontainer.Find` inspects `.devcontainer/devcontainer.json` first and falls back to `.devcontainer.json` at the project root, accepting only regular files up to `1 MiB` (`1048576` bytes) and stripping JSON comments and trailing commas via `jsonc.Strip`.

Command-line flags always override `devcontainer.json` properties, which in turn override global defaults and environment variables:

```mermaid
flowchart LR
  subgraph CLI["CLI Flags (Highest Precedence)"]
    FromFlag["--from IMAGE"]
    PortsFlag["--ports LIST"]
    ToolsLayersFlags["--tools / --layers"]
    NoDC["--no-devcontainer"]
  end

  subgraph ProjectFile["Project devcontainer.json"]
    DCImage["image (public registry only)"]
    DCPorts["forwardPorts (localhost / 127.0.0.1)"]
    DCFeatures["features (toolchains + docker)"]
    DCEnvCmds["containerEnv / remoteEnv / commands"]
  end

  subgraph Defaults["Host Defaults & Fallbacks"]
    EnvFrom["$DEVVM_FROM"]
    UbuntuCloud["Ubuntu 26.04 Cloud Image"]
    DefaultPorts["settings.Defaults.Ports"]
    DetectedParts["detectFiles + setup defaults"]
  end

  subgraph PlanOut["Resolved VM Plan (newVM)"]
    BaseOrigin["Base Image Origin"]
    PortForwards["Forwarded Host Ports"]
    VMParts["Image Layers & Shared Tools"]
  end

  NoDC -. "disables" .-> ProjectFile
  FromFlag --> BaseOrigin
  DCImage --> BaseOrigin
  EnvFrom --> BaseOrigin
  UbuntuCloud --> BaseOrigin

  PortsFlag --> PortForwards
  DCPorts --> PortForwards
  DefaultPorts --> PortForwards

  ToolsLayersFlags --> VMParts
  DCFeatures --> VMParts
  DetectedParts --> VMParts
```

### CLI flags and environment variables

<ParamField body="--from" type="string">
  Starts the `base` image from an OCI or Docker container reference (for example, `node:24`, `alpine:3`, `debian:13`, or `node:24@sha256:...`). Supported on `devvm` (new VMs), `devvm build`, `devvm rebuild`, and `devvm images rm`. Overrides `devcontainer.json` `image` (emitting a `devcontainer_flag_wins` warning) and `DEVVM_FROM`.
</ParamField>

<ParamField body="--no-devcontainer" type="boolean" default="false">
  Skips reading `.devcontainer/devcontainer.json` and `.devcontainer.json` when creating a new VM, and records `"off": true` in the VM state's `devcontainer` section so subsequent launches and `devvm update` ignore any devcontainer file in the project folder.
</ParamField>

<ParamField body="DEVVM_FROM" type="string">
  Environment variable (`from.Env`) setting the default `--from` container image reference when neither `--from` nor a `devcontainer.json` `image` property is present.
</ParamField>

---

## `devcontainer.json` translation reference

`devcontainer.Parse` decodes the top-level JSON object into a `devcontainer.Plan` and classifies every property key via `kind(key)`. Properties that attempt to execute code on the host, mount host paths, or build Dockerfiles are either ignored with a warning or refused outright before any VM or image build starts.

### Property handling matrix

| Property | Classification | `devvm` behavior |
| --- | --- | --- |
| `image` | Mapped (`keyUsed`) | Sets `Plan.Image`. Must resolve anonymously on a public internet registry (`from.Lookup.Public`) and is pinned to `name@sha256:<digest>`. Private images, local Docker daemon images, `localhost`, and private/link-local IP addresses are refused with `devcontainer_image_not_public`. |
| `features` | Mapped (`keyUsed`) | Maps supported Dev Container feature IDs to `devvm` built-in layers or shared toolchains. Feature options or version strings are ignored with a `devcontainer_feature_options` warning. Scripts from feature registries are never fetched or executed. |
| `forwardPorts` | Mapped (`keyUsed`) | Accepts integer ports (`1`–`65535`) or `"localhost:N"` / `"127.0.0.1:N"` strings and appends them to the VM's forwarded port list on `127.0.0.1`. Entries for other hosts or out-of-range ports are ignored with `devcontainer_ports_ignored`. |
| `containerEnv`, `remoteEnv` | Mapped (`keyUsed`) | Merged into `Plan.Env` (sorted by variable name) and written as `export NAME=VALUE` lines in the guest environment. `remoteEnv` overrides `containerEnv`. A `null` value in `remoteEnv` unsets the entry from `Plan.Env`, whereas `null` in `containerEnv` is refused (`devcontainer_bad_value`). Variable names must match POSIX `[A-Za-z_][A-Za-z0-9_]*` (`shell.EnvName`). |
| `onCreateCommand`, `updateContentCommand`, `postCreateCommand` | Mapped (`keyUsed`) | Appended in order to `Plan.Create`. Executed once inside the VM after initial setup completes. |
| `postStartCommand` | Mapped (`keyUsed`) | Stored in `Plan.Start`. Executed inside the VM once per VM boot (`b.Running` boot ID hash). |
| `initializeCommand` | Warned (`keyInitialize`) | Never executed because `initializeCommand` targets the host machine. Emits `devcontainer_initialize`. |
| `build`, `dockerFile`, `dockerfile`, `dockerComposeFile` | Refused (`keyBuild`) | Fails parsing immediately with `devcontainer_build`. `devvm` only starts VMs from prebuilt images. |
| `name`, `customizations`, `$schema` | Silent (`keySilent`) | Ignored silently without warnings. |
| `postAttachCommand`, `privileged`, `capAdd`, `securityOpt`, `mounts`, `runArgs`, `workspaceMount`, `workspaceFolder`, `remoteUser`, `containerUser`, `updateRemoteUserUID`, `init`, `shutdownAction`, `overrideCommand`, `hostRequirements`, `appPort`, `portsAttributes`, `otherPortsAttributes`, `userEnvProbe`, `waitFor`, `overrideFeatureInstallOrder`, `context`, `service`, `runServices` | Ignored (`keyIgnored`) | Ignored and reported in a single `devcontainer_ignored` warning line listing the keys. |
| Any other property | Unknown (`keyUnknown`) | Ignored and reported in a single `devcontainer_unknown` warning line listing the keys. |

### Feature ID mapping

`devcontainer.feature` strips any `:tag` or `@sha256:...` suffix (`featureName`), lowercases the identifier, and requires the prefix `ghcr.io/devcontainers/features/`:

| Feature ID (after stripping tag/digest) | Mapped `devvm` target | Kind |
| --- | --- | --- |
| `ghcr.io/devcontainers/features/docker-in-docker` | `docker` (`image.DockerLayer`) | Built-in image layer |
| `ghcr.io/devcontainers/features/node` | `node` | Shared toolchain |
| `ghcr.io/devcontainers/features/python` | `python` | Shared toolchain |
| `ghcr.io/devcontainers/features/rust` | `rust` | Shared toolchain |
| `ghcr.io/devcontainers/features/go` | `go` | Shared toolchain |
| `ghcr.io/devcontainers/features/dotnet` | `dotnet` | Shared toolchain |
| `ghcr.io/devcontainers/features/docker-outside-of-docker` | Refused (`devcontainer_docker_outside`) | Error (exposes host Docker) |
| Any other feature ID or publisher | Refused (`devcontainer_feature`) | Error |

### Variable substitution rules

`parser.subst` processes `${...}` expressions across `image`, `forwardPorts`, `containerEnv`, `remoteEnv`, and lifecycle command strings or arrays. Strings containing a NUL byte (`\x00`) are refused with `devcontainer_bad_value`.

| Variable syntax | Allowed contexts | Substitution result |
| --- | --- | --- |
| `${containerWorkspaceFolder}` | All contexts (`modeLiteral`, `modeShell`, `modeEnv`) | Replaced with the project folder path (`workspace`), which is identical on host and guest. |
| `${containerWorkspaceFolderBasename}` | All contexts (`modeLiteral`, `modeShell`, `modeEnv`) | Replaced with `path.Base(workspace)`. |
| `${containerEnv:VAR}` | String commands (`modeShell`) and `containerEnv` / `remoteEnv` (`modeEnv`) | In `modeShell`, becomes `${VAR}`. In `modeEnv`, surrounding literals are single-quoted via `shell.Quote` and the reference is emitted as `"${VAR}"`. Refused in array commands, `image`, `forwardPorts`, or if `VAR` has a default suffix (`${containerEnv:VAR:default}`). |
| `${localEnv:...}`, `${env:...}` | None | Refused in all contexts with `devcontainer_local_env` to prevent leaking host environment variables into the VM. |
| `${localWorkspaceFolder}`, `${localWorkspaceFolderBasename}`, `${devcontainerId}` | None | Refused in all contexts with `devcontainer_variable`. |
| Unrecognized `${OTHER}` (e.g., `${HOME}`) | All contexts | Left untouched as literal text for the guest shell to evaluate (single-quoted in `modeEnv`). |

### Lifecycle command execution

Each lifecycle command property (`onCreateCommand`, `updateContentCommand`, `postCreateCommand`, `postStartCommand`) accepts three JSON shapes:
- **String:** Executed via `sh -c "<command>"` (`Command{Name: key, Argv: ["sh", "-c", s]}`). Whitespace-only strings are skipped.
- **String array:** Executed directly without a shell wrapper (`Command{Name: key, Argv: argv}`). Empty arrays or arrays with `argv[0] == ""` are skipped.
- **Object of named commands:** Keys are sorted lexicographically, and each non-empty entry becomes `Command{Name: key + " " + name, Argv: argv}`.

At runtime, `devcontainers.run` executes each command inside the VM in the project directory (`backend.ExecOpts{Dir: dir, Agent: agent}`) using a login shell wrapper:

```sh
bash -lc 'exec "$@"' devvm <argv...>
```

1. **Create phase (`Plan.Create`):** Before running `onCreateCommand`, `updateContentCommand`, and `postCreateCommand`, `devvm` persists `"made": {"created": true}` to `vm.json` so an interrupted or failed command never re-runs on subsequent launches.
2. **Start phase (`Plan.Start`):** On every launch, `devvm` queries `b.Running(ctx, vm)` for the VM's current boot instance ID, hashes it with SHA-256 (`hex.EncodeToString(sum[:16])`), and compares it against `made.started`. If different, `devvm` updates `made.started` first and runs `postStartCommand`.
3. **Failure handling:** Command stdout and stderr stream to the run log. If a command exits non-zero, `devvm` logs a `devcontainer_command_failed` warning, skips the remaining commands in that phase, and continues opening the VM shell.

---

## Re-reading `devcontainer.json` on `devvm update`

Modifying `.devcontainer/devcontainer.json` does not alter an existing VM automatically. On launch, `devcontainers.check` compares the file's current path and SHA-256 against the `devcontainer` section recorded in `~/.local/share/devvm/vms/<hash>/vm.json` and warns once if the file was added (`devcontainer_new`), modified (`devcontainer_changed`), or deleted.

When you run `devvm update`, `devcontainers.reread` reconciles the previous state (`was`) with the newly parsed `Plan` (`now`) while preserving user-chosen overrides:

```go title="internal/devcontainer/reread.go"
func Reread(was, gave, has, now []string) (add, drop, gaveNow []string) {
	for _, p := range gave {
		switch {
		case !slices.Contains(has, p):
		case !slices.Contains(now, p):
			drop = append(drop, p)
		default:
			gaveNow = append(gaveNow, p)
		}
	}
	for _, p := range now {
		if slices.Contains(was, p) || slices.Contains(has, p) || slices.Contains(add, p) {
			continue
		}
		add = append(add, p)
		gaveNow = append(gaveNow, p)
	}
	return add, drop, gaveNow
}
```

- **Toolchains and layers (`rereadParts`):** `gave` tracks only the parts the VM received exclusively from `devcontainer.json` (excluding parts explicitly set via `--tools`/`--layers`, configured in `devvm setup` default layers, or auto-detected from project files by `detectFiles`). `Reread` drops items in `gave` that are no longer in `now`, adds items newly introduced in `now` (that weren't in `was` and aren't already in `has`), and never re-adds a feature the user previously removed or deselected.
- **Ports (`filePorts`):** Applies the same `Reread(was.Ports, was.PortsGave, has, listed)` logic via `RemovePorts` and `AddPorts`. Ports added manually with `devvm ports add` remain, and file ports removed with `devvm ports rm` are not re-added.
- **Base image (`Image` / `From`):** If the VM was originally created with `--from` (`was.FromFlag == true`), the CLI `--from` image is kept and `devcontainer_flag_wins` is emitted if the file specifies `image`. Otherwise, a changed `image` is pinned via `PublicImage`, or resets to `defaultFrom` (`$DEVVM_FROM` or Ubuntu cloud image) if `image` was removed.
- **Remake:** `devvm update` rebuilds the VM from the updated image and tool set, preserving the guest home directory, project folder, and VM settings, and runs the new `Plan.Create` and `Plan.Start` commands on the fresh root disk.

---

## OCI reference resolution and digest pinning

`internal/from` parses container references using Docker's rules (`from.ParseRef`) and resolves them to immutable SHA-256 digests so `base` image keys change automatically whenever an upstream tag moves.

### Reference syntax and parsing

All references must match `^[A-Za-z0-9][A-Za-z0-9._/:@+-]*$` (`from.Valid`). `from.ParseRef` splits a reference into `Host`, `Repo`, and `Tag`:
- If the first `/` segment contains `.` or `:`, or equals `localhost`, it is treated as the registry host (`docker.io` and `index.docker.io` normalize to `registry-1.docker.io`). Otherwise `Host` defaults to `registry-1.docker.io`.
- Single-segment repositories on Docker Hub receive the `library/` prefix (`node:24` becomes `Host: "registry-1.docker.io"`, `Repo: "library/node"`, `Tag: "24"`).
- Omitting a `:tag` defaults `Tag` to `"latest"`.
- References containing `@sha256:<64-hex>` (`from.Pinned`) bypass remote lookup for `--from`.

### CLI `--from` vs. `devcontainer.json` `image` lookup policies

`from.Lookup` maintains two separate registry clients and distinct security rules depending on whether you passed `--from` or the project's `devcontainer.json` specified `image`:

| Aspect | CLI `--from` / `DEVVM_FROM` (`Lookup.Refresh`) | `devcontainer.json` `image` (`Lookup.Public`) |
| --- | --- | --- |
| Registry client | `NewRegistry`: HTTPS only, `30s` timeout, `2` retries (`1s` initial backoff). | `NewPublicRegistry`: HTTPS only, plus custom `net.Dialer.Control` (`publicOnly`) that blocks loopback, private, link-local, multicast, CGNAT (`100.64.0.0/10`), and reserved IPv4/IPv6 ranges after DNS resolution (except for a configured company proxy). |
| Manifest request | `HEAD /v2/<repo>/manifests/<tag-or-digest>` with OCI and Docker v2 manifest/index `Accept` headers (does not consume Docker Hub pull rate limits). | Same `HEAD` request; if the reference was already `@sha256:` pinned, verifies the registry's `Docker-Content-Digest` matches. |
| Authentication | Anonymous `Bearer` token exchange via `WWW-Authenticate` challenge (`https://` realm only, `1 MiB` response cap). | Anonymous `Bearer` token exchange only. Private images fail with `devcontainer_image_not_public`. |
| Local Docker daemon fallback | If the registry returns not-found/unauthorized (`!errors.Is(err, ErrUnreachable)`), queries `docker image inspect --format '{{.Id}}' <ref>` and records the local `sha256:<id>`. Also falls back to `DockerID` if unreachable and no cached digest exists. | **Never** queries the local Docker daemon, preventing an untrusted repo from loading private or local-only host Docker images into its VM. |
| Offline / cached fallback | If unreachable or not found and neither registry nor Docker resolves it, reuses the last recorded digest from `~/.local/share/devvm/cache/from-digests.json` (or the newest built `base` image's `record.json`) with a `from_cached_unreachable` or `from_cached_not_found` warning. | If unreachable and unpinned, reuses a previously cached registry digest (`name@sha256:...`) with a `from_cached_unreachable` warning. Never falls back to a local Docker ID. |

---

## Converting OCI images into bootable VM disks

Lima VMs boot a virtual disk with a kernel and initramfs rather than running a container share of the host kernel. When building `base` with `--from` (or a `devcontainer.json` `image`), `internal/backend/lima/oci.go` converts the container image inside an isolated, throwaway Lima VM named `devvm-oci-build` booted from the host's pinned Ubuntu 26.04 cloud image.

```mermaid
sequenceDiagram
  autonumber
  participant Host as devvm Host (lima.images.convert)
  participant Docker as Host Docker Daemon (optional)
  participant BuildVM as Throwaway VM (devvm-oci-build)
  participant Chroot as Image Rootfs Chroot (/var/tmp/devvm-oci/root)

  Host->>Host: Acquire ~/.local/share/devvm/oci-build.lock
  Host->>Host: Create temp dir devvm-oci.<random>/{vm,oci,guest}
  alt Digest is local Docker ID (sha256:... without @)
    Host->>Docker: docker save -o vm/image.tar <ref>
    Host->>Host: Verify manifest.json / index.json matches Digest (exportIs)
  end
  Host->>BuildVM: Create & start confined Ubuntu VM (mounts: oci RO, guest RO, vm RW)
  Host->>BuildVM: Run sudo bash oci/build.sh
  alt Registry digest (name@sha256:...)
    BuildVM->>BuildVM: Download & verify pinned crane v0.22.1
    BuildVM->>BuildVM: crane export --platform linux/$devvm_arch <ref> rootfs.tar
  else Local Docker export (-)
    BuildVM->>BuildVM: crane export - rootfs.tar < vm/image.tar
  end
  BuildVM->>Chroot: tar -xpf rootfs.tar --numeric-owner
  BuildVM->>Chroot: Inspect /etc/os-release (gnu vs musl) & getconf GNU_LIBC_VERSION
  BuildVM->>Chroot: chroot_run sh /tmp/devvm/bootify.sh (install kernel, init, cloud-init, sshd, sudo, user)
  BuildVM->>BuildVM: Run oci/kernel.sh & mke2fs -t ext4 -d root vm/disk.raw (20G)
  Host->>BuildVM: Delete devvm-oci-build VM
  Host->>Host: Validate vm/{disk.raw,kernel,initrd,cmdline,libc,glibc} (checkConverted)
```

### Step-by-step conversion pipeline

<Steps>
  <Step title="Locking and workspace preparation">
    `lima.images.convert` acquires an exclusive OS file lock on `~/.local/share/devvm/oci-build.lock` (`lockOCI`, printing `oci_waiting` after 3 seconds if another conversion is running) so only one `devvm-oci-build` VM exists at a time. It creates a temporary directory `devvm-oci.<random>` containing:
    - `oci/` (mounted read-only): `build.sh` (prepended with `assets/prelude.sh`), `bootify.sh`, and `kernel.sh`.
    - `guest/` (mounted read-only when custom CAs exist in `~/.config/devvm/ca`): `ca.sh`.
    - `vm/` (mounted read-write, `0700`): the output directory where the converted artifacts are written.
  </Step>
  <Step title="Exporting local-only Docker images (when needed)">
    If `f.Digest` is a raw Docker image ID (`sha256:...` without `@`), `exportDocker` checks `docker info`, runs `docker save -o <vmOut>/image.tar <ref>`, and inspects `manifest.json` and `index.json` inside the archive (`exportIs`, capped at `16 MiB`) to verify the exported tarball still matches `f.Digest`. If the tag moved during the build, `devvm` aborts with `docker_image_changed`.
  </Step>
  <Step title="Flattening layers with crane">
    Inside `devvm-oci-build`, `assets/oci/build.sh` downloads `crane` `v0.22.1` (`go-containerregistry`), verifies its SHA-256 checksum (`898c0cff975f898a33e8c4580bdafb0e7c02c7faa33374e946762f97c4ab7110` on `arm64`, `0ab7a1d6932a213aed964ce97666c3077fe691c8606413674a8b3e0b9ec4cda0` on `amd64`), and runs `crane export --platform linux/$devvm_arch` (or reads `vm/image.tar` for `-`) to flatten the container filesystem into `/var/tmp/devvm-oci/root` with `--numeric-owner`. If the image lacks a manifest for `linux/$devvm_arch`, `build.sh` queries `crane manifest` and fails listing the available architectures.
  </Step>
  <Step title="Detecting distribution family, libc, and glibc version">
    `build.sh` reads `/etc/os-release` inside the extracted rootfs (`ID` and `ID_LIKE`):
    - `debian` or `ubuntu` maps to `libc=gnu`, and `chroot root getconf GNU_LIBC_VERSION` extracts the `glibc` version (for example, `2.36`).
    - `alpine` maps to `libc=musl`.
    - Any other distribution or an image missing `/bin/sh` or `/etc/os-release` is refused.
  </Step>
  <Step title="Installing kernel, init, cloud-init, and guest user (bootify.sh)">
    `chroot_run` mounts `/proc`, `/sys`, `/dev`, and `/dev/pts`, copies `/etc/resolv.conf`, installs a temporary `/usr/sbin/policy-rc.d` (`exit 101` on Debian/Ubuntu so `apt-get` does not start daemons in the chroot), optionally installs the user's custom CAs (`ca.sh`), and executes `assets/oci/bootify.sh`:
    - **Debian / Ubuntu:** Installs `initramfs-tools`, kernel (`linux-image-virtual` + `netplan.io` on Ubuntu; `linux-image-$arch` on Debian because Debian's cloud kernel lacks `9p` and `gpio_keys`), `systemd`, `systemd-sysv`, `systemd-timesyncd`, `systemd-resolved`, `cloud-init`, `openssh-server`, `sudo`, `bash`, and `rsync`. Adds `virtio_pci`, `virtio_mmio`, `virtio_blk`, `virtio_net`, `virtio_console`, and `ext4` to `/etc/initramfs-tools/modules`, regenerates the initramfs, enables `en_US.UTF-8`, disables LLMNR/mDNS, preserves the container's apt sources (`preserve_sources_list: true`), and sets `cmdline` to `root=/dev/vda rw console=hvc0` (plus `net.ifnames=0` on Debian).
    - **Alpine:** Installs `linux-virt`, `mkinitfs`, `alpine-base`, `openrc`, `cloud-init`, `e2fsprogs`, `openssh`, `sudo`, `bash`, `shadow`, `rsync`, and full `blkid` (required to read Lima's ISO 9660 `cidata` label), enables `isofs` in `/etc/modules`, registers OpenRC `sysinit`/`boot`/`default` services (`sshd`, `cloud-init`), and sets `cmdline` to `root=/dev/vda rootfstype=ext4 rw console=hvc0 modules=virtio_blk,ext4`.
    - **Guest user:** Removes any existing container user sharing `USER_UID` (such as Ubuntu's `ubuntu` at UID `1000`), creates `USER_NAME` with `USER_UID` and home `/home/<username>.guest`, removes `/etc/ssh/ssh_host_*`, and grants passwordless `sudo` in `/etc/sudoers.d/90-devvm`.
  </Step>
  <Step title="Extracting kernel and building the ext4 disk">
    `make_disk` removes `/.dockerenv` (which would otherwise cause `systemd` and `cloud-init` to detect a container environment and skip Lima's `cidata` mount), ensures `/dev/console` (`c 5 1`) exists, runs `assets/oci/kernel.sh` (which on `aarch64` decompresses EFI-stub zstd or gzip streams until it locates the uncompressed `ARMd` magic header at offset `0x38` required by macOS `Virtualization.framework`), and formats a `20G` raw ext4 image (`disk.raw`, label `devvm-root`) with `mke2fs -q -t ext4 -L devvm-root -d "$r"`. When Lima creates `base` from this disk, it expands the image to `base`'s `100 GiB` size (`backend.BaseDiskGiB`).
  </Step>
</Steps>

### Post-conversion untrusted output verification

Because container packages and post-install scripts execute as `root` inside `devvm-oci-build`, `devvm` deletes `devvm-oci-build` first so nothing can mutate `vm/` during inspection, then runs `checkConverted` on the host before Lima touches the output:

| Output file in `vm/` | Host verification rule in `checkConverted` | Security rationale |
| --- | --- | --- |
| `disk.raw`, `kernel`, `initrd`, `cmdline`, `libc` | `os.Lstat` mode must be `IsRegular()` (no symlinks, FIFOs, or device nodes). | Prevents a symlink from pointing Lima or `devvm` at arbitrary host files or blocking on a host FIFO. |
| `disk.raw` | First `1024` bytes must be all `0x00`, followed by the ext4 superblock magic `0x53 0xef` (`0xEF53` little-endian) at byte offset `1080` (`ext4()`). | Rejects `qcow2` or `VMDK` headers in the first 1024 bytes, which could specify a host file as a backing image or extent. |
| `cmdline` | Read capped at `4096` bytes; must match `^[A-Za-z0-9=,./:_ -]+$`. | Prevents injection into Lima's YAML configuration. |
| `libc` | Must equal `"gnu"` or `"musl"`. | Selects the shared tools target ABI. |
| `glibc` (when `libc == "gnu"`) | Checked with `os.Lstat` (`IsRegular()`) before opening, and must pass `image.ValidGlibc` (for example, `2.36`). | Ensures valid glibc version comparison before mounting shared tool EROFS images. |

---

## Shared toolchains, `libc`, and `glibc` compatibility

Base images built with `--from` (or `devcontainer.json` `image`) sit alongside the default Ubuntu `base` in `~/.local/share/devvm/images/<key>/`. Their image keys hash the `--from` reference, its resolved digest, the conversion scripts (`assets/prelude.sh` + `assets/oci/build.sh`, `bootify.sh`, `kernel.sh`), the guest user metadata (`Username`, `Uid`, `/home/<user>.guest`), and the converted disk size (`20G`).

Shared tool images (`<tool>-<version>-<key>.erofs`) are built and verified according to the `libc` and `glibc` fields recorded in `base`'s `record.json` (`build.Env.ToolTarget`):

- **Debian and Ubuntu container bases (`libc: "gnu"`):** VMs share the exact same `gnu` EROFS tool images as VMs on `devvm`'s default Ubuntu cloud image. To prevent untrusted container code from tampering with shared tool builds mounted by other VMs, `gnu` tool images are always built inside `devvm`'s own Ubuntu cloud `base` image (`tools_need_own_base`), never inside the `--from` container image. During tool image creation, `build.sh` runs `readelf` across all ELF binaries and libraries in the tool (skipping `testdata` directories) to record the highest required `GLIBC_` symbol version in `<image>.glibc`. Before attaching a tool to a `--from` VM (on `devvm`, `devvm tools add`, `devvm tools update`, or `devvm update`), `devvm` compares the tool's required glibc version against the container base's `glibc` version and refuses incompatible tools with `tool_needs_glibc`.
- **Alpine container bases (`libc: "musl"`):** Alpine VMs require `musl` tool builds. `devvm` builds `musl` tool images inside the VM's own `--from` Alpine `base` image and includes that `base` image's `Digest` in the tool image key (`tg.Base = rec.Digest`), isolating those tool images to VMs sharing that exact Alpine image digest.

---

## Related pages

<CardGroup cols={2}>
  <Card title="Images, layers, and content keys" href="/image-and-layer-model">
    Understand deterministic SHA-256 key computation for base images, built-in and custom layers, and read-only EROFS shared tool images.
  </Card>
  <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.
  </Card>
  <Card title="VM lifecycle and state" href="/vm-lifecycle-and-state">
    Inspect how state sections like devcontainer and ports persist in vm.json and how in-place vs. remake updates work.
  </Card>
  <Card title="CLI reference: VM and workspace commands" href="/cli-vm-and-workspace-commands">
    Full flag and argument reference for devvm (up), devvm update, and devvm ports.
  </Card>
</CardGroup>
