#!/bin/sh
# i9 installer — https://i9.io/vm
#
# WHAT THIS SCRIPT DOES, in order, and nothing else:
#   1. Checks this host: Linux x86_64 or arm64 with /dev/kvm, or macOS on
#      Apple silicon.
#   2. Downloads ONE file, the i9 binary for this platform (its URL is in the
#      "release data" block below), to $I9_HOME/bin/i9 (default
#      ~/.i9/bin/i9). The script itself writes nothing outside $I9_HOME.
#      (The VM image is streamed by the binary on first run, not by this
#      script; see step 4.)
#   3. Refuses to install it unless its sha256 is exactly the one in the
#      "release data" block below.
#   4. Starts a VM from the image named in that block (skipped with
#      I9_NO_BOOT=1). The i9 binary, not this script, streams that image
#      from a.i9.io while the VM runs.
#
# WHAT THE i9 BINARY DOES ONCE STEP 4 RUNS IT (not this script):
#   - Telemetry beacon, on by default: fetching the VM image sends your OS,
#     CPU architecture and i9 version, with no identifier. Turn it off with
#     `i9 config set telemetry.enabled false` (one run: DO_NOT_TRACK=1).
#   - Temporary files: a running VM keeps its per-run sockets
#     in /tmp and removes
#     them when it stops. Its images, snapshots and traces are under $I9_HOME.
#   More: `i9 docs DATA-HANDLING`.
#   To remove i9: `i9 uninstall` (or `i9 uninstall --dry-run` to see
#   what it would remove without acting).
#
# WHY TRUST THE SHA256 IN THE RELEASE DATA BLOCK: each i9 release's digests
# are signed with the i9 release key (ECDSA P-256). The server that sent you
# this script verified that signature before writing the digests in, and
# refuses to send an installer for a release whose signature does not verify.
# Release key ids (sha256 of the public key, first 8 bytes):
#   9bcabf33e1bafba6
# Check the signature yourself, independent of this script:
#   https://i9.io/vm/verify
# Trust boundary: whoever controls the i9.io web server controls this script,
# as for any `curl | sh` installer. A stolen download-bucket credential alone cannot
# pass step 3.
#
# TOOLS USED: sh, uname, mkdir, mktemp, rm, mv, chmod, curl, and sha256sum or
#   shasum. macOS also: xattr. No sudo. This script downloads nothing else.
# ENVIRONMENT READ: I9_HOME, I9_CHANNEL (stable|edge), I9_NO_BOOT (1),
#   I9_DETACH (1), I9_SESSION_NAME.
# EXIT CODES (the numbers the i9 binary uses; schemas/exit-codes.yaml):
#   0 installed (and VM started)       5 ENETWORK    download failed; retry
#   2 EUSAGE     invalid I9_* value    6 EREFUSED    release not signed or withdrawn
#   4 EVALIDATION sha256 mismatch      7 ESERVER     VM did not come up
#   8 EPLATFORM  unsupported host
# Every refusal prints one line:  [i9] E<NAME>: <what happened> — <what to do>
# Contact and security: https://i9.io/vm/contact · security@i9.io
#
# >>> agent instructions: the promoted stable's https://i9.io/vm/agent.md >>>
# # i9 agent-dev VM — agent instructions
#
# Goal: a running agent-dev VM reachable through the i9 MCP server. Do the
# steps in order. On any non-zero exit, stop and report the failing step's
# command and its output.
#
# ## When something breaks
# 1. Read what failed: `i9 doctor`, then `i9 trace search --failed --session <PID>`.
# 2. If i9 itself failed (exit 1 `EINTERNAL`, a crash, or a hang you had to kill), post a report so it gets fixed. Include:
#    - the i9 version
#    - the command you ran
#    - the exit status and the error envelope
#    - the output of `i9 trace search --failed` for the session that failed
#
#    ```
#    curl -X POST https://feedback.i9.io -H 'Content-Type: text/plain' -H "X-I9-Version: $(i9 --version)" --data-binary @report.txt
#    ```
#
#    Never include secrets, tokens, file contents or personal data. Posts are anonymous and get no reply.
#
# ## What this VM is
# - The VM resumes from a streamed snapshot and takes its first command within seconds, before the whole image has arrived; it can be stopped and discarded or kept.
# - Every command and process run in the guest is recorded as a structured trace: `i9 trace search --failed --session <PID>` lists what failed and `i9 trace stats --by slowest --session <PID>` what took longest; the `i9_trace_*` MCP tools query the same data, so two runs are compared by querying each.
# - `i9 vm save "$URL"` captures the running VM; `i9 vm fork <snapshot>` (the path `vm save` printed) starts an independent copy with its own memory and disk, as many as the running-vCPU cap allows.
# - Your commands run in the guest as the user `dev` (uid 1000), NOT as root; `sudo` is passwordless, so prefix anything that needs root with `sudo`. The VM is isolated from this host's FILES and PROCESSES: the host's files and processes are out of reach unless a port is published or a file is copied in. Outbound traffic to the public internet is open. The host's own networks are refused: its LAN, its VPN/Tailscale address (100.64.0.0/10), its loopback and link-local. A service on this host or your LAN is unreachable from the guest unless its literal address is allowed (`i9 run --allow <ip>:<port>`) or, for a host loopback service, its port is published.
# - Snapshot a session with `i9_vm_save`, branch it with `i9_vm_fork`, and roll it back with `i9 vm rewind <URL> <id>` in a shell (the `i9_vm_rewind` tool exists only on an MCP server started with `--write-tools`).
# - A snapshot restores on the host that saved it. Copying the `.i9io` file to another host does not move it: its memory and disk are kept in that host's snapshot store. To move a snapshot, `i9 snapshot export <snapshot> --out <file>` writes a standalone copy that carries the VM's memory and disk. It restores on another host of the same architecture that can still download the VM's base image and accepts this install's signer (`i9 config set provenance.mode interactive`). A snapshot another install signed is judged on who signed it, not on where it came from: with `provenance.mode` unset an agent (MCP, `--json`, no TTY) is refused by name and a person at a terminal is asked to confirm; `i9 config set provenance.mode development` restores it under a prominent warning instead, for a file and a URL alike. A corrupt or truncated one is refused in every mode. A snapshot carries everything in the guest, including any credential or token file copied in with `i9 vm cp`, so share a `.i9io` only with people you would hand those secrets to.
# - No agent is preinstalled: install yours in the guest (§2). To drive VMs from an agent, run i9's MCP server on the HOST (`i9 agent install --write-tools`); wiring MCP inside the guest is optional and gives a guest agent only its own traces. Every command run in the guest is traced; query it with the `i9_trace_*` tools. No Docker daemon is needed on your host, and no account. The VM image itself carries Docker Engine with the compose and buildx plugins.
# - i9 needs no account and no i9 service to run a VM: `i9 run <file>.i9io` boots a downloaded image or an exported snapshot with no network at all, and `i9 run` given the URL of a file on any HTTP server you run that answers Range requests streams it from there. The image URLs i9 publishes need the network.
# - Provider API keys an agent uses without reading: `i9 secret set` plus `--secret NAME` puts the real key into requests as they leave for the provider, so it never enters the guest, a trace or a snapshot (a secret that is not a provider key does enter the guest).
# - The telemetry beacon is on by default: fetching the VM image sends your OS, CPU architecture and i9 version, with no identifier; turn it off with `i9 config set telemetry.enabled false`. i9 keeps its images, snapshots and traces under `~/.i9`; a running VM also keeps its per-run sockets in `/tmp` until it stops. `i9 docs DATA-HANDLING` has the rest.
# - Contract for `i9 vm exec` and `i9 vm cp`: a VM, with your command run in a container inside it, as `dev` (uid 1000) with HOME `/home/dev` and PATH `/home/dev/.i9/bin:/home/dev/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin` (the login shell's PATH, so an agent installed per §2 runs by bare name with no export). `exec` and `cp` reach the container filesystem, where `/`, `/home/dev` and `/tmp` are one overlay; `i9_vm_save` and `i9_vm_rewind` restore all of it. `exec` carries output bytes exactly: binary output arrives unchanged. A plain `i9 vm exec` streams output with no size cap; with `--json` or through the MCP tool, one exec returns at most 8 MiB per stream (use `cp` beyond that). `i9 vm exec` forwards its stdin to the command as `ssh` does (`printf x | i9 vm exec "$URL" -- cat` prints `x`), without a size limit; it returns when the command exits whether or not stdin is still open, a terminal stdin is not forwarded, and inside a `while read` loop give the exec `< /dev/null`. The `i9_vm_exec` MCP tool has no stdin (the command reads /dev/null): put input in a file with `cp` and read it there. The command starts in `/workspace` unless `--cwd` names another directory. `--shell` runs it through `/bin/sh`, which is dash, so bash-isms such as `time ( ... )` fail with `Syntax error`: run `bash -c '...'` for those. A background process outlives the exec that started it (`nohup setsid cmd >log 2>&1 &`; redirect its output so the exec can return), which is how a device-code login started in one exec finishes in a later one.
#
# ## What it is for
# - A sandbox for a coding agent or for code of unknown provenance, run here rather than on the host: you run as `dev` with passwordless `sudo`, and the host's files and processes are out of reach.
# - A prepared environment: install the toolchain, clone the repo and start the services once, then `i9 vm save "$URL"`; start each new task with `i9 vm fork <snapshot>` (the path `vm save` printed) instead of repeating setup.
# - Parallel attempts: `i9 vm save` the failing project, `i9 vm fork <snapshot>` once per patch, run the same tests in each fork, compare the results and each fork's trace, keep the branch that passes and `i9 vm stop --discard` the rest. Running forks count against the vCPU cap (4 running vCPUs on the free tier, 2 per default VM); parked forks do not.
# - Investigate a failing or slow build: run it, then `i9 trace search --failed --session <PID>` and `i9 trace stats --by slowest --session <PID>` (or the `i9_trace_*` tools) name what failed and what was slow; change one thing, run it again and query the new session the same way to compare.
# - Agent evals: fork every attempt from the same snapshot so each starts from the same machine state (services outside the VM are not part of that state), and compare attempts by querying each one's trace with the `i9_trace_*` tools.
# - Undo for a risky step: `i9 vm save` before a migration, an upgrade or an unfamiliar script; if it goes wrong, read its trace, `i9 vm rewind "$URL" <id>` puts the whole machine back; then try again.
#
# ## 0. Preflight
# ```
# uname -s; uname -m
# ```
# Expect `Linux x86_64`, `Linux aarch64`/`Linux arm64`, or `Darwin arm64`.
# Anything else: stop, host unsupported.
#
# On Windows there is no `uname`: in PowerShell run `$env:OS` and expect
# `Windows_NT`, then use the PowerShell command in §1 instead of this §0.
#
# On Linux:
# ```
# [ -r /dev/kvm ] && echo KVM_OK
# ```
# Expect stdout `KVM_OK`. No output: stop, `/dev/kvm` required.
#
# ```
# command -v curl
# ```
# Expect a path on stdout. Empty: stop, install curl.
#
# ## 1. Install and start
# Detached — no TTY required, use this form:
# ```
# curl -fsSL https://i9.io/vm | I9_DETACH=1 sh
# ```
# The assignment goes on `sh` (the right side of the pipe): the script is
# what reads I9_DETACH. `I9_DETACH=1 curl … | sh` applies it to curl, the
# script never sees it, and the interactive --terminal branch hangs at a
# password prompt.
# Expect exit 0 and a `ws://` or `wss://` URL as the last stdout line. The
# same URL is also written to `~/.i9/run/vm.url`. The VM is started by
# `i9 run --detach`, which runs it in its own session — a SIGTERM to this
# command's process group, what a timeout or your own harness sends when a
# tool call ends, does not reach it — and returns once the VM reports ready;
# if it does not, the installer exits 7 (`ESERVER`) after the path of its
# startup log (`~/.i9/detach/<pid>.log`).
# These paths are under `$I9_HOME` when you set it (default `~/.i9`); with a
# read-only `$HOME`, set `I9_HOME` on `sh` next to `I9_DETACH` and use that
# directory in place of `~/.i9` below. The VM's session token lives in that
# home, so export `I9_HOME` (or pass `--home <dir>`) on every later verb
# (`vm exec`, `vm stop`, `vm ls`, …), not only on `i9 run`; a verb run without it
# is refused with `session control needs this session's token`.
# Binary only, no VM: `I9_NO_BOOT=1`.
# Non-zero exit: see §4; every refusal is one `[i9] E<NAME>:` line.
#
# Interactive form (human at a real terminal — attaches a shell, does not
# return):
# ```
# curl -fsSL https://i9.io/vm | sh
# ```
#
# On Windows, in PowerShell:
# ```
# irm https://i9.io/vm/install.ps1 | iex
# ```
# Windows boots attached only -- no detached form and no `~/.i9/run/vm.url`, so the later steps need a Linux or macOS host.
#
# ## 2. Install your agent in the guest
# The image ships no agent harnesses — the VM is neutral ground.
# Install yours in the guest through the session from §1.
#
# The second column, wiring the guest agent to i9's MCP tools, is
# OPTIONAL and most readers should skip it. Inside the guest there is no
# `/dev/kvm` and `i9 vm ls` is empty, so the sandbox-lifecycle tools
# have nothing to act on from in there: the tools add a large schema to
# every request and none of them apply. Wire it only
# if you want the guest agent to query its own traces with the
# `i9_trace_*` tools.
#
# To drive the VM itself from an agent, run the MCP server on the HOST
# instead. The default server drives VMs that already exist: find one with
# `i9_vm_ls`, then run commands in it with `i9_vm_exec`; the installer has
# already booted one. `i9_run` and `i9_vm_rewind` are served only when the
# server is registered with `i9 agent install --write-tools`; `i9 vm rewind` in a shell undoes a save
# either way.
#
# Installs run minutes. `i9 vm exec` has no default deadline: like `ssh`, it
# runs until the command exits. To bound a call, put `--timeout-ms <ms>`
# BEFORE the `--` (after it, the flag goes to the guest command);
# `i9 vm exec --help-all` lists the flag. A call that exceeds its bound kills
# the command, exits 124 and prints on stderr:
# `error: i9 vm exec: the command did not finish within <ms> ms and was killed (exit 124)`,
# then advice to raise `--timeout-ms` or omit it to wait for the command to exit.
# When i9 recognises the caller as an agent, any non-zero exit (124 included)
# also adds one line after the command's own stderr:
# `i9: exit <N>; see what it did: i9 trace show <id> --session <pid> --internal; where its time went: i9 trace stats --by slowest --where 'under(span_id = <id>)' --session <pid>`,
# with ` (timed out)` after the number when `--timeout-ms` ended the command. The second query ranks only
# that exec's own processes, so the VM's start-up work and other execs stay out of it; `--json` carries it as `trace.slowest`.
# `--json` also exits 124, reports `"timedOut":true` on stdout and prints no stderr line.
# Over MCP, give `i9_vm_exec` a `timeoutMs` long enough for the command: pass
# `timeoutMs: 600000` for each install command below. The wiring commands are
# short and need none.
# ```
# URL="$(cat ~/.i9/run/vm.url)"
# ```
#
#
# `exec` runs in the guest as `dev`, not root, so the `npm install -g` rows
# use `sudo`: without it npm cannot create `/usr/lib/node_modules/<pkg>` and
# exits `EACCES`. `sudo` needs no password in this image.
#
# | Harness | In-guest install | In-guest MCP wiring (optional) |
# |---|---|---|
# | Claude Code | `~/.i9/bin/i9 vm exec "$URL" --shell -- 'curl -fsSL -o /tmp/claude-install.sh https://claude.ai/install.sh && bash /tmp/claude-install.sh'` | `~/.i9/bin/i9 vm exec "$URL" -- i9 agent install --harness claude-code` |
# | Codex | `~/.i9/bin/i9 vm exec "$URL" -- sudo npm install -g @openai/codex` | `~/.i9/bin/i9 vm exec "$URL" -- i9 agent install --harness codex` |
# | Gemini | `~/.i9/bin/i9 vm exec "$URL" -- sudo npm install -g @google/gemini-cli` | `~/.i9/bin/i9 vm exec "$URL" -- i9 agent install --harness gemini` |
# | OpenCode | `~/.i9/bin/i9 vm exec "$URL" -- sudo npm install -g opencode-ai` | `~/.i9/bin/i9 vm exec "$URL" -- i9 agent install --harness opencode` |
# | Pi | `~/.i9/bin/i9 vm exec "$URL" -- sudo npm install -g @earendil-works/pi-coding-agent` | `~/.i9/bin/i9 vm exec "$URL" -- i9 agent install --harness pi` |
# | Hermes | `~/.i9/bin/i9 vm exec "$URL" -- pip install --break-system-packages --no-cache-dir hermes-agent` | `~/.i9/bin/i9 vm exec "$URL" -- i9 agent install --harness hermes` |
#
# A harness's own tool-call sandbox is a second boundary inside the guest, and
# the guest is already the boundary: the VM holds only what you put in it, has
# no access to your host's files or processes, and `i9 vm stop --discard` throws
# it away. So inside the guest, and only there, it is safe to turn the harness's
# sandbox off, and some harnesses need it. A sandbox built on bubblewrap or user
# namespaces can fail in the guest even though the guest does not turn
# unprivileged user namespaces off (`unshare -U true` exits 0 and
# `unshare -Ur id` prints `uid=0`), so a failing sandbox is the harness's own.
# Codex reports every call as
# `bwrap: No permissions to create a new namespace`; in the guest, set
# `sandbox_mode = "danger-full-access"` and `approval_policy = "never"` in the
# guest's `~/.codex/config.toml` and it runs. Why this is safe there and nowhere
# else: the setting removes Codex's own sandbox, and in the guest the VM is the
# sandbox, holding no host files, processes or credentials beyond what you
# copied in, and discarded with `i9 vm stop --discard`. Write the reason next to
# the setting, as a comment in that file, so a reader of the config sees it.
# Turn the equivalent sandbox off in any other harness that has one, again only
# in the guest.
#
# Never change a harness's sandbox, or any host security setting, on your host.
# That includes Codex's `sandbox_mode` and `approval_policy` and the host
# kernel's user-namespace restrictions. On the host nothing stands between the
# harness and the user's files, so this advice applies only inside an i9 guest.
# If a harness's sandbox fails on the host, run the harness inside the i9 guest
# instead.
#
# OpenCode's stock permissions auto-reject `external_directory`, so its first
# read outside the project directory (for example `/etc`) ends the turn. Allow
# `external_directory` in the OpenCode config in the guest before you rely on
# the i9 workflow.
#
# The agent's model credential goes through the secret store, not a file you
# copy in and not argv. Store it on the host once, then start the VM naming it:
# the installer's VM was started with no secrets, so start one yourself (the
# URL it prints replaces `$URL`):
# ```
# i9 secret set ANTHROPIC_API_KEY < ~/.anthropic
# i9 run https://a.i9.io/vm/manifest.json --detach --secret ANTHROPIC_API_KEY
# ~/.i9/bin/i9 vm exec "$URL" --secret ANTHROPIC_API_KEY -- claude
# ```
# The agent reads a stand-in token as `$ANTHROPIC_API_KEY` and at
# `/run/i9/secrets/ANTHROPIC_API_KEY`; use the variable name your harness reads
# (`OPENAI_API_KEY` for Codex, `GEMINI_API_KEY` for Gemini). `i9 secret set`
# stores a provider's key in proxy mode: i9 puts the real value into the request
# as it leaves for the provider's hosts, so the value never enters the guest,
# argv, a trace span, a trace archive or a snapshot. A secret that is not a
# provider key is stored as a file and environment value instead; that value
# does enter the guest, stays readable for the VM's life, and a snapshot taken
# after the agent read it holds it in guest memory. `i9 secret --help-all` lists
# the modes.
#
# MCP tools for the VM: `i9_vm_ls`, `i9_vm_exec`, `i9_vm_cp`,
# `i9_vm_status`, `i9_vm_ports`, `i9_vm_pause`, `i9_vm_resume`, `i9_vm_fork`,
# `i9_vm_save`, `i9_vm_stop`, `i9_trace_*`, `i9_doctor`; `i9_run` and
# `i9_vm_rewind` join them only on a server registered with
# `i9 agent install --write-tools`. The server's tool list gives your harness
# every other tool it serves.
#
# A VM runs one exclusive operation at a time. `i9_vm_save`,
# `i9_vm_fork` and `i9_vm_stop` (and `i9_vm_rewind`, where served) each hold the VM while they
# run, so do not issue another call on the same VM in parallel with one. EVERY
# call that arrives meanwhile, `i9_vm_exec` and `vm cp` included, is refused with `EVMBUSY` (exit 13) and the
# message names what is running: wait for it to finish, then run the call
# again. A rewind replaces the VM, so a command still running in it ends as
# `EVMINTERRUPTED` (exit 14) rather than with an exit status of its own; run
# it again on the restored VM.
#
# ## 3. Verify
# ```
# ~/.i9/bin/i9 vm ls
# ~/.i9/bin/i9 vm exec "$(cat ~/.i9/run/vm.url)" -- echo ok
# ```
# Expect `vm ls` to list a row named `vm`; expect `vm exec` to
# print `ok`. MCP equivalent: `i9_vm_ls`, then `i9_vm_exec` with
# that result's `controlUrl` field.
#
# ### Can this host snapshot?
# ```
# ~/.i9/bin/i9 doctor
# ```
# MCP equivalent: `i9_doctor`. Read the `environment` block. A host whose
# kernel lacks userfaultfd write-protect boots and execs normally and then
# refuses `i9 vm save` — so nothing surfaces the limit until you
# have already done work worth keeping. The doctor says it up front:
#
# ```
#     WARN  uffd write-protect         ABSENT
#     This host can RUN and RESTORE a guest but cannot CAPTURE one
# ```
#
# `--json` carries the same under `environment`: `can_run`, `can_restore`,
# `can_capture`. On arm64 Linux (KVM) it also reports the PSCI versions this host will
# hold, which is what decides whether a snapshot from another machine can be
# restored here at all; the macOS (Hypervisor.framework) report has no PSCI row.
#
# ### Choosing the VM's size
# The VM from §1 resumes from a checkpoint captured at 2048 MiB and 2 vCPUs,
# so it is configured at 2048 MiB and 2 vCPUs. Inside the guest `MemTotal` and `free` read about 1.9 GiB:
# the kernel reserves the rest. For another size, stop that VM
# (`i9 vm stop "$URL" --discard`: both want the default port) and run the
# image yourself with `--memory <MiB>` and/or `--vcpus <n>`:
# ```
# ~/.i9/bin/i9 run https://a.i9.io/vm/manifest.json --no-viewer-gate --detach --memory 4096 --vcpus 4
# ```
# Expect exit 0 and a `ws://` control URL as the last stdout line; use it as
# `$URL` in place of §1's. `i9 vm exec "$URL" --shell -- 'free -m; nproc'`
# then reports the size you asked for. Pass only the flag you want to change:
# `--memory 4096` alone keeps the captured 2 vCPUs.
#
# A size other than the captured one COSTS a cold boot: the guest boots from
# scratch instead of resuming, so the checkpoint's memory and running
# processes are not in it. It takes longer than the default resume, and
# longer still the first time, when the image is fetched as it boots. The
# command returns the control URL once the guest agent answers, with a
# `docker: starting` line: this VM's Docker Engine starts from zero, so
# `docker` commands fail until `i9 vm exec "$URL" --shell -- 'docker info'`
# succeeds. `i9 vm ls` lists the VM and `i9 vm stop "$URL"` reaches it meanwhile.
# It prints
# `i9: cold-booting … at <MiB> MiB / <n> vCPUs: the checkpoint was captured at
# 2048 MiB / 2 vCPUs` to say so. Without a size flag, or with the captured
# size, the resume is unchanged. `i9 vm stop "$URL"` saves the VM, and the
# saved archive restores at the size it was saved at. While the VM is running it counts against
# the limit below like any other (parked forks do not count).
#
# ### Limits
# - Each default VM is sized at 2 GB of RAM and 2 vCPUs
#   (`free -m` in the guest shows about 1960 MB), so size the count against your
#   host's memory as well as the cap below. The host memory a running VM holds is more than the
#   guest's size, and it grows with what the guest touches: measured at about 0.75 to 1.1 GB per
#   idle VM (Linux arm64, macOS), 2.3 to 3 GB per VM with a test suite running in each of three or four
#   VMs forked from one snapshot (macOS arm64 and Linux x86_64, with outliers up to 5.1 GB), and 5.65 GB
#   after one guest touched 1.8 GiB. Pages the guest frees are not handed back to the host while the VM
#   runs; `i9 vm stop` returns them. `i9 vm rewind` restores a snapshot into the VM and briefly holds more
#   than the VM's steady size: one measurement on a 32 GiB Mac peaked at 19.1 GB host memory. These are
#   measurements, not limits: check `ps` or Activity Monitor for your workload, and do not run more VMs
#   at once than the host's free memory covers at the per-VM figure above.
# - A guest's disk writes consume host disk, and deleting a file in the guest does not return the space
#   to the host while the VM runs: one guest that wrote 62 GiB took the host's free space from 142 GB
#   to 77 GB, and the space came back when the VM was stopped. Nothing refuses a write because the host
#   is nearly full, so keep a guest's writes within the host's free disk. Removing a snapshot frees
#   only the chunks no other snapshot shares (eight snapshots removed from about 12 GB freed 57 MB).
# - The guest's network is the same in every VM, including a fork child: address 192.168.86.20/24,
#   gateway 192.168.86.1 (the host, which a connect reaches on the host's loopback), the same MAC and,
#   in a fork child, the parent's hostname. 192.168.86.0/24 is also a common home-router range. When the
#   host's own LAN uses it, a LAN machine at an address in that range cannot be reached from the guest:
#   the guest treats the whole /24 as directly attached, asks for the machine's MAC address there, and
#   nothing answers (`ip neigh` in the guest shows the entry INCOMPLETE). The host itself reaches its
#   LAN as usual. No flag changes the guest's subnet.
# - The free tier runs at most 4 vCPUs at once per user on this machine (each default VM uses 2 vCPUs;
#   only running VMs count, parked forks do not); `i9 grant show` prints the cap and how many
#   running vCPUs count against it. A call that would add vCPUs (`i9 run`, `i9 vm fork`,
#   or a restore that adds one) is refused with `concurrent vCPU limit reached`
#   (§4). Free a slot with `i9 vm stop "$URL" --discard`.
# - `i9 vm fork` leaves the parent running, on Linux and macOS alike:
#   Hypervisor.framework permits one VM per process, so on macOS each fork
#   child runs in a process of its own beside the running parent. The child
#   is listed by `i9 vm ls` with the parent. Address each VM's own URL.
# - `i9 vm fork <snapshot>`, with the path `i9 vm save` printed, starts a new VM
#   of its own and leaves every running VM as it was, on Linux and macOS alike.
#   It restores the saved state first, so it takes longer than forking a running VM.
# - The guest image has no web browser (no `chromium`, `google-chrome` or
#   `firefox`): a test that needs Chrome or puppeteer fails in the guest unless
#   you install a browser there yourself.
#
# ### Other verbs
# - `i9 vm cp` is source first, destination second, in both directions.
#   In: `i9 vm cp "$URL" <local-src> <guest-dst>`. Out:
#   `i9 vm cp "$URL" --from-guest <guest-src> <local-dst>`. The guest path
#   must be absolute. Directories recurse.
# - `i9 vm ports "$URL" --publish <host-port>:<guest-port>` forwards a host
#   port to the guest; `--unpublish <host-port>` removes it. One of the two is
#   required: there is no list form, and neither `vm status` nor `vm ls` reports forwards.
# - `i9 vm ls` prints one tab-separated row per VM: id, name (`-` when unset), pid,
#   age (whole seconds when piped), control URL, state and kind, then a total line.
# - `i9 vm pause "$URL"` freezes the VM and `i9 vm resume "$URL"` thaws it.
# - `i9 vm stop "$URL"` saves the VM to an archive and prints its path;
#   `--discard` destroys it without saving.
#
# ## 4. Failure lines
# | stderr contains | Meaning | Action |
# |---|---|---|
# | `is not published for` | no image for this arch in the manifest yet | stop; do not fall back to another arch |
# | `the Windows hypervisor is not running` | WHP unavailable: virtualization off in firmware, or Windows Hypervisor Platform not installed | stop; the host cannot start a VM until it is enabled |
# | `[i9] EREFUSED:` | the installer refused the release: not verifiably signed, withdrawn (the line gives the reason), or older than a release already served — nothing was downloaded | stop; report the line verbatim to security@i9.io if it says not signed |
# | `[i9] EVALIDATION:` | the downloaded binary's sha256 is not the one the signed release names — nothing was installed | stop; report the line verbatim to security@i9.io |
# | `[i9] ENETWORK:` | the binary download failed | check the network and run the installer again |
# | `[i9] EUSAGE:` | an `I9_*` variable has a value the installer does not take (the line names it) | fix the value as the line says and run again |
# | `[i9] ESERVER:` | `i9 run --detach` did not report the VM ready; its startup log path is printed above the line | report that log's tail |
# | `/dev/kvm is not readable` | host lacks `/dev/kvm` (Linux) | stop, unsupported host |
# | `userfaultfd unavailable` | this user cannot open userfaultfd (the kernel restricts it), and every VM boot needs it; the installer and `i9 run` both print it | stop, unsupported host as configured; `~/.i9/bin/i9 doctor` names the kernel setting that refuses it |
# | `host_kernel_no_uffd_wp` | kernel has userfaultfd but NOT write-protect: this host can RUN and RESTORE a guest but cannot CAPTURE one | not a boot failure and not fatal — snapshots taken elsewhere still restore here. Only `i9 vm save` refuses, and it refuses rather than writing an archive nobody can restore |
# | non-zero exit from §2's in-guest install/wiring | the harness failed to install or wire itself in the guest | report the harness name and the command's own stderr, verbatim |
# | `the VM is paused` | the VM is paused (EVMPAUSED); a guest request cannot run in a frozen guest | `i9 vm resume "$URL"`, then repeat the request |
# | `the VM is no longer reachable` | the VM can never answer again: its VM process exited, its parent was restored or destroyed, or its backing object could not be read (EVMUNREACHABLE); the snapshots it saved are intact | `i9 run <snapshot>` boots one of its saved snapshots; `i9 vm stop --discard "$URL"` frees its slot; or fork again from the parent, or restore again once the object is back |
# | `the VM is busy` | an exclusive operation (a save, a rewind, a fork or a stop) is running on the VM, and the message names it (EVMBUSY, exit 13) | wait for that operation to finish, then run the request again; a VM runs one exclusive operation at a time |
# | `the command was interrupted` | a rewind replaced the VM while the command was running, so the command did not finish (EVMINTERRUPTED, exit 14) | run the command again on the restored VM |
# | `concurrent vCPU limit reached` | you already run your cap of vCPUs on this machine (4 running vCPUs on the free tier, each default VM uses 2; parked forks do not count), so the run, fork or restore that would add vCPUs is refused | `i9 vm stop "$URL" --discard` a VM you no longer need, then repeat the call; `i9 grant show` prints the cap and how many running vCPUs count against it |
# | `CONNECT handshake read failed` | the host reached the VM but the guest agent never answered its readiness handshake, so the request did not run (printed as `exec failed: CONNECT handshake read failed`) | check `i9 vm status "$URL"`; if the VM does not answer, `i9 vm stop "$URL" --discard`, start a new one (§1) and report this line |
# | `CPU-identity gate (this host is below the guest's floor)` | this host's CPU is below the floor the snapshot needs, so the restore was refused and the same rootfs was cold-booted: the VM runs, but the snapshot's processes and memory are not in it | not fatal; set up again in the fresh VM, or restore the snapshot on a host that meets its floor (`i9 doctor` reports what this host holds) |
#
# ## 5. Reference
# Manifest: `https://a.i9.io/vm/manifest.json` (per-arch; the manifest is the
# one mutable object and names immutable, versioned image URLs).
# Trust: a binary is installed or updated only when its sha256 and size equal
# those in its release's signed release.json (the signature is checked against
# a release key this binary carries). Discovery follows a pointer:
# `i9 update --channel edge` and the edge installer read the release named by
# `https://a.i9.io/i9/latest/release.json`; that file is trusted only through
# its signature, never by itself.
# Session URL file: `~/.i9/run/vm.url`, written by every `i9 run`, attached or `--detach`, once its control URL exists. It names the most recent VM started without `--name`; `--name <n>` writes `~/.i9/run/<n>.url`, and `i9 vm ls` lists every VM.
# Traces: a run's trace is inside its archive under `~/.i9/sessions/` (`session-<pid>-<time>.i9io`), not loose files in `~/.i9/traces/`; read it with `i9 trace ls --kind sessions` and `i9 trace search --session <pid>`.
# Release: the manifest's `version` names the release of the VM image these
# instructions install, and `~/.i9/bin/i9 --version` the installed build.
# The two stamps can differ: the `stable` channel installs the binary of the
# image's release, and the `edge` channel installs a newer binary beside the
# stable image (an image is built only with a release), so a binary newer than
# its image is the paired state, not build skew.
# Stop: `~/.i9/bin/i9 vm stop "$(cat ~/.i9/run/vm.url)"`. A successful stop removes `~/.i9/run/vm.url` when it names the stopped VM.
# Log: `~/.i9/detach/<pid>.log` (the path `i9 run --detach` prints).
#
# [Terms](https://i9.io/terms) · [Privacy](https://i9.io/privacy) · [Security](https://i9.io/vm/security) · [Releases](https://i9.io/vm/releases) · [Pricing](https://i9.io/vm/pricing) · [Contact](https://i9.io/vm/contact) · [Feedback](https://feedback.i9.io)
# <<< agent instructions <<<
#
set -eu
die() { _c=$1; _n=$2; shift 2; printf '[i9] %s: %s\n' "$_n" "$*" >&2; exit "$_c"; }
case "${I9_CHANNEL:-stable}" in stable|edge) ;; *) die 2 EUSAGE "I9_CHANNEL must be stable or edge, not '${I9_CHANNEL}'" ;; esac
case "${I9_NO_BOOT:-}" in ''|1) ;; *) die 2 EUSAGE "I9_NO_BOOT must be 1 or unset, not '${I9_NO_BOOT}'" ;; esac
case "${I9_DETACH:-}" in ''|1) ;; *) die 2 EUSAGE "I9_DETACH must be 1 or unset, not '${I9_DETACH}'" ;; esac
CHANNEL=${I9_CHANNEL:-stable}
OS=$(uname -s)
ARCH=$(uname -m)
# >>> release data: rendered per request from the signed release.json >>>
case "$CHANNEL-$OS-$ARCH" in
  stable-Linux-x86_64) TAG='v2026.11.02.093335-91f1bb6ee' BIN='i9-linux-x86_64' URL='https://a.i9.io/i9/v2026.11.02.093335-91f1bb6ee/i9-linux-x86_64' SHA256='bba9cd9357e7edb0ad906856ec28d66b4b1e84ff2c957cd7c3d3997877e505e2' SIZE='36800936' IMG='https://a.i9.io/vm/agent-dev-x86_64-2026.11.02.093335-91f1bb6ee.boot.i9io' ;;
  stable-Linux-aarch64|stable-Linux-arm64) TAG='v2026.11.02.093335-91f1bb6ee' BIN='i9-linux-arm64' URL='https://a.i9.io/i9/v2026.11.02.093335-91f1bb6ee/i9-linux-arm64' SHA256='1f85dd1086e9be00e2eca189d449e6d07bbea5c76ce18d71855314a93cfe7f6a' SIZE='34937136' IMG='https://a.i9.io/vm/agent-dev-arm64-2026.11.02.093335-91f1bb6ee.boot.i9io' ;;
  stable-Darwin-arm64) TAG='v2026.11.02.093335-91f1bb6ee' BIN='i9-darwin-arm64' URL='https://a.i9.io/i9/v2026.11.02.093335-91f1bb6ee/i9-darwin-arm64' SHA256='36569833732ba2d94c79b18113f154720825766a0ae92dbebf66e956db0979aa' SIZE='31402944' IMG='https://a.i9.io/vm/agent-dev-arm64-2026.11.02.093335-91f1bb6ee.boot.i9io' ;;
  edge-Linux-x86_64) TAG='v2026.11.02.123345-c063b71bb' BIN='i9-linux-x86_64' URL='https://a.i9.io/i9/v2026.11.02.123345-c063b71bb/i9-linux-x86_64' SHA256='160bc4b42e3ea0298f4234aeaeec97a270f51e24afc39534cfdb49826bdcc2e3' SIZE='36800936' IMG='https://a.i9.io/vm/agent-dev-x86_64-2026.11.02.093335-91f1bb6ee.boot.i9io' ;;
  edge-Linux-aarch64|edge-Linux-arm64) TAG='v2026.11.02.123345-c063b71bb' BIN='i9-linux-arm64' URL='https://a.i9.io/i9/v2026.11.02.123345-c063b71bb/i9-linux-arm64' SHA256='5d888f0adc4bad66c5ec809dd76da068f6979f21d1e3894bbba6e7e9397c14d7' SIZE='34937136' IMG='https://a.i9.io/vm/agent-dev-arm64-2026.11.02.093335-91f1bb6ee.boot.i9io' ;;
  edge-Darwin-arm64) TAG='v2026.11.02.123345-c063b71bb' BIN='i9-darwin-arm64' URL='https://a.i9.io/i9/v2026.11.02.123345-c063b71bb/i9-darwin-arm64' SHA256='d1e48986e608b7d4afda526043706426796c8958a0939fc382d846775be41282' SIZE='31402944' IMG='https://a.i9.io/vm/agent-dev-arm64-2026.11.02.093335-91f1bb6ee.boot.i9io' ;;
  *) die 8 EPLATFORM "this host is $OS $ARCH — i9 runs on Linux x86_64 or arm64 with /dev/kvm, or macOS on Apple silicon" ;;
