diff --git a/README.md b/README.md index 9e94204..cac264a 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,9 @@ The default credentials are: | `agentbox.project.destPath` | string | `"/home/dev/project"` | Destination path in VM | | `agentbox.project.marker` | string | `"flake.nix"` | File that identifies project root | | `agentbox.project.validateMarker` | bool | `true` | Validate marker file exists after setup | +| `agentbox.project.devShellPackages.enable` | bool | `false` | Pre-install the project flake's devShell packages at build time | +| `agentbox.project.devShellPackages.flake` | flake or null | `null` | Project flake (a locked input) to read the devShell from (required when enabled) | +| `agentbox.project.devShellPackages.name` | string | `"default"` | Which `devShells..` to extract | ### Tool Options @@ -220,6 +223,45 @@ agentbox.project = { **Use case:** CI/CD environments, reproducible builds, or when you don't have the project locally. +## devShell Pre-install (Build Time) + +If your project's flake defines a `devShell`, agentbox can read it **at build time** and bake the packages it declares into the VM image as globally installed packages. When you boot the VM, the tools are already on `PATH` — no download, no build, works offline — and a later `nix develop` finds them already in the Nix store. + +This is opt-in and disabled by default. Enable it by passing your project flake as a Nix value (a locked input) and turning the option on: + +```nix +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + agentbox.url = "github:gotha/agentbox"; + project.url = "git+ssh://git@github.com/you/your-project"; # locked in flake.lock + project.flake = true; + }; + + outputs = { self, nixpkgs, agentbox, project }: { + # ... mkDevVm ... extraConfig = { + agentbox.project.devShellPackages = { + enable = true; + flake = project; # the locked input + # name = "default"; # or a named devShell: devShells.. + }; + # }; + }; +} +``` + +How it works: + +- **Build time is evaluation time.** The packages are extracted while the VM is built, so the project flake must be readable then. That is why it is supported via a **flake input** (locked, fetched with your normal git/SSH credentials), not via the runtime `source.git.url` string (which is only cloned after boot). +- **Git only.** Because `mount` and `copy` sources only exist inside the VM after boot, the build fails clearly if you enable this without providing a `flake`. +- **Globally installed.** The extracted packages are added to the VM's system packages, so they are on `PATH` at boot — you don't need to run `nix develop`. +- **Multiple devShells.** `name` selects `devShells..` (default `"default"`). +- **Fail loud.** If the named devShell is missing or declares no packages, the build fails with a clear message rather than producing a confusing VM. +- **Visibility.** The resolved package set is traced at build time and written to `/etc/agentbox/devshell-packages` inside the VM. +- **Trade-off.** The VM image grows by the devShell's build closure, and the baked set reflects the flake at build/lock time (refresh = rebuild). + +See [examples/devshell-prebuild-git](./examples/devshell-prebuild-git) for a complete configuration. + ## Docker Docker is disabled by default. To enable it: diff --git a/config.nix b/config.nix index eed39ef..e9c2ab8 100644 --- a/config.nix +++ b/config.nix @@ -50,6 +50,16 @@ destPath = "/home/dev/project"; marker = "flake.nix"; # File that identifies project root validateMarker = true; # Validate marker exists + + # Build-time pre-install of the project flake's devShell packages. + # When enabled, the packages declared in the project flake's devShell are + # baked into the VM image as globally available packages (on PATH at boot). + # Opt-in; disabled by default so the build is unchanged unless requested. + devShellPackages = { + enable = false; # Opt-in + flake = null; # Project flake (a Nix value / locked input). Required when enabled. + name = "default"; # Which devShells.. to extract + }; }; # Environment variables diff --git a/examples/custom-tools-dotfiles-git-clone/flake.lock b/examples/custom-tools-dotfiles-git-clone/flake.lock new file mode 100644 index 0000000..53a46ea --- /dev/null +++ b/examples/custom-tools-dotfiles-git-clone/flake.lock @@ -0,0 +1,136 @@ +{ + "nodes": { + "agentbox": { + "inputs": { + "gotha-nixpkgs": "gotha-nixpkgs", + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1776942799, + "narHash": "sha256-2dfOcaNy+Z4EkHh49c3DWWNjQjUgRrgaRfZmVG5/TvU=", + "owner": "gotha", + "repo": "agentbox", + "rev": "38317bbbf525ea707ee835f23f1dcd7260a7e5f1", + "type": "github" + }, + "original": { + "owner": "gotha", + "ref": "v0.1.0", + "repo": "agentbox", + "type": "github" + } + }, + "dotfiles": { + "flake": false, + "locked": { + "lastModified": 1777374649, + "narHash": "sha256-BfT0YKkM7Tf26keuzPqLAaqy/SnK0a5MjrQenB3prWw=", + "owner": "gotha", + "repo": "dotfiles", + "rev": "e2669e93b2906b9b11bc77982175c9bdb88d8da2", + "type": "github" + }, + "original": { + "owner": "gotha", + "repo": "dotfiles", + "type": "github" + } + }, + "gotha-nixpkgs": { + "inputs": { + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1771394122, + "narHash": "sha256-Z8gca9rOPaYchx7uR7g4iWKh8FOQ7+ZRSsR9O/Ilri8=", + "owner": "gotha", + "repo": "nixpkgs", + "rev": "2292ed482b47e3b600d69da97123b697c49bd5fb", + "type": "github" + }, + "original": { + "owner": "gotha", + "repo": "nixpkgs", + "type": "github" + } + }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1777771528, + "narHash": "sha256-YycygK6n7KeW1YCobdFJcORWzkmrvNcp6xT+IovA0d4=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "0585fbf645640973e3398863bbaf3bd1ddce4a51", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "release-25.11", + "repo": "home-manager", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1762193579, + "narHash": "sha256-JYU1yRI4QSDDybCHaVeRGyxLdDQJf2nsYrh8+6LQvgk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "7aeb722dae56de97ee1246e22c88880ca2606238", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1772047000, + "narHash": "sha256-7DaQVv4R97cii/Qdfy4tmDZMB2xxtyIvNGSwXBBhSmo=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1267bb4920d0fc06ea916734c11b0bf004bbe17e", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1777428379, + "narHash": "sha256-ypxFOeDz+CqADEQNL72haqGjvZQdBR5Vc7pyx2JDttI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "755f5aa91337890c432639c60b6064bb7fe67769", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "agentbox": "agentbox", + "dotfiles": "dotfiles", + "home-manager": "home-manager", + "nixpkgs": "nixpkgs_3" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/examples/custom-tools-dotfiles-git-clone/flake.nix b/examples/custom-tools-dotfiles-git-clone/flake.nix index d7941bd..9c81f8d 100644 --- a/examples/custom-tools-dotfiles-git-clone/flake.nix +++ b/examples/custom-tools-dotfiles-git-clone/flake.nix @@ -3,7 +3,7 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; - agentbox.url = "github:gotha/agentbox"; + agentbox.url = "github:gotha/agentbox/v0.1.0"; # Home-manager for user-level configuration (pinned to match nixpkgs 25.11) home-manager = { diff --git a/examples/custom-tools-git-clone/flake.lock b/examples/custom-tools-git-clone/flake.lock new file mode 100644 index 0000000..d169370 --- /dev/null +++ b/examples/custom-tools-git-clone/flake.lock @@ -0,0 +1,97 @@ +{ + "nodes": { + "agentbox": { + "inputs": { + "gotha-nixpkgs": "gotha-nixpkgs", + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1776942799, + "narHash": "sha256-2dfOcaNy+Z4EkHh49c3DWWNjQjUgRrgaRfZmVG5/TvU=", + "owner": "gotha", + "repo": "agentbox", + "rev": "38317bbbf525ea707ee835f23f1dcd7260a7e5f1", + "type": "github" + }, + "original": { + "owner": "gotha", + "ref": "v0.1.0", + "repo": "agentbox", + "type": "github" + } + }, + "gotha-nixpkgs": { + "inputs": { + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1771394122, + "narHash": "sha256-Z8gca9rOPaYchx7uR7g4iWKh8FOQ7+ZRSsR9O/Ilri8=", + "owner": "gotha", + "repo": "nixpkgs", + "rev": "2292ed482b47e3b600d69da97123b697c49bd5fb", + "type": "github" + }, + "original": { + "owner": "gotha", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1762193579, + "narHash": "sha256-JYU1yRI4QSDDybCHaVeRGyxLdDQJf2nsYrh8+6LQvgk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "7aeb722dae56de97ee1246e22c88880ca2606238", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1772047000, + "narHash": "sha256-7DaQVv4R97cii/Qdfy4tmDZMB2xxtyIvNGSwXBBhSmo=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1267bb4920d0fc06ea916734c11b0bf004bbe17e", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1777428379, + "narHash": "sha256-ypxFOeDz+CqADEQNL72haqGjvZQdBR5Vc7pyx2JDttI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "755f5aa91337890c432639c60b6064bb7fe67769", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "agentbox": "agentbox", + "nixpkgs": "nixpkgs_3" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/examples/custom-tools-git-clone/flake.nix b/examples/custom-tools-git-clone/flake.nix index 23fe62f..2db08bb 100644 --- a/examples/custom-tools-git-clone/flake.nix +++ b/examples/custom-tools-git-clone/flake.nix @@ -3,7 +3,7 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; - agentbox.url = "github:gotha/agentbox"; + agentbox.url = "github:gotha/agentbox/v0.1.0"; }; outputs = { self, nixpkgs, agentbox }: diff --git a/examples/devshell-prebuild-git/flake.nix b/examples/devshell-prebuild-git/flake.nix new file mode 100644 index 0000000..29d0498 --- /dev/null +++ b/examples/devshell-prebuild-git/flake.nix @@ -0,0 +1,52 @@ +{ + description = "Example VM that pre-installs a project's devShell packages at build time"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + agentbox.url = "github:gotha/agentbox"; + + # The project whose devShell packages get baked into the VM image at build + # time. It is passed to agentbox as a Nix value, so it must be a flake input + # readable at evaluation time (fetched/locked with your normal git/SSH creds). + # + # In real use, point this at your repository, e.g.: + # project.url = "git+ssh://git@github.com/you/your-project"; + # + # Here it references a bundled sample project so the example is self-contained. + project.url = "path:./project"; + }; + + outputs = { self, nixpkgs, agentbox, project }: + let + allSystems = [ "aarch64-linux" "x86_64-linux" "aarch64-darwin" "x86_64-darwin" ]; + in + { + nixosConfigurations = builtins.listToAttrs (map (hostSystem: { + name = "vm-${hostSystem}"; + value = agentbox.lib.mkDevVm { + inherit hostSystem; + extraConfig = { + agentbox.vm.hostname = "devshell-vm"; + + # Optionally also clone the same repo at runtime. The runtime source + # (a git URL string, cloned after boot) is independent of the + # build-time devShell extraction (the flake input below). + # agentbox.project.source.type = "git"; + # agentbox.project.source.git.url = "git@github.com:you/your-project.git"; + + # Bake the project's devShell packages into the image at build time. + # After boot they are on PATH - no download, no `nix develop` needed. + agentbox.project.devShellPackages = { + enable = true; + flake = project; + # name = "default"; # or a named devShell: devShells.. + }; + }; + }; + }) allSystems); + + apps = agentbox.lib.mkVmApps { + inherit (self) nixosConfigurations; + }; + }; +} diff --git a/examples/devshell-prebuild-git/project/flake.nix b/examples/devshell-prebuild-git/project/flake.nix new file mode 100644 index 0000000..06ff2cb --- /dev/null +++ b/examples/devshell-prebuild-git/project/flake.nix @@ -0,0 +1,28 @@ +{ + description = "Sample project flake with a devShell (consumed by the agentbox example)"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + + outputs = { self, nixpkgs }: + let + systems = [ "aarch64-linux" "x86_64-linux" "aarch64-darwin" "x86_64-darwin" ]; + forAllSystems = nixpkgs.lib.genAttrs systems; + in + { + # agentbox reads devShells.. from this flake at build time + # and bakes the declared packages into the VM image. + devShells = forAllSystems (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + default = pkgs.mkShell { + packages = [ + pkgs.jq + pkgs.ripgrep + pkgs.hello + ]; + }; + }); + }; +} diff --git a/examples/minimal-auggie-mount/flake.lock b/examples/minimal-auggie-mount/flake.lock new file mode 100644 index 0000000..d169370 --- /dev/null +++ b/examples/minimal-auggie-mount/flake.lock @@ -0,0 +1,97 @@ +{ + "nodes": { + "agentbox": { + "inputs": { + "gotha-nixpkgs": "gotha-nixpkgs", + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1776942799, + "narHash": "sha256-2dfOcaNy+Z4EkHh49c3DWWNjQjUgRrgaRfZmVG5/TvU=", + "owner": "gotha", + "repo": "agentbox", + "rev": "38317bbbf525ea707ee835f23f1dcd7260a7e5f1", + "type": "github" + }, + "original": { + "owner": "gotha", + "ref": "v0.1.0", + "repo": "agentbox", + "type": "github" + } + }, + "gotha-nixpkgs": { + "inputs": { + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1771394122, + "narHash": "sha256-Z8gca9rOPaYchx7uR7g4iWKh8FOQ7+ZRSsR9O/Ilri8=", + "owner": "gotha", + "repo": "nixpkgs", + "rev": "2292ed482b47e3b600d69da97123b697c49bd5fb", + "type": "github" + }, + "original": { + "owner": "gotha", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1762193579, + "narHash": "sha256-JYU1yRI4QSDDybCHaVeRGyxLdDQJf2nsYrh8+6LQvgk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "7aeb722dae56de97ee1246e22c88880ca2606238", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1772047000, + "narHash": "sha256-7DaQVv4R97cii/Qdfy4tmDZMB2xxtyIvNGSwXBBhSmo=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1267bb4920d0fc06ea916734c11b0bf004bbe17e", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1777428379, + "narHash": "sha256-ypxFOeDz+CqADEQNL72haqGjvZQdBR5Vc7pyx2JDttI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "755f5aa91337890c432639c60b6064bb7fe67769", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "agentbox": "agentbox", + "nixpkgs": "nixpkgs_3" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/examples/minimal-auggie-mount/flake.nix b/examples/minimal-auggie-mount/flake.nix index f08a36c..a3a8032 100644 --- a/examples/minimal-auggie-mount/flake.nix +++ b/examples/minimal-auggie-mount/flake.nix @@ -3,7 +3,7 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; - agentbox.url = "github:gotha/agentbox"; + agentbox.url = "github:gotha/agentbox/v0.1.0"; }; outputs = { self, nixpkgs, agentbox }: diff --git a/examples/minimal-cursor-mount/flake.lock b/examples/minimal-cursor-mount/flake.lock new file mode 100644 index 0000000..d169370 --- /dev/null +++ b/examples/minimal-cursor-mount/flake.lock @@ -0,0 +1,97 @@ +{ + "nodes": { + "agentbox": { + "inputs": { + "gotha-nixpkgs": "gotha-nixpkgs", + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1776942799, + "narHash": "sha256-2dfOcaNy+Z4EkHh49c3DWWNjQjUgRrgaRfZmVG5/TvU=", + "owner": "gotha", + "repo": "agentbox", + "rev": "38317bbbf525ea707ee835f23f1dcd7260a7e5f1", + "type": "github" + }, + "original": { + "owner": "gotha", + "ref": "v0.1.0", + "repo": "agentbox", + "type": "github" + } + }, + "gotha-nixpkgs": { + "inputs": { + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1771394122, + "narHash": "sha256-Z8gca9rOPaYchx7uR7g4iWKh8FOQ7+ZRSsR9O/Ilri8=", + "owner": "gotha", + "repo": "nixpkgs", + "rev": "2292ed482b47e3b600d69da97123b697c49bd5fb", + "type": "github" + }, + "original": { + "owner": "gotha", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1762193579, + "narHash": "sha256-JYU1yRI4QSDDybCHaVeRGyxLdDQJf2nsYrh8+6LQvgk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "7aeb722dae56de97ee1246e22c88880ca2606238", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1772047000, + "narHash": "sha256-7DaQVv4R97cii/Qdfy4tmDZMB2xxtyIvNGSwXBBhSmo=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1267bb4920d0fc06ea916734c11b0bf004bbe17e", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1777428379, + "narHash": "sha256-ypxFOeDz+CqADEQNL72haqGjvZQdBR5Vc7pyx2JDttI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "755f5aa91337890c432639c60b6064bb7fe67769", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "agentbox": "agentbox", + "nixpkgs": "nixpkgs_3" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/examples/minimal-cursor-mount/flake.nix b/examples/minimal-cursor-mount/flake.nix index 633eb3d..4cba25e 100644 --- a/examples/minimal-cursor-mount/flake.nix +++ b/examples/minimal-cursor-mount/flake.nix @@ -3,7 +3,7 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; - agentbox.url = "github:gotha/agentbox"; + agentbox.url = "github:gotha/agentbox/v0.1.0"; }; outputs = { self, nixpkgs, agentbox }: diff --git a/flake.lock b/flake.lock index 5026501..8c0978c 100644 --- a/flake.lock +++ b/flake.lock @@ -35,16 +35,16 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1772047000, - "narHash": "sha256-7DaQVv4R97cii/Qdfy4tmDZMB2xxtyIvNGSwXBBhSmo=", + "lastModified": 1781216227, + "narHash": "sha256-9mUW6gNwoN2SWc/l0fW4svPNOulXLl8ijqKyeSOGgJE=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "1267bb4920d0fc06ea916734c11b0bf004bbe17e", + "rev": "a0374025a863d007d98e3297f6aa46cc3141c2f0", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-25.11", + "ref": "nixos-26.05", "repo": "nixpkgs", "type": "github" } diff --git a/flake.nix b/flake.nix index f415588..facfd13 100644 --- a/flake.nix +++ b/flake.nix @@ -2,7 +2,7 @@ description = "gotha/agentbox - NixOS VM for coding agents"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; gotha-nixpkgs.url = "github:gotha/nixpkgs"; }; diff --git a/lib/default.nix b/lib/default.nix index 7a74f99..a6639c5 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -9,5 +9,9 @@ # Generate apps from nixosConfigurations (eliminates boilerplate in consumer flakes) mkVmApps = import ./mk-vm-apps.nix { inherit nixpkgs; }; + + # Pure helper to extract packages from a project flake's devShell (build-time + # pre-install). Exposed for reuse and testing. + extractDevShellPackages = import ./extract-devshell-packages.nix { lib = nixpkgs.lib; }; } diff --git a/lib/extract-devshell-packages.nix b/lib/extract-devshell-packages.nix new file mode 100644 index 0000000..035f9d4 --- /dev/null +++ b/lib/extract-devshell-packages.nix @@ -0,0 +1,49 @@ +# Pure helper: extract the packages declared by a project flake's devShell. +# +# Build time == evaluation time for a flake-based build, so this reads the +# project flake (a Nix value) during evaluation and returns the list of package +# derivations its selected devShell makes available. The caller (the +# devshell-packages module) appends the result to environment.systemPackages so +# the tools are baked into the VM image and available on PATH at boot. +# +# Signature: extractDevShellPackages { flake, system, name ? "default" } -> [ ] +# +# Errors (clear, fail-loud — never a silent no-op): +# - flake has no devShells for the given system -> throw +# - the named devShell does not exist -> throw (lists available names) +# - the devShell declares no installable packages -> throw +{ lib }: + +{ flake, system, name ? "default" }: +let + hasDevShellsForSystem = + flake ? devShells && flake.devShells ? ${system}; + + devShellsForSystem = + if hasDevShellsForSystem + then flake.devShells.${system} + else throw "agentbox.project.devShellPackages: project flake has no devShells for system '${system}'"; + + availableNames = builtins.attrNames devShellsForSystem; + + shell = + if devShellsForSystem ? ${name} + then devShellsForSystem.${name} + else throw "agentbox.project.devShellPackages: devShell '${name}' not found for system '${system}'. Available: ${ + if availableNames == [] then "none" else builtins.concatStringsSep ", " availableNames + }"; + + # mkShell merges `packages` into nativeBuildInputs; collect every input kind so + # tools declared via packages/buildInputs/propagated* are all captured. + collected = + (shell.buildInputs or []) + ++ (shell.nativeBuildInputs or []) + ++ (shell.propagatedBuildInputs or []) + ++ (shell.propagatedNativeBuildInputs or []); + + # De-duplicate by derivation identity (same package referenced twice). + deduped = lib.unique collected; +in +if deduped == [] +then throw "agentbox.project.devShellPackages: devShell '${name}' for system '${system}' declares no installable packages" +else deduped diff --git a/modules/default.nix b/modules/default.nix index 2761d0c..6fd1f51 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -50,6 +50,7 @@ in ./cursor.nix ./claude-code.nix ./crush.nix + ./devshell-packages.nix ./services ]; @@ -219,6 +220,38 @@ in default = defaults.project.validateMarker; description = "If true, validates that the marker file exists in the source"; }; + + # Build-time pre-install of the project flake's devShell packages + devShellPackages = { + enable = mkOption { + type = types.bool; + default = defaults.project.devShellPackages.enable; + description = '' + Pre-install the packages declared in the project flake's devShell into + the VM at build time, as globally available packages (on PATH at boot). + Opt-in; when false the build is unchanged. Requires a flake readable at + evaluation time, so it is supported only for git-style flake inputs + (not mount/copy sources). + ''; + }; + + flake = mkOption { + type = types.nullOr types.raw; + default = defaults.project.devShellPackages.flake; + example = literalExpression "inputs.project"; + description = '' + The project flake, passed as a Nix value (a locked input from the + consumer's flake.lock). Its devShells.. is read at build + time. Required when enable = true. + ''; + }; + + name = mkOption { + type = types.str; + default = defaults.project.devShellPackages.name; + description = "Which devShell to extract: devShells..."; + }; + }; }; # Packages diff --git a/modules/devshell-packages.nix b/modules/devshell-packages.nix new file mode 100644 index 0000000..0dcac30 --- /dev/null +++ b/modules/devshell-packages.nix @@ -0,0 +1,64 @@ +# Build-time pre-install of a project flake's devShell packages. +# +# When agentbox.project.devShellPackages.enable is true, this reads the project +# flake's selected devShell at evaluation time, extracts its packages, and adds +# them to environment.systemPackages so they are baked into the VM image and +# available on PATH the moment the VM boots (no download, offline-capable). +{ config, lib, pkgs, ... }: +let + cfg = config.agentbox.project.devShellPackages; + + extractDevShellPackages = import ../lib/extract-devshell-packages.nix { inherit lib; }; + + # The VM is always built for the guest system; pkgs is the guest's package set, + # so pkgs.system is the correct system to resolve devShells. against. + # (Works both via lib.mkDevVm and when the module is used directly in tests.) + system = pkgs.stdenv.hostPlatform.system; + + # Guard extraction so a null flake produces the friendly assertion below + # instead of a raw "no devShells" throw. + extracted = + if cfg.flake == null + then [ ] + else extractDevShellPackages { + inherit (cfg) flake name; + inherit system; + }; + + pkgNames = map (p: lib.getName p) extracted; + + # Surface what was pre-installed at build time (FR-009). + tracedPackages = + if extracted == [ ] + then extracted + else builtins.trace + "agentbox.project.devShellPackages: pre-installing from devShell '${cfg.name}' (${system}): ${lib.concatStringsSep ", " pkgNames}" + extracted; +in +{ + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.flake != null; + message = '' + agentbox.project.devShellPackages.enable is true but + agentbox.project.devShellPackages.flake is null. + + Provide the project flake as a Nix value (a locked flake input), e.g.: + agentbox.project.devShellPackages.flake = inputs.project; + + Build-time devShell extraction requires a flake readable at evaluation + time, so it is supported only for git-style flake inputs - not for + mount/copy project sources, whose contents only exist after boot. + ''; + } + ]; + + # Bake the devShell's packages into the image as global packages (on PATH). + environment.systemPackages = tracedPackages; + + # Readable manifest of what was pre-installed, enumerable from inside the VM. + environment.etc."agentbox/devshell-packages".text = + lib.concatStringsSep "\n" pkgNames + "\n"; + }; +} diff --git a/specs/001-devshell-package-prebuild/checklists/requirements.md b/specs/001-devshell-package-prebuild/checklists/requirements.md new file mode 100644 index 0000000..80ceac5 --- /dev/null +++ b/specs/001-devshell-package-prebuild/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Pre-install devShell Packages at Build Time + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-25 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Two [NEEDS CLARIFICATION] markers were resolved with the user: FR-004 install semantics → devShell packages are installed as **globally available** packages (on PATH at boot, no flake activation required); FR-005 supported source types → **`git` only**, with `mount`/`copy` clearly reporting pre-install is unavailable. Spec updated accordingly. +- All checklist items pass. Spec is ready for `/speckit-clarify` (optional) or `/speckit-plan`. diff --git a/specs/001-devshell-package-prebuild/contracts/extraction.md b/specs/001-devshell-package-prebuild/contracts/extraction.md new file mode 100644 index 0000000..4bf79d6 --- /dev/null +++ b/specs/001-devshell-package-prebuild/contracts/extraction.md @@ -0,0 +1,47 @@ +# Contract: `extractDevShellPackages` helper + +Pure evaluation-time function in `lib/extract-devshell-packages.nix`, exposed via `lib/default.nix`. It is the testable core of the feature (no I/O, no build, no impurity). + +## Signature + +```nix +extractDevShellPackages :: { + flake :: , # attrset exposing devShells + system :: String, # guest system, e.g. "x86_64-linux" + name :: String ? "default", +} -> [ ] +``` + +## Semantics + +1. Resolve `shell = flake.devShells.${system}.${name}`. + - If `flake.devShells.${system}` is missing → **throw**: `"devShellPackages: project flake has no devShells for system '${system}'"`. + - Else if `${name}` is missing → **throw**: `"devShellPackages: devShell '${name}' not found for '${system}'. Available: "`. +2. Collect inputs: + `pkgs = (shell.buildInputs or []) ++ (shell.nativeBuildInputs or []) ++ (shell.propagatedBuildInputs or []) ++ (shell.propagatedNativeBuildInputs or [])`. +3. **De-duplicate** by store path / derivation identity. +4. If the de-duplicated list is **empty** → **throw**: `"devShellPackages: devShell '${name}' declares no installable packages"` (FR-007 — avoid a silent no-op image). +5. Return the de-duplicated, non-empty list. + +## Properties (verified by pure eval tests) + +| Property | Expectation | +|---|---| +| P1 — happy path | Fixture devShell declaring `[cowsay hello]` ⇒ result contains both (and any `mkShell` implicit inputs), de-duplicated. | +| P2 — `packages =` arg | Tools passed via `mkShell { packages = [...]; }` appear (they land in `nativeBuildInputs`). | +| P3 — named shell | `name = "ci"` extracts `devShells..ci`, not `default`. | +| P4 — missing system | Unknown `system` ⇒ throws the system-missing message. | +| P5 — missing name | Unknown `name` ⇒ throws, message lists available names. | +| P6 — empty shell | devShell with no inputs ⇒ throws the "no installable packages" message. | +| P7 — purity | Function evaluates with no build and no `--impure`. | + +## Module integration contract (`modules/devshell-packages.nix`) + +- Guarded by `lib.mkIf cfg.project.devShellPackages.enable`. +- `assertions = [{ assertion = flake != null; message = "..."; }]` for the null-flake case (C3) before calling the helper. +- Calls `extractDevShellPackages { flake; system = guestSystem; inherit name; }` and appends the result to `environment.systemPackages`. +- **Observability (FR-009/SC-006)**: emit the resolved package names via `builtins.trace`/`lib.warn` at build time **and** write a readable manifest into the image (e.g. a file under the user's project area or `/etc`) listing the pre-installed package names, so the set is enumerable from inside the VM after boot. + +## Guest-system source + +`guestSystem` is provided by `lib/mk-dev-vm.nix` (host→guest map) and threaded to the module via `specialArgs`/config, ensuring extraction targets the VM's Linux architecture even on macOS hosts (Decision 3). diff --git a/specs/001-devshell-package-prebuild/contracts/module-options.md b/specs/001-devshell-package-prebuild/contracts/module-options.md new file mode 100644 index 0000000..18fdeb4 --- /dev/null +++ b/specs/001-devshell-package-prebuild/contracts/module-options.md @@ -0,0 +1,68 @@ +# Contract: `agentbox.project.devShellPackages` module options + +This is the consumer-facing interface (the agentbox "API" is its NixOS option surface). It extends `modules/default.nix` and `config.nix`, following the same pattern as `agentbox.docker`, `agentbox.auggie`, etc. + +## Option surface + +```nix +agentbox.project.devShellPackages = { + enable = mkOption { + type = types.bool; + default = false; # from config.nix + description = '' + Pre-install the packages declared in the project flake's devShell into the + VM at build time, as globally available packages (on PATH at boot). + Opt-in; when false the build is unchanged. + ''; + }; + + flake = mkOption { + type = types.nullOr types.unspecified; # a flake value (attrset with devShells) + default = null; # from config.nix + example = literalExpression "inputs.project"; + description = '' + The project flake, passed as a Nix value (a locked input from the + consumer's flake.lock). Its devShells.. is read at + build time. Required when enable = true. Only a build-time-readable flake + works here, so mount/copy sources are not supported (see git source). + ''; + }; + + name = mkOption { + type = types.str; + default = "default"; # from config.nix + description = '' + Which devShell to extract: devShells... + ''; + }; +}; +``` + +## Behavioral contract + +| # | Given | Then | Maps to | +|---|---|---|---| +| C1 | `enable = false` | Module contributes nothing; no assertions; build identical to today. | FR-001, FR-011, SC-003 | +| C2 | `enable = true`, valid `flake`, devShell with packages | The devShell's packages are appended to `environment.systemPackages`; available on `PATH` in the booted VM with no network. | FR-002, FR-003, FR-004, SC-001, SC-004 | +| C3 | `enable = true`, `flake = null` | **Assertion failure** with message naming `devShellPackages.flake` and explaining the build-time-flake (git-only) requirement. | FR-005, FR-006 | +| C4 | `enable = true`, `flake` set, `devShells.` or `` absent | **`throw`** listing available shell names (or "none"). | FR-006, FR-008 | +| C5 | `enable = true`, selected devShell unevaluatable / yields 0 packages | **`throw`** / surfaced eval error; build fails, no partial image. | FR-007 | +| C6 | `enable = true`, `name` set to a present named shell | That named shell is used instead of `default`. | FR-008 | +| C7 | any | Runtime `source.{type,git,…}` behavior is unchanged; this option only affects what is baked at build time. | FR-011 | +| C8 | C2 holds | The extracted package set is **observable** to the user (build trace and/or readable manifest in the VM). | FR-009, SC-006 | + +## Defaults added to `config.nix` + +```nix +project.devShellPackages = { + enable = false; + flake = null; + name = "default"; +}; +``` + +## Non-goals (explicit) + +- No new credential-handling flow — fetching/locking the `flake` input uses the host's standard git/SSH config. +- No `mount`/`copy` build-time support — those populate the VM only at boot. +- No continuous sync — the set reflects the flake at build/lock time; refresh = rebuild. diff --git a/specs/001-devshell-package-prebuild/data-model.md b/specs/001-devshell-package-prebuild/data-model.md new file mode 100644 index 0000000..802eba3 --- /dev/null +++ b/specs/001-devshell-package-prebuild/data-model.md @@ -0,0 +1,80 @@ +# Phase 1 Data Model: Pre-install devShell Packages at Build Time + +This feature has no persistent/runtime data store. The "entities" are **evaluation-time** values: module configuration, the project flake value, and the derived package set. They are modeled here as the option schema and the helper's input/output shapes. + +--- + +## Entity: `devShellPackages` configuration (option group) + +New option group `agentbox.project.devShellPackages`, declared in `modules/default.nix`, defaults in `config.nix`. + +| Field | Type | Default | Required when enabled | Description | +|---|---|---|---|---| +| `enable` | bool | `false` | — | Master opt-in. When `false`, the module is inert and the build is identical to current behavior (FR-001, SC-003). | +| `flake` | nullOr (flake value / attrset) | `null` | **yes** | The project flake, passed as a Nix value (a locked input from the consumer's `flake.lock`). Source of `devShells`. (Decision 1) | +| `name` | str | `"default"` | — | Which `devShells..` to extract (FR-008, Decision 5). | + +**Validation rules** (enforced in `modules/devshell-packages.nix`; see `contracts/module-options.md`): +- `enable == true && flake == null` → **assertion failure** (clear message; this is the `mount`/`copy` / forgotten-input case — FR-005, FR-006). +- `enable == true && flake != null` but `flake.devShells.` missing **or** `` missing → **`throw`** listing available shell names or "none" (FR-006, Decision 4/5). +- `enable == true` and the selected devShell evaluates but yields **zero** installable packages → **`throw`** "devShell present but no packages extracted" (FR-007). +- `enable == false` → no assertions, no contributions (FR-001). + +**State**: none (no transitions). The value is fixed at evaluation time; changing the project's devShell requires re-locking the input and rebuilding (spec Assumption: pre-installed set reflects build-time state). + +--- + +## Entity: Project flake + +The consumer's project flake, supplied as `devShellPackages.flake`. + +- **Relevant shape**: `flake.devShells..` → a derivation (typically a `pkgs.mkShell` result). +- **Origin**: a locked flake input in the consumer's flake (`inputs.project`), fetched with the host's git/SSH credentials at lock time. +- **Relationship**: read-only input to the **Extraction helper**; independent of the runtime `source.git.*` config used for the boot-time clone (the two should point at the same repo/ref for consistency — documented, not enforced). + +--- + +## Entity: devShell derivation + +The selected `devShells..` value. + +- **Relevant attributes** (all read at eval time, no build): `buildInputs`, `nativeBuildInputs`, `propagatedBuildInputs`, `propagatedNativeBuildInputs` — each a list of package derivations. Absent attributes default to `[]`. +- **Relationship**: the union of these lists (de-duplicated) is the **Extracted package set**. + +--- + +## Entity: Extracted package set + +The output of the helper — a list of package derivations. + +- **Derivation**: `dedup(buildInputs ++ nativeBuildInputs ++ propagatedBuildInputs ++ propagatedNativeBuildInputs)` of the selected devShell (Decision 2). +- **Consumption**: appended to `environment.systemPackages` (alongside base packages and `agentbox.packages.extra`), realized into the system closure → baked into the image (global install, on `PATH`). +- **Constraints**: every element must be buildable for `` (guaranteed by extracting from `devShells.`). Determines the image-size growth noted in the spec. +- **Observability** (FR-009, SC-006): the set must be enumerable by the user — surfaced via a build-time trace and/or a readable artifact in the VM (see `contracts/extraction.md`). + +--- + +## Entity: Guest system (derived) + +`guestSystem` ∈ { `aarch64-linux`, `x86_64-linux` }, mapped from `hostSystem` in `lib/mk-dev-vm.nix`. + +- **Role**: selects the platform slice of the project flake's `devShells` (Decision 3). +- **Relationship**: threaded into the module so extraction resolves the correct architecture regardless of host OS (macOS hosts still build Linux guests). + +--- + +## Relationships (summary) + +```text +consumer flake + └─ inputs.project (locked) ──passed as──▶ devShellPackages.flake + │ + guestSystem ──┐ │ + ▼ ▼ + flake.devShells.. (devShell derivation) + │ + extract-devshell-packages + │ + ▼ + Extracted package set ──▶ environment.systemPackages ──▶ VM image (PATH) +``` diff --git a/specs/001-devshell-package-prebuild/plan.md b/specs/001-devshell-package-prebuild/plan.md new file mode 100644 index 0000000..0a2f6ba --- /dev/null +++ b/specs/001-devshell-package-prebuild/plan.md @@ -0,0 +1,117 @@ +# Implementation Plan: Pre-install devShell Packages at Build Time + +**Branch**: `001-devshell-package-prebuild` | **Date**: 2026-06-25 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `specs/001-devshell-package-prebuild/spec.md` + +## Summary + +Add an **opt-in** capability to agentbox so that, at VM **build time**, it reads a project's Nix flake `devShell`, extracts the packages that shell declares, and bakes them into the VM image as **globally installed** packages (`environment.systemPackages`). The result: when the user boots the VM, the project's dev tools are already on `PATH` — no download, no build, works offline — and a later `nix develop` finds the identical store paths already present. + +The core technical constraint drives the whole design: in a flake-based build, **build time == Nix evaluation time**. The existing `agentbox.project.source.git.url` is only a runtime string handed to a boot-time `systemd` service; the evaluator never sees the repository. To extract packages during evaluation, the project flake must be reachable by the evaluator. The chosen approach (see [research.md](./research.md)) is to accept the **project flake as a Nix value** (a locked flake input the consumer already has), keeping evaluation pure and reproducible, and to construct the package list with a small pure helper that reads `devShells..`. + +## Technical Context + +**Language/Version**: Nix (flakes enabled); NixOS module system; nixpkgs `nixos-26.05`; guest `system.stateVersion = "25.11"`. Boot-time glue is POSIX `sh` in `systemd` oneshot services. + +**Primary Dependencies**: `nixpkgs` (nixos-26.05) + `gotha-nixpkgs` flake inputs; `nixpkgs.lib` (module system, `mkOption`, `mkIf`, assertions); `modulesPath + /virtualisation/qemu-vm.nix`; the consumer project flake's `devShells` output (typically built with `pkgs.mkShell`). + +**Storage**: N/A — declarative. The unit of persistence is the VM image / Nix store closure produced by the build. + +**Testing**: NixOS VM testing framework (`pkgs.nixosTest`, see `tests/`), run via `nix flake check` / `nix build .#checks..`; plus pure evaluation tests for the extraction helper (`tests/lib.nix` pattern). A test fixture flake with a known devShell goes under `tests/fixtures/`. + +**Target Platform**: NixOS guest VM (`aarch64-linux`, `x86_64-linux`), built from Linux hosts directly or macOS hosts via a Linux builder. Guest system is mapped from host in `lib/mk-dev-vm.nix`. + +**Project Type**: Nix flake / NixOS module library (single project). No application src/ tree — code lives in `modules/`, `lib/`, `config.nix`, with tests in `tests/` and consumer examples in `examples/`. + +**Performance Goals**: Build-time evaluation/extraction overhead is negligible relative to building the package closure. Primary win is runtime: first availability of the dev toolchain inside the VM drops from "download + build on first `nix develop`" to "already present at boot" (offline-capable). Image size grows by the devShell's build closure — an accepted trade-off. + +**Constraints**: Must stay **pure-eval** (no `--impure` required for the primary path). Must be **backward compatible** — default off, zero change when disabled. Must **fail clearly** (build error) when enabled but no usable devShell is reachable. Supported source: **git** (a build-time-readable flake); `mount`/`copy` must report unavailability rather than silently skip. + +**Scale/Scope**: Small, additive change — one new module (`modules/devshell-packages.nix`), one new lib helper (`lib/extract-devshell-packages.nix`), option wiring in `modules/default.nix` + `config.nix`, one fixture flake, one VM test + eval tests, README + one example. No changes to existing source-type services. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +The project constitution (`.specify/memory/constitution.md`) is still the **unfilled template** — no principles are ratified, so there are no formal constitutional gates to enforce. In their absence, this plan is held to the project's **de-facto conventions**, evident from the codebase: + +| Gate (de-facto) | Status | Notes | +|---|---|---| +| **Opt-in / backward compatible** — new behavior defaults off, existing configs unchanged | PASS | Feature gated behind `agentbox.project.devShellPackages.enable = false` by default (mirrors `docker.enable`, `auggie.enable`, etc.). | +| **Declarative, reproducible, pure eval** — no `--impure`, no impure builtins on the primary path | PASS | Project flake consumed as a locked Nix input; extraction is a pure function. Impure `getFlake`/URL path documented only as an explicit fallback. | +| **Option-driven module pattern** — config via `agentbox.*` options backed by `config.nix` defaults | PASS | New options follow the exact `mkOption` + `defaults` pattern in `modules/default.nix`. | +| **Tested via NixOS VM framework** — end-to-end behavior covered by `pkgs.nixosTest` + added to `tests/default.nix` | PASS | New VM test boots offline and asserts the tool is on `PATH`; pure eval tests cover the helper. | +| **Clear failure over silent partial state** | PASS | Assertions/`throw` on misconfiguration and unevaluatable devShells (FR-006/FR-007). | + +No violations → Complexity Tracking left empty. (Recommend running `/speckit-constitution` to ratify principles; this plan will need a re-check if real gates are later defined.) + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-devshell-package-prebuild/ +├── plan.md # This file (/speckit-plan output) +├── spec.md # Feature specification (/speckit-specify output) +├── research.md # Phase 0 output — design decisions & rationale +├── data-model.md # Phase 1 output — config/eval entities +├── quickstart.md # Phase 1 output — wire-up + validation guide +├── contracts/ # Phase 1 output — interface contracts +│ ├── module-options.md # agentbox.project.devShellPackages option surface +│ └── extraction.md # extract-devshell-packages helper contract +├── checklists/ +│ └── requirements.md # Spec quality checklist (already complete) +└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here) +``` + +### Source Code (repository root) + +Actual agentbox layout; new/changed paths marked. No application `src/` tree exists — this is a Nix module library. + +```text +lib/ +├── default.nix # CHANGED: expose extractDevShellPackages helper +├── mk-dev-vm.nix # CHANGED (minimal): pass guestSystem context so the +│ # devshell module can resolve devShells. +├── extract-devshell-packages.nix # NEW: pure helper — flake -> [packages] (+ error cases) +├── mk-vm-apps.nix # (unchanged) +└── mk-vm-runner.nix # (unchanged) + +modules/ +├── default.nix # CHANGED: declare agentbox.project.devShellPackages options +│ # + import ./devshell-packages.nix +├── devshell-packages.nix # NEW: consumes options, runs extraction, appends to +│ # environment.systemPackages, defines assertions +├── packages.nix # (unchanged — systemPackages already aggregates) +└── ... # (other modules unchanged) + +config.nix # CHANGED: add project.devShellPackages defaults + +tests/ +├── default.nix # CHANGED: register the new test(s) +├── devshell-packages.nix # NEW: nixosTest — build w/ feature on, boot offline, +│ # assert fixture tool on PATH; assert clear failure cases +├── lib.nix # CHANGED (or new eval test): pure tests for the helper +└── fixtures/ + └── devshell-project/ # NEW: minimal flake fixture exposing devShells..default + └── flake.nix # declaring a distinctive package (e.g. `hello`/`cowsay`) + +examples/ +└── devshell-prebuild-git/ # NEW: example consumer flake wiring project as an input + ├── flake.nix # + enabling devShellPackages + └── flake.lock + +README.md # CHANGED: document the option, the input wiring, limits +CLAUDE.md # CHANGED: SPECKIT markers point to this plan +``` + +**Structure Decision**: Single Nix module-library project. The feature is delivered as (1) a **pure lib helper** (`lib/extract-devshell-packages.nix`) that is independently unit-testable at eval time, (2) a thin **NixOS module** (`modules/devshell-packages.nix`) that wires options → helper → `environment.systemPackages` and owns the assertions, and (3) **option declarations** in `modules/default.nix` backed by defaults in `config.nix` — matching how every existing agentbox capability (docker, auggie, codex, …) is structured. No existing source-type service is modified; this is purely additive at build/eval time. + +## Complexity Tracking + +> No constitution gate violations. No entries required. + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| — | — | — | diff --git a/specs/001-devshell-package-prebuild/quickstart.md b/specs/001-devshell-package-prebuild/quickstart.md new file mode 100644 index 0000000..185f71f --- /dev/null +++ b/specs/001-devshell-package-prebuild/quickstart.md @@ -0,0 +1,111 @@ +# Quickstart: Pre-install devShell Packages at Build Time + +A validation/run guide proving the feature end-to-end. For option details see +[contracts/module-options.md](./contracts/module-options.md); for the helper see +[contracts/extraction.md](./contracts/extraction.md). + +## Prerequisites + +- Nix with flakes enabled. +- Linux host, or macOS with a Linux builder (per README). +- A project flake that exposes `devShells..default` (or a named shell). + +## 1. Wire the project flake into a consumer VM flake + +The project is added as a **flake input** (so it is locked and readable at build time) and enabled via the new option: + +```nix +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + agentbox.url = "github:gotha/agentbox"; + project.url = "git+ssh://git@github.com/you/your-project"; # locked in flake.lock + project.flake = true; + }; + + outputs = { self, nixpkgs, agentbox, project }: + let allSystems = [ "aarch64-linux" "x86_64-linux" "aarch64-darwin" "x86_64-darwin" ]; in { + nixosConfigurations = builtins.listToAttrs (map (hostSystem: { + name = "vm-${hostSystem}"; + value = agentbox.lib.mkDevVm { + inherit hostSystem; + extraConfig = { + # Runtime source (boot-time clone) — points at the same repo: + agentbox.project.source.type = "git"; + agentbox.project.source.git.url = "git@github.com:you/your-project.git"; + agentbox.project.marker = "flake.nix"; + + # NEW: bake the devShell packages into the image at build time + agentbox.project.devShellPackages = { + enable = true; + flake = project; # the locked input above + # name = "default"; # or a named devShell + }; + }; + }; + }) allSystems); + + apps = agentbox.lib.mkVmApps { inherit (self) nixosConfigurations; }; + }; +} +``` + +## 2. Build the VM + +```bash +nix build .#nixosConfigurations.vm-x86_64-linux.config.system.build.vm +# or just: nix run .#vm +``` + +**Expected**: build succeeds; build output traces the resolved devShell package set +(FR-009). The devShell's closure is built and included in the image. + +## 3. Validate: tools are present at boot, offline (SC-001, User Story 1) + +```bash +nix run .#vm # boot the VM +# inside the VM (ssh dev@... , empty password): +which # e.g. `cowsay` / `go` / `node` + --version # runs — no `nix develop`, no download +cat # enumerates what was baked in (FR-009 / SC-006) +``` + +To prove **offline** availability, boot with networking disabled (or run the +automated test below, which asserts this in a hermetic VM). + +## 4. Validate failure modes (FR-006 / FR-007) + +| Try | Expected | +|---|---| +| `enable = true;` with `flake` omitted (or `source.type = "mount"`/`"copy"`) | **Build fails** with an assertion naming `devShellPackages.flake` and the git-only requirement. | +| `name = "nope";` (no such shell) | **Build fails** with a `throw` listing available shell names. | +| Point `flake` at a project whose devShell has no packages | **Build fails** with "devShell declares no installable packages". | +| `enable = false;` | Build identical to baseline agentbox (SC-003). | + +## 5. Automated tests + +```bash +# Pure eval tests for the extraction helper: +nix eval .#checks.x86_64-linux.devshell-packages-eval # (or via `nix flake check`) + +# End-to-end: build VM with the feature on, boot offline, assert tool on PATH: +nix build .#checks.x86_64-linux.devshell-packages --print-build-logs + +# Whole suite: +nix flake check +``` + +The VM test uses `tests/fixtures/devshell-project` (a flake whose devShell +declares a distinctive tool) and asserts that tool is on `PATH` in the booted +VM without network access — the concrete form of SC-001. + +## Notes & limits + +- **Reproducibility**: keep `devShellPackages.flake` and the runtime + `source.git.url`/`ref` pointed at the same repo/ref. The baked set reflects the + flake **at lock/build time**; updating the devShell means re-locking + rebuild. +- **Image size** grows by the devShell's build closure — expected. +- **Private repos**: locking the `project` input uses your normal git/SSH config; + no extra agentbox credential setup is needed for the build-time read. +- **`getFlake`/URL fallback** (impure): if you cannot add an input, you may pin a + `rev` and use the documented impure path — not the recommended default. diff --git a/specs/001-devshell-package-prebuild/research.md b/specs/001-devshell-package-prebuild/research.md new file mode 100644 index 0000000..6bfaa56 --- /dev/null +++ b/specs/001-devshell-package-prebuild/research.md @@ -0,0 +1,109 @@ +# Phase 0 Research: Pre-install devShell Packages at Build Time + +This phase resolves the open technical decisions behind the plan. The spec already settled the two product-level questions (install mode = **global packages**; source scope = **git only**); the questions here are about *how* to realize that purely and reproducibly in Nix. + +--- + +## Decision 1 — How the project flake reaches the evaluator at build time + +**Decision**: The consumer passes their **project flake as a Nix value** (a flake input they already lock in their own `flake.lock`) into agentbox via a new option (`agentbox.project.devShellPackages.flake`). agentbox reads `flake.devShells..` from it during evaluation. + +**Rationale**: +- In a flake build, **build time is evaluation time**. The only repository contents the evaluator may read are those reachable through locked inputs or already-fetched sources. The current `source.git.url` is a runtime string for a boot-time `systemd` clone — invisible to the evaluator — so it cannot drive build-time extraction. +- A flake input is **pure and reproducible**: it is pinned in `flake.lock`, fetched with the host's normal git/SSH credentials at `nix flake lock` time (covers private repos), and requires **no `--impure`**. +- It is idiomatic Nix and composes with agentbox's existing model where the consumer writes their own flake and calls `agentbox.lib.mkDevVm`. Adding one input + one option is minimal boilerplate. + +**Alternatives considered**: +- **`builtins.getFlake "git+ssh://…?ref=main"` from `source.git.url`** — most "automatic" and closest to the literal request ("download the remote repository"), but a *mutable* ref fails in pure evaluation ("cannot fetch … in pure evaluation mode"); it only works with a pinned `rev` or under `--impure`. Private-repo auth at eval time is brittle (depends on daemon environment / SSH agent). **Kept as a documented opt-in fallback** for users who pin a rev or accept `--impure`, but not the primary path. +- **Import-From-Derivation (IFD): clone in a derivation, then import its flake** — heavy, serializes the build, fragile, and still needs network during eval. Rejected. +- **Parse `flake.nix`/`devShell` statically (text scraping)** — cannot resolve real derivations, breaks on any non-trivial expression. Rejected. + +**Reconciliation with the spec**: FR-002 ("locate the project's flake from the configured project source") is satisfied — the flake *is* the configured source, just provided as a locked input rather than a runtime URL string. FR-005 (git only; mount/copy report unavailable) is satisfied because only a build-time-readable flake can feed extraction; when the feature is enabled without such a flake (the situation for `mount`/`copy`), evaluation fails with a clear message (Decision 4). The spec's credential assumption resolves to "the host's standard git/SSH config used by Nix when locking inputs." + +--- + +## Decision 2 — Extracting the package list from a devShell + +**Decision**: Treat the selected devShell as a derivation and collect its declared inputs: +`(shell.buildInputs or []) ++ (shell.nativeBuildInputs or []) ++ (shell.propagatedBuildInputs or []) ++ (shell.propagatedNativeBuildInputs or [])`, then de-duplicate. These derivations are appended to `environment.systemPackages`. + +**Rationale**: +- `pkgs.mkShell` (the near-universal way to define a devShell) merges its `packages` argument into `nativeBuildInputs` and keeps `buildInputs`; both are plain attributes on the resulting derivation, readable at eval time without building anything. +- `environment.systemPackages` realizes exactly those store paths into the system closure → baked into the image (global install, on `PATH`). Because they are the *same* derivations the flake's devShell references, a later `nix develop` finds them already in the store (satisfies FR-010 — no re-fetch from mismatch). +- Reading attributes is pure and cheap; no IFD, no build during eval. + +**Alternatives considered**: +- **Add the whole `devShell` derivation to `systemPackages`** — a `mkShell` result is not a normal installable package (no usable `bin/`); installing it doesn't put tools on `PATH`. Rejected. +- **`nix print-dev-env` / `nix develop --profile` at build time** — runtime tooling, impure, not available during pure module evaluation. Rejected. +- **Only `buildInputs`** — misses tools passed via `packages =`/`nativeBuildInputs` (the common case). Rejected in favor of the union above. + +**Edge handling**: if the shell is not a `mkShell`-style derivation, the `or []` fallbacks yield an empty union; combined with Decision 4 this surfaces as a "no packages detected" condition rather than a crash. + +--- + +## Decision 3 — System matching (guest vs host) + +**Decision**: Resolve the devShell at `devShells..`, where `guestSystem` is the Linux system already computed in `lib/mk-dev-vm.nix` (`hostToGuest` map). Thread that value to the module via `specialArgs`/`config` so the module evaluates the correct platform. + +**Rationale**: The VM is always Linux (`aarch64-linux`/`x86_64-linux`) even when the host is macOS. Extracting `devShells.x86_64-darwin.*` would yield Darwin store paths that cannot be installed in the guest. Using `guestSystem` guarantees the extracted closure matches the VM's architecture. `mk-dev-vm.nix` already exposes `hostSystem`/`gothaPkgs` via `specialArgs`; adding `guestSystem` is a one-line, consistent extension. + +**Alternatives considered**: deriving the system from `pkgs.system` inside the module (works, but `mk-dev-vm.nix` is the single source of truth for the host→guest mapping; reuse it). Acceptable either way; prefer the explicit thread-through for clarity. + +--- + +## Decision 4 — Error & no-op behavior (FR-006 / FR-007) + +**Decision**: Fail the build with a **clear, actionable message** rather than silently producing an incomplete VM. Specifically: +- Feature **enabled but no `flake` provided** (the case for `mount`/`copy`, or a forgotten input) → module **assertion** failure naming the option and explaining git-only/build-time-flake requirement. +- `flake` provided but `devShells.` or the selected `` is **absent** → `throw` with the available shell names (or "none") listed. +- Selected devShell exists but **fails to evaluate / yields zero installable packages** → surface the evaluation error (do not swallow it) / `throw` a clear "devShell present but no packages could be extracted" message. + +**Rationale**: The user explicitly opted in and supplied (or should have supplied) a flake, so any of these is almost certainly a misconfiguration. A hard, descriptive failure is the most "unambiguous" outcome (FR-006) and prevents the "partially pre-installed VM" FR-007 forbids. NixOS `assertions` and `throw` both abort evaluation with the message shown to the user — no silent path. + +**Alternatives considered**: `lib.warn` + empty list (proceed) — reads as a near-silent no-op and risks shipping a VM the user believes is pre-baked but isn't. Rejected for the default. (A future "soft mode" option could allow warn-and-continue, but it is out of scope for v1.) + +--- + +## Decision 5 — Selecting among multiple devShells (FR-008) + +**Decision**: A `name` option (default `"default"`) selects which `devShells..` to extract. If the named shell is missing, fail per Decision 4 and list the available names. + +**Rationale**: Flakes commonly expose a `default` plus named shells. Defaulting to `default` matches `nix develop` with no argument; an explicit `name` covers projects whose dev tooling lives in a named shell. Simple, predictable, matches FR-008. + +--- + +## Decision 6 — Opt-in, backward compatibility, and option placement + +**Decision**: Gate everything behind `agentbox.project.devShellPackages.enable` (default `false`), declared in `modules/default.nix` with defaults in `config.nix`, implemented in a new `modules/devshell-packages.nix` imported by `modules/default.nix`. When disabled, the module contributes nothing. + +**Rationale**: Mirrors every existing agentbox capability (`docker`, `auggie`, `codex`, `cursor`, `crush`, `claudecode`) — same `mkOption`/`defaults`/`mkIf` pattern, same import style. Guarantees FR-001/SC-003 (zero change when off) and keeps the new behavior discoverable and consistent. + +**Alternatives considered**: folding the logic into `modules/packages.nix` — rejected; a dedicated module keeps concerns and assertions isolated and testable, consistent with the one-file-per-capability convention. + +--- + +## Decision 7 — Testing strategy + +**Decision**: Two layers. +1. **Pure eval tests** for `lib/extract-devshell-packages.nix` (extends `tests/lib.nix`): given a fixture flake, assert the extracted package set, the multi-shell selection, and the error cases (missing shell name → throws). +2. **NixOS VM test** (`tests/devshell-packages.nix`, registered in `tests/default.nix`): build a VM with the feature enabled against the `tests/fixtures/devshell-project` flake (devShell declaring a distinctive tool, e.g. `cowsay`), boot it, and assert the tool is on `PATH` **without network** — directly validating SC-001 and User Story 1. + +**Rationale**: Matches the project's existing test conventions (`pkgs.nixosTest` + `tests/lib.nix`, fixtures under `tests/fixtures/`). The eval tests give fast feedback on the helper's logic and error paths; the VM test proves the end-to-end, offline-availability outcome the feature exists for. + +**Note on test purity**: the fixture flake is referenced as a **path input** within the test, keeping the VM test hermetic (no network needed to obtain the project flake itself), which also lets the offline-boot assertion be meaningful. + +--- + +## Resolved unknowns summary + +| Unknown | Resolution | +|---|---| +| Make project flake visible at eval time | Consumer passes locked flake **input** as a Nix value (pure); `getFlake`/URL is a documented impure fallback | +| Get packages from a devShell | Union of `build/nativeBuild/propagated*` inputs of the `mkShell` derivation, de-duped → `environment.systemPackages` | +| Host vs guest architecture | Extract `devShells..` using the existing host→guest mapping | +| No flake / no devShell / eval error | Assertion or `throw` with a clear message — never a silent no-op (FR-006/FR-007) | +| Multiple devShells | `name` option, default `"default"`; missing name → clear failure listing available names | +| Opt-in & backward compat | `devShellPackages.enable = false` default; dedicated module mirroring existing capability pattern | +| Testing | Pure eval tests for the helper + an offline-boot nixosTest asserting the tool is on `PATH` | + +No `NEEDS CLARIFICATION` items remain. diff --git a/specs/001-devshell-package-prebuild/spec.md b/specs/001-devshell-package-prebuild/spec.md new file mode 100644 index 0000000..0dc745e --- /dev/null +++ b/specs/001-devshell-package-prebuild/spec.md @@ -0,0 +1,113 @@ +# Feature Specification: Pre-install devShell Packages at Build Time + +**Feature Branch**: `001-devshell-package-prebuild` + +**Created**: 2026-06-25 + +**Status**: Draft + +**Input**: User description: "When an agentbox module is created, if the user specifies during build time, the build should download the remote repository (or look at the mounted repository, or whatever the installation method is) and detect whether it contains a Nix flake with a devShell. If a devShell is present, extract the packages declared in that devShell and install them into the virtual machine during build time. That way, when users enter the virtual machine and activate the dev flake, all dependencies are already installed in the VM and they don't have to download and install them." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Pre-bake project devShell dependencies into the VM (Priority: P1) + +A developer (or an AI coding agent) configures an agentbox VM for a project whose repository contains a Nix flake that defines a development shell (`devShell`). They opt in to the pre-install behavior when defining the VM. When the VM is built, agentbox locates the project's flake, reads the packages the devShell declares, and bakes those packages into the VM image as globally installed packages. When the user later enters the VM, every dependency is already present on the command path — they don't need to download anything or even activate the flake to use the tools. + +**Why this priority**: This is the entire point of the feature — turning a slow, network-dependent first `nix develop` inside the VM into tools that are simply present and ready the moment the VM boots, offline included. Without this, the feature delivers no value. + +**Independent Test**: Configure a VM with the pre-install option enabled and a project flake whose devShell declares a known, distinctive package. Build the VM, boot it with networking disabled, and confirm the declared package is available on the command path without any downloads. + +**Acceptance Scenarios**: + +1. **Given** a project repository containing a flake with a devShell that declares packages, **When** the user enables the pre-install option and builds the VM, **Then** the build completes successfully and the devShell's declared packages are present in the VM. +2. **Given** a built VM whose devShell packages were pre-installed, **When** the user enters the VM, **Then** the declared packages are available on the command path without the user activating the project's flake. +3. **Given** a built VM whose devShell packages were pre-installed, **When** the user enters the VM with no network access, **Then** the declared packages are available and, if the user does activate the project's development environment, activation fetches and builds nothing. + +--- + +### User Story 2 - Opt-in, with safe default behavior (Priority: P2) + +A developer creates an agentbox VM for a project that either has no flake, has a flake without a devShell, or is not one they want pre-baked. They expect agentbox to behave exactly as it does today unless they explicitly opt in to the pre-install behavior. When they do opt in but the project has no detectable devShell, the build should not silently produce a broken or confusingly incomplete VM. + +**Why this priority**: The behavior must be backward compatible and predictable. Existing users and configurations must be unaffected, and opting in must never make a VM harder to reason about than not opting in. + +**Independent Test**: Build a VM for a project with no flake (a) without the option and (b) with the option enabled; confirm (a) is unchanged from today and (b) completes with a clear, observable indication that no devShell was found. + +**Acceptance Scenarios**: + +1. **Given** the pre-install option is not enabled, **When** the VM is built, **Then** behavior is identical to current agentbox behavior and no devShell inspection occurs. +2. **Given** the pre-install option is enabled but the project contains no flake or no devShell, **When** the VM is built, **Then** the build outcome is unambiguous to the user (either a clear failure or a clear, surfaced notice that nothing was pre-installed) rather than a silent no-op. + +--- + +### User Story 3 - Visibility into what was pre-installed (Priority: P3) + +A developer who enabled the pre-install behavior wants to confirm which packages were detected from the devShell and baked into the VM, so they can trust the result and debug mismatches (for example, when activation still tries to fetch something they expected to be cached). + +**Why this priority**: Trust and debuggability. Pre-installing dependencies that the user cannot see or verify makes failures hard to diagnose, but the core value (faster activation) is delivered without it. + +**Independent Test**: Enable the option for a project with a known devShell, build the VM, and confirm there is a discoverable record of which packages were extracted and pre-installed. + +**Acceptance Scenarios**: + +1. **Given** the pre-install option is enabled and a devShell was detected, **When** the VM is built, **Then** the set of packages that were extracted and pre-installed is observable to the user. + +--- + +### Edge Cases + +- **No flake present**: The project source contains no flake at the expected location → resolved per Story 2 (clear failure or surfaced notice; never a silent broken VM). +- **Flake without a devShell**: A flake exists but declares no development shell → treated the same as "no devShell detected." +- **Multiple devShells**: A flake declares more than one development shell (a default plus named variants) → the build needs a defined rule for which shell(s) to extract. +- **devShell that cannot be evaluated**: The flake's devShell references a definition that fails to evaluate or resolve → the build must fail clearly rather than partially baking the VM. +- **Source not available at build time**: The project uses a delivery method (`mount` or `copy`) whose contents are only populated after the VM boots → the build must clearly report that pre-install is unavailable for that delivery method (only `git` is supported). +- **Private repository**: The project flake lives in a private repository that requires credentials to fetch at build time → the build needs a defined, secure behavior (succeed with provided credentials, or fail clearly). +- **Large dependency set**: The devShell declares a very large dependency closure → the VM image grows accordingly; the user should be able to anticipate this. +- **Stale pre-install**: The project's devShell changes after the VM was built → the pre-installed set reflects the state at build time until the VM is rebuilt. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST provide an explicit, opt-in setting on an agentbox VM configuration that enables pre-installing a project's devShell packages at build time. When the setting is not enabled, build behavior MUST be unchanged from current behavior. +- **FR-002**: When the setting is enabled, the system MUST locate the project's flake from the configured project source at build time and determine whether that flake declares a development shell. +- **FR-003**: When a development shell is detected, the system MUST determine the set of packages that shell makes available. +- **FR-004**: When a development shell is detected, the system MUST add its declared packages to the VM's globally installed packages so they are available on the command path inside the VM immediately, without the user needing to activate the project's flake. Because these packages are baked into the VM image, the user MUST NOT need to download or build them after boot, and activating the project's development environment MUST NOT re-fetch or rebuild them. +- **FR-005**: The pre-install behavior MUST be supported for the `git` project source type, where the repository can be fetched and evaluated at build time. For project source types whose contents are only populated after the VM boots (`mount` and `copy`), the system MUST clearly report that pre-install is unavailable rather than silently skipping it. +- **FR-006**: When the setting is enabled but no flake or no devShell is detected, the system MUST surface this outcome to the user unambiguously rather than producing a silent no-op or a confusingly incomplete VM. +- **FR-007**: When a devShell is detected but its packages cannot be evaluated or resolved, the system MUST fail the build with a clear error rather than producing a partially pre-installed VM. +- **FR-008**: When a flake declares more than one development shell, the system MUST apply a defined, documented rule for which shell is used (defaulting to the flake's default/primary development shell) and MUST allow the user to select a specific named shell. +- **FR-009**: The system MUST make the set of packages it extracted and pre-installed observable to the user, so they can verify and debug the result. +- **FR-010**: The packages activating the development environment relies on MUST match the packages that were pre-installed, so that activation does not re-fetch equivalent dependencies due to a mismatch. +- **FR-011**: Enabling the setting MUST NOT change the VM's runtime project-source behavior (mount/copy/git remain as configured); it only affects what is pre-baked into the image at build time. + +### Key Entities *(include if data involved)* + +- **Project flake**: The Nix flake belonging to the user's project, identified within the project source. Source of the development shell definition. +- **devShell (development shell)**: The development environment declared by the project flake. May be a single default shell or one of several named shells. Holds the list of packages a developer needs to work on the project. +- **Extracted package set**: The concrete collection of packages derived from the selected devShell, which is the unit that gets pre-installed into the VM and reported back to the user. +- **VM configuration / agentbox module**: The declarative definition of the VM, where the user opts in to the pre-install behavior and (optionally) selects which devShell to use. +- **Built VM image**: The artifact produced by the build, into which the extracted package set is baked. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: With the option enabled for a project whose devShell declares packages, the declared packages are available on the command path inside the freshly booted VM with zero packages downloaded or built (verifiable by booting with networking disabled). +- **SC-002**: Getting the project's tools ready to use inside a pre-installed VM is substantially faster than in a VM built without the option, because the tools are present at boot instead of being downloaded/built on first use. +- **SC-003**: 100% of builds where the option is left disabled produce results identical to current agentbox behavior (no regression). +- **SC-004**: When the option is enabled and a devShell is present, 100% of the packages the devShell declares are present in the VM after build (none silently dropped). +- **SC-005**: When the option is enabled and no devShell is found, the user can determine that fact from the build outcome in 100% of cases (no silent no-op). +- **SC-006**: A user can enumerate the exact set of packages that were pre-installed from the devShell after a successful build. + +## Assumptions + +- The behavior is **opt-in**; the default agentbox build is unchanged. This preserves backward compatibility for all existing configurations. +- The extracted devShell packages are installed as **globally available** packages in the VM, so they are on the command path the moment the VM boots; the user does not need to activate the project's flake to use them. Activating the flake remains possible and, because the packages are already baked in, needs no network access. +- Pre-install is supported only for the **`git`** project source type at build time. For `mount` and `copy` sources, whose contents are populated only after boot, the build clearly reports that pre-install is unavailable. +- When multiple development shells exist and the user does not select one, the flake's **default/primary** development shell is used. +- If the project's devShell changes after the VM is built, the pre-installed contents reflect the devShell as it was **at build time**; refreshing requires a rebuild. Keeping the pre-installed set continuously in sync with a changing devShell is out of scope. +- The size of the VM image will grow in proportion to the devShell's dependency closure; users opting in accept this trade-off in exchange for faster, offline activation. +- For private project repositories, fetching the flake at build time relies on credentials the user already supplies through existing agentbox mechanisms; introducing new credential-handling flows is out of scope for this feature. +- Detecting and pre-installing dependencies that are **not** expressed through the project's flake devShell (e.g. language-ecosystem lockfiles, Dockerfiles) is out of scope; this feature is specifically about the Nix flake devShell. diff --git a/specs/001-devshell-package-prebuild/tasks.md b/specs/001-devshell-package-prebuild/tasks.md new file mode 100644 index 0000000..371de8a --- /dev/null +++ b/specs/001-devshell-package-prebuild/tasks.md @@ -0,0 +1,200 @@ +--- +description: "Task list for Pre-install devShell Packages at Build Time" +--- + +# Tasks: Pre-install devShell Packages at Build Time + +**Input**: Design documents from `specs/001-devshell-package-prebuild/` + +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md + +**Tests**: Test tasks ARE included. Rationale: the plan's Constitution gate ("Tested via NixOS VM framework") and research Decision 7 make tests part of this feature's deliverable, and every existing agentbox capability ships with a `tests/` entry. Tests here are not strict TDD-first; they validate each story's behavior. + +**Organization**: Tasks are grouped by user story (US1 = P1, US2 = P2, US3 = P3) so each story is an independently testable increment. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks) +- **[Story]**: Which user story this task belongs to (US1, US2, US3) + +## Path Conventions + +This is a **Nix module library** (no `src/` tree). Code lives in `lib/`, `modules/`, `config.nix`; tests in `tests/` (+ `tests/fixtures/`); consumer examples in `examples/`. All paths below are repository-relative. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Defaults and a reusable, hermetic test fixture that the rest of the work builds on. + +- [x] T001 [P] Add a `project.devShellPackages` defaults block (`enable = false; flake = null; name = "default";`) to `config.nix` (mirrors existing default groups; see data-model.md). +- [x] T002 [P] Create a hermetic devShell test fixture at `tests/fixtures/devshell-project/default.nix` — a `{ pkgs, system }:` function returning `{ devShells.${system} = { default = pkgs.mkShell { packages = [ pkgs.cowsay ]; }; ci = pkgs.mkShell { packages = [ pkgs.hello ]; }; empty = pkgs.mkShell { }; }; }`. Distinctive tools (`cowsay`, `hello`) make PATH assertions unambiguous; reused by eval and VM tests with no network. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: The pure extraction helper, option surface, and plumbing that EVERY user story depends on. No user-visible behavior yet. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [x] T003 [P] Implement the pure helper `lib/extract-devshell-packages.nix` per `contracts/extraction.md`: resolve `flake.devShells.${system}.${name}`; return the de-duplicated union of `buildInputs ++ nativeBuildInputs ++ propagatedBuildInputs ++ propagatedNativeBuildInputs`; `throw` clear messages on missing system, missing `name` (listing available names), and empty package set. No build, no `--impure`. +- [x] T004 Export `extractDevShellPackages` from `lib/default.nix` (depends on T003). +- [x] T005 [P] Resolve the guest system inside `modules/devshell-packages.nix` via `pkgs.stdenv.hostPlatform.system` (the module's `pkgs` is always the guest's package set, so this is the guest system on macOS hosts too — and unlike `specialArgs` threading it also works when the module is used directly in `pkgs.testers.nixosTest`). Refinement of research Decision 3; `lib/mk-dev-vm.nix` left unchanged. +- [x] T006 [P] Declare the `agentbox.project.devShellPackages` options (`enable`, `flake` as `nullOr unspecified`, `name`) in `modules/default.nix`, backed by the `config.nix` defaults from T001 (see contracts/module-options.md). + +**Checkpoint**: Helper is unit-testable; options exist and evaluate; nothing yet alters the image. + +--- + +## Phase 3: User Story 1 - Pre-bake devShell dependencies into the VM (Priority: P1) 🎯 MVP + +**Goal**: With the feature enabled and a project flake provided, the devShell's packages are baked into the VM as global packages — present on `PATH` at boot, offline. + +**Independent Test**: Build a VM with `devShellPackages.enable = true` against the fixture's `default` shell, boot with networking disabled, and confirm `cowsay` is on `PATH` with zero downloads. + +### Tests for User Story 1 + +- [x] T007 [P] [US1] Create `tests/devshell-packages-eval.nix` with a pure eval test: feeding the T002 fixture `default` shell through `extractDevShellPackages` returns a set containing `cowsay` (properties P1/P2/P7 in contracts/extraction.md). Depends on T003/T004. +- [x] T008 [P] [US1] Create the VM test `tests/devshell-packages.nix`: a `pkgs.nixosTest` that imports `self.nixosModules.default`, sets `agentbox.project.devShellPackages = { enable = true; flake = ; }`, boots, and asserts `cowsay` is on the dev user's `PATH` (`machine.succeed`) — validating SC-001/SC-004. +- [x] T009 [US1] Register `devshell-packages` (VM) and `devshell-packages-eval` in `tests/default.nix` (depends on T007, T008). + +### Implementation for User Story 1 + +- [x] T010 [US1] Create `modules/devshell-packages.nix`: `lib.mkIf cfg.project.devShellPackages.enable`, read `guestSystem`, call `extractDevShellPackages { flake; system = guestSystem; inherit name; }`, and append the result to `environment.systemPackages` (without clobbering base packages or `agentbox.packages.extra`); add the file to `imports` in `modules/default.nix`. Depends on T003, T004, T005, T006. (Realizes FR-002/003/004, FR-010, FR-011.) +- [x] T011 [US1] Run the quickstart happy-path validation (build the VM, boot offline, confirm the tool on `PATH`) per quickstart.md §3; fix any wiring gaps. Depends on T009, T010. + +**Checkpoint**: MVP — pre-installed devShell tools are available at boot, offline. Feature is demoable. + +--- + +## Phase 4: User Story 2 - Opt-in, with safe default behavior (Priority: P2) + +**Goal**: Disabled by default (zero change to existing builds); when enabled but misconfigured (no build-time flake / mount-copy / missing shell / empty shell), the build fails clearly instead of producing a confusing VM. + +**Independent Test**: (a) Build with the option off → identical to baseline. (b) Enable with `flake = null` → build fails with a message naming `devShellPackages.flake`. (c) `name = "nope"` → build fails listing available shells. + +### Implementation for User Story 2 + +- [x] T012 [US2] Add a module-level assertion in `modules/devshell-packages.nix`: when `enable == true && flake == null`, fail with a clear message naming `devShellPackages.flake` and explaining the build-time-flake / git-only requirement (mount/copy unsupported). Depends on T010. (Realizes FR-005/FR-006, contract C3.) + +### Tests for User Story 2 + +- [x] T013 [US2] Extend `tests/devshell-packages-eval.nix` with failure-mode eval tests: missing system throws; missing `name` throws and lists available names; the fixture `empty` shell throws "no installable packages" (properties P4/P5/P6, FR-006/FR-007). Depends on T007. +- [x] T014 [US2] Add a default-off regression test (eval/build assertion that with `enable = false` the module contributes nothing and `systemPackages` matches baseline) to `tests/devshell-packages-eval.nix` (SC-003, FR-001). Depends on T013. +- [x] T015 [US2] Add a test that `enable = true` with `flake = null` fails the build with the expected assertion message (contract C3) to `tests/devshell-packages-eval.nix`. Depends on T012, T013. +- [x] T016 [US2] Run the quickstart failure-mode validations (table in quickstart.md §4) and confirm each produces the documented clear failure. Depends on T012, T015. + +**Checkpoint**: Feature is safe to ship — invisible when off, loud and clear when misconfigured. + +--- + +## Phase 5: User Story 3 - Visibility into what was pre-installed (Priority: P3) + +**Goal**: The user can enumerate exactly which packages were extracted and baked in — at build time and from inside the running VM. + +**Independent Test**: Build with the feature on; confirm the build output lists the resolved package names and a manifest inside the VM lists the same set (including `cowsay`). + +### Implementation for User Story 3 + +- [x] T017 [US3] In `modules/devshell-packages.nix`, emit the resolved package names at build time via `builtins.trace`/`lib.warn` (FR-009, contract C8). Depends on T010. +- [x] T018 [US3] In `modules/devshell-packages.nix`, write a readable manifest into the image listing the pre-installed package names (e.g. `environment.etc."agentbox/devshell-packages".text`). Depends on T017. (SC-006.) + +### Tests for User Story 3 + +- [x] T019 [US3] Extend `tests/devshell-packages.nix` to assert the manifest file exists in the booted VM and lists the fixture tool (`cowsay`). Depends on T008, T018. +- [x] T020 [US3] Run the quickstart observability validation (quickstart.md §3, manifest read). Depends on T018, T019. + +**Checkpoint**: All three stories independently functional. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [x] T021 [P] Update `README.md`: document `agentbox.project.devShellPackages` (option table row + a dedicated section), the flake-input wiring, the git-only/build-time limitation, image-size trade-off, the impure `getFlake` fallback, and add `devshell-packages` to the "Available tests" list. +- [x] T022 [P] Create the example consumer flake `examples/devshell-prebuild-git/flake.nix` (+ `flake.lock`) wiring a project as an input and enabling `devShellPackages` (mirror `examples/custom-tools-git-clone` style; per quickstart.md §1). +- [x] T023 Code-consistency pass over `lib/extract-devshell-packages.nix` and `modules/devshell-packages.nix` (naming, comments, dedup logic match existing module conventions). Depends on T010, T018. +- [x] T024 Run the full suite `nix flake check` and the complete quickstart end-to-end on a clean checkout; confirm all `checks` pass. Depends on all prior tasks. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — start immediately. +- **Foundational (Phase 2)**: Depends on Setup (uses T001 defaults). BLOCKS all user stories. +- **User Stories (Phase 3–5)**: All depend on Foundational. US1 is the MVP. US2 and US3 build on US1's module file (they extend `modules/devshell-packages.nix`), so they layer on after US1 rather than in full parallel. +- **Polish (Phase 6)**: Depends on the desired user stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: After Foundational. Self-contained MVP. +- **US2 (P2)**: After US1 (adds an assertion to and tests around US1's module). Independently testable via its own failure-mode tests. +- **US3 (P3)**: After US1 (adds trace + manifest to US1's module). Independently testable via the manifest assertion. + +### Within Each User Story + +- Foundational helper/options before module wiring. +- Module behavior before its tests can pass; eval tests can be authored alongside the helper. +- Story complete (checkpoint) before moving to the next priority. + +### Parallel Opportunities + +- Setup: **T001 and T002** in parallel (different files). +- Foundational: **T003, T005, T006** in parallel (different files); T004 after T003. +- US1: **T007 and T008** (two new test files) in parallel; then T009, T010, T011. +- Polish: **T021 and T022** in parallel (README vs example). +- Sequential within a file: T012/T017/T018 all edit `modules/devshell-packages.nix` (not parallel); T013/T014/T015 all edit `tests/devshell-packages-eval.nix` (not parallel). + +--- + +## Parallel Example: Foundational Phase + +```bash +# After Setup, launch the independent foundational files together: +Task: "Implement lib/extract-devshell-packages.nix (pure helper)" # T003 +Task: "Thread guestSystem via specialArgs in lib/mk-dev-vm.nix" # T005 +Task: "Declare devShellPackages options in modules/default.nix" # T006 +# Then: T004 (export in lib/default.nix) once T003 lands. +``` + +## Parallel Example: User Story 1 Tests + +```bash +Task: "Eval test for extractDevShellPackages in tests/devshell-packages-eval.nix" # T007 +Task: "Offline-boot VM test in tests/devshell-packages.nix" # T008 +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 only) + +1. Phase 1: Setup (T001–T002) +2. Phase 2: Foundational (T003–T006) — **blocks everything** +3. Phase 3: User Story 1 (T007–T011) +4. **STOP and VALIDATE**: build + boot offline; confirm devShell tools on `PATH`. This is a shippable MVP. + +### Incremental Delivery + +1. Setup + Foundational → plumbing ready +2. US1 → pre-install works (MVP) → demo +3. US2 → safe default + clear failures → demo +4. US3 → observability/manifest → demo +5. Polish → docs, example, full `nix flake check` + +### MVP Scope + +**User Story 1 (T001–T011)** delivers the core value on its own: devShell packages baked into the VM, available at boot, offline. US2 (safety/clarity) and US3 (visibility) are valuable hardening layers but not required to demonstrate the feature. + +--- + +## Notes + +- `[P]` = different files, no incomplete dependencies. +- `[Story]` labels map tasks to spec.md user stories for traceability. +- US2 and US3 deliberately extend US1's module file; sequence them after US1 rather than forcing artificial file-level parallelism. +- Tests use **synthetic flake-value attrsets** (the T002 fixture) so both eval and VM tests stay hermetic and the offline-boot assertion is meaningful — no project repo is fetched during tests. +- Commit after each task or logical group; stop at any checkpoint to validate a story independently. diff --git a/tests/default.nix b/tests/default.nix index 56df300..de596f9 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -39,5 +39,14 @@ # Cursor CLI tests (CU1-CU2) tools-cursor = import ./tools-cursor.nix { inherit pkgs self; }; + + # devShellPackages build-time pre-install: VM integration test (DS1-DS2) + devshell-packages = import ./devshell-packages.nix { inherit pkgs self; }; + + # devShellPackages extraction helper: pure eval tests + devshell-packages-eval = import ./devshell-packages-eval.nix { inherit pkgs self; }; + + # devShellPackages module behavior: NixOS eval tests (default-off, null-flake assertion) + devshell-packages-module-eval = import ./devshell-packages-module-eval.nix { inherit pkgs self; }; } diff --git a/tests/devshell-packages-eval.nix b/tests/devshell-packages-eval.nix new file mode 100644 index 0000000..953c970 --- /dev/null +++ b/tests/devshell-packages-eval.nix @@ -0,0 +1,39 @@ +# devShellPackages extraction helper - pure evaluation tests +# Run: nix build .#checks.x86_64-linux.devshell-packages-eval --print-build-logs +# +# Tests the pure helper lib/extract-devshell-packages.nix against the hermetic +# fixture: happy-path extraction (US1) and the fail-loud error cases (US2). +{ pkgs, self }: + +let + lib = pkgs.lib; + system = pkgs.stdenv.hostPlatform.system; + extract = self.lib.extractDevShellPackages; + fixture = import ./fixtures/devshell-project { inherit pkgs system; }; + + hasPkg = name: list: lib.any (p: lib.getName p == name) list; + + # True when calling extract with these args throws (fail-loud cases). + throwsFor = args: !(builtins.tryEval (lib.deepSeq (extract args) true)).success; + + checks = { + # US1 - happy path + default_has_cowsay = hasPkg "cowsay" (extract { flake = fixture; inherit system; name = "default"; }); + ci_has_hello = hasPkg "hello" (extract { flake = fixture; inherit system; name = "ci"; }); + + # US2 - fail-loud error cases (FR-006 / FR-007) + missing_name_throws = throwsFor { flake = fixture; inherit system; name = "nope"; }; + missing_system_throws = throwsFor { flake = { devShells = { }; }; inherit system; name = "default"; }; + empty_shell_throws = throwsFor { flake = fixture; inherit system; name = "empty"; }; + }; + + failed = lib.attrNames (lib.filterAttrs (_: v: v != true) checks); +in +assert lib.assertMsg (failed == [ ]) + "devshell-packages-eval failed checks: ${lib.concatStringsSep ", " failed}"; +pkgs.runCommand "agentbox-devshell-packages-eval" { } '' + { + echo "agentbox devShell extraction eval checks:" + ${lib.concatStringsSep "\n" (map (n: "echo ' PASS - ${n}'") (lib.attrNames checks))} + } > $out +'' diff --git a/tests/devshell-packages-module-eval.nix b/tests/devshell-packages-module-eval.nix new file mode 100644 index 0000000..a312ef4 --- /dev/null +++ b/tests/devshell-packages-module-eval.nix @@ -0,0 +1,50 @@ +# devShellPackages module - NixOS evaluation tests (no VM build) +# Run: nix build .#checks.x86_64-linux.devshell-packages-module-eval --print-build-logs +# +# Verifies module-level behavior without booting a VM: +# - default off: the module contributes nothing (no manifest) - SC-003 / FR-001 +# - enabled with a null flake: a failing assertion is raised - FR-005 / FR-006 +{ pkgs, self }: + +let + lib = pkgs.lib; + system = pkgs.stdenv.hostPlatform.system; + fixture = import ./fixtures/devshell-project { inherit pkgs system; }; + + # Evaluate the real production assembly (imports qemu-vm + all agentbox modules) + # so module behavior is tested exactly as it is built, without booting a VM. + evalCfg = extra: (self.lib.mkDevVm { + hostSystem = system; + modules = [ extra { agentbox.project.source.required = false; } ]; + }).config; + + # Default off: module is inert. + offCfg = evalCfg { }; + defaultOffNoManifest = !(offCfg.environment.etc ? "agentbox/devshell-packages"); + + # Enabled with no flake: a failing assertion must be present. + nullFlakeCfg = evalCfg { agentbox.project.devShellPackages.enable = true; }; + nullFlakeFailsAssertion = lib.any (a: !a.assertion) nullFlakeCfg.assertions; + + # Enabled with the fixture flake: packages baked into systemPackages + manifest. + onCfg = evalCfg { agentbox.project.devShellPackages = { enable = true; flake = fixture; }; }; + onSystemHasCowsay = lib.any (p: lib.getName p == "cowsay") onCfg.environment.systemPackages; + onManifestHasCowsay = lib.hasInfix "cowsay" onCfg.environment.etc."agentbox/devshell-packages".text; + + checks = { + default_off_no_manifest = defaultOffNoManifest; + null_flake_fails_assertion = nullFlakeFailsAssertion; + enabled_systempackages_has_cowsay = onSystemHasCowsay; + enabled_manifest_has_cowsay = onManifestHasCowsay; + }; + + failed = lib.attrNames (lib.filterAttrs (_: v: v != true) checks); +in +assert lib.assertMsg (failed == [ ]) + "devshell-packages-module-eval failed checks: ${lib.concatStringsSep ", " failed}"; +pkgs.runCommand "agentbox-devshell-packages-module-eval" { } '' + { + echo "agentbox devShellPackages module eval checks:" + ${lib.concatStringsSep "\n" (map (n: "echo ' PASS - ${n}'") (lib.attrNames checks))} + } > $out +'' diff --git a/tests/devshell-packages.nix b/tests/devshell-packages.nix new file mode 100644 index 0000000..e47b2df --- /dev/null +++ b/tests/devshell-packages.nix @@ -0,0 +1,49 @@ +# devShellPackages build-time pre-install - VM integration tests +# Tests: DS1-DS2 +# Run: nix build .#checks.x86_64-linux.devshell-packages --print-build-logs +# +# Boots a VM built with devShellPackages enabled against the hermetic fixture +# and asserts the devShell's tool is on PATH (offline, no `nix develop`) and that +# the pre-install manifest is present. +{ pkgs, self }: + +let + fixture = import ./fixtures/devshell-project { inherit pkgs; system = pkgs.stdenv.hostPlatform.system; }; +in +pkgs.testers.nixosTest { + name = "agentbox-devshell-packages"; + + nodes.machine = { config, pkgs, ... }: { + imports = [ self.nixosModules.default ]; + + agentbox.vm.hostname = "test-vm"; + agentbox.user.name = "dev"; + + # No project source needed for this test - we only exercise build-time + # pre-install of the devShell packages. + agentbox.project.source.required = false; + + agentbox.project.devShellPackages = { + enable = true; + flake = fixture; + name = "default"; + }; + }; + + testScript = '' + machine.start() + machine.wait_for_unit("multi-user.target") + + # DS1: devShell tool baked in and on PATH, no network, no `nix develop` (US1 / SC-001) + machine.succeed("which cowsay") + machine.succeed("cowsay agentbox | grep -q agentbox") + print("DS1: devShell package on PATH - PASSED") + + # DS2: pre-install manifest lists the baked-in packages (US3 / SC-006 / FR-009) + machine.succeed("test -f /etc/agentbox/devshell-packages") + machine.succeed("grep -q '^cowsay$' /etc/agentbox/devshell-packages") + print("DS2: pre-install manifest present - PASSED") + + print("All devshell-packages tests passed!") + ''; +} diff --git a/tests/fixtures/devshell-project/default.nix b/tests/fixtures/devshell-project/default.nix new file mode 100644 index 0000000..451f2a9 --- /dev/null +++ b/tests/fixtures/devshell-project/default.nix @@ -0,0 +1,25 @@ +# Hermetic devShell test fixture for agentbox devShellPackages tests. +# +# Returns a flake-shaped value (an attrset with `devShells.`) that the +# `agentbox.project.devShellPackages.flake` option accepts directly. Using a +# synthetic value (rather than a real on-disk flake fetched over the network) +# keeps both the eval tests and the offline-boot VM test fully hermetic. +# +# Shells: +# default - declares a distinctive tool (cowsay) for PATH assertions +# ci - a named shell (hello) to exercise `name` selection +# empty - declares no packages, to exercise the "no installable packages" error +{ pkgs, system }: +{ + devShells.${system} = { + default = pkgs.mkShell { + packages = [ pkgs.cowsay ]; + }; + + ci = pkgs.mkShell { + packages = [ pkgs.hello ]; + }; + + empty = pkgs.mkShell { }; + }; +}