# Diagnose host, boot, and SSH failures

> Resolve devvm doctor host problems, Ubuntu cloud image boot fallbacks, SSH host-key verification errors, run logs, and moved project directories with devvm adopt.

- 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/cli/doctor.go`
- `internal/backend/lima/check.go`
- `internal/vmssh/checked.go`
- `internal/vmssh/hostkeys.go`
- `internal/vm/move.go`
- `internal/ui/runlog.go`

---

---
title: "Diagnose host, boot, and SSH failures"
description: "Resolve devvm doctor host problems, Ubuntu cloud image boot fallbacks, SSH host-key verification errors, run logs, and moved project directories with devvm adopt."
---

`devvm` diagnoses host prerequisites, VM boot failures, SSH host-key mismatches, and project folder changes across five subsystems: host readiness checks in `devvm doctor` (`internal/cli/doctor.go` and `internal/backend/lima/check.go`), per-run command logs with credential redaction (`internal/ui/runlog.go` and `internal/redact/redact.go`), PGP-verified Ubuntu cloud image pinning and boot fallback (`internal/ubuntu`, `internal/backend/lima/pin.go`, and `internal/build`), per-boot SSH host-key pinning (`internal/vmssh`), and inode birth-time folder identity tracking (`internal/vm/move.go` and `internal/platform`).

## Host readiness and `devvm doctor`

`devvm doctor` inspects the host OS, CPU architecture, Lima installation, virtualization drivers, feature support, disk and memory capacity, network policy syntax, and VM state directories without modifying host state or installing packages automatically.

```bash
devvm doctor
devvm doctor --json
```

When run without `--json`, `devvm doctor` exits `1` if one or more blocking host problems exist (`len(problems) > 0`) and exits `0` when only warnings or informational notices are present. When run with `--json`, it always prints a versioned JSON report to stdout and exits `0` so scripts and CI pipelines can inspect `problems` and `warnings` programmatically.

### Blocking host problems

Every startup path (`starter.hostChecks` in `internal/vm/start.go` and `Builds.checkHost` in `internal/build/build.go`) runs `b.Problems(ctx)` before creating or starting a VM. On the Lima backend (`internal/backend/lima/check.go`), `Problems` evaluates the checks below in order:

| Problem key | Condition checked | Minimum / requirement | Suggested `fix` command in `devvm doctor` |
| --- | --- | --- | --- |
| `os` | `runtime.GOOS` is neither `darwin` nor `linux` | macOS or Linux | `null` |
| `arch` | `runtime.GOARCH` is neither `arm64` nor `amd64` | `arm64` (`aarch64`) or `amd64` (`x86_64`) | `null` |
| `macos` | `sw_vers -productVersion` major version is below `13` (`minMacOS = "13"`) | macOS 13 (Ventura) or newer for `vz` (`Virtualization.framework`) | `null` (update macOS in System Settings) |
| `lima` | `limactl` is not on `PATH` | Lima `2.2` or newer (`minLima = "2.2"`) | `brew install lima` (if `brew` is on `PATH`; otherwise `null`) |
| `lima` | `limactl --version` fails or outputs an unparseable version string | Valid `limactl version X.Y.Z` output | `brew reinstall lima` (if `brew` is on `PATH`) |
| `lima` | `limactl --version` is older than `2.2` | Lima `2.2` or newer (`user-v2` removal and `overwrite` DNS support) | `brew upgrade lima` (if `brew` is on `PATH`) |
| `qemu` | Linux only: `qemu-system-aarch64` (on `arm64`) or `qemu-system-x86_64` (on `amd64`) or `qemu-img` is missing from `PATH` | QEMU system emulator and `qemu-img` | First package manager found on `PATH`:<br/>• `apt-get` (`amd64`): `sudo apt-get install -y --no-install-recommends qemu-utils ipxe-qemu qemu-system-x86 ovmf`<br/>• `apt-get` (`arm64`): `sudo apt-get install -y --no-install-recommends qemu-utils ipxe-qemu qemu-system-arm qemu-efi-aarch64`<br/>• `dnf`: `sudo dnf install -y qemu-kvm qemu-img`<br/>• `pacman`: `sudo pacman -S --noconfirm qemu-base`<br/>• `zypper`: `sudo zypper --non-interactive install qemu-kvm` |
| `kvm` | Linux only: `/dev/kvm` does not exist | Hardware virtualization enabled in BIOS/UEFI or nested virtualization enabled on the hypervisor | `null` |
| `kvm-access` | Linux only: `/dev/kvm` exists but cannot be opened `O_RDWR` by the current user | Read/write access to `/dev/kvm` | `sudo usermod -aG kvm <USER>` (log out and back in for the group change to take effect) |
| `lima-config` | `_config/default.yaml` or `_config/override.yaml` exists inside `~/.local/share/devvm/lima` (`DEVVM_LIMA_HOME`) | No Lima global override files inside `devvm`'s isolated Lima directory | `null` (move or remove `_config/default.yaml` and `_config/override.yaml` from `~/.local/share/devvm/lima/_config/`) |

<Warning>
`devvm` stores its VMs in `~/.local/share/devvm/lima` (`LIMA_HOME`) rather than `~/.lima`. Lima merges `_config/default.yaml` and `_config/override.yaml` from `LIMA_HOME` into every VM's configuration, which can inject unexpected mounts, networks, or provisioning scripts. Both `devvm doctor` and `Lima.Start` refuse to proceed if either file exists under `~/.local/share/devvm/lima/_config/`.
</Warning>

### Non-fatal warnings and notices

After checking blocking problems, `devvm doctor` evaluates non-fatal host warnings (`d.warnings(b)` in `internal/cli/doctor.go`) and informational notices:

- **Shadowed `devvm` binary on `PATH` (`devvm_other_first`):** Compares `os.Executable()` against `exec.LookPath("devvm")` after resolving symlinks (`filepath.EvalSymlinks`). Warns if an older or different `devvm` binary appears earlier on `PATH`.
- **Low disk space (`disk_low_warning` / `disk_free_unknown`):** Checks free space on `~/.local/share/devvm/lima` against the `base` build floor (`8 GiB` via `b.Floors().Build["base"]`). Warns if free space is below `8 GiB` and suggests `devvm space` and `devvm gc`.
- **Low host memory or CPU count (`host_memory_low` / `host_cpus_few`):** Warns when host RAM is below `8 GiB` (`recommendedMemoryGiB = 8`, where default VMs receive half of host RAM) or when logical CPUs are fewer than `4` (`recommendedCPUs = 4`, where default VMs share all host CPUs). Suggests `devvm size` to adjust per-VM or default sizing.
- **Invalid network policy rules (`policy_not_rule` / `policy_broken_deny`):** Scans `~/.config/devvm/policy`, `~/.config/devvm/layers/*.policy`, and `~/.local/share/devvm/vms/*/policy` with `netpolicy.NotRules`. Reports exact `file:line` locations for malformed rules; if a malformed line contains `deny`, warns that the broken `deny` rule is not blocking traffic.
- **Broken or stray VM state directories (`vms_stray`, `vm_folder_stateless`, `vms_unreadable`):** Calls `state.VMFolderProblems(a.dirs.VMs)` to inspect `~/.local/share/devvm/vms/`:
  - Non-directory files or folders whose names are not 12-character lowercase hexadecimal hashes (`vmname.HashLen = 12`) are reported as stray entries.
  - Directories with valid 12-character hex names that lack `vm.json` (and are not mid-creation with only a `lock` file) are reported as stateless VM folders.
- **Host proxy CA notice (`doctor_proxy_ca`):** If `HTTPS_PROXY` / `https_proxy` is set in the environment or `proxy.https` is configured in `~/.config/devvm/config.toml`, and `~/.config/devvm/ca/` contains no `.crt` or `.pem` files, `devvm doctor` prints a reminder to place your TLS inspection root certificate in `~/.config/devvm/ca` so guest VMs trust the proxy.

### `devvm doctor --json` output structure

```json
{
  "version": 1,
  "backend": "lima",
  "problems": [
    {
      "key": "kvm-access",
      "problem": "/dev/kvm cannot be opened by sheing.",
      "fix": "sudo usermod -aG kvm sheing"
    }
  ],
  "warnings": [
    "Only 5.2 GiB is free on /home/sheing/.local/share/devvm/lima; building the base image needs at least 8.0 GiB. Run devvm space to see what devvm is using, or devvm gc to clean up."
  ],
  "docker": true,
  "features": {
    "mounts-ro": "native",
    "mounts-rw": "native",
    "net-confinement": "native",
    "port-forward": "native",
    "snapshots": "native",
    "ssh": "native",
    "ssh-agent": "native",
    "vsock": "native"
  },
  "release": {
    "current": "v0.4.0",
    "latest": "v0.4.1",
    "update_available": true
  }
}
```

<ResponseField name="version" type="integer" required>
Schema version (`1`).
</ResponseField>

<ResponseField name="backend" type="string" required>
Active backend name (`"lima"`).
</ResponseField>

<ResponseField name="problems" type="array" required>
Blocking host problems. Each entry contains `key` (`os`, `arch`, `macos`, `lima`, `qemu`, `kvm`, `kvm-access`, or `lima-config`), `problem` (human-readable description), and `fix` (shell command string or `null`).
</ResponseField>

<ResponseField name="warnings" type="array" required>
Non-fatal warning strings (shadowed binary, low disk/RAM/CPUs, malformed policy lines, or stray folders in `~/.local/share/devvm/vms`).
</ResponseField>

<ResponseField name="docker" type="boolean" required>
Whether the `docker` CLI binary was found on `PATH` (used only when `devvm --from` resolves a local Docker image).
</ResponseField>

<ResponseField name="features" type="object" required>
Map of backend feature keys (`mounts-ro`, `mounts-rw`, `net-confinement`, `port-forward`, `snapshots`, `ssh`, `ssh-agent`, `vsock`) to their capability state (`"native"`, `"emulated"`, `"unreviewed"`, or `"none"`).
</ResponseField>

<ResponseField name="release" type="object">
Present when `~/.local/share/devvm/release-check.json` contains a cached release check and the binary is not a `dev` build. Reports `current`, `latest`, and `update_available`.
</ResponseField>

---

## Run logs, verbose streaming, redaction, and exit codes

Whenever `devvm` runs an external host command (`limactl`, `ssh`, `git`, `mise`, `docker`) or a provisioning script inside a VM, `ui.UI` records the command line and combined output to a per-run log file.

### Run log location, retention, and failure tails

- **Log path:** `~/.local/share/devvm/logs/runs/<YYYYMMDD-HHMMSS>-<pid>.log` (created with mode `0600` inside a `0700` directory).
- **Automatic pruning:** Creating a new run log prunes older `.log` files in `~/.local/share/devvm/logs/runs/`, keeping the newest `20` logs by default (`keepLogs = 20`) or the count configured by `retention.logs.runs` (`devvm retention`).
- **Step markers and guest output cap:** Numbered steps write `# [n/total] <step>` followed by `+ <command args>` into the run log. In-VM command streams (`vmLog`) are capped at `4 MiB` per command (`vmLogMax = 4 << 20`), appending `devvm: truncated` if exceeded.
- **Default failure output (`ErrorLogged`):** When a logged command fails and `-v` / `--verbose` is not enabled, `devvm` prints the failure message, the path to the run log, and the last `25` non-empty lines of the log with terminal control characters stripped:
  ```text
  --- the end of ~/.local/share/devvm/logs/runs/20260711-142210-48291.log:
  ```
