Skip to content

feat: package devin cli and declare devin outposts workers - #2890

Open
cameronraysmith wants to merge 7 commits into
mainfrom
fm/vx-devin-outposts
Open

feat: package devin cli and declare devin outposts workers#2890
cameronraysmith wants to merge 7 commits into
mainfrom
fm/vx-devin-outposts

Conversation

@cameronraysmith

@cameronraysmith cameronraysmith commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Packages the Devin CLI and declares Devin Outposts workers so stibnite and magnetite can serve sessions dispatched from Devin Cloud. An outpost is a named queue of sessions in Devin Cloud; a worker watches that queue, claims a session, and executes every command, file edit, and repository operation locally while Devin's planning loop stays in their cloud. Workers need outbound HTTPS only.

Everything lands disabled. No credential is populated, and nothing here reaches upstream to create or delete an outpost.

Module type per host, and why

stibnite (aarch64-darwin): launchd user agent, via home-manager launchd.agents.<name>. Not a system daemon, and not by preference. Devin's macOS 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 nothing in the configuration to point at. A consequence worth knowing before granting: macOS keys those grants to the executable, which is a store path here, so a CLI version bump changes the path and the grants have to be given again.

magnetite (x86_64-linux): user-scoped systemd service, via home-manager systemd.user.services.<name>. A user manager stops with the last login session, so serving a queue with nobody logged in also needs users.users.<name>.linger = true.

The linger seam. linger is a NixOS option a home-manager module cannot reach, and this change adds no system module for it, because the seam is already closed at the layer that owns system users: modules/clan/inventory/services/users/cameron.nix sets linger = true for cameron on every modern NixOS machine, magnetite among them, guarded by lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux. The worker module documents the dependency and points at that file rather than duplicating the setting in a second place where the two could disagree.

Both parts are home-manager rather than system modules for the same underlying reason: a session runs as a user, with that user's permissions, credentials, and desktop session.

The registry: queues, ids, and platforms

services.devin-worker.outposts is a registry of queues keyed by their Devin Cloud name, carrying the values read from the live account through the fleet API rather than assumed:

name id platform
stibnite-01 outpost_env-f47bd2ee30824fe6bc5f9330f67f3670 macos
magnetite-01 outpost_env-e178cc2f14f84011b05f52ea17ccdb66 none — created without one

Workers are addressed by id, not by name. The -01 suffix is exactly why: a name is the operator's label and can be changed in the UI, and a name-addressed worker would keep polling and then 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, since a queue that cannot be addressed is not a working default. Verified against the built launcher: its argv carries --outpost=outpost_env-f47bd2ee… and no bare name.

A null platform is its own state, neither a match nor a mismatch. magnetite-01 was created without a platform, and the reference reads null as the account default rather than as an OS, so this module neither models it as linux nor papers over it:

  • platform names a different OS than the host — mismatch, fails evaluation, because the worker validates the machine's OS against the outpost's platform and failing here beats discovering it as sessions are claimed and released;
  • platform names this host's OS — agreement, silent;
  • platform is null — a warning naming the outpost, and no assertion. Whether a no-platform queue can serve this worker is unproven, and the worker's own OS validation remains the authority at claim time.

checks.<system>.devin-worker-structural names all three separately: darwin serving stibnite-01 with platform macos and no warning; linux serving magnetite-01 with platform null and the platform-unset warning and no fired assertion; a probe with both platforms named and different is the only one that fires the platform assertion. Magnetite's enabled evaluation emits that warning in build output.

The queue a host serves is named, 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 at this repository's scale, because it carries six NixOS machines and four darwin ones, so every linux host that enabled the service would land on magnetite-01 and serve its sessions. Devin permits exactly that (N workers on one queue serve N concurrent sessions), which is why nothing upstream would report it. platform validates the pairing a host names; it never picks one.

Queues are not named per machine index. stibnite-1, stibnite-2 would partition the queue: the operator picks a queue when starting a session and cannot know which worker is free, so a session would wait on a busy queue while its sibling sat idle. Concurrency belongs on workers — N workers on one outpost serve N concurrent sessions. That rationale is recorded in the module header.

