feat(agents): make Pi a first-class agent
This commit is contained in:
@@ -1,72 +0,0 @@
|
||||
# NixOS Dotfiles
|
||||
|
||||
A single flake that builds every machine the user owns — laptop, desktop, and three servers — from one shared, modular configuration.
|
||||
|
||||
## Language
|
||||
|
||||
**Host**:
|
||||
One physical machine the flake builds a NixOS configuration for. Each Host has a directory under `hosts/` holding its machine-specific `hardware-configuration.nix` and its choice of enabled Modules.
|
||||
_Avoid_: machine, node, system, box
|
||||
|
||||
**Module**:
|
||||
A single `.nix` feature file under `modules/` that declares an `enable` option and the configuration it turns on. Every Module is always imported but stays inert until a Host enables it.
|
||||
_Avoid_: component, package, plugin
|
||||
|
||||
**Guest**:
|
||||
A reusable, machine-independent definition under `guests/` that bundles the Modules it runs inside an isolated NixOS instance, realized on a Host as a nested container.
|
||||
Its `backend` defaults to `container` (systemd-nspawn).
|
||||
`microvm` is a reserved backend value that is not yet built.
|
||||
A Guest follows the Modules convention wholesale: the file-or-folder layout, the Namespace convention (`guests/media/jellyfin.nix` declares `guests.media.jellyfin`), and the Enable convention (imported always, inert until a Host sets its `enable`).
|
||||
The Guest owns only its interior Modules.
|
||||
The Host that enables it supplies the machine-specific placement, such as its VLAN, pool mounts, and resource caps.
|
||||
_Avoid_: container, VM, instance, LXC, appliance
|
||||
|
||||
**Skeleton**:
|
||||
The flake's plumbing — the Auto-loader, the helper lib, the flake inputs/overlays, and the shared base config — as distinct from the Modules that sit on top of it.
|
||||
The shared base config splits in two: a host base (`system.nix`) and a slim guest-base that every nested Guest stands on.
|
||||
The host base carries host-only machinery, such as the bootloader, hardware profile, host identity, and the boot and garbage-collection timers.
|
||||
The guest-base carries only what a nested service needs and auto-enables the `toolkit` bundle and `modules.ssh`.
|
||||
Both include the primary user, home-manager, and the shared overlays.
|
||||
_Avoid_: framework, core, base, scaffolding
|
||||
|
||||
**toolkit**:
|
||||
A deliberate bundle Module (`modules.toolkit`) that turns on the baseline interactive environment — fish, tmux, nvim, git, direnv — as one unit, so any Host or Guest shell feels identical.
|
||||
The guest-base auto-enables it; a Host enables it explicitly like any other Module, keeping the Host a full checklist.
|
||||
Distinct from the grouping-directory enables ADR 0004 rejected, since this is a bundle wanted as a unit.
|
||||
_Avoid_: base, workstation, essentials
|
||||
|
||||
**Auto-loader**:
|
||||
The lib code that recursively discovers and imports every Module under `modules/`, every Host under `hosts/`, and every Guest under `guests/`, so new files wire themselves in without manual `imports` edits.
|
||||
_Avoid_: loader, importer, scanner
|
||||
|
||||
**Enable convention**:
|
||||
The rule that every Module is imported unconditionally and guards its own body with `mkIf config.modules.<path>.enable`, so a Host reads as a checklist of `enable = true` flags.
|
||||
_Avoid_: feature flag, toggle, opt-in
|
||||
|
||||
**Namespace convention**:
|
||||
The rule that a Module's option path mirrors its directory path under `modules/`, so a file's location is its namespace.
|
||||
A file whose name matches its enclosing directory is that directory's index node, declaring the directory's own segment rather than a doubled one.
|
||||
A directory with no such file is a pure namespace prefix that carries no aggregate enable.
|
||||
_Avoid_: option tree, module path, config key
|
||||
|
||||
**admin identity**:
|
||||
The age identity held only in the operator's password manager, never committed, that is a recipient of every secrets file.
|
||||
It is the recovery path for any wiped machine and the credential that authorizes registering a new host.
|
||||
_Avoid_: master key, admin key, root key
|
||||
|
||||
**host identity**:
|
||||
The dedicated age key on one machine's encrypted root, generated there and never transmitted, that decrypts that machine's own secrets and the shared file.
|
||||
Deliberately distinct from the machine's SSH host key.
|
||||
_Avoid_: machine key, node key, host key
|
||||
|
||||
**secrets file**:
|
||||
One sops-encrypted file in the repo, encrypted to the admin identity plus whichever hosts may read it. Either shared across every host or specific to one.
|
||||
_Avoid_: vault, secret store, keyring
|
||||
|
||||
**unstable overlay**:
|
||||
The overlay exposing `nixpkgs-unstable` packages as `unstable.<name>`, used to pull an individual package fresher than the `nixos-unstable` base.
|
||||
_Avoid_: bleeding-edge, latest
|
||||
|
||||
**stable overlay**:
|
||||
The overlay exposing the latest stable release (`nixos-26.05`) as `stable.<name>`, used to pin an individual package to the rock-solid release from the `nixos-unstable` base.
|
||||
_Avoid_: LTS, release channel
|
||||
1
.claude/CONTEXT.md
Symbolic link
1
.claude/CONTEXT.md
Symbolic link
@@ -0,0 +1 @@
|
||||
../.agents/CONTEXT.md
|
||||
1
.claude/adr
Symbolic link
1
.claude/adr
Symbolic link
@@ -0,0 +1 @@
|
||||
../.agents/adr
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
status: superseded by ADR-0002
|
||||
---
|
||||
|
||||
# Use sops-nix for secrets
|
||||
|
||||
The repo is public, so no secret — including password hashes and the WireGuard/ProtonVPN key — may be committed in plaintext. We manage all secrets with **sops-nix**: encrypted into the repo and decrypted per-host at activation via an age key derived from each machine's SSH host key.
|
||||
|
||||
We chose sops-nix over agenix for its multi-recipient encryption (one secret readable by both a host and the admin laptop) and its grouped-file editing workflow, which scale better across the planned five hosts with a mix of shared and per-host secrets. The cost is slightly more upfront machinery than agenix's one-file-per-secret model.
|
||||
|
||||
## Consequences
|
||||
|
||||
- User/root passwords use `hashedPasswordFile` backed by a sops secret, never a committed hash.
|
||||
- Each new host must have its SSH host public key registered as a recipient before it can decrypt its secrets.
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
status: accepted
|
||||
---
|
||||
|
||||
# Two-tier age identities, secrets in the public repo
|
||||
|
||||
Secrets are encrypted with sops-nix into this public repo and decrypted by a two-tier set of age identities: one **admin identity**, stored only in Proton Pass and never committed, which is a recipient of every secrets file; and one **host identity** per machine, a dedicated age key generated on that machine's encrypted root, which reads only its own secrets plus the shared file.
|
||||
The admin identity makes secrets recoverable after any machine is wiped and is the credential that authorizes registering a new host; the host identities keep a compromised server from decrypting the laptop.
|
||||
Deliberately, a host identity is *not* derived from its SSH host key — that decoupling is what lets the SSH host keys themselves be secrets, so they survive a reimage instead of being regenerated.
|
||||
|
||||
This supersedes ADR 0001, whose choice of sops-nix over agenix still holds — the shared-plus-per-host file split with overlapping recipients is exactly the multi-recipient, grouped-file model that decided against agenix — but whose key-derivation mechanism is replaced.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- **A separate private repository for secrets.** Rejected: cloning it needs credentials that would themselves be bootstrap material during an install, reintroducing a hand-carried secret to protect ciphertext that is already safe to publish.
|
||||
- **A passphrase-encrypted admin identity committed to the repo.** Rejected: in a public repo it is offline-brute-forceable indefinitely, whereas a password manager provides the same protection with rate limiting.
|
||||
- **Deriving host identities from SSH host keys**, as ADR 0001 specified. Rejected: it forces new host keys on every reimage, which means re-keying every secret, and it makes storing the host keys as secrets circular.
|
||||
- **A single admin identity for all hosts, with no per-host identities.** Rejected: with three servers planned, it gives every machine the ability to decrypt every other machine's secrets.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every secrets file must include the admin identity as a recipient. A file readable only by its own host becomes permanently unrecoverable the moment that machine is wiped.
|
||||
- The admin identity is the single point of recovery, and its durability is now a property of Proton Pass rather than of any machine or repository.
|
||||
- A host must have its identity provisioned and registered *before* its first boot, because the login password now arrives only from a decrypted secret and there is no fallback credential.
|
||||
- Registering a new host is a re-key of each file's data key, not a re-encryption of its values, so the cost stays constant as the fleet grows.
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
status: accepted
|
||||
---
|
||||
|
||||
# Hyprland as the keyboard-driven desktop compositor
|
||||
|
||||
The graphical desktop is built on **Hyprland**, a keyboard-driven Wayland tiling compositor, as a single choice serving both the laptop (neogaia) and the future desktop (zeus).
|
||||
It matches how the operator already works: an i3 model of numbered workspaces and manual tiling, ported to bindings reachable entirely on a 60% keyboard.
|
||||
Among true tilers it comes closest to "just works" through its cohesive first-party companion tools (lock, idle, wallpaper, portal) and a large ecosystem, which buys down the assembly-and-breakage cost that made past minimal tiling setups expensive for the operator.
|
||||
|
||||
The gaming and driver dimension does not constrain the choice, because both Hosts drive Wayland without caveats: neogaia is Intel and zeus is AMD.
|
||||
Notably zeus is AMD, not Nvidia — Raichu is the only Nvidia machine, and it is a server with no desktop — so no Nvidia-on-Wayland pressure shapes the decision.
|
||||
With gaming survival off the table, the choice rests on workflow and low-friction rather than on tolerating a hostile driver.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- **Sway.** Rejected: its i3-faithful minimalism is precisely what historically cost the operator hours of assembly and breakage.
|
||||
On AMD it games fine, but its only remaining edge over Hyprland was stability and purity — which the repo's pinning already provides, and which the operator's stated "just works" priority actively discounts.
|
||||
- **KDE Plasma.** Rejected: the most integrated and lowest-friction option, the best AMD gaming desktop, and the operator's prior environment — but mouse-first at its core and only keyboard-navigable at the margins, which works against the primary keyboard-first requirement.
|
||||
Its custom-tile-layout feature is an approximation of tiling on a floating desktop, not real automatic tiling.
|
||||
- **niri.** Rejected: its scrollable-tiling paradigm abandons numbered workspaces, which breaks the operator's core muscle memory of switching by number.
|
||||
It also has the smallest community of the candidates, a low-friction risk for a daily-driver desktop.
|
||||
- **A different compositor per Host** (a keyboard-pure laptop plus a separate gaming desktop). Rejected: it doubles the configuration and maintenance and defeats the goal of one transferable setup.
|
||||
It is unnecessary once AMD removes any gaming penalty from a keyboard-first compositor.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One desktop Module set serves both Hosts.
|
||||
zeus adopts the identical desktop by enabling a single flag, with battery-sensitive knobs such as blur flipped on for its AMD headroom.
|
||||
- Hyprland's churn and occasional breakage are absorbed by the pinned, declarative, reversible configuration rather than by live fixing, so upgrades happen on the operator's schedule.
|
||||
- The desktop's lock, idle, keybind syntax, and portal are Hyprland-specific, so a future move to another compositor would be a rewrite rather than a swap.
|
||||
This is the accepted cost of the first-party-cohesion benefit.
|
||||
- Hyprland is taken from nixpkgs, with no compositor plugins this pass.
|
||||
Adopting the upstream Hyprland flake later, for a plugin or a bleeding-edge feature, is a contained change that mirrors the existing chaotic-nyx input pattern (an input that must not follow nixpkgs, carrying its own binary cache).
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
status: accepted
|
||||
---
|
||||
|
||||
# A Module's option namespace mirrors its directory
|
||||
|
||||
A Module's option path mirrors its directory path under `modules/`, so a file's location on disk is its namespace: `modules/agents/tools/gitea-axi.nix` declares `modules.agents.tools.gitea-axi`, and a subfolder like `agents/` or `tools/` is a real namespace segment, not a cosmetic grouping.
|
||||
A file whose name matches its enclosing directory is that directory's index node, declaring the directory's own segment — its `enable` or aggregator — rather than a doubled segment, so `desktop/hyprland/hyprland.nix` owns `modules.desktop.hyprland` while `desktop/hyprland/hypridle.nix` nests under it as `modules.desktop.hyprland.hypridle`.
|
||||
|
||||
We chose this nested-mirrors-directory shape over the previous flat names (`modules.claude-code`, `modules.gitea-axi`) because the flat scheme let a Module sit anywhere on disk regardless of its option path, so the tree stopped predicting where a namespace lived.
|
||||
Mirroring makes the two the single fact.
|
||||
A pure grouping directory (`agents/`, `tools/`) contributes a namespace segment but declares no aggregate `enable`: agents are enabled à la carte, so there is deliberately no `modules.agents.enable` that would turn on a bundle nobody wants as a unit.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- **Flat, location-independent names** (the prior state). Rejected: a Module's option path was unconstrained by its file's location, so the directory tree and the option tree drifted and neither could be read off the other.
|
||||
- **A subfolder as cosmetic grouping only**, with the option path skipping the folder (`agents/pi.nix` → `modules.pi`). Rejected: it reintroduces the same drift for grouped Modules and makes the folder a lie the namespace does not tell.
|
||||
- **An aggregator at every grouping level** (`modules.agents.enable`). Rejected: the agent Modules have no meaningful "all agents" bundle, and an aggregate enable there would invite turning on tools no Host wants together.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The `agents/` group carries `claude-code`, `pi`, `skills`, and `tools/gitea-axi`, each enabled individually under `modules.agents.*`, with no `modules.agents.enable`.
|
||||
- The index-file rule means adding a knob to an existing group (a new `desktop/hyprland/*.nix`) nests automatically without a naming decision, while a new top-level Module names its own segment.
|
||||
- The `skills` Module remains the one deliberate exception to the Enable convention — it wires unconditionally — which the namespace convention does not change.
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
status: accepted
|
||||
---
|
||||
|
||||
# Stock Firefox with policy-installed extensions
|
||||
|
||||
The browser Module ships **stock mainline Firefox** (`pkgs.firefox`, the release train), and installs its three extensions — an ad and content blocker, the operator's password manager, and a video sponsor-skipper — through Mozilla's enterprise `ExtensionSettings` policy, keyed by add-on id with an install URL and `installation_mode = "force_installed"`.
|
||||
Firefox fetches each signed add-on from Mozilla's add-on site at runtime and enables it automatically.
|
||||
|
||||
We chose this over an ESR, unbranded, or Developer Edition build carrying hash-pinned add-on packages from the Nix store.
|
||||
Stock mainline Firefox refuses to load unsigned locally-built add-ons, so the pinned-package path forces the browser variant: it works only on a build that relaxes signature enforcement, which the mainline release does not.
|
||||
Pairing the variant to the extension mechanism makes this the pivotal, hard-to-reverse decision — the choice of build dictates the whole extension story — so it is recorded here rather than left implicit in the Module.
|
||||
|
||||
The trade-off is deliberate.
|
||||
The policy path gives up build-time reproducibility of the extension binaries, and needs network on first launch to populate them, in exchange for staying on current mainline Firefox with add-ons that are actually enabled and no new flake input.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- **Stock mainline Firefox with policy-installed extensions** (chosen).
|
||||
Current release train, no signature-enforcement caveat, no extra flake input.
|
||||
The extension binaries are fetched signed at runtime rather than pinned, so their exact versions are not reproducible from the flake and first launch needs network.
|
||||
- **ESR or unbranded Firefox with hash-pinned add-on packages** (e.g. via a NUR add-ons input).
|
||||
Rejected: it buys reproducible extension binaries but drags in an older or unusual browser variant to satisfy the signature check the mainline build enforces, plus a new flake input to maintain, for a browser the operator wants on the mainline feature and security cadence.
|
||||
- **Stock Firefox with extensions installed by hand.**
|
||||
Rejected: the state would live outside the flake, would not survive a reimage, and defeats the point of declaring the browser at all.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The Module needs no new flake input and no add-on package set.
|
||||
The extension list is three id/URL pairs under the enterprise policy.
|
||||
- Extension versions are whatever Mozilla currently serves, not a pinned hash, so the browser tracks upstream add-on updates automatically and the flake does not gate them.
|
||||
- First launch after a fresh build requires network to fetch the add-ons.
|
||||
An offline first boot comes up with the extensions not yet present, populating them once online.
|
||||
- Moving to a pinned-package posture later would mean changing the browser variant as well, since the two are coupled — the reason this is captured as a decision rather than a detail.
|
||||
- The no-pinned-package rule scopes to the three functional extensions, which is where the signature-enforcement conflict bites.
|
||||
Nord chrome theming comes from the Stylix Firefox Color add-on, a signed add-on that Stylix pins and manages, so it loads on stock mainline Firefox and adds no flake input of ours.
|
||||
That is a bounded, deliberate exception, not a reversal: it is what lets the browser be themed from the shared Stylix scheme without hand-written chrome CSS.
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
status: accepted
|
||||
---
|
||||
|
||||
# A Guest is a third concept beside Host and Module
|
||||
|
||||
The flake gains a third first-class concept, the Guest, beside the Host and the Module.
|
||||
A Guest is a reusable, machine-independent definition under `guests/` that bundles the Modules it runs inside an isolated NixOS instance, realized on a Host as a nested container.
|
||||
A Host composes both Modules and Guests: a Module turned on directly is a host-level install, and a Guest is a service running in its own isolated instance, so a Host still reads as one flat checklist of everything it carries.
|
||||
|
||||
A Guest follows the Modules convention wholesale.
|
||||
The Auto-loader discovers `guests/` as a third kind, the Namespace convention and file-or-folder rule apply unchanged (`guests/media/jellyfin.nix` declares `guests.media.jellyfin`), and a Guest is Module-shaped: it declares its own `guests.<path>` option namespace with an `enable` and its placement fields and guards its body on that `enable` per the Enable convention.
|
||||
The single difference from a Module is the payload — a Module's body merges settings into the Host, while a Guest's body realizes a nested container running the Guest's interior.
|
||||
|
||||
A Guest is backend-agnostic through a `backend` field but only the `container` backend (systemd-nspawn) is built now; `microvm` is a reserved value for a future hard-isolation backend.
|
||||
A Guest is sealed and singleton: its interior Modules are fixed in the Guest file and not overridable by a Host, which supplies only placement, and a Guest is instantiated at most once per Host.
|
||||
Every Guest stands on a slim guest-base, distinct from the host base carved out of the shared base config, and imports the full `modules/` tree so any Module is available inside it.
|
||||
|
||||
We chose this because the homelab this flake is growing to build runs services as isolated guests that are first-class citizens on a VLAN-tagged network, and the two-concept model had no way to say "run this service in its own instance, on this VLAN, with these pool mounts" declaratively or to reuse that definition across machines.
|
||||
Making the Guest a peer of Host and Module — same Auto-loader, same conventions, same checklist — adds the capability without adding a second mental model, and keeps the imperative container lifecycle and mutable drift of the Proxmox setup it replaces out of the flake.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- **Inline `containers.<name>` per Host.** The platform's native nested-container option, declared directly inside each Host. Rejected: a guest would be tied to one Host with no reuse, and the machine-independent identity of an appliance would be entangled with one machine's config, the same drift ADR 0004 removed for Modules.
|
||||
- **Incus or another imperative container stack.** A maintained LXC-style manager on the Host. Rejected: its instance lifecycle is imperative and its state mutable, which is precisely the Proxmox property the migration exists to eliminate; guests would not be declared in the flake.
|
||||
- **A microvm-first Guest.** Realize every Guest as a hard-isolated micro VM from the start. Deferred, not chosen for now: the operator's guests are the soft-isolation class the Proxmox LXCs already are, and a shared-kernel container backend is the like-for-like replacement; the backend field reserves `microvm` for when a guest genuinely needs a distinct kernel.
|
||||
- **A Guest as a parameterized template with Host-declared instances.** Let one Guest file be stamped out as many named instances per Host. Rejected: it breaks "follow the Modules convention completely" by making a Guest a non-singleton template with its own instance sublevel, a fourth shape; multiplicity is instead expressed as separate Guests over shared, configurable service Modules.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The Skeleton grows a Guest realization: the Auto-loader discovers `guests/`, a Guest's interior compiles into a nested container, and placement fields wire VLAN attachment, MAC pinning, bind mounts, secret mounts, unit caps, and the nesting prerequisites.
|
||||
- The shared base config splits into a host base (`system.nix`) and a slim guest-base; both include the primary user, home-manager, and the shared overlays, and the guest-base auto-enables the `toolkit` bundle and `modules.ssh`.
|
||||
- Guests attach to host-level foundations declared once per Host: `modules.network` for the trunk and per-VLAN bridges, and `modules.zfs` with a shared fixed-gid `storage` group for identity-mapped pool writes.
|
||||
- OCI software has a declarative home without a new mechanism: a Guest with `nesting` runs Podman in its interior.
|
||||
- The `microvm` backend, multi-instance Guests, and the concrete homelab Host with its real trunk, VLAN, pool, and MAC values remain future work.
|
||||
1
.claude/skills
Symbolic link
1
.claude/skills
Symbolic link
@@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
1
.claude/spec
Symbolic link
1
.claude/spec
Symbolic link
@@ -0,0 +1 @@
|
||||
../.agents/spec
|
||||
1
.claude/tasks
Symbolic link
1
.claude/tasks
Symbolic link
@@ -0,0 +1 @@
|
||||
../.agents/tasks
|
||||
Reference in New Issue
Block a user