diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index ba88db05..b355e20a 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -30,9 +30,13 @@ let # 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: - # it's a secret and the settings file is world-readable; authenticated - # collectors need a runtime mechanism (tracked as a follow-up). + # 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"; OTEL_METRICS_EXPORTER = "otlp"; @@ -264,18 +268,18 @@ in internal = true; description = '' Absolute path to an operator-provided secret file whose contents - would become `OTEL_EXPORTER_OTLP_HEADERS` (e.g. + become `OTEL_EXPORTER_OTLP_HEADERS` (e.g. `Authorization=Bearer `). Host-driven via `services.hyperhive.otel.headersCredential`. - **Not yet wired up.** OTEL config now ships through the managed - claude settings json (`/etc/claude-code/managed-settings.json`), - which is world-readable, so a secret auth header can't be baked - into it. Setting this option currently has no effect — the - unauthenticated export path is the only one implemented. A - follow-up will inject the header at runtime (e.g. the harness - writing it into the agent's `0600` `~/.claude/settings.json`), - keeping it out of the nix store and the world-readable file. + 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. ''; }; @@ -1133,6 +1137,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-ag3nt.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" + ''; + }; + }; + # Merged frontend static tree. Base = `${frontend.dist}/agent/`, # then each `extraFiles` entry is laid on top at its `target` # path. The runCommand derivation aborts on overwrite so a