- **Live verbose streaming (`-v` / `--verbose`):** Passing `-v` or `--verbose` to any `devvm` command streams each command's output live to `stderr` (dimmed on terminals, with ANSI C0/C1 control sequences stripped) while simultaneously writing to the run log. If the command fails under `-v`, `devvm` prints `(details: <log-path>)` without repeating the 25-line tail.

### Automatic credential redaction

`devvm` redacts secrets before writing lines to the run log and scrubs Lima's hostagent log on disk after every VM start:

1. **Run log stream redaction (`redact.String` in `internal/redact/redact.go`):**
   - Replaces proxy authentication tokens matching `devvm:<32–64 lowercase hex digits>@` with `devvm:<key>@`.
   - Replaces URL credentials matching `<scheme>://<user>:<password>@` with `<scheme>://<user>:<password>@` (literal `<password>`).
   - Replaces bare `user:password@host:port` tokens when `:port` starts with a digit.
   - `redactWriter` buffers up to `64 KiB` (`redactLineMax`) for incomplete lines and holds up to `4 KiB` (`redactTailMax`) of a trailing non-whitespace word across write boundaries so credentials split across `write()` calls are still redacted.
2. **Lima hostagent log redaction (`redact.File` on `ha.stderr.log`):**
   - During `limactl start`, Lima's hostagent writes `lima.yaml` (including `env.HTTP_PROXY` and `env.HTTPS_PROXY`) to `~/.local/share/devvm/lima/<vm>/ha.stderr.log`.
   - Immediately after `limactl start` returns (even on failure), `Lima.Start` calls `redact.File` on `ha.stderr.log`, which overwrites the secret bytes in place with `*` characters without changing the file's byte length or offsets. If in-place redaction fails, `devvm` emits the `lima_log_unredacted` warning naming the file path.