esac
# <<< release data <<<
I9DIR=${I9_HOME:-$HOME/.i9}
I9=$I9DIR/bin/i9
# Under a custom I9_HOME every command this script prints for the reader
# carries that home: a bare `i9 ...` reads the default ~/.i9 and cannot find
# the VM (or the registry) this install just made under $I9DIR.
HOMEENV=
if [ -n "${I9_HOME:-}" ]; then HOMEENV="I9_HOME=\"$I9DIR\" "; fi
if command -v sha256sum >/dev/null 2>&1; then sha() { sha256sum "$1"; }
elif command -v shasum >/dev/null 2>&1; then sha() { shasum -a 256 "$1"; }
else die 8 EPLATFORM "neither sha256sum nor shasum is installed — install coreutils or perl and run this again"; fi
hash_of() { _h=$(sha "$1") || return 1; printf '%s' "${_h%% *}"; }
# A home this script creates is private from the first byte: it will hold the
# control URL (a bearer credential) and the VM's state. A home that already
# exists keeps the mode its owner gave it.
[ -d "$I9DIR" ] || mkdir -p -m 700 "$I9DIR" ||
  die 8 EPLATFORM "cannot create $I9DIR — set I9_HOME to a writable directory"
mkdir -p "$I9DIR/bin" || die 8 EPLATFORM "cannot create $I9DIR/bin — set I9_HOME to a writable directory"
if [ -x "$I9" ] && [ "$(hash_of "$I9")" = "$SHA256" ]; then
  printf '[i9] already installed: %s matches release %s\n' "$I9" "$TAG" >&2
