Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72b2a0357b | ||
|
|
fbeff69fd7 |
4 changed files with 26 additions and 149 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`
|
/// 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).
|
||||||
|
|
@ -218,9 +182,12 @@ 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);
|
||||||
|
|
||||||
// Hive-wide secrets forwarded into the container's credential store
|
// No hive-wide secrets are forwarded into agent containers. hive-priv
|
||||||
// (currently just the OTEL auth-header). Same for every agent.
|
// still accepts a credential list (see `write_nspawn_flags`), but
|
||||||
let load_creds = hive_load_credentials();
|
// 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![
|
let mut binds: Vec<BindMount> = vec![
|
||||||
BindMount {
|
BindMount {
|
||||||
|
|
|
||||||
|
|
@ -843,7 +843,6 @@ 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.
|
||||||
|
|
@ -867,9 +866,6 @@ 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())
|
||||||
|
|
@ -881,7 +877,6 @@ 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,
|
||||||
})
|
})
|
||||||
|
|
@ -1209,13 +1204,6 @@ 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.
|
||||||
|
|
@ -2251,7 +2239,6 @@ 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");
|
||||||
}
|
}
|
||||||
|
|
@ -2261,17 +2248,12 @@ 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();
|
||||||
|
|
||||||
|
|
@ -2292,10 +2274,6 @@ 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(
|
||||||
|
|
@ -2303,10 +2281,6 @@ 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,14 +71,15 @@ 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. The
|
# so telemetry parity is declarative, with no launch wrapper.
|
||||||
# auth header (`otel.headersCredential`) is deliberately NOT included
|
#
|
||||||
# here: it's a secret and this file lives in the world-readable nix
|
# There is no auth header here, and no mechanism to add one. An agent
|
||||||
# store. It's injected at *runtime* into the agent's `0600`
|
# exports to the hive's own collector, which is the only thing holding
|
||||||
# `~/.claude/settings.json` by the `hive-otel-header` oneshot below
|
# a credential for anything upstream; nothing an agent can read is a
|
||||||
# (claude merges the `env` from the user settings on top of these
|
# secret to the swarm. An earlier revision forwarded the operator's
|
||||||
# managed ones), so the token is read from disk at start and never
|
# upstream token into this container and merged it into the agent's own
|
||||||
# touches the store.
|
# `~/.claude/settings.json` — which handed every agent the hive's
|
||||||
|
# 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.
|
||||||
|
|
@ -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 {
|
extraResourceAttributes = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = "";
|
default = "";
|
||||||
|
|
@ -392,49 +367,6 @@ 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,13 +99,17 @@ 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 is the variable
|
# its absence is the security half of this design: it was the variable
|
||||||
# that put the upstream token in an agent's own settings.json:
|
# that put the upstream token into an agent's own settings.json. The
|
||||||
# host_config.rs forwards it into the container as an nspawn
|
# delivery path it drove — an nspawn credential forwarded by
|
||||||
# credential, and claude-settings.nix's `hive-otel-header` oneshot
|
# host_config.rs, then written to an agent-readable file by
|
||||||
# then writes the value into a file the agent can read. The collector
|
# claude-settings.nix's `hive-otel-header` oneshot — no longer exists
|
||||||
# holding the credential achieves nothing while the harness keeps
|
# anywhere; it was removed along with this variable's last consumer.
|
||||||
# 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