diff --git a/homeConfigurations/muede/claude.nix b/homeConfigurations/muede/claude.nix new file mode 100644 index 0000000..2185c77 --- /dev/null +++ b/homeConfigurations/muede/claude.nix @@ -0,0 +1,87 @@ +{ + config, + lib, + osConfig, + pkgs, + ... +}: +let + claude-code = pkgs.unstable.claude-code; + + # Endpoint and wire protocol come straight off the hive's own OTLP config in + # nixosModules/constellation-swarm.nix, so there is one place to change them. + # Only those two are shared: the hive's `agent=` / `hive=` / `swarm=` resource + # labels are deliberately not carried over. + hiveOtel = osConfig.services.hyperhive.otel; + + # Written by the `claude-otel-user-header` oneshot in that same module, which + # copies the hive's root-only auth header into /run owned by this user. + # Absent on hosts without `my.constellation-swarm`, where the wrapper starts + # claude with no telemetry at all rather than retrying an unauthenticated + # export every interval. + headerFile = "${osConfig.my.constellation-swarm.userOtelHeaderDir}/${config.home.username}"; + + # OTEL's `host.arch` is an enum of its own spelling, not uname's. + hostArch = + { + x86_64-linux = "amd64"; + aarch64-linux = "arm64"; + } + .${pkgs.stdenv.hostPlatform.system} or pkgs.stdenv.hostPlatform.uname.processor; + + # `deployment.environment.name` is what keeps these interactive sessions + # apart from the agent fleet in the same backend, now that the hive's own + # identifying labels are gone. + resourceAttributes = lib.concatStringsSep "," [ + "service.name=claude-code" + "host.arch=${hostArch}" + "os.type=linux" + "deployment.environment.name=workstation" + ]; + + # Env claude-code reads to export telemetry. Set by the wrapper rather than + # written into ~/.claude/settings.json, because that file is self-mutating — + # /model, /config and plugin toggles all write to it, so home-manager cannot + # own it without breaking them. + otelEnv = { + CLAUDE_CODE_ENABLE_TELEMETRY = "1"; + OTEL_METRICS_EXPORTER = "otlp"; + # Interactive sessions carry far more sensitive content than hive agents, + # so prompt events and tool-decision records never leave the machine. + OTEL_LOGS_EXPORTER = "none"; + OTEL_TRACES_EXPORTER = "none"; + OTEL_EXPORTER_OTLP_PROTOCOL = hiveOtel.protocol; + OTEL_EXPORTER_OTLP_ENDPOINT = hiveOtel.endpoint; + # claude-code defaults to DELTA, which Prometheus/Mimir-family backends + # silently drop without a deltatocumulative processor. + OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = "cumulative"; + OTEL_METRICS_INCLUDE_VERSION = "1"; + } + # Follow the hive's export cadence too, so retuning it stays a one-line + # change over in constellation-swarm.nix. Null there leaves claude-code's + # own 60s default. `debug` is pointedly not mirrored: the OTEL SDK's stderr + # diagnostics would scribble over an interactive TUI. + // lib.optionalAttrs (hiveOtel.metricIntervalMs != null) { + OTEL_METRIC_EXPORT_INTERVAL = toString hiveOtel.metricIntervalMs; + }; + + exports = lib.concatStrings ( + lib.mapAttrsToList (name: value: " export ${name}=${lib.escapeShellArg value}\n") otelEnv + ); + + # Shadows claude-code's own `bin/claude`, so claude-code itself is kept out + # of home.packages (see ./default.nix) — two derivations installing the same + # binary is a home-manager collision, not a precedence win. + claude-wrapper = pkgs.writeShellScriptBin "claude" '' + hdr=${lib.escapeShellArg headerFile} + if [ -r "$hdr" ]; then + ${exports} export OTEL_EXPORTER_OTLP_HEADERS="$(cat "$hdr")" + export OTEL_RESOURCE_ATTRIBUTES=${lib.escapeShellArg resourceAttributes}",host.name=$(${pkgs.coreutils}/bin/uname -n)" + fi + + exec ${claude-code}/bin/claude "$@" + ''; +in +{ + home.packages = [ claude-wrapper ]; +} diff --git a/homeConfigurations/muede/default.nix b/homeConfigurations/muede/default.nix index c77b369..93843ac 100644 --- a/homeConfigurations/muede/default.nix +++ b/homeConfigurations/muede/default.nix @@ -2,6 +2,7 @@ { imports = [ # keep-sorted start + ./claude.nix ./editorconfig.nix ./element.nix ./fonts.nix @@ -90,7 +91,6 @@ tea telegram-desktop thunderbird - unstable.claude-code vlc wireguard-tools wirelesstools diff --git a/nixosModules/constellation-swarm.nix b/nixosModules/constellation-swarm.nix index 9919cea..0274b5a 100644 --- a/nixosModules/constellation-swarm.nix +++ b/nixosModules/constellation-swarm.nix @@ -7,11 +7,26 @@ }: let cfg = config.my.constellation-swarm; + adminUsers = [ "muede" ]; + otelCredential = "/etc/hyperhive/otel-cred"; + runtimeDirName = "claude-otel"; in { options.my.constellation-swarm = { enable = lib.mkEnableOption "hyperhive / constellation swarm"; hiveName = lib.mkOption { type = lib.types.str; }; + + userOtelHeaderDir = lib.mkOption { + type = lib.types.str; + default = "/run/${runtimeDirName}"; + readOnly = true; + description = '' + Directory holding the per-admin copies of the hive's OTLP auth header, + one 0400 file per user named after them. Read-only so the home-manager + side (homeConfigurations/muede/claude.nix) can point at it via + `osConfig` instead of repeating the path. + ''; + }; }; imports = [ hyperhive.nixosModules.default ]; @@ -39,12 +54,39 @@ in otel = { enable = true; endpoint = "https://exponentials.vibec0re.mov/otel"; - headersCredential = "/etc/hyperhive/otel-cred"; + headersCredential = otelCredential; }; c0re = { - adminUsers = ["muede"]; + inherit adminUsers; agentMemoryMax = "6G"; claudeCodePackage = pkgs.unstable.claude-code; }; }; + + # `otelCredential` is root:root 0600, so the interactive `claude` an admin + # runs as themselves cannot read it — only the hive's agent units can, via + # LoadCredential. Hand each admin their own copy so the wrapper in + # homeConfigurations/muede/claude.nix can pick it up. It lands in /run + # (tmpfs) rather than the nix store because the store is world-readable, and + # rather than /etc because the header should not outlive a reboot. + config.systemd.services.claude-otel-user-header = lib.mkIf cfg.enable { + description = "Expose the hive OTLP auth header to interactive admin users"; + wantedBy = [ "multi-user.target" ]; + unitConfig.ConditionPathExists = otelCredential; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + RuntimeDirectory = runtimeDirName; + # Traversable by everyone; the per-user files inside are the 0400 part. + RuntimeDirectoryMode = "0755"; + ExecStart = pkgs.writeShellScript "claude-otel-user-header" '' + set -eu + umask 077 + for user in ${lib.escapeShellArgs adminUsers}; do + ${pkgs.coreutils}/bin/install -m0400 -o "$user" -g root \ + ${lib.escapeShellArg otelCredential} "$RUNTIME_DIRECTORY/$user" + done + ''; + }; + }; }