diff --git a/docs/turn-loop/config.md b/docs/turn-loop/config.md index d7e2b15d..113feb32 100644 --- a/docs/turn-loop/config.md +++ b/docs/turn-loop/config.md @@ -239,3 +239,61 @@ left untouched. Set to `false` for agents that parse cargo's JSON output programmatically and do not pass `--message-format json` themselves. +## API-key backend (`useApiKey` / `backendEnvironmentFile`) + +```nix +hyperhive.useApiKey = true; # default: false +hyperhive.backendEnvironmentFile = + "/agents/myagent/state/openrouter.env"; # default: null +hyperhive.model = "anthropic/claude-3.5-sonnet"; # provider-specific model string +``` + +Runs this agent's `claude` against an API-key backend (e.g. OpenRouter) +instead of a Claude subscription via OAuth. Two options, paired — each +is a no-op without the other: + +- **`useApiKey`** tells the harness itself not to wait for a Claude OAuth + session: at boot, `LoginState::from_dir` reports `Online` without + checking `~/.claude/` (`hive_agent::login::using_api_key`, reads + `HIVE_USE_API_KEY`), and the fact is stamped into the consolidated + harness state file so the operator dashboard also stops reading this + agent's empty `~/.claude/` as "needs login". An api-key agent that hits + a real 401 (the key itself is bad) still surfaces `needs_login` — only + the boot-time "have I ever logged in" check is bypassed, not the + auth-failure path. +- **`backendEnvironmentFile`** points at an operator-managed file + (outside the nix store, one `KEY=value` per line, systemd + `EnvironmentFile` syntax) supplying the credentials `claude` itself + reads from the environment — typically `ANTHROPIC_API_KEY` and + `ANTHROPIC_BASE_URL`. Loaded as an *optional* `EnvironmentFile` + (leading `-`), so setting the option before the file exists doesn't + strand the harness at boot. + +Provision the file once, out of band (never through nix — an API key in +the store is world-readable and travels with the flake closure): + +```sh +# on the host, once per agent that should use an api-key backend +sudo install -m 0600 -o root /dev/stdin \ + /var/lib/hyperhive/agents//state/openrouter.env < Self { - if has_session(dir) { + // API-key backends (OpenRouter etc.) never populate `~/.claude/` — + // there is no OAuth flow to complete, `claude` reads + // `ANTHROPIC_BASE_URL`/`ANTHROPIC_API_KEY` from the environment + // instead. Checked first: an api-key agent with a stale or absent + // credentials dir must report `Online`, not park the turn loop + // waiting for a login that will never happen. + if using_api_key() || has_session(dir) { Self::Online } else { Self::NeedsLogin @@ -111,6 +117,16 @@ impl LoginState { } } +/// Whether this agent is configured to authenticate to its backend with an +/// API key rather than a Claude OAuth session — set by +/// `hyperhive.useApiKey` via the `HIVE_USE_API_KEY` env var +/// (`nix/agent-modules/agent-service.nix`). Read fresh on every call rather +/// than cached: it's an env var, not a value worth a `OnceLock` for. +#[must_use] +pub fn using_api_key() -> bool { + std::env::var("HIVE_USE_API_KEY").is_ok_and(|v| v == "1") +} + /// Baseline for [`has_fresh_credentials`] when the caller has no specific /// prior-failure instant to compare against (the cold-boot call site, where /// `has_session` already established no credential file exists yet — so diff --git a/hive-agent/src/main.rs b/hive-agent/src/main.rs index d4e8aa94..b6a8028e 100644 --- a/hive-agent/src/main.rs +++ b/hive-agent/src/main.rs @@ -559,6 +559,10 @@ async fn serve_main(socket: &Path, poll_ms: u64) -> Result<()> { let claude_dir = login::default_dir(); let initial = LoginState::from_dir(&claude_dir); tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "harness boot"); + // Config fact, stamped once — see `harness_state::write_api_key_mode`'s + // doc for why hive-c0re needs this to stop reporting `needs_login` for + // an agent whose `~/.claude/` is empty by design. + harness_state::write_api_key_mode(login::using_api_key()); let login_state = Arc::new(Mutex::new(initial)); let bus = Bus::new(); // Set by the web UI's `/api/cancel` on a successful SIGINT, read-and- diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index 445ccaac..ef7d5459 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -130,9 +130,23 @@ pub async fn build_all(hive: &crate::coordinator::HiveEnv) -> Vec // (boot-time / fresh container) OR the harness wrote the auth-failed // sentinel because a turn hit 401. Cleared for stopped containers — // stale sentinel state is not meaningful when the harness isn't up. + // + // The first half doesn't apply to an api-key agent + // (`hyperhive.useApiKey`, stamped as `api_key_mode` in the + // consolidated state file by `hive_agent::harness_state::write_api_key_mode`): + // its `~/.claude/` is empty by design (no OAuth flow to complete), + // so an empty dir there means nothing — only the auth-failed + // sentinel (a real 401, meaning the configured key itself is bad) + // still counts. + // + // One `read_harness_flags` call for both fields, not a wrapper per + // field: this is the only call site that needs more than one, and + // a wrapper each would read the file twice per agent per sweep for + // no reason. + let (_, needs_login_sentinel, is_api_key) = read_harness_flags(&logical); let needs_login = running - && (!claude_has_session(&Coordinator::agent_claude_dir(&logical)) - || auth_failed_sentinel(&logical)); + && ((!is_api_key && !claude_has_session(&Coordinator::agent_claude_dir(&logical))) + || needs_login_sentinel); // Read the active model from the harness state file. Only surfaced // when the container is running — stale model info from a stopped // agent is misleading (the model may change on next boot). @@ -180,11 +194,14 @@ pub fn claude_has_session(dir: &Path) -> bool { .any(|e| e.file_type().is_ok_and(|t| t.is_file())) } -/// Read `rate_limited` + `needs_login` (auth-failed sentinel) from -/// the consolidated `hyperhive-harness.json`. Falls back to the legacy -/// individual sentinel files written by older harness builds so in-place -/// upgrades don't lose state during the transition window. -fn read_harness_flags(name: &hive_types::Ident) -> (bool, bool) { +/// Read `rate_limited` + `needs_login` (auth-failed sentinel) + +/// `api_key_mode` from the consolidated `hyperhive-harness.json`. Falls +/// back to the legacy individual sentinel files written by older harness +/// builds so in-place upgrades don't lose state during the transition +/// window — `api_key_mode` has no legacy equivalent (it postdates the +/// consolidated file), so that fallback just says `false`, the correct +/// answer for any harness build old enough to have never written it. +fn read_harness_flags(name: &hive_types::Ident) -> (bool, bool, bool) { let dir = Coordinator::agent_notes_dir(name); if let Ok(raw) = std::fs::read_to_string(dir.join("hyperhive-harness.json")) && let Ok(v) = serde_json::from_str::(&raw) @@ -197,16 +214,16 @@ fn read_harness_flags(name: &hive_types::Ident) -> (bool, bool) { .get("needs_login") .and_then(serde_json::Value::as_bool) .unwrap_or(false); - return (rl, nl); + let akm = v + .get("api_key_mode") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + return (rl, nl, akm); } // Legacy fallback: presence of individual sentinel files. let rate_limited = dir.join("hyperhive-rate-limited").exists(); let needs_login = dir.join("hyperhive-needs-login").exists(); - (rate_limited, needs_login) -} - -fn auth_failed_sentinel(name: &hive_types::Ident) -> bool { - read_harness_flags(name).1 + (rate_limited, needs_login, false) } /// Read the agent's free-text status and the Unix timestamp when it was last set diff --git a/nix/agent-modules/agent-service.nix b/nix/agent-modules/agent-service.nix index 21671fb5..a4494748 100644 --- a/nix/agent-modules/agent-service.nix +++ b/nix/agent-modules/agent-service.nix @@ -106,6 +106,60 @@ in ''; }; + options.hyperhive.useApiKey = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Authenticate this agent's `claude` invocations with an API key + (`ANTHROPIC_API_KEY`/`ANTHROPIC_BASE_URL`, e.g. OpenRouter) rather + than a Claude OAuth session. Sets `HIVE_USE_API_KEY=1`, which the + harness reads (`hive_agent::login::using_api_key`) to report + `LoginState::Online` at boot without checking `~/.claude/` — an + api-key agent has no OAuth session to wait for, so it must never + park the turn loop expecting one. Also stamped into the consolidated + harness state file so hive-c0re's dashboard stops reading an empty + `~/.claude/` as "needs login" for this agent (see + `hive_c0re::container_view`'s `needs_login` computation). + + Set this AND `hyperhive.backendEnvironmentFile` together — this + option changes what the harness believes about its own login state, + the other actually supplies the credentials `claude` reads. Neither + is useful alone. + ''; + }; + + options.hyperhive.backendEnvironmentFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "/agents/myagent/state/openrouter.env"; + description = '' + Path (outside the nix store) to a systemd `EnvironmentFile` loaded + by the harness service — the mechanism for supplying + `ANTHROPIC_API_KEY`/`ANTHROPIC_BASE_URL` (or any other backend + credential `claude`/the harness reads from the environment) without + baking a secret into the nix store. + + The file must use systemd `EnvironmentFile` syntax: one `KEY=value` + pair per line, no `export`, no shell quoting needed for simple + values. Example contents: + + ``` + ANTHROPIC_API_KEY=sk-or-v1-... + ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1 + ``` + + Place the file inside the agent's bind-mounted state dir (e.g. + `/agents//state/openrouter.env`) so it survives container + rebuilds; permissions should be `0600`, owned by the agent's unix + user. Loaded with a leading `-` (optional `EnvironmentFile`), so a + path that doesn't exist yet — an operator setting this option + before creating the file, or a fresh host rebuild before state is + restored — makes systemd skip it rather than refuse to start the + harness. See `hyperhive.useApiKey`'s doc for the option this one is + paired with. + ''; + }; + options.hyperhive.extraWebProxies = lib.mkOption { type = lib.types.attrsOf lib.types.str; default = { }; @@ -231,6 +285,11 @@ in # reverse-proxies. See `hyperhive.extraWebProxies` option # and `web_ui/proxy.rs::extra_proxy_service`. HIVE_EXTRA_WEB_PROXIES = builtins.toJSON config.hyperhive.extraWebProxies; + } + // lib.optionalAttrs config.hyperhive.useApiKey { + # Tells the harness not to wait for a Claude OAuth session — see + # `hyperhive.useApiKey`'s own description for the full mechanism. + HIVE_USE_API_KEY = "1"; }; serviceConfig = { ExecStart = "${config.hyperhive.packages.hive-agent}/bin/${binary}"; @@ -246,6 +305,11 @@ in RuntimeDirectory = "hive-config"; User = userName; Group = userName; + } + // lib.optionalAttrs (config.hyperhive.backendEnvironmentFile != null) { + # See `hyperhive.backendEnvironmentFile`'s own description for + # the file shape and the leading-`-` rationale. + EnvironmentFile = "-${config.hyperhive.backendEnvironmentFile}"; }; }; };