else
  # The download is staged beside its destination, so the final mv is a
  # rename on one filesystem; the binary's path never holds unverified bytes.
  STAGE=$(mktemp "$I9DIR/bin/.i9.XXXXXX") ||
    die 8 EPLATFORM "cannot write in $I9DIR/bin — set I9_HOME to a writable directory"
  trap 'rm -f "$STAGE"' EXIT
  printf '[i9] downloading %s (%s)\n' "$BIN" "$TAG" >&2
  curl -fSL --progress-bar --max-filesize "$SIZE" -o "$STAGE" "$URL" ||
    die 5 ENETWORK "download of $URL failed — check the network and run this again"
  GOT=$(hash_of "$STAGE")
  [ "$GOT" = "$SHA256" ] ||
    die 4 EVALIDATION "sha256 of the download is $GOT but the signed release says $SHA256 — nothing was installed; report this to security@i9.io"
  chmod +x "$STAGE"
  mv -f "$STAGE" "$I9"
  trap - EXIT
  if [ "$OS" = Darwin ]; then xattr -c "$I9" 2>/dev/null || true; fi
  printf '[i9] installed %s — sha256 matches signed release %s\n' "$I9" "$TAG" >&2
fi
printf "[i9] add to PATH: export %sPATH=\"%s/bin:\$PATH\"\n" "$HOMEENV" "$I9DIR" >&2
if [ "${I9_NO_BOOT:-}" = 1 ]; then
  printf '%s\n' "$I9"
  exit 0
fi
[ -n "$IMG" ] || die 6 EREFUSED "the $CHANNEL channel has no VM image — run with I9_NO_BOOT=1 to install the binary only"
# The edge channel pairs a newer binary with the stable image; name both.
printf '[i9] booting VM image %s with the %s binary (channel %s)\n' "${IMG##*/}" "$TAG" "$CHANNEL" >&2
if [ "$OS" = Linux ] && [ ! -r /dev/kvm ]; then
  die 8 EPLATFORM "/dev/kvm is not readable — enable KVM, or run with I9_NO_BOOT=1 to install the binary only"
fi
# No controlling terminal (CI, a container, an agent's tool call) means the
# attached form cannot run, so it boots detached as I9_DETACH=1 does. The
# /dev/tty probe runs in a subshell: a failed redirection on `exec` ends a
# POSIX shell, and the subshell takes that exit instead of this script.
if [ -n "${I9_DETACH:-}" ] || { [ ! -t 0 ] && ! (exec </dev/tty) 2>/dev/null; }; then
  NAME=${I9_SESSION_NAME:-vm}
  # `i9 run` writes run/<name>.url itself, for every run: the control URL as
  # it prints it, without its token (a vm verb given it takes the token from
  # the owner-only registry). The umask is inherited by that child, so what it
  # creates is owner-only from the first byte.
  umask 077
  OUT=$("$I9" run "$IMG" --session --name "$NAME" --no-viewer-gate --detach) ||
    die 7 ESERVER "the VM did not come up — the reason is on stderr above"
  VMURL=${OUT##*
}
  printf "[i9] VM %s is live — check: %s\"%s\" vm exec \"\$(cat \"%s/run/%s.url\")\" -- echo ok\n" "$NAME" "$HOMEENV" "$I9" "$I9DIR" "$NAME" >&2
  printf '%s\n' "$VMURL"
  exit 0
fi
# `< /dev/tty` is on the command, never on the shell: under `curl … | sh` the
# shell reads this script from stdin, and bash (macOS /bin/sh) stops reading
# a piped script at a shell-level redirect, silently skipping what follows.
exec "$I9" run "$IMG" --terminal </dev/tty
