Compare commits
4 changed files with 149 additions and 26 deletions
|
|
@ -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`
|
/// 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
|
/// 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).
|
/// 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.
|
// is needed here — the bind alone is enough.
|
||||||
let claude_mount = container_claude_mount(agent_name);
|
let claude_mount = container_claude_mount(agent_name);
|
||||||
|
|
||||||
// No hive-wide secrets are forwarded into agent containers. hive-priv
|
// Hive-wide secrets forwarded into the container's credential store
|
||||||
// still accepts a credential list (see `write_nspawn_flags`), but
|
// (currently just the OTEL auth-header). Same for every agent.
|
||||||
// nothing produces one: the only entry was the OTEL upstream token,
|
let load_creds = hive_load_credentials();
|
||||||
// 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![
|
let mut binds: Vec<BindMount> = vec![
|
||||||
BindMount {
|
BindMount {
|
||||||
|
|
|
||||||
|
|
@ -843,6 +843,7 @@ struct OtelConfig {
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
protocol: String,
|
protocol: String,
|
||||||
extra_resource_attributes: Option<String>,
|
extra_resource_attributes: Option<String>,
|
||||||
|
headers_credential: Option<String>,
|
||||||
metric_interval_ms: Option<u64>,
|
metric_interval_ms: Option<u64>,
|
||||||
/// `HYPERHIVE_OTEL_DEBUG=1` → `hyperhive.otel.debug = true` →
|
/// `HYPERHIVE_OTEL_DEBUG=1` → `hyperhive.otel.debug = true` →
|
||||||
/// `CLAUDE_CODE_OTEL_DIAG_STDERR=1` in every agent's env.
|
/// `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")
|
let extra_resource_attributes = std::env::var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES")
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|v| !v.is_empty());
|
.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")
|
let metric_interval_ms = std::env::var("HYPERHIVE_OTEL_METRIC_INTERVAL_MS")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.parse::<u64>().ok())
|
.and_then(|v| v.parse::<u64>().ok())
|
||||||
|
|
@ -877,6 +881,7 @@ fn otel_config() -> Option<OtelConfig> {
|
||||||
endpoint,
|
endpoint,
|
||||||
protocol,
|
protocol,
|
||||||
extra_resource_attributes,
|
extra_resource_attributes,
|
||||||
|
headers_credential,
|
||||||
metric_interval_ms,
|
metric_interval_ms,
|
||||||
debug,
|
debug,
|
||||||
})
|
})
|
||||||
|
|
@ -1204,6 +1209,13 @@ where
|
||||||
esc(attrs)
|
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 {
|
if let Some(ms) = otel.metric_interval_ms {
|
||||||
// Int option — emit a bare numeric literal (no quotes). `ms` is a
|
// Int option — emit a bare numeric literal (no quotes). `ms` is a
|
||||||
// parsed u64, so it can't inject anything into the rendered nix.
|
// parsed u64, so it can't inject anything into the rendered nix.
|
||||||
|
|
@ -2239,6 +2251,7 @@ mod tests {
|
||||||
};
|
};
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES");
|
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_ENDPOINT", "https://c.example/otel");
|
||||||
std::env::set_var("HYPERHIVE_OTEL_PROTOCOL", "grpc");
|
std::env::set_var("HYPERHIVE_OTEL_PROTOCOL", "grpc");
|
||||||
}
|
}
|
||||||
|
|
@ -2248,12 +2261,17 @@ mod tests {
|
||||||
"HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES",
|
"HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES",
|
||||||
"deployment.environment=prod",
|
"deployment.environment=prod",
|
||||||
);
|
);
|
||||||
|
std::env::set_var(
|
||||||
|
"HYPERHIVE_OTEL_HEADERS_CREDENTIAL",
|
||||||
|
"/run/secrets/otel-headers",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let on_full = render();
|
let on_full = render();
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("HYPERHIVE_OTEL_ENDPOINT");
|
std::env::remove_var("HYPERHIVE_OTEL_ENDPOINT");
|
||||||
std::env::remove_var("HYPERHIVE_OTEL_PROTOCOL");
|
std::env::remove_var("HYPERHIVE_OTEL_PROTOCOL");
|
||||||
std::env::remove_var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES");
|
std::env::remove_var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES");
|
||||||
|
std::env::remove_var("HYPERHIVE_OTEL_HEADERS_CREDENTIAL");
|
||||||
}
|
}
|
||||||
let off = render();
|
let off = render();
|
||||||
|
|
||||||
|
|
@ -2274,6 +2292,10 @@ mod tests {
|
||||||
!on_minimal.contains("hyperhive.otel.extraResourceAttributes"),
|
!on_minimal.contains("hyperhive.otel.extraResourceAttributes"),
|
||||||
"extraResourceAttributes must not appear when unset:\n{on_minimal}"
|
"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!(
|
assert!(
|
||||||
on_full.contains(
|
on_full.contains(
|
||||||
|
|
@ -2281,6 +2303,10 @@ mod tests {
|
||||||
),
|
),
|
||||||
"extraResourceAttributes must be injected when set:\n{on_full}"
|
"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!(
|
assert!(
|
||||||
!off.contains("hyperhive.otel"),
|
!off.contains("hyperhive.otel"),
|
||||||
|
|
|
||||||
|
|
@ -71,15 +71,14 @@ let
|
||||||
# OTEL environment Claude Code reads to export metrics/logs/traces.
|
# OTEL environment Claude Code reads to export metrics/logs/traces.
|
||||||
# Shipped via the managed claude settings json (below), which claude
|
# Shipped via the managed claude settings json (below), which claude
|
||||||
# auto-discovers for BOTH the harness turn-loop and `hivectl choom` —
|
# auto-discovers for BOTH the harness turn-loop and `hivectl choom` —
|
||||||
# so telemetry parity is declarative, with no launch wrapper.
|
# so telemetry parity is declarative, with no launch wrapper. The
|
||||||
#
|
# auth header (`otel.headersCredential`) is deliberately NOT included
|
||||||
# There is no auth header here, and no mechanism to add one. An agent
|
# here: it's a secret and this file lives in the world-readable nix
|
||||||
# exports to the hive's own collector, which is the only thing holding
|
# store. It's injected at *runtime* into the agent's `0600`
|
||||||
# a credential for anything upstream; nothing an agent can read is a
|
# `~/.claude/settings.json` by the `hive-otel-header` oneshot below
|
||||||
# secret to the swarm. An earlier revision forwarded the operator's
|
# (claude merges the `env` from the user settings on top of these
|
||||||
# upstream token into this container and merged it into the agent's own
|
# managed ones), so the token is read from disk at start and never
|
||||||
# `~/.claude/settings.json` — which handed every agent the hive's
|
# touches the store.
|
||||||
# credential, and was removed with the direct-export path it served.
|
|
||||||
otelSettingsEnv = {
|
otelSettingsEnv = {
|
||||||
CLAUDE_CODE_ENABLE_TELEMETRY = "1";
|
CLAUDE_CODE_ENABLE_TELEMETRY = "1";
|
||||||
# Attach feedback-survey data to the OTEL pipeline.
|
# 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 {
|
extraResourceAttributes = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = "";
|
default = "";
|
||||||
|
|
@ -367,6 +392,49 @@ in
|
||||||
'. + { env: $env }' ${baseSettings} > "$out"
|
'. + { 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
|
# Seed claude's onboarding + per-project trust state once. claude only
|
||||||
# marks `hasCompletedOnboarding` (global) and the project trust dialog
|
# marks `hasCompletedOnboarding` (global) and the project trust dialog
|
||||||
# as accepted when run *interactively*; the harness only ever runs it
|
# as accepted when run *interactively*; the harness only ever runs it
|
||||||
|
|
|
||||||
|
|
@ -99,17 +99,13 @@ in
|
||||||
HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES = otel.extraResourceAttributes;
|
HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES = otel.extraResourceAttributes;
|
||||||
}
|
}
|
||||||
# HYPERHIVE_OTEL_HEADERS_CREDENTIAL is deliberately NOT emitted, and
|
# HYPERHIVE_OTEL_HEADERS_CREDENTIAL is deliberately NOT emitted, and
|
||||||
# its absence is the security half of this design: it was the variable
|
# its absence is the security half of this design. It is the variable
|
||||||
# that put the upstream token into an agent's own settings.json. The
|
# that put the upstream token in an agent's own settings.json:
|
||||||
# delivery path it drove — an nspawn credential forwarded by
|
# host_config.rs forwards it into the container as an nspawn
|
||||||
# host_config.rs, then written to an agent-readable file by
|
# credential, and claude-settings.nix's `hive-otel-header` oneshot
|
||||||
# claude-settings.nix's `hive-otel-header` oneshot — no longer exists
|
# then writes the value into a file the agent can read. The collector
|
||||||
# anywhere; it was removed along with this variable's last consumer.
|
# holding the credential achieves nothing while the harness keeps
|
||||||
#
|
# handing out a copy — so there is exactly one holder, on the host.
|
||||||
# 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) {
|
// lib.optionalAttrs (otel.metricIntervalMs != null) {
|
||||||
HYPERHIVE_OTEL_METRIC_INTERVAL_MS = toString otel.metricIntervalMs;
|
HYPERHIVE_OTEL_METRIC_INTERVAL_MS = toString otel.metricIntervalMs;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue