Commit b32bd9894d84

Vincent Demeester <vincent@sbr.pm>
2026-02-02 08:02:47
feat(microvm): add microvm.nix support for isolated Claude Code agents
Add infrastructure for ephemeral NixOS VMs on aomi for running AI coding agents in isolation. Based on Michael Stapelberg's approach. Features: - NAT networking on 192.168.83.0/24 via microbr bridge - Shared /nix/store via virtiofs (no package rebuilding) - Auto-generated SSH host keys per VM - Home-manager with zsh, starship, git, tmux, fzf - Claude Code with convenience alias VMs configured: - claude-home (192.168.83.2) - homelab/NixOS work - claude-tekton (192.168.83.3) - Tekton development - claude-nixpkgs (192.168.83.4) - nixpkgs contributions Reference: https://michael.stapelberg.ch/posts/2026-02-01-coding-agent-microvm-nix/ Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 1cd6a86
Changed files (5)
modules/microvm/default.nix
@@ -0,0 +1,319 @@
+# MicroVM host module for running ephemeral Claude Code agents
+#
+# Based on: https://michael.stapelberg.ch/posts/2026-02-01-coding-agent-microvm-nix/
+#
+# Usage:
+#   services.microvm-host = {
+#     enable = true;
+#     vms.claude-home = {
+#       ip = "192.168.83.2";
+#       workspace = "/home/vincent/src/home";
+#     };
+#   };
+{
+  config,
+  lib,
+  pkgs,
+  inputs,
+  globals,
+  ...
+}:
+with lib;
+let
+  cfg = config.services.microvm-host;
+
+  # VM configuration type
+  vmType = types.submodule (
+    { ... }:
+    {
+      options = {
+        ip = mkOption {
+          type = types.str;
+          description = "Static IP address for the VM";
+          example = "192.168.83.2";
+        };
+        workspace = mkOption {
+          type = types.str;
+          description = "Host path to mount as /workspace in the VM";
+          example = "/home/vincent/src/home";
+        };
+        extraPackages = mkOption {
+          type = types.listOf types.package;
+          default = [ ];
+          description = "Additional packages to install in the VM";
+        };
+        vcpu = mkOption {
+          type = types.int;
+          default = 8;
+          description = "Number of virtual CPUs";
+        };
+        mem = mkOption {
+          type = types.int;
+          default = 4096;
+          description = "Memory in MB";
+        };
+        autostart = mkOption {
+          type = types.bool;
+          default = false;
+          description = "Whether to start the VM automatically";
+        };
+      };
+    }
+  );
+
+  # Generate MAC address from VM name (deterministic)
+  vmMac =
+    name:
+    let
+      hash = builtins.hashString "sha256" name;
+    in
+    "02:00:00:01:${builtins.substring 0 2 hash}:${builtins.substring 2 2 hash}";
+
+  # Generate short tap interface name (Linux limit is 15 chars)
+  # Use "mv" prefix + first 11 chars of hash
+  tapName =
+    name:
+    let
+      hash = builtins.hashString "sha256" name;
+    in
+    "mv${builtins.substring 0 11 hash}";
+
+  # Build VM NixOS configuration
+  mkVmConfig = name: vmCfg: {
+    imports = [
+      inputs.microvm.nixosModules.microvm
+      inputs.home-manager.nixosModules.home-manager
+    ];
+
+    # Basic system config
+    system.stateVersion = "24.11";
+    networking.hostName = name;
+
+    # MicroVM configuration
+    microvm = {
+      hypervisor = "cloud-hypervisor";
+      vcpu = vmCfg.vcpu;
+      mem = vmCfg.mem;
+
+      # Don't build a store image, use host's store via virtiofs
+      storeOnDisk = false;
+
+      # Shared directories via virtiofs
+      shares = [
+        {
+          proto = "virtiofs";
+          tag = "nix-store";
+          source = "/nix/store";
+          mountPoint = "/nix/store";
+        }
+        {
+          proto = "virtiofs";
+          tag = "workspace";
+          source = vmCfg.workspace;
+          mountPoint = "/workspace";
+        }
+        {
+          proto = "virtiofs";
+          tag = "claude-config";
+          source = "/home/vincent/.claude";
+          mountPoint = "/home/vincent/.claude";
+        }
+        {
+          proto = "virtiofs";
+          tag = "ssh-host-keys";
+          source = "${cfg.stateDir}/${name}/ssh-host-keys";
+          mountPoint = "/etc/ssh/host-keys";
+        }
+      ];
+
+      # Network interface (name must be <=15 chars for Linux)
+      interfaces = [
+        {
+          type = "tap";
+          id = tapName name;
+          mac = vmMac name;
+        }
+      ];
+    };
+
+    # Static IP networking (using systemd-networkd, microvm default)
+    networking = {
+      useDHCP = false;
+      defaultGateway = {
+        address = "${cfg.subnet}.1";
+        interface = "eth0";
+      };
+      nameservers = [
+        "1.1.1.1"
+        "8.8.8.8"
+      ];
+      firewall.enable = false; # Trust NAT isolation
+    };
+
+    # Configure network via systemd-networkd
+    systemd.network = {
+      enable = true;
+      networks."10-eth0" = {
+        matchConfig.Name = "eth0";
+        networkConfig = {
+          Address = "${vmCfg.ip}/24";
+          Gateway = "${cfg.subnet}.1";
+          DNS = [
+            "1.1.1.1"
+            "8.8.8.8"
+          ];
+        };
+      };
+    };
+
+    # SSH server with persistent host keys
+    services.openssh = {
+      enable = true;
+      settings = {
+        PasswordAuthentication = false;
+        PermitRootLogin = "no";
+      };
+      hostKeys = [
+        {
+          path = "/etc/ssh/host-keys/ssh_host_ed25519_key";
+          type = "ed25519";
+        }
+      ];
+    };
+
+    # User configuration
+    users.users.vincent = {
+      isNormalUser = true;
+      home = "/home/vincent";
+      shell = pkgs.zsh;
+      extraGroups = [ "wheel" ];
+      openssh.authorizedKeys.keys = globals.ssh.vincent;
+    };
+    security.sudo.wheelNeedsPassword = false;
+
+    # Enable zsh system-wide
+    programs.zsh.enable = true;
+
+    # Home-manager for shell setup
+    home-manager = {
+      useGlobalPkgs = true;
+      useUserPackages = true;
+      users.vincent = import ./vm-home.nix {
+        inherit pkgs;
+        extraPackages = vmCfg.extraPackages;
+      };
+    };
+
+    # Base packages
+    environment.systemPackages = with pkgs; [
+      git
+      vim
+      curl
+      wget
+      htop
+    ];
+  };
+in
+{
+  # Import the microvm host module at module level (not in config)
+  imports = [ inputs.microvm.nixosModules.host ];
+
+  options.services.microvm-host = {
+    enable = mkEnableOption "MicroVM host for Claude Code agents";
+
+    bridge = mkOption {
+      type = types.str;
+      default = "microbr";
+      description = "Name of the bridge interface for VM networking";
+    };
+
+    subnet = mkOption {
+      type = types.str;
+      default = "192.168.83";
+      description = "First three octets of the VM subnet (e.g., 192.168.83)";
+    };
+
+    stateDir = mkOption {
+      type = types.str;
+      default = "/home/vincent/microvm";
+      description = "Directory for persistent VM state (SSH keys, etc.)";
+    };
+
+    externalInterface = mkOption {
+      type = types.str;
+      default = "enp0s31f6";
+      description = "External network interface for NAT";
+    };
+
+    vms = mkOption {
+      type = types.attrsOf vmType;
+      default = { };
+      description = "VM definitions";
+    };
+  };
+
+  config = mkIf cfg.enable {
+    # Enable microvm host
+    microvm.host.enable = true;
+
+    # Create bridge for VM networking
+    networking.bridges.${cfg.bridge}.interfaces = [ ];
+    networking.interfaces.${cfg.bridge} = {
+      ipv4.addresses = [
+        {
+          address = "${cfg.subnet}.1";
+          prefixLength = 24;
+        }
+      ];
+    };
+
+    # NAT for VM internet access
+    networking.nat = {
+      enable = true;
+      internalInterfaces = [ cfg.bridge ];
+      externalInterface = cfg.externalInterface;
+    };
+
+    # Trust the microvm bridge
+    networking.firewall.trustedInterfaces = [ cfg.bridge ];
+
+    # IP forwarding
+    boot.kernel.sysctl."net.ipv4.ip_forward" = 1;
+
+    # Define VMs
+    microvm.vms = mapAttrs (name: vmCfg: {
+      inherit (vmCfg) autostart;
+      restartIfChanged = true;
+
+      # Pass through special args
+      specialArgs = {
+        inherit inputs globals;
+      };
+
+      config = mkVmConfig name vmCfg;
+    }) cfg.vms;
+
+    # Auto-generate SSH host keys for each VM
+    system.activationScripts.microvm-ssh-keys = {
+      text = concatStringsSep "\n" (
+        mapAttrsToList (name: _vmCfg: ''
+          keyDir="${cfg.stateDir}/${name}/ssh-host-keys"
+          if [ ! -f "$keyDir/ssh_host_ed25519_key" ]; then
+            echo "Generating SSH host keys for microvm ${name}..."
+            mkdir -p "$keyDir"
+            ${pkgs.openssh}/bin/ssh-keygen -t ed25519 -N "" -f "$keyDir/ssh_host_ed25519_key"
+            chown -R vincent:users "$keyDir"
+            chmod 600 "$keyDir/ssh_host_ed25519_key"
+            chmod 644 "$keyDir/ssh_host_ed25519_key.pub"
+          fi
+        '') cfg.vms
+      );
+      deps = [ ];
+    };
+
+    # Create state directories
+    systemd.tmpfiles.rules = mapAttrsToList (
+      name: _vmCfg: "d ${cfg.stateDir}/${name} 0755 vincent users -"
+    ) cfg.vms;
+  };
+}
modules/microvm/vm-home.nix
@@ -0,0 +1,195 @@
+# Home-manager configuration for microVM guests
+#
+# Provides shell setup, git config, and development tools for Claude Code agents
+{
+  pkgs,
+  extraPackages ? [ ],
+}:
+{ config, ... }:
+{
+  home.stateVersion = "24.11";
+  home.username = "vincent";
+  home.homeDirectory = "/home/vincent";
+
+  # Shell configuration - zsh with starship prompt
+  programs.zsh = {
+    enable = true;
+    dotDir = "${config.xdg.configHome}/zsh"; # Use XDG config directory
+    autosuggestion.enable = true;
+    enableCompletion = true;
+    syntaxHighlighting.enable = true;
+    history = {
+      size = 10000;
+      save = 10000;
+      share = true;
+      ignoreDups = true;
+      ignoreSpace = true;
+    };
+    shellAliases = {
+      ll = "eza -la";
+      la = "eza -a";
+      l = "eza -l";
+      ls = "eza";
+      cat = "bat";
+      g = "git";
+      gs = "git status";
+      gd = "git diff";
+      gc = "git commit";
+      gp = "git push";
+      gl = "git log --oneline -20";
+    };
+    initContent = ''
+      # Start in workspace
+      cd /workspace 2>/dev/null || true
+
+      # Claude Code convenience
+      alias cc="claude --dangerously-skip-permissions"
+    '';
+  };
+
+  programs.starship = {
+    enable = true;
+    enableZshIntegration = true;
+    settings = {
+      add_newline = true;
+      format = "$hostname$directory$git_branch$git_status$nix_shell$character";
+      hostname = {
+        ssh_only = false;
+        format = "[$hostname]($style) ";
+        style = "bold cyan";
+      };
+      directory = {
+        truncation_length = 3;
+        truncate_to_repo = true;
+      };
+      git_branch = {
+        format = "[$branch]($style) ";
+        style = "bold purple";
+      };
+      character = {
+        success_symbol = "[>](bold green)";
+        error_symbol = "[>](bold red)";
+      };
+      nix_shell = {
+        format = "[$symbol$state]($style) ";
+        symbol = " ";
+      };
+    };
+  };
+
+  # Git configuration (using new option names per home-manager 26.05)
+  programs.git = {
+    enable = true;
+    signing = {
+      key = null; # No GPG in VMs
+      signByDefault = false;
+    };
+    settings = {
+      user.name = "Vincent Demeester";
+      user.email = "vincent@sbr.pm";
+      init.defaultBranch = "main";
+      pull.rebase = true;
+      push.autoSetupRemote = true;
+      core.editor = "vim";
+      diff.algorithm = "histogram";
+      merge.conflictstyle = "zdiff3";
+      rerere.enabled = true;
+      # Safe directory for workspace
+      safe.directory = "/workspace";
+    };
+  };
+
+  # Delta for git diffs
+  programs.delta = {
+    enable = true;
+    enableGitIntegration = true;
+    options = {
+      navigate = true;
+      line-numbers = true;
+      syntax-theme = "Monokai Extended";
+    };
+  };
+
+  # Tmux for session persistence
+  programs.tmux = {
+    enable = true;
+    clock24 = true;
+    keyMode = "vi";
+    terminal = "screen-256color";
+    historyLimit = 50000;
+    extraConfig = ''
+      set -g mouse on
+      set -g status-style 'bg=#333333 fg=#ffffff'
+      set -g status-left '[#S] '
+      set -g status-right '%H:%M'
+
+      # Better splits
+      bind | split-window -h -c "#{pane_current_path}"
+      bind - split-window -v -c "#{pane_current_path}"
+    '';
+  };
+
+  # FZF for fuzzy finding
+  programs.fzf = {
+    enable = true;
+    enableZshIntegration = true;
+    defaultOptions = [
+      "--height 40%"
+      "--layout=reverse"
+      "--border"
+    ];
+  };
+
+  # Direnv for per-project environments
+  programs.direnv = {
+    enable = true;
+    enableZshIntegration = true;
+    nix-direnv.enable = true;
+  };
+
+  # Development tools
+  home.packages =
+    with pkgs;
+    [
+      # Core tools
+      ripgrep
+      fd
+      bat
+      eza
+      jq
+      yq-go
+      tree
+      htop
+      curl
+      wget
+
+      # Git tools
+      delta
+      gh
+      lazygit
+
+      # Nix tools
+      nixfmt
+      deadnix
+      nil # Nix LSP
+
+      # Claude Code (from nixpkgs-master via overlay)
+      master.claude-code
+
+      # Build tools
+      gnumake
+    ]
+    ++ extraPackages;
+
+  # XDG directories
+  xdg.enable = true;
+
+  # Environment variables
+  home.sessionVariables = {
+    EDITOR = "vim";
+    VISUAL = "vim";
+    PAGER = "less -FR";
+    # Claude reads config from shared mount
+    CLAUDE_CONFIG_DIR = "/home/vincent/.claude";
+  };
+}
systems/aomi/extra.nix
@@ -43,6 +43,9 @@
 
     # Automated flake updates
     ../../modules/nix-flake-updater
