ash is a CLI for spawning, attaching to, suspending, resuming, mounting into,
and deleting NixOS agent VMs through virtle.
Most documentation lives in the command help and generated command pages:
Build with flakes:
nix build github:0xferrous/ash
./result/bin/ash --helpRun directly:
nix run github:0xferrous/ash -- --helpStart a reusable background VM:
ash spawn --name work -f ../my-nix#agentAttach to it:
ash attach workFollow its logs:
ash logs -f workUse Kitty's SSH kitten for an attached session:
ash attach --kitty workForward Wayland and X11 applications from the guest to the host with Waypipe:
ash attach --waypipe workWaypipe and Kitty can be combined:
ash attach --waypipe --kitty workStart and attach immediately, keeping the VM after SSH exits:
ash spawn --name work -f ../my-nix#agent --attach --keepCopy files between the host and a running VM:
ash cp work ./input.txt ~/workspace/input.txt
ash cp --from guest work ~/workspace/output.txt ./output.txtUse -r to copy directories and -v to print a successful transfer.
Mount a host directory into a running VM:
ash mount work ~/dev/projectUnmount it:
ash umount work ~/dev/projectSuspend and resume a background VM:
ash stop --suspend work
ash resume workStop a background VM:
ash stop workInspect and list VM state, or interactively delete stopped VM state and cached store images:
ash inspect work
ash ls
ash ls --cache
ash rmReset a stopped VM's strategy-specific Nix store state:
ash stop work
ash rebuild-db work
ash attach --spawn workAsh uses ASH_NAME as its XDG application namespace, defaulting to ash.
It reads $XDG_CONFIG_HOME/$ASH_NAME/config.toml, falling back to
~/.config/$ASH_NAME/config.toml, and stores VM state and caches below the
matching XDG_STATE_HOME and XDG_CACHE_HOME namespaces. For example,
ASH_NAME=nash selects ~/.config/nash, ~/.local/state/nash, and
~/.cache/nash. --config overrides the configuration file directly.
See example_config.toml for the global and space
mount formats. Set global.memory to configure VM memory in MiB; it defaults
to 4096. Set global.kitty = true to use kitten ssh by default for spawned
and attached sessions; an explicit --kitty also enables it.
VMs attach to the private host bridge ash0 through
/run/wrappers/bin/qemu-bridge-helper; override these with
global.network_bridge and global.qemu_bridge_helper. The host must create
the bridge and authorize it in /etc/qemu/bridge.conf.
Ash passes ash.mdns-host=<dns-label> and ash.mdns-mac=<stable-mac> on the
guest kernel command line. A compatible guest can use these values to publish
<dns-label>.ash.local; the agent NixOS configuration uses Avahi for this.
Names that are not already lowercase DNS labels receive an eight-hex digest
suffix after normalization. The host must have .local mDNS resolution
enabled, and multicast UDP 5353 must pass over the VM bridge.
Every VM exposes exactly two directory shares: shares-ro and shares-rw.
Workspace, cwd, configured spaces, and runtime mounts are staged beneath these
roots and bind-mounted at their final guest paths. A VM state directory has the
following relevant layout; entries marked as conditional are created only when
that feature is used:
<state-dir>/
|-- ash-state.toml
|-- virtle.toml
|-- shares/
| |-- ro/ -> shares-ro
| | |-- system/
| | | |-- nix-store/ # shared strategy
| | | `-- guest-store-state/ # shared strategy
| | `-- mounts/
| | |-- spaces/<tag>/ # configured read-only mounts
| | `-- hotmounts/<id>/ # runtime read-only mounts
| `-- rw/ -> shares-rw
| |-- system/
| | |-- guest-store-state/ # shared strategy
| | |-- guest-store-upper/ # shared strategy
| | `-- guest-store-work/ # shared strategy
| `-- mounts/
| |-- workspace/
| |-- cwd/ # with --mount-cwd
| |-- spaces/<tag>/ # configured writable mounts
| `-- hotmounts/<id>/ # runtime writable mounts
|-- persist.img
|-- nix-store.img # image strategy
`-- virtle_state/ # sockets, locks, runtime files
The guest mounts the two roots at /run/ash/shares/ro and
/run/ash/shares/rw; Ash then bind-mounts individual staged children at their
requested guest destinations. Ash leaves the consolidated shares' daemon
argument arrays unset so Virtle supplies its standard virtiofsd arguments. The
default Nix store strategy is shared; it stages the host /nix/store at
shares/ro/system/nix-store, with lower-store metadata and writable overlay
state under the corresponding system directories. Guests using this strategy
must mount shares-ro during stage 1 and bind the staged store at /nix/store.
Pass --shares-ro-socket PATH to reuse an existing daemon socket;
--ro-store-socket remains a compatibility alias.
To use a private image-backed store instead:
[global.nix_store]
strategy = "image"
image_size_mib = 32768Override these defaults for an individual VM when spawning it:
ash spawn --name private --nix-store-strategy image \
--nix-store-image-size-mib 65536 -f ../my-nix#agentThe overrides are saved as nix_store_strategy and
nix_store_image_size_mib in that VM's ash-state.toml and reused by later
spawns and regeneration.
Ash caches one closure-sized ext4 base image for each selected toplevel under
$XDG_CACHE_HOME/$ASH_NAME/nix-store-images (or
~/.cache/$ASH_NAME/nix-store-images, with ASH_NAME defaulting to ash). New VM state uses a sparse reflink/CoW clone
of that base image when the host filesystem supports it, then grows the clone
to the VM's configured capacity. Different image-size settings therefore reuse
the same cached closure without rebuilding or fully copying the store image.
Each cached base has a neighboring <cache-key>.toml sidecar, and each writable
VM image has nix-store.toml. These record the exact closure and registration,
closure sizes, creation and last-use timestamps, cache lineage, and deduplicated
flake URL, lock hash, and effective input-override provenance. Provenance is
informational and does not affect cache identity. The writable clone is labeled nix-store and does not expose the
host store to the guest. Ash appends ash.nix-store=image or
ash.nix-store=shared to the kernel command line so guests can select the
matching stage-1 mount layout. Image-capable guests must mount the nix-store
label at /nix; Ash loads the Nix database through QGA after boot. Increasing
image_size_mib grows an existing stopped VM's filesystem automatically.
When the selected closure changes, Ash imports only missing immutable store
paths into the stopped VM's existing image and retains guest-added and older
paths. Shrinking the image or migrating a legacy image created with mke2fs -d requires ash rebuild-db NAME, which discards guest-added store paths.
Select a space with a repeatable --space/-s option:
ash spawn --name work -s ash -f ../my-nix#agentUse repeatable --override-input NAME=FLAKE options to override inputs while
Ash evaluates and builds the selected flake:
ash spawn --name work -f ../my-nix#agent \
--override-input ash=path:../ashOverrides are saved with named VM state and reused by later regeneration. Relative path references are saved as absolute paths.
For a new VM, omitting --space applies no configured spaces. For an existing
named VM, it reuses the saved space list. Spaces can compose other spaces with
extends = ["base", ...]; extended spaces are evaluated recursively before the
extending space.
A space may also define files = ["HOST_PATH", "HOST_PATH:GUEST_PATH", ...].
Ash reads each regular host file when generating virtle.toml and emits it as a
Virtle write_files entry, preserving its permission mode. Host and guest ~
expansion follows the mount rules. Files written below the guest user's home are
owned by that user; other destinations retain Virtle's default ownership.
The repository also builds a standalone OCaml implementation of the Agent-box Portal protocol:
agent-portal-host— host-side capability brokeragent-portal-cli— diagnostic and direct API clientash-dbus-proxy— experimental filtered host D-Bus/vsock bridgegh— transparent GitHub CLI wrapperwl-paste— transparent image-clipboard wrapper
Portal uses MessagePack over a permission-restricted Unix socket or Linux
AF_VSOCK stream and remains wire-compatible with the Rust Agent-box
implementation. Its code is isolated under lib/portal/,
bin/agent-portal-*, and wrappers/; it does not depend
on the Ash VM implementation.
See PORTAL.md for configuration, security behavior, and wrapper
compatibility. With an enabled [portal] section, Ash either manages a
per-VM vsock Portal process or injects the endpoint of a user-managed global
Portal into the guest.
Use command help:
ash spawn --help
ash resume --help
ash mount --help