### CLI exit codes

| Exit code | Meaning | Triggered by |
| --- | --- | --- |
| `0` | Success | Command completed normally |
| `1` | Runtime or operational failure | Host check failures (`devvm doctor`), VM boot/start errors, build failures, SSH failures, refused prompts without `--force`, or non-zero exit from a command run via `devvm -- <cmd>` (when no signal interrupted `devvm`) |
| `2` | CLI usage error | Unknown commands or words (`word.go`), invalid argument counts (`args.go`), invalid flag values (`checkFlagValue`), or mutually exclusive flags (`exclusive`), all validated before any VM or host action runs |
| `130` | Interrupted (`SIGINT`, `SIGTERM`, `SIGHUP`) | Signal cancels the root context, waits for any in-flight logged command, waits up to `2s` (`returnGrace`) for `RunE` to return when cleanups are registered, runs all registered cleanups with a fresh context, and exits `130`. A second signal is ignored |

---

## Ubuntu cloud image pinning, boot fallback, and Lima start failures

When building the `base` image on Lima (without `--from`), `devvm` resolves an upstream Ubuntu 26.04 (`resolute`) cloud image, verifies its PGP signature, pins the release, and boots a build VM.

```mermaid
flowchart TD
    A["Build base image (internal/build)"] --> B{"~/.local/share/devvm/images/ubuntu.json exists?"}
    B -- "Yes (and not Rebuild)" --> C["Use pinned release & SHA-256"]
    B -- "No (or Rebuild)" --> D["Fetch releases index, SHA256SUMS, SHA256SUMS.gpg"]
    D --> E{"PGP signature valid against ubuntu-cloud.gpg?"}
    E -- "No" --> F["Fail immediately: ubuntu_bad_signature (no fallback)"]
    E -- "Yes" --> G["Write pin to ubuntu.json (mark fresh in current run)"]
    C --> H["Create & start build VM devvm-img-<key>"]
    G --> H
    H --> I{"Boot succeeded?"}
    I -- "Yes" --> J["Run base.sh & feature scripts"]
    I -- "ErrBoot on freshly pinned release (once per run)" --> K["Warn origin_no_boot & PinOlder release"]
    K --> H
    I -- "ErrBoot on previously pinned or second failure" --> L["Keep stopped devvm-img-<key> & fail"]
```