+
+    # MicroVMs for isolated Claude Code agents
+    ./microvms.nix
   ];
 
   # Firewall is enabled in openshift-port-forward.nix
systems/aomi/microvms.nix
@@ -0,0 +1,88 @@
+# MicroVM configuration for aomi
+#
+# Ephemeral VMs for running Claude Code agents in isolation.
+# VMs share host's /nix/store and mount specific workspaces.
+#
+# Usage:
+#   sudo systemctl start microvm@claude-home
+#   ssh vincent@192.168.83.2
+#   cd /workspace && claude --dangerously-skip-permissions
+#
+{ pkgs, ... }:
+{
+  imports = [ ../../modules/microvm ];
+
+  services.microvm-host = {
+    enable = true;
+
+    # Network configuration
+    bridge = "microbr";
+    subnet = "192.168.83";
+    externalInterface = "enp0s31f6"; # ThinkPad P1 Gen3 ethernet
+
+    # State directory for persistent VM data (SSH keys, etc.)
+    stateDir = "/home/vincent/microvm";
+
+    # VM definitions
+    vms = {
+      # VM for home repository work (NixOS configs, homelab)
+      claude-home = {
+        ip = "192.168.83.2";
+        workspace = "/home/vincent/src/home";
+        vcpu = 8;
+        mem = 4096;
+        autostart = false;
+        extraPackages = with pkgs; [
+          # Nix development
+          deadnix
+          statix
+          nixfmt
+          nix-prefetch-scripts
+
+          # Go (for tools in this repo)
+          go
+        ];
+      };
+
+      # VM for Tekton pipeline development
+      claude-tekton = {
+        ip = "192.168.83.3";
+        workspace = "/home/vincent/src/tekton-pipelines";
+        vcpu = 8;
+        mem = 8192; # Tekton tests need more memory
+        autostart = false;
+        extraPackages = with pkgs; [
+          # Go development
+          go
+          gopls
+          golangci-lint
+          ko
+
+          # Kubernetes
+          kubectl
+          kind
+          kubernetes-helm
+        ];
+      };
+
+      # VM for nixpkgs contributions
+      claude-nixpkgs = {
+        ip = "192.168.83.4";
+        workspace = "/home/vincent/src/nixpkgs";
+        vcpu = 8;
+        mem = 8192; # nixpkgs builds need memory
+        autostart = false;
+        extraPackages = with pkgs; [
+          # Nix tools
+          nixpkgs-review
+          nix-update
+          nurl
+          nix-init
+          nixfmt
+          deadnix
+          statix
+        ];
+      };
+    };
+  };
+}
flake.nix
@@ -300,6 +300,12 @@
       inputs.nixpkgs.follows = "nixpkgs";
     };
 
+    # microvm.nix for ephemeral coding agent VMs
+    microvm = {
+      url = "github:astro/microvm.nix";
+      inputs.nixpkgs.follows = "nixpkgs";
+    };
+
     # nixpkgs
     nixpkgs = {
       type = "github";