From fbeff69fd7961478e0b3c47c9f85a1e295c4ad9d Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 16 Aug 2026 02:04:36 +0200 Subject: [PATCH] fix(otel): stop delivering the hive's upstream token to agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host-side collector is the only path telemetry leaves a hive, so HYPERHIVE_OTEL_HEADERS_CREDENTIAL is never emitted and everything downstream of it is unreachable. What made it worth removing rather than leaving inert is what it looked like to a reader: a complete, well-commented mechanism for writing the hive's upstream credential into a file the agent can read, described in the present tense. Anyone auditing "can an agent obtain the OTEL token?" had to reconstruct the whole env-var chain to find out the answer is no. Gone: the per-agent `hyperhive.otel.headersCredential` option, the `hive-otel-header` oneshot that merged OTEL_EXPORTER_OTLP_HEADERS into the agent's own settings.json, and meta.rs's field, env read and render. ⚠️ Scoped by NAMESPACE, not by name. `hyperhive.otel.headersCredential` (per-agent) and `services.hyperhive.otel.headersCredential` (host) are different options sharing a leaf name — the host one is read by `stats/otel_metrics.rs` for c0re's own container-resource exporter and stays. Sweeping the string would have taken out working code. The comment above `otelSettingsEnv` now states the property rather than the absence: there is no auth header and no mechanism to add one, because an agent exports to the hive's own collector and nothing an agent can read is a secret to the swarm. The old behaviour is named in the past tense so it reads as removed rather than overlooked. meta.rs's assertions that pinned the injection are deleted rather than adjusted; the surrounding test keeps covering extraResourceAttributes and the endpoint/protocol injection, which are live. --- hive-c0re/src/lifecycle/host_config.rs | 45 ++------------ hive-c0re/src/meta.rs | 26 -------- nix/agent-modules/claude-settings.nix | 86 +++----------------------- 3 files changed, 15 insertions(+), 142 deletions(-) diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index cedf84cf..3b572895 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -130,42 +130,6 @@ fn bind_child_agent_dirs(child: &str, binds: &mut Vec) { } } -/// Hive-wide secrets forwarded into every agent container via nspawn -/// `--load-credential=:`. Currently just the OTEL -/// auth-header secret, when `services.hyperhive.otel.headersCredential` -/// is set (surfaced as `HYPERHIVE_OTEL_HEADERS_CREDENTIAL` on hive-c0re's -/// unit env — the same host option meta.rs reads to inject -/// `hyperhive.otel.headersCredential`). The inner harness unit reads it -/// via `LoadCredential=otel-headers` (inherit). The secret never lands in -/// a bind mount, the nix store, or the generated config. -/// -/// A configured-but-missing file is skipped with a warning rather than -/// forwarded (nspawn would refuse to start the container otherwise): a -/// host-level secret typo shouldn't take down every agent's start; OTEL -/// just exports without the auth header until the file appears. -fn hive_load_credentials() -> Vec { - let mut out = Vec::new(); - let Ok(path) = std::env::var("HYPERHIVE_OTEL_HEADERS_CREDENTIAL") else { - return out; - }; - if path.is_empty() { - return out; - } - if std::path::Path::new(&path).is_file() { - out.push(CredentialMount { - name: "otel-headers".to_owned(), - host_path: path, - }); - } else { - tracing::warn!( - %path, - "HYPERHIVE_OTEL_HEADERS_CREDENTIAL is set but the file is missing; \ - skipping --load-credential (OTEL will export without the auth header)" - ); - } - out -} - /// Idempotently rewrite the lines in `/etc/nixos-containers/.conf` /// that hive-c0re owns: `PRIVATE_NETWORK` (forced 0 so the agent's web UI port /// is reachable on the host) and `EXTRA_NSPAWN_FLAGS` (the runtime-dir bind). @@ -218,9 +182,12 @@ async fn set_nspawn_flags( // is needed here — the bind alone is enough. let claude_mount = container_claude_mount(agent_name); - // Hive-wide secrets forwarded into the container's credential store - // (currently just the OTEL auth-header). Same for every agent. - let load_creds = hive_load_credentials(); + // No hive-wide secrets are forwarded into agent containers. hive-priv + // still accepts a credential list (see `write_nspawn_flags`), but + // nothing produces one: the only entry was the OTEL upstream token, + // and an agent has no business holding the hive's credential for + // anything outside it. + let load_creds: Vec = Vec::new(); let mut binds: Vec = vec![ BindMount { diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index f4ece83b..a17d0186 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -843,7 +843,6 @@ struct OtelConfig { endpoint: String, protocol: String, extra_resource_attributes: Option, - headers_credential: Option, metric_interval_ms: Option, /// `HYPERHIVE_OTEL_DEBUG=1` → `hyperhive.otel.debug = true` → /// `CLAUDE_CODE_OTEL_DIAG_STDERR=1` in every agent's env. @@ -867,9 +866,6 @@ fn otel_config() -> Option { let extra_resource_attributes = std::env::var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES") .ok() .filter(|v| !v.is_empty()); - let headers_credential = std::env::var("HYPERHIVE_OTEL_HEADERS_CREDENTIAL") - .ok() - .filter(|v| !v.is_empty()); let metric_interval_ms = std::env::var("HYPERHIVE_OTEL_METRIC_INTERVAL_MS") .ok() .and_then(|v| v.parse::().ok()) @@ -881,7 +877,6 @@ fn otel_config() -> Option { endpoint, protocol, extra_resource_attributes, - headers_credential, metric_interval_ms, debug, }) @@ -1209,13 +1204,6 @@ where esc(attrs) ); } - if let Some(cred) = &otel.headers_credential { - let _ = writeln!( - out, - " hyperhive.otel.headersCredential = \"{}\";", - esc(cred) - ); - } if let Some(ms) = otel.metric_interval_ms { // Int option — emit a bare numeric literal (no quotes). `ms` is a // parsed u64, so it can't inject anything into the rendered nix. @@ -2251,7 +2239,6 @@ mod tests { }; unsafe { std::env::remove_var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES"); - std::env::remove_var("HYPERHIVE_OTEL_HEADERS_CREDENTIAL"); std::env::set_var("HYPERHIVE_OTEL_ENDPOINT", "https://c.example/otel"); std::env::set_var("HYPERHIVE_OTEL_PROTOCOL", "grpc"); } @@ -2261,17 +2248,12 @@ mod tests { "HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES", "deployment.environment=prod", ); - std::env::set_var( - "HYPERHIVE_OTEL_HEADERS_CREDENTIAL", - "/run/secrets/otel-headers", - ); } let on_full = render(); unsafe { std::env::remove_var("HYPERHIVE_OTEL_ENDPOINT"); std::env::remove_var("HYPERHIVE_OTEL_PROTOCOL"); std::env::remove_var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES"); - std::env::remove_var("HYPERHIVE_OTEL_HEADERS_CREDENTIAL"); } let off = render(); @@ -2292,10 +2274,6 @@ mod tests { !on_minimal.contains("hyperhive.otel.extraResourceAttributes"), "extraResourceAttributes must not appear when unset:\n{on_minimal}" ); - assert!( - !on_minimal.contains("hyperhive.otel.headersCredential"), - "headersCredential must not appear when unset:\n{on_minimal}" - ); assert!( on_full.contains( @@ -2303,10 +2281,6 @@ mod tests { ), "extraResourceAttributes must be injected when set:\n{on_full}" ); - assert!( - on_full.contains("hyperhive.otel.headersCredential = \"/run/secrets/otel-headers\";"), - "headersCredential must be injected when set:\n{on_full}" - ); assert!( !off.contains("hyperhive.otel"), diff --git a/nix/agent-modules/claude-settings.nix b/nix/agent-modules/claude-settings.nix index 26b96ffb..93b3aa43 100644 --- a/nix/agent-modules/claude-settings.nix +++ b/nix/agent-modules/claude-settings.nix @@ -71,14 +71,15 @@ let # OTEL environment Claude Code reads to export metrics/logs/traces. # Shipped via the managed claude settings json (below), which claude # auto-discovers for BOTH the harness turn-loop and `hivectl choom` — - # so telemetry parity is declarative, with no launch wrapper. The - # auth header (`otel.headersCredential`) is deliberately NOT included - # here: it's a secret and this file lives in the world-readable nix - # store. It's injected at *runtime* into the agent's `0600` - # `~/.claude/settings.json` by the `hive-otel-header` oneshot below - # (claude merges the `env` from the user settings on top of these - # managed ones), so the token is read from disk at start and never - # touches the store. + # so telemetry parity is declarative, with no launch wrapper. + # + # There is no auth header here, and no mechanism to add one. An agent + # exports to the hive's own collector, which is the only thing holding + # a credential for anything upstream; nothing an agent can read is a + # secret to the swarm. An earlier revision forwarded the operator's + # upstream token into this container and merged it into the agent's own + # `~/.claude/settings.json` — which handed every agent the hive's + # credential, and was removed with the direct-export path it served. otelSettingsEnv = { CLAUDE_CODE_ENABLE_TELEMETRY = "1"; # Attach feedback-survey data to the OTEL pipeline. @@ -148,32 +149,6 @@ in ''; }; - headersCredential = lib.mkOption { - # `str`, not `path`: a `path`-typed *relative* literal (e.g. - # `./otel-headers`) is hash-copied into the world-readable nix store - # at eval time, which would defeat the whole point of this option. - # Keep it a string and require an absolute runtime path so the secret - # is only ever read from disk by systemd at start, never nix-stored. - type = lib.types.nullOr lib.types.str; - default = null; - internal = true; - description = '' - Absolute path to an operator-provided secret file whose contents - become `OTEL_EXPORTER_OTLP_HEADERS` (e.g. - `Authorization=Bearer `). Host-driven via - `services.hyperhive.otel.headersCredential`. - - The rest of the OTEL config ships in the world-readable managed - claude settings json, but the header is a secret, so it's handled - separately: hive-c0re forwards this file into the container's - systemd credential store, and the `hive-otel-header` oneshot - reads it at runtime (`LoadCredential`) and writes it into the - agent's `0600` `~/.claude/settings.json` `env` block. The token - is read from disk at start and never copied into the nix store or - the world-readable settings file. - ''; - }; - extraResourceAttributes = lib.mkOption { type = lib.types.str; default = ""; @@ -392,49 +367,6 @@ in '. + { env: $env }' ${baseSettings} > "$out" ''; - # Inject the OTEL auth header (a secret) into the agent's *user* - # claude settings at runtime, keeping it out of the world-readable - # managed settings json above and out of the nix store entirely. - # hive-c0re forwards the operator's `headersCredential` file into - # this container's systemd credential store; this oneshot reads it - # via `LoadCredential` at start and merges `OTEL_EXPORTER_OTLP_HEADERS` - # into `~/.claude/settings.json` (0600, agent-owned). claude layers - # the user `env` on top of the managed one, so both the harness - # turn-loop and `hivectl choom` (same agent user) pick it up. Ordering - # is best-effort (`before`, not a hard dep): if it fails the harness - # still starts and telemetry just exports unauthenticated. - systemd.services.hive-otel-header = - lib.mkIf (config.hyperhive.otel.enable && config.hyperhive.otel.headersCredential != null) - { - description = "Inject the OTEL auth header into the agent's claude user settings"; - wantedBy = [ "multi-user.target" ]; - before = [ "hive-agent.service" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - User = userName; - Group = userName; - LoadCredential = [ "otel-headers" ]; - ExecStart = pkgs.writeShellScript "hive-otel-header" '' - set -eu - umask 077 - hdr="$CREDENTIALS_DIRECTORY/otel-headers" - [ -r "$hdr" ] || exit 0 - dir=${homeDir}/.claude - settings="$dir/settings.json" - mkdir -p "$dir" - base='{}' - [ -s "$settings" ] && base="$(cat "$settings")" - printf '%s' "$base" | ${pkgs.jq}/bin/jq \ - --rawfile h "$hdr" \ - '.env = ((.env // {}) + { OTEL_EXPORTER_OTLP_HEADERS: ($h | rtrimstr("\n")) })' \ - > "$settings.tmp" - mv "$settings.tmp" "$settings" - chmod 0600 "$settings" - ''; - }; - }; - # Seed claude's onboarding + per-project trust state once. claude only # marks `hasCompletedOnboarding` (global) and the project trust dialog # as accepted when run *interactively*; the harness only ever runs it