### PGP verification and `ubuntu.json` pinning

`ubuntu.Finder` (`internal/ubuntu/ubuntu.go`) queries `https://cloud-images.ubuntu.com/releases/resolute/` for date-stamped release directories (`YYYYMMDD` or `YYYYMMDD.N`), starting from the newest release:

1. For each candidate directory, it downloads `SHA256SUMS` and `SHA256SUMS.gpg` (capped at `64 KiB`).
2. `verifyPGP` verifies the detached signature in `SHA256SUMS.gpg` against the embedded Ubuntu Cloud Image keyring (`assets/layers/ubuntu-cloud.gpg`, fingerprint `D2EB44626FDDC30B513D5BB71A5D6C4C7DB87C81`).
   - If the signature is invalid or made by a different key, `devvm` fails immediately with `ubuntu_bad_signature` (`Ubuntu's SHA256SUMS signature for <release> is not valid`). It never falls back past a bad signature.
3. Once verified, it extracts the SHA-256 digest for `ubuntu-26.04-server-cloudimg-arm64.img` or `ubuntu-26.04-server-cloudimg-amd64.img`, resolves the final redirect URL via `HEAD`, and writes `~/.local/share/devvm/images/ubuntu.json` under the file lock `~/.local/share/devvm/images/locks/ubuntu`.

### Automatic boot fallback (`origin_no_boot`)

Occasionally, a newly published upstream Ubuntu cloud image passes PGP verification and SHA-256 checks but fails to boot under Lima's `vz` or `qemu` driver. `Builds.fallBack` (`internal/build/build.go`) handles this automatically under strict conditions:

- The image being built is `base` (with `From == ""`),
- The error wraps `backend.ErrBoot` (`limactl start` failed to bring the VM up),
- The Ubuntu release was **freshly pinned during the current `devvm` invocation** (`b.fresh != ""`), and
- `devvm` has not already fallen back in this invocation (`!b.fellBack`).

When all four conditions hold, `devvm` prints a warning (`Ubuntu cloud image <release> did not boot; trying the previous release`), calls `PinOrigin(ctx, nil, backend.PinOlder)` to pin the newest verified release older than the failed one in `~/.local/share/devvm/images/ubuntu.json`, and retries the build once. A release that was already pinned in a prior run does not trigger fallback because it already booted when first pinned.

To manually re-pin to the latest upstream Ubuntu cloud image and rebuild `base`, run:

```bash
devvm rebuild base
```

### Degraded boot, 60-second SSH check, and serial logs

When `limactl start` returns a non-zero exit code during `Lima.Start` (`internal/backend/lima/start.go`), `degraded()` checks whether Lima's hostagent actually brought the VM to `Status == "Running"` (for example, when a non-critical cloud-init check timed out):

1. If the VM is not `Running`, `Start` fails immediately.
2. If the VM is `Running`, `degraded()` tests SSH readiness by running `limactl shell --workdir / <vm> true` with a **60-second timeout** (`sshTimeout = 60 * time.Second`).
3. **SSH succeeds within 60 seconds:** `devvm` emits the `started_with_warnings` warning (`VM <vm> started with warnings; see <run-log>`) and continues normal startup.
4. **SSH fails or times out after 60 seconds:** `devvm` force-stops the half-started VM (`limactl stop --force <vm>`) and returns `start_no_ssh` (`wrapping backend.ErrBoot`), pointing you to the guest serial console log:
   ```text
   ~/.local/share/devvm/lima/<vm>/serial*.log
   ```
   Inspect `serial*.log` (such as `serial.log` or `serialv.log`) and `ha.stderr.log` in `~/.local/share/devvm/lima/<vm>/` to see kernel panic messages, cloud-init errors, or rootfs mount failures.

### Inspecting a failed image build VM

When an image build fails **after** the temporary build VM (`devvm-img-<key>`) has been created, `devvm` stops the build VM and **keeps it on disk** so you can inspect the failure inside the guest. The error output prints the exact command to open a shell in the failed build VM:

```bash
devvm limactl shell --start devvm-img-<key>
```

The next `devvm build`, `devvm rebuild`, or `devvm gc` automatically deletes the leftover `devvm-img-<key>` VM before starting a new build.

### Broken Lima driver states, socket path limits, and network checks

- **Broken Lima state recovery (`statusBroken`):**
  - If an earlier start was interrupted and left the VM in Lima's `Broken` state with a live `HostAgentPID` and `DriverPID == 0`, `Lima.Stop` waits up to **30 seconds** (`driverWait = 30 * time.Second`, polling every `200ms` in `settled()`) for the hostagent to record `DriverPID` before running `limactl stop --force <vm>`, preventing orphaned `qemu-system-*` processes.
  - Conversely, when `Lima.Start` sees `Status == "Broken"` with `DriverPID != 0` and `HostAgentPID == 0` (an orphaned QEMU process still holding the disk write lock after a crashed hostagent), `Start` runs `limactl stop --force <vm>` before starting the VM.
- **Unix socket path length limit (`lima_home_too_long`):**
  - Lima creates `ssh.sock` inside `<LimaHome>/<vm>/ssh.sock` and OpenSSH appends a 17-byte suffix (`sshSocketExtra = 17`) against the 104-byte Unix domain socket path limit (`socketMax = 104`).
  - If `~/.local/share/devvm/lima` is on a deep directory path such that `<LimaHome>/<vm>/ssh.sock` plus 17 bytes reaches 104 bytes, `CreateVM` fails with `lima_home_too_long`. Set `DEVVM_LIMA_HOME` to a shorter path (for example, `/tmp/devvm-lima` or `~/.devvm-lima`) to shorten the socket path.
- **Unauthorized Lima networks (`vm_has_networks`):**
  - `devvm` VMs use only Lima's user-mode networking (`user-v2: false`) confined by the host proxy. If a VM's `lima.yaml` contains any `networks:` entries, `Lima.Start` refuses to start the VM with `vm_has_networks` (`VM <vm> has Lima networks configured, which would bypass network confinement`). Run `devvm update` or `devvm rebuild` to recreate the VM from a clean template.

---

## SSH host-key verification and connection failures

Every SSH connection managed by `devvm`—including `~/.local/share/devvm/ssh_config`, local port forwarders (`devvm _ports`), inter-VM links (`devvm _links`), and `--clone` git fetches—passes through `vmssh.Checked` (`internal/vmssh/checked.go`).

### How per-boot host-key pinning works (`PinHostKeys`)

Instead of using `StrictHostKeyChecking=accept-new` or trusting static keys across reboots, `devvm` reads and pins the VM's public host keys directly through the backend's local control channel (`b.Exec`) on every VM start:

