Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
18 changed files with 130 additions and 450 deletions

View file

@ -38,14 +38,6 @@ Four env vars cover the identity surface, read by
federated hives at different DNS domains can share a swarm federated hives at different DNS domains can share a swarm
name. name.
Inside an agent container, the hive's name and domain don't come from the
agent's build. `hive-priv` writes them to `/etc/hyperhive-hive-facts` before
every container start. The agent's environment generator
(`nix/agent-modules/hive-facts.nix`) passes them to every unit, including
`hivectl choom` sessions, and `/etc/set-environment` exports them in login
shells that no unit starts (`nixos-container root-login`). Neither is part of
an agent's closure, and an agent started on another hive reports that hive.
`hive_name` + `swarm_name` are **distinct** from `hive_name` + `swarm_name` are **distinct** from
`HYPERHIVE_HIVE_DOMAIN`: the domain may carry the hive name as `HYPERHIVE_HIVE_DOMAIN`: the domain may carry the hive name as
its leftmost label by convention, but the convention isn't its leftmost label by convention, but the convention isn't

View file

@ -193,12 +193,9 @@ automatically:
| -------------- | ------------------------------------------------------------ | | -------------- | ------------------------------------------------------------ |
| `service.name` | `hyperhive-agent` (constant) | | `service.name` | `hyperhive-agent` (constant) |
| `agent` | agent logical name (for example `iris`) | | `agent` | agent logical name (for example `iris`) |
| `hive` | hive display name (`services.hyperhive.hiveName`) |
| `swarm` | swarm display name (`services.hyperhive.swarm.name`, if set) | | `swarm` | swarm display name (`services.hyperhive.swarm.name`, if set) |
The swarm's collector adds `hive`, the name of the hive whose receiver accepted
the push (see [Authenticated ingest](#authenticated-ingest)). The container
doesn't set it.
Append additional labels via `extraResourceAttributes` (see option Append additional labels via `extraResourceAttributes` (see option
reference above); pass custom per-data-point labels with reference above); pass custom per-data-point labels with
`hive-metric --labels` (see below). `hive-metric --labels` (see below).
@ -371,10 +368,10 @@ API-reported window, else the model's default. A turn that parsed no usage
leaves both where the previous turn put them. The dashboard's **Context leaves both where the previous turn put them. The dashboard's **Context
window used by agent** panel (health tab) divides one by the other. window used by agent** panel (health tab) divides one by the other.
Resource attributes (`service.name`, `agent`, `swarm`) come from the Resource attributes (`service.name`, `agent`, `hive`, `swarm`) come from the
same container-wide `OTEL_RESOURCE_ATTRIBUTES` as everything else in this same container-wide `OTEL_RESOURCE_ATTRIBUTES` as everything else in this
section, and the swarm's collector adds `hive` — nothing extra to configure. section — nothing extra to configure. Cadence follows
Cadence follows `HYPERHIVE_OTEL_METRIC_INTERVAL_MS` (default 60s, same variable + default as `HYPERHIVE_OTEL_METRIC_INTERVAL_MS` (default 60s, same variable + default as
`hive-c0re`'s container-resource export above) — that only controls how often `hive-c0re`'s container-resource export above) — that only controls how often
the harness flushes the batched points to the collector, not how often it the harness flushes the batched points to the collector, not how often it
records them (every turn, always). records them (every turn, always).
@ -482,7 +479,7 @@ hive-metric <name> <value> [--type counter|gauge] [--temporality delta|cumulativ
itself). itself).
- `--labels key=value` — extra per-data-point labels. Repeat the flag for - `--labels key=value` — extra per-data-point labels. Repeat the flag for
more than one. more than one.
`hive-metric` inherits the resource labels (agent, swarm, service.name) `hive-metric` inherits the resource labels (agent, hive, swarm, service.name)
automatically from `OTEL_RESOURCE_ATTRIBUTES` — don't re-specify them. automatically from `OTEL_RESOURCE_ATTRIBUTES` — don't re-specify them.
### Examples ### Examples

View file

@ -260,7 +260,7 @@ needs to `chown` a bind mount), once at startup:
substituted: `{label}` (short agent name), `{qualified_label}` substituted: `{label}` (short agent name), `{qualified_label}`
(hive-qualified `name@domain` form), `{operator_pronouns}`, (hive-qualified `name@domain` form), `{operator_pronouns}`,
`{hive_identity}` (for example `` on hive `pr1ma` ``; empty when `{hive_identity}` (for example `` on hive `pr1ma` ``; empty when
`HYPERHIVE_HIVE_NAME` is unset), and `{swarm_identity}` (same `services.hyperhive.agent.hiveName` is unset), and `{swarm_identity}` (same
shape for the swarm). Pronouns come from `HIVE_OPERATOR_PRONOUNS` shape for the swarm). Pronouns come from `HIVE_OPERATOR_PRONOUNS`
env (set by the meta flake from env (set by the meta flake from
`services.hyperhive.c0re.operatorPronouns`, default `she/her`). `services.hyperhive.c0re.operatorPronouns`, default `she/her`).

View file

@ -31,9 +31,9 @@ pub fn hive_domain() -> Option<String> {
/// Human display name of this hive (e.g. `pr1ma`). Distinct from /// Human display name of this hive (e.g. `pr1ma`). Distinct from
/// [`hive_domain`] — the domain is the machine-readable DNS address; /// [`hive_domain`] — the domain is the machine-readable DNS address;
/// this is the prose label humans use in conversation. Returns None /// this is the prose label humans use in conversation. Returns None
/// when `/etc/hyperhive-hive-facts` carries no name (the host's /// when the host-side `services.hyperhive.hiveName` option is unset,
/// `services.hyperhive.hiveName` is unset), in which case callers fall /// in which case callers fall back to the domain or the short label
/// back to the domain or the short label at their discretion. /// at their discretion.
#[must_use] #[must_use]
pub fn hive_name() -> Option<String> { pub fn hive_name() -> Option<String> {
non_empty_env("HYPERHIVE_HIVE_NAME") non_empty_env("HYPERHIVE_HIVE_NAME")

View file

@ -476,14 +476,7 @@ async fn set_nspawn_flags(
); );
// Delegate the actual conf-file rewrite to hive-priv (runs as root). // Delegate the actual conf-file rewrite to hive-priv (runs as root).
crate::priv_client::write_nspawn_flags( crate::priv_client::write_nspawn_flags(container, &binds, isolation, &load_creds).await
container,
&binds,
isolation,
&load_creds,
super::hive_facts_from_env(),
)
.await
} }
#[cfg(test)] #[cfg(test)]

View file

@ -177,17 +177,6 @@ pub fn network_isolation_from_env() -> Result<hive_priv_sock::NetworkIsolation>
network_isolation_from_vars(bridge.as_deref(), subnet.as_deref()) network_isolation_from_vars(bridge.as_deref(), subnet.as_deref())
} }
/// The hive's facts every container is handed at runtime, from hive-c0re's
/// own unit environment (`nix/host-modules/hive-c0re/environment.nix`). An
/// unset or empty variable is `None`.
fn hive_facts_from_env() -> hive_priv_sock::HiveFacts {
let read = |name: &str| std::env::var(name).ok().filter(|v| !v.is_empty());
hive_priv_sock::HiveFacts {
name: read("HYPERHIVE_HIVE_NAME"),
domain: read("HYPERHIVE_HIVE_DOMAIN"),
}
}
fn validate(name: &str) -> Result<()> { fn validate(name: &str) -> Result<()> {
if name.is_empty() { if name.is_empty() {
bail!("agent name must not be empty"); bail!("agent name must not be empty");

View file

@ -508,6 +508,8 @@ const FORWARDED_VARS: &[&str] = &[
"HIVE_FORGE_URL", "HIVE_FORGE_URL",
"HIVE_FORGE_PUBLIC_URL", "HIVE_FORGE_PUBLIC_URL",
"HIVE_MATRIX_URL", "HIVE_MATRIX_URL",
"HYPERHIVE_HIVE_DOMAIN",
"HYPERHIVE_HIVE_NAME",
"HYPERHIVE_SWARM_NAME", "HYPERHIVE_SWARM_NAME",
]; ];
@ -518,11 +520,14 @@ const FORWARDED_VARS: &[&str] = &[
/// var is read at runtime. Setting only one leaves the other on its default, /// var is read at runtime. Setting only one leaves the other on its default,
/// which is how a hive ends up with two disagreeing answers for one value. /// which is how a hive ends up with two disagreeing answers for one value.
/// ///
/// The swarm display name is here for that reason: `otel.nix` reads the option /// The hive/swarm display names are here for exactly that reason, learned the
/// at eval time for the `swarm=` resource label, and with the env var alone /// hard way: they were forwarded as runtime env only, while the agent's
/// that label evaluates to `unknown` while the process env holds the right /// claude-settings module read them from the container's `environment.variables`
/// name. Half-wiring this map is a value that evaluates fine and is silently /// at *eval* time — where they were never set. Every agent baked
/// wrong. /// `hive=unknown,swarm=unknown` into its OTEL resource attributes and shipped
/// that label on every metric, while the same process's env held the right
/// answer. Half-wiring this map is not a missing nicety; it is a value that
/// evaluates fine and is silently wrong.
const FORWARDED_VAR_OPTIONS: &[(&str, &str)] = &[ const FORWARDED_VAR_OPTIONS: &[(&str, &str)] = &[
// Read at build time to decide whether the harness unit imports the hive's // Read at build time to decide whether the harness unit imports the hive's
// queue credential at all — see `nix/agent-modules/queue.nix`. Both halves // queue credential at all — see `nix/agent-modules/queue.nix`. Both halves
@ -553,6 +558,7 @@ const FORWARDED_VAR_OPTIONS: &[(&str, &str)] = &[
), ),
("HIVE_FORGE_URL", "services.hyperhive.agent.forge.url"), ("HIVE_FORGE_URL", "services.hyperhive.agent.forge.url"),
("HIVE_MATRIX_URL", "services.hyperhive.agent.matrix.url"), ("HIVE_MATRIX_URL", "services.hyperhive.agent.matrix.url"),
("HYPERHIVE_HIVE_NAME", "services.hyperhive.agent.hiveName"),
("HYPERHIVE_SWARM_NAME", "services.hyperhive.agent.swarmName"), ("HYPERHIVE_SWARM_NAME", "services.hyperhive.agent.swarmName"),
]; ];
@ -1158,7 +1164,9 @@ where
// → in-container accessors fall back to None / defaults gracefully. // → in-container accessors fall back to None / defaults gracefully.
// //
// - HIVE_FORGE_URL: agents poll this for Forgejo notifications. // - HIVE_FORGE_URL: agents poll this for Forgejo notifications.
// - HYPERHIVE_SWARM_NAME: human display name for the swarm. // - HYPERHIVE_HIVE_DOMAIN: machine-readable hive DNS.
// - HYPERHIVE_HIVE_NAME / HYPERHIVE_SWARM_NAME: human display
// names for hive + swarm.
for (var, val) in forwarded_env_vars() { for (var, val) in forwarded_env_vars() {
let escaped = val.replace('\\', "\\\\").replace('"', "\\\""); let escaped = val.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(out, " {var} = \"{escaped}\";"); let _ = writeln!(out, " {var} = \"{escaped}\";");
@ -1762,53 +1770,6 @@ mod tests {
); );
} }
/// The hive's name and domain reach an agent at runtime
/// (`/etc/hyperhive-hive-facts`), so the rendered flake does not change with
/// either.
#[test]
fn render_flake_is_identical_across_hive_names_and_domains() {
const VARS: [&str; 2] = ["HYPERHIVE_HIVE_NAME", "HYPERHIVE_HIVE_DOMAIN"];
let _env = crate::test_env::lock();
let saved = VARS.map(std::env::var_os);
let render_on = |name: &str, domain: &str| {
// SAFETY: serialised by the guard above; restored before returning.
unsafe {
std::env::set_var(VARS[0], name);
std::env::set_var(VARS[1], domain);
}
render_flake(
"github:example/hyperhive",
"path:/nix/store/bbbb-hyperhive-docs-source",
"path:/nix/store/aaaa-nixpkgs-source",
None,
8000,
"she/her",
&std::collections::HashMap::new(),
"4G",
&[sample_spec("alice", false, 9001)],
)
.expect("render flake")
};
let alpha = render_on("alpha-hive", "alpha-hive.example.test");
let beta = render_on("beta-hive", "beta-hive.example.test");
for (var, value) in VARS.iter().zip(saved) {
// SAFETY: as above.
unsafe {
match value {
Some(v) => std::env::set_var(var, v),
None => std::env::remove_var(var),
}
}
}
for hive in ["alpha-hive", "beta-hive"] {
assert!(
!alpha.contains(hive) && !beta.contains(hive),
"the rendered flake must not name the hive ({hive}):\n{alpha}"
);
}
assert_eq!(alpha, beta, "the render must not depend on the hive");
}
#[test] #[test]
fn service_url_options_render_from_forwarded_pairs() { fn service_url_options_render_from_forwarded_pairs() {
// Pure over the pairs, deliberately: the render-level tests have to // Pure over the pairs, deliberately: the render-level tests have to
@ -1821,21 +1782,21 @@ mod tests {
&[ &[
("HIVE_FORGE_URL", "http://forge.example.test".to_string()), ("HIVE_FORGE_URL", "http://forge.example.test".to_string()),
("HIVE_MATRIX_URL", "http://matrix.example.test".to_string()), ("HIVE_MATRIX_URL", "http://matrix.example.test".to_string()),
// Mapped too: `otel.nix` reads the option at eval time. // Mapped too, and that is the fix this test now guards: the
("HYPERHIVE_SWARM_NAME", "constellat1on".to_string()), // display names used to be forwarded as runtime env *only*,
// so the build-time reader fell back to "unknown" and every
// agent shipped that label on every metric.
("HYPERHIVE_HIVE_NAME", "pr1ma".to_string()),
// Forwarded but genuinely unmapped — proves the map is a // Forwarded but genuinely unmapped — proves the map is a
// filter, not a pass-through. // filter, not a pass-through.
( ("HYPERHIVE_HIVE_DOMAIN", "pr1ma.darkest.space".to_string()),
"HIVE_FORGE_PUBLIC_URL",
"https://forge.example.test".to_string(),
),
], ],
); );
assert_eq!( assert_eq!(
out, out,
" services.hyperhive.agent.forge.url = \"http://forge.example.test\";\n\ " services.hyperhive.agent.forge.url = \"http://forge.example.test\";\n\
\x20 services.hyperhive.agent.matrix.url = \"http://matrix.example.test\";\n\ \x20 services.hyperhive.agent.matrix.url = \"http://matrix.example.test\";\n\
\x20 services.hyperhive.agent.swarmName = \"constellat1on\";\n", \x20 services.hyperhive.agent.hiveName = \"pr1ma\";\n",
"expected exactly the mapped options, in input order, indented for the module block" "expected exactly the mapped options, in input order, indented for the module block"
); );
} }
@ -1875,7 +1836,7 @@ mod tests {
fn require_service_urls_accepts_a_rendered_forge_url() { fn require_service_urls_accepts_a_rendered_forge_url() {
require_service_urls(&[ require_service_urls(&[
("HIVE_FORGE_URL", "http://forge.example.test".to_string()), ("HIVE_FORGE_URL", "http://forge.example.test".to_string()),
("HYPERHIVE_SWARM_NAME", "constellat1on".to_string()), ("HYPERHIVE_HIVE_NAME", "pr1ma".to_string()),
]) ])
.expect("a forwarded forge URL satisfies the requirement"); .expect("a forwarded forge URL satisfies the requirement");
} }
@ -1886,7 +1847,7 @@ mod tests {
// every agent would simply come up with no forge login and no way to // every agent would simply come up with no forge login and no way to
// tell that was unintended. The hive asserts its own requirement // tell that was unintended. The hive asserts its own requirement
// because it is the only layer that knows a forge is mandatory. // because it is the only layer that knows a forge is mandatory.
let err = require_service_urls(&[("HYPERHIVE_SWARM_NAME", "constellat1on".to_string())]) let err = require_service_urls(&[("HYPERHIVE_HIVE_NAME", "pr1ma".to_string())])
.expect_err("a missing forge URL must stop the write"); .expect_err("a missing forge URL must stop the write");
assert!( assert!(
err.to_string().contains("HIVE_FORGE_URL is unset"), err.to_string().contains("HIVE_FORGE_URL is unset"),
@ -1901,16 +1862,13 @@ mod tests {
// so absence is an absent integration rather than a misdirected one. // so absence is an absent integration rather than a misdirected one.
// Required services don't reach here: `require_service_urls` rejects // Required services don't reach here: `require_service_urls` rejects
// them first. // them first.
// `HIVE_FORGE_PUBLIC_URL` is forwarded as env but carries no option — // `HYPERHIVE_HIVE_DOMAIN` is forwarded as env but carries no option —
// deliberately picked over a mapped var, since the whole point is a // deliberately picked over a mapped var, since the whole point is a
// forwarded var the option map does not know about. // forwarded var the option map does not know about.
let mut out = String::new(); let mut out = String::new();
push_forwarded_var_options( push_forwarded_var_options(
&mut out, &mut out,
&[( &[("HYPERHIVE_HIVE_DOMAIN", "pr1ma.darkest.space".to_string())],
"HIVE_FORGE_PUBLIC_URL",
"https://forge.example.test".to_string(),
)],
); );
assert!( assert!(
out.is_empty(), out.is_empty(),

View file

@ -8,7 +8,7 @@
use anyhow::{Context as _, Result, bail}; use anyhow::{Context as _, Result, bail};
use hive_priv_sock::{ use hive_priv_sock::{
BindMount, CredentialMount, HiveFacts, InfraAction, InfraContainer, MEASURE_AGENT_DISK_TIMEOUT, BindMount, CredentialMount, InfraAction, InfraContainer, MEASURE_AGENT_DISK_TIMEOUT,
NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream, NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream,
}; };
use std::os::fd::{AsRawFd as _, OwnedFd, RawFd}; use std::os::fd::{AsRawFd as _, OwnedFd, RawFd};
@ -288,14 +288,12 @@ pub async fn write_nspawn_flags(
binds: &[BindMount], binds: &[BindMount],
isolation: NetworkIsolation, isolation: NetworkIsolation,
load_credentials: &[CredentialMount], load_credentials: &[CredentialMount],
hive_facts: HiveFacts,
) -> Result<()> { ) -> Result<()> {
ok(call(&PrivRequest::WriteNspawnFlags { ok(call(&PrivRequest::WriteNspawnFlags {
container: container.to_owned(), container: container.to_owned(),
binds: binds.to_vec(), binds: binds.to_vec(),
isolation, isolation,
load_credentials: load_credentials.to_vec(), load_credentials: load_credentials.to_vec(),
hive_facts,
}) })
.await?) .await?)
} }

View file

@ -232,18 +232,6 @@ pub struct NetworkIsolation {
pub gateway_ip: String, pub gateway_ip: String,
} }
/// Facts about the hive a container runs on, for `WriteNspawnFlags`.
/// hive-priv writes them into the container's `/etc/hyperhive-hive-facts`,
/// which the agent loads into its environment at boot, so no agent build
/// carries them. A `None` field leaves that variable unset in the agent.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HiveFacts {
/// `HYPERHIVE_HIVE_NAME`: the hive's name (`pr1ma`).
pub name: Option<String>,
/// `HYPERHIVE_HIVE_DOMAIN`: the hive's DNS domain.
pub domain: Option<String>,
}
/// Wall-clock bound hive-priv puts on one [`PrivRequest::MeasureAgentDisk`] /// Wall-clock bound hive-priv puts on one [`PrivRequest::MeasureAgentDisk`]
/// walk. A caller's own wait has to be longer than this, so a slow walk /// walk. A caller's own wait has to be longer than this, so a slow walk
/// reaches it as hive-priv's timeout error rather than as its own. /// reaches it as hive-priv's timeout error rather than as its own.
@ -318,10 +306,6 @@ pub enum PrivRequest {
/// so a hive-priv built before this field deserialises new requests. /// so a hive-priv built before this field deserialises new requests.
#[serde(default)] #[serde(default)]
load_credentials: Vec<CredentialMount>, load_credentials: Vec<CredentialMount>,
/// Written to the container's `/etc/hyperhive-hive-facts` on every
/// call, so a fact the hive no longer has is removed, not kept.
#[serde(default)]
hive_facts: HiveFacts,
}, },
/// Create the agent's socket dir `/run/hive-agent/<name>` as `0751 /// Create the agent's socket dir `/run/hive-agent/<name>` as `0751

View file

@ -22,10 +22,10 @@ use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, anyhow, bail}; use anyhow::{Context as _, Result, anyhow, bail};
use hive_priv_sock::{ use hive_priv_sock::{
AGENT_PREFIX, AGENT_RUNTIME_ROOT, AGENT_STATE_ROOT, BindMount, CredentialMount, HiveFacts, AGENT_PREFIX, AGENT_RUNTIME_ROOT, AGENT_STATE_ROOT, BindMount, CredentialMount, InfraAction,
InfraAction, InfraContainer, MEASURE_AGENT_DISK_TIMEOUT, META_DIR, MIGRATE_STAGING_ROOT, InfraContainer, MEASURE_AGENT_DISK_TIMEOUT, META_DIR, MIGRATE_STAGING_ROOT, NetworkIsolation,
NetworkIsolation, PAUSED_MARKER_FILE, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PAUSED_MARKER_FILE, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream,
PrivStream, PrivStreamLine, SIBLING_CONTAINERS, PrivStreamLine, SIBLING_CONTAINERS,
}; };
use tokio::io::{AsyncWriteExt, BufReader}; use tokio::io::{AsyncWriteExt, BufReader};
use tokio::net::unix::OwnedWriteHalf; use tokio::net::unix::OwnedWriteHalf;
@ -377,8 +377,7 @@ async fn exec(
ref binds, ref binds,
ref isolation, ref isolation,
ref load_credentials, ref load_credentials,
ref hive_facts, } => handle_write_nspawn_flags(container, binds, isolation, load_credentials),
} => handle_write_nspawn_flags(container, binds, isolation, load_credentials, hive_facts),
PrivRequest::EnsureAgentSocketDir { ref name } => ensure_agent_socket_dir(name), PrivRequest::EnsureAgentSocketDir { ref name } => ensure_agent_socket_dir(name),
@ -985,7 +984,6 @@ fn handle_write_nspawn_flags(
binds: &[BindMount], binds: &[BindMount],
isolation: &NetworkIsolation, isolation: &NetworkIsolation,
load_credentials: &[CredentialMount], load_credentials: &[CredentialMount],
hive_facts: &HiveFacts,
) -> Result<(String, String)> { ) -> Result<(String, String)> {
validate_container_system_name(container)?; validate_container_system_name(container)?;
for bind in binds { for bind in binds {
@ -999,8 +997,7 @@ fn handle_write_nspawn_flags(
// uses `:` as the name/path separator. // uses `:` as the name/path separator.
validate_bind_path(&cred.host_path)?; validate_bind_path(&cred.host_path)?;
} }
let hive_facts = render_hive_facts(hive_facts)?; write_nspawn_flags(container, binds, isolation, load_credentials)?;
write_nspawn_flags(container, binds, isolation, load_credentials, &hive_facts)?;
Ok((String::new(), String::new())) Ok((String::new(), String::new()))
} }
@ -2873,7 +2870,6 @@ fn write_nspawn_flags(
binds: &[BindMount], binds: &[BindMount],
isolation: &NetworkIsolation, isolation: &NetworkIsolation,
load_credentials: &[CredentialMount], load_credentials: &[CredentialMount],
hive_facts: &str,
) -> Result<()> { ) -> Result<()> {
use std::fmt::Write as _; use std::fmt::Write as _;
use std::os::unix::fs::MetadataExt as _; use std::os::unix::fs::MetadataExt as _;
@ -2954,78 +2950,36 @@ fn write_nspawn_flags(
// and which unit does it: `docs/networking/network.md` § *How the isolated // and which unit does it: `docs/networking/network.md` § *How the isolated
// container gets its resolver*. // container gets its resolver*.
write_bridge_dns_marker(container, isolation)?; write_bridge_dns_marker(container, isolation)?;
// Read by the agent's environment generator at boot
// (`nix/agent-modules/hive-facts.nix`).
write_container_etc_file_in(&container_rootfs(container), HIVE_FACTS_FILE, hive_facts)?;
Ok(()) Ok(())
} }
/// Filename of the in-container DNS marker, in the container's own `/etc`. /// Filename of the in-container DNS marker, in the container's own `/etc`.
const BRIDGE_DNS_MARKER: &str = "hyperhive-bridge-dns"; const BRIDGE_DNS_MARKER: &str = "hyperhive-bridge-dns";
/// Filename of the hive-facts environment file, in the container's own `/etc`.
const HIVE_FACTS_FILE: &str = "hyperhive-hive-facts";
/// The container's `/` on the host.
fn container_rootfs(container: &str) -> PathBuf {
PathBuf::from(format!("/var/lib/nixos-containers/{container}"))
}
/// Render [`HiveFacts`] as the systemd environment file the agent loads,
/// one `KEY=value` line per present fact.
///
/// A value is refused unless it is DNS-name characters only, so it can carry
/// neither a newline (a second assignment) nor anything an environment file
/// or a shell would interpret.
fn render_hive_facts(facts: &HiveFacts) -> Result<String> {
use std::fmt::Write as _;
let mut out = String::new();
for (key, value) in [
("HYPERHIVE_HIVE_NAME", &facts.name),
("HYPERHIVE_HIVE_DOMAIN", &facts.domain),
] {
let Some(value) = value else { continue };
if value.is_empty()
|| value.len() > 253
|| !value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'))
{
bail!("invalid {key} value {value:?}: must be 1-253 chars of [A-Za-z0-9._-]");
}
let _ = writeln!(out, "{key}={value}");
}
Ok(out)
}
/// Write the bridge-DNS marker the `hyperhive-isolated-dns` oneshot keys /// Write the bridge-DNS marker the `hyperhive-isolated-dns` oneshot keys
/// off. The marker file contains just the gateway IP. Always written: /// off. The marker file contains just the gateway IP. Always written:
/// every container is isolated, so there is no host-netns case that /// every container is isolated, so there is no host-netns case that
/// wants the marker absent. /// wants the marker absent.
fn write_bridge_dns_marker(container: &str, isolation: &NetworkIsolation) -> Result<()> { fn write_bridge_dns_marker(container: &str, isolation: &NetworkIsolation) -> Result<()> {
write_bridge_dns_marker_in(&container_rootfs(container), &isolation.gateway_ip) let rootfs = PathBuf::from(format!("/var/lib/nixos-containers/{container}"));
write_bridge_dns_marker_in(&rootfs, &isolation.gateway_ip)
} }
/// Write `<rootfs>/etc/hyperhive-bridge-dns`; see [`write_container_etc_file_in`]. /// Write `<rootfs>/etc/hyperhive-bridge-dns` without following a symlink.
fn write_bridge_dns_marker_in(rootfs: &Path, gateway_ip: &str) -> Result<()> {
write_container_etc_file_in(rootfs, BRIDGE_DNS_MARKER, &format!("{gateway_ip}\n"))
}
/// Write `<rootfs>/etc/<name>` without following a symlink.
/// ///
/// Root inside the container owns its `/etc`, and this write runs as host /// Root inside the container owns its `/etc`, and this write runs as host
/// root, where an absolute symlink planted in the container resolves against /// root, where an absolute symlink planted in the container resolves against
/// the host's `/`. So this opens `etc` with `O_DIRECTORY|O_NOFOLLOW` (a /// the host's `/`. So this opens `etc` with `O_DIRECTORY|O_NOFOLLOW` (a
/// symlinked `etc` fails with `ELOOP`) and publishes the file through a /// symlinked `etc` fails with `ELOOP`) and publishes the marker through a
/// [`StagedFile`] relative to that fd: whatever the container planted at the /// [`StagedFile`] relative to that fd: whatever the container planted at the
/// leaf, symlink or FIFO or device node, is replaced by the rename, never /// leaf, symlink or FIFO or device node, is replaced by the rename, never
/// written through or opened. /// written through or opened.
/// ///
/// On a fresh install the container's `/etc` may not exist yet (nixos-container /// On a fresh install the container's `/etc` may not exist yet (nixos-container
/// materialises the rootfs on the first start), so this creates it first; the /// materialises the rootfs on the first start), so this creates it first; the
/// dir and the file persist through that start. Mode 0644: nothing written /// dir and the marker persist through that start. Mode 0644: the gateway IP
/// here is a secret. /// is not a secret.
fn write_container_etc_file_in(rootfs: &Path, name: &str, contents: &str) -> Result<()> { fn write_bridge_dns_marker_in(rootfs: &Path, gateway_ip: &str) -> Result<()> {
use std::os::unix::fs::OpenOptionsExt as _; use std::os::unix::fs::OpenOptionsExt as _;
let etc_path = rootfs.join("etc"); let etc_path = rootfs.join("etc");
@ -3060,7 +3014,14 @@ fn write_container_etc_file_in(rootfs: &Path, name: &str, contents: &str) -> Res
} }
// SAFETY: `etc_fd` is a fresh, valid fd that nothing else owns. // SAFETY: `etc_fd` is a fresh, valid fd that nothing else owns.
let etc = std::fs::File::from(unsafe { OwnedFd::from_raw_fd(etc_fd) }); let etc = std::fs::File::from(unsafe { OwnedFd::from_raw_fd(etc_fd) });
StagedFile::write(&etc, &etc_path, name, contents.as_bytes(), 0o644)?.publish() StagedFile::write(
&etc,
&etc_path,
BRIDGE_DNS_MARKER,
format!("{gateway_ip}\n").as_bytes(),
0o644,
)?
.publish()
} }
/// Written by older hive-priv builds. Nothing writes it now, but left on disk /// Written by older hive-priv builds. Nothing writes it now, but left on disk
@ -3163,14 +3124,13 @@ fn ensure_socket_dir_in(root: &Path, name: &str) -> Result<()> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
BindMount, BoundedRun, Command, HiveFacts, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, BindMount, BoundedRun, Command, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, StagedFile,
StagedFile, agent_disk_paths, check_fd_agreement, contains_secret_shaped_run, agent_disk_paths, check_fd_agreement, contains_secret_shaped_run, describe_forge_admin,
describe_forge_admin, du_command, du_total, ensure_plain_filename, ensure_socket_dir_in, du_command, du_total, ensure_plain_filename, ensure_socket_dir_in, exec_measure_agent_disk,
exec_measure_agent_disk, git_overlay_flags, limits_dropin_body, open_dir, open_export_dest, git_overlay_flags, limits_dropin_body, open_dir, open_export_dest, parse_du_total,
parse_du_total, partial_name, publish_file, redact_secret_line, remove_marker_in, partial_name, publish_file, redact_secret_line, remove_marker_in, run_bounded,
render_hive_facts, run_bounded, single_output_path, toplevel_attr, single_output_path, toplevel_attr, validate_credential_name, validate_snapshot_name,
validate_credential_name, validate_snapshot_name, write_agent_dir_file, write_agent_dir_file, write_bridge_dns_marker_in,
write_bridge_dns_marker_in,
}; };
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
@ -3822,45 +3782,6 @@ mod tests {
std::fs::remove_dir_all(&dir).ok(); std::fs::remove_dir_all(&dir).ok();
} }
/// Each present fact is one environment-file line; an absent one writes
/// nothing, so the agent sees the variable unset rather than empty.
#[test]
fn hive_facts_render_one_line_per_present_fact() {
let both = HiveFacts {
name: Some("pr1ma".to_owned()),
domain: Some("pr1ma.darkest.space".to_owned()),
};
assert_eq!(
render_hive_facts(&both).unwrap(),
"HYPERHIVE_HIVE_NAME=pr1ma\nHYPERHIVE_HIVE_DOMAIN=pr1ma.darkest.space\n"
);
let domain_only = HiveFacts {
name: None,
domain: Some("h1.example.test".to_owned()),
};
assert_eq!(
render_hive_facts(&domain_only).unwrap(),
"HYPERHIVE_HIVE_DOMAIN=h1.example.test\n"
);
assert_eq!(render_hive_facts(&HiveFacts::default()).unwrap(), "");
}
/// A value that could end its line or be read as syntax is refused, not
/// escaped: a newline would add an assignment of the caller's choosing.
#[test]
fn hive_facts_refuse_values_outside_dns_characters() {
for bad in ["", "pr1ma\nLD_PRELOAD=/x", "a b", "a\"b", "$(x)"] {
let facts = HiveFacts {
name: Some(bad.to_owned()),
domain: None,
};
assert!(
render_hive_facts(&facts).is_err(),
"{bad:?} must be refused"
);
}
}
/// Whether `pid` is gone, or only a zombie awaiting its reaper: either /// Whether `pid` is gone, or only a zombie awaiting its reaper: either
/// way it is no longer running. /// way it is no longer running.
fn is_dead(pid: &str) -> bool { fn is_dead(pid: &str) -> bool {

View file

@ -19,6 +19,17 @@ let
# meta.rs `otel_config`). Options declared in `otel.nix`, which also # meta.rs `otel_config`). Options declared in `otel.nix`, which also
# exports the generic OTLP environment this container's producers read. # exports the generic OTLP environment this container's producers read.
otelCfg = config.services.hyperhive.agent.otel; otelCfg = config.services.hyperhive.agent.otel;
# Hive display name, read from the per-agent option meta.rs renders
# (NOT from `environment.variables` — that carries the same name at
# *runtime* only, so reading it here silently yielded "unknown" on
# every agent while the process env held the right answer). `null`
# means the hive did not name itself; "unknown" is then an honest label
# rather than a guess.
hiveDisplayName =
if config.services.hyperhive.agent.hiveName == null then
"unknown"
else
config.services.hyperhive.agent.hiveName;
# Effective per-agent MemoryMax=, in bytes, injected by meta.rs's # Effective per-agent MemoryMax=, in bytes, injected by meta.rs's
# per-agent flake render (`services.hyperhive.agent.claudeMemoryMaxBytes`). `null` # per-agent flake render (`services.hyperhive.agent.claudeMemoryMaxBytes`). `null`
# when the effective cap is unbounded ("infinity") or a RAM # when the effective cap is unbounded ("infinity") or a RAM
@ -47,6 +58,8 @@ let
CLAUDE_CODE_RESUME_INTERRUPTED_TURN = "1"; CLAUDE_CODE_RESUME_INTERRUPTED_TURN = "1";
# Use the simpler system prompt variant suited to headless operation. # Use the simpler system prompt variant suited to headless operation.
CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT = "1"; CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT = "1";
# Tag remote-control sessions with "<hive>-<agent>" for identification.
CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX = "${hiveDisplayName}-${userName}";
} }
# Bun/JavaScriptCore (the runtime under claude-code's `.claude-wrapped` # Bun/JavaScriptCore (the runtime under claude-code's `.claude-wrapped`
# binary) sizes its default heap ceiling off the container's visible # binary) sizes its default heap ceiling off the container's visible
@ -84,21 +97,6 @@ let
}; };
in in
{ {
options.services.hyperhive.agent.swarmName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
internal = true;
description = ''
Human-readable swarm name, rendered per-agent by
`meta.rs::render_flake` from the host's
`services.hyperhive.swarm.name`. Read during evaluation for the
OTEL `swarm` resource label; `HYPERHIVE_SWARM_NAME` carries the
same value at runtime.
`null` means the hive is not part of a named swarm.
'';
};
# Build-time implementation surface for the JSC-heap-ceiling fix: # Build-time implementation surface for the JSC-heap-ceiling fix:
# meta.rs's per-agent flake render injects this from the effective # meta.rs's per-agent flake render injects this from the effective
# `MemoryMax=` (per-agent `resource-limits.json` override, else the # `MemoryMax=` (per-agent `resource-limits.json` override, else the
@ -110,6 +108,37 @@ in
# the agent's *last rebuild* — `set-limits` still applies the # the agent's *last rebuild* — `set-limits` still applies the
# cgroup cap live via a drop-in reload, but this derived heap ceiling # cgroup cap live via a drop-in reload, but this derived heap ceiling
# needs a rebuild to pick up a new value. # needs a rebuild to pick up a new value.
options.services.hyperhive.agent.hiveName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
internal = true;
description = ''
Human-readable hive name, rendered per-agent by
`meta.rs::render_flake` from the host's
`services.hyperhive.hiveName`. Baked into the OTEL resource
attributes at build time, which is why it is an option and not
just the `HYPERHIVE_HIVE_NAME` env var: the env var is read at
runtime, this is read during evaluation, and wiring only one of
the two is how every agent ended up reporting `hive=unknown`.
`null` means the hive did not name itself.
'';
};
options.services.hyperhive.agent.swarmName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
internal = true;
description = ''
Human-readable swarm name, rendered per-agent by
`meta.rs::render_flake` from the host's
`services.hyperhive.swarm.name`. Same build-time/runtime split as
`services.hyperhive.agent.hiveName`.
`null` means the hive is not part of a named swarm.
'';
};
options.services.hyperhive.agent.claudeMemoryMaxBytes = lib.mkOption { options.services.hyperhive.agent.claudeMemoryMaxBytes = lib.mkOption {
type = lib.types.nullOr lib.types.ints.positive; type = lib.types.nullOr lib.types.ints.positive;
default = null; default = null;
@ -250,7 +279,7 @@ in
# Hive-enforced settings merged with a per-agent `env` block at BUILD # Hive-enforced settings merged with a per-agent `env` block at BUILD
# time via `jq` (not eval-time `readFile`, which would be import-from- # time via `jq` (not eval-time `readFile`, which would be import-from-
# derivation). The `env` block is always present: `baseClaudeEnv` sets # derivation). The `env` block is always present: `baseClaudeEnv` sets
# behaviour flags for every agent; # behaviour flags and the remote-control session prefix for every agent;
# `otelSettingsEnv` is merged on top when OTEL is enabled. claude-code # `otelSettingsEnv` is merged on top when OTEL is enabled. claude-code
# auto-discovers this file in every context (harness turn-loop AND # auto-discovers this file in every context (harness turn-loop AND
# `hivectl choom`) so no launch wrapper is needed. # `hivectl choom`) so no launch wrapper is needed.
@ -258,8 +287,9 @@ in
let let
baseSettings = "${config.services.hyperhive.agent.packages.assets}/share/hyperhive/prompts/claude-settings.json"; baseSettings = "${config.services.hyperhive.agent.packages.assets}/share/hyperhive/prompts/claude-settings.json";
# Merge base env (always) with OTEL env (when enabled). jq is always # Merge base env (always) with OTEL env (when enabled). jq is always
# run — `baseClaudeEnv` can carry per-agent values (e.g. # run — `baseClaudeEnv` contains per-agent values (e.g.
# BUN_JSC_forceRAMSize) that can't live in the static store asset. # CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX) that can't live in the
# static store asset.
allEnv = allEnv =
baseClaudeEnv baseClaudeEnv
// lib.optionalAttrs otelCfg.enable otelSettingsEnv // lib.optionalAttrs otelCfg.enable otelSettingsEnv

View file

@ -36,11 +36,6 @@ let
See docs/tools/matrix.md. See docs/tools/matrix.md.
''; '';
hiveNameRemoved = ''
An agent reads its hive's name at runtime from /etc/hyperhive-hive-facts,
which the hive writes into every container, so no agent build sets it.
Remove the setting.
'';
in in
{ {
imports = [ imports = [
@ -56,7 +51,6 @@ in
./frontend.nix ./frontend.nix
./github.nix ./github.nix
./github-token.nix ./github-token.nix
./hive-facts.nix
./logs.nix ./logs.nix
./matrix.nix ./matrix.nix
./mcp.nix ./mcp.nix
@ -91,9 +85,6 @@ in
# breaks evaluation for everyone, removed or not. # breaks evaluation for everyone, removed or not.
(lib.mkRemovedOptionModule [ "hyperhive" "matrix" "enable" ] matrixEnableRemoved) (lib.mkRemovedOptionModule [ "hyperhive" "matrix" "enable" ] matrixEnableRemoved)
(lib.mkRemovedOptionModule [ "services" "hyperhive" "agent" "matrix" "enable" ] matrixEnableRemoved) (lib.mkRemovedOptionModule [ "services" "hyperhive" "agent" "matrix" "enable" ] matrixEnableRemoved)
# Both spellings, for the reason given above.
(lib.mkRemovedOptionModule [ "hyperhive" "hiveName" ] hiveNameRemoved)
(lib.mkRemovedOptionModule [ "services" "hyperhive" "agent" "hiveName" ] hiveNameRemoved)
]; ];
options.services.hyperhive.agent.icon = lib.mkOption { options.services.hyperhive.agent.icon = lib.mkOption {

View file

@ -1,62 +0,0 @@
# The hive this container runs on, read at boot rather than built in, so an
# agent's closure does not carry it.
#
# hive-priv writes `/etc/hyperhive-hive-facts` into the container before every
# start (`HYPERHIVE_HIVE_NAME`, `HYPERHIVE_HIVE_DOMAIN`, one `KEY=value` line
# each). Two readers hand those lines on:
#
# - an environment generator, for the container's systemd: it passes them to
# every unit it starts, `machinectl shell` sessions (`hivectl choom`)
# included, since those are transient units of the same manager. A unit's
# own `Environment=` still overrides them.
# - `/etc/set-environment`, for login shells that no unit starts:
# `nixos-container root-login` / `run` (nsenter + `su -l`, which resets the
# environment) and getty logins.
#
# Tested by `checks.script-test-agent-hive-facts`.
{
config,
lib,
pkgs,
...
}:
let
userName = config.services.hyperhive.agent.user.name;
# Shell builtins only: nothing is assumed on PATH this early. Only the two
# known keys are passed on. `|| [ -n "$key" ]` keeps a last line that has
# no trailing newline.
generator = pkgs.writeShellScript "hyperhive-hive-facts" ''
hive=unknown
if [ -r /etc/hyperhive-hive-facts ]; then
while IFS='=' read -r key value || [ -n "$key" ]; do
case "$key" in
HYPERHIVE_HIVE_NAME) hive=$value ;;
HYPERHIVE_HIVE_DOMAIN) ;;
*) continue ;;
esac
printf '%s=%s\n' "$key" "$value"
done < /etc/hyperhive-hive-facts
fi
# Names claude's remote-control sessions `<hive>-<agent>`.
printf 'CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX=%s-%s\n' "$hive" ${lib.escapeShellArg userName}
'';
# Sourced by every shell's `/etc/set-environment`, so POSIX sh only. Each
# line is exported as one word and never evaluated.
profile = pkgs.writeText "hyperhive-hive-facts.sh" ''
while IFS= read -r __hyperhive_fact; do
case "$__hyperhive_fact" in
?*=*) export "$__hyperhive_fact" ;;
esac
done <<__HYPERHIVE_FACTS__
$(${generator})
__HYPERHIVE_FACTS__
unset __hyperhive_fact
'';
in
{
environment.etc."systemd/system-environment-generators/50-hyperhive-hive-facts".source = generator;
environment.etc."hyperhive/hive-facts.sh".source = profile;
environment.extraInit = ". /etc/hyperhive/hive-facts.sh";
}

View file

@ -25,10 +25,17 @@
let let
cfg = config.services.hyperhive.agent.otel; cfg = config.services.hyperhive.agent.otel;
userName = config.services.hyperhive.agent.user.name; userName = config.services.hyperhive.agent.user.name;
# Swarm display name, read from the per-agent option meta.rs renders # Hive/swarm display names, read from the per-agent options meta.rs
# (NOT from `environment.variables`, which carries it at *runtime* # renders (NOT from `environment.variables` — those carry the same
# only). `null` means the swarm is unnamed; "unknown" is then an honest # names at *runtime* only, so reading them here silently yielded
# label rather than a guess. # "unknown" on every agent while the process env held the right
# answer). `null` means the hive did not name itself; "unknown" is then
# an honest label rather than a guess.
hiveDisplayName =
if config.services.hyperhive.agent.hiveName == null then
"unknown"
else
config.services.hyperhive.agent.hiveName;
swarmDisplayName = swarmDisplayName =
if config.services.hyperhive.agent.swarmName == null then if config.services.hyperhive.agent.swarmName == null then
"unknown" "unknown"
@ -38,11 +45,9 @@ let
# Resource labels every producer in this container stamps on what it # Resource labels every producer in this container stamps on what it
# emits. `service.name` names the container's role, not one binary # emits. `service.name` names the container's role, not one binary
# inside it: claude's samples and `hive-metric`'s both come from this # inside it: claude's samples and `hive-metric`'s both come from this
# agent, and their metric names already tell them apart. No `hive`: the # agent, and their metric names already tell them apart.
# swarm collector stamps it from the receiver that accepted the hive's
# push (nix/host-modules/swarm-otel.nix), so the build names no hive.
resourceAttributes = resourceAttributes =
"service.name=hyperhive-agent,agent=${userName},swarm=${swarmDisplayName}" "service.name=hyperhive-agent,agent=${userName},hive=${hiveDisplayName},swarm=${swarmDisplayName}"
+ lib.optionalString (cfg.extraResourceAttributes != "") ",${cfg.extraResourceAttributes}"; + lib.optionalString (cfg.extraResourceAttributes != "") ",${cfg.extraResourceAttributes}";
# The variables an OTEL SDK reads on its own, in any language, in any # The variables an OTEL SDK reads on its own, in any language, in any
@ -126,7 +131,7 @@ in
description = '' description = ''
Extra comma-separated entries appended to Extra comma-separated entries appended to
`OTEL_RESOURCE_ATTRIBUTES` after the built-in `OTEL_RESOURCE_ATTRIBUTES` after the built-in
`service.name` / `agent` / `swarm` labels. `service.name` / `agent` / `hive` / `swarm` labels.
Host-driven via `services.hyperhive.otel.extraResourceAttributes`. Host-driven via `services.hyperhive.otel.extraResourceAttributes`.
''; '';
}; };
@ -269,9 +274,10 @@ in
# Identity the hop above cannot supply. A host-side reader can say # Identity the hop above cannot supply. A host-side reader can say
# which machine a line came from; only a collector running as the # which machine a line came from; only a collector running as the
# agent can say which agent. `hive` and `swarm` are deliberately # agent can say which agent. `hive` and `swarm` are deliberately
# NOT stamped here: the swarm tier upserts both from the receiver # NOT stamped here even though the env above carries them for
# that accepted the sample, precisely so the labels come from # in-process producers: the swarm tier upserts `hive` from the
# something the sender cannot write. # receiver that accepted the sample, precisely so the label comes
# from something the sender cannot write.
processors.resource.attributes = [ processors.resource.attributes = [
{ {
key = "service.name"; key = "service.name";

View file

@ -112,6 +112,7 @@
[ "hyperhive" "gui" "vncPort" ] [ "hyperhive" "gui" "vncPort" ]
[ "services" "hyperhive" "agent" "gui" "vncPort" ] [ "services" "hyperhive" "agent" "gui" "vncPort" ]
) )
(lib.mkRenamedOptionModule [ "hyperhive" "hiveName" ] [ "services" "hyperhive" "agent" "hiveName" ])
(lib.mkRenamedOptionModule [ "hyperhive" "icon" ] [ "services" "hyperhive" "agent" "icon" ]) (lib.mkRenamedOptionModule [ "hyperhive" "icon" ] [ "services" "hyperhive" "agent" "icon" ])
(lib.mkRenamedOptionModule (lib.mkRenamedOptionModule
[ "hyperhive" "logs" "queryUrl" ] [ "hyperhive" "logs" "queryUrl" ]

View file

@ -132,12 +132,6 @@ in
inherit pkgs self nixosSystem; inherit pkgs self nixosSystem;
inherit (pkgs) lib; inherit (pkgs) lib;
}; };
# Runs ./agent-modules/hive-facts.nix's generator and login-shell snippet
# against sample facts files.
script-test-agent-hive-facts = import ./script-tests/agent-hive-facts.nix {
inherit pkgs self nixosSystem;
inherit (pkgs) lib;
};
module-eval-agent-github-bao = import ./module-eval/agent-github-bao.nix { module-eval-agent-github-bao = import ./module-eval/agent-github-bao.nix {
inherit pkgs self nixosSystem; inherit pkgs self nixosSystem;
inherit (pkgs) lib; inherit (pkgs) lib;

View file

@ -1,41 +0,0 @@
# `checks.script-test-agent-hive-facts` — runs the two readers of
# `/etc/hyperhive-hive-facts` in ../agent-modules/hive-facts.nix: the systemd
# environment generator and the login-shell snippet `/etc/set-environment`
# sources. The cases are in ./agent-hive-facts.sh.
#
# What runs is the rendered generator and snippet. The one edit is the facts
# file's path, moved under the build directory, and the snippet's reference to
# the generator, pointed at that edited copy.
{
pkgs,
lib,
self,
nixosSystem,
}:
let
inherit
(import ../module-eval/lib.nix {
inherit
pkgs
lib
self
nixosSystem
;
})
agentWith
;
machine = agentWith { services.hyperhive.agent.user.name = "a1"; };
in
pkgs.runCommand "hyperhive-script-test-agent-hive-facts"
{
GENERATOR =
machine.environment.etc."systemd/system-environment-generators/50-hyperhive-hive-facts".source;
PROFILE = machine.environment.etc."hyperhive/hive-facts.sh".source;
EXTRA_INIT = machine.environment.extraInit;
SHELLS = "${pkgs.dash}/bin/dash ${pkgs.bash}/bin/sh";
}
''
${pkgs.bash}/bin/bash ${./agent-hive-facts.sh}
touch "$out"
''

View file

@ -1,71 +0,0 @@
# Cases for ./agent-hive-facts.nix. Each case writes a facts file (or none),
# runs the generator with a clean environment, and compares its whole output;
# then sources the login-shell snippet under each POSIX shell and compares what
# a child process sees.
set -uo pipefail
failures=0
count=0
fail() {
echo "FAILED: $case: $*" >&2
failures=$((failures + 1))
}
dir=$(mktemp -d)
facts=$dir/facts
sed "s|/etc/hyperhive-hive-facts|$facts|g" "$GENERATOR" >"$dir/generator"
chmod +x "$dir/generator"
sed "s|$GENERATOR|$dir/generator|g" "$PROFILE" >"$dir/profile"
envbin=$(command -v env)
case="setup"
if grep -q '/etc/hyperhive-hive-facts' "$dir/generator"; then fail "the generator still names /etc after the rewrite"; fi
if ! grep -qF "$facts" "$dir/generator"; then fail "the rewrite did not reach the generator's read"; fi
if ! grep -qF "$dir/generator" "$dir/profile"; then fail "the snippet does not call the rewritten generator"; fi
if ! printf '%s\n' "$EXTRA_INIT" | grep -qxF '. /etc/hyperhive/hive-facts.sh'; then
fail "/etc/set-environment does not source the snippet"
fi
# check <description> <expected generator output>
check() {
case=$1
count=$((count + 1))
local want=$2 got
got=$(env -i "$dir/generator")
if [ "$got" != "$want" ]; then
fail "generator printed:"$'\n'"$got"$'\n'"wanted:"$'\n'"$want"
fi
local sh
for sh in $SHELLS; do
# Only what the snippet exported reaches the child's environment. No PATH:
# the snippet must not need one.
got=$(env -i "$sh" -c ". '$dir/profile'; exec '$envbin'" | grep -E '^(HYPERHIVE_|CLAUDE_|LD_PRELOAD=|__hyperhive)' | sort)
if [ "$got" != "$(printf '%s\n' "$want" | sort)" ]; then
fail "$sh: a child of the sourcing shell saw:"$'\n'"$got"
fi
done
}
printf 'HYPERHIVE_HIVE_NAME=pr1ma\nHYPERHIVE_HIVE_DOMAIN=pr1ma.example.test\n' >"$facts"
check "both facts" "HYPERHIVE_HIVE_NAME=pr1ma
HYPERHIVE_HIVE_DOMAIN=pr1ma.example.test
CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX=pr1ma-a1"
printf 'LD_PRELOAD=/x\nHYPERHIVE_HIVE_NAME=pr1ma\nPATH=/evil\n' >"$facts"
check "a planted key is dropped" "HYPERHIVE_HIVE_NAME=pr1ma
CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX=pr1ma-a1"
printf 'HYPERHIVE_HIVE_DOMAIN=pr1ma.example.test\nHYPERHIVE_HIVE_NAME=pr1ma' >"$facts"
check "a last line without a newline is kept" "HYPERHIVE_HIVE_DOMAIN=pr1ma.example.test
HYPERHIVE_HIVE_NAME=pr1ma
CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX=pr1ma-a1"
: >"$facts"
check "an empty file (no facts)" "CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX=unknown-a1"
rm -f "$facts"
check "no file" "CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX=unknown-a1"
echo "$count cases, $failures failed"
[ "$failures" -eq 0 ]