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