# VM lifecycle and state

> How project paths map to VM names and 8-hex hashes, state persistence in vm.json, layout version checks, and in-place vs. remake transitions.

- 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/vmname/vmname.go`
- `internal/project/project.go`
- `internal/state/vm.go`
- `internal/state/layout.go`
- `internal/vm/lifecycle.go`
- `internal/vm/remake.go`
- `internal/vm/finder.go`

---

---
title: "VM lifecycle and state"
description: "How project paths map to VM names and 8-hex hashes, state persistence in vm.json, layout version checks, and in-place vs. remake transitions."
---

`devvm` isolates each project directory in a dedicated virtual machine whose identity, configuration, and runtime processes are tracked outside the project tree under `~/.local/share/devvm` and `~/.config/devvm`. The packages `internal/vmname`, `internal/project`, `internal/state`, and `internal/vm` govern how physical host paths map to deterministic VM names and state directories, how `vm.json` persists core attributes and package-specific sections under OS file locks, and how VMs transition through creation, startup, in-place reconfiguration, full disk remakes, and teardown.

## Project path mapping and VM naming

Every project VM name follows the grammar enforced by `internal/vmname` and constructed by `internal/project`:

```text
devvm-<slug>-<hash>
  │     │      └── 14 lowercase hex digits (56 bits of SHA-1 of physical path)
  │     └───────── 4 to 20 chars of sanitized folder basename ([a-z0-9-])
  └─────────────── Fixed prefix ("devvm-")
