diff --git a/docs/process/conventions.md b/docs/process/conventions.md index de91633f..8cca616d 100644 --- a/docs/process/conventions.md +++ b/docs/process/conventions.md @@ -38,14 +38,6 @@ Four env vars cover the identity surface, read by federated hives at different DNS domains can share a swarm 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 `HYPERHIVE_HIVE_DOMAIN`: the domain may carry the hive name as its leftmost label by convention, but the convention isn't diff --git a/docs/scheduler/observability.md b/docs/scheduler/observability.md index 8ca6160a..0fdd7c61 100644 --- a/docs/scheduler/observability.md +++ b/docs/scheduler/observability.md @@ -193,12 +193,9 @@ automatically: | -------------- | ------------------------------------------------------------ | | `service.name` | `hyperhive-agent` (constant) | | `agent` | agent logical name (for example `iris`) | +| `hive` | hive display name (`services.hyperhive.hiveName`) | | `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 reference above); pass custom per-data-point labels with `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 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 -section, and the swarm's collector adds `hive` — nothing extra to configure. -Cadence follows `HYPERHIVE_OTEL_METRIC_INTERVAL_MS` (default 60s, same variable + default as +section — nothing extra to configure. Cadence follows +`HYPERHIVE_OTEL_METRIC_INTERVAL_MS` (default 60s, same variable + default as `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 records them (every turn, always). @@ -482,7 +479,7 @@ hive-metric [--type counter|gauge] [--temporality delta|cumulativ itself). - `--labels key=value` — extra per-data-point labels. Repeat the flag for 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. ### Examples diff --git a/docs/turn-loop/claude-invocation.md b/docs/turn-loop/claude-invocation.md index bdbc0742..e6d3cf3e 100644 --- a/docs/turn-loop/claude-invocation.md +++ b/docs/turn-loop/claude-invocation.md @@ -260,7 +260,7 @@ needs to `chown` a bind mount), once at startup: substituted: `{label}` (short agent name), `{qualified_label}` (hive-qualified `name@domain` form), `{operator_pronouns}`, `{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` env (set by the meta flake from `services.hyperhive.c0re.operatorPronouns`, default `she/her`). diff --git a/hive-agent/src/identity.rs b/hive-agent/src/identity.rs index 21d7d702..878b4319 100644 --- a/hive-agent/src/identity.rs +++ b/hive-agent/src/identity.rs @@ -31,9 +31,9 @@ pub fn hive_domain() -> Option { /// Human display name of this hive (e.g. `pr1ma`). Distinct from /// [`hive_domain`] — the domain is the machine-readable DNS address; /// this is the prose label humans use in conversation. Returns None -/// when `/etc/hyperhive-hive-facts` carries no name (the host's -/// `services.hyperhive.hiveName` is unset), in which case callers fall -/// back to the domain or the short label at their discretion. +/// when the host-side `services.hyperhive.hiveName` option is unset, +/// in which case callers fall back to the domain or the short label +/// at their discretion. #[must_use] pub fn hive_name() -> Option { non_empty_env("HYPERHIVE_HIVE_NAME") diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index 2c70fd1a..236413fa 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -476,14 +476,7 @@ async fn set_nspawn_flags( ); // Delegate the actual conf-file rewrite to hive-priv (runs as root). - crate::priv_client::write_nspawn_flags( - container, - &binds, - isolation, - &load_creds, - super::hive_facts_from_env(), - ) - .await + crate::priv_client::write_nspawn_flags(container, &binds, isolation, &load_creds).await } #[cfg(test)] diff --git a/hive-c0re/src/lifecycle/mod.rs b/hive-c0re/src/lifecycle/mod.rs index a513f731..4328b8fa 100644 --- a/hive-c0re/src/lifecycle/mod.rs +++ b/hive-c0re/src/lifecycle/mod.rs @@ -177,17 +177,6 @@ pub fn network_isolation_from_env() -> Result 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<()> { if name.is_empty() { bail!("agent name must not be empty"); diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 917a85e1..1ce14990 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -508,6 +508,8 @@ const FORWARDED_VARS: &[&str] = &[ "HIVE_FORGE_URL", "HIVE_FORGE_PUBLIC_URL", "HIVE_MATRIX_URL", + "HYPERHIVE_HIVE_DOMAIN", + "HYPERHIVE_HIVE_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, /// 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 -/// at eval time for the `swarm=` resource label, and with the env var alone -/// that label evaluates to `unknown` while the process env holds the right -/// name. Half-wiring this map is a value that evaluates fine and is silently -/// wrong. +/// The hive/swarm display names are here for exactly that reason, learned the +/// hard way: they were forwarded as runtime env only, while the agent's +/// claude-settings module read them from the container's `environment.variables` +/// at *eval* time — where they were never set. Every agent baked +/// `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)] = &[ // 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 @@ -553,6 +558,7 @@ const FORWARDED_VAR_OPTIONS: &[(&str, &str)] = &[ ), ("HIVE_FORGE_URL", "services.hyperhive.agent.forge.url"), ("HIVE_MATRIX_URL", "services.hyperhive.agent.matrix.url"), + ("HYPERHIVE_HIVE_NAME", "services.hyperhive.agent.hiveName"), ("HYPERHIVE_SWARM_NAME", "services.hyperhive.agent.swarmName"), ]; @@ -1158,7 +1164,9 @@ where // → in-container accessors fall back to None / defaults gracefully. // // - 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() { let escaped = val.replace('\\', "\\\\").replace('"', "\\\""); 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] fn service_url_options_render_from_forwarded_pairs() { // 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_MATRIX_URL", "http://matrix.example.test".to_string()), - // Mapped too: `otel.nix` reads the option at eval time. - ("HYPERHIVE_SWARM_NAME", "constellat1on".to_string()), + // Mapped too, and that is the fix this test now guards: the + // 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 // filter, not a pass-through. - ( - "HIVE_FORGE_PUBLIC_URL", - "https://forge.example.test".to_string(), - ), + ("HYPERHIVE_HIVE_DOMAIN", "pr1ma.darkest.space".to_string()), ], ); assert_eq!( out, " 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.swarmName = \"constellat1on\";\n", + \x20 services.hyperhive.agent.hiveName = \"pr1ma\";\n", "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() { require_service_urls(&[ ("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"); } @@ -1886,7 +1847,7 @@ mod tests { // every agent would simply come up with no forge login and no way to // tell that was unintended. The hive asserts its own requirement // 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"); assert!( 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. // Required services don't reach here: `require_service_urls` rejects // 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 // forwarded var the option map does not know about. let mut out = String::new(); push_forwarded_var_options( &mut out, - &[( - "HIVE_FORGE_PUBLIC_URL", - "https://forge.example.test".to_string(), - )], + &[("HYPERHIVE_HIVE_DOMAIN", "pr1ma.darkest.space".to_string())], ); assert!( out.is_empty(), diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index 0cd30e75..8e1bb4d9 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -8,7 +8,7 @@ use anyhow::{Context as _, Result, bail}; 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, }; use std::os::fd::{AsRawFd as _, OwnedFd, RawFd}; @@ -288,14 +288,12 @@ pub async fn write_nspawn_flags( binds: &[BindMount], isolation: NetworkIsolation, load_credentials: &[CredentialMount], - hive_facts: HiveFacts, ) -> Result<()> { ok(call(&PrivRequest::WriteNspawnFlags { container: container.to_owned(), binds: binds.to_vec(), isolation, load_credentials: load_credentials.to_vec(), - hive_facts, }) .await?) } diff --git a/hive-priv-sock/src/lib.rs b/hive-priv-sock/src/lib.rs index 24844cf5..4b393e4f 100644 --- a/hive-priv-sock/src/lib.rs +++ b/hive-priv-sock/src/lib.rs @@ -232,18 +232,6 @@ pub struct NetworkIsolation { 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, - /// `HYPERHIVE_HIVE_DOMAIN`: the hive's DNS domain. - pub domain: Option, -} - /// 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 /// 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. #[serde(default)] load_credentials: Vec, - /// 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/` as `0751 diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 052ff7aa..12e9dfe5 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -22,10 +22,10 @@ use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result, anyhow, bail}; use hive_priv_sock::{ - AGENT_PREFIX, AGENT_RUNTIME_ROOT, AGENT_STATE_ROOT, BindMount, CredentialMount, HiveFacts, - InfraAction, InfraContainer, MEASURE_AGENT_DISK_TIMEOUT, META_DIR, MIGRATE_STAGING_ROOT, - NetworkIsolation, PAUSED_MARKER_FILE, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, - PrivStream, PrivStreamLine, SIBLING_CONTAINERS, + AGENT_PREFIX, AGENT_RUNTIME_ROOT, AGENT_STATE_ROOT, BindMount, CredentialMount, InfraAction, + InfraContainer, MEASURE_AGENT_DISK_TIMEOUT, META_DIR, MIGRATE_STAGING_ROOT, NetworkIsolation, + PAUSED_MARKER_FILE, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream, + PrivStreamLine, SIBLING_CONTAINERS, }; use tokio::io::{AsyncWriteExt, BufReader}; use tokio::net::unix::OwnedWriteHalf; @@ -377,8 +377,7 @@ async fn exec( ref binds, ref isolation, ref load_credentials, - ref hive_facts, - } => handle_write_nspawn_flags(container, binds, isolation, load_credentials, hive_facts), + } => handle_write_nspawn_flags(container, binds, isolation, load_credentials), PrivRequest::EnsureAgentSocketDir { ref name } => ensure_agent_socket_dir(name), @@ -985,7 +984,6 @@ fn handle_write_nspawn_flags( binds: &[BindMount], isolation: &NetworkIsolation, load_credentials: &[CredentialMount], - hive_facts: &HiveFacts, ) -> Result<(String, String)> { validate_container_system_name(container)?; for bind in binds { @@ -999,8 +997,7 @@ fn handle_write_nspawn_flags( // uses `:` as the name/path separator. validate_bind_path(&cred.host_path)?; } - let hive_facts = render_hive_facts(hive_facts)?; - write_nspawn_flags(container, binds, isolation, load_credentials, &hive_facts)?; + write_nspawn_flags(container, binds, isolation, load_credentials)?; Ok((String::new(), String::new())) } @@ -2873,7 +2870,6 @@ fn write_nspawn_flags( binds: &[BindMount], isolation: &NetworkIsolation, load_credentials: &[CredentialMount], - hive_facts: &str, ) -> Result<()> { use std::fmt::Write 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 // container gets its resolver*. 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(()) } /// Filename of the in-container DNS marker, in the container's own `/etc`. 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 { - 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 /// off. The marker file contains just the gateway IP. Always written: /// every container is isolated, so there is no host-netns case that /// wants the marker absent. 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 `/etc/hyperhive-bridge-dns`; see [`write_container_etc_file_in`]. -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 `/etc/` without following a symlink. +/// Write `/etc/hyperhive-bridge-dns` without following a symlink. /// /// Root inside the container owns its `/etc`, and this write runs as host /// root, where an absolute symlink planted in the container resolves against /// 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 /// leaf, symlink or FIFO or device node, is replaced by the rename, never /// written through or opened. /// /// 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 -/// dir and the file persist through that start. Mode 0644: nothing written -/// here is a secret. -fn write_container_etc_file_in(rootfs: &Path, name: &str, contents: &str) -> Result<()> { +/// dir and the marker persist through that start. Mode 0644: the gateway IP +/// is not a secret. +fn write_bridge_dns_marker_in(rootfs: &Path, gateway_ip: &str) -> Result<()> { use std::os::unix::fs::OpenOptionsExt as _; 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. 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 @@ -3163,14 +3124,13 @@ fn ensure_socket_dir_in(root: &Path, name: &str) -> Result<()> { #[cfg(test)] mod tests { use super::{ - BindMount, BoundedRun, Command, HiveFacts, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, - StagedFile, agent_disk_paths, check_fd_agreement, contains_secret_shaped_run, - describe_forge_admin, du_command, du_total, ensure_plain_filename, ensure_socket_dir_in, - exec_measure_agent_disk, git_overlay_flags, limits_dropin_body, open_dir, open_export_dest, - parse_du_total, partial_name, publish_file, redact_secret_line, remove_marker_in, - render_hive_facts, run_bounded, single_output_path, toplevel_attr, - validate_credential_name, validate_snapshot_name, write_agent_dir_file, - write_bridge_dns_marker_in, + BindMount, BoundedRun, Command, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, StagedFile, + agent_disk_paths, check_fd_agreement, contains_secret_shaped_run, describe_forge_admin, + du_command, du_total, ensure_plain_filename, ensure_socket_dir_in, exec_measure_agent_disk, + git_overlay_flags, limits_dropin_body, open_dir, open_export_dest, parse_du_total, + partial_name, publish_file, redact_secret_line, remove_marker_in, run_bounded, + single_output_path, toplevel_attr, validate_credential_name, validate_snapshot_name, + write_agent_dir_file, write_bridge_dns_marker_in, }; use std::path::PathBuf; use std::sync::atomic::{AtomicU32, Ordering}; @@ -3822,45 +3782,6 @@ mod tests { 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 /// way it is no longer running. fn is_dead(pid: &str) -> bool { diff --git a/nix/agent-modules/claude-settings.nix b/nix/agent-modules/claude-settings.nix index 0322bc8e..f77f790c 100644 --- a/nix/agent-modules/claude-settings.nix +++ b/nix/agent-modules/claude-settings.nix @@ -19,6 +19,17 @@ let # meta.rs `otel_config`). Options declared in `otel.nix`, which also # exports the generic OTLP environment this container's producers read. 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 # per-agent flake render (`services.hyperhive.agent.claudeMemoryMaxBytes`). `null` # when the effective cap is unbounded ("infinity") or a RAM @@ -47,6 +58,8 @@ let 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 "-" for identification. + CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX = "${hiveDisplayName}-${userName}"; } # Bun/JavaScriptCore (the runtime under claude-code's `.claude-wrapped` # binary) sizes its default heap ceiling off the container's visible @@ -84,21 +97,6 @@ let }; 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: # meta.rs's per-agent flake render injects this from the effective # `MemoryMax=` (per-agent `resource-limits.json` override, else the @@ -110,6 +108,37 @@ in # the agent's *last rebuild* — `set-limits` still applies the # cgroup cap live via a drop-in reload, but this derived heap ceiling # 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 { type = lib.types.nullOr lib.types.ints.positive; default = null; @@ -250,7 +279,7 @@ in # 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 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 # auto-discovers this file in every context (harness turn-loop AND # `hivectl choom`) so no launch wrapper is needed. @@ -258,8 +287,9 @@ in let 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 - # run — `baseClaudeEnv` can carry per-agent values (e.g. - # BUN_JSC_forceRAMSize) that can't live in the static store asset. + # 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 diff --git a/nix/agent-modules/default.nix b/nix/agent-modules/default.nix index ff833705..7f46fb80 100644 --- a/nix/agent-modules/default.nix +++ b/nix/agent-modules/default.nix @@ -36,11 +36,6 @@ let 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 { imports = [ @@ -56,7 +51,6 @@ in ./frontend.nix ./github.nix ./github-token.nix - ./hive-facts.nix ./logs.nix ./matrix.nix ./mcp.nix @@ -91,9 +85,6 @@ in # breaks evaluation for everyone, removed or not. (lib.mkRemovedOptionModule [ "hyperhive" "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 { diff --git a/nix/agent-modules/hive-facts.nix b/nix/agent-modules/hive-facts.nix deleted file mode 100644 index c5e33e06..00000000 --- a/nix/agent-modules/hive-facts.nix +++ /dev/null @@ -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 `-`. - 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"; -} diff --git a/nix/agent-modules/otel.nix b/nix/agent-modules/otel.nix index f42618ab..359fe81d 100644 --- a/nix/agent-modules/otel.nix +++ b/nix/agent-modules/otel.nix @@ -25,10 +25,17 @@ let cfg = config.services.hyperhive.agent.otel; userName = config.services.hyperhive.agent.user.name; - # Swarm display name, read from the per-agent option meta.rs renders - # (NOT from `environment.variables`, which carries it at *runtime* - # only). `null` means the swarm is unnamed; "unknown" is then an honest - # label rather than a guess. + # Hive/swarm display names, read from the per-agent options meta.rs + # renders (NOT from `environment.variables` — those carry the same + # names at *runtime* only, so reading them 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; swarmDisplayName = if config.services.hyperhive.agent.swarmName == null then "unknown" @@ -38,11 +45,9 @@ let # Resource labels every producer in this container stamps on what it # emits. `service.name` names the container's role, not one binary # 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 - # 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. + # agent, and their metric names already tell them apart. 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}"; # The variables an OTEL SDK reads on its own, in any language, in any @@ -126,7 +131,7 @@ in description = '' Extra comma-separated entries appended to `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`. ''; }; @@ -269,9 +274,10 @@ in # Identity the hop above cannot supply. A host-side reader can say # which machine a line came from; only a collector running as the # agent can say which agent. `hive` and `swarm` are deliberately - # NOT stamped here: the swarm tier upserts both from the receiver - # that accepted the sample, precisely so the labels come from - # something the sender cannot write. + # NOT stamped here even though the env above carries them for + # in-process producers: the swarm tier upserts `hive` from the + # receiver that accepted the sample, precisely so the label comes + # from something the sender cannot write. processors.resource.attributes = [ { key = "service.name"; diff --git a/nix/agent-modules/renamed-options.nix b/nix/agent-modules/renamed-options.nix index f0b00b74..8c10140a 100644 --- a/nix/agent-modules/renamed-options.nix +++ b/nix/agent-modules/renamed-options.nix @@ -112,6 +112,7 @@ [ "hyperhive" "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" "logs" "queryUrl" ] diff --git a/nix/checks.nix b/nix/checks.nix index 04c3d8bc..01b56173 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -132,12 +132,6 @@ in inherit pkgs self nixosSystem; 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 { inherit pkgs self nixosSystem; inherit (pkgs) lib; diff --git a/nix/script-tests/agent-hive-facts.nix b/nix/script-tests/agent-hive-facts.nix deleted file mode 100644 index 82c1a2c1..00000000 --- a/nix/script-tests/agent-hive-facts.nix +++ /dev/null @@ -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" - '' diff --git a/nix/script-tests/agent-hive-facts.sh b/nix/script-tests/agent-hive-facts.sh deleted file mode 100644 index cddb1bf7..00000000 --- a/nix/script-tests/agent-hive-facts.sh +++ /dev/null @@ -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 -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 ]