Compare commits

..
Author SHA1 Message Date
atlas
72b2a0357b docs(otel): stop describing the removed credential path in present tense
environment.nix explains why HYPERHIVE_OTEL_HEADERS_CREDENTIAL is not
emitted by naming the machinery that used to consume it -- machinery this
PR deletes. Left as written it would describe a removed nspawn credential
and a removed oneshot as if both still ran, in the same paragraph that
justifies withholding the variable.

That is this PR's own defect inverted: it exists so an auditor asking
'can an agent obtain the OTEL token?' finds nothing misleading, and a
comment claiming live delivery machinery is exactly the kind of thing
that costs an auditor a reconstruction.

Keeps the rule rather than the history -- one holder, on the host -- since
that is the part still worth reading.
2026-08-18 22:23:35 +02:00
atlas
fbeff69fd7 fix(otel): stop delivering the hive's upstream token to agents
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.
2026-08-18 22:23:35 +02:00
4 changed files with 26 additions and 149 deletions

View file

@ -130,42 +130,6 @@ 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).
@ -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<CredentialMount> = Vec::new();
let mut binds: Vec<BindMount> = vec![
BindMount {

View file

@ -843,7 +843,6 @@ 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.
@ -867,9 +866,6 @@ 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())
@ -881,7 +877,6 @@ fn otel_config() -> Option<OtelConfig> {
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"),

View file

@ -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 <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 = "";
@ -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

View file

@ -99,13 +99,17 @@ 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 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.
# 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.
// lib.optionalAttrs (otel.metricIntervalMs != null) {
HYPERHIVE_OTEL_METRIC_INTERVAL_MS = toString otel.metricIntervalMs;
}