1. **Clear previous keys before boot (`ForgetHostKeys`):** Before calling `b.Start`, `starter.start` (`internal/vm/start.go`) removes `~/.local/share/devvm/vms/<hash>/run/known_hosts`.
2. **Verify loopback SSH target (`FromTarget`):** `vmssh.Checked` verifies that `b.SSH(ctx, vm)` returns a loopback address (`127.0.0.1`), a valid TCP port (`1..65535`), a valid username, and an existing identity key file. Any non-loopback address or missing field returns `*vmssh.UnusableError`.
3. **Match against the current boot's start ID (`backend.Run.ID`):** On Lima, `Running()` constructs a unique start ID from `<ha.pid> <ha.pid mtime in RFC3339Nano>`. `PinHostKeys` checks whether `~/.local/share/devvm/vms/<hash>/run/known_hosts` starts with the header line:
   ```text
   # devvm: host keys of start <run.ID>
   ```
   If the header matches the running VM's `run.ID` and contains valid keys, `devvm` reuses the pinned file without re-querying the guest.
4. **Read and validate keys from the guest (`readVMHostKeys`):** When `run/known_hosts` is missing or belongs to an earlier boot, `PinHostKeys` runs `sh -c "cat /etc/ssh/ssh_host_*_key.pub"` inside the VM via `b.Exec` (with a `30s` timeout and `64 KiB` output cap):
   - `ParseHostKeys` validates each line: the key type must match `^[A-Za-z0-9][A-Za-z0-9@._+-]{0,63}$`, must not contain `-cert-`, must base64-decode to at most `4096` bytes, and its inner SSH wire-format length-prefixed algorithm name must equal the outer key type field.
   - Any `@cert-authority` or `@revoked` markers, wildcard host patterns, or malformed lines are dropped.
   - `PinHostKeys` re-checks `b.Running(ctx, vm)` to confirm the VM did not restart while keys were being read, then writes `~/.local/share/devvm/vms/<hash>/run/known_hosts` with `StrictHostKeyChecking yes` and `UserKnownHostsFile ~/.local/share/devvm/vms/<hash>/run/known_hosts`.

### Diagnosing SSH and editor connection errors

<AccordionGroup>
  <Accordion title="Host key verification failed when connecting via ssh or VS Code">
    **Cause:** Whenever `Lima.Start` edits `lima.yaml` via `limactl edit` (for example, after changing CPU/memory size, mounts, or `forwardAgent`), Lima's cloud-init regenerates `/etc/ssh/ssh_host_*_key.pub` on the next boot. While `devvm` batches all changes into a single `limactl edit` and pins the new keys into `~/.local/share/devvm/vms/<hash>/run/known_hosts`, starting a VM outside `devvm` (such as running `devvm limactl start <vm>` directly) leaves `run/known_hosts` with the previous boot's keys and leaves network confinement unenforced (`confinement: unenforced` in `devvm ls`).

    **Fix:** Run `devvm` or `devvm host` in the project directory. `devvm` will detect the new boot `run.ID`, re-pin `/etc/ssh/ssh_host_*_key.pub` into `run/known_hosts`, and update `~/.local/share/devvm/ssh_config`. If your personal `~/.ssh/known_hosts` cached an old key for `[127.0.0.1]:<port>`, ensure you connect using the VM host alias (`ssh devvm-<slug>-<hash>`) rather than `ssh 127.0.0.1 -p <port>`, because the generated `Host` entry isolates host keys to `~/.local/share/devvm/vms/<hash>/run/known_hosts`.
  </Accordion>

  <Accordion title="ssh: Could not resolve hostname devvm-<slug>-<hash>">
    **Cause:** There are two common causes:
    1. **The VM is stopped:** `devvm` removes stopped VMs from `~/.local/share/devvm/ssh_config` so another local process cannot bind the freed loopback port and impersonate the VM.
    2. **Missing `Include` in `~/.ssh/config`:** OpenSSH only applies `Include` directives that appear **before** any `Host` or `Match` block in `~/.ssh/config` (unless placed at the very top of the file).

    **Fix:**
    1. Run `devvm host` in your project directory. This starts the VM if stopped, writes its `Host` entry to `~/.local/share/devvm/ssh_config`, ensures `Include ~/.local/share/devvm/ssh_config` is at the top of `~/.ssh/config`, and prints the exact host alias (`devvm-<slug>-<hash>`).
    2. To refresh `~/.local/share/devvm/ssh_config` for all currently running VMs without starting a new one, run `devvm --ssh-config`.
  </Accordion>

  <Accordion title="ssh_config: Bad configuration option: PermitRemoteOpen">
    **Cause:** Every `Host` block generated in `~/.local/share/devvm/ssh_config` sets `PermitRemoteOpen none` so a compromised guest cannot open reverse tunnel destinations through your forwarded SSH client connection. OpenSSH added `PermitRemoteOpen` in **OpenSSH 8.7**.

    **Fix:** Upgrade the host OpenSSH client to OpenSSH `8.7` or newer.
  </Accordion>

  <Accordion title="SSH keys for <vm> could not be pinned: VM generated no SSH host keys">
    **Cause:** `PinHostKeys` ran `cat /etc/ssh/ssh_host_*_key.pub` inside the guest, and none of the output lines passed `ParseHostKeys` validation (`vmssh.ErrNoHostKeys`), or `sshd` / cloud-init failed to generate `/etc/ssh/ssh_host_*_key.pub`.

    **Fix:**
    1. Inspect the guest state with `devvm limactl shell <vm> -- ls -la /etc/ssh`.
    2. Regenerate missing host keys inside the guest (`sudo ssh-keygen -A`) or recreate the VM from its base image while preserving `/home/dev` with `devvm update`.
  </Accordion>
