Compare commits

..
29 changed files with 221 additions and 879 deletions

24
Cargo.lock generated
View file

@ -1367,7 +1367,6 @@ dependencies = [
"hive-sh4re",
"libc",
"listenfd",
"problem_details",
"reqwest",
"rusqlite",
"serde",
@ -1509,16 +1508,6 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "http-serde"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f056c8559e3757392c8d091e796416e4649d8e49e88b8d76df6c002f05027fd"
dependencies = [
"http",
"serde",
]
[[package]]
name = "httparse"
version = "1.10.1"
@ -2638,19 +2627,6 @@ dependencies = [
"syn",
]
[[package]]
name = "problem_details"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d50e8b46a2f32e61ae82888734e24627ea0f8c9bc7c5fc8d0c3e0eb7ed0ff5ab"
dependencies = [
"axum",
"http",
"http-serde",
"serde",
"serde_json",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"

View file

@ -70,46 +70,9 @@ Background task spawned once per harness boot. Polls
`GET /api/v1/notifications?all=false` every 30 seconds (Forgejo's
unread-only filter), formats each notification as a broker
`Wake { from: "forge" }` message, and delivers it to the agent's own
inbox so claude's normal turn loop picks it up.
### Mark-read on read, not on delivery
Delivered conversation threads are deliberately left **unread** in
forge. The hive-forge read-before-comment guard keys off forge's own
notification read-state (`GET /notifications?all=false`) to refuse a
comment when a thread has unread activity by others — so the agent
reading the thread via the CLI (`hive-forge comments` / `view`,
which `PATCH`es `/notifications/threads/{id}`) is the single
mark-read point. If `forge_notify` marked threads read on delivery,
that unread signal would be consumed before the agent acts and the
guard could never fire.
Because a delivered thread stays unread, it reappears in every
`?all=false` poll. An in-memory **delivery-dedupe cursor** (thread
id → last-delivered `updated_at`, held in the poll loop) stops the
same version from re-firing a wake; a new comment bumps `updated_at`
so genuinely new activity re-delivers. The cursor is pure anti-spam,
not a correctness oracle: lost on harness restart it just
re-delivers currently-unread threads once (harmless — `recv`
tolerates redelivery), so it carries none of the persisted-mirror
fragility that ruled out an on-disk seen-cursor. Each poll prunes
the cursor to the threads still in the unread set. A failed wake
delivery is left unread **and** out of the cursor, so it resurfaces
next tick.
Two paths still mark-read directly (no read-before-comment value):
self-echo notifications (the agent's own writes, see below) and
`HIVE_FORGE_NOTIFY_SKIP_REASONS` drop-listed reasons.
> Note: the unread list grows for threads the agent never reads via
> the CLI, since nothing else trims it. This does not affect guard
> correctness (the guard does a per-thread, repo-scoped query) nor
> wake delivery (Forgejo orders unread newest-first, so new activity
> always lands in the polled window). Bounding the unread list via a
> reason-independent firehose-reduction is a separate follow-up — the
> existing auto-unsubscribe below is gated on a `reason` field that
> this Forgejo's notification API does not actually emit, so it never
> fires today.
inbox so claude's normal turn loop picks it up. Mark-read happens
after successful delivery so a failed-delivery notification
resurfaces on the next tick.
### Activation gates (graceful no-ops)

View file

@ -40,35 +40,25 @@ and `qualify()` / `qualified_label()` semantics.
```nix
services.hyperhive.swarm.peers = {
"lab.example.com" = { }; # CA-trusted (Let's Encrypt etc.)
"edge.corp" = { certFingerprint = "sha256:…"; }; # self-signed TLS, c0re peer checks only
"mesh.internal" = { caCert = ./mesh-ca.pem; }; # self-signed, trusted for matrix federation
"edge.corp" = { certFingerprint = "sha256:…"; }; # self-signed TLS
};
```
The attrset key is the peer's DNS domain. Two independent, optional
trust knobs — pick by what you need to trust:
The attrset key is the peer's DNS domain. `certFingerprint` is
optional:
- **`certFingerprint`** (`"sha256:…"`) — pin the peer's TLS *leaf*
fingerprint. Scopes **only** to hive-c0re's own peer HTTPS checks
(the P33RS dashboard links + agent peer discovery below). It is
**not** consulted by matrix federation — tuwunel validates a peer's
federation certificate against the system CA bundle independently
(see *Matrix federation* below), so a fingerprint pin does nothing
for a self-signed matrix cert.
- **`caCert`** (path to the peer's root CA PEM) — embeds that CA (at
build time, into the nix store — no runtime file on the host) and
trusts it **everywhere the hive's own internal CA is**: it rides
alongside `hive-ca.pem` in every agent's
`security.pki.certificateFiles` (via the meta-flake renderer) **and**
in the matrix container's trust bundle, so tuwunel validates the
peer's *federation* TLS when it chains to that CA. Trust stays
**inside the hive** (agents + the matrix container), never the host
system trust store. **This is the knob that unblocks federation with
a self-signed peer hive** — use it instead of `certFingerprint` when
you control the peer's CA. (It does not affect hive-c0re's own peer
HTTPS checks — those stay on `certFingerprint` / the system bundle.)
- **Both omitted** — the stock system CA bundle validates the peer
(correct for Let's Encrypt / any publicly-trusted peer).
- **Omitted / null** — the system CA bundle validates the peer's TLS
cert. Correct for peers with Let's Encrypt or any standard CA cert.
- **Set** (`"sha256:…"`) — pin a specific cert fingerprint. Use this
for peers whose self-signed TLS cert doesn't chain to a CA your
host trusts.
`certFingerprint` scopes **only** to hive-c0re's own peer HTTPS checks
(the P33RS dashboard links and agent peer discovery below). It is
**not** consulted by matrix federation — tuwunel validates a peer's
federation certificate against the system CA bundle independently (see
*Matrix federation* below), so pinning a fingerprint here does nothing
for a self-signed matrix gateway cert.
### Fingerprint format
@ -124,13 +114,13 @@ environment and forwarded to agent containers.
3. **Matrix federation** — when `matrix.enable` is on, tuwunel
federates with the peer's matrix server (discovered via the peer's
`.well-known/matrix/server` delegation, which the gateway serves).
Federation validates the peer's TLS certificate against the matrix
**container's** trust bundle — independently of `certFingerprint`,
which it never consults. A self-signed gateway certificate therefore
won't federate unless the peer's root CA is trusted: set `caCert`
above (embeds the peer CA into the matrix container's trust bundle),
or give the peers CA-issued certs (ACME). See `docs/matrix.md` for
federation firewall + TLS requirements.
Federation validates the peer's TLS certificate against the
**system CA bundle** — independently of `certFingerprint`, which it
never consults. A self-signed gateway certificate therefore won't
federate even with a fingerprint pinned above: the peers need
CA-issued certs (ACME) or a shared private CA trusted on both
gateway hosts. See `docs/matrix.md` for federation firewall + TLS
requirements.
## Bilateral setup

View file

@ -12,25 +12,7 @@ as a proper Rust binary). Use it instead of ad-hoc curl pipelines.
## Verbs
**Kind-namespaced commands (preferred):** issue/PR operations are grouped
under `issue` and `pr` parent commands — `hive-forge pr close 42`,
`hive-forge issue create --title …`, `hive-forge pr status --pr 42`. The
`pr <verb>` / `issue <verb>` forms validate the number's kind (e.g. `pr close`
refuses an issue number, which the old generic `close` couldn't). Run
`hive-forge pr --help` / `hive-forge issue --help` for the full subcommand
list (show/create/edit/status/merge/reviews/commits/diff/view/comment/
comments/close/labels/assign/timeline as applicable).
The flat forms below (`close 42`, `pr-create …`, `pr-status …`, …) still work
as **hidden back-compat aliases** during the transition and are dropped from
`--help`; prefer the namespaced form. They'll be removed in a later sweep.
```bash
hive-forge pr close 42 # close a PR (kind-validated)
hive-forge issue close 42 # close an issue (kind-validated)
hive-forge pr status --pr 42 # PR health (mergeable / CI / reviews)
hive-forge issue create --title "..." --body "..."
# --- flat aliases below remain valid (hidden) ---
hive-forge view 42 # title + body + comments
hive-forge comments 42 # list all comments (human-readable)
hive-forge comments 42 --tail 10 # last 10 comments (count-then-page; efficient on long threads)

View file

@ -231,37 +231,20 @@ read from `GET /api/matrix-accounts?agent=<name>` →
`{ accounts: [ { name, homeserver, token_present, live, user_id } ], as_of_unix }`.
`token_present` is whether a token is **stored**; `live`, `homeserver`,
and `user_id` are backfilled from the matrix daemon's
`matrix-accounts.json` snapshot — a host-visible file the daemon
**force-rewrites every ~30s** (a heartbeat), so `as_of_unix` (the
snapshot mtime) advances while the daemon is alive and a *stalled* value
genuinely means "stopped publishing", not just "old snapshot". An account
with a token but absent from the snapshot reports `live: false`.
The status dot renders these states:
- **green**`live` and the container is running: online.
- **dim green**`live` but `as_of_unix` hasn't advanced in > ~90s (3
missed heartbeats) while the container is *not* down: the daemon stopped
publishing, so the snapshot's `live` is no longer trustworthy (likely
dead/wedged). Labelled "online · no heartbeat".
- **amber**`live` but the container is **down** (a stopped container
⟹ a dead daemon, so the snapshot is stale); also the `token_present &&
!live` "provisioned but offline" case.
- **grey** — no token (not provisioned).
The container-down cross-reference (`/api/state`) takes precedence over
the age check. `as_of_unix` is tooltipped ("live as of N ago") throughout
so freshness is always legible. When `live` is absent (an older backend
without the snapshot) the dot falls back to a token-present rendering.
`matrix-accounts.json` snapshot — a host-visible file the daemon writes at
startup after its sessions restore (an account with a token but absent
from the snapshot reports `live: false`). `as_of_unix` is the snapshot's
mtime (null when absent), so the dot can show "live as of N ago". The
snapshot is rewritten each daemon (re)start, so an old `as_of_unix` is
ambiguous (stable uptime vs dead daemon) — the live-status dot rendering
(3-state + snapshot-age tooltip, cross-referencing container-running
state) is the dashboard-side follow-up.
The provision form (account name, homeserver, login method) posts
`POST /api/matrix-account-login` (`x-www-form-urlencoded`, operator-auth):
`POST /matrix-account-login` (`x-www-form-urlencoded`, operator-auth):
fields `agent, account, homeserver, mode=password|token, user_id?,
password?, token?` → `200 { ok, user_id }` on success. Failures come back
as RFC 9457 `application/problem+json` (`{ type, title, status, detail }`)
with the human-readable message in `detail` and the status code reflecting
the cause (400 for a validation error, 500 for a login / `whoami` /
internal failure); the page reads `detail` for display. The host coordinator performs the login
password?, token?` → `2xx { ok, user_id }` on success or
`4xx { error }` on failure. The host coordinator performs the login
(password) or validates the token (`whoami`) and writes the bearer to
the agent's `matrixAccounts.<account>.tokenFile` via the same
privileged write path as the hive-internal `matrix-token`; the token is

View file

@ -1,13 +1,7 @@
//! Background Forgejo notification poller. Polls
//! `GET /notifications?all=false` every 30s, formats each unread
//! notification as a broker `Wake { from: "forge" }` message, and
//! delivers it to the agent's inbox. Delivered threads are deliberately
//! left UNREAD in forge — the hive-forge read-before-comment guard keys
//! off forge's own unread-state, and the agent reading the thread via the
//! CLI is what marks it read. An in-memory delivery-dedupe cursor
//! (thread id → last-delivered `updated_at`) stops the still-unread
//! notification from re-firing a wake every poll; self-echo and
//! drop-listed notifications are still marked read directly.
//! marks it read after delivery so failures resurface next tick.
//!
//! Activation gates, self-notification filtering, body excerpt +
//! truncation + heading escape, wrapper formats (comment / review /
@ -15,7 +9,7 @@
//! reason drop-list, and auto-unsubscribe on broad watches all live
//! in [`docs/forge.md::Notification poller`](../../../docs/forge.md).
use std::collections::{HashMap, HashSet};
use std::collections::HashSet;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::time::Duration;
@ -145,19 +139,6 @@ pub async fn run(socket: PathBuf) {
// across polls so we don't hammer DELETE on every cycle.
let mut unsubbed_repos: HashSet<String> = HashSet::new();
// Delivery-dedupe cursor: notification thread id -> the `updated_at`
// of the version we last woke the agent for. We no longer mark a
// thread read on delivery (that would consume the unread signal the
// hive-forge read-before-comment guard relies on), so this in-memory
// map is what stops the same unread notification from re-firing a
// wake every poll. A new comment bumps `updated_at`, so the thread
// re-delivers. This is purely anti-spam, NOT a correctness oracle:
// lost on harness restart it just re-delivers currently-unread
// threads once (harmless — recv tolerates redelivery), so it carries
// none of the persisted-mirror fragility that sank the on-disk
// cursor approach.
let mut delivered: HashMap<u64, String> = HashMap::new();
loop {
interval.tick().await;
poll_once(
@ -167,7 +148,6 @@ pub async fn run(socket: PathBuf) {
&socket,
keep_subscriptions,
&mut unsubbed_repos,
&mut delivered,
&own_login,
&skip_reasons,
)
@ -743,9 +723,9 @@ fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
#[allow(
clippy::too_many_arguments,
reason = "the notification poll's config + mutable subscription / \
delivery-dedupe state, wired once from the poll loop; a struct \
would just move the same fields one level out"
reason = "the notification poll's config + mutable subscription state, \
wired once from the poll loop; a struct would just move the \
same fields one level out"
)]
#[allow(
clippy::too_many_lines,
@ -760,7 +740,6 @@ async fn poll_once(
socket: &Path,
keep_subscriptions: bool,
unsubbed_repos: &mut HashSet<String>,
delivered: &mut HashMap<u64, String>,
own_login: &str,
skip_reasons: &[String],
) {
@ -805,17 +784,6 @@ async fn poll_once(
continue;
};
// Delivery-dedupe: we no longer mark threads read on delivery, so
// an unread notification reappears in every `?all=false` poll.
// Skip it silently unless its `updated_at` advanced since the
// version we last delivered a wake for (i.e. genuinely new
// activity). See the `delivered` cursor note in `run`.
let updated_at = notif["updated_at"].as_str().unwrap_or("").to_owned();
if !should_deliver(delivered, id, &updated_at) {
debug!(%id, "forge_notify: skipping (already delivered this version)");
continue;
}
// Reason drop-list: suppress noisy reasons; null/unknown pass
// through so directed signals stay deliverable (see
// `docs/forge.md::Reason drop-list`).
@ -841,21 +809,12 @@ async fn poll_once(
body,
transient: false,
};
let deliver_result = crate::client::request::<_, hive_sh4re::Response>(socket, &req)
let delivered = crate::client::request::<_, hive_sh4re::Response>(socket, &req)
.await
.map(|_| ());
match deliver_result {
match delivered {
Ok(()) => {
debug!(%id, "forge_notify: delivered");
// Record the delivered version in the dedupe cursor INSTEAD
// of marking the thread read. Leaving it unread is
// deliberate: the hive-forge read-before-comment guard keys
// off forge's own unread-state, and the agent reading the
// thread via the CLI is what marks it read. Recorded only
// here in the Ok arm — a failed delivery hits the Err arm
// and `continue`s without recording, so it re-delivers next
// tick.
delivered.insert(id, updated_at);
}
Err(e) => {
warn!(%id, error = ?e, "forge_notify: deliver failed — leaving unread");
@ -863,6 +822,10 @@ async fn poll_once(
}
}
// Mark as read only after successful delivery so a failed-delivery
// notification resurfaces on the next poll tick.
mark_read(client, forge_url, token, id).await;
// Auto-unsubscribe from broad repo watches after delivering a
// `subscribed` notification. Gated by HIVE_FORGE_KEEP_SUBSCRIPTIONS
// for triage / firehose agents (see
@ -893,34 +856,12 @@ async fn poll_once(
}
}
}
// Prune the dedupe cursor down to the threads still present in this
// poll's unread set. Once the agent reads a thread (marking it read
// via the CLI) it drops out of `?all=false`, so its cursor entry is
// dead weight; dropping it bounds the map to the current unread size.
// If such a thread later goes unread again it carries a fresh
// `updated_at` and re-delivers correctly.
let current_ids: HashSet<u64> = notifications
.iter()
.filter_map(|n| n["id"].as_u64())
.collect();
delivered.retain(|id, _| current_ids.contains(id));
}
/// Whether a notification should be delivered as a wake given the
/// delivery-dedupe cursor. Delivers when the thread has never been
/// delivered, or when its `updated_at` advanced since the last delivered
/// version (genuinely new activity). Pure for unit testing.
fn should_deliver(delivered: &HashMap<u64, String>, id: u64, updated_at: &str) -> bool {
delivered.get(&id).is_none_or(|seen| seen != updated_at)
}
/// Mark a notification thread as read. Best-effort — logs on failure but
/// does not abort the poll loop. Called only on the self-echo and
/// drop-listed paths (the agent's own writes / explicitly-suppressed
/// reasons) — delivered threads are deliberately left unread for the
/// read-before-comment guard, and a failed delivery is left unread + out
/// of the dedupe cursor so it resurfaces on the next poll tick.
/// does not abort the poll loop. A notification left unread will resurface
/// on the next poll tick (desirable for delivery failures; for self-echo
/// silencing we call this without prior delivery).
async fn mark_read(client: &reqwest::Client, forge_url: &str, token: &str, id: u64) {
let mark_url = format!("{forge_url}/api/v1/notifications/threads/{id}");
match client
@ -945,38 +886,6 @@ async fn mark_read(client: &reqwest::Client, forge_url: &str, token: &str, id: u
mod tests {
use super::*;
#[test]
fn should_deliver_when_thread_never_seen() {
let delivered = HashMap::new();
assert!(should_deliver(&delivered, 42, "2026-06-22T16:00:00Z"));
}
#[test]
fn should_not_deliver_same_version_again() {
// The dedupe case: an unread thread reappears every poll with the
// same `updated_at` — must not re-fire a wake.
let mut delivered = HashMap::new();
delivered.insert(42, "2026-06-22T16:00:00Z".to_owned());
assert!(!should_deliver(&delivered, 42, "2026-06-22T16:00:00Z"));
}
#[test]
fn should_deliver_when_updated_at_advanced() {
// A new comment bumps `updated_at` → genuinely new activity →
// deliver again.
let mut delivered = HashMap::new();
delivered.insert(42, "2026-06-22T16:00:00Z".to_owned());
assert!(should_deliver(&delivered, 42, "2026-06-22T16:05:00Z"));
}
#[test]
fn should_deliver_tracks_per_thread() {
// A cursor for one thread says nothing about another.
let mut delivered = HashMap::new();
delivered.insert(42, "2026-06-22T16:00:00Z".to_owned());
assert!(should_deliver(&delivered, 99, "2026-06-22T16:00:00Z"));
}
#[test]
fn escape_md_headings_escapes_top_level_atx() {
// Argus reviews start with `## argus review`, which would

View file

@ -24,7 +24,6 @@ tokio.workspace = true
tokio-stream.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
problem_details = { version = "0.9.0", features = ["axum"] }
[dev-dependencies]
tempfile = "3"

View file

@ -1133,21 +1133,19 @@ mod tests {
use super::*;
#[test]
fn problem_details_carry_rfc9457_status_and_detail() {
// Contract the frontend depends on: the problem_details crate
// serialises the RFC 9457 members we rely on — `status` (numeric)
// and `detail` (the caller message; the FE reads `.detail`).
let pd = problem_details::ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail("bad input");
let v = serde_json::to_value(&pd).expect("problem details serialise");
assert_eq!(v["status"], 400);
assert_eq!(v["detail"], "bad input");
// The 500 wrapper path carries the internal-error status.
let five =
problem_details::ProblemDetails::from_status_code(StatusCode::INTERNAL_SERVER_ERROR)
.with_detail("boom");
let fv = serde_json::to_value(&five).expect("problem details serialise");
assert_eq!(fv["status"], 500);
fn problem_body_has_rfc9457_members() {
// about:blank type → title is the canonical status reason phrase,
// status is the numeric code, detail is the caller message.
let body = problem_body(StatusCode::BAD_REQUEST, "bad input");
assert_eq!(body["type"], "about:blank");
assert_eq!(body["title"], "Bad Request");
assert_eq!(body["status"], 400);
assert_eq!(body["detail"], "bad input");
// error_response (the 500 wrapper) carries the same shape with the
// internal-error status.
let five = problem_body(StatusCode::INTERNAL_SERVER_ERROR, "boom");
assert_eq!(five["status"], 500);
assert_eq!(five["title"], "Internal Server Error");
}
#[test]
@ -1657,23 +1655,44 @@ fn strip_container_prefix(name: &str) -> String {
.to_owned()
}
/// The common internal-error case as a `ProblemDetails`: a 500 RFC 9457
/// (`application/problem+json`) value via the `problem_details` crate.
/// `from_status_code` sets `status` + `title` (the canonical reason phrase)
/// and leaves `type` as the default `about:blank`; `with_detail` carries the
/// caller message; the crate's axum `IntoResponse` emits the
/// `application/problem+json` body the frontend parses (it reads `detail`).
/// Handlers that surface client failures return `Result<_, ProblemDetails>`
/// and hand this (or an inline `from_status_code(4xx)`) straight to `Err` —
/// no manual `.into_response()`.
fn error_problem(message: &str) -> problem_details::ProblemDetails {
problem_details::ProblemDetails::from_status_code(StatusCode::INTERNAL_SERVER_ERROR)
.with_detail(message)
/// The RFC 9457 problem-details media type.
const PROBLEM_JSON_CONTENT_TYPE: &str = "application/problem+json";
/// Build the RFC 9457 problem-details body for `status` + `detail`. The
/// object carries the standard members: `type` ("about:blank", i.e. no
/// problem-specific type), `title` (the HTTP status reason phrase),
/// `status` (numeric code) and `detail` (the caller-supplied message).
/// Split from [`problem_response`] so the member shape is unit-testable
/// without axum response plumbing.
fn problem_body(status: StatusCode, detail: &str) -> serde_json::Value {
serde_json::json!({
"type": "about:blank",
"title": status.canonical_reason().unwrap_or("Error"),
"status": status.as_u16(),
"detail": detail,
})
}
/// `Response` wrapper around [`error_problem`] for the many handlers typed
/// `-> Response` whose only failure mode is a 500 — they funnel errors
/// through here rather than threading a `Result` return type.
/// Build an RFC 9457 (`application/problem+json`) error response.
/// Centralising this keeps every dashboard error on one machine-readable
/// shape the frontend can parse (read `detail` for display) instead of
/// guessing between plain text and JSON.
fn problem_response(status: StatusCode, detail: &str) -> Response {
let body = serde_json::to_string(&problem_body(status, detail))
.expect("problem+json body is always serialisable");
(
status,
[(axum::http::header::CONTENT_TYPE, PROBLEM_JSON_CONTENT_TYPE)],
body,
)
.into_response()
}
/// Convenience wrapper for the common internal-error case: a 500
/// problem-details response (see [`problem_response`]). Most dashboard
/// handlers funnel their errors through here; handlers with a more
/// specific failure (bad input, not found) call [`problem_response`]
/// directly with the right status.
fn error_response(message: &str) -> Response {
error_problem(message).into_response()
problem_response(StatusCode::INTERNAL_SERVER_ERROR, message)
}

View file

@ -16,9 +16,7 @@ use axum::{
use hive_sh4re::Approval;
use serde::Deserialize;
use problem_details::ProblemDetails;
use super::{AppState, error_problem, error_response};
use super::{AppState, error_response};
use crate::actions;
use crate::coordinator::Coordinator;
use crate::lifecycle;
@ -180,22 +178,19 @@ pub(super) async fn get_approval_diff(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
axum::extract::Query(q): axum::extract::Query<DiffBaseQuery>,
) -> Result<Response, ProblemDetails> {
) -> Response {
let base = q.base.as_deref().unwrap_or("applied");
let approval = match state.coord.approvals.get(id) {
Ok(Some(a)) => a,
Ok(None) => return Err(error_problem(&format!("approval {id} not found"))),
Err(e) => return Err(error_problem(&format!("approval {id}: {e:#}"))),
Ok(None) => return error_response(&format!("approval {id} not found")),
Err(e) => return error_response(&format!("approval {id}: {e:#}")),
};
if !matches!(approval.kind, hive_sh4re::ApprovalKind::ApplyCommit) {
return Err(error_problem("spawn approvals carry no commit to diff"));
return error_response("spawn approvals carry no commit to diff");
}
let applied = Coordinator::agent_applied_dir(&approval.agent);
if !applied.join(".git").exists() {
return Ok(plain_text(format!(
"(no applied git repo at {})",
applied.display()
)));
return plain_text(format!("(no applied git repo at {})", applied.display()));
}
let target = format!("refs/tags/proposal/{id}");
let base_ref = match base {
@ -214,23 +209,18 @@ pub(super) async fn get_approval_diff(
.max()
.map(|n| format!("refs/tags/proposal/{n}"))
}
other => {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail(format!("unknown diff base {other:?}")));
}
other => return error_response(&format!("unknown diff base {other:?}")),
};
let Some(base_ref) = base_ref else {
return Ok(plain_text(match base {
return plain_text(match base {
"approved" => "(no earlier approved proposal to diff against)".to_owned(),
_ => "(no previous proposal to diff against)".to_owned(),
}));
});
};
match git_diff_refs(&applied, &base_ref, &target).await {
Ok(s) if s.is_empty() => Ok(plain_text(
"(identical — no changes vs this base)".to_owned(),
)),
Ok(s) => Ok(plain_text(s)),
Err(e) => Err(error_problem(&format!("git diff: {e:#}"))),
Ok(s) if s.is_empty() => plain_text("(identical — no changes vs this base)".to_owned()),
Ok(s) => plain_text(s),
Err(e) => error_response(&format!("git diff: {e:#}")),
}
}

View file

@ -13,9 +13,7 @@ use axum::{
};
use serde::Deserialize;
use problem_details::ProblemDetails;
use super::{error_problem, strip_container_prefix, validate_agent_name};
use super::{error_response, strip_container_prefix, validate_agent_name};
use crate::lifecycle;
#[derive(Deserialize)]
@ -36,14 +34,13 @@ pub(super) struct JournalQuery {
pub(super) async fn get_journal(
AxumPath(name): AxumPath<String>,
axum::extract::Query(q): axum::extract::Query<JournalQuery>,
) -> Result<Response, ProblemDetails> {
) -> Response {
// Defense-in-depth format check so weird chars never reach the
// shellout below — the `lifecycle::list()` existence check would
// catch them anyway, but rejecting at the boundary keeps the
// failure mode crisp.
if let Some(reason) = validate_agent_name(&name) {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail(format!("bad agent name: {reason}")));
return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response();
}
// Validate the container name against the list of managed
// containers so we don't shell out with arbitrary input.
@ -51,8 +48,7 @@ pub(super) async fn get_journal(
let prefixed = format!("{}{container}", lifecycle::AGENT_PREFIX);
let live = lifecycle::list().await.unwrap_or_default();
if !live.iter().any(|c| c == &prefixed) {
return Err(ProblemDetails::from_status_code(StatusCode::NOT_FOUND)
.with_detail(format!("journal: no managed container {prefixed:?}")));
return error_response(&format!("journal: no managed container {prefixed:?}"));
}
let lines = q.lines.unwrap_or(500).min(5000);
let unit = match q.unit.as_deref().filter(|s| !s.is_empty()) {
@ -65,8 +61,7 @@ pub(super) async fn get_journal(
format!("{u}.service")
};
if !allowed.contains(&unit.as_str()) {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail(format!("journal: unknown unit {unit:?}")));
return error_response(&format!("journal: unknown unit {unit:?}"));
}
Some(unit)
}
@ -91,9 +86,9 @@ pub(super) async fn get_journal(
body.push_str("\n--- stderr ---\n");
body.push_str(&stderr);
}
Ok(([("content-type", "text/plain; charset=utf-8")], body).into_response())
([("content-type", "text/plain; charset=utf-8")], body).into_response()
}
Err(e) => Err(error_problem(&format!("journal read: {e:#}"))),
Err(e) => error_response(&format!("journal read: {e:#}")),
}
}
@ -113,7 +108,7 @@ pub(super) struct JournalHostQuery {
/// dashboard binding to a host-only port.
pub(super) async fn get_journal_host(
axum::extract::Query(q): axum::extract::Query<JournalHostQuery>,
) -> Result<Response, ProblemDetails> {
) -> Response {
let lines = q.lines.unwrap_or(500).min(5000);
let allowed = ["hive-c0re.service"];
let mut cmd = tokio::process::Command::new("journalctl");
@ -126,8 +121,7 @@ pub(super) async fn get_journal_host(
format!("{u}.service")
};
if !allowed.contains(&unit.as_str()) {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail(format!("journal-host: unknown unit {unit:?}")));
return error_response(&format!("journal-host: unknown unit {unit:?}"));
}
cmd.args(["-u", &unit]);
}
@ -138,8 +132,8 @@ pub(super) async fn get_journal_host(
body.push_str("\n--- stderr ---\n");
body.push_str(&String::from_utf8_lossy(&out.stderr));
}
Ok(([("content-type", "text/plain; charset=utf-8")], body).into_response())
([("content-type", "text/plain; charset=utf-8")], body).into_response()
}
Err(e) => Err(error_problem(&format!("journalctl spawn: {e}"))),
Err(e) => error_response(&format!("journalctl spawn: {e}")),
}
}

View file

@ -12,9 +12,7 @@ use axum::{
};
use serde::{Deserialize, Serialize};
use problem_details::ProblemDetails;
use super::{AppState, guard_agent_name, strip_container_prefix};
use super::{AppState, error_response, guard_agent_name, strip_container_prefix};
#[derive(Serialize)]
pub(super) struct ToolGroupsSnapshot {
@ -114,19 +112,15 @@ pub(super) async fn post_tool_groups(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
axum::Json(body): axum::Json<SetToolGroupsBody>,
) -> Result<Response, ProblemDetails> {
) -> Response {
let logical = strip_container_prefix(&name);
// `guard_agent_name` yields a ready-made rejection `Response`; pass it
// through as `Ok` (axum sends it verbatim) rather than re-deriving a
// `ProblemDetails` — the guard is shared with `-> Response` handlers.
if let Some(reject) = guard_agent_name(&state, &logical).await {
return Ok(reject);
return reject;
}
// Validate group names before queuing — fail fast so the operator
// sees the error immediately rather than waiting for the worker.
if let Err(e) = crate::tool_groups::validate_groups(&body.groups) {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail(format!("invalid tool-groups for {logical}: {e}")));
return error_response(&format!("invalid tool-groups for {logical}: {e}"));
}
// Enqueue a PermChange so the JSON file write is serialised through
// the FIFO worker. Prevents concurrent batch-apply actions for
@ -141,7 +135,7 @@ pub(super) async fn post_tool_groups(
);
state.coord.emit_rebuild_queue_snapshot();
tracing::info!(agent = %logical, groups = ?body.groups, "operator: set tool-groups via dashboard");
Ok((StatusCode::OK, "ok").into_response())
(StatusCode::OK, "ok").into_response()
}
#[derive(Serialize)]
@ -199,10 +193,10 @@ pub(super) async fn post_capabilities(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
axum::Json(body): axum::Json<SetCapabilitiesBody>,
) -> Result<Response, ProblemDetails> {
) -> Response {
let logical = strip_container_prefix(&name);
if let Some(reject) = guard_agent_name(&state, &logical).await {
return Ok(reject);
return reject;
}
let known: Vec<&str> = hive_sh4re::Capability::ALL
.iter()
@ -210,8 +204,7 @@ pub(super) async fn post_capabilities(
.collect();
for cap in &body.caps {
if !known.contains(&cap.as_str()) {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail(format!("unknown capability: {cap}")));
return error_response(&format!("unknown capability: {cap}"));
}
}
// Enqueue a PermChange so the JSON file write is serialised through
@ -227,7 +220,7 @@ pub(super) async fn post_capabilities(
);
state.coord.emit_rebuild_queue_snapshot();
tracing::info!(agent = %logical, caps = ?body.caps, "operator: set capabilities via dashboard");
Ok((StatusCode::OK, "ok").into_response())
(StatusCode::OK, "ok").into_response()
}
/// One agent's slice of a batch permission change. Sparse: an omitted
@ -262,7 +255,7 @@ type StagedPerm = (String, Option<Vec<String>>, Option<Vec<String>>);
pub(super) async fn post_permissions(
State(state): State<AppState>,
axum::Json(body): axum::Json<BatchPermsBody>,
) -> Result<Response, ProblemDetails> {
) -> Response {
let known_caps: Vec<&str> = hive_sh4re::Capability::ALL
.iter()
.map(|c| c.as_str())
@ -274,19 +267,17 @@ pub(super) async fn post_permissions(
for change in &body.changes {
let logical = strip_container_prefix(&change.agent);
if let Some(reject) = guard_agent_name(&state, &logical).await {
return Ok(reject);
return reject;
}
if let Some(groups) = &change.tool_groups
&& let Err(e) = crate::tool_groups::validate_groups(groups)
{
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail(format!("invalid tool-groups for {logical}: {e}")));
return error_response(&format!("invalid tool-groups for {logical}: {e}"));
}
if let Some(caps) = &change.capabilities {
for cap in caps {
if !known_caps.contains(&cap.as_str()) {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail(format!("unknown capability for {logical}: {cap}")));
return error_response(&format!("unknown capability for {logical}: {cap}"));
}
}
}
@ -309,7 +300,7 @@ pub(super) async fn post_permissions(
tracing::info!(agent = %logical, "operator: batch perm change via dashboard");
}
state.coord.emit_rebuild_queue_snapshot();
Ok((StatusCode::OK, "ok").into_response())
(StatusCode::OK, "ok").into_response()
}
#[cfg(test)]

View file

@ -13,8 +13,6 @@ use axum::{
};
use serde::Deserialize;
use problem_details::ProblemDetails;
use super::{AppState, error_response};
#[derive(Deserialize)]
@ -28,8 +26,7 @@ pub(super) struct AnswerForm {
/// cross-origin form-POST couldn't already reach. This shim disappears
/// once the unifying gateway makes the agent page same-origin; see
/// `docs/boundary.md`.
fn with_cors(resp: impl IntoResponse) -> Response {
let mut resp = resp.into_response();
fn with_cors(mut resp: Response) -> Response {
resp.headers_mut().insert(
axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
axum::http::HeaderValue::from_static("*"),
@ -44,10 +41,7 @@ pub(super) async fn post_answer_question(
) -> Response {
let answer = form.answer.trim();
if answer.is_empty() {
return with_cors(
ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail("answer: required"),
);
return with_cors(error_response("answer: required"));
}
let resp = match state
.coord

View file

@ -10,9 +10,7 @@ use axum::{
response::{IntoResponse, Response},
};
use problem_details::ProblemDetails;
use super::{AppState, error_problem, error_response};
use super::{AppState, error_response};
pub(super) async fn api_reminders(State(state): State<AppState>) -> Response {
match state.coord.broker.list_pending_reminders() {
@ -24,18 +22,15 @@ pub(super) async fn api_reminders(State(state): State<AppState>) -> Response {
pub(super) async fn post_cancel_reminder(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
) -> Result<Response, ProblemDetails> {
) -> Response {
match state.coord.broker.cancel_reminder(id) {
Ok(0) => Err(ProblemDetails::from_status_code(StatusCode::NOT_FOUND)
.with_detail(format!("reminder {id} not pending (already delivered?)"))),
Ok(0) => error_response(&format!("reminder {id} not pending (already delivered?)")),
Ok(_) => {
tracing::info!(%id, "operator cancelled reminder");
state.coord.emit_reminders_snapshot();
Ok((StatusCode::OK, "ok").into_response())
(StatusCode::OK, "ok").into_response()
}
Err(e) => Err(error_problem(&format!(
"cancel reminder {id} failed: {e:#}"
))),
Err(e) => error_response(&format!("cancel reminder {id} failed: {e:#}")),
}
}
@ -47,15 +42,14 @@ pub(super) async fn post_cancel_reminder(
pub(super) async fn post_retry_reminder(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
) -> Result<Response, ProblemDetails> {
) -> Response {
match state.coord.broker.reset_reminder_failure(id) {
Ok(0) => Err(ProblemDetails::from_status_code(StatusCode::NOT_FOUND)
.with_detail(format!("reminder {id} not pending (already delivered?)"))),
Ok(0) => error_response(&format!("reminder {id} not pending (already delivered?)")),
Ok(_) => {
tracing::info!(%id, "operator reset reminder failure for retry");
state.coord.emit_reminders_snapshot();
Ok((StatusCode::OK, "ok").into_response())
(StatusCode::OK, "ok").into_response()
}
Err(e) => Err(error_problem(&format!("retry reminder {id} failed: {e:#}"))),
Err(e) => error_response(&format!("retry reminder {id} failed: {e:#}")),
}
}

View file

@ -11,9 +11,7 @@ use axum::{
response::{IntoResponse, Response},
};
use problem_details::ProblemDetails;
use super::{AppState, error_problem, error_response};
use super::{AppState, error_response};
/// `GET /api/schedules` — snapshot of every schedule for the
/// scheduled-prompts tab. Returns the wire shape directly
@ -51,18 +49,15 @@ pub(super) async fn api_schedules(State(state): State<AppState>) -> Response {
pub(super) async fn post_schedule_new(
State(state): State<AppState>,
axum::Json(payload): axum::Json<hive_sh4re::SchedulePromptPayload>,
) -> Result<Response, ProblemDetails> {
) -> Response {
if payload.targets.is_empty() {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail("schedule must have at least one target"));
return error_response("schedule must have at least one target");
}
if payload.body.trim().is_empty() {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail("schedule body must be non-empty"));
return error_response("schedule body must be non-empty");
}
if let Some(0) = payload.interval_seconds {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail("interval_seconds must be > 0 (use None for one-shot)"));
return error_response("interval_seconds must be > 0 (use None for one-shot)");
}
let new = crate::scheduled_prompts::NewSchedule {
owner: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
@ -76,9 +71,9 @@ pub(super) async fn post_schedule_new(
match state.coord.scheduled_prompts.submit(&new) {
Ok(id) => {
state.coord.emit_schedules_snapshot();
Ok(axum::Json(serde_json::json!({"id": id})).into_response())
axum::Json(serde_json::json!({"id": id})).into_response()
}
Err(e) => Err(error_problem(&format!("schedule submit: {e:#}"))),
Err(e) => error_response(&format!("schedule submit: {e:#}")),
}
}

View file

@ -13,9 +13,7 @@ use axum::{
};
use serde::Deserialize;
use problem_details::ProblemDetails;
use super::{AppState, error_problem, error_response};
use super::{AppState, error_response};
/// `POST /api/topology/set-parent` body. `child` is required.
/// `new_parent` may be:
@ -52,11 +50,10 @@ pub(super) struct SetParentBulkEntry {
pub(super) async fn post_set_parent(
State(state): State<AppState>,
Form(form): Form<SetParentForm>,
) -> Result<Response, ProblemDetails> {
) -> Response {
let child = form.child.trim().to_owned();
if child.is_empty() {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail("set-parent: `child` required"));
return error_response("set-parent: `child` required");
}
// Empty / whitespace-only `new_parent` ⇒ promote to root. Web
// forms submit the empty string for a "no value" radio button,
@ -82,9 +79,9 @@ pub(super) async fn post_set_parent(
new_parent = ?new_parent,
"operator: set-parent via dashboard"
);
Ok((StatusCode::OK, "ok").into_response())
(StatusCode::OK, "ok").into_response()
}
Err(e) => Err(error_problem(&format!("set-parent {child} failed: {e}"))),
Err(e) => error_response(&format!("set-parent {child} failed: {e}")),
}
}

View file

@ -67,12 +67,12 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default();
let initial = !dir.join(".git").exists();
// Embedded-CA list (self-signed hive CA + peer CAs): keep the
// `./hive-ca.pem` / `./peer-ca-<N>.pem` files at the meta root in
// lockstep with their host sources so the build-time `certificateFiles`
// list render_flake emits always resolves. Empty when neither a
// self-signed hive CA nor any peer CA is configured.
let (ca_files, ca_changed) = ca_embed_state(&dir);
// Hive CA embedding (self-signed TLS): keep `./hive-ca.pem` at the meta
// root in lockstep with the host CA so the build-time `certificateFiles`
// reference render_flake emits always resolves. `ca_desired` is empty
// when self-signed TLS isn't active (cert / ACME mode).
let ca_path = dir.join(HIVE_CA_FILE);
let (ca_desired, ca_changed) = hive_ca_state(&dir);
// Skip only when both the flake AND the embedded CA are unchanged — a
// CA rotation with an otherwise-identical flake must still re-commit.
@ -98,10 +98,15 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
std::fs::write(&flake_path, &new_flake)
.with_context(|| format!("write {}", flake_path.display()))?;
// Materialise the embedded CA list next to flake.nix + drop any stale
// CA file; `ca_touched` is every filename written or removed, staged
// for commit below. Public CA certs only; no private key is embedded.
let ca_touched = materialise_ca_files(&dir, &ca_files)?;
// Materialise (or drop) the embedded hive CA next to flake.nix. When
// self-signed TLS is off, `ca_desired` is empty and we remove any stale
// cert so the flake (which no longer references it) stays buildable.
if ca_desired.is_empty() {
let _ = std::fs::remove_file(&ca_path);
} else if ca_changed {
std::fs::write(&ca_path, &ca_desired)
.with_context(|| format!("write {}", ca_path.display()))?;
}
// Reconcile topology.json against the live agent set — adds
// entries for newly-spawned agents (default: manager as parent,
@ -143,13 +148,11 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
// contain '/flake.nix'". Lock then commit once with both
// flake.nix and flake.lock — single commit per change.
git(&dir, &["add", "flake.nix"]).await?;
// Stage every embedded CA file we wrote or removed (hive CA + peer
// CAs). `git add <path>` stages a deletion when the path is tracked
// and now gone; best-effort so the never-tracked-and-absent case
// (pathspec mismatch) is a harmless no-op.
for name in &ca_touched {
let _ = git(&dir, &["add", "--", name]).await;
}
// Stage the embedded hive CA — added/updated when self-signed TLS is on,
// or its deletion when it was just removed. `git add <path>` stages a
// deletion when the path is tracked and now gone; best-effort so the
// never-tracked-and-absent case (pathspec mismatch) is a harmless no-op.
let _ = git(&dir, &["add", "--", HIVE_CA_FILE]).await;
// Stage topology.json on every sync (regenerated by reconcile
// above when the agent set changed). git add is a no-op when the
// file content is unchanged.
@ -197,7 +200,6 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
"flake.nix" => Some("flake"),
"flake.lock" => Some("lock"),
"hive-ca.pem" => Some("hive-ca"),
f if f.starts_with("peer-ca-") && has_pem_ext(f) => Some("peer-ca"),
"topology.json" => Some("topology"),
"capabilities.json" => Some("capabilities"),
"tool-groups.json" => Some("tool-groups"),
@ -574,10 +576,10 @@ fn forwarded_env_vars() -> Vec<(&'static str, String)> {
.collect()
}
/// Filename the hive's own self-signed CA cert is embedded under at the
/// meta-flake root. One entry of the embedded-CA list `render_flake`
/// references in `security.pki.certificateFiles` (see `embedded_ca_files`);
/// peer CAs sit alongside it as `peer-ca-<N>.pem`.
/// Filename the hive CA cert is embedded under at the meta-flake root.
/// `sync_agents` writes it and `render_flake` references `./hive-ca.pem`
/// in `security.pki.certificateFiles` so every agent trusts it at build
/// time.
const HIVE_CA_FILE: &str = "hive-ca.pem";
/// Host path of the hive CA *certificate*, when self-signed TLS is active.
@ -595,113 +597,17 @@ fn hive_ca_source() -> Option<String> {
Some(path)
}
/// Host paths of peer-hive root CA certificates, from `HIVE_PEER_CA_PATHS`
/// (colon-separated; set by hive-c0re.nix from `swarm.peers.<d>.caCert`).
/// Each is embedded alongside the hive CA so a peer's CA is trusted
/// everywhere the hive's own internal CA is — i.e. by every agent. Empty
/// segments and paths that don't resolve to a file are dropped, so we
/// never reference a `certificateFiles` entry we couldn't embed.
fn peer_ca_sources() -> Vec<String> {
let Ok(raw) = std::env::var("HIVE_PEER_CA_PATHS") else {
return Vec::new();
};
raw.split(':')
.map(str::trim)
.filter(|p| !p.is_empty() && std::path::Path::new(p).is_file())
.map(ToOwned::to_owned)
.collect()
}
/// The ordered set of CA certs embedded next to the meta flake, as
/// `(filename, host_source_path)`. The self-signed hive CA (when active)
/// is `hive-ca.pem`; each peer CA is `peer-ca-<N>.pem` in declaration
/// order. `render_flake` emits exactly these filenames into
/// `security.pki.certificateFiles` and `sync_agents` materialises them,
/// so the rendered reference and the embedded files always agree.
fn embedded_ca_files() -> Vec<(String, String)> {
let mut out = Vec::new();
if let Some(p) = hive_ca_source() {
out.push((HIVE_CA_FILE.to_owned(), p));
}
for (i, p) in peer_ca_sources().into_iter().enumerate() {
out.push((format!("peer-ca-{i}.pem"), p));
}
out
}
/// Write each desired embedded CA file next to `flake.nix` and remove
/// any stale one (a hive CA turned off, or a peer dropped from config),
/// so the flake never references a file we didn't write. Returns every
/// filename written or removed, for the caller to stage. The public CA
/// certs only; no private key is ever embedded.
fn materialise_ca_files(dir: &Path, ca_files: &[(String, String)]) -> Result<Vec<String>> {
let desired: std::collections::HashSet<&str> =
ca_files.iter().map(|(n, _)| n.as_str()).collect();
let mut touched: Vec<String> = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for e in entries.flatten() {
let fname = e.file_name();
let Some(name) = fname.to_str() else { continue };
if is_embedded_ca_name(name) && !desired.contains(name) {
let _ = std::fs::remove_file(dir.join(name));
touched.push(name.to_owned());
}
}
}
for (name, content) in ca_files {
let path = dir.join(name);
std::fs::write(&path, content).with_context(|| format!("write {}", path.display()))?;
touched.push(name.clone());
}
Ok(touched)
}
/// True for a filename `embedded_ca_files` can produce — the hive CA or
/// a `peer-ca-<N>.pem`. Lets `sync_agents` find stale CA files to clean
/// up (a CA dropped from config) without touching unrelated meta files.
fn is_embedded_ca_name(name: &str) -> bool {
name == HIVE_CA_FILE || (name.starts_with("peer-ca-") && has_pem_ext(name))
}
/// True when `name` ends in a `.pem` extension (case-insensitive). Split
/// out so the embedded-CA filename checks share one spelling and dodge
/// clippy's case-sensitive-extension lint.
fn has_pem_ext(name: &str) -> bool {
Path::new(name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("pem"))
}
/// Embedded-CA state for the meta repo: `(desired_files, changed)`.
/// `desired_files` is `(filename, contents)` for every CA that should sit
/// next to flake.nix (the hive CA + each peer CA). `changed` is true when
/// the on-disk set differs in any way — a file's contents changed, a new
/// CA appeared, or a previously-embedded CA (`hive-ca.pem` /
/// `peer-ca-*.pem`) is no longer wanted (stale, to be removed). Drives
/// both the re-commit decision and the materialise/cleanup in
/// `sync_agents`, so a CA rotation or a peer-set change re-commits even
/// when the flake itself is byte-identical.
fn ca_embed_state(dir: &std::path::Path) -> (Vec<(String, String)>, bool) {
let desired: Vec<(String, String)> = embedded_ca_files()
.into_iter()
.filter_map(|(name, path)| std::fs::read_to_string(&path).ok().map(|c| (name, c)))
.collect();
let desired_names: std::collections::HashSet<&str> =
desired.iter().map(|(n, _)| n.as_str()).collect();
let mut changed = desired.iter().any(|(name, content)| {
std::fs::read_to_string(dir.join(name)).unwrap_or_default() != *content
});
// A previously-embedded CA file no longer wanted → stale (removal is
// a change even when every desired file already matches on disk).
if !changed && let Ok(entries) = std::fs::read_dir(dir) {
changed = entries.flatten().any(|e| {
e.file_name()
.to_str()
.is_some_and(|name| is_embedded_ca_name(name) && !desired_names.contains(name))
});
}
/// Embedded-CA state for the meta repo: `(desired_contents, changed)`.
/// `desired_contents` is the host hive CA cert (empty when self-signed TLS
/// is inactive); `changed` is true when it differs from what's already
/// embedded at `<dir>/hive-ca.pem`, so a CA rotation re-commits even when
/// the flake itself is byte-identical.
fn hive_ca_state(dir: &std::path::Path) -> (String, bool) {
let on_disk = std::fs::read_to_string(dir.join(HIVE_CA_FILE)).unwrap_or_default();
let desired = hive_ca_source()
.and_then(|p| std::fs::read_to_string(p).ok())
.unwrap_or_default();
let changed = desired != on_disk;
(desired, changed)
}
@ -842,26 +748,16 @@ where
{
"#,
);
// CA trust: embed every hive-trusted CA so each agent validates them at
// build time. The list is the hive's own self-signed CA (when active)
// plus every peer-hive root CA (`swarm.peers.<d>.caCert`) — a peer CA is
// trusted everywhere the hive's own internal CA is. `certificateFiles` is
// build-time, so the certs travel with the flake source: `sync_agents`
// writes `./hive-ca.pem` + `./peer-ca-<N>.pem` next to flake.nix and
// stages them. Only public CA certs are embedded; no private key ever
// leaves the host. The filename list matches `sync_agents` exactly (both
// derive it from `embedded_ca_files`), so we never reference a file we
// didn't embed; emitted only when the list is non-empty.
let ca_refs: Vec<String> = embedded_ca_files()
.into_iter()
.map(|(name, _)| format!("./{name}"))
.collect();
if !ca_refs.is_empty() {
let _ = writeln!(
out,
" security.pki.certificateFiles = [ {} ];",
ca_refs.join(" ")
);
// Self-signed TLS trust: embed the hive CA so every agent validates the
// gateway's self-signed leaf at build time. `security.pki.certificateFiles`
// is build-time, so the CA travels with the flake source — `sync_agents`
// writes `./hive-ca.pem` next to flake.nix and stages it. Only the public
// CA cert is embedded; the private key never leaves the host. Emitted only
// when hive-tls.nix signalled a CA (HIVE_TLS_CA_PATH) and the cert exists,
// matching the write condition in `sync_agents` so we never reference a
// file we didn't embed.
if hive_ca_source().is_some() {
out.push_str(" security.pki.certificateFiles = [ ./hive-ca.pem ];\n");
}
out.push_str(
r#" # The harness service inside the container runs as a
@ -1332,64 +1228,23 @@ mod tests {
)
};
// Two peer-hive CA temp files for the list cases.
let peer0 = std::env::temp_dir().join(format!("peer-ca0-test-{}.pem", std::process::id()));
let peer1 = std::env::temp_dir().join(format!("peer-ca1-test-{}.pem", std::process::id()));
std::fs::write(
&peer0,
"-----BEGIN CERTIFICATE-----\np0\n-----END CERTIFICATE-----\n",
)
.expect("write peer CA 0");
std::fs::write(
&peer1,
"-----BEGIN CERTIFICATE-----\np1\n-----END CERTIFICATE-----\n",
)
.expect("write peer CA 1");
let peer_paths = format!("{}:{}", peer0.display(), peer1.display());
// All env mutations are serialised within this one test (no other
// test asserts on these vars), restored before returning.
unsafe {
std::env::remove_var("HIVE_PEER_CA_PATHS");
std::env::set_var("HIVE_TLS_CA_PATH", &ca_file);
}
let with_ca = render();
// Hive CA + peer CAs: the list carries all three, hive CA first.
unsafe {
std::env::set_var("HIVE_PEER_CA_PATHS", &peer_paths);
}
let with_peers = render();
// Peers only (this hive on ACME, federating with self-signed peers).
unsafe {
std::env::remove_var("HIVE_TLS_CA_PATH");
}
let peers_only = render();
unsafe {
std::env::remove_var("HIVE_PEER_CA_PATHS");
}
let without_ca = render();
let _ = std::fs::remove_file(&ca_file);
let _ = std::fs::remove_file(&peer0);
let _ = std::fs::remove_file(&peer1);
assert!(
with_ca.contains("security.pki.certificateFiles = [ ./hive-ca.pem ]"),
"CA cert must be wired into certificateFiles when signalled:\n{with_ca}"
);
assert!(
with_peers.contains(
"security.pki.certificateFiles = [ ./hive-ca.pem ./peer-ca-0.pem ./peer-ca-1.pem ]"
),
"hive CA + peer CAs must all appear in the certificateFiles list:\n{with_peers}"
);
assert!(
peers_only
.contains("security.pki.certificateFiles = [ ./peer-ca-0.pem ./peer-ca-1.pem ]"),
"peer CAs must be trusted even when this hive has no self-signed CA:\n{peers_only}"
);
assert!(
!without_ca.contains("security.pki.certificateFiles"),
"no certificateFiles reference without any CA signal:\n{without_ca}"
"no certificateFiles reference without the HIVE_TLS_CA_PATH signal:\n{without_ca}"
);
}
}

View file

@ -47,54 +47,39 @@ struct Cli {
#[derive(Subcommand)]
enum Verb {
// The kind verbs below are hidden back-compat aliases of the new
// `pr <verb>` / `issue <verb>` forms (`pr-close` -> `pr close`, bare
// `close` -> `pr close` / `issue close`, etc.). They still parse but are
// dropped from `--help`; a later change removes them once usage migrates.
/// Dump title + body + all comments for an issue or PR.
#[command(hide = true)]
View(verbs::view::Args),
/// Issue-scoped commands: `issue <show|create|edit|view|comment|comments|close|labels|assign|timeline> …`.
Issue(verbs::issue_cmd::Args),
/// Print key fields of an issue as JSON.
Issue(verbs::issue::Args),
/// Create an issue. Prints the issue URL on success.
#[command(hide = true)]
IssueCreate(verbs::issue_create::Args),
/// Edit an issue's title, body, state, or milestone.
#[command(hide = true)]
IssueEdit(verbs::issue_edit::Args),
/// PR-scoped commands: `pr <show|status|create|merge|reviews|commits|diff|view|comment|comments|close|labels|assign|timeline> …`.
Pr(verbs::pr_cmd::Args),
/// Print key fields of a PR as JSON.
Pr(verbs::pr::Args),
/// List a PR's commits as JSON (sha, message, author date, author).
/// Survives rebase-rewritten shas — message + author date let a
/// caller match the rows against linear `main` history.
#[command(hide = true)]
PrCommits(verbs::pr_commits::Args),
/// Create a pull request. Prints the PR URL on success.
#[command(hide = true)]
PrCreate(verbs::pr_create::Args),
/// Post a comment on an issue or PR.
#[command(hide = true)]
Comment(verbs::comment::Args),
/// List all comments on an issue or PR.
#[command(hide = true)]
Comments(verbs::comments::Args),
/// Print the body (or full JSON) of a single comment by id.
CommentShow(verbs::comment_show::Args),
/// Edit an existing comment by id.
CommentEdit(verbs::comment_edit::Args),
/// Assign or unassign a user on an issue or PR.
#[command(hide = true)]
Assign(verbs::assign::Args),
/// Close an issue or PR.
#[command(hide = true)]
Close(verbs::close::Args),
/// List, add, or remove labels on an issue or PR.
#[command(hide = true)]
Labels(verbs::labels::Args),
/// PR health view: mergeable state, CI checks, requested reviewers +
/// review verdicts, last-comment time (`--pr <n>`). `--sha` is a
/// CI-only fast path. Exit code is a merge-readiness verdict.
#[command(hide = true)]
PrStatus(verbs::pr_status::Args),
/// Clone a forge repo (default `-r`/`HIVE_FORGE_REPO`) with
/// credentials auto-injected. Pairs with `pr-create --agit`.
@ -128,25 +113,21 @@ enum Verb {
/// Merge a PR (`--method merge|rebase`, default merge). Refuses unless
/// mergeable + CI not red + no changes requested (`--force` overrides).
/// Deletes the head branch unless `--keep-branch`. No squash option.
#[command(hide = true)]
PrMerge(verbs::pr_merge::Args),
/// List a PR's reviews, or submit one: `--approve` /
/// `--request-changes` / `--comment` (with `-m` for the body).
#[command(hide = true)]
PrReviews(verbs::pr_reviews::Args),
/// List branches, optionally filtered.
Branches(verbs::branches::Args),
/// Print the tree SHA at a branch or commit.
TreeSha(verbs::tree_sha::Args),
/// Print the unified diff for a PR.
#[command(hide = true)]
Diff(verbs::diff::Args),
/// Get or set this user's watch subscription on a repo.
Subscription(verbs::subscription::Args),
/// List timeline events on an issue or PR (closes, label adds,
/// assignments, commit refs, pushes, etc.) — the audit trail
/// `view` + `comments` don't surface.
#[command(hide = true)]
Timeline(verbs::timeline::Args),
/// Upload a file as an attachment to an issue.
AttachIssue(verbs::attach::IssueArgs),
@ -171,10 +152,10 @@ fn main() -> Result<()> {
let client = client::Client::from_env(cli.repo, cli.json).context("initialize forge client")?;
match cli.verb {
Verb::View(a) => verbs::view::run(&client, a),
Verb::Issue(a) => verbs::issue_cmd::run(&client, a),
Verb::Issue(a) => verbs::issue::run(&client, a),
Verb::IssueCreate(a) => verbs::issue_create::run(&client, a),
Verb::IssueEdit(a) => verbs::issue_edit::run(&client, a),
Verb::Pr(a) => verbs::pr_cmd::run(&client, a),
Verb::Pr(a) => verbs::pr::run(&client, a),
Verb::PrCommits(a) => verbs::pr_commits::run(&client, a),
Verb::PrCreate(a) => verbs::pr_create::run(&client, a),
Verb::Comment(a) => verbs::comment::run(&client, a),

View file

@ -13,7 +13,7 @@ use crate::verbs::print_json;
#[derive(ClapArgs)]
pub struct Args {
/// Issue or PR number.
pub(crate) number: u64,
number: u64,
/// User login to assign (or unassign with `--remove`).
user: String,
/// Remove the user instead of adding.

View file

@ -10,7 +10,7 @@ use crate::verbs::print_json;
#[derive(ClapArgs)]
pub struct Args {
/// Issue or PR number.
pub(crate) number: u64,
number: u64,
}
pub fn run(client: &Client, args: Args) -> Result<()> {

View file

@ -12,7 +12,7 @@ use crate::verbs::print_json;
#[derive(ClapArgs)]
pub struct Args {
/// Issue or PR number.
pub(crate) number: u64,
number: u64,
/// Inline body text.
#[arg(long, conflicts_with = "body_file")]
body: Option<String>,

View file

@ -31,7 +31,7 @@ const PAGE_SIZE: usize = 50;
#[derive(ClapArgs)]
pub struct Args {
/// Issue or PR number.
pub(crate) number: u64,
number: u64,
/// Page size for the head-of-thread shape (Forgejo caps at 50).
/// Mutually exclusive with `--tail`.
#[arg(long, default_value_t = 50, conflicts_with = "tail")]

View file

@ -1,81 +0,0 @@
//! `issue <verb>` — issue-scoped sub-commands. Wraps the per-verb modules
//! under an `issue` parent so `hive-forge issue close 42`, `issue create …`,
//! etc. read as kind-namespaced commands. The generic verbs that also work on
//! PRs (view/comment/comments/close/labels/assign/timeline) kind-check the
//! number is an issue first (`assert_kind`); the issue-only verbs are
//! kind-correct by construction. The flat `issue-*` + bare generic verbs stay
//! as hidden back-compat aliases (see `main.rs`).
use anyhow::Result;
use clap::{Args as ClapArgs, Subcommand};
use crate::client::Client;
use crate::verbs::{self, Kind, assert_kind};
#[derive(ClapArgs)]
pub struct Args {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Show issue metadata as JSON.
Show(verbs::issue::Args),
/// Create an issue.
Create(verbs::issue_create::Args),
/// Edit an issue's title / body / state / milestone.
Edit(verbs::issue_edit::Args),
/// Show title + body + comments.
View(verbs::view::Args),
/// Post a comment on the issue.
Comment(verbs::comment::Args),
/// List comments on the issue.
Comments(verbs::comments::Args),
/// Close the issue.
Close(verbs::close::Args),
/// List / add / remove labels.
Labels(verbs::labels::Args),
/// Assign or unassign a user.
Assign(verbs::assign::Args),
/// List timeline events.
Timeline(verbs::timeline::Args),
}
pub fn run(client: &Client, args: Args) -> Result<()> {
match args.cmd {
// Issue-only verbs.
Cmd::Show(a) => verbs::issue::run(client, a),
Cmd::Create(a) => verbs::issue_create::run(client, a),
Cmd::Edit(a) => verbs::issue_edit::run(client, a),
// Generics shared with `pr` — verify the number is an issue first.
Cmd::View(a) => {
assert_kind(client, a.number, Kind::Issue)?;
verbs::view::run(client, a)
}
Cmd::Comment(a) => {
assert_kind(client, a.number, Kind::Issue)?;
verbs::comment::run(client, a)
}
Cmd::Comments(a) => {
assert_kind(client, a.number, Kind::Issue)?;
verbs::comments::run(client, a)
}
Cmd::Close(a) => {
assert_kind(client, a.number, Kind::Issue)?;
verbs::close::run(client, a)
}
Cmd::Labels(a) => {
assert_kind(client, a.number, Kind::Issue)?;
verbs::labels::run(client, a)
}
Cmd::Assign(a) => {
assert_kind(client, a.number, Kind::Issue)?;
verbs::assign::run(client, a)
}
Cmd::Timeline(a) => {
assert_kind(client, a.number, Kind::Issue)?;
verbs::timeline::run(client, a)
}
}
}

View file

@ -11,7 +11,7 @@ use crate::verbs::print_json;
#[derive(ClapArgs)]
pub struct Args {
/// Issue or PR number.
pub(crate) number: u64,
number: u64,
#[command(subcommand)]
action: Option<Action>,
}

View file

@ -17,7 +17,6 @@ pub mod comment_show;
pub mod comments;
pub mod diff;
pub mod issue;
pub mod issue_cmd;
pub mod issue_create;
pub mod issue_edit;
pub mod labels;
@ -25,7 +24,6 @@ pub mod lint;
pub mod list;
pub mod milestone;
pub mod pr;
pub mod pr_cmd;
pub mod pr_commits;
pub mod pr_create;
pub mod pr_merge;
@ -54,37 +52,6 @@ pub(crate) fn print_json(v: &Value) -> Result<()> {
Ok(())
}
/// Issue-vs-PR kind, for the `pr <verb>` / `issue <verb>` sub-command
/// validation.
#[derive(Clone, Copy)]
pub(crate) enum Kind {
Pr,
Issue,
}
/// Verify `number` is the expected kind before a kind-namespaced verb (one
/// of the generics that work on both — close/comment/labels/…) acts on it —
/// the validation win the `pr <verb>` / `issue <verb>` split buys over the
/// old generic verbs. Forgejo's `/issues/{n}` endpoint serves both issues and
/// PRs and marks PRs with a non-null `pull_request` field, so one GET
/// classifies it. Errors with a "use the other command" message on mismatch.
pub(crate) fn assert_kind(client: &Client, number: u64, expected: Kind) -> Result<()> {
let repo = client.repo();
let v = client.get_json(&format!("/repos/{repo}/issues/{number}"))?;
let is_pr = v.get("pull_request").is_some_and(|p| !p.is_null());
match (expected, is_pr) {
(Kind::Pr, false) => {
anyhow::bail!(
"#{number} is an issue, not a PR — use `hive-forge issue <verb> {number}`"
)
}
(Kind::Issue, true) => {
anyhow::bail!("#{number} is a PR, not an issue — use `hive-forge pr <verb> {number}`")
}
_ => Ok(()),
}
}
/// Minimal RFC 3986 unreserved-set percent encoder. Covers the subset of
/// characters that show up in the values we splice into request paths —
/// usernames, label names, artifact names — without pulling in a fresh

View file

@ -1,93 +0,0 @@
//! `pr <verb>` — PR-scoped sub-commands. Wraps the per-verb modules under a
//! `pr` parent so `hive-forge pr close 42`, `pr status --pr 42`, etc. read as
//! kind-namespaced commands. The generic verbs that also work on issues
//! (view/comment/comments/close/labels/assign/timeline) kind-check the number
//! is a PR first (`assert_kind`); the PR-only verbs hit `/pulls/…` and are
//! kind-correct by construction. The flat `pr-*` + bare generic verbs stay as
//! hidden back-compat aliases (see `main.rs`).
use anyhow::Result;
use clap::{Args as ClapArgs, Subcommand};
use crate::client::Client;
use crate::verbs::{self, Kind, assert_kind};
#[derive(ClapArgs)]
pub struct Args {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Show PR metadata as JSON.
Show(verbs::pr::Args),
/// List the PR's commits as JSON.
Commits(verbs::pr_commits::Args),
/// Create a pull request.
Create(verbs::pr_create::Args),
/// PR health view: mergeable / CI / reviews.
Status(verbs::pr_status::Args),
/// Merge the PR.
Merge(verbs::pr_merge::Args),
/// List a PR's reviews, or submit one.
Reviews(verbs::pr_reviews::Args),
/// Print the PR's unified diff.
Diff(verbs::diff::Args),
/// Show title + body + comments.
View(verbs::view::Args),
/// Post a comment on the PR.
Comment(verbs::comment::Args),
/// List comments on the PR.
Comments(verbs::comments::Args),
/// Close the PR.
Close(verbs::close::Args),
/// List / add / remove labels.
Labels(verbs::labels::Args),
/// Assign or unassign a user.
Assign(verbs::assign::Args),
/// List timeline events.
Timeline(verbs::timeline::Args),
}
pub fn run(client: &Client, args: Args) -> Result<()> {
match args.cmd {
// PR-only verbs — kind-correct by construction (hit `/pulls/…`).
Cmd::Show(a) => verbs::pr::run(client, a),
Cmd::Commits(a) => verbs::pr_commits::run(client, a),
Cmd::Create(a) => verbs::pr_create::run(client, a),
Cmd::Status(a) => verbs::pr_status::run(client, a),
Cmd::Merge(a) => verbs::pr_merge::run(client, a),
Cmd::Reviews(a) => verbs::pr_reviews::run(client, a),
Cmd::Diff(a) => verbs::diff::run(client, a),
// Generics shared with `issue` — verify the number is a PR first.
Cmd::View(a) => {
assert_kind(client, a.number, Kind::Pr)?;
verbs::view::run(client, a)
}
Cmd::Comment(a) => {
assert_kind(client, a.number, Kind::Pr)?;
verbs::comment::run(client, a)
}
Cmd::Comments(a) => {
assert_kind(client, a.number, Kind::Pr)?;
verbs::comments::run(client, a)
}
Cmd::Close(a) => {
assert_kind(client, a.number, Kind::Pr)?;
verbs::close::run(client, a)
}
Cmd::Labels(a) => {
assert_kind(client, a.number, Kind::Pr)?;
verbs::labels::run(client, a)
}
Cmd::Assign(a) => {
assert_kind(client, a.number, Kind::Pr)?;
verbs::assign::run(client, a)
}
Cmd::Timeline(a) => {
assert_kind(client, a.number, Kind::Pr)?;
verbs::timeline::run(client, a)
}
}
}

View file

@ -26,7 +26,7 @@ use crate::verbs::print_json;
#[derive(ClapArgs)]
pub struct Args {
/// Issue or PR number.
pub(crate) number: u64,
number: u64,
/// Page size (Forgejo caps at 50). Returns the first `N` events.
#[arg(long, default_value_t = 50)]
limit: u64,

View file

@ -10,7 +10,7 @@ use crate::client::Client;
#[derive(ClapArgs)]
pub struct Args {
/// Issue or PR number.
pub(crate) number: u64,
number: u64,
}
pub fn run(client: &Client, args: Args) -> Result<()> {

View file

@ -213,35 +213,6 @@ in
then strip the colons and prepend `sha256:`. A malformed
value is ignored with a warning rather than weakening
trust. See docs/swarm.md for the full recipe.
Scopes only to hive-c0re's own peer HTTPS checks it does
NOT help Matrix federation (tuwunel validates against its
container trust bundle). For a self-signed peer whose root
CA you want trusted hive-wide (every agent + Matrix
federation), set `caCert` below.
'';
};
caCert = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "./peers/edge-ca.pem";
description = ''
Path to this peer hive's root CA certificate (PEM). When
set, the CA is embedded (at build time, into the nix store
no runtime file on the host) and trusted **everywhere the
hive's own internal CA is**: it rides alongside `hive-ca.pem`
in each agent's `security.pki.certificateFiles` (via the
meta-flake renderer), and is added to the Matrix homeserver
container's trust bundle so tuwunel validates *federation*
TLS from a self-signed peer hive whose cert chains to it.
This is the CA-trust path that `certFingerprint`
(leaf-pinning, c0re-only) can't cover, and is what unblocks
Matrix federation with a self-signed peer hive. Trust stays
inside the hive (agents + the Matrix container), never the
host system trust store. Mutually complementary with
`certFingerprint`; set `caCert` for the federation case. See
docs/swarm.md.
'';
};
@ -854,26 +825,7 @@ in
}
) config.services.hyperhive.swarm.peers
);
}
//
lib.optionalAttrs
(lib.any (p: p.caCert != null) (lib.attrValues config.services.hyperhive.swarm.peers))
{
# Peer-hive root CA file paths (colon-joined), one per peer that
# declares `swarm.peers.<domain>.caCert`. hive-c0re's meta-flake
# renderer (meta.rs) embeds each next to every agent's flake and
# adds it to `security.pki.certificateFiles`, so a peer CA is
# trusted everywhere the hive's own internal CA (`hive-ca.pem`)
# is — i.e. by every agent. The matrix container trusts the same
# CAs separately for federation TLS. The `caCert` files are
# copied into the nix store at build, so these are store paths —
# nothing mutable lives on the host.
HIVE_PEER_CA_PATHS = lib.concatStringsSep ":" (
lib.filter (c: c != null) (
lib.mapAttrsToList (_domain: p: p.caCert) config.services.hyperhive.swarm.peers
)
);
};
};
serviceConfig = {
ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --config ${serveConfig}";
# Migrate hive-c0re's *own* state to the service user after an

View file

@ -358,21 +358,6 @@ in
{
system.stateVersion = "26.05";
# Peer-hive root CAs (`swarm.peers.<domain>.caCert`) added to THIS
# container's trust bundle so tuwunel validates *federation* TLS
# from a self-signed peer hive (it checks the peer's federation
# cert against its trust bundle). Peer CAs are trusted everywhere
# the hive's own internal CA is — agents get them via the
# meta-flake renderer (`HIVE_PEER_CA_PATHS` → each agent's
# `security.pki.certificateFiles`); this block is the matrix
# container's copy, since the host `security.pki` store doesn't
# cross the container boundary. They are never installed in the
# HOST trust store. Null entries (CA-bundle / fingerprint-pinned
# peers) drop out.
security.pki.certificateFiles = lib.filter (c: c != null) (
lib.mapAttrsToList (_domain: p: p.caCert) config.services.hyperhive.swarm.peers
);
# tuwunel hard-fails to boot if `/etc/resolv.conf` has no
# `nameserver` line (`Failed to configure DNS resolver ... no
# nameservers found in config` → exit 1). This declarative