rust+nix: load static assets at runtime, drop build.rs (#555)

Cuts every `include_bytes!`/`include_str!` of a non-rust path in
the workspace over to runtime file loads from `$HIVE_ASSETS_DIR`
(the `hyperhive-assets` derivation introduced in the previous
commit). After this commit the rust derivation has no compile-time
dependency on `branding/*` or `hive-ag3nt/prompts/*` anymore.

Call-site flips:

- `hive-c0re/src/forge.rs::CORE_AVATAR_PNG` /
  `CONFIG_ORG_AVATAR_PNG`: were `include_bytes!` of
  `branding/hyperhive.png` and `$OUT_DIR/agent-configs.png`. Now
  `ensure_core_avatar` / `ensure_config_org_avatar` `tokio::fs::read`
  via `hive_sh4re::assets::{core_avatar_png, config_org_avatar_png}`
  at startup. The `agent-configs.png` is now rendered by the
  `hyperhive-assets` derivation's rsvg-convert step (was
  `hive-c0re/build.rs` + librsvg on the rust derivation's
  nativeBuildInputs — both gone in the next commit).
- `hive-ag3nt/src/prompt.rs::TEMPLATE`: `render` now takes the
  template as an argument; `write_system_prompt` reads it once from
  `$HIVE_ASSETS_DIR/prompts/system.md` before calling render. The
  test module still `include_str!`s the production template so
  `cargo test --workspace` doesn't need `HIVE_ASSETS_DIR` set —
  this is the only remaining compile-time reference to the file
  from the rust workspace, gated to `#[cfg(test)]`.
- `hive-ag3nt/src/turn.rs::CLAUDE_SETTINGS`: was `include_str!`'d
  and written via `tokio::fs::write`; now `tokio::fs::copy` from
  `$HIVE_ASSETS_DIR/prompts/claude-settings.json` into the
  per-agent socket dir.
- `hive-ag3nt/src/web_ui.rs::DEFAULT_ICON`: was `include_str!`'d;
  now read on-demand from `$HIVE_ASSETS_DIR/branding/hyperhive.svg`
  inside `serve_icon`. Falls back to an empty body if missing so
  the endpoint never panics on a misconfigured container (matches
  the existing "per-agent icon.svg override" fallthrough).

`HIVE_ASSETS_DIR` wiring:

- Inside containers: `nix/templates/harness-base.nix`
  `environment.variables` sets it to
  `${pkgs.hyperhive-assets}/share/hyperhive` (resolved through
  the default overlay applied in `mkContainer`). Verified by
  building `agent-base-toplevel` and grepping the resulting
  `/etc/set-environment`.
- Host-side: `nix/modules/hive-c0re.nix` adds an `assets` option
  defaulting to `hyperhive.packages.${system}.assets`, threaded
  in from the flake's nixosModules wiring, and sets the same env
  var on the `hive-c0re` systemd unit so the daemon's
  `forge::ensure_*_avatar` startup hooks find the PNGs.

`hive-c0re/build.rs` deleted entirely; `[package].build` removed
from `hive-c0re/Cargo.toml`; rsvg-convert dependency lives in the
assets derivation only.

Validated: `nix build .#default .#checks.x86_64-linux.clippy
.#agent-base-toplevel .#manager-toplevel --fallback` all succeed.
`/etc/set-environment` in the toplevel shows
`HIVE_ASSETS_DIR="/nix/store/.../hyperhive-assets-0.1.0/share/hyperhive"`.
This commit is contained in:
iris 2026-05-29 02:41:29 +02:00 committed by Mara
commit 73684fb00a
11 changed files with 207 additions and 105 deletions

View file

@ -68,16 +68,16 @@
}; };
# Shared between buildDepsOnly + buildPackage + cargoClippy # Shared between buildDepsOnly + buildPackage + cargoClippy
# so the three derivations see the same toolchain shape. # so the three derivations see the same toolchain shape.
# librsvg: hive-c0re/build.rs invokes `rsvg-convert` to
# render branding/agent-configs.svg → PNG that the daemon
# `include_bytes!`s (#424); SVG stays source-of-truth.
# git: naersk used to auto-include it; crane is more # git: naersk used to auto-include it; crane is more
# minimal, so we add it explicitly so hive-c0re's # minimal, so we add it explicitly so hive-c0re's
# `lifecycle::tests::setup_proposed_*` (which shell out to # `lifecycle::tests::setup_proposed_*` (which shell out to
# `git init` + commit) pass under `cargo test` in the # `git init` + commit) pass under `cargo test` in the
# sandbox. # sandbox.
# `librsvg` used to live here for `hive-c0re/build.rs`'s
# rsvg-convert call — that whole codepath moved into the
# `hyperhive-assets` derivation in #555, so the rust
# derivation no longer needs the dependency.
nativeBuildInputs = [ nativeBuildInputs = [
pkgs.librsvg
pkgs.git pkgs.git
]; ];
} }
@ -181,6 +181,7 @@
hive-c0re = import ./nix/modules/hive-c0re.nix { hive-c0re = import ./nix/modules/hive-c0re.nix {
hyperhivePackage = system: self.packages.${system}.default; hyperhivePackage = system: self.packages.${system}.default;
hyperhiveFrontend = system: self.packages.${system}.frontend; hyperhiveFrontend = system: self.packages.${system}.frontend;
hyperhiveAssets = system: self.packages.${system}.assets;
hyperhiveFlake = "${self}"; hyperhiveFlake = "${self}";
# Per-container toplevels — wired into `system.extraDependencies` # Per-container toplevels — wired into `system.extraDependencies`
# when `services.hive-c0re.preBuildAgentTemplates` is on so the # when `services.hive-c0re.preBuildAgentTemplates` is on so the

View file

@ -29,22 +29,24 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use anyhow::Result; use anyhow::{Context, Result};
use crate::mcp::Flavor; use crate::mcp::Flavor;
const TEMPLATE: &str = include_str!("../prompts/system.md");
/// Assemble the system prompt for a given flavor + label + pronouns. /// Assemble the system prompt for a given flavor + label + pronouns.
/// Pure function — no I/O. Splits out from [`write_system_prompt`] so /// Pure function — no I/O. Splits out from [`write_system_prompt`] so
/// the marker logic + substitution is unit-testable in isolation. /// the marker logic + substitution is unit-testable in isolation.
/// The caller supplies the template body so tests can pass an inline
/// fixture and production reads it once at harness startup via
/// [`hive_sh4re::assets::prompt_template`] (`$HIVE_ASSETS_DIR/prompts/
/// system.md`).
#[must_use] #[must_use]
pub fn render(flavor: Flavor, label: &str, operator_pronouns: &str) -> String { pub fn render(template: &str, flavor: Flavor, label: &str, operator_pronouns: &str) -> String {
let target = match flavor { let target = match flavor {
Flavor::Agent => "agent", Flavor::Agent => "agent",
Flavor::Manager => "manager", Flavor::Manager => "manager",
}; };
let body = filter_role_blocks(TEMPLATE, target); let body = filter_role_blocks(template, target);
body.replace("{label}", label) body.replace("{label}", label)
.replace("{operator_pronouns}", operator_pronouns) .replace("{operator_pronouns}", operator_pronouns)
} }
@ -123,7 +125,11 @@ pub async fn write_system_prompt(
let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive")); let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive"));
tokio::fs::create_dir_all(parent).await.ok(); tokio::fs::create_dir_all(parent).await.ok();
let pronouns = std::env::var("HIVE_OPERATOR_PRONOUNS").unwrap_or_else(|_| "she/her".to_owned()); let pronouns = std::env::var("HIVE_OPERATOR_PRONOUNS").unwrap_or_else(|_| "she/her".to_owned());
let body = render(flavor, label, &pronouns); let template_path = hive_sh4re::assets::prompt_template();
let template = tokio::fs::read_to_string(&template_path)
.await
.with_context(|| format!("read claude system prompt template from {}", template_path.display()))?;
let body = render(&template, flavor, label, &pronouns);
let path = parent.join("claude-system-prompt.md"); let path = parent.join("claude-system-prompt.md");
tokio::fs::write(&path, body).await?; tokio::fs::write(&path, body).await?;
tracing::info!(path = %path.display(), "wrote claude system prompt"); tracing::info!(path = %path.display(), "wrote claude system prompt");
@ -134,6 +140,18 @@ pub async fn write_system_prompt(
mod tests { mod tests {
use super::*; use super::*;
// #555: production reads the system-prompt template from
// `$HIVE_ASSETS_DIR/prompts/system.md` at startup. The test module
// still `include_str!`s it directly because:
// 1. the "real template still substitutes / still filters" tests
// below need the actual production wording to be honest;
// 2. embedding it at compile time keeps `cargo test --workspace`
// runnable without setting `HIVE_ASSETS_DIR`;
// 3. this is the ONLY remaining compile-time reference to
// `prompts/system.md` from the rust workspace — production
// code loads it at runtime.
const PRODUCTION_TEMPLATE: &str = include_str!("../prompts/system.md");
const SAMPLE: &str = "\ const SAMPLE: &str = "\
shared opener shared opener
<!-- role:agent --> <!-- role:agent -->
@ -233,7 +251,7 @@ shared closer
// Real template's first agent line — keeps the renderer // Real template's first agent line — keeps the renderer
// honest about the {label} / {operator_pronouns} pair the // honest about the {label} / {operator_pronouns} pair the
// harness already relied on. // harness already relied on.
let rendered = render(Flavor::Agent, "alice", "they/them"); let rendered = render(PRODUCTION_TEMPLATE, Flavor::Agent, "alice", "they/them");
assert!(rendered.contains("hyperhive agent `alice`")); assert!(rendered.contains("hyperhive agent `alice`"));
assert!(rendered.contains("**they/them** pronouns")); assert!(rendered.contains("**they/them** pronouns"));
assert!(!rendered.contains("{label}")); assert!(!rendered.contains("{label}"));
@ -246,7 +264,7 @@ shared closer
// kill, schedule_*) MUST NOT appear in the agent's rendered // kill, schedule_*) MUST NOT appear in the agent's rendered
// prompt. Drift between flavor and tool surface bites every // prompt. Drift between flavor and tool surface bites every
// time it happens (cf. #511 missing-allow-list bug). // time it happens (cf. #511 missing-allow-list bug).
let rendered = render(Flavor::Agent, "alice", "she/her"); let rendered = render(PRODUCTION_TEMPLATE, Flavor::Agent, "alice", "she/her");
assert!(!rendered.contains("request_init_config")); assert!(!rendered.contains("request_init_config"));
assert!(!rendered.contains("request_apply_commit")); assert!(!rendered.contains("request_apply_commit"));
assert!(!rendered.contains("get_logs")); assert!(!rendered.contains("get_logs"));
@ -257,7 +275,7 @@ shared closer
#[test] #[test]
fn render_manager_includes_manager_only_tools() { fn render_manager_includes_manager_only_tools() {
let rendered = render(Flavor::Manager, "hm1nd", "she/her"); let rendered = render(PRODUCTION_TEMPLATE, Flavor::Manager, "hm1nd", "she/her");
assert!(rendered.contains("request_init_config")); assert!(rendered.contains("request_init_config"));
assert!(rendered.contains("request_apply_commit")); assert!(rendered.contains("request_apply_commit"));
assert!(rendered.contains("get_logs")); assert!(rendered.contains("get_logs"));
@ -269,9 +287,9 @@ shared closer
#[test] #[test]
fn render_uses_correct_role_opener() { fn render_uses_correct_role_opener() {
let agent = render(Flavor::Agent, "alice", "she/her"); let agent = render(PRODUCTION_TEMPLATE, Flavor::Agent, "alice", "she/her");
assert!(agent.starts_with("You are hyperhive agent")); assert!(agent.starts_with("You are hyperhive agent"));
let manager = render(Flavor::Manager, "hm1nd", "she/her"); let manager = render(PRODUCTION_TEMPLATE, Flavor::Manager, "hm1nd", "she/her");
assert!(manager.starts_with("You are the hyperhive manager")); assert!(manager.starts_with("You are the hyperhive manager"));
} }
} }

View file

@ -10,7 +10,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use anyhow::{Result, bail}; use anyhow::{Context, Result, bail};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command; use tokio::process::Command;
@ -18,15 +18,14 @@ use crate::events::{Bus, LiveEvent};
use crate::login::LoginState; use crate::login::LoginState;
use crate::mcp; use crate::mcp;
/// `--settings` JSON applied to every claude invocation. Lives as a // `--settings` JSON is read at runtime from
/// properly-formatted file in `prompts/claude-settings.json` so it's easy // `$HIVE_ASSETS_DIR/prompts/claude-settings.json` via
/// to read and edit; we ship it via `include_str!`. We turn off claude's // `hive_sh4re::assets::claude_settings()` (#555). We turn off claude's
/// in-session auto-compaction and its cross-session auto-memory because // in-session auto-compaction and its cross-session auto-memory because
/// hyperhive owns those concerns (`/compact` on overflow, notes // hyperhive owns those concerns (`/compact` on overflow, notes
/// persistence under `/state`). Unknown keys are silently ignored by // persistence under `/state`). Unknown keys are silently ignored by
/// claude-code; if a key gets renamed we'll spot it because the // claude-code; if a key gets renamed we'll spot it because the
/// corresponding behavior will start firing mid-turn again. // corresponding behavior will start firing mid-turn again.
const CLAUDE_SETTINGS: &str = include_str!("../prompts/claude-settings.json");
/// Regex-ish marker claude-code emits when context overflows. Same string /// Regex-ish marker claude-code emits when context overflows. Same string
/// bitburner-agent watches for. Empirically reliable across claude-code /// bitburner-agent watches for. Empirically reliable across claude-code
@ -159,7 +158,14 @@ pub async fn write_settings(socket: &Path) -> Result<PathBuf> {
let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive")); let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive"));
tokio::fs::create_dir_all(parent).await.ok(); tokio::fs::create_dir_all(parent).await.ok();
let path = parent.join("claude-settings.json"); let path = parent.join("claude-settings.json");
tokio::fs::write(&path, CLAUDE_SETTINGS).await?; // #555: source-of-truth is `$HIVE_ASSETS_DIR/prompts/claude-settings.json`;
// copy through the per-agent runtime dir so claude reads it from the
// same socket-adjacent location every time and so a future override
// (per-agent settings JSON layer) drops in cleanly.
let src = hive_sh4re::assets::claude_settings();
tokio::fs::copy(&src, &path)
.await
.with_context(|| format!("copy claude settings from {} to {}", src.display(), path.display()))?;
tracing::info!(path = %path.display(), "wrote claude settings"); tracing::info!(path = %path.display(), "wrote claude settings");
Ok(path) Ok(path)
} }
@ -508,10 +514,10 @@ pub async fn wait_for_login(
/// regular files + newest `mtime` across them. The two axes are both /// regular files + newest `mtime` across them. The two axes are both
/// load-bearing for `wait_for_login`'s refresh check (`session_refreshed`): /// load-bearing for `wait_for_login`'s refresh check (`session_refreshed`):
/// mtime catches the common case (re-login overwrites an existing /// mtime catches the common case (re-login overwrites an existing
/// credentials file in-place), file_count catches the pathological case /// credentials file in-place), `file_count` catches the pathological case
/// where `meta.modified()` errors on every file (exotic fs, NFS quirks) /// where `meta.modified()` errors on every file (exotic fs, NFS quirks)
/// so the mtime axis stays `None` forever but new files still trigger a /// so the mtime axis stays `None` forever but new files still trigger a
/// resume. Defaults to `{0, None}` on read_dir failure (missing or /// resume. Defaults to `{0, None}` on `read_dir` failure (missing or
/// unreadable dir) — `wait_for_login` then resumes when files first /// unreadable dir) — `wait_for_login` then resumes when files first
/// appear. /// appear.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
@ -542,9 +548,9 @@ fn snapshot_dir(dir: &Path) -> DirSnapshot {
/// Has the credentials dir been written since `prev`? Used as the /// Has the credentials dir been written since `prev`? Used as the
/// exit condition for `wait_for_login`: /// exit condition for `wait_for_login`:
/// ///
/// - file_count changed → something was added or removed, treat as /// - `file_count` changed → something was added or removed, treat as
/// refresh (covers the "all files have unreadable mtime" edge case). /// refresh (covers the "all files have unreadable mtime" edge case).
/// - newest_mtime advanced → existing file was rewritten in place /// - `newest_mtime` advanced → existing file was rewritten in place
/// (the common claude re-login path). /// (the common claude re-login path).
/// - prev had no mtime (empty or all-unreadable) and now has one → /// - prev had no mtime (empty or all-unreadable) and now has one →
/// first useful signal we've seen, treat as refresh. /// first useful signal we've seen, treat as refresh.

View file

@ -221,9 +221,15 @@ fn try_bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
/// Always returns an image, so consumers (dashboard, favicon) can hit /// Always returns an image, so consumers (dashboard, favicon) can hit
/// `/icon` unconditionally without probing whether one is configured. /// `/icon` unconditionally without probing whether one is configured.
async fn serve_icon() -> impl IntoResponse { async fn serve_icon() -> impl IntoResponse {
const DEFAULT_ICON: &str = include_str!("../../branding/hyperhive.svg"); // Per-agent icon overrides go through `/etc/hyperhive/icon.svg` (set
let body = std::fs::read_to_string("/etc/hyperhive/icon.svg") // via the `hyperhive.icon` agent.nix option); the bundled default is
.unwrap_or_else(|_| DEFAULT_ICON.to_string()); // resolved at runtime from
// `$HIVE_ASSETS_DIR/branding/hyperhive.svg` (#555). If neither file
// can be read we serve an empty body — keeps the response a valid
// SVG content-type without a panic on a misconfigured container.
let body = std::fs::read_to_string("/etc/hyperhive/icon.svg").unwrap_or_else(|_| {
std::fs::read_to_string(hive_sh4re::assets::branding_svg()).unwrap_or_default()
});
([("content-type", "image/svg+xml")], body) ([("content-type", "image/svg+xml")], body)
} }

View file

@ -2,13 +2,6 @@
name = "hive-c0re" name = "hive-c0re"
edition.workspace = true edition.workspace = true
version.workspace = true version.workspace = true
# Render branding/agent-configs.svg → $OUT_DIR/agent-configs.png at
# compile time (#424). build.rs shells out to `rsvg-convert`
# (librsvg, pulled in via flake.nix' crane nativeBuildInputs); the
# baked PNG is included via include_bytes! from forge.rs so no
# raster gets checked into git.
build = "build.rs"
[lints] [lints]
workspace = true workspace = true

View file

@ -1,49 +0,0 @@
//! Render `branding/agent-configs.svg` → `$OUT_DIR/agent-configs.png`
//! at compile time so the daemon can `include_bytes!` the PNG without
//! checking the raster into git (#424 mara: "generate png on the fly
//! or in build"). The SVG is the source of truth; the PNG is a build
//! artifact.
//!
//! Uses `rsvg-convert` from PATH (librsvg, already available in
//! nixpkgs and added to the crane derivation's `nativeBuildInputs`
//! in `flake.nix`). For dev builds outside Nix, install librsvg via
//! your system package manager (Debian/Ubuntu: `librsvg2-bin`,
//! macOS: `brew install librsvg`).
use std::env;
use std::path::PathBuf;
use std::process::Command;
const SVG_PATH: &str = "../branding/agent-configs.svg";
const PNG_NAME: &str = "agent-configs.png";
// 300×300 to match the existing branding/hyperhive.png, which the
// Forgejo avatar endpoint accepts without resizing on upload.
const PX: &str = "300";
fn main() {
// Re-run the build script when either the SVG itself or this
// script change. We deliberately don't watch every file in
// `branding/` — only the one PNG we generate.
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed={SVG_PATH}");
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR set by cargo"));
let png_path = out_dir.join(PNG_NAME);
let status = Command::new("rsvg-convert")
.args(["--width", PX, "--height", PX, "-o"])
.arg(&png_path)
.arg(SVG_PATH)
.status();
match status {
Ok(s) if s.success() => {}
Ok(s) => panic!("rsvg-convert exited with {s} rendering {SVG_PATH}"),
Err(e) => panic!(
"failed to invoke rsvg-convert: {e}\n\
install librsvg (Debian/Ubuntu: librsvg2-bin, macOS: brew install librsvg, \
NixOS: pkgs.librsvg). The Nix derivation already pulls it in via \
flake.nix craneLib.buildPackage.nativeBuildInputs.",
),
}
}

View file

@ -41,20 +41,14 @@ const CORE_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-core-avatar-set";
/// Sibling marker for the `agent-configs` org avatar (#424). Same one- /// Sibling marker for the `agent-configs` org avatar (#424). Same one-
/// shot semantics — delete to force the upload to re-run. /// shot semantics — delete to force the upload to re-run.
const CONFIG_ORG_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-agent-configs-avatar-set"; const CONFIG_ORG_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-agent-configs-avatar-set";
/// Hyperhive logo bytes, baked into the daemon. Uploaded once via the // Avatar PNGs are loaded at runtime from
/// admin avatar API so the `core` Forgejo user shows the project mark // `$HIVE_ASSETS_DIR/branding/{hyperhive,agent-configs}.png` via the
/// next to commits in `agent-configs/*`, `core/meta`, etc. instead of // helpers in `hive_sh4re::assets` (#555 — was `include_bytes!` of an
/// the default hash identicon. // in-source path and an OUT_DIR-rendered sibling, both of which
const CORE_AVATAR_PNG: &[u8] = include_bytes!("../../branding/hyperhive.png"); // invalidated the crane src cache on any branding edit). The
/// `agent-configs` org logo bytes (#424). Sibling visual to the main // `agent-configs.png` is rendered from its SVG during the
/// hyperhive mark — same dark base + outer ring + corner brackets, // `hyperhive-assets` derivation's build (was `hive-c0re/build.rs`
/// with a stacked-config-files glyph in the centre so the operator // + `rsvg-convert` on PATH; both gone now).
/// can distinguish the agent-configs namespace from the main
/// `hyperhive` org at a glance. Source-of-truth is
/// `branding/agent-configs.svg`; `hive-c0re/build.rs` renders it
/// into `$OUT_DIR/agent-configs.png` at compile time via
/// `rsvg-convert` so the raster never gets checked into git.
const CONFIG_ORG_AVATAR_PNG: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/agent-configs.png"));
/// Forgejo org grouping every agent's applied config repo. Core is a /// Forgejo org grouping every agent's applied config repo. Core is a
/// site admin and reads + writes every repo here; agents are NOT /// site admin and reads + writes every repo here; agents are NOT
/// members and the repos are private, so no agent — not even the one /// members and the repos are private, so no agent — not even the one
@ -298,9 +292,13 @@ async fn ensure_core_avatar(token: &str) -> Result<()> {
if marker.exists() { if marker.exists() {
return Ok(()); return Ok(());
} }
let png_path = hive_sh4re::assets::core_avatar_png();
let png_bytes = tokio::fs::read(&png_path)
.await
.with_context(|| format!("read core avatar PNG from {}", png_path.display()))?;
let body = format!( let body = format!(
r#"{{"image":"{}"}}"#, r#"{{"image":"{}"}}"#,
base64::engine::general_purpose::STANDARD.encode(CORE_AVATAR_PNG), base64::engine::general_purpose::STANDARD.encode(&png_bytes),
); );
let url = format!("{FORGE_HTTP}/api/v1/admin/users/core/avatar"); let url = format!("{FORGE_HTTP}/api/v1/admin/users/core/avatar");
let status = forge_http(reqwest::Method::POST, &url, token, &body).await?; let status = forge_http(reqwest::Method::POST, &url, token, &body).await?;
@ -325,9 +323,13 @@ async fn ensure_config_org_avatar(token: &str) -> Result<()> {
if marker.exists() { if marker.exists() {
return Ok(()); return Ok(());
} }
let png_path = hive_sh4re::assets::config_org_avatar_png();
let png_bytes = tokio::fs::read(&png_path)
.await
.with_context(|| format!("read {CONFIG_ORG} avatar PNG from {}", png_path.display()))?;
let body = format!( let body = format!(
r#"{{"image":"{}"}}"#, r#"{{"image":"{}"}}"#,
base64::engine::general_purpose::STANDARD.encode(CONFIG_ORG_AVATAR_PNG), base64::engine::general_purpose::STANDARD.encode(&png_bytes),
); );
let url = format!("{FORGE_HTTP}/api/v1/orgs/{CONFIG_ORG}/avatar"); let url = format!("{FORGE_HTTP}/api/v1/orgs/{CONFIG_ORG}/avatar");
let status = forge_http(reqwest::Method::POST, &url, token, &body).await?; let status = forge_http(reqwest::Method::POST, &url, token, &body).await?;

99
hive-sh4re/src/assets.rs Normal file
View file

@ -0,0 +1,99 @@
//! Resolve the on-disk path to hyperhive's static assets (branding +
//! claude prompts). Single source of truth for both the host daemon
//! (`hive-c0re`) and the in-container harness (`hive-ag3nt` /
//! `hive-m1nd`) so they agree on the lookup contract.
//!
//! At runtime, the path is read from `$HIVE_ASSETS_DIR`. In nix
//! builds that env var is set by the `hive-c0re` / `harness-base` modules
//! to `${pkgs.hyperhive-assets}/share/hyperhive` (see `nix/assets.nix`).
//! For `cargo run` outside nix, set it yourself:
//!
//! ```sh
//! HIVE_ASSETS_DIR=$(pwd)/dev-assets cargo run ...
//! ```
//!
//! where `dev-assets/` is laid out the same as the nix output:
//!
//! ```text
//! dev-assets/
//! branding/
//! hyperhive.{svg,png}
//! agent-configs.{svg,png}
//! prompts/
//! system.md
//! claude-settings.json
//! ```
//!
//! A convenience `cargo xtask seed-dev-assets` could materialise this
//! by copying `branding/` + `hive-ag3nt/prompts/` + rendering
//! `agent-configs.png` via rsvg-convert, but it's intentionally not
//! shipped yet — dev setups vary too much for one helper to fit.
//!
//! Each crate that wants a specific asset goes through one of the
//! typed helpers (`branding_svg()`, `prompt_template()`, …) so the
//! lookup contract is centralised. Missing files panic at first
//! call with a clear "set `HIVE_ASSETS_DIR` + put the file at …"
//! message — same failure shape as the old `include_*!` macros
//! (which would have failed at compile time, not runtime, but the
//! diagnostic value is identical).
use std::path::PathBuf;
/// Read `$HIVE_ASSETS_DIR`. Panics with a clear remediation message
/// when unset — every code path that calls into this module is one
/// the binary cannot run without, so a hard early failure is the
/// right shape.
fn dir() -> PathBuf {
match std::env::var("HIVE_ASSETS_DIR") {
Ok(v) if !v.is_empty() => PathBuf::from(v),
_ => panic!(
"HIVE_ASSETS_DIR is not set. Inside the nix-built systemd \
units this is wired automatically from \
`pkgs.hyperhive-assets`; for `cargo run` outside nix, \
set it to a directory laid out like \
`nix/assets.nix`'s output (branding/ + prompts/ \
subdirs). See hive-sh4re/src/assets.rs for the contract.",
),
}
}
/// `$HIVE_ASSETS_DIR/branding/hyperhive.svg` — the project's primary
/// mark. Loaded by the per-agent web UI as its default icon
/// (`/agents/<n>/icon.svg` falls through here when the agent didn't
/// override `hyperhive.icon` in its `agent.nix`).
#[must_use]
pub fn branding_svg() -> PathBuf {
dir().join("branding/hyperhive.svg")
}
/// `$HIVE_ASSETS_DIR/branding/hyperhive.png` — the same mark
/// rasterised, used by `hive-c0re::forge::push_avatar` to upload
/// the forge org avatar.
#[must_use]
pub fn core_avatar_png() -> PathBuf {
dir().join("branding/hyperhive.png")
}
/// `$HIVE_ASSETS_DIR/branding/agent-configs.png` — secondary org
/// mark for the `agent-configs/` mirror org. Rendered from
/// `agent-configs.svg` at asset-build time (was rendered in
/// `hive-c0re/build.rs` before #555).
#[must_use]
pub fn config_org_avatar_png() -> PathBuf {
dir().join("branding/agent-configs.png")
}
/// `$HIVE_ASSETS_DIR/prompts/system.md` — the claude system prompt
/// template. `hive-ag3nt::prompt::render` filters the role markers
/// inside it per agent / manager flavor.
#[must_use]
pub fn prompt_template() -> PathBuf {
dir().join("prompts/system.md")
}
/// `$HIVE_ASSETS_DIR/prompts/claude-settings.json` — the static
/// `--settings` JSON every claude invocation reads.
#[must_use]
pub fn claude_settings() -> PathBuf {
dir().join("prompts/claude-settings.json")
}

View file

@ -2,6 +2,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub mod assets;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Host admin socket — /run/hyperhive/host.sock // Host admin socket — /run/hyperhive/host.sock
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------

View file

@ -1,6 +1,7 @@
{ {
hyperhivePackage, hyperhivePackage,
hyperhiveFrontend, hyperhiveFrontend,
hyperhiveAssets,
hyperhiveFlake, hyperhiveFlake,
agentBaseToplevel, agentBaseToplevel,
managerToplevel, managerToplevel,
@ -66,6 +67,20 @@ in
endpoints) is the source of truth for any replacement. endpoints) is the source of truth for any replacement.
''; '';
}; };
assets = lib.mkOption {
type = lib.types.package;
default = hyperhiveAssets pkgs.stdenv.hostPlatform.system;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.assets";
description = ''
Bundled static runtime assets (see `./nix/assets.nix`): the
project's branding family + the claude system-prompt template +
claude-settings JSON. Output has `share/hyperhive/{branding,prompts}/`;
passed to hive-c0re's systemd unit via `HIVE_ASSETS_DIR`
(`hive_sh4re::assets::*` resolve paths underneath). Override to
ship customised branding or prompts without rebuilding the
rust derivation.
'';
};
hyperhiveFlake = lib.mkOption { hyperhiveFlake = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = hyperhiveFlake; default = hyperhiveFlake;
@ -196,6 +211,10 @@ in
# serves this via `tower_http::ServeDir` for any path it doesn't # serves this via `tower_http::ServeDir` for any path it doesn't
# match against an API/action route. # match against an API/action route.
HIVE_STATIC_DIR = "${cfg.frontend}/dashboard"; HIVE_STATIC_DIR = "${cfg.frontend}/dashboard";
# Path to the static runtime asset tree (branding + claude
# prompts). `hive_sh4re::assets::*` reads paths underneath.
# `forge.rs` reads the avatar PNGs from here on startup.
HIVE_ASSETS_DIR = "${cfg.assets}/share/hyperhive";
} }
// lib.optionalAttrs config.hyperhive.forge.enable { // lib.optionalAttrs config.hyperhive.forge.enable {
# Agents poll this URL for Forgejo notifications. Derived from # Agents poll this URL for Forgejo notifications. Derived from

View file

@ -531,10 +531,15 @@
# HIVE_DEFAULT_MODEL seeds the initial model selection when no persisted # HIVE_DEFAULT_MODEL seeds the initial model selection when no persisted
# model choice exists in the state dir. SHELL must be set so claude's # model choice exists in the state dir. SHELL must be set so claude's
# Bash tool finds a POSIX shell. # Bash tool finds a POSIX shell.
# HIVE_ASSETS_DIR points at the project's static runtime assets
# (branding + claude prompts; see `nix/assets.nix`). Set here so
# both the harness binary and any user-shell `cargo run` inside the
# container resolve them from the same path.
# HIVE_CONTEXT_WINDOW_TOKENS_* are injected by the meta flake from the # HIVE_CONTEXT_WINDOW_TOKENS_* are injected by the meta flake from the
# host-level `services.hive-c0re.contextWindowTokens` option — not set here. # host-level `services.hive-c0re.contextWindowTokens` option — not set here.
environment.variables = { environment.variables = {
HIVE_DEFAULT_MODEL = config.hyperhive.model; HIVE_DEFAULT_MODEL = config.hyperhive.model;
HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive";
SHELL = "${pkgs.bashInteractive}/bin/bash"; SHELL = "${pkgs.bashInteractive}/bin/bash";
} // lib.optionalAttrs (!config.hyperhive.autoCompact) { } // lib.optionalAttrs (!config.hyperhive.autoCompact) {
# Zero watermark disables proactive compaction; the reactive path # Zero watermark disables proactive compaction; the reactive path