Each instance gets its own working directory, because a session's repositories are checked out under $(pwd)/repos and two instances would otherwise race on one checkout, and its own explicit acceptor id (<hostname>-<outpost>-<index>, resolved at start), because the upstream default is generated per worker data directory, which the instances on one host share, and an id must never be shared.

Token plumbing: per queue, fail-closed, and account-scoped

Each registry entry names its own tokenFile; there is no host-level or module-level token option. A queue whose file is missing refuses to start rather than running addressed at one queue while holding another's credential, and an assertion names the entry at evaluation time. The launcher reads the file at start and exits EX_CONFIG (78) when it is missing or empty. It never falls back to the CLI login, which is documented upstream but would authenticate as the operator's personal login and implicitly create an outpost upstream.

What each supervisor does with a tokenless worker, which is not identical. systemd stops it: Restart = on-failure with RestartPreventExitStatus = 78. launchd has no per-exit-code equivalent, and its KeepAlive dictionary conditions are ORed, so exactly one is usable — PathState on the token file. On darwin a missing token therefore stops the worker, because there is no path to keep alive, and starts it on its own once the secret is rendered there; KeepAlive = true would instead have respawned it every ten seconds forever. What darwin still does not do is distinguish a token file that exists but is empty or unreadable: that case respawns, every 30s under ThrottleInterval rather than at launchd's floor, where linux would stop. The module header states this limitation rather than implying parity, and the structural check pins the PathState key and the interval so KeepAlive = true cannot return quietly.

These files are for rotation, not for containment. Measured against the live account: a token issued for one outpost lists every outpost through the account-level endpoint, so the credentials are account-scoped for reads despite being issued per outpost. Separate files buy independent rotation, not a smaller blast radius. Every comment and option description in the branch says this; an earlier revision of this description claimed per-outpost confinement and was wrong.

The credential reaches the process through DEVIN_OUTPOSTS_TOKEN, set by the launcher from the file — never through a launchd plist's EnvironmentVariables or a systemd unit's Environment, both of which are world-readable Nix store files. That is the same defect class as an API token in a rendered YAML file. The structural check asserts the service environment carries PATH and nothing else, so the shortcut cannot be reintroduced quietly.

Rotation is documented in the module, not only here (modules/home/ai/devin/worker.nix header): rotate the token in the Devin UI for that outpost — no rebuild is needed for that step alone, since nothing in this repository holds the value; replace that one key's value in secrets/home-manager/users/crs58/secrets.yaml, leaving the other outpost's entry untouched; then re-activate that host so the new value reaches the worker's runtime path and restart the worker service. The restart is not optional: the launcher reads the token once, at process start, so a running worker keeps using the old value however current the file on disk has become.

Secrets are 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 the corresponding secret's .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; that is the operator's.

The declarations are gated twice, and neither gate is cosmetic. On the service being enabled, because 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 — and reddens home-manager-crs58 in CI — for a service that is off. And on the host: each machine declares only the key for the queue it serves. Declaring both everywhere would couple the hosts through that same validation, so neither could be enabled, nor its token rotated, until the other's ciphertext existed and validated on both machines, and each machine would decrypt a credential it never uses. Verified: enabled stibnite declares devin-outposts-token-stibnite alone, enabled magnetite devin-outposts-token-magnetite alone, and a disabled host declares neither.

Which queue each host serves is keyed on the machine, not its platform, in a small outpostByHost table (stibnitestibnite-01, magnetitemagnetite-01) read from osConfig.networking.hostName; a machine absent from it serves no queue and declares nothing. The credential gate requires both that the host owns the queue and that it is the one assigned, so a machine that names another machine's queue gets no credential and fails the module's tokenFile assertion by name rather than borrowing one. Measured on the real configurations: enabling on pyrite without naming a queue fails, and naming magnetite-01 there also fails.

The gate sits on the leaf (outposts."<name>".tokenFile) rather than on the attribute set, so outposts contributes the same literal attribute names whatever the condition and nothing consults a tokenFile to learn the names or platforms.

Configuration file

programs.devin renders ~/.config/devin/config.json from a typed subset of the documented surface — model, autoUpdate, notify, themeMode, attribution — plus a settings escape hatch for the rest (permissions, keymap, proxy, sandbox). A null-valued option emits no key, so the CLI keeps its own default rather than being pinned to a value this module invented. The file contains no credential field of any kind, which is why rendering it into the world-readable store is safe.