</AccordionGroup>

---

## Moved, recreated, and missing project folders (`devvm adopt` and `devvm rm`)

`devvm` stores zero files inside your project directory. Instead, it hashes the project directory's resolved physical path (`paths.Physical`) into a 12-character hex ID (`vmname.HashOf(dir)`) and stores both the path (`"dir"`) and the directory's filesystem identity (`"folder"`) in `~/.local/share/devvm/vms/<hash>/vm.json`.

### How `devvm` identifies project folders (`FolderID`)

`platform.FolderID(path)` (`internal/platform/folder_darwin.go` and `folder_linux.go`) records a composite string of device ID, inode number, and creation (birth) timestamp:

```text
<dev>:<ino>:<btime_sec>.<btime_nsec>
```

- **macOS (`darwin`):** Reads `st.Dev`, `st.Ino`, and `st.Birthtimespec` via `os.Lstat`.
- **Linux (`linux`):** Calls `unix.Statx` with `STATX_BTIME | STATX_INO`. If the kernel or filesystem sets `STATX_BTIME`, formats `<DevMajor>:<DevMinor>:<Ino>:<Btime.Sec>.<Btime.Nsec>`; otherwise falls back to `<dev>:<ino>` without a birth time.

Automatic move detection (`finder.moveHere` in `internal/vm/move.go`) only trusts `FolderID` automatically when `platform.UniqueFolderID(dir, id)` returns `true`:
1. `id` includes a birth timestamp (`strings.Count(id, ":") == 2`), and
2. `dir` resides on a local filesystem whose inode and birth time are stable and never fabricated (`apfs` or `hfs` on macOS; `ext4`, `xfs`, `btrfs`, `tmpfs`, `zfs`, `f2fs`, or `bcachefs` on Linux), and
3. The current user owns the directory (`uid == os.Getuid()`).

### Resolving folder state changes

| Scenario | What `devvm` detects | How to resolve |
| --- | --- | --- |
| **Moved or renamed on the same disk** (`mv ~/code/app ~/code/app-v2` or symlinked old path to new path) | Current directory has no VM of its own, `UniqueFolderID` matches an existing finished non-clone VM's `st.Folder`, and the old path is gone (`FolderGone`) or is a symlink to the new path. | • **Interactive terminal:** Run `devvm` in the new folder and answer `y` to `Folder moved from <old> to <new>. Move VM <vm> here? [y/N]`.<br/>• **Non-interactive / scripts:** Run `devvm adopt` (or `devvm adopt --force`) in the new folder. |
| **Deleted and recreated at the same path** (`rm -rf app && mkdir app` or fresh `git clone` at the same path) | `st.Dir` matches the current directory, but `platform.FolderID(dir) != st.Folder`. Commands refuse with `This folder was recreated since VM <vm> was made for it`. | • **Keep the existing VM for the new folder:** Run `devvm adopt` (or `devvm adopt --force`). This updates `st.Folder` and resets `.git` protection state (`gitprotect.Made{}`) so `.git` is locked fresh on the next launch.<br/>• **Discard the old VM:** Run `devvm rm` in an interactive terminal (for safety, `--force` does **not** bypass the `Delete VM <vm> from the earlier folder at <dir>?` prompt, and agent history export is skipped). |
| **Copied across disks or onto NFS/SMB/FUSE** (`cp -R` to another volume, then deleted original) | New directory has a different `<dev>:<ino>:<btime>`, so automatic move detection does not match. | 1. Ensure the original folder has been deleted or its drive is disconnected (`devvm adopt <VM>` refuses with `VM <vm>'s folder still exists at <dir>` while the old folder is present).<br/>2. In the new folder, run `devvm adopt <VM>` (or `devvm adopt <VM> --force`). |
| **Deleted project folder (`(gone)` in `devvm ls`)** | `FolderOf(st)` walks up to the nearest existing parent directory and sees it is on the **same** device as `st.Folder`, proving the folder was deleted rather than unmounted. | • **Attach to a replacement folder:** `cd` into the new folder and run `devvm adopt <VM>`.<br/>• **Delete the VM:** Run `devvm rm <VM>`. If the VM is stopped and has coding agents installed, pass `--no-export` (`devvm rm --no-export <VM>`) because starting a stopped Lima VM to export agent history would cause Lima to recreate the missing host mount directory. If the VM is a `--clone` VM, pass `--no-clone-check` (`devvm rm --no-clone-check <VM>`). |
| **Project folder on an unmounted drive (`(not_connected)` in `devvm ls`)** | `FolderOf(st)` walks up to the nearest existing ancestor directory (such as `/Volumes` or `/mnt`) and sees its device ID differs from the device prefix in `st.Folder`. | • Reconnect the external drive or volume and run `devvm`.<br/>• To delete the VM without reconnecting the drive, run `devvm rm <VM>` in an interactive terminal (`--force` never bypasses the `not connected` confirmation prompt). |
| **Nested project folder replaced by a symlink or another folder** | Checked three times during startup (`folderChanged` in `internal/vm/start.go`): before host checks, immediately before `b.Start`, and immediately after `b.Start`. | `devvm` refuses to start (`project_folder_changed`) or immediately stops the VM and proxy (`project_folder_changed_stopped`) if the directory was swapped or replaced by a symlink during startup. Restore the real directory or run `devvm adopt`. |

