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.
This commit is contained in:
parent
a5ef31ed8b
commit
fbeff69fd7
3 changed files with 15 additions and 142 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue