Compare commits

..
4 changed files with 149 additions and 26 deletions

View file

@ -130,6 +130,42 @@ fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
}
}
/// Hive-wide secrets forwarded into every agent container via nspawn
/// `--load-credential=<name>:<host_path>`. 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<CredentialMount> {
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/<container>.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<CredentialMount> = 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<BindMount> = vec![
BindMount {

View file

@ -843,6 +843,7 @@ struct OtelConfig {
endpoint: String,
protocol: String,
extra_resource_attributes: Option<String>,
headers_credential: Option<String>,
metric_interval_ms: Option<u64>,
/// `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<OtelConfig> {
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::<u64>().ok())
@ -877,6 +881,7 @@ fn otel_config() -> Option<OtelConfig> {
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"),

View file

@ -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 <token>`). 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

View file

@ -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;
}