diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index 3b572895..cedf84cf 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -130,6 +130,42 @@ 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). @@ -182,12 +218,9 @@ async fn set_nspawn_flags( // is needed here — the bind alone is enough. let claude_mount = container_claude_mount(agent_name); - // 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(); + // 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(); let mut binds: Vec = vec![ BindMount { diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index a17d0186..f4ece83b 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -843,6 +843,7 @@ 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. @@ -866,6 +867,9 @@ 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()) @@ -877,6 +881,7 @@ fn otel_config() -> Option { endpoint, protocol, extra_resource_attributes, + headers_credential, metric_interval_ms, debug, }) @@ -1204,6 +1209,13 @@ 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. @@ -2239,6 +2251,7 @@ 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"); } @@ -2248,12 +2261,17 @@ 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(); @@ -2274,6 +2292,10 @@ 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( @@ -2281,6 +2303,10 @@ 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 93b3aa43..26b96ffb 100644 --- a/nix/agent-modules/claude-settings.nix +++ b/nix/agent-modules/claude-settings.nix @@ -71,15 +71,14 @@ 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. - # - # 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. + # so telemetry parity is declarative, with no launch wrapper. The + # auth header (`otel.headersCredential`) is deliberately NOT included + # here: it's a secret and this file lives in the world-readable nix + # store. It's injected at *runtime* into the agent's `0600` + # `~/.claude/settings.json` by the `hive-otel-header` oneshot below + # (claude merges the `env` from the user settings on top of these + # managed ones), so the token is read from disk at start and never + # touches the store. otelSettingsEnv = { CLAUDE_CODE_ENABLE_TELEMETRY = "1"; # Attach feedback-survey data to the OTEL pipeline. @@ -149,6 +148,32 @@ 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 = ""; @@ -367,6 +392,49 @@ 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 diff --git a/nix/host-modules/hive-c0re/environment.nix b/nix/host-modules/hive-c0re/environment.nix index 64975b30..a88572fb 100644 --- a/nix/host-modules/hive-c0re/environment.nix +++ b/nix/host-modules/hive-c0re/environment.nix @@ -99,17 +99,13 @@ in HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES = otel.extraResourceAttributes; } # HYPERHIVE_OTEL_HEADERS_CREDENTIAL is deliberately NOT emitted, and - # its absence is the security half of this design: it was the variable - # that put the upstream token into an agent's own settings.json. The - # delivery path it drove — an nspawn credential forwarded by - # host_config.rs, then written to an agent-readable file by - # claude-settings.nix's `hive-otel-header` oneshot — no longer exists - # anywhere; it was removed along with this variable's last consumer. - # - # Kept as a comment rather than deleted because the useful part is the - # RULE, not the history: the collector holding the credential achieves - # nothing while anything else hands out a copy, so there is exactly one - # holder and it is on the host. + # its absence is the security half of this design. It is the variable + # that put the upstream token in an agent's own settings.json: + # host_config.rs forwards it into the container as an nspawn + # credential, and claude-settings.nix's `hive-otel-header` oneshot + # then writes the value into a file the agent can read. The collector + # holding the credential achieves nothing while the harness keeps + # handing out a copy — so there is exactly one holder, on the host. // lib.optionalAttrs (otel.metricIntervalMs != null) { HYPERHIVE_OTEL_METRIC_INTERVAL_MS = toString otel.metricIntervalMs; }