refactor: nix/host-modules + nix/agent-modules layout, update doc paths
This commit is contained in:
parent
cb755b677c
commit
4a48ce5024
52 changed files with 48 additions and 44 deletions
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -63,12 +63,16 @@ hand-maintained per-file tree drifts out of sync with the code.
|
||||||
### Other top-level dirs
|
### Other top-level dirs
|
||||||
|
|
||||||
- **`frontend/`** — npm workspaces → static dashboard + per-agent UI
|
- **`frontend/`** — npm workspaces → static dashboard + per-agent UI
|
||||||
dist, built hermetically by `nix/frontend.nix`. Packages: `shared`
|
dist, built hermetically by `nix/packages/frontend.nix`. Packages:
|
||||||
(terminal pane + Catppuccin palette), `dashboard` (the operator SPA),
|
`shared` (terminal pane + Catppuccin palette), `dashboard` (the
|
||||||
`agent` (the default per-container UI).
|
operator SPA), `agent` (the default per-container UI).
|
||||||
- **`nix/`** — NixOS modules (`modules/hive-{c0re,forge,gateway,matrix,
|
- **`nix/`** — `host-modules/` (the host stack: hyperhive core options,
|
||||||
ci,network}.nix`) + per-container templates (`templates/{harness-base,
|
`hive-{c0re,priv,forge,gateway,matrix,network,tls,ci}`, otel, swarm),
|
||||||
agent-base,manager,weston-vnc}.nix`) + the options-doc derivation.
|
`agent-modules/` (the per-agent harness feature modules),
|
||||||
|
`templates/{agent,ruth}.nix` (container entry points), `packages/`
|
||||||
|
(flake package outputs), `docs/` (the options-doc derivation), plus
|
||||||
|
`sources.nix` / `rust.nix` / `checks.nix` / `devshell.nix` /
|
||||||
|
`treefmt.nix` behind the thin `flake.nix`.
|
||||||
- **`docs/`** — subsystem reference docs (see *Reading paths* below).
|
- **`docs/`** — subsystem reference docs (see *Reading paths* below).
|
||||||
- **`branding/`**, **`scripts/`** — static assets + helper scripts.
|
- **`branding/`**, **`scripts/`** — static assets + helper scripts.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ Tree-shape version:
|
||||||
`HYPERHIVE_STATE_DIR` is now injected uniformly via
|
`HYPERHIVE_STATE_DIR` is now injected uniformly via
|
||||||
`systemd.globalEnvironment` in `meta.rs` for every container
|
`systemd.globalEnvironment` in `meta.rs` for every container
|
||||||
(manager included), so all token/state paths resolve through
|
(manager included), so all token/state paths resolve through
|
||||||
`$HYPERHIVE_STATE_DIR`. The harness-base shell scripts
|
`$HYPERHIVE_STATE_DIR`. The agent-module shell scripts
|
||||||
(tea-login, forge-avatar-sync) simplified from glob+for loops to a
|
(tea-login, forge-avatar-sync) simplified from glob+for loops to a
|
||||||
direct `$HYPERHIVE_STATE_DIR/<token>` read.
|
direct `$HYPERHIVE_STATE_DIR/<token>` read.
|
||||||
|
|
||||||
|
|
@ -215,15 +215,15 @@ nspawn agent. Open questions, not yet wired:
|
||||||
## Harness systemd unit shape
|
## Harness systemd unit shape
|
||||||
|
|
||||||
One harness serve binary (`hive-agent`, with its `hive-agent-mcp` /
|
One harness serve binary (`hive-agent`, with its `hive-agent-mcp` /
|
||||||
`hive-agent-wake` siblings), one `harness-base.nix` template, one
|
`hive-agent-wake` siblings), one shared `nix/agent-modules/` tree, one
|
||||||
service unit (`systemd.services.hive-ag3nt`) for all agents. There
|
service unit (`systemd.services.hive-ag3nt`) for all agents. There
|
||||||
is no longer a separate manager service name or role distinction in
|
is no longer a separate manager service name or role distinction in
|
||||||
the harness — privilege differences live server-side in the broker
|
the harness — privilege differences live server-side in the broker
|
||||||
socket (which tool groups and manager-surface calls each agent
|
socket (which tool groups and manager-surface calls each agent
|
||||||
receives).
|
receives).
|
||||||
|
|
||||||
`agent-base.nix` and `manager.nix` both import `harness-base.nix`.
|
`agent.nix` and `ruth.nix` both import the shared `nix/agent-modules/`.
|
||||||
`manager.nix` additionally sets forge defaults to suppress the
|
`ruth.nix` additionally sets forge defaults to suppress the
|
||||||
subscription/participation firehose so ruth's inbox stays focused
|
subscription/participation firehose so ruth's inbox stays focused
|
||||||
on direct mentions, reviews, and assignments.
|
on direct mentions, reviews, and assignments.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,6 @@ GC on the coordinator host won't reclaim space on the builder.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- `nix/modules/hive-ci.nix`: runner configuration, auto-registration script, container setup.
|
- `nix/host-modules/hive-ci.nix`: runner configuration, auto-registration script, container setup.
|
||||||
- `.forgejo/workflows/ci.yml`: workflow definition.
|
- `.forgejo/workflows/ci.yml`: workflow definition.
|
||||||
- `docs/gotchas.md`: nix sandboxing limitations in containers.
|
- `docs/gotchas.md`: nix sandboxing limitations in containers.
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ socket without needing a clean reinstall.
|
||||||
## `claude-code` is unfree
|
## `claude-code` is unfree
|
||||||
|
|
||||||
`claude-code` comes from the flake's main `nixpkgs` (nixos-26.05).
|
`claude-code` comes from the flake's main `nixpkgs` (nixos-26.05).
|
||||||
It's unfree, so `harness-base.nix` sets `config.allowUnfreePredicate`
|
It's unfree, so the agent modules set `config.allowUnfreePredicate`
|
||||||
at the container level to whitelist `claude-code` specifically —
|
at the container level to whitelist `claude-code` specifically —
|
||||||
scoped, only this one package. This is needed because each per-agent
|
scoped, only this one package. This is needed because each per-agent
|
||||||
`nixosConfiguration` evaluates its own nixpkgs instance and the
|
`nixosConfiguration` evaluates its own nixpkgs instance and the
|
||||||
|
|
@ -204,7 +204,7 @@ containers don't get user-namespaces by default, so `nix build`
|
||||||
invocations _inside_ the container can't set up the build sandbox
|
invocations _inside_ the container can't set up the build sandbox
|
||||||
and fail outright if the host daemon's
|
and fail outright if the host daemon's
|
||||||
`nix.settings.sandbox-fallback` is `false` (nixpkgs default).
|
`nix.settings.sandbox-fallback` is `false` (nixpkgs default).
|
||||||
`nix/templates/harness-base.nix` does `lib.mkForce true` so builds
|
`nix/agent-modules/default.nix` does `lib.mkForce true` so builds
|
||||||
fall back to unsandboxed local builds rather than failing. Security
|
fall back to unsandboxed local builds rather than failing. Security
|
||||||
implications: `docs/security.md`.
|
implications: `docs/security.md`.
|
||||||
|
|
||||||
|
|
@ -284,7 +284,7 @@ connects to the compositor at `127.0.0.1:<vnc_port>`.
|
||||||
inherit it.
|
inherit it.
|
||||||
- **Fixed Wayland socket name (`--socket=wayland-0`)**: weston is
|
- **Fixed Wayland socket name (`--socket=wayland-0`)**: weston is
|
||||||
launched with `--socket=wayland-0` so the socket path is
|
launched with `--socket=wayland-0` so the socket path is
|
||||||
deterministic. `harness-base.nix` exports `WAYLAND_DISPLAY=wayland-0`
|
deterministic. `nix/agent-modules/weston-vnc.nix` exports `WAYLAND_DISPLAY=wayland-0`
|
||||||
and `XDG_RUNTIME_DIR=/run/gui` as global system environment
|
and `XDG_RUNTIME_DIR=/run/gui` as global system environment
|
||||||
variables (gated on `hyperhive.gui.enable`) so every systemd service
|
variables (gated on `hyperhive.gui.enable`) so every systemd service
|
||||||
in the container inherits them. Without this, services starting
|
in the container inherits them. Without this, services starting
|
||||||
|
|
@ -311,7 +311,7 @@ connects to the compositor at `127.0.0.1:<vnc_port>`.
|
||||||
must never block on weston signalling readiness. A misconfigured
|
must never block on weston signalling readiness. A misconfigured
|
||||||
weston degrades to a `Restart=on-failure` loop visible in
|
weston degrades to a `Restart=on-failure` loop visible in
|
||||||
`journalctl`, it does not abort the `nixos-container update`.
|
`journalctl`, it does not abort the `nixos-container update`.
|
||||||
Same reasoning as the `tea-login` unit in `harness-base.nix`.
|
Same reasoning as the `tea-login` unit in `nix/agent-modules/forge.nix`.
|
||||||
- **`[core] idle-time=0`**: disables weston's 300-second idle
|
- **`[core] idle-time=0`**: disables weston's 300-second idle
|
||||||
timeout. Without it the VNC desktop fades to black and
|
timeout. Without it the VNC desktop fades to black and
|
||||||
desktop-shell shows its click-to-unlock screen — useless for an
|
desktop-shell shows its click-to-unlock screen — useless for an
|
||||||
|
|
@ -325,7 +325,7 @@ connects to the compositor at `127.0.0.1:<vnc_port>`.
|
||||||
`pkgs.nixosOptionsDoc` over two evaluated module trees:
|
`pkgs.nixosOptionsDoc` over two evaluated module trees:
|
||||||
`hostEval` (a stub NixOS system loading `hive-c0re.nix` with every
|
`hostEval` (a stub NixOS system loading `hive-c0re.nix` with every
|
||||||
hyperhive subsystem `mkForce false` so heavy build inputs stay out of
|
hyperhive subsystem `mkForce false` so heavy build inputs stay out of
|
||||||
the eval) and `agentEval` (evaluates `agent-base.nix` fresh for the
|
the eval) and `agentEval` (evaluates `agent.nix` fresh for the
|
||||||
per-agent options tree).
|
per-agent options tree).
|
||||||
|
|
||||||
Three output trees consumed by `flake.nix`, all **markdown**:
|
Three output trees consumed by `flake.nix`, all **markdown**:
|
||||||
|
|
@ -333,7 +333,7 @@ Three output trees consumed by `flake.nix`, all **markdown**:
|
||||||
- `docs-host` — operator-facing host module options
|
- `docs-host` — operator-facing host module options
|
||||||
(`services.hyperhive.*`)
|
(`services.hyperhive.*`)
|
||||||
- `docs-agent` — per-agent harness options (`hyperhive.*`
|
- `docs-agent` — per-agent harness options (`hyperhive.*`
|
||||||
declared in `nix/templates/harness-base.nix`)
|
declared in `nix/agent-modules/`)
|
||||||
- `docs` — bundle of `index.md` + `host.md` + `agent.md`
|
- `docs` — bundle of `index.md` + `host.md` + `agent.md`
|
||||||
|
|
||||||
Pipeline:
|
Pipeline:
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,7 @@ which has two upstream gaps for fluffychat's web target:
|
||||||
- `native_imaging`'s C source isn't built — emscripten isn't a
|
- `native_imaging`'s C source isn't built — emscripten isn't a
|
||||||
flutter-builder native build input.
|
flutter-builder native build input.
|
||||||
|
|
||||||
Both fixed in `nix/modules/hive-matrix.nix` via two derivations:
|
Both fixed in `nix/host-modules/hive-matrix.nix` via two derivations:
|
||||||
|
|
||||||
- **`fluffychat-web-imaging`** builds `Imaging.{js,wasm}` from the
|
- **`fluffychat-web-imaging`** builds `Imaging.{js,wasm}` from the
|
||||||
`native_imaging` C source via `pkgs.emscripten`. Source comes
|
`native_imaging` C source via `pkgs.emscripten`. Source comes
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ address arithmetic.
|
||||||
and `HOST_BRIDGE=<bridgeName>` via `lifecycle::set_nspawn_flags` when
|
and `HOST_BRIDGE=<bridgeName>` via `lifecycle::set_nspawn_flags` when
|
||||||
creating or updating containers. `LOCAL_ADDRESS` is left empty so the
|
creating or updating containers. `LOCAL_ADDRESS` is left empty so the
|
||||||
container's dhcpcd acquires an address from the bridge dnsmasq pool
|
container's dhcpcd acquires an address from the bridge dnsmasq pool
|
||||||
(`networking.useDHCP = true` in `harness-base.nix`). This applies uniformly
|
(`networking.useDHCP = true` in `nix/agent-modules/network.nix`). This applies uniformly
|
||||||
to all containers — agents and service containers alike.
|
to all containers — agents and service containers alike.
|
||||||
|
|
||||||
`HOST_ADDRESS` is the bridge gateway IP (the address part of
|
`HOST_ADDRESS` is the bridge gateway IP (the address part of
|
||||||
|
|
@ -154,7 +154,7 @@ wiring is runtime:
|
||||||
|
|
||||||
- `hive-priv` drops a marker file (`/etc/hyperhive-bridge-dns`, carrying the
|
- `hive-priv` drops a marker file (`/etc/hyperhive-bridge-dns`, carrying the
|
||||||
gateway IP) into each container's `/etc`.
|
gateway IP) into each container's `/etc`.
|
||||||
- the `hyperhive-isolated-dns` oneshot (harness-base.nix), gated on that
|
- the `hyperhive-isolated-dns` oneshot (`nix/agent-modules/network.nix`), gated on that
|
||||||
marker, rewrites `/etc/resolv.conf` to `nameserver <gateway-ip>` at boot.
|
marker, rewrites `/etc/resolv.conf` to `nameserver <gateway-ip>` at boot.
|
||||||
It is ordered `before` the harness (`hive-ag3nt`), the matrix daemon, and
|
It is ordered `before` the harness (`hive-ag3nt`), the matrix daemon, and
|
||||||
`tea-login` so the resolver is correct before the first DNS lookup.
|
`tea-login` so the resolver is correct before the first DNS lookup.
|
||||||
|
|
|
||||||
|
|
@ -369,7 +369,7 @@ state dirs need a one-time data shuffle so they don't lose their claude
|
||||||
session.
|
session.
|
||||||
|
|
||||||
`system.activationScripts.hive-agent-user-migrate` (in
|
`system.activationScripts.hive-agent-user-migrate` (in
|
||||||
`nix/templates/harness-base.nix`) runs on every activation,
|
`nix/agent-modules/user.nix`) runs on every activation,
|
||||||
marker-guarded so the substantive moves only happen once per
|
marker-guarded so the substantive moves only happen once per
|
||||||
container lifetime:
|
container lifetime:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ component can't traverse.
|
||||||
Agent containers bind-mount the host's `nix-daemon` socket. The host daemon may
|
Agent containers bind-mount the host's `nix-daemon` socket. The host daemon may
|
||||||
have `sandbox-fallback = false` (strict NixOS defaults), which causes `nix build`
|
have `sandbox-fallback = false` (strict NixOS defaults), which causes `nix build`
|
||||||
inside nspawn containers to fail — containers lack kernel user namespaces, so nix
|
inside nspawn containers to fail — containers lack kernel user namespaces, so nix
|
||||||
cannot set up its build sandbox. `harness-base.nix` sets `sandbox-fallback = true`
|
cannot set up its build sandbox. the agent modules set `sandbox-fallback = true`
|
||||||
so that builds fall back to unsandboxed execution rather than failing outright.
|
so that builds fall back to unsandboxed execution rather than failing outright.
|
||||||
|
|
||||||
### Threat model
|
### Threat model
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Background shell execution via `hive-bash-mcp`. Tools land as
|
Background shell execution via `hive-bash-mcp`. Tools land as
|
||||||
`mcp__bash__<tool>` (the MCP server name is `bash`, not `hyperhive`).
|
`mcp__bash__<tool>` (the MCP server name is `bash`, not `hyperhive`).
|
||||||
Available on every agent unconditionally — `harness-base.nix` always
|
Available on every agent unconditionally — `nix/agent-modules/mcp.nix` always
|
||||||
injects bash into `hyperhive.extraMcpServers` (with `allowedTools =
|
injects bash into `hyperhive.extraMcpServers` (with `allowedTools =
|
||||||
["*"]`), so `mcp__bash__*` is in `--allowedTools` for every claude
|
["*"]`), so `mcp__bash__*` is in `--allowedTools` for every claude
|
||||||
invocation regardless of tool groups.
|
invocation regardless of tool groups.
|
||||||
|
|
@ -96,7 +96,7 @@ The bash tooling follows the same daemon + stdio-bridge pattern as the
|
||||||
matrix MCP:
|
matrix MCP:
|
||||||
|
|
||||||
- **`hive-bash-daemon`** — long-running process (one per agent container,
|
- **`hive-bash-daemon`** — long-running process (one per agent container,
|
||||||
systemd service in `harness-base.nix`). Owns subprocess management,
|
systemd service in `nix/agent-modules/mcp.nix`). Owns subprocess management,
|
||||||
output file writing, `mcp-loose-ends/` state, and wake signal delivery.
|
output file writing, `mcp-loose-ends/` state, and wake signal delivery.
|
||||||
Listens on `/run/hive-bash/socket` inside the container.
|
Listens on `/run/hive-bash/socket` inside the container.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# hive-forge CLI
|
# hive-forge CLI
|
||||||
|
|
||||||
`hive-forge` is the Forgejo API wrapper available in every agent
|
`hive-forge` is the Forgejo API wrapper available in every agent
|
||||||
container (installed via `harness-base.nix`; lives in `/hive-forge`
|
container (installed via `nix/agent-modules/forge.nix`; lives in `/hive-forge`
|
||||||
as a proper Rust binary). Use it instead of ad-hoc curl pipelines.
|
as a proper Rust binary). Use it instead of ad-hoc curl pipelines.
|
||||||
|
|
||||||
## Credentials and repo defaults
|
## Credentials and repo defaults
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ parsed from a turn (usage, cost, context window, resolved model) as
|
||||||
|
|
||||||
Hive-enforced settings ship at `/etc/claude-code/managed-settings.json`
|
Hive-enforced settings ship at `/etc/claude-code/managed-settings.json`
|
||||||
(claude-code's canonical managed-settings path — precedence #1,
|
(claude-code's canonical managed-settings path — precedence #1,
|
||||||
read-only, un-overridable), wired in `nix/templates/harness-base.nix`
|
read-only, un-overridable), wired in `nix/agent-modules/claude-settings.nix`
|
||||||
from the `prompts/claude-settings.json` asset. `effortLevel` is
|
from the `prompts/claude-settings.json` asset. `effortLevel` is
|
||||||
deliberately not in that file — effort is controlled live via the
|
deliberately not in that file — effort is controlled live via the
|
||||||
`--effort` flag (`HIVE_DEFAULT_EFFORT` / the per-agent UI slider), which
|
`--effort` flag (`HIVE_DEFAULT_EFFORT` / the per-agent UI slider), which
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ sentence into the agent's system prompt so it knows the docs exist and
|
||||||
where to find them. The tree is served by `claude --add-dir` so the full
|
where to find them. The tree is served by `claude --add-dir` so the full
|
||||||
markdown is readable during every turn.
|
markdown is readable during every turn.
|
||||||
|
|
||||||
Enabled by default only for the root/manager agent (`manager.nix`). Any
|
Enabled by default only for the root/manager agent (`nix/templates/ruth.nix`). Any
|
||||||
agent can opt in by adding the line above to its `agent.nix`.
|
agent can opt in by adding the line above to its `agent.nix`.
|
||||||
|
|
||||||
The `docs/` source is a narrow flake input (`hyperhive-docs`) tracked
|
The `docs/` source is a narrow flake input (`hyperhive-docs`) tracked
|
||||||
|
|
|
||||||
12
flake.nix
12
flake.nix
|
|
@ -20,7 +20,7 @@
|
||||||
# nix/checks.nix flake checks
|
# nix/checks.nix flake checks
|
||||||
# nix/devshell.nix dev shell
|
# nix/devshell.nix dev shell
|
||||||
# nix/treefmt.nix formatter config
|
# nix/treefmt.nix formatter config
|
||||||
# nix/modules/, nix/templates/ the NixOS module + container trees
|
# nix/host-modules/, nix/agent-modules/, nix/templates/ the NixOS module trees
|
||||||
outputs =
|
outputs =
|
||||||
inputs@{
|
inputs@{
|
||||||
self,
|
self,
|
||||||
|
|
@ -71,7 +71,7 @@
|
||||||
let
|
let
|
||||||
# Package wiring for agent containers — the harness modules
|
# Package wiring for agent containers — the harness modules
|
||||||
# consume hyperhive's own packages via the `hyperhive.packages`
|
# consume hyperhive's own packages via the `hyperhive.packages`
|
||||||
# option (see nix/templates/harness/packages.nix); no overlay.
|
# option (see nix/agent-modules/packages.nix); no overlay.
|
||||||
# `mkDefault` so a per-agent override of an individual key wins.
|
# `mkDefault` so a per-agent override of an individual key wins.
|
||||||
agentPackages =
|
agentPackages =
|
||||||
{ lib, pkgs, ... }:
|
{ lib, pkgs, ... }:
|
||||||
|
|
@ -103,7 +103,7 @@
|
||||||
./nix/templates/ruth.nix
|
./nix/templates/ruth.nix
|
||||||
agentPackages
|
agentPackages
|
||||||
];
|
];
|
||||||
# The full host stack (nix/modules/default.nix aggregator) plus
|
# The full host stack (nix/host-modules/default.nix aggregator) plus
|
||||||
# the package/source wiring from this flake. The wiring is a
|
# the package/source wiring from this flake. The wiring is a
|
||||||
# plain config module setting the `services.hyperhive.c0re.*`
|
# plain config module setting the `services.hyperhive.c0re.*`
|
||||||
# package options via `lib.mkDefault` — no overlay involved, and
|
# package options via `lib.mkDefault` — no overlay involved, and
|
||||||
|
|
@ -115,7 +115,7 @@
|
||||||
default =
|
default =
|
||||||
{ lib, pkgs, ... }:
|
{ lib, pkgs, ... }:
|
||||||
{
|
{
|
||||||
imports = [ ./nix/modules ];
|
imports = [ ./nix/host-modules ];
|
||||||
services.hyperhive.c0re = {
|
services.hyperhive.c0re = {
|
||||||
package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||||
frontend = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.frontend;
|
frontend = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.frontend;
|
||||||
|
|
@ -136,8 +136,8 @@
|
||||||
managerToplevel = lib.mkDefault self.packages.x86_64-linux.ruth-toplevel;
|
managerToplevel = lib.mkDefault self.packages.x86_64-linux.ruth-toplevel;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
hive-ci = ./nix/modules/hive-ci.nix;
|
hive-ci = ./nix/host-modules/hive-ci.nix;
|
||||||
hive-forge = ./nix/modules/hive-forge;
|
hive-forge = ./nix/host-modules/hive-forge;
|
||||||
};
|
};
|
||||||
|
|
||||||
nixosConfigurations =
|
nixosConfigurations =
|
||||||
|
|
|
||||||
245
nix/agent-modules/agent-service.nix
Normal file
245
nix/agent-modules/agent-service.nix
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
# The hive-ag3nt harness service itself, plus the per-agent knobs it
|
||||||
|
# reads from its environment: model selection, effort level,
|
||||||
|
# compaction watermark, and the extra reverse-proxies of the per-agent
|
||||||
|
# web UI.
|
||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
lib,
|
||||||
|
config,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
userName = config.hyperhive.user.name;
|
||||||
|
homeDir = "/home/${userName}";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.hyperhive.model = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "haiku";
|
||||||
|
example = "sonnet";
|
||||||
|
description = ''
|
||||||
|
Claude model for this agent. Sets the `HIVE_DEFAULT_MODEL`
|
||||||
|
environment variable; the harness applies it at boot and it takes
|
||||||
|
priority over any persisted runtime override. The operator can still
|
||||||
|
switch the model at runtime via the per-agent web UI — that choice
|
||||||
|
is tracked in the state dir for the current session but is reset by
|
||||||
|
any rebuild that changes this option.
|
||||||
|
|
||||||
|
Valid values are the short model names that `claude --model` accepts:
|
||||||
|
`"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). Context
|
||||||
|
window sizes are looked up at runtime from the
|
||||||
|
`HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>` env vars injected by the
|
||||||
|
meta flake; override sizes via `services.hyperhive.c0re.contextWindowTokens`
|
||||||
|
on the host.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
options.hyperhive.availableModels = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [
|
||||||
|
"haiku"
|
||||||
|
"sonnet"
|
||||||
|
"opus"
|
||||||
|
];
|
||||||
|
example = [
|
||||||
|
"sonnet"
|
||||||
|
"opus"
|
||||||
|
];
|
||||||
|
description = ''
|
||||||
|
Models offered in the per-agent web UI's model quick-picker. Rendered
|
||||||
|
into the `HIVE_AVAILABLE_MODELS` environment variable (comma-separated)
|
||||||
|
which the harness surfaces to the agent UI, so the picker lists exactly
|
||||||
|
these models instead of a hardcoded set.
|
||||||
|
|
||||||
|
Configure hive-wide by setting a shared default (e.g. in your
|
||||||
|
`agent.nix` shared defaults) or per-agent to narrow the menu — for example a
|
||||||
|
haiku-only agent can hide `opus` and `sonnet`. The *current* model is
|
||||||
|
still set by `hyperhive.model` and remains switchable at runtime via the
|
||||||
|
UI; this option only controls which choices the picker presents.
|
||||||
|
|
||||||
|
Values are the short model names that `claude --model` accepts:
|
||||||
|
`"haiku"`, `"sonnet"`, `"opus"` (or any future identifier).
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
options.hyperhive.effortLevel = lib.mkOption {
|
||||||
|
type = lib.types.enum [
|
||||||
|
"low"
|
||||||
|
"medium"
|
||||||
|
"high"
|
||||||
|
"xhigh"
|
||||||
|
"max"
|
||||||
|
];
|
||||||
|
default = "medium";
|
||||||
|
example = "high";
|
||||||
|
description = ''
|
||||||
|
Baseline claude effort level for this agent. Rendered into the
|
||||||
|
`HIVE_DEFAULT_EFFORT` environment variable; the harness resolves
|
||||||
|
effort as operator-override-file → this env → built-in `"medium"`,
|
||||||
|
and passes the result to `claude --effort` at turn launch.
|
||||||
|
|
||||||
|
Ascending scale: `"low"` (minimal thinking budget), `"medium"`
|
||||||
|
(default — balanced), `"high"` (platform default), `"xhigh"`
|
||||||
|
(recommended for autonomous coding on capable models), `"max"`
|
||||||
|
(maximum thinking budget, highest cost). The operator can override
|
||||||
|
at runtime per-agent via the web UI (applied on the next session);
|
||||||
|
any rebuild that changes this option resets that override.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
options.hyperhive.autoCompact = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = ''
|
||||||
|
Enable proactive watermark-based compaction. When `true` (the
|
||||||
|
default) the harness automatically runs a notes-checkpoint turn
|
||||||
|
followed by `/compact` once the context window crosses 75% of
|
||||||
|
the model's limit, keeping later turns from hitting the hard
|
||||||
|
overflow path. Set to `false` to disable proactive compaction
|
||||||
|
entirely (`HIVE_COMPACT_WATERMARK_TOKENS=0`); the reactive path
|
||||||
|
(compact-on-overflow when the session is already past the limit)
|
||||||
|
still applies.
|
||||||
|
|
||||||
|
Disable for agents that run large-context models (sonnet/opus)
|
||||||
|
where the heuristic fires too early and discards useful history
|
||||||
|
before the session is actually close to the limit.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
options.hyperhive.extraWebProxies = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf lib.types.str;
|
||||||
|
default = { };
|
||||||
|
example = lib.literalExpression ''{ "stats" = "http://127.0.0.1:3737"; }'';
|
||||||
|
description = ''
|
||||||
|
Transparent reverse-proxies mounted under `/extra/` in the per-agent web UI.
|
||||||
|
Each attribute name becomes the sub-path and the value is the upstream.
|
||||||
|
E.g. `{ "stats" = "http://127.0.0.1:3737"; }` mounts a proxy at
|
||||||
|
`/agent/<name>/extra/stats/` that forwards to port 3737 with the prefix
|
||||||
|
stripped. All user-declared proxies live under `/extra/` so they can
|
||||||
|
never conflict with native agent endpoints (`/api/*`, `/events/*`, etc.).
|
||||||
|
|
||||||
|
The upstream value is either an `http(s)://` URL or a Unix domain
|
||||||
|
socket, spelled `unix:<path>` (e.g. `unix:/run/myapp/http.sock`) — for
|
||||||
|
agents whose secondary web server only listens on a UDS.
|
||||||
|
|
||||||
|
Intended for agents that run secondary web servers in the same container.
|
||||||
|
Static assets served by the secondary app must use relative paths to
|
||||||
|
resolve correctly under the sub-path prefix.
|
||||||
|
|
||||||
|
Sets the `HIVE_EXTRA_WEB_PROXIES` environment variable (JSON object)
|
||||||
|
on the harness service unit.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
config = {
|
||||||
|
assertions = [
|
||||||
|
# hyperhive.model must be a non-empty string — an empty value causes
|
||||||
|
# the harness to pass an invalid model flag to claude.
|
||||||
|
{
|
||||||
|
assertion = config.hyperhive.model != "";
|
||||||
|
message = "hyperhive.model must not be empty (set it to e.g. \"haiku\" or \"sonnet\")";
|
||||||
|
}
|
||||||
|
# The current model must appear in the quick-picker menu, otherwise the
|
||||||
|
# UI would offer no way back to the model the agent is actually running.
|
||||||
|
{
|
||||||
|
assertion =
|
||||||
|
config.hyperhive.availableModels == [ ]
|
||||||
|
|| builtins.elem config.hyperhive.model config.hyperhive.availableModels;
|
||||||
|
message =
|
||||||
|
"hyperhive.model (\"${config.hyperhive.model}\") must be one of "
|
||||||
|
+ "hyperhive.availableModels ([ ${lib.concatStringsSep " " config.hyperhive.availableModels} ]) "
|
||||||
|
+ "— add it to the list or change the model.";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
# HIVE_DEFAULT_MODEL seeds the initial model selection when no
|
||||||
|
# persisted model choice exists in the state dir.
|
||||||
|
environment.variables = {
|
||||||
|
HIVE_DEFAULT_MODEL = config.hyperhive.model;
|
||||||
|
# Comma-separated menu for the per-agent UI model quick-picker
|
||||||
|
# (see hyperhive.availableModels). The harness surfaces it to the
|
||||||
|
# frontend; an empty value falls back to the built-in default list.
|
||||||
|
HIVE_AVAILABLE_MODELS = lib.concatStringsSep "," config.hyperhive.availableModels;
|
||||||
|
# Per-agent baseline effort (see hyperhive.effortLevel). The
|
||||||
|
# harness resolves operator-override-file → this env → "medium"
|
||||||
|
# and passes it to claude --effort at turn launch.
|
||||||
|
HIVE_DEFAULT_EFFORT = config.hyperhive.effortLevel;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (!config.hyperhive.autoCompact) {
|
||||||
|
# Zero watermark disables proactive compaction; the reactive path
|
||||||
|
# (compact-on-overflow) still fires when the session is truly full.
|
||||||
|
HIVE_COMPACT_WATERMARK_TOKENS = "0";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Harness systemd unit. Unit shape (PATH wrapper-dir trick, env vars,
|
||||||
|
# RuntimeDirectory, User=, standalone-eval fallbacks):
|
||||||
|
# docs/agent-hierarchy.md::Harness systemd unit shape. PATH /bin
|
||||||
|
# auto-append behaviour: docs/gotchas.md::systemd.services.*.path
|
||||||
|
# appends /bin to every entry.
|
||||||
|
systemd.services.hive-ag3nt =
|
||||||
|
let
|
||||||
|
binary = "hive-agent";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
description = "${binary} harness";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "network.target" ];
|
||||||
|
# `/run/wrappers` before `/run/current-system/sw` so setuid
|
||||||
|
# `sudo` resolves first. Passing the bare prefixes (no trailing
|
||||||
|
# `/bin`) is intentional — see docs pointer above.
|
||||||
|
path = [
|
||||||
|
"/run/wrappers"
|
||||||
|
"/run/current-system/sw"
|
||||||
|
];
|
||||||
|
environment = {
|
||||||
|
SHELL = "${pkgs.bashInteractive}/bin/bash";
|
||||||
|
HOME = homeDir;
|
||||||
|
HIVE_STATIC_DIR = "${config.hyperhive.frontend.mergedDist}";
|
||||||
|
HIVE_ASSETS_DIR = "${config.hyperhive.packages.assets}/share/hyperhive";
|
||||||
|
# Unix-socket path for the harness web UI. All agents always bind
|
||||||
|
# here; there is no TCP fallback. Path matches
|
||||||
|
# `hive_c0re::agent_sockets::socket_path_for(name)` so lifecycle
|
||||||
|
# bind-mounts and gateway upstream config stay in sync.
|
||||||
|
HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock";
|
||||||
|
# Loopback URL of the persistent `hive-mcp-http` daemon that
|
||||||
|
# `render_claude_config` points claude at for the built-in
|
||||||
|
# surface (HTTP is the sole transport — no per-turn stdio child).
|
||||||
|
# Kept in sync with the `hive-mcp-http` unit's `--http` port
|
||||||
|
# (see ./mcp.nix) via the same option. Always set — network
|
||||||
|
# isolation is unconditional, so a fixed per-container port is
|
||||||
|
# collision-free.
|
||||||
|
HYPERHIVE_MCP_HTTP_PORT = toString config.hyperhive.mcp.httpPort;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs config.hyperhive.gui.enable {
|
||||||
|
# Tells the harness which fixed VNC port weston bound, and (by
|
||||||
|
# its presence) that gui is enabled — the harness `/screen/ws`
|
||||||
|
# relay reads this instead of a runtime marker file. The port is
|
||||||
|
# container-local + fixed (network isolation is unconditional),
|
||||||
|
# so the same value for every gui agent is fine. See
|
||||||
|
# ./weston-vnc.nix::hyperhive.gui.vncPort.
|
||||||
|
HIVE_GUI_VNC_PORT = toString config.hyperhive.gui.vncPort;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (config.hyperhive.extraWebProxies != { }) {
|
||||||
|
# JSON object {"<path>": "<upstream>"} for the transparent
|
||||||
|
# reverse-proxies. See `hyperhive.extraWebProxies` option
|
||||||
|
# and `web_ui/proxy.rs::extra_proxy_service`.
|
||||||
|
HIVE_EXTRA_WEB_PROXIES = builtins.toJSON config.hyperhive.extraWebProxies;
|
||||||
|
};
|
||||||
|
serviceConfig = {
|
||||||
|
ExecStart = "${config.hyperhive.packages.hive-agent}/bin/${binary}";
|
||||||
|
# Pin the journal identity to the binary name (otherwise systemd
|
||||||
|
# derives SyslogIdentifier from the ExecStart basename).
|
||||||
|
SyslogIdentifier = binary;
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 2;
|
||||||
|
# Per-service runtime dir owned by `User=` below; the harness
|
||||||
|
# writes its regenerated claude-{mcp-config,settings,system-prompt}
|
||||||
|
# files here (`paths::config_dir`). Separate from /run/hive,
|
||||||
|
# which holds hive-c0re's mcp.sock.
|
||||||
|
RuntimeDirectory = "hive-config";
|
||||||
|
User = userName;
|
||||||
|
Group = userName;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
134
nix/agent-modules/bash-env.nix
Normal file
134
nix/agent-modules/bash-env.nix
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
# Shell-environment feature hooks: the `_bashEnvFragments`
|
||||||
|
# accumulator, the `/etc/hyperhive/bash-env.sh` file it renders to,
|
||||||
|
# and the cargo `--message-format short` injector that contributes to
|
||||||
|
# it. Loaded via `$BASH_ENV` for non-interactive shells (claude's
|
||||||
|
# `Bash` tool runs `bash -c`) and via `programs.bash` for interactive
|
||||||
|
# ones.
|
||||||
|
{
|
||||||
|
lib,
|
||||||
|
config,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
# Internal accumulator for shell snippets that should land in
|
||||||
|
# `/etc/hyperhive/bash-env.sh`. Per-feature hooks set this via
|
||||||
|
# `lib.mkIf` gated on their own option; the lines type merges
|
||||||
|
# all contributions across modules into one file. Generic by
|
||||||
|
# design so future hooks don't need to rename this file or
|
||||||
|
# invent a parallel dispatcher.
|
||||||
|
options.hyperhive._bashEnvFragments = lib.mkOption {
|
||||||
|
type = lib.types.lines;
|
||||||
|
default = "";
|
||||||
|
internal = true;
|
||||||
|
description = ''
|
||||||
|
Shell snippets concatenated into `/etc/hyperhive/bash-env.sh`.
|
||||||
|
Feature hooks contribute via `lib.mkIf` gated on their own
|
||||||
|
option. When empty, the file isn't created, `BASH_ENV` stays
|
||||||
|
unset, and the interactive bashrc hook is omitted — zero cost
|
||||||
|
when no feature is on. Internal — set indirectly via the
|
||||||
|
per-feature options that own the gate (e.g.
|
||||||
|
`hyperhive.cargo.shortMessages`).
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
options.hyperhive.cargo.shortMessages = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
example = false;
|
||||||
|
description = ''
|
||||||
|
Auto-inject `--message-format short` on cargo compile
|
||||||
|
subcommands (`build`, `check`, `clippy`, `test`, `run`,
|
||||||
|
`doc`, `bench`, `install`, `rustc`, `fix`) when claude (or
|
||||||
|
anything else) invokes `cargo` inside this container.
|
||||||
|
Saves tokens + context — the verbose default output floods
|
||||||
|
the response window with per-crate progress lines that
|
||||||
|
carry no signal beyond the warning/error summary.
|
||||||
|
|
||||||
|
Implementation: contributes a `cargo` shell function to
|
||||||
|
`/etc/hyperhive/bash-env.sh` (see `hyperhive._bashEnvFragments`).
|
||||||
|
Loaded via `BASH_ENV` for non-interactive shells (`bash -c` —
|
||||||
|
what the claude `Bash` tool runs) and sourced from
|
||||||
|
`programs.bash.interactiveShellInit` for interactive shells.
|
||||||
|
The function:
|
||||||
|
|
||||||
|
- handles the `+toolchain` selector prefix (`cargo +nightly
|
||||||
|
build` works);
|
||||||
|
- passes through cleanly when the caller already specified
|
||||||
|
`--message-format` (any form);
|
||||||
|
- leaves non-compile subcommands (`new`, `add`, `search`,
|
||||||
|
third-party `cargo-*` subcommands) untouched so they
|
||||||
|
don't error on the unknown flag.
|
||||||
|
|
||||||
|
Set to `false` for agents that need full cargo output (e.g.
|
||||||
|
tooling that parses `--message-format json` programmatically
|
||||||
|
and doesn't pass the flag explicitly).
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
config = {
|
||||||
|
# Cargo `--message-format short` injector. `command cargo …` falls
|
||||||
|
# back to the un-wrapped binary in PATH (the rust toolchain's cargo
|
||||||
|
# — either from `environment.systemPackages` or from whatever
|
||||||
|
# `nix develop` shell the agent's working in).
|
||||||
|
hyperhive._bashEnvFragments = lib.mkIf config.hyperhive.cargo.shortMessages ''
|
||||||
|
# Auto-injects --message-format short on cargo compile
|
||||||
|
# subcommands so per-crate progress lines don't flood
|
||||||
|
# claude's context. Bypassed when the caller already passes
|
||||||
|
# --message-format (any form).
|
||||||
|
cargo() {
|
||||||
|
# Strip leading +toolchain selectors (cargo +nightly …).
|
||||||
|
local pre=()
|
||||||
|
while [ "''${1:0:1}" = "+" ] && [ -n "''${1:-}" ]; do
|
||||||
|
pre+=("$1")
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
case "''${1:-}" in
|
||||||
|
build|check|clippy|test|run|doc|bench|install|rustc|fix)
|
||||||
|
local sub="$1"
|
||||||
|
shift
|
||||||
|
local arg
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--message-format|--message-format=*)
|
||||||
|
command cargo "''${pre[@]}" "$sub" "$@"
|
||||||
|
return $?
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
command cargo "''${pre[@]}" "$sub" --message-format short "$@"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
command cargo "''${pre[@]}" "$@"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Single bash-env file with all configured shell fragments.
|
||||||
|
# Wiring is gated on at least one fragment being active so a
|
||||||
|
# fully feature-disabled agent has neither the file nor the
|
||||||
|
# `BASH_ENV` / interactive sourcing — zero cost in that case.
|
||||||
|
environment.etc."hyperhive/bash-env.sh" = lib.mkIf (config.hyperhive._bashEnvFragments != "") {
|
||||||
|
text = config.hyperhive._bashEnvFragments;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Non-interactive bash invocations (claude's `Bash` tool runs
|
||||||
|
# `bash -c`) source $BASH_ENV at startup — drops every active
|
||||||
|
# feature hook's snippet into scope without touching
|
||||||
|
# `/etc/profile` (login-only).
|
||||||
|
environment.variables = lib.mkIf (config.hyperhive._bashEnvFragments != "") {
|
||||||
|
BASH_ENV = "/etc/hyperhive/bash-env.sh";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Interactive shells don't honour BASH_ENV — wire the same file
|
||||||
|
# in via the bashrc hook so operator SSH sessions get the same
|
||||||
|
# hook surface as claude's non-interactive calls. Gated on at
|
||||||
|
# least one fragment being active so we don't write a no-op
|
||||||
|
# source line into `/etc/bashrc` on fully-feature-disabled agents.
|
||||||
|
programs.bash.interactiveShellInit = lib.mkIf (config.hyperhive._bashEnvFragments != "") ''
|
||||||
|
if [ -r /etc/hyperhive/bash-env.sh ]; then
|
||||||
|
. /etc/hyperhive/bash-env.sh
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
376
nix/agent-modules/claude-settings.nix
Normal file
376
nix/agent-modules/claude-settings.nix
Normal file
|
|
@ -0,0 +1,376 @@
|
||||||
|
# Everything that shapes claude-code's own configuration inside the
|
||||||
|
# container: the managed settings json (base env + OTEL), the
|
||||||
|
# onboarding/trust seed, the runtime OTEL auth-header injection, and
|
||||||
|
# the plugin/marketplace install lists the harness reads at boot.
|
||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
lib,
|
||||||
|
config,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
userName = config.hyperhive.user.name;
|
||||||
|
homeDir = "/home/${userName}";
|
||||||
|
# Hive-wide OpenTelemetry config (host-driven; baked in per-agent by
|
||||||
|
# meta.rs `otel_config`).
|
||||||
|
otelCfg = config.hyperhive.otel;
|
||||||
|
# Hive/swarm display names are forwarded into each agent's build by
|
||||||
|
# meta.rs as `environment.variables` (per-agent, build-time strings),
|
||||||
|
# so they can be baked into the resource attributes below without a
|
||||||
|
# runtime shell. Absent (option unset) → "unknown".
|
||||||
|
hiveDisplayName = config.environment.variables.HYPERHIVE_HIVE_NAME or "unknown";
|
||||||
|
swarmDisplayName = config.environment.variables.HYPERHIVE_SWARM_NAME or "unknown";
|
||||||
|
# Base claude-code environment applied to every agent regardless of OTEL.
|
||||||
|
# Shipped via the managed settings `env` block so claude and `hivectl
|
||||||
|
# choom` both inherit them without a launch wrapper.
|
||||||
|
baseClaudeEnv = {
|
||||||
|
# Suppress analytics, survey pings, and other non-essential outbound
|
||||||
|
# traffic — agents are headless and don't need any of that.
|
||||||
|
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
||||||
|
DO_NOT_TRACK = "1";
|
||||||
|
# Disable claude's self-update machinery; package management is nix's job.
|
||||||
|
DISABLE_AUTOUPDATER = "1";
|
||||||
|
DISABLE_UPDATES = "1";
|
||||||
|
# Keep plugin updates synchronized on install; prefer HTTPS for fetches.
|
||||||
|
CLAUDE_CODE_PLUGIN_PREFER_HTTPS = "1";
|
||||||
|
CLAUDE_CODE_SYNC_PLUGIN_INSTALL = "1";
|
||||||
|
FORCE_AUTOUPDATE_PLUGINS = "1";
|
||||||
|
# Suppress the "install GitHub app" prompt — not applicable in-hive.
|
||||||
|
DISABLE_INSTALL_GITHUB_APP_COMMAND = "1";
|
||||||
|
# Disable Anthropic's hosted claude.ai MCP servers; the hive supplies its own.
|
||||||
|
ENABLE_CLAUDEAI_MCP_SERVERS = "0";
|
||||||
|
# Resume an interrupted turn on reconnect (recovers from transient MCP flaps).
|
||||||
|
CLAUDE_CODE_RESUME_INTERRUPTED_TURN = "1";
|
||||||
|
# Use the simpler system prompt variant suited to headless operation.
|
||||||
|
CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT = "1";
|
||||||
|
# Tag remote-control sessions with "<hive>-<agent>" for identification.
|
||||||
|
CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX = "${hiveDisplayName}-${userName}";
|
||||||
|
};
|
||||||
|
# OTEL environment Claude Code reads to export metrics/logs/traces.
|
||||||
|
# Shipped via the managed claude settings json (below), which claude
|
||||||
|
# auto-discovers for BOTH the harness turn-loop and `hivectl choom` —
|
||||||
|
# so telemetry parity is declarative, with no launch wrapper. The
|
||||||
|
# auth header (`otel.headersCredential`) is deliberately NOT included
|
||||||
|
# here: it's a secret and this file lives in the world-readable nix
|
||||||
|
# store. It's injected at *runtime* into the agent's `0600`
|
||||||
|
# `~/.claude/settings.json` by the `hive-otel-header` oneshot below
|
||||||
|
# (claude merges the `env` from the user settings on top of these
|
||||||
|
# managed ones), so the token is read from disk at start and never
|
||||||
|
# touches the store.
|
||||||
|
otelSettingsEnv = {
|
||||||
|
CLAUDE_CODE_ENABLE_TELEMETRY = "1";
|
||||||
|
# Attach feedback-survey data to the OTEL pipeline.
|
||||||
|
CLAUDE_CODE_ENABLE_FEEDBACK_SURVEY_FOR_OTEL = "1";
|
||||||
|
OTEL_METRICS_EXPORTER = "otlp";
|
||||||
|
OTEL_LOGS_EXPORTER = "otlp";
|
||||||
|
OTEL_TRACES_EXPORTER = "otlp";
|
||||||
|
OTEL_EXPORTER_OTLP_PROTOCOL = otelCfg.protocol;
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT = otelCfg.endpoint;
|
||||||
|
# Force CUMULATIVE temporality — Claude Code defaults to DELTA,
|
||||||
|
# which Prometheus/Mimir-family backends (incl. grafana-lgtm)
|
||||||
|
# silently drop without a deltatocumulative processor.
|
||||||
|
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = "cumulative";
|
||||||
|
OTEL_RESOURCE_ATTRIBUTES =
|
||||||
|
"service.name=hyperhive-agent,agent=${userName},hive=${hiveDisplayName},swarm=${swarmDisplayName}"
|
||||||
|
+ lib.optionalString (otelCfg.extraResourceAttributes != "") ",${otelCfg.extraResourceAttributes}";
|
||||||
|
# Include the Claude Code version label in emitted metrics.
|
||||||
|
OTEL_METRICS_INCLUDE_VERSION = "1";
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (otelCfg.metricIntervalMs != null) {
|
||||||
|
OTEL_METRIC_EXPORT_INTERVAL = toString otelCfg.metricIntervalMs;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
# OTEL stats export is configured ONCE at host level via
|
||||||
|
# `services.hyperhive.otel.*` (see nix/host-modules/hive-c0re.nix) and
|
||||||
|
# injected into every agent's build by the meta-flake renderer
|
||||||
|
# (`hive-c0re/src/meta.rs::otel_config`). These per-agent options are
|
||||||
|
# the build-time implementation surface that injection writes into;
|
||||||
|
# they are not meant to be set directly in an agent.nix. Marked
|
||||||
|
# `internal` so the host option is the only documented operator knob.
|
||||||
|
options.hyperhive.otel = {
|
||||||
|
enable = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
internal = true;
|
||||||
|
description = ''
|
||||||
|
Export this agent's Claude Code stats (token usage, cost, tool
|
||||||
|
calls) to an OTLP endpoint via Claude Code's built-in
|
||||||
|
OpenTelemetry. Each agent's harness exports directly to the
|
||||||
|
collector, so it keeps working even when hive-c0re is down.
|
||||||
|
Host-driven: set `services.hyperhive.otel.enable` instead.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
endpoint = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
internal = true;
|
||||||
|
description = ''
|
||||||
|
OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT`.
|
||||||
|
Host-driven via `services.hyperhive.otel.endpoint`.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
protocol = lib.mkOption {
|
||||||
|
type = lib.types.enum [
|
||||||
|
"http/protobuf"
|
||||||
|
"http/json"
|
||||||
|
"grpc"
|
||||||
|
];
|
||||||
|
default = "http/protobuf";
|
||||||
|
internal = true;
|
||||||
|
description = ''
|
||||||
|
OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`.
|
||||||
|
Host-driven via `services.hyperhive.otel.protocol`.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
headersCredential = lib.mkOption {
|
||||||
|
# `str`, not `path`: a `path`-typed *relative* literal (e.g.
|
||||||
|
# `./otel-headers`) is hash-copied into the world-readable nix store
|
||||||
|
# at eval time, which would defeat the whole point of this option.
|
||||||
|
# Keep it a string and require an absolute runtime path so the secret
|
||||||
|
# is only ever read from disk by systemd at start, never nix-stored.
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
internal = true;
|
||||||
|
description = ''
|
||||||
|
Absolute path to an operator-provided secret file whose contents
|
||||||
|
become `OTEL_EXPORTER_OTLP_HEADERS` (e.g.
|
||||||
|
`Authorization=Bearer <token>`). Host-driven via
|
||||||
|
`services.hyperhive.otel.headersCredential`.
|
||||||
|
|
||||||
|
The rest of the OTEL config ships in the world-readable managed
|
||||||
|
claude settings json, but the header is a secret, so it's handled
|
||||||
|
separately: hive-c0re forwards this file into the container's
|
||||||
|
systemd credential store, and the `hive-otel-header` oneshot
|
||||||
|
reads it at runtime (`LoadCredential`) and writes it into the
|
||||||
|
agent's `0600` `~/.claude/settings.json` `env` block. The token
|
||||||
|
is read from disk at start and never copied into the nix store or
|
||||||
|
the world-readable settings file.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
extraResourceAttributes = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
internal = true;
|
||||||
|
description = ''
|
||||||
|
Extra comma-separated entries appended to
|
||||||
|
`OTEL_RESOURCE_ATTRIBUTES` after the built-in
|
||||||
|
`service.name` / `agent` / `hive` / `swarm` labels.
|
||||||
|
Host-driven via `services.hyperhive.otel.extraResourceAttributes`.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
metricIntervalMs = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.ints.positive;
|
||||||
|
default = null;
|
||||||
|
internal = true;
|
||||||
|
description = ''
|
||||||
|
Metric export interval in milliseconds, set as
|
||||||
|
`OTEL_METRIC_EXPORT_INTERVAL`. Null leaves Claude Code's 60s
|
||||||
|
default. Host-driven via `services.hyperhive.otel.metricIntervalMs`.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
debug = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
internal = true;
|
||||||
|
description = ''
|
||||||
|
Emit OTEL SDK diagnostics to stderr (`CLAUDE_CODE_OTEL_DIAG_STDERR=1`).
|
||||||
|
Host-driven via `services.hyperhive.otel.debug`.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
options.hyperhive.claudeMarketplaces = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ "anthropics/claude-plugins-official" ];
|
||||||
|
example = [
|
||||||
|
"anthropics/claude-plugins-official"
|
||||||
|
"anthropics/claude-plugins-community"
|
||||||
|
];
|
||||||
|
description = ''
|
||||||
|
Claude Code plugin marketplaces to add at harness boot. Each
|
||||||
|
entry is passed to `claude plugin marketplace add <source>`
|
||||||
|
(`owner/repo`, full git URL, or local path). Idempotent —
|
||||||
|
re-adding an existing marketplace is treated as success.
|
||||||
|
Required before `hyperhive.claudePlugins` entries that
|
||||||
|
reference a marketplace (e.g. `foo@claude-plugins-official`).
|
||||||
|
Rendered to `/etc/hyperhive/claude-marketplaces.json`.
|
||||||
|
|
||||||
|
Defaults to Anthropic's official marketplace; agents get it
|
||||||
|
out of the box without any per-agent.nix wiring.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
options.hyperhive.claudePlugins = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ ];
|
||||||
|
example = [
|
||||||
|
"formatter@my-marketplace"
|
||||||
|
"thinking-tools@anthropics"
|
||||||
|
];
|
||||||
|
description = ''
|
||||||
|
Claude Code plugins to install at harness boot. Each entry is
|
||||||
|
passed verbatim to `claude plugin install <spec>` once per
|
||||||
|
container start, before the turn loop opens. `claude plugin
|
||||||
|
install` is expected to be idempotent, so reinstalling on every
|
||||||
|
boot is cheap. Failures log a warning but do not abort boot — a
|
||||||
|
missing plugin is preferable to a non-serving agent. Rendered to
|
||||||
|
`/etc/hyperhive/claude-plugins.json`; the harness reads it via
|
||||||
|
`plugins::install_configured`.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
options.hyperhive.claudePluginsAutoUpdate = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = ''
|
||||||
|
When true, the harness runs `claude plugin marketplace update`
|
||||||
|
before installing plugins at boot, pulling the latest index from
|
||||||
|
all configured marketplaces. Disabled by default — most agents
|
||||||
|
want pinned plugin versions and the network round-trip adds to
|
||||||
|
boot time. Enable for agents that should always install the latest
|
||||||
|
available version of their plugins.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
config = {
|
||||||
|
environment.etc."hyperhive/claude-plugins.json".text =
|
||||||
|
builtins.toJSON config.hyperhive.claudePlugins;
|
||||||
|
|
||||||
|
environment.etc."hyperhive/claude-marketplaces.json".text =
|
||||||
|
builtins.toJSON config.hyperhive.claudeMarketplaces;
|
||||||
|
|
||||||
|
environment.etc."hyperhive/claude-plugins-auto-update.json".text =
|
||||||
|
builtins.toJSON config.hyperhive.claudePluginsAutoUpdate;
|
||||||
|
|
||||||
|
# Hive-enforced claude settings. claude-code auto-discovers managed
|
||||||
|
# settings at this canonical Linux path (precedence #1, read-only,
|
||||||
|
# un-overridable by user/project/CLI) — so the harness doesn't pass
|
||||||
|
# `--settings` or copy the blob per turn. effortLevel is
|
||||||
|
# deliberately NOT shipped here: effort is controlled live via the
|
||||||
|
# `--effort` CLI flag (HIVE_DEFAULT_EFFORT / the per-agent UI slider),
|
||||||
|
# which managed scope would otherwise override and lock.
|
||||||
|
# Hive-enforced settings merged with a per-agent `env` block at BUILD
|
||||||
|
# time via `jq` (not eval-time `readFile`, which would be import-from-
|
||||||
|
# derivation). The `env` block is always present: `baseClaudeEnv` sets
|
||||||
|
# behaviour flags and the remote-control session prefix for every agent;
|
||||||
|
# `otelSettingsEnv` is merged on top when OTEL is enabled. claude-code
|
||||||
|
# auto-discovers this file in every context (harness turn-loop AND
|
||||||
|
# `hivectl choom`) so no launch wrapper is needed.
|
||||||
|
environment.etc."claude-code/managed-settings.json".source =
|
||||||
|
let
|
||||||
|
baseSettings = "${config.hyperhive.packages.assets}/share/hyperhive/prompts/claude-settings.json";
|
||||||
|
# Merge base env (always) with OTEL env (when enabled). jq is always
|
||||||
|
# run — `baseClaudeEnv` contains per-agent values (e.g.
|
||||||
|
# CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX) that can't live in the
|
||||||
|
# static store asset.
|
||||||
|
allEnv =
|
||||||
|
baseClaudeEnv
|
||||||
|
// lib.optionalAttrs otelCfg.enable otelSettingsEnv
|
||||||
|
// lib.optionalAttrs (otelCfg.enable && otelCfg.debug) {
|
||||||
|
# SDK diagnostics — noisy; only on when services.hyperhive.otel.debug = true.
|
||||||
|
CLAUDE_CODE_OTEL_DIAG_STDERR = "1";
|
||||||
|
};
|
||||||
|
in
|
||||||
|
pkgs.runCommand "managed-settings.json" { nativeBuildInputs = [ pkgs.jq ]; } ''
|
||||||
|
jq --argjson env ${lib.escapeShellArg (builtins.toJSON allEnv)} \
|
||||||
|
'. + { env: $env }' ${baseSettings} > "$out"
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Inject the OTEL auth header (a secret) into the agent's *user*
|
||||||
|
# claude settings at runtime, keeping it out of the world-readable
|
||||||
|
# managed settings json above and out of the nix store entirely.
|
||||||
|
# hive-c0re forwards the operator's `headersCredential` file into
|
||||||
|
# this container's systemd credential store; this oneshot reads it
|
||||||
|
# via `LoadCredential` at start and merges `OTEL_EXPORTER_OTLP_HEADERS`
|
||||||
|
# into `~/.claude/settings.json` (0600, agent-owned). claude layers
|
||||||
|
# the user `env` on top of the managed one, so both the harness
|
||||||
|
# turn-loop and `hivectl choom` (same agent user) pick it up. Ordering
|
||||||
|
# is best-effort (`before`, not a hard dep): if it fails the harness
|
||||||
|
# still starts and telemetry just exports unauthenticated.
|
||||||
|
systemd.services.hive-otel-header =
|
||||||
|
lib.mkIf (config.hyperhive.otel.enable && config.hyperhive.otel.headersCredential != null)
|
||||||
|
{
|
||||||
|
description = "Inject the OTEL auth header into the agent's claude user settings";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
before = [ "hive-ag3nt.service" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
User = userName;
|
||||||
|
Group = userName;
|
||||||
|
LoadCredential = [ "otel-headers" ];
|
||||||
|
ExecStart = pkgs.writeShellScript "hive-otel-header" ''
|
||||||
|
set -eu
|
||||||
|
umask 077
|
||||||
|
hdr="$CREDENTIALS_DIRECTORY/otel-headers"
|
||||||
|
[ -r "$hdr" ] || exit 0
|
||||||
|
dir=${homeDir}/.claude
|
||||||
|
settings="$dir/settings.json"
|
||||||
|
mkdir -p "$dir"
|
||||||
|
base='{}'
|
||||||
|
[ -s "$settings" ] && base="$(cat "$settings")"
|
||||||
|
printf '%s' "$base" | ${pkgs.jq}/bin/jq \
|
||||||
|
--rawfile h "$hdr" \
|
||||||
|
'.env = ((.env // {}) + { OTEL_EXPORTER_OTLP_HEADERS: ($h | rtrimstr("\n")) })' \
|
||||||
|
> "$settings.tmp"
|
||||||
|
mv "$settings.tmp" "$settings"
|
||||||
|
chmod 0600 "$settings"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Seed claude's onboarding + per-project trust state once. claude only
|
||||||
|
# marks `hasCompletedOnboarding` (global) and the project trust dialog
|
||||||
|
# as accepted when run *interactively*; the harness only ever runs it
|
||||||
|
# headless (`--print`) and `claude auth login` doesn't set them either.
|
||||||
|
# So the first interactive launch (`hivectl choom`) would drop the
|
||||||
|
# operator into the onboarding/trust walkthrough despite valid OAuth
|
||||||
|
# creds. This oneshot is the single place hyperhive touches
|
||||||
|
# `~/.claude.json`: it runs before the harness (so nothing races it),
|
||||||
|
# is idempotent (skips when the flags are already set), and is
|
||||||
|
# best-effort (`before`, not a hard dep — a failed seed leaves the file
|
||||||
|
# untouched and the harness still starts). Credentials live in the
|
||||||
|
# separate `~/.claude/.credentials.json`, so this never touches secrets.
|
||||||
|
systemd.services.hive-claude-onboarding = {
|
||||||
|
description = "Seed claude onboarding + project-trust so choom skips the walkthrough";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
before = [ "hive-ag3nt.service" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
User = userName;
|
||||||
|
Group = userName;
|
||||||
|
ExecStart = pkgs.writeShellScript "hive-claude-onboarding" ''
|
||||||
|
set -eu
|
||||||
|
umask 077
|
||||||
|
cfg=${homeDir}/.claude.json
|
||||||
|
dir=/agents/${userName}/state
|
||||||
|
base='{}'
|
||||||
|
[ -s "$cfg" ] && base="$(cat "$cfg")"
|
||||||
|
# Idempotent: nothing to do when already onboarded + trusted.
|
||||||
|
if printf '%s' "$base" | ${pkgs.jq}/bin/jq -e \
|
||||||
|
--arg d "$dir" \
|
||||||
|
'.hasCompletedOnboarding == true and (.projects[$d].hasTrustDialogAccepted == true)' \
|
||||||
|
>/dev/null 2>&1; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf '%s' "$base" | ${pkgs.jq}/bin/jq \
|
||||||
|
--arg d "$dir" \
|
||||||
|
'.hasCompletedOnboarding = true
|
||||||
|
| .projects[$d].hasTrustDialogAccepted = true
|
||||||
|
| .projects[$d].hasCompletedProjectOnboarding = true' \
|
||||||
|
> "$cfg.tmp"
|
||||||
|
mv "$cfg.tmp" "$cfg"
|
||||||
|
chmod 0600 "$cfg"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
73
nix/agent-modules/dashboard-links.nix
Normal file
73
nix/agent-modules/dashboard-links.nix
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
# Extra navigation links surfaced on the hive-c0re dashboard card for
|
||||||
|
# this agent: the option and the oneshot that writes them into the
|
||||||
|
# state dir where hive-c0re reads them.
|
||||||
|
{
|
||||||
|
lib,
|
||||||
|
config,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
options.hyperhive.dashboardLinks = lib.mkOption {
|
||||||
|
type = lib.types.listOf (
|
||||||
|
lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
label = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "Display label for the link.";
|
||||||
|
};
|
||||||
|
icon = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
description = "Optional icon emoji or short glyph.";
|
||||||
|
};
|
||||||
|
url = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "Full URL (may include a different port, e.g. http://localhost:9001/stats).";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
default = [ ];
|
||||||
|
example = lib.literalExpression ''
|
||||||
|
[
|
||||||
|
{ label = "Stats"; icon = "📊"; url = "http://localhost:9001/stats"; }
|
||||||
|
]
|
||||||
|
'';
|
||||||
|
description = ''
|
||||||
|
Extra navigation links surfaced on the hive-c0re dashboard card for
|
||||||
|
this agent. Declare any additional web UI pages the agent exposes —
|
||||||
|
stats pages, custom UIs, etc. hive-c0re reads the JSON file this
|
||||||
|
option produces at each container-view snapshot and attaches the
|
||||||
|
links to the agent card without any code changes.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
config = {
|
||||||
|
# Write declared dashboardLinks to the state dir so hive-c0re can
|
||||||
|
# read them without accessing the container's /etc/ from the host.
|
||||||
|
# Best-effort oneshot (always exit 0):
|
||||||
|
# docs/conventions.md::Best-effort oneshot services.
|
||||||
|
systemd.services.hive-dashboard-links = lib.mkIf (config.hyperhive.dashboardLinks != [ ]) {
|
||||||
|
description = "write declarative dashboardLinks to agent state dir";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
# Pin the journal identity (else it's the `script` store-path wrapper).
|
||||||
|
SyslogIdentifier = "hive-dashboard-links";
|
||||||
|
};
|
||||||
|
environment.LINKS_JSON = builtins.toJSON config.hyperhive.dashboardLinks;
|
||||||
|
script = ''
|
||||||
|
# Sub-agents have their state dir bind-mounted at /agents/<name>/state.
|
||||||
|
# Use a glob — exactly one match per container at runtime.
|
||||||
|
STATE_DIR=$(echo /agents/*/state)
|
||||||
|
if [ ! -d "$STATE_DIR" ]; then
|
||||||
|
echo "hive-dashboard-links: no state dir found at /agents/*/state; skipping"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf '%s' "$LINKS_JSON" > "$STATE_DIR/hyperhive-dashboard-links.json"
|
||||||
|
echo "hive-dashboard-links: wrote $(printf '%s' "$LINKS_JSON" | wc -c) bytes to $STATE_DIR/hyperhive-dashboard-links.json"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
220
nix/agent-modules/default.nix
Normal file
220
nix/agent-modules/default.nix
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
# Shared scaffolding for every hyperhive harness container.
|
||||||
|
# `../templates/agent.nix` and `../templates/ruth.nix` both import
|
||||||
|
# agents use the same service unit regardless of which entry-point
|
||||||
|
# they came from.
|
||||||
|
#
|
||||||
|
# This is the core module: container plumbing (boot/nix/nixpkgs),
|
||||||
|
# base tooling, and the cross-cutting `hyperhive.icon` option. Each
|
||||||
|
# feature lives in its own sibling module (imported below) that
|
||||||
|
# declares its own `hyperhive.*` options + config.
|
||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
lib,
|
||||||
|
config,
|
||||||
|
# Flake inputs routed through _module.args by the agent flake.nix.
|
||||||
|
# Default to {} so the module evaluates cleanly even when the agent
|
||||||
|
# flake doesn't set up the routing pattern (e.g. during standalone
|
||||||
|
# nixos-rebuild without a flake wrapper).
|
||||||
|
flakeInputs ? { },
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
imports = [
|
||||||
|
./agent-service.nix
|
||||||
|
./bash-env.nix
|
||||||
|
./claude-settings.nix
|
||||||
|
./dashboard-links.nix
|
||||||
|
./docs.nix
|
||||||
|
./forge.nix
|
||||||
|
./frontend.nix
|
||||||
|
./github.nix
|
||||||
|
./matrix.nix
|
||||||
|
./mcp.nix
|
||||||
|
./network.nix
|
||||||
|
./packages.nix
|
||||||
|
./user.nix
|
||||||
|
./weston-vnc.nix
|
||||||
|
(lib.mkRemovedOptionModule [ "hyperhive" "web" "useUnixSocket" ] ''
|
||||||
|
Unix socket mode is always enabled for all agents. Remove the
|
||||||
|
setting from your agent.nix.
|
||||||
|
'')
|
||||||
|
(lib.mkRemovedOptionModule [ "hyperhive" "allowedBashPatterns" ] ''
|
||||||
|
The built-in Bash tool is fully disabled; agents use
|
||||||
|
mcp__bash__run instead. Remove the setting from your agent.nix.
|
||||||
|
'')
|
||||||
|
];
|
||||||
|
|
||||||
|
options.hyperhive.icon = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
example = lib.literalExpression "./icon.svg";
|
||||||
|
description = ''
|
||||||
|
Path to an SVG file used as this agent's icon — shown on the
|
||||||
|
dashboard and the per-agent web UI (header + favicon). Commit
|
||||||
|
the SVG into the agent's config repo next to `agent.nix` and
|
||||||
|
reference it as a relative path (`./icon.svg`).
|
||||||
|
|
||||||
|
When null (the default) the agent falls back to the shared
|
||||||
|
hyperhive logo. The harness serves the icon (configured or
|
||||||
|
default) at `GET /icon` on the per-agent web port.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
config = {
|
||||||
|
assertions = [
|
||||||
|
# Guard the inputs-routed-as-output pattern: the agent flake.nix is
|
||||||
|
# expected to set `_module.args.flakeInputs = builtins.removeAttrs inputs ["self"]`.
|
||||||
|
# If `self` leaks into flakeInputs the agent gets a spurious attrset
|
||||||
|
# entry that can shadow real inputs and is almost certainly a bug.
|
||||||
|
# Guard with `or {}` so standalone evaluation stays clean when
|
||||||
|
# flakeInputs is absent from _module.args.
|
||||||
|
{
|
||||||
|
assertion = !(builtins.hasAttr "self" (config._module.args.flakeInputs or { }));
|
||||||
|
message = ''
|
||||||
|
hyperhive: `flakeInputs` must not contain "self".
|
||||||
|
In your agent flake.nix, use:
|
||||||
|
_module.args.flakeInputs = builtins.removeAttrs inputs [ "self" ];
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
# hyperhive.icon must reference an SVG file when set.
|
||||||
|
{
|
||||||
|
assertion = config.hyperhive.icon == null || lib.hasSuffix ".svg" (toString config.hyperhive.icon);
|
||||||
|
message = "hyperhive.icon must point to an .svg file";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
# Operator-set per-agent icon (hyperhive.icon). When configured, the
|
||||||
|
# SVG lands at /etc/hyperhive/icon.svg; the harness serves it at
|
||||||
|
# GET /icon, falling back to the bundled hyperhive logo when absent.
|
||||||
|
# Consumed by forge-avatar-sync (./forge.nix) and the matrix avatar
|
||||||
|
# sync (./matrix.nix) too.
|
||||||
|
environment.etc."hyperhive/icon.svg" = lib.mkIf (config.hyperhive.icon != null) {
|
||||||
|
source = config.hyperhive.icon;
|
||||||
|
};
|
||||||
|
|
||||||
|
boot.isNspawnContainer = true;
|
||||||
|
|
||||||
|
# Use a disk-backed /tmp instead of the default tmpfs so large scratch
|
||||||
|
# writes (nix-develop shells, cargo build dirs, multi-GB downloads) land
|
||||||
|
# on disk rather than eating container RAM. The tmpfs default mounts
|
||||||
|
# ~3.2 GB of RAM per container; disk-backed /tmp is effectively unlimited
|
||||||
|
# and cheaper for agents that do heavy build work.
|
||||||
|
#
|
||||||
|
# cleanOnBoot defaults to false in nixpkgs — set it explicitly so /tmp is
|
||||||
|
# cleared on each container start (D! tmpfiles rule), preserving the
|
||||||
|
# ephemeral-per-boot semantics agents expect from a tmpfs /tmp, just
|
||||||
|
# without the RAM cost.
|
||||||
|
boot.tmp.useTmpfs = false;
|
||||||
|
boot.tmp.cleanOnBoot = true;
|
||||||
|
|
||||||
|
# Every agent gets flakes + the modern `nix` CLI out of the box.
|
||||||
|
# Equivalent to passing `--extra-experimental-features 'nix-command
|
||||||
|
# flakes'` on every invocation. Agents shell out to `nix build` /
|
||||||
|
# `nix flake` constantly (devshells, ad-hoc evals, fetching their
|
||||||
|
# own MCP-server flakes); without this they hit the "experimental
|
||||||
|
# feature not enabled" wall on the first try.
|
||||||
|
nix.settings.experimental-features = [
|
||||||
|
"nix-command"
|
||||||
|
"flakes"
|
||||||
|
];
|
||||||
|
|
||||||
|
# `lib.mkForce` overrides nixpkgs's normal-priority `false` so
|
||||||
|
# in-container `nix build` invocations fall back to unsandboxed
|
||||||
|
# local builds rather than failing on the missing user-namespace.
|
||||||
|
# See `docs/gotchas.md::Containerized nix-daemon needs
|
||||||
|
# sandbox-fallback = true` + `docs/security.md` for the rationale.
|
||||||
|
#
|
||||||
|
# Note: with NIX_REMOTE=daemon below this becomes a no-op for the
|
||||||
|
# common case — daemon-routed builds run on the host where sandboxing
|
||||||
|
# works. It stays as a belt-and-suspenders fallback for any context
|
||||||
|
# that bypasses the daemon (e.g. direct nix-store invocations).
|
||||||
|
nix.settings.sandbox-fallback = lib.mkForce true;
|
||||||
|
|
||||||
|
# Route ALL nix invocations in this container through the host
|
||||||
|
# nix-daemon socket, regardless of whether the caller is root or
|
||||||
|
# non-root. Without this, root contexts (PID 1, systemd services
|
||||||
|
# running as root) default to store=auto which resolves to the LOCAL
|
||||||
|
# store — bypassing the shared daemon, its remote builders, and the
|
||||||
|
# host's prebuilt derivation cache, causing spurious full rebuilds.
|
||||||
|
#
|
||||||
|
# systemd.globalEnvironment sets DefaultEnvironment in systemd.conf,
|
||||||
|
# so every unit started by PID 1 inherits NIX_REMOTE=daemon.
|
||||||
|
# Non-root nix clients already default to the daemon socket, so this
|
||||||
|
# is a no-op for them; it only matters for root services that would
|
||||||
|
# otherwise silently use the local store.
|
||||||
|
systemd.globalEnvironment.NIX_REMOTE = "daemon";
|
||||||
|
|
||||||
|
# `claude-code` is unfree. Each per-agent container's nixosConfiguration
|
||||||
|
# evaluates its own `nixpkgs` instance, so the operator's host-level
|
||||||
|
# `nixpkgs.config.allowUnfreePredicate` does not propagate into here —
|
||||||
|
# we have to allow it inside the container's config as well.
|
||||||
|
nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [ "claude-code" ];
|
||||||
|
|
||||||
|
# Core tooling every agent gets. Per-bin split packages (see
|
||||||
|
# nix/packages/default.nix + ./packages.nix) rather than the full
|
||||||
|
# `hyperhive` bundle — that bundle also carries `hivectl` (a
|
||||||
|
# host-admin CLI that dials the *host* admin socket — useless and
|
||||||
|
# unreachable from inside a container — wrapped with
|
||||||
|
# `wireguard-tools` for `hivectl wg`). The daemon/harness/MCP bins
|
||||||
|
# the harness execs (hive-agent{,-mcp}, hive-bash-daemon,
|
||||||
|
# hive-matrix-daemon, hive-bash-mcp, hive-matrix-mcp) are wired via
|
||||||
|
# their own ExecStart/command lines in the sibling modules — they
|
||||||
|
# don't need to be on PATH too. Only these two are actually looked
|
||||||
|
# up on PATH by claude/shell code inside the container:
|
||||||
|
# `hive-agent-wake` (external wake CLI, docs/turn-loop/mcp.md) and
|
||||||
|
# `hive-metric` (agent-emitted custom metrics CLI,
|
||||||
|
# docs/observability.md).
|
||||||
|
environment.systemPackages = [
|
||||||
|
config.hyperhive.packages.hive-agent-wake
|
||||||
|
config.hyperhive.packages.hive-metric
|
||||||
|
]
|
||||||
|
++ (with pkgs; [
|
||||||
|
claude-code
|
||||||
|
bashInteractive
|
||||||
|
coreutils-full
|
||||||
|
# procps for pkill — used by the web UI's /api/cancel to SIGINT the
|
||||||
|
# in-flight claude turn.
|
||||||
|
procps
|
||||||
|
# jq: JSON processing in shell — useful for parsing API responses,
|
||||||
|
# forge REST calls, sqlite output, etc.
|
||||||
|
jq
|
||||||
|
# curl: HTTP client for forge REST API and other web requests.
|
||||||
|
curl
|
||||||
|
]);
|
||||||
|
|
||||||
|
# HIVE_ASSETS_DIR points at the project's static runtime assets
|
||||||
|
# (branding + claude prompts; see `nix/packages/assets.nix`). Set
|
||||||
|
# here so both the harness binary and any user-shell `cargo run`
|
||||||
|
# inside the container resolve them from the same path.
|
||||||
|
# SHELL must be set so claude's Bash tool finds a POSIX shell.
|
||||||
|
# HIVE_CONTEXT_WINDOW_TOKENS_* are injected by the meta flake from the
|
||||||
|
# host-level `services.hyperhive.c0re.contextWindowTokens` option — not
|
||||||
|
# set here.
|
||||||
|
environment.variables = {
|
||||||
|
HIVE_ASSETS_DIR = "${config.hyperhive.packages.assets}/share/hyperhive";
|
||||||
|
SHELL = "${pkgs.bashInteractive}/bin/bash";
|
||||||
|
# Route interactive-shell nix invocations through the host daemon.
|
||||||
|
# Redundant with /etc/profile.d/nix-daemon.sh but ensures it's set
|
||||||
|
# regardless of which profile files are sourced.
|
||||||
|
NIX_REMOTE = "daemon";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Git is needed by claude's Bash tool (for the agent <-> manager config
|
||||||
|
# request flow) and by hive-c0re's own setup_applied / setup_proposed.
|
||||||
|
# The per-agent `applied/<name>/flake.nix` overrides `user.name` and
|
||||||
|
# `user.email` with the agent's identity — values here are `mkDefault`
|
||||||
|
# so the per-agent override wins without needing `mkForce`.
|
||||||
|
programs.git = {
|
||||||
|
enable = true;
|
||||||
|
config = {
|
||||||
|
user = {
|
||||||
|
name = lib.mkDefault "hyperhive";
|
||||||
|
email = lib.mkDefault "hyperhive@local";
|
||||||
|
};
|
||||||
|
init.defaultBranch = lib.mkDefault "main";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
system.stateVersion = "25.11";
|
||||||
|
};
|
||||||
|
}
|
||||||
50
nix/agent-modules/docs.nix
Normal file
50
nix/agent-modules/docs.nix
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# In-container hyperhive reference docs: the `hyperhive.docs.*`
|
||||||
|
# options and the `$HIVE_DOCS_DIR` wiring the harness reads.
|
||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
lib,
|
||||||
|
config,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
options.hyperhive.docs.enable = lib.mkEnableOption ''
|
||||||
|
make the hyperhive reference docs (the repo `docs/` tree, shipped
|
||||||
|
read-only as the standalone `hyperhive-docs` derivation) available
|
||||||
|
in-container. When enabled the harness exposes the docs dir to claude
|
||||||
|
via `claude --add-dir`, so the markdown is readable at
|
||||||
|
`$HIVE_DOCS_DIR/`, and appends a single pointer sentence to the agent's
|
||||||
|
system prompt so it knows the docs exist (see
|
||||||
|
`hive-ag3nt::prompt::render`). Default-on for the root/manager agent
|
||||||
|
(see `../templates/ruth.nix`), off elsewhere; any agent can flip it from its
|
||||||
|
`agent.nix`.
|
||||||
|
'';
|
||||||
|
|
||||||
|
options.hyperhive.docs.source = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
default = config.hyperhive.packages.reference-docs;
|
||||||
|
defaultText = lib.literalMD "`hyperhive.packages.reference-docs` (built from the repo `docs/` tree)";
|
||||||
|
description = ''
|
||||||
|
Store path of the reference-docs tree exposed at `$HIVE_DOCS_DIR`
|
||||||
|
when `hyperhive.docs.enable` is set. Defaults to the flake's
|
||||||
|
`reference-docs` package (the `nix/packages/reference-docs.nix`
|
||||||
|
build) so a standalone container build from a full checkout
|
||||||
|
works unchanged. The generated meta flake overrides this with the
|
||||||
|
narrow `hyperhive-docs` flake input so a doc edit only
|
||||||
|
re-locks that input instead of rebuilding the container from a
|
||||||
|
re-hashed `hyperhive` source.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
config = {
|
||||||
|
environment.variables = lib.mkIf config.hyperhive.docs.enable {
|
||||||
|
# The harness reads HIVE_DOCS_DIR and passes it to claude as
|
||||||
|
# `--add-dir` so the docs are readable, and appends a single
|
||||||
|
# pointer sentence to the system prompt
|
||||||
|
# (hive-ag3nt::prompt::render) telling the agent the docs exist.
|
||||||
|
# Source is `hyperhive.docs.source` (the narrow `hyperhive-docs`
|
||||||
|
# meta-flake input, or `pkgs.hyperhive-docs` for standalone
|
||||||
|
# builds). See hive-ag3nt::turn.
|
||||||
|
HIVE_DOCS_DIR = "${config.hyperhive.docs.source}";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
195
nix/agent-modules/forge.nix
Normal file
195
nix/agent-modules/forge.nix
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
# In-container forge (Forgejo) integration: the `tea` CLI login
|
||||||
|
# oneshot, the `hive-forge` verb CLI on PATH, and the icon → forge
|
||||||
|
# avatar sync.
|
||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
lib,
|
||||||
|
config,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
userName = config.hyperhive.user.name;
|
||||||
|
homeDir = "/home/${userName}";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.hyperhive.forge.url = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "http://localhost:3000";
|
||||||
|
example = "http://forge.internal:3000";
|
||||||
|
description = ''
|
||||||
|
Base URL of the hyperhive-managed Forgejo. Used at container
|
||||||
|
boot by a oneshot systemd unit that calls
|
||||||
|
`tea login add --url <this> --token "$(cat $HYPERHIVE_STATE_DIR/forge-token)"`
|
||||||
|
(= `/agents/<name>/state/forge-token`) so the agent's claude can
|
||||||
|
shell out to `tea` without an extra auth dance. No-op when the
|
||||||
|
forge-token file is missing (i.e. hive-forge isn't running on
|
||||||
|
the host).
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
config = {
|
||||||
|
assertions = [
|
||||||
|
# hyperhive.forge.url must look like an HTTP URL when non-default.
|
||||||
|
{
|
||||||
|
assertion =
|
||||||
|
config.hyperhive.forge.url == ""
|
||||||
|
|| lib.hasPrefix "http://" config.hyperhive.forge.url
|
||||||
|
|| lib.hasPrefix "https://" config.hyperhive.forge.url;
|
||||||
|
message = "hyperhive.forge.url must be an http:// or https:// URL (got: \"${config.hyperhive.forge.url}\")";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
environment.systemPackages = [
|
||||||
|
# tea: gitea/forgejo CLI client. Configured at boot by the
|
||||||
|
# tea-login oneshot below if /state/forge-token is present, so
|
||||||
|
# claude can `tea repos create`, `tea pulls create`, etc.
|
||||||
|
pkgs.tea
|
||||||
|
# hive-forge <verb>: CLI wrapping common Forgejo REST API operations
|
||||||
|
# (view, pr, issue, comment, assign, close, labels, branches, etc.).
|
||||||
|
# The per-bin split package — narrow closure, no hivectl/wireguard.
|
||||||
|
config.hyperhive.packages.hive-forge
|
||||||
|
];
|
||||||
|
|
||||||
|
# One-shot: tea config.yml from the seeded forge token. Shape
|
||||||
|
# contract (always exit 0, no set -e, skip-silently, re-runnable):
|
||||||
|
# docs/conventions.md::Best-effort oneshot services.
|
||||||
|
systemd.services.tea-login = {
|
||||||
|
description = "configure tea CLI from hive-forge token (best-effort)";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "local-fs.target" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
# Pin the journal identity (else it's the `script` store-path wrapper).
|
||||||
|
SyslogIdentifier = "tea-login";
|
||||||
|
};
|
||||||
|
path = [
|
||||||
|
pkgs.curl
|
||||||
|
pkgs.python3
|
||||||
|
pkgs.coreutils
|
||||||
|
];
|
||||||
|
environment.HOME_DIR = homeDir;
|
||||||
|
environment.AGENT_USER = userName;
|
||||||
|
script = ''
|
||||||
|
# No `set -e`: best-effort posture (see docs pointer above).
|
||||||
|
FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url}
|
||||||
|
# $HYPERHIVE_STATE_DIR is system-wide via the meta flake.
|
||||||
|
TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token"
|
||||||
|
if [ ! -f "$TOKEN_FILE" ]; then
|
||||||
|
echo "tea-login: no forge-token at $TOKEN_FILE; skipping"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
TOKEN=$(cat "$TOKEN_FILE")
|
||||||
|
# Resolve the agent username from the forge API.
|
||||||
|
USER=$(curl -sf --max-time 5 \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
"$FORGE_URL/api/v1/user" \
|
||||||
|
| python3 -c 'import sys,json; print(json.load(sys.stdin).get("login",""))' \
|
||||||
|
2>/dev/null || true)
|
||||||
|
if [ -z "$USER" ]; then
|
||||||
|
echo "tea-login: could not resolve username from forge API; skipping"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# Config under the agent user's home, chown'd to them;
|
||||||
|
# service stays root-owned (see docs pointer above).
|
||||||
|
CONFIG="$HOME_DIR/.config/tea/config.yml"
|
||||||
|
mkdir -p "$(dirname "$CONFIG")" || true
|
||||||
|
cat > "$CONFIG" << EOF
|
||||||
|
logins:
|
||||||
|
- name: forge
|
||||||
|
url: $FORGE_URL
|
||||||
|
token: $TOKEN
|
||||||
|
default: true
|
||||||
|
ssh_host: ""
|
||||||
|
ssh_key: ""
|
||||||
|
insecure: false
|
||||||
|
ssh_agent: false
|
||||||
|
user: $USER
|
||||||
|
preferences:
|
||||||
|
editor: false
|
||||||
|
flag_defaults:
|
||||||
|
remote: ""
|
||||||
|
EOF
|
||||||
|
chown -R "$AGENT_USER:$AGENT_USER" "$HOME_DIR/.config" 2>/dev/null || true
|
||||||
|
echo "tea-login: configured for $FORGE_URL as $USER (config at $CONFIG)"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# Path-trigger sibling: re-fires forge-avatar-sync the moment
|
||||||
|
# `<state>/forge-token` appears. Mirrors the hive-matrix-daemon
|
||||||
|
# token-watcher pattern — on first agent deployment the container
|
||||||
|
# boots before hive-c0re has provisioned the forge-token, so the
|
||||||
|
# service fires too early and exits with "no forge-token found".
|
||||||
|
# Without this path unit, RemainAfterExit=true would prevent systemd
|
||||||
|
# from ever re-running the service. See
|
||||||
|
# docs/persistence.md::forge-avatar-sync.
|
||||||
|
systemd.paths.forge-avatar-sync = {
|
||||||
|
description = "trigger forge-avatar-sync when forge-token appears";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
pathConfig.PathExistsGlob = "/agents/*/state/forge-token";
|
||||||
|
};
|
||||||
|
|
||||||
|
# One-shot: hyperhive.icon → Forgejo profile avatar. Shape contract:
|
||||||
|
# docs/conventions.md::Best-effort oneshot services.
|
||||||
|
# RemainAfterExit = false so the .path trigger above can re-fire
|
||||||
|
# this unit when the forge-token arrives after boot.
|
||||||
|
systemd.services.forge-avatar-sync = {
|
||||||
|
description = "sync agent icon to Forgejo user avatar (best-effort)";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "tea-login.service" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = false;
|
||||||
|
# Pin the journal identity (else it's the `script` store-path wrapper).
|
||||||
|
SyslogIdentifier = "forge-avatar-sync";
|
||||||
|
};
|
||||||
|
path = [
|
||||||
|
pkgs.curl
|
||||||
|
pkgs.coreutils
|
||||||
|
pkgs.jq
|
||||||
|
pkgs.librsvg
|
||||||
|
];
|
||||||
|
script = ''
|
||||||
|
ICON=/etc/hyperhive/icon.svg
|
||||||
|
if [ ! -f "$ICON" ]; then
|
||||||
|
echo "forge-avatar-sync: no icon configured; skipping"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url}
|
||||||
|
# $HYPERHIVE_STATE_DIR is set system-wide by the meta flake
|
||||||
|
# (systemd.globalEnvironment) to `/agents/<name>/state`.
|
||||||
|
TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token"
|
||||||
|
if [ ! -f "$TOKEN_FILE" ]; then
|
||||||
|
echo "forge-avatar-sync: no forge-token found; skipping"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
TOKEN=$(cat "$TOKEN_FILE")
|
||||||
|
# Rasterize SVG → PNG (Forgejo's Go image library can't decode SVG).
|
||||||
|
PNG=$(mktemp --suffix=.png)
|
||||||
|
if ! rsvg-convert -f png -w 512 -h 512 "$ICON" -o "$PNG" 2>/dev/null; then
|
||||||
|
echo "forge-avatar-sync: rsvg-convert failed; skipping"
|
||||||
|
rm -f "$PNG"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
IMAGE=$(base64 -w 0 < "$PNG")
|
||||||
|
rm -f "$PNG"
|
||||||
|
# Forgejo POST /user/avatar expects {"image":"<base64>"} — just the
|
||||||
|
# raw base64 string, NOT a data URI (data:image/png;base64,...).
|
||||||
|
# Use jq to build the payload so the large base64 value is safely quoted.
|
||||||
|
PAYLOAD=$(jq -n --arg img "$IMAGE" '{image:$img}')
|
||||||
|
RESP=$(curl -sf --max-time 10 \
|
||||||
|
-X POST "$FORGE_URL/api/v1/user/avatar" \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$PAYLOAD" \
|
||||||
|
-w "\n%{http_code}" 2>/dev/null || true)
|
||||||
|
CODE=$(printf '%s' "$RESP" | tail -1)
|
||||||
|
if [ "$CODE" = "204" ] || [ "$CODE" = "200" ]; then
|
||||||
|
echo "forge-avatar-sync: avatar uploaded (HTTP $CODE)"
|
||||||
|
else
|
||||||
|
echo "forge-avatar-sync: upload returned HTTP $CODE — skipping (non-fatal)"
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue