Watch
0
0
Fork
You've already forked hyperhive
0

hive-forge: require HIVE_FORGE_URL, give agent login shells the forge env

This commit is contained in:
atlas 2026-11-03 00:38:47 +01:00 • committed by mara
commit 4440d8fb29
4 changed files with 171 additions and 87 deletions

View file

@ -7,7 +7,7 @@ consistent error handling, exit codes, and JSON shapes. This is the
**only supported path** to the forge from inside an agent container;
never `curl` it directly.
Reads credentials from the environment (`HIVE_FORGE_URL`,
Reads credentials from the environment (`HIVE_FORGE_URL`, required — no default,
`HYPERHIVE_STATE_DIR`); `-f/--forge <label>` retargets a
swarm-UI-linked external forge account instead. The active repo
resolves `-r/--repo` > the `origin` remote of the cwd's git checkout >

View file

@ -17,9 +17,6 @@ use reqwest::header::{
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Default Forgejo URL when `HIVE_FORGE_URL` is unset.
const DEFAULT_URL: &str = "http://localhost:3000";
/// Bound on reaching the forge.
const HTTP_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
/// Whole-request budget for a small JSON call (an `/api/v1` GET, or the
@ -395,9 +392,22 @@ pub fn index(n: u64) -> Result<i64> {
i64::try_from(n).with_context(|| format!("number {n} out of range"))
}
/// The internal forge's base URL from the `HIVE_FORGE_URL` value, which has
/// no default: inside an agent container `localhost` is the agent itself.
fn forge_url(value: Option<String>) -> Result<String> {
match value {
Some(url) if !url.trim().is_empty() => Ok(url),
_ => bail!(
"hive-forge: HIVE_FORGE_URL is unset or empty — the forge URL comes from the \
hive config (services.hyperhive.swarm.forge.domain) and is set in every \
agent container; export it, or use -f/--forge <label> for a linked external forge"
),
}
}
/// Resolve the `(base_url, token)` pair the client authenticates with.
/// `None` (the default) resolves the internal forge exactly as before:
/// `HIVE_FORGE_URL` (default [`DEFAULT_URL`]) + `read_token()`.
/// `HIVE_FORGE_URL` (required, see [`forge_url`]) + `read_token()`.
/// `Some(label)` (from `-f/--forge <label>`) instead resolves an
/// external forge account linked in the swarm UI: the token comes from
/// `${HYPERHIVE_STATE_DIR}/forge-<label>-token` and the base URL from
@ -411,7 +421,7 @@ pub fn index(n: u64) -> Result<i64> {
/// nonsense/traversing path and surfacing a confusing file error later.
pub(crate) fn resolve_credentials(forge_label: Option<&str>) -> Result<(String, String)> {
let Some(label) = forge_label else {
let base = std::env::var("HIVE_FORGE_URL").unwrap_or_else(|_| DEFAULT_URL.to_owned());
let base = forge_url(std::env::var("HIVE_FORGE_URL").ok())?;
let token = read_token().context("read forge-token")?;
return Ok((base, token));
};
@ -595,7 +605,26 @@ fn check_status(resp: Response, op: &str) -> Result<Response> {
#[cfg(test)]
mod tests {
use super::{WebStatusError, disposition_filename, index, owner_repo_from_git_url, split_repo};
use super::{
WebStatusError, disposition_filename, forge_url, index, owner_repo_from_git_url, split_repo,
};
#[test]
fn forge_url_is_the_value_as_is() {
assert_eq!(
forge_url(Some("http://forge.t.local".to_owned())).unwrap(),
"http://forge.t.local"
);
}
#[test]
fn forge_url_unset_or_blank_names_the_variable() {
for v in [None, Some(String::new()), Some(" ".to_owned())] {
let err = forge_url(v).unwrap_err().to_string();
assert!(err.contains("HIVE_FORGE_URL is unset or empty"), "{err}");
assert!(err.contains("hive config"), "{err}");
}
}
#[test]
fn disposition_filename_reads_forgejos_real_header() {

View file

@ -1,7 +1,7 @@
//! `hive-forge` — typed CLI wrapper around the in-cluster Forgejo's
//! REST API. Reads credentials from the environment:
//!
//! `HIVE_FORGE_URL` — base URL, e.g. `http://localhost:3000`
//! `HIVE_FORGE_URL` — base URL (required), e.g. `http://forge.example.org`
//! `HYPERHIVE_STATE_DIR` — state dir; `forge-token` lives here
//! `HIVE_FORGE_DEADLINE` — optional; whole seconds one invocation may run
//! before it exits with code 75 (default 900)

View file

@ -0,0 +1,57 @@
//! `HIVE_FORGE_URL` has no default: unset or empty is a clear error naming
//! the variable, and a set value is the URL the client dials.
use std::process::{Command, Output};
const UNSET_MSG: &str = "HIVE_FORGE_URL is unset or empty";
fn scratch_state_dir(tag: &str) -> std::path::PathBuf {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let dir = std::env::temp_dir().join(format!("hive-forge-url-required-{tag}-{ts}"));
std::fs::create_dir_all(&dir).expect("create scratch state dir");
std::fs::write(dir.join("forge-token"), "test-token\n").expect("seed forge-token");
dir
}
fn run(tag: &str, url: Option<&str>) -> Output {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_hive-forge"));
cmd.args(["-r", "o/r", "pr", "edit", "7", "--title", "t"])
.env("HYPERHIVE_STATE_DIR", scratch_state_dir(tag))
.env_remove("HIVE_FORGE_TOKEN_FILE")
.env_remove("HIVE_LABEL");
match url {
Some(u) => cmd.env("HIVE_FORGE_URL", u),
None => cmd.env_remove("HIVE_FORGE_URL"),
};
cmd.output().expect("run hive-forge")
}
#[test]
fn unset_url_is_a_clear_error() {
let out = run("unset", None);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success());
assert!(stderr.contains(UNSET_MSG), "stderr: {stderr}");
assert!(!stderr.contains("localhost"), "stderr: {stderr}");
}
#[test]
fn empty_url_is_a_clear_error() {
let out = run("empty", Some(""));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success());
assert!(stderr.contains(UNSET_MSG), "stderr: {stderr}");
}
/// Presence control: a set value passes the URL check and the client dials it
/// (nothing listens on port 1, so the failure is a connection error).
#[test]
fn set_url_is_used_as_is() {
let out = run("set", Some("http://127.0.0.1:1"));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success());
assert!(!stderr.contains(UNSET_MSG), "stderr: {stderr}");
assert!(stderr.contains("127.0.0.1"), "stderr: {stderr}");
}