autoUpdate defaults to false: the binary comes from the store, where it is read-only and its version is a property of the generation, and upstream's background updater promotes new versions by swapping a symlink in a self-managed installation that does not exist here. nix run .#update-devin-cli is the update path.

Two consequences of a declarative config the module documents: the rendered file is a read-only store symlink, so anything the CLI would persist itself (the first-run theme prompt, bindings saved from /shortcuts) has to be declared in settings; and the worker process reads the same file, which is the other reason auto_update must stay off.

Package

pkgs/by-name/devin-cli/ carries the nixpkgs derivation byte-identical below a provenance header. It is vendored because the pinned channel is at 3000.3.22 while this worker surface 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 on every machine — verified: both darwinConfigurations.stibnite.pkgs.devin-cli and nixosConfigurations.magnetite.pkgs.devin-cli resolve to the vendored build.

Upstream's update.sh was rewritten rather than copied. Its version probe resolves the 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 checks.updater-repository-paths has enforced since #2888 — and declares git among its nix-shell dependencies, as that check also requires.

One deviation worth calling out because it enlarges the diff: nixpkgs vendors 3000.6.2, and this branch lands 3000.6.7. The bump is exactly the output of nix run .#update-devin-cli, run to check that the updater works rather than only that it passes the guard — a script can resolve the repository root correctly and still fail to find or rewrite anything. Keeping its output means the updater's correctness is evidenced by the artifact rather than by a discarded experiment, and each hash is visible landing in its own platform's block, which is the part most able to go wrong silently.

A defect found while wiring the credentials

The registry first 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, so evaluation would have failed. Definitions merge where defaults do not, so the fleet's queues moved into the module's config layer as mkDefault values. The check's probes now supply only token files and take ids, platforms, and names from the module, so a regression here fails them.

Verification

Selected as the checks that would fail if this change were wrong, rather than the full set.

  • checks.aarch64-darwin.updater-repository-paths — passes; the guard this change had to satisfy.
  • checks.aarch64-darwin.package-devin-cli, and nix build .#packages.aarch64-darwin.devin-cli — the result reports devin 3000.6.7 (260a97c8).
  • nix run .#update-devin-cli — bumped the version and refreshed all three platform hashes into the correct blocks.
  • checks.aarch64-darwin.devin-worker-structural (eval-only) — pins the launchd restart conditions (KeepAlive.PathState on the token file, ThrottleInterval = 30), platform routing and fan-out (workers = 2 gives two launchd agents and no systemd units on darwin, the mirror on linux); asserts the service environment carries only PATH; asserts the per-instance working directories are distinct; pins that outpost has no default at all, and the selected queue's platform value; and drives each assertion clause with its own malformed input alongside a well-formed control, so neither a tautology nor an always-firing predicate passes. The token probe leaves the sibling queue wired, so a cross-queue fallback would fail it. Positive claims run through a real minimal homeManagerConfiguration on both platforms so plist keys and unit sections are validated by the actual option types; the assertion and warning claims run through a bare lib.evalModules, because home-manager throws on the whole configuration when an assertion fails and so hides which clause fired.
  • The regression guard for the machines arriving next, in the check: a linux host enabling the worker without naming a queue fires the unset-outpost assertion, and one naming a queue whose credential it does not hold fires the token assertion. Both were also measured against the real nixosConfigurations.pyrite, where enabling unnamed and enabling as magnetite-01 each fail evaluation. Those host-level evaluations are deliberately not baked into the check: evaluating whole machine toplevels costs 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.
  • darwinConfigurations.stibnite and nixosConfigurations.magnetite force-evaluated both disabled and, via extendModules, enabled — where enabling needs nothing but enable = true, because the id comes from the registry and the outpost and token path from the per-host wiring. The enabled evaluations also prove the module is reachable in each host's home-manager closure (crs58 on stibnite, cameron on magnetite), and magnetite's emits the platform-unset warning with no assertion.
  • The darwin launcher built, so shellcheck ran over the launcher script, and it was read back to confirm it addresses the queue by id, reads its own token file, and never mentions the sibling queue.
  • The rendered config.json built and was read back — only documented keys, no nulls.
  • checks.aarch64-darwin.treefmt, gitleaks, naming-conventions — pass.

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 cover the module's reachability from both configurations. Adding pkgs/by-name/devin-cli also auto-creates package-devin-cli on aarch64-linux and x86_64-linux, which CI will build.

