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/modules/checks/devin-worker.nix b/modules/checks/devin-worker.nix new file mode 100644 index 000000000..3f3147903 --- /dev/null +++ b/modules/checks/devin-worker.nix @@ -0,0 +1,341 @@ +# 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. 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 +# 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; + + # 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; + # 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 + { + 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 { + specialArgs.osConfig = null; + 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; + + # 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; + outpost = "stibnite-01"; + outposts = wired; + }; + }; + + linux = evalHome { + system = "x86_64-linux"; + homeDirectory = "/home/probe"; + worker = { + enable = true; + workers = 2; + outpost = "magnetite-01"; + outposts = wired; + }; + }; + + 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 "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 + "unknown-outpost" + else if lib.hasInfix "has not been told which queue it serves" message then + "unset-outpost" + else + "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 + { + 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) + ); + # 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; + 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) + ); + + # `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; + + 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; + outpost = "stibnite-01"; + 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; + outpost = "stibnite-01"; + outposts = { + "magnetite-01".tokenFile = wired."magnetite-01".tokenFile; + }; + } + ); + idless = firedClauses ( + evalBare "aarch64-darwin" { + enable = true; + outpost = "stibnite-01"; + 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-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"; + outposts = wired; + } + ); + # 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; + outpost = "magnetite-01"; + } + ); + }; + expected = { + darwinAgents = [ + "devin-worker-1" + "devin-worker-2" + ]; + darwinUnits = [ ]; + darwinPlistEnvKeys = [ "PATH" ]; + darwinWorkDirsDistinct = true; + darwinKeepAliveOnTokenPath = [ "/run/secrets/devin-outposts-token-stibnite.dummy" ]; + darwinThrottleInterval = 30; + + linuxUnits = [ + "devin-worker-1" + "devin-worker-2" + ]; + linuxAgents = [ ]; + linuxUnitEnvNames = [ "PATH" ]; + linuxWorkDirsDistinct = true; + + outpostDefault = null; + darwinSelectedPlatform = "macos"; + darwinWarned = [ ]; + linuxSelectedPlatform = null; + linuxWarned = [ "platform-unset" ]; + + wellFormed = [ ]; + tokenless = [ "token" ]; + idless = [ "id" ]; + platformMismatch = [ "platform" ]; + platformUnsetFired = [ ]; + platformUnsetWarned = [ "platform-unset" ]; + unknownOutpost = [ "unknown-outpost" ]; + secondLinuxHostUnnamed = [ "unset-outpost" ]; + secondLinuxHostBorrowing = [ "token" ]; + }; + }; + }; +} 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. 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..4238aed57 --- /dev/null +++ b/modules/home/ai/devin/worker.nix @@ -0,0 +1,599 @@ +# 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. +# +# 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. +# 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. +# +# 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 +# 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 = + { + 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 + cfg = config.services.devin-worker; + + hostOutpostPlatform = if pkgs.stdenv.hostPlatform.isDarwin then "macos" else "linux"; + + # 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; + + # 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}"; + + 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. + # + # 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 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 + 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 + + # 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 selectedOutpostId} ${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 = { + 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.nullOr ( + lib.types.enum [ + "linux" + "macos" + "windows" + ] + ); + default = null; + description = '' + 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. + ''; + }; + + description = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Human-readable description, as shown for the outpost in the Devin web app."; + }; + }; + } + ); + 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-01".tokenFile = ...` + adds to it. Overriding a value this module sets needs + `lib.mkForce`, since the module's own entries are `lib.mkDefault`. + ''; + }; + + outpost = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "magnetite-01"; + description = '' + 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. + ''; + }; + + 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. + ''; + }; + + 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.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 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. + ''; + } + { + 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; + # 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; + # 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 the outpost entry's 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"; + 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/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 a06ea2686..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 @@ -247,6 +249,101 @@ 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 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. + # + # 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 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 + # second definition of the same attribute path in one literal. + imports = [ + ( + let + 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 (serves "stibnite-01") { + devin-outposts-token-stibnite = { + mode = "0400"; + }; + }) + (lib.mkIf (serves "magnetite-01") { + devin-outposts-token-magnetite = { + mode = "0400"; + }; + }) + ]; + + 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; + }; + } + ) + ]; + # 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 = '' diff --git a/pkgs/by-name/devin-cli/package.nix b/pkgs/by-name/devin-cli/package.nix new file mode 100644 index 000000000..45e1a9ff3 --- /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.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-+I7azqaSVTkQ1y8nVRW9C1K10nHVUlCYGwxBARFC0ns="; + }; + + aarch64-linux = fetchurl { + url = "https://static.devin.ai/cli/${version}/devin-${version}-aarch64-unknown-linux.tar.gz"; + 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-/dBoEgd9XP7lZM7XolbQueJD7kZ9VSGUJmKXFETF2lQ="; + }; + }; +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}"