<Note>
`--clone` VMs cannot be moved or adopted (`devvm adopt` refuses with `Clone VM <vm> cannot be adopted`) because their git worktree resides inside the guest disk rather than on a host mount.
</Note>

### What `devvm` updates when a VM moves to a new folder

When a VM is moved (either by answering `y` to the move prompt or by running `devvm adopt` / `devvm adopt <VM>`):

1. **Stops the running VM and host daemons (`stopForMove`):** If the VM is running, `devvm` stops the guest VM, stops its host proxy (`_proxy`), port forwarder (`_ports`), and inter-VM link sessions (`_links`), and refreshes `~/.local/share/devvm/ssh_config` so the VM restarts with a mount pointing to the new host path.
2. **Updates `vm.json` and records the previous path:** Updates `st.Dir` and `st.Folder` in `~/.local/share/devvm/vms/<hash>/vm.json` (the VM's name and `<hash>` stay unchanged) and saves the previous path in the `moved` section (`state.MovedSection`) until the next guest sync completes.
3. **Preserves or resets `.git` protection:**
   - For a same-folder move (`moveHere` or `adoptVM` with an explicit `<VM>` argument), `.git` protection state is preserved across the move.
   - For a folder recreated in place (`devvm adopt` with no arguments), `gitprotect.Save(st, gitprotect.Made{})` clears the recorded `.git` lock state so the new directory's `.git` is locked fresh on the next `devvm` launch.
4. **Migrates coding agent session history:**
   - **Claude Code:** On the next launch, `sync.sh` passes the old folder path to `assets/guest/claude.sh`, which renames `~/.claude/projects/<old-slug>` to `~/.claude/projects/<new-slug>` (where `<slug>` replaces every non-alphanumeric character with `-`) and updates the key in `~/.claude.json` under `projects` so `claude --continue` and `claude --resume` find prior conversations immediately.
   - **Codex:** Stores session transcripts by date under `~/.codex/sessions` tagged with the working directory; after moving a folder, run `codex resume --all` inside the VM to view sessions from the previous path.
   - **opencode:** Indexes sessions by git root commit in `~/.local/share/opencode`, so sessions remain attached across directory moves as long as the git repository history is unchanged.

## Related pages

<CardGroup cols={2}>
  <Card title="Installation & host setup" icon="download" href="/installation">
    Install devvm, Lima, QEMU/KVM on Linux, and verify host prerequisites.
  </Card>
  <Card title="VM lifecycle & state" icon="server" href="/vm-lifecycle-and-state">
    Understand how devvm creates, starts, stops, updates, and tracks project VMs in vm.json.
  </Card>
  <Card title="SSH, editors & desktop" icon="terminal" href="/ssh-editors-and-desktop">
    Connect to VMs over SSH, VS Code Remote-SSH, SSH agent forwarding, and VNC desktop.
  </Card>
  <Card title="Network, CA & storage troubleshooting" icon="shield-halved" href="/network-ca-and-storage-troubleshooting">
    Debug blocked proxy requests, TLS inspection CAs, disk space floors, and garbage collection.
  </Card>
</CardGroup>
