Plan 0007
Forkable dev VM environments
One dev.nix a user owns and forks: the default config for new VMs, a declarative fleet, and an opt-in shared SMB volume that carries one Claude/ix login across the whole fleet.
- Status
- Accepted
- Ambition
- 7 one file spans single VM, declared fleet, and shared login, recursively
- Impact
- 7 forkable personal environments are the daily-driver surface
- Effort
- 6 mkDev, the SMB volume, and credential carry across guests
- Risk
- 5 a shared home volume concentrates credentials in one place
- Maturity
- 4 dev.nix and mkDev exist, the shared-volume story is partial
- Leverage
- 8 every user forks the same seed, improvements flow to all fleets
- Taste
- 7 what the default environment feels like is pure taste
- Authors
- Created
- Updated
- Tracking issue
- -
- Supersedes
- -
- Superseded by
- -
Summary
Add a single user-owned file, dev.nix, and a helper index.lib.mkDev that consumes it. The file is the user’s personal VM environment (packages, dotfiles, plus our wrapped claude-code and codex by default). The same file does three jobs: it is the default config new VMs boot from (a single VM via ix up), it declares a fleet deployed with one ix up, and it can opt into a shared SMB volume that carries one ~/.claude (and ~/.n ix credentials) login across every VM in the fleet. The artifact is meant to be forked into the user’s own git repo and edited over time. Every VM also gets /ix — the same config plus the ix CLI — so a VM can recursively bring up its own fleet from the same source.
Motivation
Today there is no first-class “this is my dev environment” object. The pieces all exist but nobody has assembled them:
- Fleets are
index.lib.mkFleet(lib/image/fleet.nix):defaultsare modules applied to every node,nodescarryreplicas/dependsOn/groups, and the helper emits theix upwrapper. This already does “fleet a group declaratively.” - Single-VM boot: per the ix playbook,
ix up <installable>expands a flake installable tonixosConfigurations.<name>.config.system.build.toplevel, nixos-rebuild style, and the CLI has a per-user config surface under~/.n/. Nothing wires a user’s chosen config in as the default. - Shared files:
examples/multi-client/file-sharingalready serves an SMB share with correct cross-client locking (strict locking,posix locking,strict sync) and mounts it on replicas. - Shared identity:
examples/synced-github/authgives a fleet one GitHub identity, and its README explicitly leaves open “whether ix should also offer a config-sync primitive for the local dev-VM case” (#465). This plan is that primitive, scoped tightly. - Agent CLIs:
images/dev/development-basealready bakes our wrappedclaude-code(IS_SANDBOX=1+ a read-only/etc/claude-code/managed-settings.jsonpolicy) andcodex, and treats~/.claude/settings.jsonas app-owned.
The concrete pain: a user who wants “my agent box, three of them, all logged into the same Claude account, and let any of them spin up more” has to hand-write a fleet, copy the SMB example, reconcile it with the auth example, and manually re-run claude login on every replica. There is no forkable starting point and no way to make a chosen config the default a bare ix up uses.
Detailed design
The forkable repo
The unit a user owns is a small flake. ix dev init scaffolds it; thereafter the user edits dev.nix and commits to their own git remote.
my-ix-dev/ # user-owned flake, in their git
├── flake.nix # boilerplate: inputs.index + outputs = index.lib.mkDev { module = ./dev.nix; src = self; }
├── dev.nix # the only file normally edited
└── README.md flake.nix is fixed boilerplate; it maps the mkDev result under packages.<system> and threads the flake self in as src (what gets materialized at /ix). The user’s dev.nix never mentions src.
dev.nix: an ordinary NixOS module
dev.nix is a plain NixOS module. The environment is written at the top level the way any NixOS config is; ix.dev.* (declared in lib/dev/options.nix) describes the agents, the fleet, and the shared volume. This is the key DevEx property: a fork reads like a hand-authored config, not a framework struct. mkDev reads ix.dev once via a probe eval to plan the fleet (so the fleet/shared topology is not re-derived per node), then builds each node with the same module.
{ pkgs, ... }:
{
# Your environment — applied to every VM (single or fleet). Normal NixOS config.
environment.systemPackages = [ pkgs.ripgrep pkgs.jq ];
programs.git.enable = true;
# Claude Code + Codex are installed by default; toggle either off:
# ix.dev.agents.codex = false;
# A fleet (omit for a single VM named `dev`):
ix.dev.fleet = {
agent.replicas = 3;
builder.dependsOn = [ "agent" ];
};
# One shared Claude (and ix) login for the fleet over SMB (opt-in):
ix.dev.shared = {
enable = true;
ix = true; # also share ~/.n (claude is shared by default)
excludeNodes = [ "builder" ]; # per-VM opt-out; default is every node in
};
} index.lib.mkDev
A helper at lib/image/dev.nix, exported from lib/default.nix next to mkFleet, layered on top of mkFleet (it does not modify it). It takes { module, src }, reads ix.dev from module via a probe evalImageConfig, and returns the mkFleet result shape (.up, .health, .diff, .down, withNodePrefix, …).
- module + base + agents →
defaults. The user’s module,ix.dev.baseImage, and the agent layer (lib/dev/agents.nix) becomemkFleet’sdefaults, so every node is the user’s environment and ships the agents. ix.dev.fleet→nodes. Emptyfleetsynthesizes a single node nameddev. The single-VM default and the fleet are the same code path with a one-node degenerate case.ix.dev.shared.enable→ a dedicated server node + injected client/identity modules.mkDevadds a dedicatedfile-servernode (the generalizedserver.nix) and injects the CIFS client mount plus the~/.claude/~/.nbinds into every node not inexcludeNodes. It auto-joins the fleet to a private east-westgroupso the share is reachable as//file-server/devby hostname and is never publicly exposed.
A dedicated file-server is the default (not co-locating smbd on a workload node) so the canonical credentials’ lifecycle is decoupled from the workload VMs: an ix up that recreates a workload node never blips the share. Co-locating smbd on an existing node stays available as an option for cost-sensitive single-replica fleets.
Mode 1 — default config for new VMs
The fork exposes nixosConfigurations.dev. ix up already resolves an installable to nixosConfigurations.<name>.config.system.build.toplevel. The missing link is a default: ix dev use .#dev writes the chosen installable into ~/.n/config.toml (e.g. a [up] default_installable key), and a bare ix up resolves it when no installable is passed. ix dev use github:me/my-ix-dev#dev works the same with a remote flake.
Mode 2 — declarative fleet
With fleet.nodes set, the fork’s flake outputs include .up / .health / .diff exactly as mkFleet produces them today. ix up (or nix run .#up) brings up the whole group, every node carrying the user’s env. dependsOn, replicas, and groups are passed straight through to mkFleet.
Mode 3 — shared SMB identity volume
The share is an identity volume, not a general scratch disk. On each participating node, mkDev binds:
~/.claude→<mount>/claudewhenix.dev.shared.claude(default on) — one Claude login for the whole fleet; the first node to runclaude loginseeds it, every other node sees it immediately.~/.n→<mount>/nwhenix.dev.shared.ix— the ix CLI credentials, so a VM with the shared identity can itself create VMs (see recursion below).
We share only ~/.claude and ~/.n, never the whole ~/.config — directly answering the warning in the synced-github-auth README about blast radius. The image’s /etc/claude-code/managed-settings.json policy layer is untouched and stays in the image; only Claude’s app-owned credential/state under ~/.claude lives on the share, so there is no collision with the managed layer.
The SMB server reuses the example’s locking knobs verbatim (strict locking, posix locking, strict sync = yes), which is what keeps ~/.claude/.credentials.json honest when one node’s token refresh writes while another reads. Unlike the example’s demo share, the volume is not guest-writable: it is served behind a Samba user whose credentials reach the clients through a systemd LoadCredential (the same shape python-daily-scraper uses), and it is only reachable on the fleet’s private east-west group.
The /ix directory and recursion
When selfSource (default on), every VM gets /ix containing the dev.nix repo source plus the ix CLI on PATH, so a VM can cd /ix && ix up and bring up its own fleet from the same config. Two mechanics:
- Editability. A
/nix/storecopy is read-only. Whenshared.enable,/ixlives on the share, so it is writable and edits propagate fleet-wide. Without a share, first-boot activation copies the source into/ixas a writable working tree (optionallygit init) seeded from a read-only store copy. - Credentials for recursion. Creating VMs needs real ix credentials;
ix.dev.shared.ixbinding~/.nonto the share is what makes a guest able to do it. This is a separate opt-in fromix.dev.shared.claudeprecisely because it widens blast radius.
The ix CLI is not in dev images today (development-base ships claude-code, codex, and a build toolchain only). Adding it to the dev base is a required piece of this plan.
The agent layer
Our wrapped claude-code (the IS_SANDBOX=1 wrapper plus the read-only /etc/claude-code/managed-settings.json policy) and codex live in one reusable module, lib/dev/agents.nix, gated on ix.dev.agents.{claude,codex} (default on). Both development-base and mkDev import it, so the agents are defined once and cannot drift; importing it twice (mkDev on top of development-base) is idempotent, so there is exactly one wrapped claude binary and no bin/claude collision. This is what makes “ships with our claude and codex” both visible in dev.nix (the ix.dev.agents toggles) and correct. It lives in lib/dev/, not modules/, so it never adds claude-code to every image in the repo.
CLI surface
ix dev init [dir]— scaffold the forkable repo from a flake template (templates/dev/),git init, optionally seed~/.claudefrom the host’s current login.ix dev use <installable>— record the default installable for bareix upin~/.n/config.toml.ix up(no args) — boot a single VM from the recorded default.ix upin a dev repo /nix run .#up— bring up the fleet if one is declared, else the single VM.
What gets built, and where
In index (this repo):
lib/image/dev.nix— themkDevhelper; export fromlib/default.nix, add to the example shim inlib/discovery.nix.lib/dev/options.nix— theix.dev.*option surface.lib/dev/agents.nix— the wrapped claude-code + codex + managed-settings layer, gated onix.dev.agents; also consumed bydevelopment-base.lib/dev/shared-mount.nix— generalized SMB server + client modules (from the example).lib/dev/identity.nix— bind~/.claude/~/.nonto the share and materialize/ix.templates/dev/— the forkableflake.nix+dev.nixtemplate, registered astemplates.dev.examples/dev/fleet/— a runnable demo of all three modes.- Add the
ixCLI to the dev base image (for recursion).
In ix (sibling repo):
ix upreads a default installable from~/.n/config.tomlwhen none is passed.ix dev init/ix dev useverbs.
Drawbacks
- Shared auth = shared blast radius. Every VM in the fleet can read the fleet’s
~/.claudeand (withix.dev.shared.ix)~/.n. This is inherent to “one login for all VMs.” It is bounded to a single user’s own fleet on a private network, but a compromise of any node exposes the shared credentials.ix.dev.shared.ixis worse thanix.dev.shared.claude: it hands out the ability to create more VMs, so it is a distinct opt-in. - Token-refresh races over SMB. Concurrent writes to
~/.claude/.credentials.jsonduring refresh rely on the SMB locking config; this is a real edge even if the locking knobs are correct. - Extra VM. The dedicated
file-serveris one more VM to pay for and keep alive; it is also where the canonical creds live, so its availability matters more than a workload node’s. - Surface area. A new helper, new lib modules, a flake template, and two new
ixCLI verbs.mkDevis opinionated sugar overmkFleet; two ways to define a fleet now exist. - Cross-repo coupling. The “default for new VMs” and the
ix devverbs require changes in theixrepo, so this cannot ship entirely fromindex.
Alternatives
- Do nothing. Users keep hand-assembling fleet + SMB + auth examples and re-running
claude loginper node. The pieces exist but the assembly cost and the missing “default VM” wiring are the whole point. - Extend
mkFleetinstead of addingmkDev. Rejected:mkFleetis the lower primitive and should stay minimal; the dev-flavored opinions (claude/codex injection, identity volume,/ix) belong in a layer above it. - Co-locate
smbdon a workload node by default. Saves a VM but couples the canonical creds to a workload node’s lifecycle, so anix upthat recreates that node blips the share. Kept as an option, not the default. - Secret-ref auth (the
synced-github-authshape) instead of a shared mount. More secure — each node holds its own materialized copy at/run/secrets— but it cannot express “one writable login that re-auth on any box updates for all,” which is the explicit ask. Better for non-interactive service tokens; wrong for an interactive shared agent login. - virtiofs from the host instead of SMB. Works only for co-located local dev VMs (the
vmkitpath), not remote fleets. NFS was rejected by the requester; it also needs in-guest kernel NFS support not demonstrated here.
Prior art
lib/image/fleet.nix— the Colmena-style fleet evaluatormkDevwraps.examples/multi-client/file-sharing— the SMB server/client pattern with cross-client locking.examples/synced-github/auth— fleet-wide single identity, and the open question (#465) this plan answers.examples/declared/groups— private east-west group networking the share rides on.images/dev/development-base— the wrappedclaude-code+codexbase and the managed-settings model.- Nix flake templates (
nix flake init -t) and dotfiles repos as the “fork and own” pattern.
Unresolved questions
- Exact
~/.n/config.tomlkey for the default installable, and whetherix dev useshould also accept a bare path vs only a flake ref. - First-login bootstrap: does
ix dev initseed~/.claudefrom the host, or is the firstclaude loginalways done inside a VM? Seeding from the host is convenient but copies host creds onto the share. - Should
sharedsupport arbitrary extra paths beyond~/.claude/~/.n, or stay deliberately narrow to keep blast radius small? - Server election when the user does not name one: fixed synthesized
file-servernode vs electing the first node of the first node group. - Does the dedicated
file-serverneed any of the user’senv(it is not a workload box), or should it be a minimal image to cut attack surface?
Future possibilities
- A registry of shareable
dev.nixprofiles (team baselines) a user layers their personal overrides on top of. - Per-agent scoped identities on the same fleet (split
~/.claudeper node-group) once the single-login case is solid. - Snapshotting the identity volume so a fleet can be torn down and brought back with its logins intact.
- Promoting the shared identity volume to a general declarative-mount primitive on
mkFleetonce the dev-VM case proves the shape.
hari