```

<Note>
While short descriptions sometimes refer to truncated path hashes, `vmname.HashLen` is `14` hex characters (56 bits of SHA-1). This 14-character suffix distinguishes project VMs from image build VMs, which use 12-character keys (`devvm-img-<12-hex>`), even if a project folder is named `img-...`.
</Note>

### Name grammar and hash derivation

`project.New(dir, nameMax)` and `project.Nth(dir, nameMax, n)` construct a `project.Folder` from a physical directory path (`pwd -P` / `filepath.EvalSymlinks`):

1. **Path validation**: `project.Nth` rejects any path containing a comma (`,`, which breaks Lima mount option parsing) or any Unicode control character, returning `*project.BadPathError`.
2. **Slug generation (`project.Slug`)**: The base name of the directory (`filepath.Base(dir)`) is lowercased. Any character outside `a-z` and `0-9` is replaced with `-`, and leading or trailing dashes are stripped. If the resulting string is empty (for example, `/` or a name consisting only of punctuation), the slug defaults to `"root"`.
3. **Length bounding**: The slug is capped at `slugMax = 20` characters (or `nameMax - len("devvm-") - 1 - 14` if the backend imposes a stricter `NameMax()`), with trailing dashes stripped after truncation. If the backend's `NameMax()` leaves fewer than `slugMin = 4` characters for the slug, `project.Nth` returns `project.ErrNoRoom`.
4. **Hash computation (`project.Hash` and `hashNth`)**:
   - For the initial VM at a path (`n == 0`), `project.Hash(dir)` computes `sha1.Sum([]byte(dir))` and takes the first 14 hex characters (`vmname.HashLen = 14`).
   - If a previous VM created for `dir` was moved to a new directory, that moved VM retains its original name and `vms/<hash>/` directory. When a new VM is subsequently created at the original `dir`, `finder.newName` increments `n` (from `0` up to `maxNames = 100`) and hashes `dir + "\x00" + strconv.Itoa(n)` until it finds a hash whose `vms/<hash>/` directory holds no state.

| Function / Constant | Package | Behavior |
| --- | --- | --- |
| `vmname.Prefix` | `internal/vmname` | Constant `"devvm-"` prefix required on all project VM names. |
| `vmname.HashLen` | `internal/vmname` | Constant `14` (lowercase hex digits at the end of a project VM name). |
| `vmname.IsVM(name)` | `internal/vmname` | Matches `^devvm-[a-z0-9]([a-z0-9-]*[a-z0-9])?-[0-9a-f]{14}$`. |
| `vmname.HashOf(name)` | `internal/vmname` | Extracts the trailing 14-hex hash used as the directory name under `vms/<hash>/`. |
| `vmname.SlugOf(name)` | `internal/vmname` | Extracts `<slug>` from `devvm-<slug>-<hash>`, which also serves as the guest hostname. |
| `vmname.Short(name)` | `internal/vmname` | Returns `devvm-<slug>`, the short SSH/editor alias candidate when unclaimed by another VM. |
| `vmname.IsAlias(alias, name)` | `internal/vmname` | Validates that `alias` is either `Short(name)` or `name` itself, preventing arbitrary host patterns in `~/.ssh/config`. |
| `vmname.Aside(name)` | `internal/vmname` | Returns `devvm-<hash>-old`, the temporary machine name used while remaking a VM. Does not match `IsVM`. |

### Refused and unsafe project folders

Because a standard VM mounts its project folder read-write, `project.Unsafe` refuses to create or move a VM into directories that would expose host credentials, `devvm` state, or other VM disks:

- **`Unsafe.Holds(dir)`**: Refuses `dir` if it is or contains `$HOME`, `$XDG_CONFIG_HOME/devvm`, `$XDG_DATA_HOME/devvm`, or `$DEVVM_LIMA_HOME` (for example, running `devvm` in `/` or `$HOME`), unless `--unsafe-folder` is explicitly passed on creation.
- **`Unsafe.Inside(dir)`**: Refuses `dir` if it is nested inside the config folder, data folder, or Lima home (which could let a guest rewrite layer build scripts or host binaries).

---

## Target resolution and folder identity

When a command acts on a project VM, `finder.target(ctx, need)` in `internal/vm/finder.go` locates the VM state and queries the backend once.

### Folder resolution rules (`--here` and `--parent`)

`finder.folders` resolves which VM applies to the current physical working directory (`finder.dir()`) according to `FolderRule`:

| Rule | CLI Flag | Resolution behavior |
| --- | --- | --- |
| `FolderAuto` | *(default)* | Uses the current directory's own VM if `vm.json` or a backend instance exists for it. Otherwise walks parent directories (`project.Parents`, nearest first, stopping before `$HOME` and `/`) and uses the first parent with a valid `vm.json` and backend instance, **unless** the current directory or an intermediate directory below that parent contains its own `.git` directory or file. |
| `FolderHere` | `--here` (or `--clone`) | Uses only the current directory's own VM; never walks parent directories. |
| `FolderParent` | `--parent` | Skips the current directory's VM and any intermediate `.git` boundaries to use the nearest parent directory's VM, or fails with `no_parent_vm`. |

<Warning>
When `finder.parentVM` walks upward through `project.Parents(dir, home)`, it only passes over parent directories that have **no** `vm.json`. It stops at the first parent that has a `vm.json`. If that parent's folder identity no longer matches (`!sameFolder`) or its backend lacks the VM, the search returns `nil` rather than continuing to an outer parent. This prevents a VM in an outer folder from replacing a nested project folder through its read-write mount and causing commands in the nested folder to fall through to the outer VM.
</Warning>

### Physical folder identity (`FolderID`)

`state.VM` stores both the physical path (`Dir`) and a platform-specific identity string (`Folder`, produced by `platform.FolderID(dir)`), which encodes the filesystem device, inode number, and creation/birth time. This pair distinguishes four directory states:

1. **Same folder (`sameFolder(f, st) == true`)**: Both `st.Dir == f.Dir` and `st.Folder == platform.FolderID(f.Dir)` match.
2. **Replaced folder (`t.other == true`)**: A VM state exists for `f.Dir`, but `st.Folder` differs (the directory was deleted and recreated, or replaced with another repository). Normal commands refuse with `vm_other_folder`. Only `devvm adopt` (which updates `st.Folder` and resets `.git` protection state) or `devvm rm` (which deletes the old VM without exporting from the missing folder) can act on it.
3. **Moved folder (`finder.moveHere`)**: The current directory has no VM of its own, but exactly one finished, non-clone VM in `Inventory.States()` has `st.Folder == id` at a different `st.Dir` whose old path is now gone (`FolderGone`) or a symlink resolving to the new path. If `platform.UniqueFolderID` confirms the filesystem does not reuse deleted inodes (APFS/HFS+ on macOS; ext4, XFS, Btrfs, tmpfs, ZFS, F2FS, or bcachefs on Linux with birth time) and the current user owns the directory, `devvm` prompts to move the VM (`move_vm`, or moves automatically with `devvm adopt --force`). Moving stops the VM if running, updates `st.Dir` and `st.Folder`, and records the previous path in the `moved` section of `vm.json` so the next guest setup relocates Claude Code session history.
4. **Missing or disconnected folder (`FolderOf(st)`)**: Evaluated with a 5-second timeout (`folderWait = 5 * time.Second`) so hanging network shares default to `FolderThere`:
   - `FolderThere` (`"there"`): Path exists, or `os.Lstat` fails with an error other than `fs.ErrNotExist`, or times out.
   - `FolderGone` (`"gone"`): Nothing exists at `st.Dir`, and the nearest existing parent directory resides on the **same** device ID recorded in `st.Folder`.
   - `FolderNotConnected` (`"not_connected"`): Nothing exists at `st.Dir`, and the nearest existing parent directory resides on a **different** device ID (for example, an unmounted external drive or network volume). `devvm rm <vm>` requires interactive confirmation and ignores `--force` when a folder is `not_connected`.

---

## State persistence and directory layout

`devvm` never writes state files into project directories. All configuration and runtime state reside in `paths.Dirs`:

- **Config directory (`Dirs.Config`)**: `$XDG_CONFIG_HOME/devvm` (default `~/.config/devvm`), mode `0700`. Holds user-authored `config.toml`, global `policy`, custom `layers/`, trusted `ca/`, and imported `agents/<agent>/` settings.
- **Data directory (`Dirs.Data`)**: `$XDG_DATA_HOME/devvm` (default `~/.local/share/devvm`), mode `0700`. Holds `settings.json`, `ssh_config`, `vms/<hash>/`, `images/`, `tools/`, `packages/`, `exports/`, `logs/`, `cache/`, `run/`, and `lima/` (unless overridden by `$DEVVM_LIMA_HOME`).

### Layout version checks

Before running any stateful command (excluding `help`, `version`, `--version`, `completion`, and internal `_` subcommands), `state.CheckLayout(ctx, d)` verifies the layout version of both `Dirs.Data` and `Dirs.Config` under an exclusive file lock on `~/.local/share/devvm/version.lock`:

1. `state.Layout` is currently `1`.
2. `CheckLayout` reads `~/.local/share/devvm/version` and `~/.config/devvm/version`.
3. If either file contains a non-integer or a number `< 1`, `CheckLayout` fails with `*state.CorruptError` (`not a layout version`).
4. If either file records a version `> state.Layout`, `CheckLayout` aborts immediately with `msg.LayoutNewer`, preventing an older `devvm` binary from corrupting state written by a newer release.
5. If a `version` file is missing or `< state.Layout`, `CheckLayout` runs any registered `Migration` functions sequentially from `min(v) - 1` and writes `"1\n"` atomically to both `version` files.

### Per-VM directory structure and locks

Each project VM stores all of its host-side files in `~/.local/share/devvm/vms/<hash>/` (`paths.VM`), except Unix domain sockets, which are placed in `~/.local/share/devvm/run/` with short hash names to stay within macOS's 104-byte `sun_path` limit (`paths.SocketMax = 104`, accounting for OpenSSH appending 17 bytes to temporary control sockets).

:::files
~/.local/share/devvm/
├── version
├── version.lock
├── making.lock
├── groups.lock
├── retention.lock
├── tools.lock
├── ssh_config
├── settings.json
├── run/
│   ├── claims.lock
│   ├── create-<hash>.lock
│   ├── port-<hash>.ctl
│   ├── link-<hash>.sock
│   ├── link-<hash>.ctl
│   ├── build-ports/
│   └── builds/
└── vms/
    └── <14-hex-hash>/
        ├── vm.json
        ├── lock
        ├── policy
        ├── home.tar.gz
        ├── run/
        │   ├── remake.lock
        │   ├── known_hosts
        │   ├── started
        │   ├── sandbox.sb
        │   ├── proxy.json
        │   ├── proxy.pid
        │   ├── proxy.status
        │   ├── ports.lock
        │   ├── ports.pid
        │   ├── ports.status
        │   ├── links.json
        │   ├── links.pid
        │   ├── secrets.json
        │   ├── secrets.policy
        │   └── ca/
        │       ├── lock
        │       ├── ca.pem
        │       ├── ca.key
        │       ├── next.pem
        │       └── next.key
        └── logs/
            ├── network.jsonl
            ├── proxy.log
            ├── ports.log
            └── links.log
:::

All locks use OS-level `flock` (`platform.LockFile` / `platform.TryLockFile`) on open file descriptors so the kernel automatically releases them if a `devvm` process crashes or receives `SIGKILL`.

| Lock path | Acquisition mode | Scope and ordering rules |
| --- | --- | --- |
| `version.lock` | Blocking exclusive | Held during `state.CheckLayout` startup verification and migrations. |
| `run/create-<hash>.lock` | Non-blocking (`TryLockFile`) | Held from before `devvm` prompts for a new VM plan until `makeMachine` records `created` in `vm.json`, and by `devvm rm`, `devvm adopt`, and `devvm gc --orphans`. Lives outside `vms/<hash>/` so deleting/recreating the VM directory does not unlink the lock file. Removed on release. |
| `vms/<hash>/lock` | Blocking exclusive | Held by `state.VM.Create`, `Update`, `Claim`, `Remove`, and `RemoveFolder`. Only one VM's `lock` is held at a time. |
| `run/claims.lock` | Blocking exclusive | Held while allocating resources that must be unique across all VMs (SSH aliases via `sshconfig.ChooseAlias`, and host ports for SSH, proxy, and VNC desktop via `ports.Allocator`). Always acquired **after** `vms/<hash>/lock` (`VM.Claim`), or alone for stateless build VMs (`state.Claims`). |
| `vms/<hash>/run/remake.lock` | Non-blocking (`TryLockFile`) | Held throughout `Lifecycle` remake operations (`layer add`, `layer rm`, `update`, `clone`) and `finishPending`. Concurrent attempts fail immediately with `remake_running`. |
| `making.lock` | Shared / non-blocking exclusive | Held shared (`tools.Making.Hold`) while creating a VM, updating VM tools, or remaking with reread tools; tried exclusively by `devvm gc --tool-images` and tool retention so active builds never have their tool images garbage-collected before `vm.json` records them. |

### `vm.json` schema

`vms/<hash>/vm.json` is written atomically via `state.WriteFile` (write to a temporary file in the same directory, `chmod 0600`, and `os.Rename`). Because every write replaces the file whole, reads (`state.ReadHash`, `state.ReadVM`, `state.ReadAll`) require no lock.

Every `vm.json` contains top-level core fields (`state.core`) followed by alphabetically sorted package sections (`map[string]json.RawMessage`). Decoding (`VM.decode`) fails with `*state.CorruptError` if the file is not a JSON object or is missing any required core field (`version >= 1`, `id`, valid `name` matching `vmname.IsVM`, `image`, `dir`, or `backend`), or if `ReadAll` finds a `name` whose trailing hash does not match its enclosing `vms/<hash>/` folder.

#### Core fields

<ResponseField name="version" type="int" required>
  Schema version of `vm.json`, fixed at `1` (`vmVersion = 1`).
</ResponseField>

<ResponseField name="id" type="string" required>
  32-character hex string (128 random bits from `crypto/rand`) generated on `VM.Create`. Allows links and concurrent commands (`stillGone`, `sameCreation`) to distinguish a recreated VM in the same folder from a previous VM that had the same name.
</ResponseField>

<ResponseField name="name" type="string" required>
  Full project VM name (`devvm-<slug>-<14-hex-hash>`). Chosen once at creation for the target backend and never changed.
</ResponseField>

<ResponseField name="alias" type="string">
  Short SSH host alias (`devvm-<slug>`) assigned under `claims.lock` by `sshconfig.ChooseAlias` if no other VM had claimed it when the VM was created. Validated via `vmname.IsAlias`.
</ResponseField>

<ResponseField name="image" type="string" required>
  Target image name: `"base"` or layers joined with `+` (for example, `"docker+desktop"`).
</ResponseField>

<ResponseField name="chain" type="string">
  Recorded image build chain key sequence from `Builder.Chain(img).String()`, used by `Builds.Outdated(st.Chain)` to detect when the base image or any layer in the VM's stack has been rebuilt or updated.
</ResponseField>

<ResponseField name="from" type="string">
  Pinned OCI container image reference (`repo@sha256:...`) when the VM's base image was built with `--from` or a `devcontainer.json` `image` property; omitted for the default Ubuntu cloud image.
</ResponseField>

<ResponseField name="dir" type="string" required>
  Physical host path of the project folder. Updated in place when a moved folder is adopted.
</ResponseField>

<ResponseField name="folder" type="string">
  Platform folder identity (`platform.FolderID(dir)` encoding device, inode, and birth time) used to detect folder replacement (`folderChanged`, `sameFolder`) and directory moves (`movedFrom`).
</ResponseField>

<ResponseField name="backend" type="string" required>
  Name of the backend managing the VM (for example, `"lima"`). Once recorded, the VM is always operated through this backend regardless of global backend flags.
</ResponseField>

<ResponseField name="backend_at" type="string">
  Filesystem path where the backend stores its data (`backend.LocationOf(b)`), used to detect if the backend home (such as `DEVVM_LIMA_HOME`) moved.
</ResponseField>

<ResponseField name="created" type="string (RFC 3339 timestamp)">
  UTC timestamp set by `creation.created` immediately after `b.CreateVM` succeeds. If `created` is zero/omitted (`!st.Finished()`), VM creation did not finish and `refuseUnfinished` blocks the VM from starting.
</ResponseField>

<ResponseField name="made_by" type="string" required>
  Version string of the `devvm` binary that originally created the VM. Preserved across subsequent updates and remakes.
</ResponseField>

#### Package sections

Each subsystem reads its own section via `state.Section[T](st, name)` and mutates it inside `st.Update` or `st.Claim` via `state.SetSection(st, name, val)`. Encoding a value that marshals to `{}` or `null` deletes the section key from `vm.json`.

| Section key | Owner package | Go type | Stored fields and purpose |
| --- | --- | --- | --- |
| `net` | `internal/netpolicy` | `netpolicy.Net` | `tier` (`allowlist`, `internet`, `open`, `none`) and `made` (`proxy_key`, placeholder `salt`, and `checked` self-check state). |
| `git` | `internal/gitprotect` | `gitprotect.InVM` | `mode` (`on`, `strict`, `off`, `clone`), `next` (mode queued while the VM was running), and `made` (locked `.git` identity, linked git dirs, and config/hook handoff hash). |
| `ports` | `internal/ports` | `ports.Ports` | `forward` (`""` for all ports `>= 1024`, `"none"`, or comma-separated ports/ranges) and `host` (`ssh`, `proxy`, and `desktop` loopback ports allocated under `claims.lock`). |
| `agent` | `internal/sshagent` | `sshagent.InVM` | `mode` (`off`, `devvm`, `on`) and `made` (`toggle` capability flag, plus `run` ID and `mode` of the current boot). |
| `tools` | `internal/tools` | `tools.InVM` | `names` (toolchains and coding agents) and `made` (`files` EROFS/tar image filenames, `libc`, and `glibc` version). |
| `secrets` | `internal/secrets` | `secrets.InVM` | Bound secret names and allowed target host patterns for proxy placeholder substitution. |
| `size` | `internal/vm` | `vm.Size` | Requested `cpus`, `memory` (GiB), and `disk` (GiB). Empty strings use the base image's defaults. |
| `clone` | `internal/vm` | `vm.cloneInVM` | `on` (`true` for `--clone` VMs) and `made` (`remote` name added to host repo and `seeded` boolean). |
| `devcontainer` | `internal/vm` | `vm.devcontainerInVM` | Source file path and SHA-256, container env vars, lifecycle commands, `parts` / `gave` (toolchains and layers), `ports` / `ports_gave`, `image`, `from_flag`, and `made` (`created` and `started` run markers). |
| `desktop` | `internal/vm` | `vm.desktopInVM` | VNC relay password generated for `devvm desktop`. |
| `links` | `internal/vm` | `vm.linksInVM` | Published TCP ports (`publish`), outbound VM links (`to`), and `group` membership. |
| `remake` | `internal/vm` | `vm.remakeInVM` | Active only during a VM remake: target `image`, `command`, `home`, `no_export`, `made`, `clone`, `done`, `reread`, and `was` rollback snapshot. |
| `moved` | `internal/vm` | `vm.movedInVM` | Original project path (`from`) preserved across folder moves until guest setup migrates Claude Code project history. |

---

## VM lifecycle state machine

```mermaid
stateDiagram-v2
    [*] --> Planned: devvm (up) acquires run/create-<hash>.lock
    Planned --> Unfinished: st.Create writes vm.json (created = zero)
    Unfinished --> [*]: Interrupt cleanup (undoCreate) or devvm rm
    Unfinished --> Stopped: b.CreateVM succeeds -> st.Update sets created

    Stopped --> Running: starter.bringUp (hostChecks, Proxy.Prepare, b.Start, SelfCheck)
    Running --> Stopped: Lifecycle.Stop / devvm restart / SelfCheck failure

    Running --> RemakeAside: Remake starts (acquires remake.lock, exports history & home.tar.gz, renames to devvm-<hash>-old)
    Stopped --> RemakeAside: Remake starts (acquires remake.lock, renames to devvm-<hash>-old)

    RemakeAside --> RemakeNew: b.CreateVM makes new machine & starter.bringUp starts it
    RemakeAside --> Stopped: Failure/Interrupt before restoreHome -> undo / putBack renames aside back
    RemakeNew --> Stopped: Failure/Interrupt before restoreHome -> undo deletes new & renames aside back
    RemakeNew --> RemakeDone: restoreHome succeeds -> committed=true, records new image/chain & remake.done=true
    RemakeDone --> Running: dropOld deletes devvm-<hash>-old, clears remake section & home.tar.gz

    Stopped --> [*]: Lifecycle.Rm / RmVM -> b.Delete & vmFolders.teardown
    Running --> [*]: Lifecycle.Rm / RmVM -> exportFirst, b.Delete & vmFolders.teardown