Non-goals honored

No outpost created or deleted upstream. No credential populated, and no ciphertext added to any sops file. Services disabled by default, mirroring how the nixbot module landed before its deployment. No passwordless sudo anywhere — upstream warns against it on long-lived machines and both hosts are long-lived. No ffmpeg, Chrome, or Xvfb. git is in the launcher's runtime inputs, since upstream lists it as the one required machine dependency: every repository operation in a session is a git invocation on this machine.

Repository-wide seam

mk-home.nix now passes extraSpecialArgs.osConfig = null for every user's standalone home configuration, not only for the one that enables a worker. The module system binds every declared formal to a throwing thunk, so an osConfig ? null default inside the consuming module is not sufficient on its own: standalone homeConfigurations have no NixOS or nix-darwin wrapper to supply that argument, and evaluation fails before the default applies. Passing an explicit null is therefore required rather than defensive. The visible consequence is that any module may now take osConfig as a formal and test it for null to distinguish a host-attached home from a standalone one; a standalone evaluation reports user@system where a host-attached one reports its hostname.

@cameronraysmith cameronraysmith changed the title feat: package Devin CLI and declare Devin Outposts workers feat: package devin cli and declare devin outposts workers Sep 1, 2026
@mergify

mergify Bot commented Sep 1, 2026

Copy link
Copy Markdown

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Triggered from #2890 by @​cameronraysmith.

Checking if we can fast forward main (d5a7ab7) to fm/vx-devin-outposts (e8b2ceb).

Target branch (main):

commit d5a7ab75e880812e3964f82279f85febf7e43006 (HEAD -> main, origin/main)
Author: Cameron Smith <cameron.ray.smith@gmail.com>
Date:   Tue Sep 1 18:19:18 2026 -0400

    claude-code: 2.1.252 -> 2.1.258

Pull request (fm/vx-devin-outposts):

commit e8b2ceb200308346678e221ccac95fc8f01aca2b (pull_request/fm/vx-devin-outposts)
Author: Cameron Smith <cameron.ray.smith@gmail.com>
Date:   Tue Sep 1 20:11:34 2026 -0400

    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."<user>@<system>" 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.

Can't fast forward main (d5a7ab7) to fm/vx-devin-outposts (e8b2ceb). main (d5a7ab7) is not a direct ancestor of fm/vx-devin-outposts (e8b2ceb). Branches appear to have diverged at bd9a7ea:

* e8b2ceb200308346678e221ccac95fc8f01aca2b fix(home): name the queue each host serves instead of deriving it
* 9769a88d5ff36b5bedca3195b44d25e0a866a1ee fix(home): stop a tokenless darwin worker, and decouple the two hosts
* 89f611f21766a92f383f6c46426dc0cebc128d98 docs(checks): name the actor neutrally in a gate test-case comment
* df291d41f3b9b0f264add90b661b509ad43378d3 fix(home): per-outpost credentials, id addressing, live registry values
* c15a04f79c6f8033b6915b1533e40deaa9c0b1c7 chore(pkgs): devin-cli 3000.6.2 -> 3000.6.7 via its own updater
* 5b57cfd050003dd2920a3a37011254dca93dcd4b feat(home): declare Devin CLI configuration and Outposts workers
* e360b7a2eb8cedd873705fe017de76cd0f797658 feat(pkgs): vendor devin-cli 3000.6.2 with a repo-root updater
| * d5a7ab75e880812e3964f82279f85febf7e43006 claude-code: 2.1.252 -> 2.1.258
|/  
* bd9a7eaf0a7088c4dfe8da9943e8fca4a1c62410 fix(linear-cli): update to 2.5.0

commit bd9a7eaf0a7088c4dfe8da9943e8fca4a1c62410
Author: Cameron Smith <cameron.ray.smith@gmail.com>
Date:   Tue Sep 1 00:38:49 2026 -0400

    fix(linear-cli): update to 2.5.0

Rebase locally, and then force push to fm/vx-devin-outposts.

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).
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.<system>.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.
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.
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.
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.
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.
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."<user>@<system>" 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant