From bf524aec37826fc45e1048fb591485b931429fef Mon Sep 17 00:00:00 2001 From: Cameron Smith Date: Tue, 1 Sep 2026 11:31:45 -0400 Subject: [PATCH 1/7] feat(pkgs): vendor devin-cli 3000.6.2 with a repo-root updater Vendors the nixpkgs pkgs/by-name/de/devin-cli derivation verbatim. The pinned channel carries 3000.3.22, while the Devin Outposts worker surface this repository is about to declare is documented against the 3000.6 CLI; modules/nixpkgs/compose.nix merges the by-name set into the default overlay after the channel overlays, so this attribute shadows the channel's and the updater keeps it current independently of the channel bump. The updater is rewritten rather than copied: upstream's resolves its package through `import ./. {}`, which only means anything inside a nixpkgs tree. This one resolves REPO_ROOT with `git rev-parse --show-toplevel` and addresses pkgs/by-name/devin-cli beneath it, the convention that checks.updater-repository-paths enforces since PR 2888, and declares git in its nix-shell dependencies as that check also requires. Verification: `nix build .#checks.aarch64-darwin.updater-repository-paths` passes, `nix build .#packages.aarch64-darwin.devin-cli` succeeds and the result reports `devin 3000.6.2 (ce8ebcc1)`, and `nix eval .#apps.aarch64-darwin.update-devin-cli.program` resolves. The package.nix body is byte-identical to upstream below the provenance header (`diff` against the nixpkgs checkout). --- modules/apps/updates.nix | 5 ++ pkgs/by-name/devin-cli/package.nix | 92 ++++++++++++++++++++++++++++++ pkgs/by-name/devin-cli/update.sh | 55 ++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 pkgs/by-name/devin-cli/package.nix create mode 100755 pkgs/by-name/devin-cli/update.sh diff --git a/modules/apps/updates.nix b/modules/apps/updates.nix index 2d41e60f4..7d230a149 100644 --- a/modules/apps/updates.nix +++ b/modules/apps/updates.nix @@ -20,6 +20,11 @@ program = "${config.packages.claude-code.updateScript}"; }; + apps.update-devin-cli = { + type = "app"; + program = "${config.packages.devin-cli.updateScript}"; + }; + apps.update-xsra = { type = "app"; program = "${config.packages.xsra.updateScript}"; diff --git a/pkgs/by-name/devin-cli/package.nix b/pkgs/by-name/devin-cli/package.nix new file mode 100644 index 000000000..cbf282e56 --- /dev/null +++ b/pkgs/by-name/devin-cli/package.nix @@ -0,0 +1,92 @@ +# Devin CLI, vendored from nixpkgs pkgs/by-name/de/devin-cli. +# +# The derivation body is upstream's, unmodified. It lives here because the +# pinned nixpkgs channel carries 3000.3.22 while the Outposts worker surface +# this repository declares (`services.devin-worker`) is documented against the +# 3000.6 CLI. modules/nixpkgs/compose.nix merges the by-name set into +# flake.overlays.default after the channel overlays, so this attribute shadows +# the channel's `devin-cli` on every machine; `nix run .#update-devin-cli` +# keeps it current independently of the channel bump. +{ + lib, + stdenvNoCC, + fetchurl, + installShellFiles, + versionCheckHook, +}: + +let + version = "3000.6.2"; + + throwSystem = throw "Unsupported system: ${stdenvNoCC.hostPlatform.system}"; + + srcs = { + x86_64-linux = fetchurl { + url = "https://static.devin.ai/cli/${version}/devin-${version}-x86_64-unknown-linux.tar.gz"; + hash = "sha256-6p5wSh4DXCjfWkwfsROtjOR1fTw9WoRx1CPUvPSBZ4g="; + }; + + aarch64-linux = fetchurl { + url = "https://static.devin.ai/cli/${version}/devin-${version}-aarch64-unknown-linux.tar.gz"; + hash = "sha256-cmsBAHzHBG/BZdMsxx1AxzmO2rMEV903v0jA0kdKfqE="; + }; + + aarch64-darwin = fetchurl { + url = "https://static.devin.ai/cli/${version}/devin-${version}-aarch64-apple-darwin.tar.gz"; + hash = "sha256-lxbqBCDqEg6iyInwe0IW7vINXqm2db5FgK79gstG8fQ="; + }; + }; +in + +stdenvNoCC.mkDerivation (finalAttrs: { + pname = "devin-cli"; + inherit version; + + outputs = [ + "out" + "man" + "doc" + ]; + + strictDeps = true; + __structuredAttrs = true; + + src = srcs.${stdenvNoCC.hostPlatform.system} or throwSystem; + + sourceRoot = "."; + + nativeBuildInputs = [ installShellFiles ]; + + dontConfigure = true; + dontBuild = true; + + installPhase = '' + runHook preInstall + + installBin ./bin/devin + installManPage ./share/man/man1/*.1 + + mkdir -p $out/share/doc + + mv ./share/devin/docs/* $out/share/doc + + runHook postInstall + ''; + + nativeInstallCheckInputs = [ versionCheckHook ]; + doInstallCheck = true; + + passthru.updateScript = ./update.sh; + + meta = { + description = "Cognition's Devin Agent CLI"; + homepage = "https://devin.ai/cli"; + license = lib.licenses.unfree; + sourceProvenance = [ lib.sourceTypes.binaryNativeCode ]; + maintainers = with lib.maintainers; [ + ethancedwards8 + nhshah15 + ]; + mainProgram = "devin"; + }; +}) diff --git a/pkgs/by-name/devin-cli/update.sh b/pkgs/by-name/devin-cli/update.sh new file mode 100755 index 000000000..04e51155d --- /dev/null +++ b/pkgs/by-name/devin-cli/update.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env nix-shell +#!nix-shell --pure -i bash -p bash curl jq cacert git nix +# shellcheck shell=bash + +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +PKG_NIX="${REPO_ROOT}/pkgs/by-name/devin-cli/package.nix" + +current_version="$(sed -n 's/.*version = "\(.*\)";/\1/p' "$PKG_NIX" | head -1)" + +latest_version="$(curl -fsSL https://static.devin.ai/cli/current/manifest.json | jq -r '.version')" + +if [[ -z "$latest_version" || "$latest_version" == "null" ]]; then + echo "error: failed to discover a version from https://static.devin.ai/cli/current/manifest.json" >&2 + exit 1 +fi + +if [[ "$current_version" == "$latest_version" ]]; then + echo "devin-cli is already at version ${current_version}; refreshing hashes anyway" +else + echo "Updating devin-cli: ${current_version} -> ${latest_version}" + sed -i'' -e "s/version = \"${current_version}\"/version = \"${latest_version}\"/" "$PKG_NIX" +fi + +# Platform map: nix system -> release asset triple. Upstream publishes no +# x86_64-darwin asset, so that system stays absent from package.nix and hits +# its throwSystem branch. +declare -A platform_map=( + ["x86_64-linux"]="x86_64-unknown-linux" + ["aarch64-linux"]="aarch64-unknown-linux" + ["aarch64-darwin"]="aarch64-apple-darwin" +) + +for platform in "${!platform_map[@]}"; do + triple="${platform_map[$platform]}" + url="https://static.devin.ai/cli/${latest_version}/devin-${latest_version}-${triple}.tar.gz" + + echo "Prefetching ${platform} (devin-${latest_version}-${triple}.tar.gz)..." + sri_hash="$(nix store prefetch-file --json --hash-type sha256 "$url" | jq -r .hash)" + + if [[ -z "$sri_hash" || "$sri_hash" == "null" ]]; then + echo "error: failed to compute hash for ${platform} from ${url}" >&2 + exit 1 + fi + + # The url line interpolates ${version} in Nix source, so each asset triple is + # a literal that appears exactly once; advance to the following hash line and + # substitute there. + sed -i'' -e "\|-${triple}\.tar\.gz|{ n; s|hash = \"sha256-[^\"]*\"|hash = \"${sri_hash}\"|; }" "$PKG_NIX" + + echo " ${platform}: ${sri_hash}" +done + +echo "Updated devin-cli to ${latest_version}" From a72c1e8ef4486d65f191fec1e27420da15b54074 Mon Sep 17 00:00:00 2001 From: Cameron Smith Date: Tue, 1 Sep 2026 11:31:45 -0400 Subject: [PATCH 2/7] feat(home): declare Devin CLI configuration and Outposts workers Adds `flake.modules.homeManager.devin`, joined to the ai aggregate, in two parts: `programs.devin` renders ~/.config/devin/config.json from a typed subset of the documented option surface, and `services.devin-worker` turns a host into Outposts execution capacity. Both land disabled, so every host evaluates unchanged until a per-host decision enables them. Home-manager rather than a system module because a session runs as a user, with that user's permissions and -- on macOS -- that user's desktop session. The platform then decides the supervisor. darwin gets a launchd USER AGENT: computer use drives the existing desktop session and needs Screen Recording and Accessibility granted to the worker process, which a system daemon without a desktop session would silently lose. linux gets a user-scoped systemd service; lingering that user is a NixOS option home-manager cannot reach, and the clan users inventory already sets it (see modules/clan/inventory/services/users/cameron.nix), so the seam stays with the layer that owns system users instead of being duplicated here. Outposts are configured as a registry of queues plus a worker count, not hard-coded per machine. An outpost is a queue rather than a machine, so naming queues per machine index would leave a session waiting on a busy queue while a sibling queue sat idle; concurrency belongs on the count. Each instance gets its own working directory, because session repositories are checked out under $(pwd)/repos and two instances would otherwise race on one checkout, and its own explicit acceptor id, because the upstream default is generated per worker data directory, which the instances on a host share. Token plumbing is declared and deliberately unpopulated: tokenFile is null, an assertion refuses an enabled worker without it, and the launcher reads the sops-rendered file at start and exits EX_CONFIG when it is missing rather than falling back to the CLI login, which would authenticate as the operator and implicitly create an outpost upstream. The credential never enters a launchd plist's EnvironmentVariables or a systemd unit's Environment, both of which are world-readable store files. Verification, selected as the checks that would fail if this change were wrong rather than the whole set: the new eval-only checks..devin-worker-structural pins platform routing and fan-out (2 workers -> 2 launchd agents and no systemd units on darwin, the mirror on linux), asserts the service environment carries PATH and nothing else, asserts per-instance working directories are distinct, and drives each assertion clause with its own malformed input plus a well-formed control. Both real hosts were force-evaluated disabled and enabled against a dummy secret path (darwinConfigurations.stibnite, nixosConfigurations.magnetite, via extendModules), the darwin launcher was built so shellcheck ran over it, and the rendered config.json was built and read back: only documented keys, no nulls. treefmt, gitleaks, naming-conventions, and package-devin-cli pass on aarch64-darwin. Deliberately not run: the full check set, and the slow machine-toplevel and home-manager activation builds. Nothing here changes a package a machine builds, and the host evaluations above cover the module's reachability from both configurations. --- modules/checks/devin-worker.nix | 236 ++++++++++++++++++ modules/home/ai/devin/default.nix | 160 +++++++++++++ modules/home/ai/devin/worker.nix | 386 ++++++++++++++++++++++++++++++ 3 files changed, 782 insertions(+) create mode 100644 modules/checks/devin-worker.nix create mode 100644 modules/home/ai/devin/default.nix create mode 100644 modules/home/ai/devin/worker.nix diff --git a/modules/checks/devin-worker.nix b/modules/checks/devin-worker.nix new file mode 100644 index 000000000..2853f7135 --- /dev/null +++ b/modules/checks/devin-worker.nix @@ -0,0 +1,236 @@ +# Structural check for `flake.modules.homeManager.devin`'s worker surface +# (see modules/home/ai/devin/worker.nix). +# +# The module lands disabled on every host, so nothing in the machine +# configurations exercises it. What the module promises when enabled is +# checked here instead, with a dummy token path standing in for the sops-nix +# secret the operator has yet to mint. Only names, counts, and booleans are +# serialized into the diff, so this check evaluates and never builds a +# worker's launcher. +# +# Two evaluation vehicles, for two different reasons. +# +# * The positive claims run through a minimal real +# `homeManagerConfiguration` on both platforms, so launchd plist keys and +# systemd unit sections are validated by the actual option types rather +# than by a stub of them. +# +# * The assertion claims run through a bare `lib.evalModules` against the +# same deferred module. home-manager throws on the whole configuration +# when any assertion fails, which makes the failure observable but hides +# WHICH clause fired; outside that wrapper the resolved `assertions` list +# is an ordinary value and each clause can be identified. +# +# Claims exercised: +# +# 1. Platform routing and instance fan-out: `workers = 2` produces exactly two +# launchd agents and no systemd units on darwin, and exactly two systemd +# user services and no launchd agents on linux. +# +# 2. No credential in the unit definition. A launchd plist and a systemd unit +# are Nix store files readable by every user on the machine, which is why +# the token is read from a file by the launcher at start. The service +# environment is therefore required to carry PATH and nothing else. +# +# 3. Per-instance working directories: session repositories live under +# `$(pwd)/repos`, so two instances sharing a working directory would race +# on the same checkout. +# +# 4. Each assertion clause fires on exactly its own malformed input, and a +# well-formed configuration fires none. +# +# Severity rationale (Mayo): each claim fails under a plausible incorrect +# implementation. Dropping the `mkIf isDarwin` / `mkIf isLinux` gates puts +# units on both platforms and breaks claim 1. Moving the token into +# `EnvironmentVariables` or `Environment` -- the shortcut this module exists +# to refuse -- adds a key and breaks claim 2. Deriving the working directory +# from the outpost alone rather than from the instance index collapses the two +# paths and breaks claim 3. Weakening any assertion to a tautology empties its +# fired-clause list, and strengthening one into an always-firing predicate +# populates `wellFormed`, so claim 4 is falsifiable in both directions. +{ inputs, self, ... }: +{ + perSystem = + { pkgs, ... }: + let + lib = pkgs.lib; + mkCheck = self.lib.mkStructuralCheck pkgs; + + dummyTokenPath = "/run/secrets/devin-outposts-token.dummy"; + + # The same package set the home configurations get (see + # modules/home/mk-home.nix): `self.legacyPackages` is the channel before + # flake.overlays.default is applied, where `devin-cli` would resolve to + # the channel's older build rather than the one this repository vendors. + probePkgs = + system: + import inputs.nixpkgs { + inherit system; + config.allowUnfree = true; + overlays = [ self.overlays.default ]; + }; + + # Real home-manager evaluation: option types enforced, assertions + # required to pass (home-manager throws otherwise, so reaching the + # values below is itself part of the positive claim). + evalHome = + { + system, + homeDirectory, + worker, + }: + (inputs.home-manager.lib.homeManagerConfiguration { + pkgs = probePkgs system; + modules = [ + self.modules.homeManager.devin + { + home = { + username = "probe"; + inherit homeDirectory; + stateVersion = "25.05"; + }; + services.devin-worker = worker; + } + ]; + }).config; + + # Bare module evaluation, for inspecting the assertions themselves. + evalBare = + system: worker: + (lib.evalModules { + modules = [ + self.modules.homeManager.devin + { + _module.check = false; + _module.args.pkgs = probePkgs system; + freeformType = lib.types.lazyAttrsOf lib.types.raw; + } + { services.devin-worker = worker; } + ]; + }).config; + + darwin = evalHome { + system = "aarch64-darwin"; + homeDirectory = "/Users/probe"; + worker = { + enable = true; + workers = 2; + tokenFile = dummyTokenPath; + }; + }; + + linux = evalHome { + system = "x86_64-linux"; + homeDirectory = "/home/probe"; + worker = { + enable = true; + workers = 2; + tokenFile = dummyTokenPath; + }; + }; + + agents = config: lib.attrNames (config.launchd.agents or { }); + units = config: lib.attrNames (config.systemd.user.services or { }); + + # Failing clauses, named by a distinctive substring of their message so + # the diff identifies the clause instead of embedding whole prose. + firedClauses = + config: + map ( + message: + if lib.hasInfix "tokenFile is null" message then + "token" + else if lib.hasInfix "registered for platform" message then + "platform" + else if lib.hasInfix "not a key of" message then + "unknown-outpost" + else if lib.hasInfix "no default resolved" message then + "unset-outpost" + else + "unrecognized: ${message}" + ) (map (a: a.message) (lib.filter (a: !a.assertion) config.assertions)); + + distinctWorkDirs = paths: paths != [ ] && lib.length (lib.unique paths) == lib.length paths; + in + { + checks.devin-worker-structural = mkCheck { + name = "devin-worker-structural"; + actual = { + darwinAgents = agents darwin; + darwinUnits = units darwin; + darwinPlistEnvKeys = + lib.attrNames + darwin.launchd.agents."devin-worker-1".config.EnvironmentVariables; + darwinWorkDirsDistinct = distinctWorkDirs ( + map (name: darwin.launchd.agents.${name}.config.WorkingDirectory) (agents darwin) + ); + + linuxUnits = units linux; + linuxAgents = agents linux; + linuxUnitEnvNames = map ( + entry: lib.head (lib.splitString "=" entry) + ) linux.systemd.user.services."devin-worker-1".Service.Environment; + linuxWorkDirsDistinct = distinctWorkDirs ( + map (name: linux.systemd.user.services.${name}.Service.WorkingDirectory) (units linux) + ); + + wellFormed = firedClauses ( + evalBare "aarch64-darwin" { + enable = true; + tokenFile = dummyTokenPath; + } + ); + tokenless = firedClauses ( + evalBare "aarch64-darwin" { + enable = true; + tokenFile = null; + } + ); + platformMismatch = firedClauses ( + evalBare "aarch64-darwin" { + enable = true; + outpost = "magnetite"; + tokenFile = dummyTokenPath; + } + ); + unknownOutpost = firedClauses ( + evalBare "aarch64-darwin" { + enable = true; + outpost = "no-such-queue"; + tokenFile = dummyTokenPath; + } + ); + unresolvedOutpost = firedClauses ( + evalBare "aarch64-darwin" { + enable = true; + outposts = { }; + tokenFile = dummyTokenPath; + } + ); + }; + expected = { + darwinAgents = [ + "devin-worker-1" + "devin-worker-2" + ]; + darwinUnits = [ ]; + darwinPlistEnvKeys = [ "PATH" ]; + darwinWorkDirsDistinct = true; + + linuxUnits = [ + "devin-worker-1" + "devin-worker-2" + ]; + linuxAgents = [ ]; + linuxUnitEnvNames = [ "PATH" ]; + linuxWorkDirsDistinct = true; + + wellFormed = [ ]; + tokenless = [ "token" ]; + platformMismatch = [ "platform" ]; + unknownOutpost = [ "unknown-outpost" ]; + unresolvedOutpost = [ "unset-outpost" ]; + }; + }; + }; +} diff --git a/modules/home/ai/devin/default.nix b/modules/home/ai/devin/default.nix new file mode 100644 index 000000000..cead4c456 --- /dev/null +++ b/modules/home/ai/devin/default.nix @@ -0,0 +1,160 @@ +# The Devin CLI as a member of the ai aggregate: the packaged CLI plus its +# declaratively rendered user configuration. `services.devin-worker`, in +# worker.nix, turns a host into Outposts execution capacity and shares this +# aspect. +# +# Both parts are home-manager rather than system modules because a Devin +# session runs as a user, with that user's permissions, credentials, and -- +# on macOS -- that user's desktop session. Nothing about it belongs to a +# system-wide service manager, and home-manager is the layer that reaches +# every host in this repository where the user exists. +# +# The rendered config.json is a Nix store symlink, so the CLI cannot write it +# back. Anything the CLI would otherwise persist itself -- the first-run theme +# prompt, keybindings saved from `/shortcuts` -- has to be declared here +# instead, which is what `settings` is for. +{ config, ... }: +{ + flake.modules.homeManager.ai = { + imports = [ config.flake.modules.homeManager.devin ]; + }; + + flake.modules.homeManager.devin = + { + config, + lib, + pkgs, + ... + }: + let + cfg = config.programs.devin; + + jsonFormat = pkgs.formats.json { }; + + # Only documented keys from + # https://docs.devin.ai/cli/reference/configuration/config-file + # are emitted, and a null-valued option emits no key at all so the CLI + # keeps its own default rather than being pinned to a value this module + # invented. + dropNull = lib.filterAttrs (_: v: v != null); + + agentSection = dropNull { model = cfg.model; }; + + declared = dropNull { + agent = if agentSection == { } then null else agentSection; + auto_update = cfg.autoUpdate; + notify = cfg.notify; + theme_mode = cfg.themeMode; + attribution = cfg.attribution; + }; + in + { + options.programs.devin = { + enable = lib.mkEnableOption "the Devin CLI with a declaratively rendered user configuration"; + + package = lib.mkPackageOption pkgs "devin-cli" { }; + + model = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "swe-1-6-fast"; + description = '' + Default model for local CLI sessions, rendered as `agent.model`. + Null leaves the key unset, so the CLI applies its own default. + + Model names are not enumerated here: the available set is an + account-level property that changes upstream, and a Nix-side enum + would reject a newly published model until this module caught up. + ''; + }; + + autoUpdate = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether the CLI may download and activate new releases in the + background, rendered as `auto_update`. + + Off by default because this CLI comes from the Nix store, where + the binary is read-only and the version is a property of the + generation. Upstream's background updater promotes a new version + by swapping a `current` symlink in a self-managed installation it + owns; under Nix there is no such installation to promote into, and + a worker service silently running a different build than the one + its generation declares is exactly the drift this repository + exists to prevent. `nix run .#update-devin-cli` is the update + path. + ''; + }; + + notify = lib.mkOption { + type = lib.types.nullOr ( + lib.types.enum [ + "never" + "smart" + "always" + ] + ); + default = null; + description = '' + Terminal notification policy when a session finishes or needs + input, rendered as `notify`. Null leaves the key unset. + ''; + }; + + themeMode = lib.mkOption { + type = lib.types.nullOr ( + lib.types.enum [ + "light" + "dark" + "terminal-dark" + "terminal-light" + "nocolor" + ] + ); + default = null; + description = '' + Colour theme, rendered as `theme_mode`. Null leaves the key unset, + which is upstream's auto-detect behaviour -- but note that + auto-detect asks on first run and cannot record the answer, + because the rendered file is a read-only store symlink. + ''; + }; + + attribution = lib.mkOption { + type = lib.types.nullOr lib.types.bool; + default = null; + description = '' + Whether commits and pull requests the agent creates carry Devin + attribution, rendered as `attribution`. Null leaves the key unset. + ''; + }; + + settings = lib.mkOption { + type = jsonFormat.type; + default = { }; + example = lib.literalExpression '' + { + permissions.deny = [ "Exec(sudo)" ]; + keymap.global.clear_screen = "ctrl-shift-k"; + } + ''; + description = '' + Additional configuration merged over the keys the typed options + above produce. This is the escape hatch for the rest of the + documented surface -- permissions, keymap, proxy, sandbox, + read_config_from -- without this module having to mirror every + option upstream defines. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + home.packages = [ cfg.package ]; + + xdg.configFile."devin/config.json".source = jsonFormat.generate "devin-config.json" ( + lib.recursiveUpdate declared cfg.settings + ); + }; + }; +} diff --git a/modules/home/ai/devin/worker.nix b/modules/home/ai/devin/worker.nix new file mode 100644 index 000000000..7c7d7c0b0 --- /dev/null +++ b/modules/home/ai/devin/worker.nix @@ -0,0 +1,386 @@ +# Devin Outposts workers: one option surface, two service backends. +# +# An outpost is a named QUEUE of sessions in Devin Cloud, not a machine. A +# worker is a process that watches one queue, claims a session, and executes +# every command, file edit, and repository operation locally while Devin's +# planning loop stays in their cloud; it needs outbound HTTPS only. N workers +# on one outpost therefore serve N concurrent sessions. +# +# That is why `outposts` is a registry of queues and `workers` is a count, and +# why queues are NOT named per machine index. Naming them `stibnite-1`, +# `stibnite-2` would partition the queue: a session dispatched to a busy queue +# would wait while its sibling queue sat idle, because the operator picks a +# queue when starting the session and cannot know which worker is free. +# Concurrency belongs on the worker count. +# +# Per host the platform decides the supervisor: +# +# * darwin gets a launchd USER AGENT, deliberately not a system daemon. +# Devin's computer-use features drive the machine's existing desktop +# session and need Screen Recording (screenshots) and Accessibility +# (input) granted to the worker process. A system daemon has no desktop +# session, so those features would fail with no configuration error to +# point at. Note that macOS keys those grants to the executable, which is +# a store path here: a CLI version bump changes the path and the grants +# have to be given again. +# +# * linux gets a user-scoped systemd service. A user manager stops with the +# last login session, so a host serving a queue with nobody logged in also +# needs `users.users..linger = true`. That is a NixOS-level option +# this module cannot reach from home-manager; for the machines in this +# repository the clan users inventory already sets it (see +# modules/clan/inventory/services/users/cameron.nix, which lingers +# cameron on magnetite among others), so the seam is closed at the layer +# that owns system users rather than duplicated here. +# +# Each worker instance gets its own working directory because a session's +# repositories are checked out under `$(pwd)/repos`: two workers sharing a +# directory would race on the same checkout. Each also gets its own explicit +# acceptor id, since the upstream default is generated per worker DATA +# directory -- which the instances on one host share -- and an id must never +# be shared, across instances or across machines. +{ ... }: +{ + flake.modules.homeManager.devin = + { + config, + lib, + pkgs, + ... + }: + let + cfg = config.services.devin-worker; + + hostOutpostPlatform = if pkgs.stdenv.hostPlatform.isDarwin then "macos" else "linux"; + + platformOutposts = lib.attrNames ( + lib.filterAttrs (_: outpost: outpost.platform == hostOutpostPlatform) cfg.outposts + ); + + selected = if cfg.outpost == null then null else cfg.outposts.${cfg.outpost} or null; + + indices = lib.genList (index: index + 1) cfg.workers; + + unitName = index: "devin-worker-${toString index}"; + + workDir = index: "${cfg.workRoot}/${cfg.outpost}-${toString index}"; + + logFile = index: "${cfg.stateDir}/${unitName index}.log"; + + # Exit code the launcher uses for a missing credential, distinguishing a + # permanently misconfigured worker from a transient failure so systemd + # can decline to restart it. EX_CONFIG from sysexits(3). + configErrorExit = 78; + + launcher = + index: + pkgs.writeShellApplication { + name = unitName index; + runtimeInputs = [ + cfg.package + pkgs.coreutils + # Required, not optional: every repository operation in a session + # is a git invocation on this machine. + pkgs.git + ]; + meta.description = "Devin Outposts worker ${toString index} for the ${toString cfg.outpost} queue"; + text = '' + work_dir=${lib.escapeShellArg (workDir index)} + install -d -m 0700 "$work_dir/repos" + + # The token reaches the process from a sops-rendered file read + # here, at start, and never from the unit definition: a launchd + # plist and a systemd unit both land in the world-readable Nix + # store, so a credential written into either is a credential + # published to every user on the machine. + token_file=${lib.escapeShellArg (if cfg.tokenFile == null then "" else cfg.tokenFile)} + if [ ! -s "$token_file" ]; then + echo "${unitName index}: no Outposts token at '$token_file'." >&2 + echo "${unitName index}: set services.devin-worker.tokenFile to the sops-nix path holding a v3 API token whose service-user role grants Outposts read and write scope." >&2 + echo "${unitName index}: refusing to start. Without a token the CLI would fall back to the operator's interactive login, authenticating as a person rather than this machine and implicitly creating an outpost upstream." >&2 + exit ${toString configErrorExit} + fi + DEVIN_OUTPOSTS_TOKEN="$(cat "$token_file")" + export DEVIN_OUTPOSTS_TOKEN + + # Stable across restarts, distinct per instance, and carrying the + # machine's own name so it cannot collide with a worker elsewhere + # in the fleet. Read at runtime rather than at eval time because + # home-manager has no hostname to read. + nodename="$(uname -n)" + DEVIN_WORKER_ACCEPTOR_ID="''${nodename%%.*}-${cfg.outpost}-${toString index}" + export DEVIN_WORKER_ACCEPTOR_ID + + cd "$work_dir" + exec devin worker start --outpost=${lib.escapeShellArg cfg.outpost} ${lib.escapeShellArgs cfg.extraArgs} + ''; + }; + + # launchd and a user systemd unit both start with a minimal PATH, and a + # session shells out to whatever the repository's own tooling needs. + servicePath = lib.concatStringsSep ":" ( + [ "${config.home.profileDirectory}/bin" ] + ++ lib.optionals pkgs.stdenv.hostPlatform.isDarwin [ + "/usr/local/bin" + "/opt/homebrew/bin" + ] + ++ [ + "/usr/bin" + "/bin" + "/usr/sbin" + "/sbin" + ] + ); + in + { + options.services.devin-worker = { + enable = lib.mkEnableOption '' + long-running Devin Outposts workers serving one outpost queue on this + host. Off by default: a worker is owned execution capacity that + claims sessions and runs them with this user's permissions, so + enabling it is a per-host decision taken alongside minting its token + ''; + + package = lib.mkPackageOption pkgs "devin-cli" { }; + + outposts = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.submodule { + options = { + platform = lib.mkOption { + type = lib.types.enum [ + "linux" + "macos" + "windows" + ]; + description = '' + Machine platform the queue was created for. The worker + refuses a queue whose platform does not match the machine + it runs on, so this is asserted at evaluation time rather + than discovered when a session is claimed and released. + ''; + }; + + description = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Human-readable description, as shown for the outpost in the Devin web app."; + }; + }; + } + ); + default = { + stibnite = { + platform = "macos"; + description = "Apple silicon workstation with a live desktop session for computer use"; + }; + magnetite = { + platform = "linux"; + description = "x86_64 server capacity for headless sessions"; + }; + }; + description = '' + Outpost queues this repository knows about, keyed by the name they + carry in Devin Cloud. Recording them here is a declaration, not a + creation: creating and deleting an outpost is an account-level + action taken through the web app or `devin worker outpost create`, + and nothing in this module reaches upstream to do it. + ''; + }; + + outpost = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = if lib.length platformOutposts == 1 then lib.head platformOutposts else null; + defaultText = lib.literalMD '' + the single entry of `outposts` whose `platform` matches this host, + or `null` when zero or several match + ''; + example = "magnetite"; + description = '' + Queue this host's workers serve. The default resolves whenever the + registry holds exactly one queue for this platform, which is what + makes the two hosts in this repository work without configuration; + a third host of an existing platform has to name its own queue, + because serving another machine's queue would send that machine's + sessions here. + ''; + }; + + workers = lib.mkOption { + type = lib.types.ints.positive; + default = 1; + description = '' + Number of worker instances on this host, and therefore the number + of sessions it serves concurrently; further sessions wait in the + queue. Each instance gets its own working directory and acceptor + id. + + Raising this above one on a macOS host is only useful for sessions + that do not use computer use: instances share the machine's single + desktop session, so two of them driving mouse and keyboard would + fight over it. + ''; + }; + + tokenFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = lib.literalExpression ''config.sops.secrets."devin-outposts-token".path''; + description = '' + Path to a file holding the worker's v3 API token, normally a + sops-nix secret rendered at activation. The token belongs to a + service user whose role grants Outposts read and write scope. + + It is a bearer credential, so it must never be written into a Nix + store path: not into a rendered configuration file, and not into a + launchd plist's EnvironmentVariables or a systemd unit's + Environment, both of which are store files readable by every user + on the machine. The launcher reads this file at start and passes + the value through DEVIN_OUTPOSTS_TOKEN, and refuses to start when + the file is missing or empty rather than falling back to the CLI's + interactive login. + + Left null deliberately: minting the token is an account-level + action for the operator, and until it exists this option has + nothing correct to point at. + ''; + }; + + workRoot = lib.mkOption { + type = lib.types.path; + default = "${config.home.homeDirectory}/devin/workers"; + defaultText = lib.literalExpression ''"''${config.home.homeDirectory}/devin/workers"''; + description = '' + Parent directory of the per-instance working directories, each + `/-`. Sessions check their repositories + out under an instance's `repos` subdirectory and are free to write + anywhere beneath it, so this is deliberately a plain directory in + the user's home rather than anything this repository manages + declaratively. + ''; + }; + + stateDir = lib.mkOption { + type = lib.types.path; + default = "${config.xdg.stateHome}/devin-worker"; + defaultText = lib.literalExpression ''"''${config.xdg.stateHome}/devin-worker"''; + description = "Directory holding each instance's service log."; + }; + + extraArgs = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "--poll-interval-secs=15" ]; + description = "Extra arguments appended to `devin worker start`."; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.outpost != null; + message = '' + services.devin-worker.outpost is unset and no default resolved: + services.devin-worker.outposts holds ${toString (lib.length platformOutposts)} queues for this host's platform (${hostOutpostPlatform}). Name the queue this host serves. + ''; + } + { + assertion = cfg.outpost == null || selected != null; + message = '' + services.devin-worker.outpost is "${toString cfg.outpost}", which is not a key of + services.devin-worker.outposts (${lib.concatStringsSep ", " (lib.attrNames cfg.outposts)}). + ''; + } + { + assertion = cfg.outpost == null || selected == null || selected.platform == hostOutpostPlatform; + message = '' + services.devin-worker.outpost "${toString cfg.outpost}" is registered for platform + "${toString (selected.platform or null)}" but this host is "${hostOutpostPlatform}". The worker + validates the machine's OS against the outpost's platform and refuses to serve a mismatch. + ''; + } + { + assertion = cfg.tokenFile != null; + message = '' + services.devin-worker is enabled but services.devin-worker.tokenFile is null. + Point it at the sops-nix path holding the Outposts token, e.g. + + sops.secrets."devin-outposts-token" = { }; + services.devin-worker.tokenFile = config.sops.secrets."devin-outposts-token".path; + + Starting without a token is not a fallback worth taking: the CLI would authenticate as + the operator's personal login and implicitly create an outpost upstream. + ''; + } + ]; + + # launchd opens the log file and systemd enters the working directory + # before the launcher runs, so neither can be left to the launcher to + # create on first start. + home.activation.devinWorkerDirectories = + lib.hm.dag.entryBefore + [ + "setupLaunchAgents" + "reloadSystemd" + ] + '' + $DRY_RUN_CMD install -d -m 0700 ${lib.escapeShellArg cfg.stateDir} ${ + lib.escapeShellArgs (map (index: workDir index) indices) + } + ''; + + launchd.agents = lib.mkIf pkgs.stdenv.hostPlatform.isDarwin ( + lib.listToAttrs ( + map ( + index: + lib.nameValuePair (unitName index) { + enable = true; + config = { + ProgramArguments = [ (lib.getExe (launcher index)) ]; + RunAtLoad = true; + KeepAlive = true; + WorkingDirectory = workDir index; + StandardOutPath = logFile index; + StandardErrorPath = logFile index; + # Not "Background": that class caps CPU and I/O priority, + # and a session on this worker runs the repository's builds + # and tests. + ProcessType = "Standard"; + # PATH only. A credential here would be a store-published + # credential; see services.devin-worker.tokenFile. + EnvironmentVariables.PATH = servicePath; + }; + } + ) indices + ) + ); + + systemd.user.services = lib.mkIf pkgs.stdenv.hostPlatform.isLinux ( + lib.listToAttrs ( + map ( + index: + lib.nameValuePair (unitName index) { + Unit = { + Description = "Devin Outposts worker ${toString index} serving the ${toString cfg.outpost} queue"; + After = [ "network-online.target" ]; + Wants = [ "network-online.target" ]; + }; + Service = { + ExecStart = lib.getExe (launcher index); + WorkingDirectory = workDir index; + Restart = "on-failure"; + # A worker that cannot authenticate stays down instead of + # polling the API on a loop; every other failure is treated + # as transient and retried on a slow cadence. + RestartPreventExitStatus = configErrorExit; + RestartSec = 30; + Environment = [ "PATH=${servicePath}" ]; + }; + Install.WantedBy = [ "default.target" ]; + } + ) indices + ) + ); + }; + }; +} From 3f23f64d32cbafead49988e79ad715c9622b9d83 Mon Sep 17 00:00:00 2001 From: Cameron Smith Date: Tue, 1 Sep 2026 11:31:46 -0400 Subject: [PATCH 3/7] chore(pkgs): devin-cli 3000.6.2 -> 3000.6.7 via its own updater The entire diff is what `nix run .#update-devin-cli` produced, run to check that the updater works and not only that it satisfies checks.updater-repository-paths: a script can resolve the repository root correctly and still fail to find or rewrite anything. Keeping its output rather than reverting it means the vendored package is the version upstream currently publishes, and the updater's correctness is evidenced by the artifact instead of by a discarded experiment. Each hash landed in its own platform's block, which is the part of the script most able to go wrong silently: the substitution anchors on the asset triple in the url line and rewrites the hash line that follows it. Verification: `nix build .#packages.aarch64-darwin.devin-cli` succeeds and the result reports `devin 3000.6.7 (260a97c8)`; checks.aarch64-darwin.updater-repository-paths and checks.aarch64-darwin.package-devin-cli pass. --- pkgs/by-name/devin-cli/package.nix | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkgs/by-name/devin-cli/package.nix b/pkgs/by-name/devin-cli/package.nix index cbf282e56..45e1a9ff3 100644 --- a/pkgs/by-name/devin-cli/package.nix +++ b/pkgs/by-name/devin-cli/package.nix @@ -16,24 +16,24 @@ }: let - version = "3000.6.2"; + version = "3000.6.7"; throwSystem = throw "Unsupported system: ${stdenvNoCC.hostPlatform.system}"; srcs = { x86_64-linux = fetchurl { url = "https://static.devin.ai/cli/${version}/devin-${version}-x86_64-unknown-linux.tar.gz"; - hash = "sha256-6p5wSh4DXCjfWkwfsROtjOR1fTw9WoRx1CPUvPSBZ4g="; + hash = "sha256-+I7azqaSVTkQ1y8nVRW9C1K10nHVUlCYGwxBARFC0ns="; }; aarch64-linux = fetchurl { url = "https://static.devin.ai/cli/${version}/devin-${version}-aarch64-unknown-linux.tar.gz"; - hash = "sha256-cmsBAHzHBG/BZdMsxx1AxzmO2rMEV903v0jA0kdKfqE="; + hash = "sha256-jelew2I9k+bzywg0+mWf19ZpjU/v8/t+dS6XerxApuA="; }; aarch64-darwin = fetchurl { url = "https://static.devin.ai/cli/${version}/devin-${version}-aarch64-apple-darwin.tar.gz"; - hash = "sha256-lxbqBCDqEg6iyInwe0IW7vINXqm2db5FgK79gstG8fQ="; + hash = "sha256-/dBoEgd9XP7lZM7XolbQueJD7kZ9VSGUJmKXFETF2lQ="; }; }; in From 440534a1d08eac1f52f024fda5ce3fac9bce4588 Mon Sep 17 00:00:00 2001 From: Cameron Smith Date: Tue, 1 Sep 2026 11:31:46 -0400 Subject: [PATCH 4/7] fix(home): per-outpost credentials, id addressing, live registry values Four corrections from evidence that arrived after the module landed, plus one defect found while making them. TWO TOKENS, NOT ONE. `tokenFile` moves from a host-level option into the outpost registry entry, so each queue names its own file. There is now no shared option a queue could fall back to: a queue whose file is missing refuses to start rather than running addressed at one queue while holding another's credential. The structural check's token probe leaves the sibling queue wired, so it fails if a fallback is ever introduced. The isolation claim that shaped the first version is dropped as measured false: a token issued for one outpost lists every outpost through the account-level endpoint, so these credentials are account-scoped for reads despite being issued per outpost. Separate files buy independent rotation, not a smaller blast radius. Every comment, option description, and the pull request body are corrected to say so. ADDRESSED BY ID. Registry entries gain `id`, and the worker is passed the id rather than the name. The live queues are `stibnite-01` and `magnetite-01`, which is exactly why: a name is the operator's label, it can be changed in the UI, and a name-addressed worker would fail when it tried to claim, on a machine nobody is watching. The name is kept for unit naming, working directories, acceptor ids, and messages. An entry with no id fails evaluation naming the outpost. Verified against the built launcher: its argv carries `--outpost=outpost_env-f47bd2ee...` and no bare name. PLATFORM IS OPTIONAL, AND NULL IS ITS OWN STATE. magnetite-01 was created without a platform, and the reference reads null as the account default, so this module neither models it as linux nor papers over it. A platform naming a different OS is a mismatch and fails evaluation; a null platform produces a warning naming the outpost and no assertion, because whether a no-platform queue can serve this worker is unproven and the worker's own OS validation is the authority at claim time. The check names all three states separately: darwin selects stibnite-01 with platform macos and no warning, linux selects magnetite-01 with platform null and the platform-unset warning, and a probe with both platforms named and different is the only one that fires the assertion. Default selection resolves in two tiers to match: entries naming this host's platform, and only when there are none, entries naming no platform. Each host therefore still resolves exactly one queue with no per-host configuration, without treating null as equal to linux. SECRETS WIRED BY NAME at the repository's existing location, following hindsight-api-token: `devin-outposts-token-stibnite` and `devin-outposts-token-magnetite`, mode 0400, each outpost's tokenFile pointed at its own `.path`. sops rather than clan vars because clan vars is effectively NixOS-shaped in this fleet -- magnetite carries 41 generators against stibnite's one, this repository already records a related clan feature as NixOS-only, and sops-nix through home-manager already delivers secrets to the darwin host for four existing tools. No ciphertext is added here; that is the operator's. Both are gated on the service being enabled, and the gate is not cosmetic: sops-nix validates every declared key against the sops file when the manifest is built (check-mode=sopsfile calls recurseSecretKey), so declaring a key whose ciphertext is absent fails home-manager activation on both hosts -- and reddens home-manager-crs58 in CI -- for a service that is off. The operator's two actions, adding the ciphertexts and enabling a host, land together, and enabling without them fails at build time naming the missing key. They are carried as an inline module because `sops.secrets` is already defined in that file's attribute set. ROTATION IS DOCUMENTED IN THE MODULE, not only in the pull request: rotate in the UI (no rebuild needed for that step alone), replace that one key's value in secrets.yaml, then re-activate the host and restart the worker. The restart is not optional, because the launcher reads the token once at process start, so a running worker keeps the old value however current the file has become; rotating one outpost's key never touches the other's. DEFECT FOUND WHILE WIRING: the registry lived in the option's `default`, and an option default is replaced wholesale by any definition. Adding one entry's tokenFile would therefore have dropped the other entry and dropped the `platform` of the entry being edited, which has no default and would have failed evaluation. Definitions merge where defaults do not, so the fleet's queues move into the module's config layer as mkDefault values. The check's probes now supply only token files and rely on the module for ids, platforms, and names, so a regression here fails them. Verification, re-running what this branch already ran rather than adding new lanes: devin-worker-structural, treefmt, gitleaks, naming-conventions, updater-repository-paths, and package-devin-cli pass on aarch64-darwin; both hosts force-evaluated disabled and enabled, where enabling now needs nothing but `enable = true` because ids come from the registry and token paths from the gated wiring; magnetite's enabled evaluation emits the platform-unset warning and no assertion; and the built launcher was read back to confirm it addresses by id, reads its own token file, and never mentions the sibling queue. --- modules/checks/devin-worker.nix | 110 +++++- modules/home/ai/devin/worker.nix | 479 ++++++++++++++++++--------- modules/home/users/crs58/default.nix | 43 +++ 3 files changed, 465 insertions(+), 167 deletions(-) diff --git a/modules/checks/devin-worker.nix b/modules/checks/devin-worker.nix index 2853f7135..a0d34b7d9 100644 --- a/modules/checks/devin-worker.nix +++ b/modules/checks/devin-worker.nix @@ -37,7 +37,9 @@ # on the same checkout. # # 4. Each assertion clause fires on exactly its own malformed input, and a -# well-formed configuration fires none. +# well-formed configuration fires none. The token probe leaves the sibling +# queue fully wired, so it also shows that a queue whose own credential is +# missing does not borrow another queue's. # # Severity rationale (Mayo): each claim fails under a plausible incorrect # implementation. Dropping the `mkIf isDarwin` / `mkIf isLinux` gates puts @@ -56,8 +58,6 @@ lib = pkgs.lib; mkCheck = self.lib.mkStructuralCheck pkgs; - dummyTokenPath = "/run/secrets/devin-outposts-token.dummy"; - # The same package set the home configurations get (see # modules/home/mk-home.nix): `self.legacyPackages` is the channel before # flake.overlays.default is applied, where `devin-cli` would resolve to @@ -109,13 +109,22 @@ ]; }).config; + # Only the token files are supplied: the ids, platforms and names come + # from the module's own registry, so these probes also show that a + # partial definition merges with it instead of replacing it -- an option + # `default` would have dropped the entries and the platforms. + wired = { + "stibnite-01".tokenFile = "/run/secrets/devin-outposts-token-stibnite.dummy"; + "magnetite-01".tokenFile = "/run/secrets/devin-outposts-token-magnetite.dummy"; + }; + darwin = evalHome { system = "aarch64-darwin"; homeDirectory = "/Users/probe"; worker = { enable = true; workers = 2; - tokenFile = dummyTokenPath; + outposts = wired; }; }; @@ -125,7 +134,7 @@ worker = { enable = true; workers = 2; - tokenFile = dummyTokenPath; + outposts = wired; }; }; @@ -140,6 +149,8 @@ message: if lib.hasInfix "tokenFile is null" message then "token" + else if lib.hasInfix "id is null" message then + "id" else if lib.hasInfix "registered for platform" message then "platform" else if lib.hasInfix "not a key of" message then @@ -150,6 +161,22 @@ "unrecognized: ${message}" ) (map (a: a.message) (lib.filter (a: !a.assertion) config.assertions)); + # Warnings are the third platform state: neither an assertion failure nor + # silence. + warnedClauses = + config: + map ( + message: + if lib.hasInfix "carries no platform" message then "platform-unset" else "unrecognized: ${message}" + ) (config.warnings or [ ]); + + # A no-platform queue selected on a darwin host: warned, not asserted. + platformUnsetProbe = evalBare "aarch64-darwin" { + enable = true; + outpost = "magnetite-01"; + outposts = wired; + }; + distinctWorkDirs = paths: paths != [ ] && lib.length (lib.unique paths) == lib.length paths; in { @@ -174,37 +201,86 @@ map (name: linux.systemd.user.services.${name}.Service.WorkingDirectory) (units linux) ); + # Selection: the exact-platform tier picks stibnite-01 on darwin, and + # with no linux-platform entry the no-platform tier picks + # magnetite-01 on linux. Both come from the module's registry, so a + # wrong name or a missing merge shows up here. + darwinOutpost = darwin.services.devin-worker.outpost; + darwinSelectedPlatform = + darwin.services.devin-worker.outposts.${darwin.services.devin-worker.outpost}.platform; + darwinWarned = warnedClauses darwin; + + linuxOutpost = linux.services.devin-worker.outpost; + linuxSelectedPlatform = + linux.services.devin-worker.outposts.${linux.services.devin-worker.outpost}.platform; + # magnetite-01 carries no platform, so agreement with a linux host is + # unestablished rather than confirmed. Named as its own state: not a + # match, not a mismatch, and deliberately not an assertion, because + # whether a no-platform queue can serve this worker is unproven. + linuxWarned = warnedClauses linux; + wellFormed = firedClauses ( evalBare "aarch64-darwin" { enable = true; - tokenFile = dummyTokenPath; + outposts = wired; } ); + # The sibling queue keeps its token, so this also shows a queue whose + # own file is missing does not fall back to another's. tokenless = firedClauses ( evalBare "aarch64-darwin" { enable = true; - tokenFile = null; + outposts = { + "magnetite-01".tokenFile = wired."magnetite-01".tokenFile; + }; + } + ); + idless = firedClauses ( + evalBare "aarch64-darwin" { + enable = true; + outposts = wired // { + "stibnite-01" = { + id = null; + inherit (wired."stibnite-01") tokenFile; + }; + }; } ); + # Both platforms named and different, which is the only mismatch the + # module asserts on. platformMismatch = firedClauses ( evalBare "aarch64-darwin" { enable = true; - outpost = "magnetite"; - tokenFile = dummyTokenPath; + outpost = "magnetite-01"; + outposts = wired // { + "magnetite-01" = { + platform = "linux"; + inherit (wired."magnetite-01") tokenFile; + }; + }; } ); + platformUnsetFired = firedClauses platformUnsetProbe; + platformUnsetWarned = warnedClauses platformUnsetProbe; unknownOutpost = firedClauses ( evalBare "aarch64-darwin" { enable = true; outpost = "no-such-queue"; - tokenFile = dummyTokenPath; + outposts = wired; } ); + # Two queues naming this host's platform, so no single default + # resolves and the exact tier never falls through to the other. unresolvedOutpost = firedClauses ( evalBare "aarch64-darwin" { enable = true; - outposts = { }; - tokenFile = dummyTokenPath; + outposts = wired // { + spare = { + platform = "macos"; + id = "outpost_env-11111111111111111111111111111111"; + tokenFile = "/run/secrets/devin-outposts-token-spare.dummy"; + }; + }; } ); }; @@ -225,9 +301,19 @@ linuxUnitEnvNames = [ "PATH" ]; linuxWorkDirsDistinct = true; + darwinOutpost = "stibnite-01"; + darwinSelectedPlatform = "macos"; + darwinWarned = [ ]; + linuxOutpost = "magnetite-01"; + linuxSelectedPlatform = null; + linuxWarned = [ "platform-unset" ]; + wellFormed = [ ]; tokenless = [ "token" ]; + idless = [ "id" ]; platformMismatch = [ "platform" ]; + platformUnsetFired = [ ]; + platformUnsetWarned = [ "platform-unset" ]; unknownOutpost = [ "unknown-outpost" ]; unresolvedOutpost = [ "unset-outpost" ]; }; diff --git a/modules/home/ai/devin/worker.nix b/modules/home/ai/devin/worker.nix index 7c7d7c0b0..a7f62aadf 100644 --- a/modules/home/ai/devin/worker.nix +++ b/modules/home/ai/devin/worker.nix @@ -39,6 +39,32 @@ # acceptor id, since the upstream default is generated per worker DATA # directory -- which the instances on one host share -- and an id must never # be shared, across instances or across machines. +# +# One token file per queue, for rotation rather than for containment. The web +# UI issues a token when an outpost is created, but a token issued for one +# outpost lists every outpost through the account-level endpoint: measured +# against the live account, these credentials are account-scoped for reads +# despite being issued per outpost. Separate files therefore buy independent +# rotation, not blast-radius containment, and no registry entry may fall back +# to a sibling's file -- a queue without its own token refuses to start, so a +# worker never runs addressed at one queue with another queue's credential. +# +# Rotating one, in three steps: +# +# 1. Rotate the token in the Devin UI for that outpost. No rebuild is +# needed for this step alone -- nothing in this repository holds the +# value, and the running worker still holds the old one. +# 2. Replace that one key's value in +# secrets/home-manager/users/crs58/secrets.yaml. The other outpost's key +# is a separate entry and is not touched. +# 3. Re-activate that host so the new value reaches the worker's runtime +# path, then restart the worker service +# (`launchctl kickstart -k gui/$UID/devin-worker-1` on darwin, +# `systemctl --user restart devin-worker-1` on linux). +# +# Step 3's restart is not optional: the launcher reads the token from the file +# once, at process start, so a running worker keeps using the old value until +# it is restarted, however current the file on disk has become. { ... }: { flake.modules.homeManager.devin = @@ -53,12 +79,40 @@ hostOutpostPlatform = if pkgs.stdenv.hostPlatform.isDarwin then "macos" else "linux"; - platformOutposts = lib.attrNames ( + # A registry entry's platform may be null: the account permits creating + # an outpost without one, and the reference reads a null platform as the + # account default rather than as any particular OS. Candidates are + # therefore resolved in two tiers -- entries that name this host's + # platform, and only if there are none, entries that name no platform at + # all. That gives each host in this fleet exactly one candidate + # (stibnite-01 names macos; magnetite-01 names nothing) without treating + # a null as equal to linux. + exactPlatformOutposts = lib.attrNames ( lib.filterAttrs (_: outpost: outpost.platform == hostOutpostPlatform) cfg.outposts ); + unsetPlatformOutposts = lib.attrNames ( + lib.filterAttrs (_: outpost: outpost.platform == null) cfg.outposts + ); + + platformOutposts = + if exactPlatformOutposts != [ ] then exactPlatformOutposts else unsetPlatformOutposts; + selected = if cfg.outpost == null then null else cfg.outposts.${cfg.outpost} or null; + # Neither a match nor a mismatch: whether a queue with no platform can + # serve this host is unproven here, so the module reports the state and + # leaves the worker's own OS validation as the authority at claim time. + selectedPlatformUnset = selected != null && selected.platform == null; + + # Both resolve to a harmless empty value when the registry entry is + # missing or incomplete, so the assertions below are what report the + # problem rather than an evaluation error from deep inside the launcher. + selectedOutpostId = + if selected == null then "" else (if selected.id == null then "" else selected.id); + + selectedTokenFile = if selected == null then null else selected.tokenFile; + indices = lib.genList (index: index + 1) cfg.workers; unitName = index: "devin-worker-${toString index}"; @@ -93,10 +147,16 @@ # plist and a systemd unit both land in the world-readable Nix # store, so a credential written into either is a credential # published to every user on the machine. - token_file=${lib.escapeShellArg (if cfg.tokenFile == null then "" else cfg.tokenFile)} + # + # This queue's own file, with no shared option to fall back to, so + # a queue whose file is missing refuses to start rather than + # running with a sibling's credential. That is addressing + # discipline, not isolation: the credentials are account-scoped + # for reads. + token_file=${lib.escapeShellArg (if selectedTokenFile == null then "" else selectedTokenFile)} if [ ! -s "$token_file" ]; then - echo "${unitName index}: no Outposts token at '$token_file'." >&2 - echo "${unitName index}: set services.devin-worker.tokenFile to the sops-nix path holding a v3 API token whose service-user role grants Outposts read and write scope." >&2 + echo "${unitName index}: no Outposts token for the ${toString cfg.outpost} queue at '$token_file'." >&2 + echo "${unitName index}: set services.devin-worker.outposts.${toString cfg.outpost}.tokenFile to the sops-nix path holding that outpost's worker token." >&2 echo "${unitName index}: refusing to start. Without a token the CLI would fall back to the operator's interactive login, authenticating as a person rather than this machine and implicitly creating an outpost upstream." >&2 exit ${toString configErrorExit} fi @@ -111,8 +171,12 @@ DEVIN_WORKER_ACCEPTOR_ID="''${nodename%%.*}-${cfg.outpost}-${toString index}" export DEVIN_WORKER_ACCEPTOR_ID + # Addressed by id rather than by name: a name can be renamed in + # the web UI, and a stale name would surface as a failure at claim + # time on a machine nobody is watching, while the id is stable for + # the queue's lifetime. cd "$work_dir" - exec devin worker start --outpost=${lib.escapeShellArg cfg.outpost} ${lib.escapeShellArgs cfg.extraArgs} + exec devin worker start --outpost=${lib.escapeShellArg selectedOutpostId} ${lib.escapeShellArgs cfg.extraArgs} ''; }; @@ -147,17 +211,89 @@ type = lib.types.attrsOf ( lib.types.submodule { options = { + id = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "outpost_env-0123456789abcdef0123456789abcdef"; + description = '' + Stable identifier the web UI issues for the queue, of the + form `outpost_env-<32 hex>`. `devin worker start + --outpost=` accepts either a name or an id, and this + module passes the id: the name is the operator's label and + can be changed in the UI, at which point a name-addressed + worker would keep polling and fail when it tried to claim, + on a machine nobody is watching. + + Null until the queue exists, because the id is issued + rather than chosen. Enabling a worker for an entry with no + id fails evaluation, naming the outpost: a queue that + cannot be addressed is not a working default. Fill it in + here, beside the platform, once the queue is created -- + the id is an identifier, not a credential. + ''; + }; + + tokenFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = lib.literalExpression ''config.sops.secrets."devin-outposts-token-magnetite".path''; + description = '' + Path to a file holding this queue's worker token, normally + a sops-nix secret rendered at activation. + + One file per queue, for rotation rather than for + containment. The web UI issues a token when an outpost is + created, but a token issued for one outpost lists every + outpost through the account-level endpoint -- measured + against the live account -- so these credentials are + account-scoped for reads despite being issued per outpost. + Separate files buy independent rotation, not a smaller + blast radius. + + There is deliberately no host-level or module-level token + option, so a queue whose file is missing cannot fall back + to a sibling's: it refuses to start, and a worker never + runs addressed at one queue holding another's credential. + + The token is a bearer credential, so it must never be + written into a Nix store path: not into a rendered + configuration file, and not into a launchd plist's + EnvironmentVariables or a systemd unit's Environment, both + of which are store files readable by every user on the + machine. The launcher reads this file at start and passes + the value through DEVIN_OUTPOSTS_TOKEN. + + Left null in the registry defaults deliberately: minting + the token is an account-level action for the operator, and + until it exists this option has nothing correct to point + at. + ''; + }; + platform = lib.mkOption { - type = lib.types.enum [ - "linux" - "macos" - "windows" - ]; + type = lib.types.nullOr ( + lib.types.enum [ + "linux" + "macos" + "windows" + ] + ); + default = null; description = '' - Machine platform the queue was created for. The worker - refuses a queue whose platform does not match the machine - it runs on, so this is asserted at evaluation time rather - than discovered when a session is claimed and released. + Machine platform the queue was created for, or null when + the outpost was created without one -- which the account + permits, and which the reference reads as the account + default rather than as a particular OS. + + A platform that names a different OS than this host is a + mismatch and fails evaluation: the worker validates the + machine's OS against the outpost's platform, and failing + here beats discovering it as sessions are claimed and + released. A null platform is neither a match nor a + mismatch. Whether a queue with no platform can serve this + host is not established, so it is reported as its own + state -- a warning naming the outpost -- and the worker's + own validation remains the authority at claim time. ''; }; @@ -169,22 +305,23 @@ }; } ); - default = { - stibnite = { - platform = "macos"; - description = "Apple silicon workstation with a live desktop session for computer use"; - }; - magnetite = { - platform = "linux"; - description = "x86_64 server capacity for headless sessions"; - }; - }; + default = { }; description = '' Outpost queues this repository knows about, keyed by the name they carry in Devin Cloud. Recording them here is a declaration, not a creation: creating and deleting an outpost is an account-level action taken through the web app or `devin worker outpost create`, and nothing in this module reaches upstream to do it. + + The fleet's own queues are populated by this module's config layer + rather than by this option's default, because an option default is + replaced wholesale by any definition: a caller adding one entry's + `id` or `tokenFile` through a default-carried registry would drop + every other entry, and drop the `platform` of the entry it was + editing. Definitions merge, so with the registry in the config + layer a caller writing `outposts.magnetite.tokenFile = ...` adds + to it. Overriding a value this module sets needs `lib.mkForce`, + since the module's own entries are `lib.mkDefault`. ''; }; @@ -222,30 +359,6 @@ ''; }; - tokenFile = lib.mkOption { - type = lib.types.nullOr lib.types.path; - default = null; - example = lib.literalExpression ''config.sops.secrets."devin-outposts-token".path''; - description = '' - Path to a file holding the worker's v3 API token, normally a - sops-nix secret rendered at activation. The token belongs to a - service user whose role grants Outposts read and write scope. - - It is a bearer credential, so it must never be written into a Nix - store path: not into a rendered configuration file, and not into a - launchd plist's EnvironmentVariables or a systemd unit's - Environment, both of which are store files readable by every user - on the machine. The launcher reads this file at start and passes - the value through DEVIN_OUTPOSTS_TOKEN, and refuses to start when - the file is missing or empty rather than falling back to the CLI's - interactive login. - - Left null deliberately: minting the token is an account-level - action for the operator, and until it exists this option has - nothing correct to point at. - ''; - }; - workRoot = lib.mkOption { type = lib.types.path; default = "${config.home.homeDirectory}/devin/workers"; @@ -275,112 +388,168 @@ }; }; - config = lib.mkIf cfg.enable { - assertions = [ - { - assertion = cfg.outpost != null; - message = '' - services.devin-worker.outpost is unset and no default resolved: - services.devin-worker.outposts holds ${toString (lib.length platformOutposts)} queues for this host's platform (${hostOutpostPlatform}). Name the queue this host serves. - ''; - } - { - assertion = cfg.outpost == null || selected != null; - message = '' - services.devin-worker.outpost is "${toString cfg.outpost}", which is not a key of - services.devin-worker.outposts (${lib.concatStringsSep ", " (lib.attrNames cfg.outposts)}). - ''; - } - { - assertion = cfg.outpost == null || selected == null || selected.platform == hostOutpostPlatform; - message = '' - services.devin-worker.outpost "${toString cfg.outpost}" is registered for platform - "${toString (selected.platform or null)}" but this host is "${hostOutpostPlatform}". The worker - validates the machine's OS against the outpost's platform and refuses to serve a mismatch. - ''; - } - { - assertion = cfg.tokenFile != null; - message = '' - services.devin-worker is enabled but services.devin-worker.tokenFile is null. - Point it at the sops-nix path holding the Outposts token, e.g. - - sops.secrets."devin-outposts-token" = { }; - services.devin-worker.tokenFile = config.sops.secrets."devin-outposts-token".path; - - Starting without a token is not a fallback worth taking: the CLI would authenticate as - the operator's personal login and implicitly create an outpost upstream. - ''; - } - ]; - - # launchd opens the log file and systemd enters the working directory - # before the launcher runs, so neither can be left to the launcher to - # create on first start. - home.activation.devinWorkerDirectories = - lib.hm.dag.entryBefore - [ - "setupLaunchAgents" - "reloadSystemd" - ] - '' - $DRY_RUN_CMD install -d -m 0700 ${lib.escapeShellArg cfg.stateDir} ${ - lib.escapeShellArgs (map (index: workDir index) indices) - } - ''; - - launchd.agents = lib.mkIf pkgs.stdenv.hostPlatform.isDarwin ( - lib.listToAttrs ( - map ( - index: - lib.nameValuePair (unitName index) { - enable = true; - config = { - ProgramArguments = [ (lib.getExe (launcher index)) ]; - RunAtLoad = true; - KeepAlive = true; - WorkingDirectory = workDir index; - StandardOutPath = logFile index; - StandardErrorPath = logFile index; - # Not "Background": that class caps CPU and I/O priority, - # and a session on this worker runs the repository's builds - # and tests. - ProcessType = "Standard"; - # PATH only. A credential here would be a store-published - # credential; see services.devin-worker.tokenFile. - EnvironmentVariables.PATH = servicePath; - }; - } - ) indices - ) - ); - - systemd.user.services = lib.mkIf pkgs.stdenv.hostPlatform.isLinux ( - lib.listToAttrs ( - map ( - index: - lib.nameValuePair (unitName index) { - Unit = { - Description = "Devin Outposts worker ${toString index} serving the ${toString cfg.outpost} queue"; - After = [ "network-online.target" ]; - Wants = [ "network-online.target" ]; - }; - Service = { - ExecStart = lib.getExe (launcher index); - WorkingDirectory = workDir index; - Restart = "on-failure"; - # A worker that cannot authenticate stays down instead of - # polling the API on a loop; every other failure is treated - # as transient and retried on a slow cadence. - RestartPreventExitStatus = configErrorExit; - RestartSec = 30; - Environment = [ "PATH=${servicePath}" ]; - }; - Install.WantedBy = [ "default.target" ]; - } - ) indices - ) - ); - }; + config = lib.mkMerge [ + # Unconditional, and pure data: the registry has to be readable for + # `outpost` to resolve its default, and describing a queue commits this + # host to nothing. + { + # Read from the live account through the fleet API, not assumed: the + # names carry an -01 suffix, and magnetite-01 was created without a + # platform. Addressing by id is what makes the suffix a labelling + # detail rather than a claim-time failure. + services.devin-worker.outposts = { + "stibnite-01" = { + id = lib.mkDefault "outpost_env-f47bd2ee30824fe6bc5f9330f67f3670"; + platform = lib.mkDefault "macos"; + description = lib.mkDefault "Apple silicon workstation with a live desktop session for computer use"; + }; + "magnetite-01" = { + id = lib.mkDefault "outpost_env-e178cc2f14f84011b05f52ea17ccdb66"; + # Platform deliberately left null, mirroring the account: the + # outpost was created without one, and asserting linux here + # would be this module inventing a fact the account does not + # carry. + description = lib.mkDefault "x86_64 server capacity for headless sessions"; + }; + }; + } + + (lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.outpost != null; + message = '' + services.devin-worker.outpost is unset and no default resolved: + ${toString (lib.length platformOutposts)} queues in services.devin-worker.outposts are candidates for this + host (platform "${hostOutpostPlatform}", or no platform when none names it). Name the queue this host serves. + ''; + } + { + assertion = cfg.outpost == null || selected != null; + message = '' + services.devin-worker.outpost is "${toString cfg.outpost}", which is not a key of + services.devin-worker.outposts (${lib.concatStringsSep ", " (lib.attrNames cfg.outposts)}). + ''; + } + { + assertion = + cfg.outpost == null + || selected == null + || selected.platform == null + || selected.platform == hostOutpostPlatform; + message = '' + services.devin-worker.outpost "${toString cfg.outpost}" is registered for platform + "${toString (selected.platform or null)}" but this host is "${hostOutpostPlatform}". The worker + validates the machine's OS against the outpost's platform and refuses to serve a mismatch. + ''; + } + { + assertion = cfg.outpost == null || selected == null || selected.id != null; + message = '' + services.devin-worker.outposts.${toString cfg.outpost}.id is null, so this host has no + stable address for the queue it is meant to serve. The web UI issues the id as + outpost_env-<32 hex> when the outpost is created; set it beside that entry's platform. + + The name is not used as the address on purpose: renaming the outpost in the UI would + leave a name-addressed worker failing at claim time rather than here. + ''; + } + { + assertion = cfg.outpost == null || selected == null || selected.tokenFile != null; + message = '' + services.devin-worker is enabled but + services.devin-worker.outposts.${toString cfg.outpost}.tokenFile is null. + Point it at the sops-nix path holding that outpost's own worker token, e.g. + + sops.secrets."devin-outposts-token-" = { mode = "0400"; }; + services.devin-worker.outposts."${toString cfg.outpost}".tokenFile = + config.sops.secrets."devin-outposts-token-".path; + + No entry falls back to another's file, so this cannot be satisfied by a sibling + queue's token even though the credentials are account-scoped for reads. Starting + without one is not a fallback worth taking: the CLI would authenticate as the + operator's personal login and implicitly create an outpost upstream. + ''; + } + ]; + + warnings = lib.optional selectedPlatformUnset '' + services.devin-worker.outposts."${toString cfg.outpost}" carries no platform, so this + host's agreement with it is unestablished rather than confirmed: the account permits an + outpost without a platform and the reference reads that as the account default. This is + neither a match nor a mismatch here, and the worker's own OS validation is the authority + when it claims a session. Set the platform on the outpost upstream, and on this entry, to + have the disagreement caught at evaluation instead. + ''; + + # launchd opens the log file and systemd enters the working directory + # before the launcher runs, so neither can be left to the launcher to + # create on first start. + home.activation.devinWorkerDirectories = + lib.hm.dag.entryBefore + [ + "setupLaunchAgents" + "reloadSystemd" + ] + '' + $DRY_RUN_CMD install -d -m 0700 ${lib.escapeShellArg cfg.stateDir} ${ + lib.escapeShellArgs (map (index: workDir index) indices) + } + ''; + + launchd.agents = lib.mkIf pkgs.stdenv.hostPlatform.isDarwin ( + lib.listToAttrs ( + map ( + index: + lib.nameValuePair (unitName index) { + enable = true; + config = { + ProgramArguments = [ (lib.getExe (launcher index)) ]; + RunAtLoad = true; + KeepAlive = true; + WorkingDirectory = workDir index; + StandardOutPath = logFile index; + StandardErrorPath = logFile index; + # Not "Background": that class caps CPU and I/O priority, + # and a session on this worker runs the repository's builds + # and tests. + ProcessType = "Standard"; + # PATH only. A credential here would be a store-published + # credential; see services.devin-worker.tokenFile. + EnvironmentVariables.PATH = servicePath; + }; + } + ) indices + ) + ); + + systemd.user.services = lib.mkIf pkgs.stdenv.hostPlatform.isLinux ( + lib.listToAttrs ( + map ( + index: + lib.nameValuePair (unitName index) { + Unit = { + Description = "Devin Outposts worker ${toString index} serving the ${toString cfg.outpost} queue"; + After = [ "network-online.target" ]; + Wants = [ "network-online.target" ]; + }; + Service = { + ExecStart = lib.getExe (launcher index); + WorkingDirectory = workDir index; + Restart = "on-failure"; + # A worker that cannot authenticate stays down instead of + # polling the API on a loop; every other failure is treated + # as transient and retried on a slow cadence. + RestartPreventExitStatus = configErrorExit; + RestartSec = 30; + Environment = [ "PATH=${servicePath}" ]; + }; + Install.WantedBy = [ "default.target" ]; + } + ) indices + ) + ); + }) + ]; }; } diff --git a/modules/home/users/crs58/default.nix b/modules/home/users/crs58/default.nix index a06ea2686..b65db7937 100644 --- a/modules/home/users/crs58/default.nix +++ b/modules/home/users/crs58/default.nix @@ -247,6 +247,49 @@ let # modules/home/ai/moshi for how the launcher consumes it. services.moshi-hook.pairingTokenFile = config.sops.secrets.moshi-pairing-token.path; + # Devin Outposts worker tokens, one file per outpost queue so each can be + # rotated on its own. Not a containment boundary: a token issued for one + # outpost lists every outpost through the account-level endpoint, so both + # are account-scoped for reads. See modules/home/ai/devin/worker.nix for + # the rotation procedure and for why the launcher reads the path rather + # than receiving the value. + # + # sops rather than clan vars: clan vars is effectively NixOS-shaped in + # this fleet -- magnetite carries 41 generators against stibnite's one, + # this repository already records a related clan feature as NixOS-only, + # and sops-nix through home-manager already delivers secrets to the + # darwin host for four existing tools. + # + # Declaration and wiring are gated on the service, not because either is + # conditional in spirit but because sops-nix validates every declared key + # against the sops file when the manifest is built (check-mode=sopsfile): + # declaring a key whose ciphertext is not yet in secrets.yaml would fail + # home-manager activation on both hosts for a service that is off. The + # operator's two actions -- add the two ciphertexts, enable the worker on + # a host -- therefore land together, and enabling without the ciphertext + # fails at build time naming the missing key. + # + # Carried as an inline module because `sops.secrets` is already defined + # in the attribute set above, and a conditional slice of it cannot be a + # second definition of the same attribute path in one literal. + imports = [ + { + sops.secrets = lib.mkIf config.services.devin-worker.enable { + devin-outposts-token-stibnite = { + mode = "0400"; + }; + devin-outposts-token-magnetite = { + mode = "0400"; + }; + }; + + services.devin-worker.outposts = lib.mkIf config.services.devin-worker.enable { + "stibnite-01".tokenFile = config.sops.secrets.devin-outposts-token-stibnite.path; + "magnetite-01".tokenFile = config.sops.secrets.devin-outposts-token-magnetite.path; + }; + } + ]; + # Deploy radicle public key (not secret - can be plaintext, but identity-bound) # This is the SSH public key used for Radicle node identity home.file.".radicle/keys/radicle.pub".text = '' From 38747274077ec93d3d6aeef2a974993bb5aa0b3a Mon Sep 17 00:00:00 2001 From: Cameron Smith Date: Tue, 1 Sep 2026 11:32:49 -0400 Subject: [PATCH 5/7] docs(checks): name the actor neutrally in a gate test-case comment The comment recording why quoted ripgrep patterns must stay ungated named the stalled actor with a term from an operating channel rather than with anything this repository defines. The file already calls it an agent worker twelve lines below, so that term is reused. Comment only: no test case, expectation, or gate behavior changes. Verified with nix build .#checks.aarch64-darwin.hook-gate-dangerous-commands. --- modules/checks/hooks.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/checks/hooks.nix b/modules/checks/hooks.nix index 3779cb60a..3c89cbabc 100644 --- a/modules/checks/hooks.nix +++ b/modules/checks/hooks.nix @@ -48,7 +48,7 @@ # A `kill` inside a quoted search pattern is data, not a command: # the `|` alternations of an rg pattern are not shell operators. # Observed 2026-08-12: the gate pattern-matched the quoted word and - # stalled a crewmate on two read-only ripgrep searches. + # stalled an agent worker on two read-only ripgrep searches. "allow rg -n \"process group|kill -.*-\\$|setsid|pgid|kill_tree|_drain\" bin/*.sh | head -40" "allow rg -n 'watchdog|kill -9' modules/ | head -20" # A genuine process termination next to a quoted pattern still gates. From e9b8a5c9a69e0de2ba9c2e526e6f78acfe35660b Mon Sep 17 00:00:00 2001 From: Cameron Smith Date: Tue, 1 Sep 2026 19:20:44 -0400 Subject: [PATCH 6/7] fix(home): stop a tokenless darwin worker, and decouple the two hosts Four review findings, one behavioural and three corrections. RESTART ASYMMETRY. The systemd side stopped a worker that exited with the config-error code, while the launchd side kept `KeepAlive = true` and so respawned a tokenless worker every ten seconds forever -- a permanent loop where linux stopped, contradicting the parity the header implied. launchd has no per-exit-code equivalent of RestartPreventExitStatus, and its KeepAlive dictionary conditions are ORed, so exactly one is usable. PathState on the token file is the one that matches the failure this module actually produces: with no secret rendered there is nothing to keep alive, so the worker stops rather than looping, and it starts on its own once the path appears. ThrottleInterval = 30 aligns the cadence of the failures that do respawn with the systemd side's RestartSec and lifts launchd's ten-second floor. Exact parity is still not reachable, so the header now states what darwin does instead of implying a guarantee: a token file that exists but is empty or unreadable still respawns, every 30s, where linux would stop. Verified by rendering the plist through home-manager's own toPlist: KeepAlive is a dict carrying only PathState on the token path, and ThrottleInterval is 30. PER-HOST SECRET DECLARATIONS. Both hosts previously declared both keys under `enable`, which coupled them through the sops manifest: because sops-nix validates every declared key against the file, one host could not be enabled, nor its token rotated, until the other's ciphertext existed and validated on that machine too -- and each machine decrypted a credential it never uses. Each host now declares only the key for the queue it serves. Verified: enabled stibnite declares devin-outposts-token-stibnite alone and enabled magnetite devin-outposts-token-magnetite alone, each wired to its own path, and a disabled host declares neither. The discriminator is the host platform rather than the resolved outpost name, and that is not a shortcut: these are definitions OF `outposts`, and that option's default is computed FROM `outposts`, so conditioning them on the resolved name is a cycle. Platform is what the module's own two-tier selection keys on, so the two agree by construction, and a host pointed at some other queue fails the module's tokenFile assertion by name rather than starting without a credential. INERT UNIT DEPENDENCIES. `After` and `Wants` on network-online.target are removed. These are user units and a user manager has no such target, so both lines did nothing while implying ordering that was never enforced; Restart with RestartSec already covers a worker that starts before the network. STALE DOCUMENTATION from the -01 rename: the `outpost` example said "magnetite", the `outposts` prose wrote `outposts.magnetite.tokenFile`, its defaultText described one-tier platform matching rather than the two tiers the code resolves, and a plist comment still pointed at services.devin-worker.tokenFile, which no longer exists now that tokenFile is per entry. Verification: devin-worker-structural, which now also pins the launchd KeepAlive path and ThrottleInterval so `KeepAlive = true` cannot come back quietly, plus treefmt, gitleaks, naming-conventions, updater-repository-paths, package-devin-cli, and hook-gate-dangerous-commands all pass on aarch64-darwin; both hosts force-evaluated disabled and enabled. --- modules/checks/devin-worker.nix | 10 +++++ modules/home/ai/devin/worker.nix | 54 +++++++++++++++++------ modules/home/users/crs58/default.nix | 65 ++++++++++++++++++++-------- 3 files changed, 99 insertions(+), 30 deletions(-) diff --git a/modules/checks/devin-worker.nix b/modules/checks/devin-worker.nix index a0d34b7d9..059e6d7e3 100644 --- a/modules/checks/devin-worker.nix +++ b/modules/checks/devin-worker.nix @@ -191,6 +191,14 @@ darwinWorkDirsDistinct = distinctWorkDirs ( map (name: darwin.launchd.agents.${name}.config.WorkingDirectory) (agents darwin) ); + # Restart behaviour, which is where the two supervisors differ. On + # darwin the worker is kept alive by the presence of its own token + # file, so a missing secret stops it instead of respawning at + # launchd's floor; `KeepAlive = true` would reintroduce that loop. + darwinKeepAliveOnTokenPath = + lib.attrNames + darwin.launchd.agents."devin-worker-1".config.KeepAlive.PathState; + darwinThrottleInterval = darwin.launchd.agents."devin-worker-1".config.ThrottleInterval; linuxUnits = units linux; linuxAgents = agents linux; @@ -292,6 +300,8 @@ darwinUnits = [ ]; darwinPlistEnvKeys = [ "PATH" ]; darwinWorkDirsDistinct = true; + darwinKeepAliveOnTokenPath = [ "/run/secrets/devin-outposts-token-stibnite.dummy" ]; + darwinThrottleInterval = 30; linuxUnits = [ "devin-worker-1" diff --git a/modules/home/ai/devin/worker.nix b/modules/home/ai/devin/worker.nix index a7f62aadf..a01a602cc 100644 --- a/modules/home/ai/devin/worker.nix +++ b/modules/home/ai/devin/worker.nix @@ -33,6 +33,19 @@ # cameron on magnetite among others), so the seam is closed at the layer # that owns system users rather than duplicated here. # +# The two supervisors do not reach exact parity on restart, and the difference +# is stated here rather than implied away. systemd stops a worker that exits +# with the config-error code and retries anything else after 30s +# (`RestartPreventExitStatus`). launchd has no per-exit-code equivalent, and +# its `KeepAlive` dictionary conditions are ORed, so only one is usable: +# `PathState` on the token file. What darwin therefore does on a missing token +# is stop -- there is no path to keep alive, so no respawn -- and start on its +# own once the secret is rendered there. What it does NOT do is distinguish a +# token file that exists but is empty or unreadable: the launcher still refuses +# to start, and launchd still respawns it, every 30s under the +# `ThrottleInterval` set here rather than at its ten-second floor. That case is +# a slow loop on darwin where linux would stop. +# # Each worker instance gets its own working directory because a session's # repositories are checked out under `$(pwd)/repos`: two workers sharing a # directory would race on the same checkout. Each also gets its own explicit @@ -319,9 +332,9 @@ `id` or `tokenFile` through a default-carried registry would drop every other entry, and drop the `platform` of the entry it was editing. Definitions merge, so with the registry in the config - layer a caller writing `outposts.magnetite.tokenFile = ...` adds - to it. Overriding a value this module sets needs `lib.mkForce`, - since the module's own entries are `lib.mkDefault`. + layer a caller writing `outposts."magnetite-01".tokenFile = ...` + adds to it. Overriding a value this module sets needs + `lib.mkForce`, since the module's own entries are `lib.mkDefault`. ''; }; @@ -329,10 +342,11 @@ type = lib.types.nullOr lib.types.str; default = if lib.length platformOutposts == 1 then lib.head platformOutposts else null; defaultText = lib.literalMD '' - the single entry of `outposts` whose `platform` matches this host, - or `null` when zero or several match + the single entry of `outposts` naming this host's platform, or when + none names it, the single entry naming no platform; `null` when + zero or several remain ''; - example = "magnetite"; + example = "magnetite-01"; description = '' Queue this host's workers serve. The default resolves whenever the registry holds exactly one queue for this platform, which is what @@ -506,7 +520,25 @@ config = { ProgramArguments = [ (lib.getExe (launcher index)) ]; RunAtLoad = true; - KeepAlive = true; + # launchd has no per-exit-code equivalent of systemd's + # RestartPreventExitStatus, and its dictionary conditions + # are ORed, so exactly one is meaningful. PathState on the + # token file is the one that matches the failure this + # module actually produces: with no secret rendered there + # is nothing to keep alive, so the worker stops instead of + # respawning at launchd's floor, and it starts on its own + # once the path appears. A plain `true` here would loop a + # tokenless worker every ten seconds forever. + KeepAlive = + if selectedTokenFile == null then + true + else + { + PathState.${toString selectedTokenFile} = true; + }; + # Matches RestartSec on the systemd side for the failures + # that do respawn; also lifts launchd's ten-second floor. + ThrottleInterval = 30; WorkingDirectory = workDir index; StandardOutPath = logFile index; StandardErrorPath = logFile index; @@ -515,7 +547,7 @@ # and tests. ProcessType = "Standard"; # PATH only. A credential here would be a store-published - # credential; see services.devin-worker.tokenFile. + # credential; see the outpost entry's tokenFile. EnvironmentVariables.PATH = servicePath; }; } @@ -528,11 +560,7 @@ map ( index: lib.nameValuePair (unitName index) { - Unit = { - Description = "Devin Outposts worker ${toString index} serving the ${toString cfg.outpost} queue"; - After = [ "network-online.target" ]; - Wants = [ "network-online.target" ]; - }; + Unit.Description = "Devin Outposts worker ${toString index} serving the ${toString cfg.outpost} queue"; Service = { ExecStart = lib.getExe (launcher index); WorkingDirectory = workDir index; diff --git a/modules/home/users/crs58/default.nix b/modules/home/users/crs58/default.nix index b65db7937..0652d5e4f 100644 --- a/modules/home/users/crs58/default.nix +++ b/modules/home/users/crs58/default.nix @@ -265,29 +265,60 @@ let # against the sops file when the manifest is built (check-mode=sopsfile): # declaring a key whose ciphertext is not yet in secrets.yaml would fail # home-manager activation on both hosts for a service that is off. The - # operator's two actions -- add the two ciphertexts, enable the worker on - # a host -- therefore land together, and enabling without the ciphertext - # fails at build time naming the missing key. + # operator's actions -- add a ciphertext, enable the worker on that host + # -- therefore land together, and enabling without it fails at build time + # naming the missing key. + # + # Each host declares only the key for the queue it serves, so the two + # stay independent. Declaring both everywhere would couple them through + # the sops manifest: the same build-time validation means one host could + # not be enabled, nor its token rotated, until the other's ciphertext + # also existed and validated on that machine, and each machine would + # decrypt a credential it never uses. + # + # The discriminator is the host platform rather than + # `services.devin-worker.outpost`, because these are definitions OF + # `outposts` and that option's default is computed FROM `outposts`: + # conditioning them on the resolved name is a cycle. Platform is what the + # module's own two-tier selection keys on, so the two agree by + # construction here, and if a host is ever pointed at a different queue + # the module's tokenFile assertion fires by name rather than silently + # serving with no credential. # # Carried as an inline module because `sops.secrets` is already defined # in the attribute set above, and a conditional slice of it cannot be a # second definition of the same attribute path in one literal. imports = [ - { - sops.secrets = lib.mkIf config.services.devin-worker.enable { - devin-outposts-token-stibnite = { - mode = "0400"; - }; - devin-outposts-token-magnetite = { - mode = "0400"; - }; - }; + ( + let + devinEnabled = config.services.devin-worker.enable; + servesStibnite = devinEnabled && pkgs.stdenv.hostPlatform.isDarwin; + servesMagnetite = devinEnabled && pkgs.stdenv.hostPlatform.isLinux; + in + { + sops.secrets = lib.mkMerge [ + (lib.mkIf servesStibnite { + devin-outposts-token-stibnite = { + mode = "0400"; + }; + }) + (lib.mkIf servesMagnetite { + devin-outposts-token-magnetite = { + mode = "0400"; + }; + }) + ]; - services.devin-worker.outposts = lib.mkIf config.services.devin-worker.enable { - "stibnite-01".tokenFile = config.sops.secrets.devin-outposts-token-stibnite.path; - "magnetite-01".tokenFile = config.sops.secrets.devin-outposts-token-magnetite.path; - }; - } + services.devin-worker.outposts = lib.mkMerge [ + (lib.mkIf servesStibnite { + "stibnite-01".tokenFile = config.sops.secrets.devin-outposts-token-stibnite.path; + }) + (lib.mkIf servesMagnetite { + "magnetite-01".tokenFile = config.sops.secrets.devin-outposts-token-magnetite.path; + }) + ]; + } + ) ]; # Deploy radicle public key (not secret - can be plaintext, but identity-bound) From 29c30aa969587fc3f2de18140744d0f41d83ae4d Mon Sep 17 00:00:00 2001 From: Cameron Smith Date: Tue, 1 Sep 2026 20:11:34 -0400 Subject: [PATCH 7/7] fix(home): name the queue each host serves instead of deriving it The platform-keyed selection was wrong and had to go. It read as adequate while one darwin and one linux host had queues, but this repository already carries six NixOS machines and four darwin ones, with pyrite and cinnabar coming up shortly. Under that rule every further linux host that enabled the worker would default onto magnetite-01 and serve its sessions. Devin permits exactly that -- N workers on one queue serve N concurrent sessions -- so nothing upstream would have reported it. `services.devin-worker.outpost` therefore has no default at all. Which machine serves which queue is a deployment decision, not a computable fact. Enabling the worker without naming a queue now fails evaluation and names the host, taking the option's default-reads-its-own-registry coupling with it rather than working around that coupling. `platform` becomes validation only: a platform naming a different OS than the host still fails evaluation, a null platform still produces its own warning and no assertion, and neither picks a queue any more. Both credential gates move to the leaf, keyed on the queue name. The names are literals, so `outposts` contributes the same attribute names whatever the condition, and nothing consults a tokenFile to learn a name or a platform -- so there is no cycle to avoid. Each host declares exactly one sops key and exactly one tokenFile, and a host that serves no queue declares neither. modules/home/users/crs58/default.nix names the two hosts that have queues today, keyed on the machine through osConfig.networking.hostName rather than on its platform. The credential gate requires both that the host owns the queue and that it is the one assigned, so a machine naming another machine's queue gets no credential and fails the tokenFile assertion by name. That second conjunct is not decorative: with only the assigned-queue half, pyrite naming magnetite-01 evaluated cleanly and would have served magnetite's queue on magnetite's credential -- measured, then closed. An optional module argument cannot express this. The module system binds every formal it knows to a thunk that throws when the name cannot be resolved, so `osConfig ? null` does not fall back to its default; it throws wherever home-manager runs standalone. modules/home/mk-home.nix now passes `osConfig = null` explicitly, which is what keeps homeConfigurations."@" evaluable. Verification: devin-worker-structural gains the guard for the machines arriving next -- a linux host enabling without naming a queue fires unset-outpost, one naming a queue whose credential it lacks fires token, and `outpost` is pinned to having no default -- alongside treefmt, gitleaks, naming-conventions, updater-repository-paths, package-devin-cli, hook-gate-dangerous-commands, home-module-exports, and home-configurations-exposed. Both hosts force-evaluated disabled and enabled, the standalone crs58@aarch64-darwin activation package evaluated, and on the real nixosConfigurations.pyrite both enabling unnamed and enabling as magnetite-01 fail. Those host-level probes are deliberately not baked into the check: whole machine toplevels cost about 24s per host per system against roughly a second for the module-level probes, and the module assertion is the mechanism in either case. --- modules/checks/devin-worker.nix | 51 ++++++++++------- modules/home/ai/devin/worker.nix | 82 ++++++++++++++++----------- modules/home/mk-home.nix | 8 +++ modules/home/users/crs58/default.nix | 85 ++++++++++++++++++---------- 4 files changed, 141 insertions(+), 85 deletions(-) diff --git a/modules/checks/devin-worker.nix b/modules/checks/devin-worker.nix index 059e6d7e3..3f3147903 100644 --- a/modules/checks/devin-worker.nix +++ b/modules/checks/devin-worker.nix @@ -81,6 +81,9 @@ }: (inputs.home-manager.lib.homeManagerConfiguration { pkgs = probePkgs system; + # Same reason as modules/home/mk-home.nix: a module formal that the + # module system cannot resolve throws rather than taking its default. + extraSpecialArgs.osConfig = null; modules = [ self.modules.homeManager.devin { @@ -98,6 +101,7 @@ evalBare = system: worker: (lib.evalModules { + specialArgs.osConfig = null; modules = [ self.modules.homeManager.devin { @@ -124,6 +128,7 @@ worker = { enable = true; workers = 2; + outpost = "stibnite-01"; outposts = wired; }; }; @@ -134,6 +139,7 @@ worker = { enable = true; workers = 2; + outpost = "magnetite-01"; outposts = wired; }; }; @@ -155,7 +161,7 @@ "platform" else if lib.hasInfix "not a key of" message then "unknown-outpost" - else if lib.hasInfix "no default resolved" message then + else if lib.hasInfix "has not been told which queue it serves" message then "unset-outpost" else "unrecognized: ${message}" @@ -209,16 +215,15 @@ map (name: linux.systemd.user.services.${name}.Service.WorkingDirectory) (units linux) ); - # Selection: the exact-platform tier picks stibnite-01 on darwin, and - # with no linux-platform entry the no-platform tier picks - # magnetite-01 on linux. Both come from the module's registry, so a - # wrong name or a missing merge shows up here. - darwinOutpost = darwin.services.devin-worker.outpost; + # `outpost` must have nothing to resolve on its own. A default that + # inferred it from the host's platform is what would put every linux + # machine in this repository on the same queue. + outpostDefault = (evalBare "x86_64-linux" { }).services.devin-worker.outpost; + darwinSelectedPlatform = darwin.services.devin-worker.outposts.${darwin.services.devin-worker.outpost}.platform; darwinWarned = warnedClauses darwin; - linuxOutpost = linux.services.devin-worker.outpost; linuxSelectedPlatform = linux.services.devin-worker.outposts.${linux.services.devin-worker.outpost}.platform; # magnetite-01 carries no platform, so agreement with a linux host is @@ -230,6 +235,7 @@ wellFormed = firedClauses ( evalBare "aarch64-darwin" { enable = true; + outpost = "stibnite-01"; outposts = wired; } ); @@ -238,6 +244,7 @@ tokenless = firedClauses ( evalBare "aarch64-darwin" { enable = true; + outpost = "stibnite-01"; outposts = { "magnetite-01".tokenFile = wired."magnetite-01".tokenFile; }; @@ -246,6 +253,7 @@ idless = firedClauses ( evalBare "aarch64-darwin" { enable = true; + outpost = "stibnite-01"; outposts = wired // { "stibnite-01" = { id = null; @@ -277,18 +285,19 @@ outposts = wired; } ); - # Two queues naming this host's platform, so no single default - # resolves and the exact tier never falls through to the other. - unresolvedOutpost = firedClauses ( - evalBare "aarch64-darwin" { + # The regression this module's shape exists to prevent, in the form + # it will arrive: pyrite and cinnabar are linux machines coming up + # shortly on this same user. Enabling the worker there without naming + # a queue must fail, because the alternative -- inferring one -- puts + # them on magnetite's queue, where they would serve its sessions + # perfectly well and report nothing. + secondLinuxHostUnnamed = firedClauses (evalBare "x86_64-linux" { enable = true; }); + # And naming a queue whose credential this host does not have fails + # by name rather than borrowing a sibling's. + secondLinuxHostBorrowing = firedClauses ( + evalBare "x86_64-linux" { enable = true; - outposts = wired // { - spare = { - platform = "macos"; - id = "outpost_env-11111111111111111111111111111111"; - tokenFile = "/run/secrets/devin-outposts-token-spare.dummy"; - }; - }; + outpost = "magnetite-01"; } ); }; @@ -311,10 +320,9 @@ linuxUnitEnvNames = [ "PATH" ]; linuxWorkDirsDistinct = true; - darwinOutpost = "stibnite-01"; + outpostDefault = null; darwinSelectedPlatform = "macos"; darwinWarned = [ ]; - linuxOutpost = "magnetite-01"; linuxSelectedPlatform = null; linuxWarned = [ "platform-unset" ]; @@ -325,7 +333,8 @@ platformUnsetFired = [ ]; platformUnsetWarned = [ "platform-unset" ]; unknownOutpost = [ "unknown-outpost" ]; - unresolvedOutpost = [ "unset-outpost" ]; + secondLinuxHostUnnamed = [ "unset-outpost" ]; + secondLinuxHostBorrowing = [ "token" ]; }; }; }; diff --git a/modules/home/ai/devin/worker.nix b/modules/home/ai/devin/worker.nix index a01a602cc..4238aed57 100644 --- a/modules/home/ai/devin/worker.nix +++ b/modules/home/ai/devin/worker.nix @@ -13,6 +13,16 @@ # queue when starting the session and cannot know which worker is free. # Concurrency belongs on the worker count. # +# Which queue a host serves is named per host, never derived. `outpost` has no +# default: enabling the service without naming a queue fails evaluation and +# names the host. The tempting inference -- serve the queue whose platform +# matches this host -- is wrong here, because this repository carries six +# NixOS machines and four darwin ones, so it would put every linux host that +# enabled the service onto the same linux queue. Those workers would serve +# another machine's sessions perfectly well, since N workers on one queue +# serve N concurrent sessions, which is exactly why nothing would report it. +# `platform` validates the pairing a host names; it never picks one. +# # Per host the platform decides the supervisor: # # * darwin gets a launchd USER AGENT, deliberately not a system daemon. @@ -85,6 +95,9 @@ config, lib, pkgs, + # Supplied as a specialArg by home-manager's NixOS and nix-darwin + # modules, absent in a standalone home configuration. + osConfig ? null, ... }: let @@ -92,24 +105,20 @@ hostOutpostPlatform = if pkgs.stdenv.hostPlatform.isDarwin then "macos" else "linux"; - # A registry entry's platform may be null: the account permits creating - # an outpost without one, and the reference reads a null platform as the - # account default rather than as any particular OS. Candidates are - # therefore resolved in two tiers -- entries that name this host's - # platform, and only if there are none, entries that name no platform at - # all. That gives each host in this fleet exactly one candidate - # (stibnite-01 names macos; magnetite-01 names nothing) without treating - # a null as equal to linux. - exactPlatformOutposts = lib.attrNames ( - lib.filterAttrs (_: outpost: outpost.platform == hostOutpostPlatform) cfg.outposts - ); - - unsetPlatformOutposts = lib.attrNames ( - lib.filterAttrs (_: outpost: outpost.platform == null) cfg.outposts - ); - - platformOutposts = - if exactPlatformOutposts != [ ] then exactPlatformOutposts else unsetPlatformOutposts; + # Which host serves which queue is a deployment decision, so it is named + # rather than derived. `platform` validates the pairing; it does not pick + # it. Deriving the queue from the platform looked adequate while one + # darwin and one linux host had queues, but this repository already + # carries six NixOS machines and four darwin ones: under that rule every + # further linux host that enabled the service would default onto + # magnetite's queue and silently serve its sessions, which Devin permits + # -- N workers on one queue serve N concurrent sessions -- and so would + # not surface as an error anywhere. + hostLabel = + if osConfig != null then + osConfig.networking.hostName + else + "${config.home.username or "this user"}@${pkgs.stdenv.hostPlatform.system}"; selected = if cfg.outpost == null then null else cfg.outposts.${cfg.outpost} or null; @@ -340,20 +349,22 @@ outpost = lib.mkOption { type = lib.types.nullOr lib.types.str; - default = if lib.length platformOutposts == 1 then lib.head platformOutposts else null; - defaultText = lib.literalMD '' - the single entry of `outposts` naming this host's platform, or when - none names it, the single entry naming no platform; `null` when - zero or several remain - ''; + default = null; example = "magnetite-01"; description = '' - Queue this host's workers serve. The default resolves whenever the - registry holds exactly one queue for this platform, which is what - makes the two hosts in this repository work without configuration; - a third host of an existing platform has to name its own queue, - because serving another machine's queue would send that machine's - sessions here. + Queue this host's workers serve, named per host. Required whenever + the service is enabled: enabling without it fails evaluation and + names the host. + + There is deliberately no default to infer it from. Which machine + serves which queue is a deployment decision, not a computable + fact, and the obvious inference -- take the queue whose platform + matches this host -- is actively wrong at fleet scale: with six + NixOS machines in this repository, every linux host that enabled + the service would land on the same linux queue and quietly serve + another machine's sessions. Devin allows that, since N workers on + one queue serve N concurrent sessions, so nothing upstream would + report it. ''; }; @@ -433,9 +444,14 @@ { assertion = cfg.outpost != null; message = '' - services.devin-worker.outpost is unset and no default resolved: - ${toString (lib.length platformOutposts)} queues in services.devin-worker.outposts are candidates for this - host (platform "${hostOutpostPlatform}", or no platform when none names it). Name the queue this host serves. + services.devin-worker is enabled on ${hostLabel} but services.devin-worker.outpost is + unset, so this host has not been told which queue it serves. Name it, e.g. + services.devin-worker.outpost = "magnetite-01"; known queues are + ${lib.concatStringsSep ", " (lib.attrNames cfg.outposts)}. + + There is no default on purpose. Inferring the queue from this host's platform would + put every linux host in this repository on the same queue, serving another machine's + sessions without any error to notice. ''; } { diff --git a/modules/home/mk-home.nix b/modules/home/mk-home.nix index e876d0830..7f8175752 100644 --- a/modules/home/mk-home.nix +++ b/modules/home/mk-home.nix @@ -21,6 +21,14 @@ flake = config.flake // { inherit inputs; }; + # home-manager's NixOS and nix-darwin modules pass the enclosing system + # configuration under this name; a standalone home configuration has no + # such system, and saying so explicitly is what keeps a module that + # takes the argument evaluable here. An absent module argument is not + # the same as one that is null: the module system binds every formal it + # knows about, including optional ones, to a thunk that throws when the + # name cannot be resolved, so the formal's own default never applies. + osConfig = null; }; modules = config.flake.users.${user}.modules; }; diff --git a/modules/home/users/crs58/default.nix b/modules/home/users/crs58/default.nix index 0652d5e4f..e3d25f80b 100644 --- a/modules/home/users/crs58/default.nix +++ b/modules/home/users/crs58/default.nix @@ -10,6 +10,8 @@ let pkgs, lib, flake, # from extraSpecialArgs + # from home-manager's NixOS and nix-darwin modules; absent standalone + osConfig ? null, ... }: let @@ -264,26 +266,29 @@ let # conditional in spirit but because sops-nix validates every declared key # against the sops file when the manifest is built (check-mode=sopsfile): # declaring a key whose ciphertext is not yet in secrets.yaml would fail - # home-manager activation on both hosts for a service that is off. The - # operator's actions -- add a ciphertext, enable the worker on that host - # -- therefore land together, and enabling without it fails at build time - # naming the missing key. + # home-manager activation for a service that is off. The operator's + # actions -- add a ciphertext, enable the worker on that host -- land + # together, and enabling without it fails at build time naming the + # missing key. # - # Each host declares only the key for the queue it serves, so the two - # stay independent. Declaring both everywhere would couple them through - # the sops manifest: the same build-time validation means one host could - # not be enabled, nor its token rotated, until the other's ciphertext - # also existed and validated on that machine, and each machine would - # decrypt a credential it never uses. + # They are gated again on the queue the host actually serves, so a host + # declares exactly one key and exactly one tokenFile, and a host serving + # no queue declares neither. Declaring every key everywhere would couple + # the machines through that same validation: none could be enabled, nor + # its token rotated, until every other ciphertext existed and validated + # on it too, and each machine would decrypt credentials it never uses. # - # The discriminator is the host platform rather than - # `services.devin-worker.outpost`, because these are definitions OF - # `outposts` and that option's default is computed FROM `outposts`: - # conditioning them on the resolved name is a cycle. Platform is what the - # module's own two-tier selection keys on, so the two agree by - # construction here, and if a host is ever pointed at a different queue - # the module's tokenFile assertion fires by name rather than silently - # serving with no credential. + # The second gate sits on the leaf rather than on the attribute set: the + # outpost names are literals either way, so `outposts` contributes the + # same attribute names whatever the condition, and only the tokenFile + # value is conditional. Nothing consults a tokenFile to learn the names + # or the platforms, so there is no cycle here. + # + # Which queue each host serves is a deployment fact, so it is keyed on + # the machine, not on its platform: six NixOS machines share this user, + # and a platform-keyed rule would put every one of them on magnetite's + # queue. A machine absent from this table serves no queue, and enabling + # the worker there fails evaluation naming it. # # Carried as an inline module because `sops.secrets` is already defined # in the attribute set above, and a conditional slice of it cannot be a @@ -291,32 +296,50 @@ let imports = [ ( let - devinEnabled = config.services.devin-worker.enable; - servesStibnite = devinEnabled && pkgs.stdenv.hostPlatform.isDarwin; - servesMagnetite = devinEnabled && pkgs.stdenv.hostPlatform.isLinux; + outpostByHost = { + stibnite = "stibnite-01"; + magnetite = "magnetite-01"; + }; + + hostName = if osConfig == null then null else osConfig.networking.hostName; + hostOutpost = if hostName == null then null else outpostByHost.${hostName} or null; + + # Both conjuncts matter. The host's own entry in the table is what + # makes a machine with no queue declare nothing, even if someone + # names another machine's queue on it -- that host then has no + # credential and the module's tokenFile assertion fails the build + # by name, rather than the host quietly serving sessions from a + # queue that is not its own on a credential that is not its own. + # The second conjunct keeps a key from being declared on a host + # whose assignment has been pointed elsewhere. + serves = + name: + config.services.devin-worker.enable + && hostOutpost == name + && config.services.devin-worker.outpost == name; in { + services.devin-worker.outpost = lib.mkIf (hostOutpost != null) (lib.mkDefault hostOutpost); + sops.secrets = lib.mkMerge [ - (lib.mkIf servesStibnite { + (lib.mkIf (serves "stibnite-01") { devin-outposts-token-stibnite = { mode = "0400"; }; }) - (lib.mkIf servesMagnetite { + (lib.mkIf (serves "magnetite-01") { devin-outposts-token-magnetite = { mode = "0400"; }; }) ]; - services.devin-worker.outposts = lib.mkMerge [ - (lib.mkIf servesStibnite { - "stibnite-01".tokenFile = config.sops.secrets.devin-outposts-token-stibnite.path; - }) - (lib.mkIf servesMagnetite { - "magnetite-01".tokenFile = config.sops.secrets.devin-outposts-token-magnetite.path; - }) - ]; + services.devin-worker.outposts = { + "stibnite-01".tokenFile = + lib.mkIf (serves "stibnite-01") config.sops.secrets.devin-outposts-token-stibnite.path; + "magnetite-01".tokenFile = + lib.mkIf (serves "magnetite-01") config.sops.secrets.devin-outposts-token-magnetite.path; + }; } ) ];