```

### Creation (`Launch.Up` and `creation.create`)

When `devvm` runs in a directory without an existing VM (or with an unfinished VM that the user agrees to recreate via `remake_unfinished`), `Launch.makeHeld` acquires `run/create-<hash>.lock` and executes `creation.create`:

<Steps>
  <Step title="Plan and validate before building">
    `creation.plan` parses flags (`--tools`, `--agents`, `--layers`, `--network`, `--ports`, `--cpus`, `--memory`, `--disk`, `--clone`, `--ssh-agent`, `--from`, `--no-devcontainer`), evaluates `.devcontainer/devcontainer.json` if present, runs first-run default prompts if needed, verifies backend feature capabilities via `Registry.Require`, checks free disk space against `Floors.VM`, and offers to import the newest agent history export for the folder (`offerHistory`).
  </Step>
  <Step title="Build base image, layers, and shared tools">
    While holding a shared lock on `making.lock`, `creation.create` refreshes `mise.lock` if the daily check is due, builds the target VM image (`build.Builds.Build`), and builds any missing read-only EROFS/tar tool images in `~/.local/share/devvm/tools/`.
  </Step>
  <Step title="Write initial vm.json before creating the machine">
    `vmFolders.clear` removes any leftover runtime files from a previously deleted VM. `st.Create` acquires `vms/<hash>/lock` and `run/claims.lock`, selects the SSH alias, and writes `vm.json` with all requested sections (`net`, `git`, `sshagent`, `tools`, `ports`, `size`, `clone`, `devcontainer`) but with `Created` left zero. If `devvm` crashes after this point before `CreateVM` finishes, `st.Finished()` is `false` and `refuseUnfinished` prevents the half-created VM from ever starting without its network confinement.
  </Step>
  <Step title="Create the backend machine and record completion">
    `creation.makeMachine` allocates the host SSH loopback port under `claims.lock`, invokes `b.CreateVM(ctx, key, spec)`, and calls `creation.created` to set `st.Created = time.Now().UTC()`. Once `Created` is written, `makeHeld` releases `run/create-<hash>.lock`.
  </Step>
  <Step title="Start, self-check, and set up the guest">
    `starter.bringUp` locks `.git` (unless `--clone`), starts the per-VM host proxy, boots the VM, pins its SSH host keys in `vms/<hash>/run/known_hosts`, starts the port forwarder (`devvm _ports`), and runs `Proxy.SelfCheck`. `creation.create` then imports any accepted agent history archive, runs `setUpVM` (syncing guest environment, CAs, secret placeholders, `~/.local/share/devvm/ssh_config`, links, initial `--clone` git push, and `devcontainer.json` commands), and replaces the `devvm` process with the guest shell (`platform.Exec`).
  </Step>
</Steps>

If `devvm` is interrupted (`SIGINT`, `SIGTERM`, `SIGHUP`) before the first shell opens, `undoCreate` deletes the backend VM, unlocks `.git` or removes the host git remote, and runs `vmFolders.teardown` to remove `vms/<hash>/`.

### Unified startup (`starter.bringUp`)

Every operation that starts a VM or interacts with a running one—including `devvm`, `devvm restart`, `devvm host`, `devvm code`, `devvm history export`, `devvm rm` clone checks, and remake verification—goes through `starter.bringUp` in `internal/vm/start.go`:

1. **Unfinished check**: Calls `refuseUnfinished(vm, st)` and aborts with `vm_unfinished` if `st.Created.IsZero()`.
2. **Agent mount reconciliation (`o.agentDirs`)**: On interactive launches, `updateAgentDirs` compares the VM's mounted agent settings directories against `~/.config/devvm/agents/<agent>/`. If the VM is already running and has pending next-start changes (size, SSH agent forwarding, agent mounts, moved project path, or a transition from a confined tier to `open`), `restartForNextStart` lists the pending changes and prompts whether to restart immediately.
3. **Three-point folder replacement guard (`folderChanged`)**: For stopped VMs that mount their project folder, `bringUp` verifies that `filepath.EvalSymlinks(st.Dir) == st.Dir` and `platform.FolderID(st.Dir) == st.Folder` at three points:
   - Before running `hostChecks` (so `.git` locking never follows a symlink planted by another VM).
   - Immediately before `b.Start`.
   - Immediately after `b.Start` returns (if the folder was swapped during boot, `bringUp` immediately stops the VM and proxy and fails with `project_folder_changed_stopped`).
4. **Host safety and `.git` protection (`hostChecks`)**: Verifies existing `.git` locks (`Protector.Check`), applies any queued `git-protect` mode change (`gitprotect.Next`) once the VM is stopped, and on Linux hosts scans for setuid/setgid files (`hostsafety.SetIDFiles`).
5. **Backend capability check**: Verifies via `Registry.Allowed` / `Require` that the backend supports all features required by the VM's current state (`startNeeds`).
6. **Start or re-confine**:
   - **If stopped (`starter.start`)**: Clears old `known_hosts`, ensures `devvm`'s SSH agent has keys if `mode == "devvm"`, prepares and starts the host proxy (`Proxy.Prepare`), checks link targets (`checkStartLinks`), constructs `backend.Want` (size, project mount + `ProjectID`, agent directory mounts, `AgentForwarding`), calls `b.Start(ctx, vm, want, conf)`, starts the host port forwarder (`forwards().Start`), and records the boot's `Run.ID` and SSH agent mode in `vm.json`.
   - **If already running (`runningConfinement`)**: Re-prepares the proxy if rules or secrets changed (stopping the VM via `Proxy.Unproxied` if the proxy fails), reloads expired SSH agent keys, and warns about any settings waiting for `devvm restart` (`nextStart`).
7. **Network self-check (`Proxy.SelfCheck`)**: Verifies end-to-end from inside the guest that traffic is routed through the VM's proxy and that disallowed connections fail on confined tiers; stops the VM immediately if confinement fails.

### Stopping and deleting VMs

- **`Lifecycle.Stop` (`devvm stop`)**: Stops the backend instance (`t.stop`), stops the host proxy (`Proxy.Stop`), stops the port forwarder (`Forwarders.Stop`), stops active link sessions (`linkRuns.stop`), runs `starter.hostChecks` while stopped (applying any queued `git-protect` mode and checking `.git` and Linux set-id files), and rewrites `~/.local/share/devvm/ssh_config` without the stopped VM.
- **`Lifecycle.Rm` and `Lifecycle.RmVM` (`devvm rm [VM]`)**:
  1. Acquires `run/create-<hash>.lock` (`holdForRm`) so a VM currently being created cannot be deleted mid-flight.
  2. Prompts for confirmation when deleting from a subfolder (`delete_parent_vm`, never bypassed by `--force`), when the folder was replaced (`delete_other_folder_vm`, never bypassed by `--force`), when the folder is on an unconnected drive (`delete_not_connected_folder_vm`, never bypassed by `--force`), or on normal deletion (`rm_confirm`, bypassed by `--force`).
  3. For finished `--clone` VMs (unless `--no-clone-check` or `t.other`), boots the VM if needed and runs `cloneCheck` to inspect up to 100 linked worktrees (`cloneWorktreesMax = 100`) for uncommitted changes and unfetched commits, offering to fetch branches into `saved/<remote>/<branch>` on the host repo.
  4. Unless `--no-export`, if the VM has coding agents configured, boots the VM and streams agent transcripts/session history into `~/.local/share/devvm/exports/<vm>-<timestamp>.tar.gz` (capped at `historyMax = 2 GiB`) alongside a `.folder` metadata file recording the project path and network tier. If the export fails, `devvm rm` aborts and keeps the VM (`export_failed_kept`); if it exceeds 2 GiB, `devvm` warns (`history_too_large`) and proceeds.
  5. Calls `b.Delete(ctx, vm)` and `vmFolders.teardown(ctx, t, false)` to stop the proxy, port forwarder, and link sessions, unlock `.git` or remove the clone remote (saving any unique remote-tracking commits under `saved/<remote>/...`), remove inbound links from other VMs' `vm.json` files, delete any leftover `devvm-<hash>-old` machine, delete `vms/<hash>/` under `vms/<hash>/lock`, and update `ssh_config`.

---

## In-place transitions vs. VM remakes

`devvm` separates configuration changes by whether they can be applied to a running process on the host, applied inside the guest on the next shell launch, applied by editing the backend VM configuration on the next boot, or require replacing the root disk from a new image (`remaking.remake`).

| Change category | Commands | When it takes effect | Mechanism |
| --- | --- | --- | --- |
| **Immediate (running VM)** | `devvm ports`, `devvm network allow/deny/rm`, `devvm network <tier>` (confined tiers), `devvm secret`, `devvm link`, `devvm group` | Immediately | Updates `vm.json` (or `policy`) and restarts `devvm _ports`, reloads `devvm _proxy`, or updates link sessions without rebooting the guest. |
| **Next shell launch (in-place)** | `devvm tools`, `devvm tools add/rm/update` | Immediately as the command opens the guest shell (new agent config dir mounts require restart) | Builds missing EROFS/tar tool images in `~/.local/share/devvm/tools/`, updates `tools` in `vm.json`, and mounts them inside the guest via `syncer.sync`. |
| **Next VM stop** | `devvm git-protect on/strict/off` (when VM is running) | Next `devvm stop` or `devvm restart` (prompts to restart now) | Saves `git.next` in `vm.json` so host `.git` file flags (`chflags`/`chattr`) are never mutated while guest processes may race them. |
| **Next VM start (`backend.Want`)** | `devvm size`, `devvm ssh-agent`, `devvm network open` (from confined), `devvm adopt` (moved folder), new agent store folders | Next `devvm` start or `devvm restart` (prompts to restart now if running) | `starter.start` passes `backend.Want` to `b.Start`, which compares and edits the stopped VM's Lima configuration in a single `limactl edit` before booting. |
| **Full VM remake (`remaking.remake`)** | `devvm layer add`, `devvm layer rm`, `devvm update` (when image chain or `devcontainer.json` changed), `devvm clone on/off` | Replaces the VM machine and opens a new shell | Sets the old machine aside as `devvm-<hash>-old`, creates a new machine from the target image, migrates the sanitized home folder, and deletes the old machine. |

<Info>
When a running VM is switched from a confined network tier (`allowlist`, `internet`, or `none`) to `open` via `devvm network open`, the VM remains attached to its sandboxed proxy network (`NetProxy`) until its next reboot, but its proxy immediately switches to open rules (allowing all hosts and local addresses). `starter.nextStart` warns that direct networking (`NetDirect`) will apply on the next `devvm restart`.
</Info>

### Remake execution and crash recovery (`internal/vm/remake.go`)

Because image layers (`docker`, `nix`, `desktop`, and custom layers in `~/.config/devvm/layers/`), base image rebuilds, and `--clone` mount topologies are baked into the VM's root disk or creation-time mount spec, changing them requires remaking the VM (`remaking.remake`).

#### Pre-flight checks and locking

`Lifecycle.toRemake` locates the folder's VM, prompts if acting on a parent folder's VM (`remake_parent_vm_ask`), and acquires a non-blocking lock on `vms/<hash>/run/remake.lock` (`remaking.hold`). Under the lock, it re-reads `vm.json`:
- If an interrupted remake is already recorded in `vm.json` (`remakePending`), `finishPending` completes it first.
- It refuses VMs that do not exist, are unfinished (`vm_unfinished`), belong to another folder (`vm_other_folder`), or are `--clone` VMs (unless the command is `devvm clone on/off`).
- `remaking.check` verifies before prompting that all layers in the target image exist, that the backend supports all required features (`Registry.Require`), and that host disk space satisfies `Floors.VM`.

#### Step-by-step remake sequence

Once confirmed interactively (or via `--force`), `remaking.remake` executes six interrupt-safe phases:

<Steps>
  <Step title="Build target image and reread tools">
    `remaking.build` builds the target image (`build.Builds.Build`) and resolves its `chainOf` (`key` and `chain`). If `devvm update` detected a changed `devcontainer.json` (`p.Reread != nil`), it holds `making.lock` and builds any new shared tools required by the reread configuration. A failure here leaves the existing VM untouched.
  </Step>
  <Step title="Export agent history and save guest home directory">
    Unless `--no-export` (`p.NoExport`), `exportFirst` exports coding agent transcripts to `~/.local/share/devvm/exports/`. Unless `--clean-home` (`!p.Home`), `saveHome` starts the VM if stopped, runs `tar -czf - --one-file-system --ignore-failed-read .` in the guest's `~`, and pipes the stream through `copyHome` on the host into `vms/<hash>/home.tar.gz`. Any failure in export (other than exceeding 2 GiB) or home archiving aborts the remake before the VM is modified.
  </Step>
  <Step title="Record remake state and set old machine aside">
    `remaking.remake` writes the `remake` section (`remakeInVM`) to `vm.json`. `setAside` stops the VM, stops its proxy, port forwarder, and link session, and renames the backend instance from `devvm-<slug>-<hash>` to `devvm-<hash>-old` (`vmname.Aside`). Registering `remaking.undo` on the UI cleanup stack ensures any subsequent error or interrupt rolls back to the aside machine.
  </Step>
  <Step title="Create and boot the new machine">
    `finish` calls `creation.makeMachine` to create a new backend VM under the original name `devvm-<slug>-<hash>` with the preserved state settings. `st.Update` sets `remake.made = true`, snapshots the previous `from`, `tools`, `ports.forward`, and `devcontainer` state into `remake.was` (`madeWith`), applies any reread `devcontainer.json` or clone mode switch (`switchedTo`), and resets `devcontainer.made` so `postCreateCommand` runs on the new disk. For `devvm clone off`, `mountFolder` removes the old clone remote (saving unmerged commits) and locks `.git`. `starter.bringUp` then boots and self-checks the new machine.
  </Step>
  <Step title="Restore home directory and commit">
    Unless `--clean-home`, `restoreHome` streams `vms/<hash>/home.tar.gz` into the running guest via `tar -xzf - --skip-old-files`, preserving files installed by the new image while restoring user files. Once `restoreHome` succeeds, `committed.Store(true)` disables rollback, and `st.Update` records the new `st.Image`, `st.Chain`, and `remake.done = true`.
  </Step>
  <Step title="Delete old machine and clear remake state">
    `dropOld` deletes `devvm-<hash>-old` from the backend, cleans up host artifacts from a previous clone mode (`leftBehind`: unlocking `.git` after `devvm clone on` or clearing the `clone` section after `devvm clone off`), removes the `remake` section from `vm.json`, deletes `vms/<hash>/home.tar.gz`, and opens the guest shell (`launcher.open`).
  </Step>
</Steps>

#### Home archive sanitization (`copyHome` and `homeSkips`)

Because the guest home directory is untrusted input streamed from the VM, `copyHome` in `internal/vm/home.go` never extracts the archive on the host. Instead, it decompresses and re-streams the tar entries in memory into `vms/<hash>/home.tar.gz`, enforcing strict filters:

- Strips leading `./`, cleans paths, and drops `.`, empty paths, absolute paths, `..` traversals, and paths containing control characters (`< 0x20` or `0x7f`).
- Drops entries under `homeSkips`: `.config/devvm` (guest env and secret placeholders), `.ssh/authorized_keys` (managed by the backend), `.cache`, agent login files (`historyExcludes`), and read-only mounted or store-copied agent configuration paths (`agentDirs` and `agentFiles`).
- Rejects any entry whose parent path traverses a previously kept symlink (`throughLink`), preventing symlink traversal outside `~` on extraction.
- Keeps only regular files (`tar.TypeReg`), directories (`tar.TypeDir`), symlinks (`tar.TypeSymlink`), and hard links (`tar.TypeLink`) whose target is a regular file already keptEarlier in the stream. Device nodes, FIFOs, and sockets are dropped.
- Masks file modes with `0777` (stripping setuid, setgid, and sticky bits) and clears user/group ownership headers.

#### Rollback (`undo` / `putBack`) and crash recovery (`finishPending`)

If an error or signal interrupts `remaking.remake` before `committed.Store(true)` (Step 5):

1. `undo` invokes `putBack`, which verifies `devvm-<hash>-old` still exists, stops processes for `devvm-<slug>-<hash>`, deletes the partially created new machine, and renames `devvm-<hash>-old` back to `devvm-<slug>-<hash>`.
2. If `remake.made` was set, `putBack` reverses any `clone` mount changes (`unmountFolder` / `switchedBack`) and restores `remake.was` (`from`, `tools`, `ports.forward`, `devcontainer`).
3. `clear` removes the `remake` section from `vm.json` and deletes `home.tar.gz`.

If `devvm` was killed with `SIGKILL` or `putBack` failed, `remakeInVM` remains in `vm.json`. While `remakePending(st)` is true, read/modify commands fail with `remake_pending` and `devvm gc --orphans` skips the VM. The next `devvm`, `devvm layer add/rm`, `devvm update`, or `devvm clone` in that folder automatically calls `finishPending`:

| Recorded state when `finishPending` runs | Recovery action |
| --- | --- |
| `remake.done == true` | New machine already has its home directory and updated `image`/`chain`; calls `dropOld` to delete `devvm-<hash>-old` and clear `remake`. |
| `devvm-<hash>-old` exists, or original machine exists with `remake.made == false` | Calls `putBack` (if `devvm-<hash>-old` exists) and `clear`, re-runs `remaking.check`, and restarts `remaking.remake` from Step 1. |
| `devvm-<hash>-old` is already gone and `remake.done == false` | The new machine never opened a shell; `makeNew` deletes any partial new machine, rebuilds the target image, and runs `finish` using the preserved `vms/<hash>/home.tar.gz` (or warns with `remake_home_lost` if missing). |

## Related pages

<CardGroup cols={2}>
  <Card title="Images, layers, and content keys" href="/image-and-layer-model">
    How base images, built-in and custom layers, and shared EROFS tool images compute deterministic SHA-256 build keys.
  </Card>
  <Card title="Backend contract and Lima driver" href="/backend-architecture">
    How the four-role backend.Backend interface and Lima driver implement CreateVM, Start, Rename, and confinement.
  </Card>
  <Card title="Isolate work with repository clones" href="/working-with-repo-clones">
    Create VMs with independent Git clones using --clone, switch modes with devvm clone, and rescue unfetched branches.
  </Card>
  <Card title="Configuration (config.toml) and --json schema reference" href="/configuration-and-json-reference">
    Complete reference for ~/.config/devvm/config.toml keys, settings.json precedence, and state directory layout.
  </Card>
  <Card title="CLI reference: VM and workspace commands" href="/cli-vm-and-workspace-commands">
    Flags, arguments, and exit codes for devvm, ls, restart, stop, update, rm, adopt, size, and ports.
  </Card>
  <Card title="Diagnose host, boot, and SSH failures" href="/host-and-vm-diagnostics">
    Troubleshoot corrupt state files, unfinished VMs, replaced folders, and moved project directories.
  </Card>
</CardGroup>
