From d5f1caab65959bc611eaf7f457385d911921ee46 Mon Sep 17 00:00:00 2001 From: Zuckerberg Date: Wed, 3 Jun 2026 13:21:22 -0700 Subject: [PATCH] Add hermes --- common/sandboxed-workspace/default.nix | 55 +++++++++- common/sandboxed-workspace/home.nix | 8 ++ common/sandboxed-workspace/incus.nix | 4 + flake.lock | 143 +++++++++++++++++++++++++ flake.nix | 13 +++ machines/fry/default.nix | 25 +++++ machines/fry/workspaces/hermes.nix | 41 +++++++ secrets/hermes-env.age | 15 +++ secrets/secrets.nix | 3 + 9 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 machines/fry/workspaces/hermes.nix create mode 100644 secrets/hermes-env.age diff --git a/common/sandboxed-workspace/default.nix b/common/sandboxed-workspace/default.nix index 6937a86..18c9f31 100644 --- a/common/sandboxed-workspace/default.nix +++ b/common/sandboxed-workspace/default.nix @@ -74,6 +74,52 @@ in See: https://man7.org/linux/man-pages/man7/vsock.7.html ''; }; + + extraMounts = mkOption { + type = types.attrsOf (types.submodule { + options = { + hostPath = mkOption { + type = types.str; + description = "Path on the host to bind-mount into the workspace."; + }; + containerPath = mkOption { + type = types.str; + description = "Mount point inside the workspace."; + }; + createHostPath = mkOption { + type = types.bool; + default = true; + description = '' + Whether the workspace setup service should `mkdir -p` the + hostPath before the workspace starts. Set to false when the + source is managed by another service (e.g. an agenix secret + file at /run/agenix/) so the setup doesn't race or + collide with the producing service. + ''; + }; + shift = mkOption { + type = types.bool; + default = true; + description = '' + Pass `shift=true` to the Incus disk device so host UIDs/GIDs + are remapped into the container's userns. Set to false when + the source filesystem can't be idmapped (e.g. tmpfs under + /run on kernels without tmpfs-idmap support). With shift + disabled, host-owned files show up as nobody:nogroup inside + the container — make the file world-readable (mode 0444) if + you need processes inside to read it. + ''; + }; + }; + }); + default = { }; + description = '' + Additional host→workspace bind mounts beyond the default workspace/, + ssh-host-keys/, and claude-config/ mounts. Useful for persisting state + (e.g. /var/lib/hermes) across container recreations on nixos-rebuild. + Only honored by the "incus" backend currently. + ''; + }; }; }); default = { }; @@ -82,6 +128,13 @@ in }; config = mkIf cfg.enable { + assertions = lib.mapAttrsToList + (name: ws: { + assertion = ws.extraMounts == { } || ws.type == "incus"; + message = ''sandboxed-workspace.workspaces.${name}: extraMounts is only supported for type = "incus" (got "${ws.type}").''; + }) + cfg.workspaces; + # Automatically enable sandbox networking when workspaces are defined networking.sandbox.enable = mkIf (cfg.workspaces != { }) true; @@ -145,7 +198,7 @@ in mkdir -p /home/googlebot/sandboxed/${name}/workspace mkdir -p /home/googlebot/sandboxed/${name}/ssh-host-keys mkdir -p /home/googlebot/sandboxed/${name}/claude-config - + ${lib.concatMapStrings (m: "mkdir -p ${m.hostPath}\n ") (lib.filter (m: m.createHostPath) (lib.attrValues ws.extraMounts))} # Fix ownership chown -R googlebot:users /home/googlebot/sandboxed/${name} diff --git a/common/sandboxed-workspace/home.nix b/common/sandboxed-workspace/home.nix index c0b9fad..8fc39a4 100644 --- a/common/sandboxed-workspace/home.nix +++ b/common/sandboxed-workspace/home.nix @@ -16,6 +16,14 @@ programs.starship.enableFishIntegration = true; programs.starship.settings.container.disabled = true; + # Land in ~/workspace on interactive login. Skipped if the dir doesn't exist + # (e.g. before the bind mount is wired) so we don't error on shell startup. + programs.fish.loginShellInit = '' + if test -d $HOME/workspace + cd $HOME/workspace + end + ''; + # Basic command-line tools programs.btop.enable = true; programs.ripgrep.enable = true; diff --git a/common/sandboxed-workspace/incus.nix b/common/sandboxed-workspace/incus.nix index 0e3f2cb..7de0a54 100644 --- a/common/sandboxed-workspace/incus.nix +++ b/common/sandboxed-workspace/incus.nix @@ -16,6 +16,7 @@ let let nixpkgs = hostConfig.inputs.nixpkgs; containerSystem = nixpkgs.lib.nixosSystem { + specialArgs = { inherit hostConfig; }; modules = [ (import ./base.nix { inherit hostConfig; @@ -65,6 +66,9 @@ let incus config device add ${containerName} workspace disk source=/home/googlebot/sandboxed/${name}/workspace path=/home/googlebot/workspace shift=true incus config device add ${containerName} ssh-keys disk source=/home/googlebot/sandboxed/${name}/ssh-host-keys path=/etc/ssh-host-keys shift=true incus config device add ${containerName} claude-config disk source=/home/googlebot/sandboxed/${name}/claude-config path=/home/googlebot/claude-config shift=true + ${lib.concatStrings (lib.mapAttrsToList (mountName: m: '' + incus config device add ${containerName} ${mountName} disk source=${m.hostPath} path=${m.containerPath} shift=${lib.boolToString m.shift} + '') ws.extraMounts)} ''; in { diff --git a/flake.lock b/flake.lock index c507ccd..80180c8 100644 --- a/flake.lock +++ b/flake.lock @@ -153,6 +153,27 @@ "type": "github" } }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "hermes-agent", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1772408722, + "narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": [ @@ -221,6 +242,31 @@ "type": "github" } }, + "hermes-agent": { + "inputs": { + "flake-parts": "flake-parts", + "nixpkgs": [ + "nixpkgs" + ], + "npm-lockfile-fix": "npm-lockfile-fix", + "pyproject-build-systems": "pyproject-build-systems", + "pyproject-nix": "pyproject-nix", + "uv2nix": "uv2nix" + }, + "locked": { + "lastModified": 1780513327, + "narHash": "sha256-mY4zJ2N1bgqUDcx4V6YVr4bJXOfklBg19ZZTs1/aSBU=", + "owner": "NousResearch", + "repo": "hermes-agent", + "rev": "39fee4f3bc13a3b74a7ab1dfa306b3ddcbbd4e71", + "type": "github" + }, + "original": { + "owner": "NousResearch", + "repo": "hermes-agent", + "type": "github" + } + }, "home-manager": { "inputs": { "nixpkgs": [ @@ -315,6 +361,77 @@ "type": "github" } }, + "npm-lockfile-fix": { + "inputs": { + "nixpkgs": [ + "hermes-agent", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1775903712, + "narHash": "sha256-2GV79U6iVH4gKAPWYrxUReB0S41ty/Y3dBLquU8AlaA=", + "owner": "jeslie0", + "repo": "npm-lockfile-fix", + "rev": "c6093acb0c0548e0f9b8b3d82918823721930fe8", + "type": "github" + }, + "original": { + "owner": "jeslie0", + "repo": "npm-lockfile-fix", + "type": "github" + } + }, + "pyproject-build-systems": { + "inputs": { + "nixpkgs": [ + "hermes-agent", + "nixpkgs" + ], + "pyproject-nix": [ + "hermes-agent", + "pyproject-nix" + ], + "uv2nix": [ + "hermes-agent", + "uv2nix" + ] + }, + "locked": { + "lastModified": 1772555609, + "narHash": "sha256-3BA3HnUvJSbHJAlJj6XSy0Jmu7RyP2gyB/0fL7XuEDo=", + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "rev": "c37f66a953535c394244888598947679af231863", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "type": "github" + } + }, + "pyproject-nix": { + "inputs": { + "nixpkgs": [ + "hermes-agent", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1772865871, + "narHash": "sha256-/ZTSg97aouL0SlPHaokA4r3iuH9QzHVuWPACD2CUCFY=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "e537db02e72d553cea470976b9733581bcf5b3ed", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, "root": { "inputs": { "agenix": "agenix", @@ -323,6 +440,7 @@ "deploy-rs": "deploy-rs", "flake-compat": "flake-compat", "flake-utils": "flake-utils", + "hermes-agent": "hermes-agent", "home-manager": "home-manager", "microvm": "microvm", "nix-index-database": "nix-index-database", @@ -387,6 +505,31 @@ "repo": "default", "type": "github" } + }, + "uv2nix": { + "inputs": { + "nixpkgs": [ + "hermes-agent", + "nixpkgs" + ], + "pyproject-nix": [ + "hermes-agent", + "pyproject-nix" + ] + }, + "locked": { + "lastModified": 1773039484, + "narHash": "sha256-+boo33KYkJDw9KItpeEXXv8+65f7hHv/earxpcyzQ0I=", + "owner": "pyproject-nix", + "repo": "uv2nix", + "rev": "b68be7cfeacbed9a3fa38a2b5adc0cfb81d9bb1f", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "uv2nix", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 220eb8b..9512935 100644 --- a/flake.nix +++ b/flake.nix @@ -81,6 +81,19 @@ flake-utils.follows = "flake-utils"; }; }; + + # Hermes agent (Nous Research) + hermes-agent = { + url = "github:NousResearch/hermes-agent"; + inputs = { + nixpkgs.follows = "nixpkgs"; + # Collapse duplicate copies of pyproject-nix and uv2nix that the + # hermes-agent flake otherwise pulls in at multiple revs. + pyproject-build-systems.inputs.pyproject-nix.follows = "hermes-agent/pyproject-nix"; + pyproject-build-systems.inputs.uv2nix.follows = "hermes-agent/uv2nix"; + uv2nix.inputs.pyproject-nix.follows = "hermes-agent/pyproject-nix"; + }; + }; }; outputs = { self, nixpkgs, ... }@inputs: diff --git a/machines/fry/default.nix b/machines/fry/default.nix index 3476ee6..575cd21 100644 --- a/machines/fry/default.nix +++ b/machines/fry/default.nix @@ -23,6 +23,31 @@ ip = "192.168.83.90"; hostKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL0SNSy/MdW38NqKzLr1SG8WKrs8XkrqibacaJtJPzgW"; }; + workspaces.hermes = { + type = "incus"; + autoStart = true; + config = ./workspaces/hermes.nix; + ip = "192.168.83.91"; + extraMounts = { + hermes-state = { + hostPath = "/home/googlebot/sandboxed/hermes/hermes-state"; + containerPath = "/var/lib/hermes"; + }; + hermes-env = { + hostPath = "/run/agenix/hermes-env"; + containerPath = "/etc/hermes-env"; + createHostPath = false; # managed by agenix + shift = false; # /run is tmpfs; idmapping not supported + }; + }; + }; + }; + # Bind-mounted into the hermes workspace with shift=false (tmpfs can't be + # idmapped). Mode 0444 lets systemd inside the container read it via the + # "other" bits — the file shows up as nobody:nogroup over an un-shifted mount. + age.secrets.hermes-env = { + file = ../../secrets/hermes-env.age; + mode = "0444"; }; environment.systemPackages = with pkgs; [ diff --git a/machines/fry/workspaces/hermes.nix b/machines/fry/workspaces/hermes.nix new file mode 100644 index 0000000..56c35ca --- /dev/null +++ b/machines/fry/workspaces/hermes.nix @@ -0,0 +1,41 @@ +{ pkgs, hostConfig, ... }: + +{ + imports = [ hostConfig.inputs.hermes-agent.nixosModules.default ]; + + services.hermes-agent = { + enable = true; + addToSystemPackages = true; + container.enable = false; + + # Run the daemon as the same user that owns workspace files so the agent + # can read/write the project tree without permission gymnastics. + user = "googlebot"; + group = "users"; + createUser = false; + + extraPackages = with pkgs; [ nix git ripgrep fd jq ]; + + # Bind-mounted from /run/agenix/hermes-env on fry (host decrypts via agenix). + # Lives at /etc/... rather than /run/... because the workspace's systemd + # mounts a fresh tmpfs over /run at boot, which would shadow the incus mount. + # Codex OAuth is NOT here — it lives per-instance in /var/lib/hermes. + environmentFiles = [ "/etc/hermes-env" ]; + + settings = { + model = { + provider = "openai-codex"; + default = "gpt-5.5"; + }; + toolsets = [ "all" ]; + terminal.backend = "local"; + }; + }; + + # Daemon sets HERMES_HOME to stateDir/.hermes via the systemd unit. Setting + # it system-wide here makes interactive `hermes` (now running as googlebot) + # pick up the same auth.json that the daemon wrote. + environment.variables.HERMES_HOME = "/var/lib/hermes/.hermes"; + + environment.systemPackages = [ pkgs.codex ]; +} diff --git a/secrets/hermes-env.age b/secrets/hermes-env.age new file mode 100644 index 0000000..11c7d2f --- /dev/null +++ b/secrets/hermes-env.age @@ -0,0 +1,15 @@ +age-encryption.org/v1 +-> ssh-ed25519 qEbiMg KYrkjemap/emxqF1CLui+DA+TDQLgosj0MlaqEIpNAE +KQfHDMnfhXD5hSq2awqXvO5jpREuI0/aX4h+K5tKHIc +-> ssh-ed25519 N7drjg wizDdRxUu6rnZU1BLtJCcSXjBlhk9Tz1+LkEHYXLEQA +d12YRFnPklP3i8+A5/LaRo+t6sWdwpMkhIsJuB9osWM +-> ssh-ed25519 jQaHAA k4nDo8oo+XG2sjwUD7KTYigk4uJ5lEycb9K8g3/5E18 +0cqELOwXNGqMQrOZN/1rAR6c2Q9PT7sDevbiBrea9Uc +-> ssh-ed25519 ZDy34A UvWhI/zJ9wnm2kw2QixM7RKOXyAMa/BqKqRdTBEAng0 +KFiiPZZPGLpeTFSUBitmf1+coA7Ss6GjOGETwoBeVBA +-> ssh-ed25519 w3nu8g rvc65tlm5zH1EO+vCUDHwFh00V47XF7BS56fSknjh3Q +zZKz+jqle+DMNuYDf30m2GlinMyv2iaJT5GhW/5lzpA +-> ssh-ed25519 evqvfg VPNt8w3pamyQwYgjRtgrRAM6wdDwvSvvzwBndfq8sng +BFJoWxARRTildOkh4BHyLf2jlY8hoeRn5jiEWhBKEkE +--- DkGMxA+x5XiQk5uwnbOz4Ua2PivD/2MaYScmJWajCjk +'zA\O8:%o1<^C#+ \ No newline at end of file diff --git a/secrets/secrets.nix b/secrets/secrets.nix index 4e73e7c..3b012ab 100644 --- a/secrets/secrets.nix +++ b/secrets/secrets.nix @@ -55,6 +55,9 @@ with roles; # Librechat "librechat-env-file.age".publicKeys = librechat; + # Hermes agent + "hermes-env.age".publicKeys = personal; + # For ACME DNS Challenge "digitalocean-dns-credentials.age".publicKeys = dns-challenge;