Compare commits

...
Author SHA1 Message Date
atlas
40403fc6d5 docs(#2916): drop two more doc-comments left dangling by the deletion
argus caught these on review. `recent_transient`'s doc still pointed at
NO_NODE_LABEL, and `running_transients`' still described destroy as
having no queue node behind it and linked suppress_crash_watch -- all
three deleted in the parent commit, so the first two were broken
intra-doc links and the third was simply false.

Migration is now the only operation in that sentence.
2026-08-14 00:24:45 +02:00
atlas
6338939657 refactor(#2916): destroy submits a DAG instead of an imperative teardown
Destroy was a straight-line async fn with no queue node behind it, so
nothing in the graph could answer "is this container going down on
purpose?". That gap is why an imperative crash-watch suppression guard
existed: an RAII handle held for the operation's duration, a second way
to say what every other lifecycle op already says through its node.

Reuse the existing Stop node rather than teaching a new node to stop
things:

    Stop -> DestroyContainer -> (PurgeState) -> DestroyBookkeeping

Stop already declares takes_container_down honestly, so the suppression
is now derived from the graph like every other op's. It also turns the
precondition into an edge: DestroyContainer runs only under a completed
Stop, so it operates on an already-stopped container and carries
takes_container_down = false permanently. A container still alive at
that point is a real bug and stays loud instead of being absorbed by a
flag -- which matters because a wrong true silently swallows a crash
while a wrong false only costs a spurious event.

Removes suppress_crash_watch, CrashWatchSuppression, crash_suppressed,
crash_watch_suppressed and NO_NODE_LABEL. The migration call sites went
with the obsolete startup migrations, so destroy was the last caller and
intent now has exactly one home.

destroy() becomes a submit-and-return, matching every sibling endpoint
(rebuild, kill, restart, start, pause, resume) -- it was the only
lifecycle op that awaited its work. The container rescan moves into the
bookkeeping tail, so ContainerRemoved now arrives after the 200 rather
than before it.

Also drops an orphaned doc-comment in coordinator.rs: two stacked blocks
where only the second described crash_suppressed, the first documenting
a field that no longer exists. Removing the field would have re-pointed
it at recent_transient.
2026-08-14 00:24:45 +02:00
damocles
2c7872841a hive-agent: guarantee a wake after a self-requested /compact 2026-08-13 23:17:10 +02:00
damocles
fa658567db hive-priv: replace json! with typed structs for account sidecar files 2026-08-13 23:16:17 +02:00
atlas
9f26c416c0 hide .git on the agent config mounts, like /knowledge already does
An agent's config mount is a git repo, so it could read every branch and
the full history of a config whose currently deployed value is the only
thing it may act on -- and an abandoned branch looks no different from a
live one.

The knowledge bind already solved this with an empty tmpfs overlaid on
its .git. Same rule, extended: an agent is handed a working tree, never
a repository. Folds both cases into git_overlay_flags so the reason is
stated once instead of hardcoded per mount.

Config mounts are matched by shape rather than a name list because the
set grows at runtime with each child bound into a parent.
2026-08-13 21:01:08 +02:00
atlas
c32a9367e4 gateway: reject unmatched Host instead of serving the dashboard
The `_` vhost was serving the hive's own surface, so every dashboard and
agent-UI request matched the default server rather than a named vhost --
and so did a request for any name at all, including a raw IP.

Split it: `_` keeps only `return 444`, and the hive surface moves to a
vhost named for the hive domain. `_` is `mkDefault` so an operator can
claim default_server themselves, plus an assertion for the case where
they add one without turning ours off -- nginx refuses to start on a
duplicate default_server and nixpkgs asserts nothing, so that would
otherwise surface as a gateway outage at rebuild time.
2026-08-13 19:57:27 +02:00
damocles
786e4610f0 hive-agent: replace json! with typed structs in web_ui handlers 2026-08-13 19:43:58 +02:00
atlas
127846ef1b mount the applied config repo, not the proposed one
/agents/<name>/config bound the working clone a config change is staged
in, so an agent could see a proposal that was never approved -- a config
that does not govern its container. Both objects already exist; this
repoints the bind at the deployed one.

Both mounts (own + child) now resolve through config_bind_source() so
they cannot drift, and agent_proposed_dir's doc-comment is corrected:
it claimed to be manager-editable and bind-mounted, and neither is true.
2026-08-13 19:31:17 +02:00
damocles
351341e87c hive-c0re: replace json! with typed structs in dashboard handlers 2026-08-13 18:12:35 +02:00
atlas
fbffccbbb2 feat(nix): a flake check that actually covers nix
Every other check in nix/checks.nix is a Rust derivation, so a
.nix-only diff moves no hash, the whole set is cache hits, and
`nix flake check` reports green without evaluating what changed.

`checks.module-eval` is one derivation holding a table of cases, each
named by the PROPERTY it defends. Its builder text embeds the evaluated
results, so the derivation's hash is a function of them: a nix change
that flips a property rebuilds the check and fails in the builder,
naming that property.

PROVEN, not assumed — the mechanism was executed before the cases were
written. Same expression with one property true vs false:

  drvPath true  -> 5v11mnbv…-module-eval.drv
  drvPath false -> ivm3dvv8…-module-eval.drv        (differs)
  build false   -> FAILS, stderr names the property

and the table itself was mutation-tested: inverting one case's
expectation gives `FAILED: a hive that has not opted into all-local
runs no swarm controller / module-eval: 1 of 5 properties broke`. A
check that cannot go red on a broken tree is not evidence.

Cases are named by property and never by ticket: a case named after the
ticket that prompted it has that ticket's lifetime; one named after the
property lives as long as the property does.

⚠️ It evaluates, it does not execute. Where the artifact is a command
line, a request or a certificate, a value assertion cannot stand in —
that is written into the file's header, because the gap is exactly what
made two earlier outages evaluable-but-broken.
2026-08-13 17:26:58 +02:00
atlas
f80facbbe0 refactor(3202): all-local asserts the host's own /etc/hosts entries
Clause 2 of #3202, reading 1 (mara: "the all local stuff and swarm
services auto conf belong in those mods, not spread all over").

`gateway.localHostsEntry` is the gateway's only local-deployment knob —
`openFirewall` is about EXTERNAL exposure, `tls.acme` needs a public DNS
name, `hsts` is a hardening choice. It is now asserted by the mode in
local-defaults.nix, beside the three swarm toggles, instead of being the
one all-local implication an operator still had to know about.

`mkDefault`, so "all local except this" still needs no new option.

⚠️ The non-obvious half: this does NOT change what CONTAINERS resolve.
dnsmasq sets `no-hosts = true` unconditionally, so agents keep getting
the bridge IP from the authoritative `address=` rules rather than the
host's 127.0.0.1 — which would point every agent at its own netns. That
guard already existing is what makes this safe to default on; without it
this one line would break every agent's access to the forge.
2026-08-13 17:26:08 +02:00
atlas
030eef0948 refactor(3202): the swarm UI declares its own vhost and dns name
Last of the four. The vhost, its `auth_request` block and the swarm
apex's dns record move into swarm-ui.nix; vhosts.nix drops `uiCfg`,
`controllerCfg` and `autheliaCfg` and is now 259 lines of hive surface
with no swarm service in it.

Also collapses a THIRD copy of the per-service list. `networking.hosts`
restated every service's name with its own copy of that service's guard,
after the vhosts and the dnsmasq records had each done the same. It asks
the same question — which names does this host answer for — so it now
reads the same answer: a service added later lands in /etc/hosts with no
edit, and cannot land there under a different condition than it used for
DNS.

The `forceSSL`-not-`addSSL` comment travels intact: it records that
authelia answers an http auth subrequest with 400 and nginx's
auth_request only understands 2xx/401/403, so the scheme is load-bearing
for this vhost and no other.
2026-08-13 16:19:08 +02:00
atlas
6caf177416 refactor(3202): matrix declares its own vhost, dns name and SPA map
Moves the matrix sub-domain vhost out of the gateway's vhosts.nix, its
`address=` rule out of dnsmasq.nix, and the Accept-header
`$matrix_spa_target` map out of the gateway's appendHttpConfig — all
three into hive-matrix.nix.

The map is the one that had no business being where it was: it exists
solely for the SPA fallback in the vhost's `/` location, and
`appendHttpConfig` is a `lines` option, so a module can contribute to
it without the gateway assembling it.

The `.well-known/matrix/*` delegation deliberately stays on the hive's
own vhost. The spec requires it at the SERVER NAME, which is the hive
domain: that is the hive answering "where is my homeserver", not the
homeserver answering for itself. Moving it would have been the obvious
symmetric thing and it would have been wrong.
2026-08-13 16:14:37 +02:00
atlas
56ab6d26c1 refactor(3202): authelia declares its own vhost and dns name
Moves the authelia vhost out of the gateway's vhosts.nix and its
`address=` rule out of dnsmasq.nix, into swarm-authelia.nix.

Both land inside that module's existing `cfg.enable` guard, which is
the load-bearing part: every hive in a swarm knows `authelia.url`, but
only the host that RUNS the container may claim the name. A client hive
declaring the vhost would answer for a service it does not run, and
publishing the DNS record would point every agent on its bridge at that
wrong answer.

The kit grows a fourth member, `errorPages`, because the vhost aims its
502/503/504 at the gateway's styled sso-unavailable page. Republished
rather than imported per module: a service rendering its own would drift
from the rest of the gateway the first time the theme changed.
2026-08-13 16:14:37 +02:00
atlas
d60a0585d6 refactor(3202): the forge declares its own vhost and dns name
Moves `forgeVhost` out of the gateway's vhosts.nix and the forge's
`address=` rule out of dnsmasq.nix, into nix/host-modules/hive-forge —
the module that already owns everything else about the forge.

The gateway keeps what is gateway knowledge (the listen set, which
issuer covers a name, the header block) and loses the last reason it
had to read `swarm.forge` at all: `forgeCfg` is gone from both files
and from the module's `let`.

Both halves stay gated on `behindGateway` — with it off the operator
fronts forgejo themselves, so this hive must neither claim the vhost nor
answer DNS for the name.
2026-08-13 16:14:36 +02:00
atlas
991cd24fc8 refactor(3202): the gateway takes contributed dns names
Adds `services.hyperhive.gateway.localNames` (internal): hostnames the
hive resolver answers with the bridge IP, contributed by the modules
that own them. The service says which name, the gateway says where it
points — the same split `lib.tlsFor` already makes.

No behaviour change yet: the list is empty until the service modules
contribute in the following commits.

The assertion is not defensive padding. Duplicate `address=` rules do
not make dnsmasq complain; it resolves them by precedence, so a name
claimed twice silently stops being served by one of its claimants. That
failure mode only becomes reachable because contribution is now open, so
it gets closed in the same commit that opens it.
2026-08-13 16:14:36 +02:00
41 changed files with 1469 additions and 644 deletions

1
Cargo.lock generated
View file

@ -1795,6 +1795,7 @@ dependencies = [
"anyhow",
"hive-priv-sock",
"libc",
"serde",
"serde_json",
"tokio",
"tracing",

View file

@ -204,6 +204,7 @@
system
treefmt-eval
;
inherit (nixpkgs.lib) nixosSystem;
}
);
};

View file

@ -80,6 +80,19 @@ pub struct RemindArgs {
pub file_path: Option<String>,
}
/// MCP tool args for `compact`.
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct CompactArgs {
/// Optional wake-up prompt. When set and the compact actually runs
/// (gated on context usage — see the tool description), the harness
/// drives one synthetic follow-up turn with this string as its body
/// as soon as compaction finishes, so you don't have to wait for the
/// next external event to continue. Omit for a fire-and-forget compact
/// with no follow-up.
#[serde(default)]
pub wake_prompt: Option<String>,
}
// -----------------------------------------------------------------------------
// Privileged tool arg types (lifecycle, approvals, scheduling, diagnostics)
// -----------------------------------------------------------------------------

View file

@ -25,10 +25,10 @@ mod render;
pub use args::{
AckUntilArgs, AgentGetLooseEndsArgs, AnswerArgs, AskArgs, CancelLooseEndArgs,
CancelScheduleArgs, CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs,
GetHostJournalArgs, GetLogsArgs, KillArgs, MarkTodosDoneArgs, RecvArgs, RemindArgs,
RequestInitConfigArgs, RequestSchedulePromptArgs, RestartArgs, SendArgs, SetStatusArgs,
StartArgs, UpdateArgs, UpdateMetaInputsArgs,
CancelScheduleArgs, CompactArgs, CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs,
GetAgentMetaArgs, GetHostJournalArgs, GetLogsArgs, KillArgs, MarkTodosDoneArgs, RecvArgs,
RemindArgs, RequestInitConfigArgs, RequestSchedulePromptArgs, RestartArgs, SendArgs,
SetStatusArgs, StartArgs, UpdateArgs, UpdateMetaInputsArgs,
};
pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv};
@ -657,11 +657,19 @@ impl AgentServer {
that the call is refused with an explanation and has no effect. On a pass, \
queues compaction for the end of the current turn (same deferred mechanism \
the dashboard button uses, so it never races a live claude process); the \
usual pre-compaction notes-checkpoint turn still fires first. No args."
usual pre-compaction notes-checkpoint turn still fires first. Pass \
`wake_prompt` to have the harness drive one synthetic follow-up turn with \
that body as soon as compaction finishes without it you just go idle \
waiting for the next external event, same as ending a turn normally."
)]
async fn compact(&self) -> String {
run_tool_envelope("compact", String::new(), async move {
match dial_agent_socket(&hive_agent_sock::Request::Compact).await {
async fn compact(&self, Parameters(args): Parameters<CompactArgs>) -> String {
let log = format!("{args:?}");
run_tool_envelope("compact", log, async move {
match dial_agent_socket(&hive_agent_sock::Request::Compact {
wake_prompt: args.wake_prompt,
})
.await
{
Some(hive_agent_sock::Response::Ok) => {
"compact queued — will run at the end of the current turn".to_owned()
}

View file

@ -117,7 +117,12 @@ pub enum Request {
/// explains why and takes no action. On a pass, queues the same
/// deferred `compact_pending` flag the operator's button sets (consumed
/// at the next turn boundary), so it never races a live claude process.
Compact,
/// `wake_prompt`, when set, is driven as a synthetic follow-up turn once
/// the compaction actually finishes — the dashboard button's own
/// requests go through `Bus::request_compact` directly with `None`, not
/// through this variant, since a human watching the dashboard isn't
/// waiting on a wake.
Compact { wake_prompt: Option<String> },
/// Mirror an outstanding question this agent asked (`ask()` succeeded).
/// `target` is who it's waiting on (`"operator"` when asked with
/// `to: None`). Part of the questions-mirror increment — see

View file

@ -260,6 +260,14 @@ pub enum TurnState {
Compacting,
}
/// One pending `/compact` request (see `Bus::request_compact`).
/// `wake_prompt` is what to drive as a synthetic follow-up turn once the
/// compaction actually finishes, if anything.
#[derive(Debug, Clone)]
pub struct CompactRequest {
pub wake_prompt: Option<String>,
}
#[derive(Clone)]
pub struct Bus {
tx: Arc<broadcast::Sender<BusEvent>>,
@ -307,7 +315,23 @@ pub struct Bus {
/// One-shot: run `/compact` after the next turn ends. Consumed at the end
/// of the current/next turn by `turn::drive_turn`. Deferring to the turn
/// boundary keeps compaction from racing a live claude process mid-turn.
compact_pending: Arc<AtomicBool>,
/// `Some(request)` when a compact is pending; `request.wake_prompt` is
/// what to drive as a synthetic follow-up turn once the compaction
/// actually completes (`None` = pending but no follow-up wake wanted,
/// e.g. the operator dashboard's `/compact` button). `None` = no compact
/// pending. Wrapped in [`CompactRequest`] rather than
/// `Option<Option<String>>` (clippy pedantic's `option_option` lint,
/// and the named field reads clearer at call sites than a bare nested
/// `Option`) so "pending" and "what to wake with" can never desync.
compact_pending: Arc<Mutex<Option<CompactRequest>>>,
/// One-shot, written by `turn::drive_turn`/`turn::run_pending_compact`
/// right after a compaction they served finishes, when that compact's
/// request carried a `wake_prompt`. Read once by the `hive-agent` serve
/// loop after either call site to decide whether to drive a synthetic
/// follow-up turn. Separate from `compact_pending`: by the time this is
/// set, the compact has already run and that flag has already been
/// cleared by `take_compact`.
post_compact_wake: Arc<Mutex<Option<String>>>,
/// Current fresh-claude-session id (FK to `sessions.id`). Set by the
/// bin loop after minting a session row on a fresh start; stamped onto
/// every `turn_stats` row until the next fresh session. `None` before
@ -397,7 +421,8 @@ impl Bus {
last_cost_usage: Arc::new(Mutex::new(None)),
rate_limited: Arc::new(AtomicBool::new(was_rate_limited)),
session_reset_pending: Arc::new(AtomicBool::new(false)),
compact_pending: Arc::new(AtomicBool::new(false)),
compact_pending: Arc::new(Mutex::new(None)),
post_compact_wake: Arc::new(Mutex::new(None)),
session_id: Arc::new(Mutex::new(None)),
fresh_session: Arc::new(AtomicBool::new(false)),
tool_calls: Arc::new(Mutex::new(std::collections::HashMap::new())),
@ -438,16 +463,40 @@ impl Bus {
}
/// Request a compaction after the next turn ends (deferred to the turn
/// boundary). Idempotent.
pub fn request_compact(&self) {
self.compact_pending.store(true, Ordering::SeqCst);
/// boundary). Idempotent — a second request before the first is
/// serviced just overwrites `wake_prompt` with the latest ask. `Some
/// (wake_prompt)` schedules a synthetic follow-up turn (driven with
/// `wake_prompt` as its body) once the compaction actually completes;
/// `None` requests a plain compact with no follow-up wake (the operator
/// dashboard's `/compact` button).
pub fn request_compact(&self, wake_prompt: Option<String>) {
*self.compact_pending.lock().unwrap() = Some(CompactRequest { wake_prompt });
}
/// Take + clear the compact one-shot. Returns true iff `drive_turn` should
/// compact at the end of this turn.
/// Take + clear the compact one-shot. `Some(request)` means
/// `drive_turn`/`run_pending_compact` should compact now —
/// `request.wake_prompt` is what to pass to `set_post_compact_wake` once
/// that compaction finishes. `None` means no compact is pending.
#[must_use]
pub fn take_compact(&self) -> bool {
self.compact_pending.swap(false, Ordering::SeqCst)
pub fn take_compact(&self) -> Option<CompactRequest> {
self.compact_pending.lock().unwrap().take()
}
/// Record that a just-finished compaction should drive a synthetic
/// follow-up turn with `prompt` as its body. Called by
/// `turn::drive_turn`/`turn::run_pending_compact` right after the
/// compaction they served (whose `take_compact()` returned a request
/// with `wake_prompt: Some(prompt)`) completes.
pub fn set_post_compact_wake(&self, prompt: String) {
*self.post_compact_wake.lock().unwrap() = Some(prompt);
}
/// Take + clear the post-compact wake one-shot. The serve loop calls
/// this after either compact call site to decide whether to
/// synthesize a follow-up turn.
#[must_use]
pub fn take_post_compact_wake(&self) -> Option<String> {
self.post_compact_wake.lock().unwrap().take()
}
/// Mark that the current turn started a fresh claude session.

View file

@ -240,6 +240,21 @@ fn synthetic_todo_message(stern: bool) -> hive_sh4re::inbox::DeliveredMessage {
}
}
/// Synthetic message that drives the follow-up turn when a `/compact` call
/// (self-requested via the `compact` MCP tool's `wake_prompt` arg) finishes
/// and asked to be woken. Mirrors `synthetic_todo_message`'s "no broker row"
/// sentinel shape (`id = 0`) — the compact tool call itself is the durable
/// record that a wake was requested, not a broker message.
fn post_compact_wake_message(prompt: String) -> hive_sh4re::inbox::DeliveredMessage {
hive_sh4re::inbox::DeliveredMessage {
from: "compact".into(),
body: prompt,
id: 0,
redelivered: false,
in_reply_to: None,
}
}
/// Synthetic message that drives the single stop-checkpoint turn when c0re
/// signals a graceful stop. The agent gets one final turn to flush durable
/// `/state` before the container is stopped; new inbound is already fenced.
@ -772,8 +787,18 @@ async fn serve_loop<S: Surface>(
let compacted = turn::run_pending_compact(files, &bus, &session).await;
if !compacted {
tokio::time::sleep(interval).await;
continue;
}
continue;
// The compact that just ran may have carried a wake prompt
// (the agent's own `compact` tool, not the operator button —
// see `Bus::request_compact`). If so, drive it as a synthetic
// turn right now instead of looping back to `recv_next` and
// waiting for the next external event.
let Some(prompt) = bus.take_post_compact_wake() else {
continue;
};
tracing::debug!("post-compact wake queued, driving synthetic follow-up turn");
post_compact_wake_message(prompt)
}
RecvOutcome::TransportError => {
// `recv_next` already logged the detail; just retry.
@ -802,32 +827,83 @@ async fn serve_loop<S: Surface>(
return Ok(());
}
};
let ctrl = handle_turn::<S>(
let turn_ctx = TurnCtx {
socket,
&bus,
stats.as_ref(),
bus: &bus,
stats: stats.as_ref(),
files,
&session,
session: &session,
interrupted: &interrupted,
login_state: &login_state,
claude_dir: &claude_dir,
interval,
};
drive_turn_and_wake_chain::<S>(&turn_ctx, next, &mut todo_miss_streak).await;
}
}
/// Loop-invariant turn-driving context for `drive_turn_and_wake_chain`,
/// threaded as one bundle instead of double-digit positional args
/// (clippy's `too_many_arguments`). Everything here is constant for the
/// lifetime of one `serve_loop` call; only the message to drive and the
/// todo-miss streak vary per turn and stay as separate params.
struct TurnCtx<'a> {
socket: &'a Path,
bus: &'a Bus,
stats: Option<&'a TurnStats>,
files: &'a turn::TurnFiles,
session: &'a turn::AgentSession,
interrupted: &'a Arc<std::sync::atomic::AtomicBool>,
login_state: &'a Arc<Mutex<LoginState>>,
claude_dir: &'a Path,
interval: Duration,
}
/// Drive `next`, then keep driving synthetic follow-up turns for as long as
/// a compact that just ran carries a wake prompt
/// (`Bus::take_post_compact_wake`) — see `serve_loop`'s comment at the call
/// site for why this loops in place instead of returning to the outer
/// `select!`/`recv_next`. Ordinarily runs exactly one iteration; only chains
/// further if the follow-up turn itself requests another woken compact.
/// Split out of `serve_loop` purely to keep that function under clippy's
/// line limit.
async fn drive_turn_and_wake_chain<S: Surface>(
ctx: &TurnCtx<'_>,
mut next: hive_sh4re::inbox::DeliveredMessage,
todo_miss_streak: &mut u32,
) {
loop {
let ctrl = handle_turn::<S>(
ctx.socket,
ctx.bus,
ctx.stats,
ctx.files,
ctx.session,
next,
&interrupted,
ctx.interrupted,
)
.await;
apply_todo_wake_checked(ctrl.todo_wake_checked, &mut todo_miss_streak, &bus);
apply_todo_wake_checked(ctrl.todo_wake_checked, todo_miss_streak, ctx.bus);
if ctrl.auth_failed {
*login_state.lock().unwrap() = LoginState::NeedsLogin;
*ctx.login_state.lock().unwrap() = LoginState::NeedsLogin;
// Baseline the resume check on *this instant*, not on a
// directory snapshot taken after `wait_for_login` starts
// polling — closes the race where a login lands between the
// 401 and the first poll. See `wait_for_login`'s doc comment.
login::wait_for_login(
&claude_dir,
login_state.clone(),
&bus,
u64::try_from(interval.as_millis()).unwrap_or(2000),
ctx.claude_dir,
ctx.login_state.clone(),
ctx.bus,
u64::try_from(ctx.interval.as_millis()).unwrap_or(2000),
std::time::SystemTime::now(),
)
.await;
}
let Some(prompt) = ctx.bus.take_post_compact_wake() else {
break;
};
tracing::debug!("post-compact wake queued mid-turn-flow, driving synthetic follow-up turn");
next = post_compact_wake_message(prompt);
}
}

View file

@ -242,7 +242,7 @@ fn dispatch(
} => record_answering_question(questions, id, &asker, &question),
Request::ClearQuestion { id } => clear_question(questions, id),
Request::ListQuestions => list_questions(questions),
Request::Compact => compact(bus),
Request::Compact { wake_prompt } => compact(bus, wake_prompt),
}
}
@ -458,8 +458,10 @@ fn mark_todos_done(store: &Todos, ids: &[i64]) -> Response {
/// the agent's own MCP tool instead of the dashboard, and refuses below
/// [`COMPACT_MIN_USAGE_FRACTION`] instead of always honouring the request —
/// an agent can call this speculatively, a human clicking the dashboard
/// button already made the judgment call.
fn compact(bus: &Bus) -> Response {
/// button already made the judgment call. `wake_prompt`, when set, is
/// forwarded to `Bus::request_compact` so the turn loop drives a synthetic
/// follow-up turn once the compaction actually finishes.
fn compact(bus: &Bus, wake_prompt: Option<String>) -> Response {
let Some(usage) = bus.last_ctx_usage() else {
return Response::Err {
message: "compact refused: no completed turn yet — nothing to compact".to_owned(),
@ -487,9 +489,16 @@ fn compact(bus: &Bus) -> Response {
),
};
}
bus.request_compact();
let will_wake = wake_prompt.is_some();
bus.request_compact(wake_prompt);
bus.emit(crate::events::LiveEvent::Note {
text: "agent: self-requested /compact — running at the end of the current turn".into(),
text: if will_wake {
"agent: self-requested /compact (with wake prompt) — running at the end of the \
current turn"
.into()
} else {
"agent: self-requested /compact — running at the end of the current turn".into()
},
});
Response::Ok
}

View file

@ -374,15 +374,18 @@ pub async fn drive_turn(
archive_session(bus);
return Err(TurnError::PromptTooLong);
}
// Operator `/compact` (`POST /api/compact`) deferred to the turn boundary:
// run it now that the turn is done, so it works mid-turn rather than only
// when the agent is idle. Only on a healthy turn — no point spawning a
// compaction after a rate-limited / auth-failed / crashed one.
// `is_ok()` first: `take_compact()` clears the flag, so it must only fire
// when the compaction will actually run. On an unhealthy turn
// (rate-limited / auth-failed / failed) the flag is left set for the next
// turn or the idle `run_pending_compact` to service — not silently eaten.
if outcome.is_ok() && bus.take_compact() {
// Operator `/compact` (`POST /api/compact`) or an agent's own `compact`
// MCP tool call, deferred to the turn boundary: run it now that the turn
// is done, so it works mid-turn rather than only when the agent is idle.
// Only on a healthy turn — no point spawning a compaction after a
// rate-limited / auth-failed / crashed one. `is_ok()` first: `take_compact()`
// clears the flag, so it must only fire when the compaction will actually
// run. On an unhealthy turn (rate-limited / auth-failed / failed) the flag
// is left set for the next turn or the idle `run_pending_compact` to
// service — not silently eaten.
if outcome.is_ok()
&& let Some(request) = bus.take_compact()
{
bus.emit(LiveEvent::Note {
text: "operator: /compact — running at turn end".into(),
});
@ -390,6 +393,14 @@ pub async fn drive_turn(
// does; the serve loop resets to `Idle` once this turn returns.
bus.set_state(crate::events::TurnState::Compacting);
let _ = session.compact(&config, &sink).await;
// If the compact call asked to be woken (the agent's own `compact`
// tool with a `wake_prompt`), stash it — the serve loop reads it
// back after this turn returns and drives a synthetic follow-up
// turn, so a self-requested compact provably doesn't strand the
// agent idle waiting for the next external event.
if let Some(prompt) = request.wake_prompt {
bus.set_post_compact_wake(prompt);
}
return Ok(true);
}
outcome
@ -500,11 +511,13 @@ pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) {
/// so a queued `/compact` runs even when no turn is driving. (The in-flight
/// case is handled at the end of [`drive_turn`].) Resume-only via
/// [`InfiniteSession::compact`]: a missing session is a harmless no-op. Returns
/// `true` if a compaction ran.
/// `true` if a compaction ran; the serve loop follows up with
/// `Bus::take_post_compact_wake` to see whether a synthetic follow-up turn
/// should run (set below when the compact request carried a `wake_prompt`).
pub async fn run_pending_compact(files: &TurnFiles, bus: &Bus, session: &AgentSession) -> bool {
if !bus.take_compact() {
let Some(request) = bus.take_compact() else {
return false;
}
};
bus.emit(LiveEvent::Note {
text: "operator: /compact — running on idle session".into(),
});
@ -520,6 +533,9 @@ pub async fn run_pending_compact(files: &TurnFiles, bus: &Bus, session: &AgentSe
}),
}
bus.set_state(crate::events::TurnState::Idle);
if let Some(prompt) = request.wake_prompt {
bus.set_post_compact_wake(prompt);
}
true
}

View file

@ -7,7 +7,7 @@ use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use super::{AppState, SigintOutcome, error_response};
@ -80,7 +80,10 @@ pub(super) async fn post_cancel_turn(State(state): State<AppState>) -> Response
/// claude process) rather than only when the agent is idle. Returns 200
/// immediately; the compaction stream lands in the live panel when it runs.
pub(super) async fn post_compact(State(state): State<AppState>) -> Response {
state.bus.request_compact();
// No wake prompt: the operator is watching the dashboard, not waiting on
// an inbox message — `request_compact`'s wake-prompt arg exists for the
// agent's own `compact` MCP tool (`todo_server.rs::compact`).
state.bus.request_compact(None);
state.bus.emit(crate::events::LiveEvent::Note {
text: "operator: /compact queued — runs at the end of the current turn".into(),
});
@ -202,5 +205,10 @@ pub(super) async fn post_mark_todos_done(Form(form): Form<MarkTodosDoneForm>) ->
acked += count;
}
}
axum::Json(serde_json::json!({ "acked": acked })).into_response()
axum::Json(MarkTodosDoneBody { acked }).into_response()
}
#[derive(Serialize)]
struct MarkTodosDoneBody {
acked: u64,
}

View file

@ -2,7 +2,7 @@
use axum::extract::State;
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use super::AppState;
@ -40,6 +40,11 @@ async fn fetch_reminder_stats(window_secs: u64) -> Option<hive_sh4re::approvals:
}
}
#[derive(Serialize)]
struct TodosBody {
todos: Vec<hive_sh4re::inbox::LooseEnd>,
}
/// `GET /api/todos` — snapshot of this agent's local todos (loose-ends v2).
///
/// Connects to the in-agent harness socket (`HIVE_AGENT_SOCKET`) and calls
@ -54,5 +59,5 @@ pub(super) async fn api_todos() -> Response {
Some(hive_agent_sock::Response::LooseEnds { loose_ends }) => loose_ends,
_ => Vec::new(),
};
axum::Json(serde_json::json!({ "todos": todos })).into_response()
axum::Json(TodosBody { todos }).into_response()
}

View file

@ -5,11 +5,23 @@ use std::convert::Infallible;
use axum::Json;
use axum::extract::{Query, State};
use axum::response::sse::{Event, KeepAlive, Sse};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream};
use super::AppState;
/// Response body for `GET /api/events/history`. `seq` is omitted from the
/// wire entirely on a paginated (non-initial) load — matches the old
/// `json!` shape, which only ever set the `"seq"` key when `Some`.
#[derive(Serialize)]
pub(super) struct EventsHistoryBody {
events: Vec<crate::events::StoredEvent>,
min_id: Option<i64>,
has_more: bool,
#[serde(skip_serializing_if = "Option::is_none")]
seq: Option<u64>,
}
/// Query params for the paginated history endpoint.
#[derive(Debug, Deserialize)]
pub(super) struct HistoryParams {
@ -23,7 +35,7 @@ pub(super) struct HistoryParams {
pub(super) async fn events_history(
State(state): State<AppState>,
Query(params): Query<HistoryParams>,
) -> Json<serde_json::Value> {
) -> Json<EventsHistoryBody> {
use crate::events::HISTORY_CAPACITY;
let limit = params.limit.unwrap_or(100).min(HISTORY_CAPACITY);
let before = params.before;
@ -51,15 +63,12 @@ pub(super) async fn events_history(
se
})
.collect();
let mut resp = serde_json::json!({
"events": events,
"min_id": min_id,
"has_more": has_more,
});
if let Some(s) = seq {
resp["seq"] = serde_json::json!(s);
}
Json(resp)
Json(EventsHistoryBody {
events,
min_id,
has_more,
seq,
})
}
pub(super) async fn events_stream(

View file

@ -899,99 +899,20 @@ pub async fn run_finalize_deploy(coord: &Arc<Coordinator>, approval_id: i64) ->
/// imperative infra that `auto_update::ensure_root_agent` recreates on the
/// next hive-c0re startup if absent, so destroying it is transient rather
/// than something to refuse at the API.
pub async fn destroy(coord: &Arc<Coordinator>, name: &str, purge: bool) -> Result<()> {
///
/// Submits the teardown DAG and returns — it does not wait for the container to
/// go away. Same contract as every other lifecycle op (`rebuild`, `kill`,
/// `restart`, `start`): the queue owns the work, the caller gets an
/// acknowledgement. Progress is visible as real nodes on the dashboard.
pub fn destroy(coord: &Arc<Coordinator>, name: &str, purge: bool) {
tracing::info!(%name, purge, "destroy");
// Guard auto-clears on the success path's final scope exit and on
// every early-return / cancellation along the way.
// Destroy has no queue node behind it, so nothing in the graph says this
// container is going away on purpose — without this the crash watcher
// reports every destroy as a crash and the manager tries to recover it.
let guard = coord.suppress_crash_watch(name);
lifecycle::destroy(name).await?;
coord.unregister_agent(name);
let runtime = crate::paths::agent_runtime_dir(name);
if runtime.exists() {
let _ = std::fs::remove_dir_all(&runtime);
if let Err(e) = coord.job_queue.insert_job(|b| {
crate::job_queue::templates::destroy(b, name, purge);
Vec::new()
}) {
tracing::error!(agent = %name, error = ?e, "destroy: insert failed");
}
if purge {
// The state root may be a btrfs subvolume: a subvolume root
// can't be removed with rmdir/`remove_dir_all`, so delete it via
// hive-priv (root) first. No-op for plain-dir agents — the loop below
// then handles the plain-dir state root plus the applied dir.
if let Err(e) = crate::priv_client::delete_agent_subvolume(name).await {
tracing::warn!(error = ?e, %name, "purge: delete state subvolume failed");
}
// A malformed name can't have a persistent state tree (the state dir
// is only ever created under a validated Ident), so its removal is a
// no-op — skip the state-dir sweep and just clear the applied dir.
let state_dir = hive_types::Ident::parse(name)
.ok()
.map(|id| crate::paths::agent_state_dir(&id));
for dir in state_dir
.into_iter()
.chain([crate::paths::applied_dir(name)])
{
if dir.exists()
&& let Err(e) = std::fs::remove_dir_all(&dir)
{
tracing::warn!(error = ?e, dir = %dir.display(), "purge: remove failed");
}
}
}
// Meta flake: drop the agent's input + nixosConfiguration so a
// future spawn under the same name re-seeds cleanly, and so the
// meta lock doesn't reference a vanished applied repo. Log + keep
// going on failure — destroy already succeeded at the
// nixos-container level, the meta repo is just bookkeeping.
if let Err(e) = sync_meta_after_lifecycle(coord).await {
tracing::warn!(error = ?e, %name, "meta sync after destroy failed");
}
let _ = coord.approvals.fail_pending_for_agent(
name,
if purge {
"agent purged"
} else {
"agent destroyed"
},
);
// Drop the durable power intent — a future agent of the same name
// seeds fresh from its observed state.
if let Err(e) = coord.power.remove(name) {
tracing::warn!(%name, error = ?e, "agent_power: remove on destroy failed");
}
drop(guard);
let _ = coord
.push_todo(
hive_sh4re::manager::MANAGER_AGENT,
"core",
Some(format!("destroyed:{name}")),
format!("agent '{name}' destroyed"),
None,
false,
)
.await;
// Container row disappeared — rescan so the dashboard fires
// `ContainerRemoved` for the gone row, then emit the
// tombstones snapshot (gained one on destroy, lost one on
// purge — recompute either way).
coord.rescan_containers_and_emit().await;
crate::dashboard::emit_tombstones_snapshot(coord).await;
// Re-emit the schedules snapshot: the rescan above refreshed the live
// roster, so any schedule that still targets the just-destroyed agent
// now drops that ghost column live (no page reload needed).
coord.emit_schedules_snapshot();
// Update tmpfiles.d to remove the destroyed agent's dirs from the
// boot-time pre-creation list. Best-effort: failure is logged only.
tokio::spawn(lifecycle::sync_tmpfiles());
Ok(())
}
/// Rerender the meta flake from whatever containers still exist on
/// disk. Called after lifecycle ops that change the agent set (today:
/// destroy). Idempotent — a no-op when nothing changed.
async fn sync_meta_after_lifecycle(coord: &Coordinator) -> Result<()> {
let agents = lifecycle::agents_for_meta_listing().await?;
crate::meta::sync_agents(&coord.hive_env(), &agents).await
coord.emit_rebuild_queue_snapshot();
}
pub fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> {

View file

@ -116,13 +116,6 @@ pub struct Coordinator {
/// is never injected into containers.
pub model_prices: crate::hive_stats::PriceTable,
agents: Mutex<HashMap<String, AgentSocket>>,
/// Agents whose lifecycle action (currently just spawn) is in flight.
/// Read by the dashboard to render a spinner; cleared when the action
/// resolves (success or failure).
/// Agents whose container is being taken down by work with **no queue node
/// behind it** (destroy, migration), so the crash watcher must not report
/// the disappearance as a crash. Not a pill — see [`CrashWatchSuppression`].
crash_suppressed: Mutex<HashSet<String>>,
/// Tombstone for transients that have JUST been cleared. The
/// crash watcher polls every 10s and would race the
/// drop-clears-immediately path of `TransientGuard`: an operator
@ -144,9 +137,7 @@ pub struct Coordinator {
/// live and both clear. Keyed by agent, the last clear *overwrites* the
/// others: a `Prebuild` (`deliberate_stop = false`) landing after a
/// `StopForUpdate` (`true`) leaves the tombstone reading `false`, and the
/// crash watcher then reports an intentional stop as a **crash**. The
/// out-of-band suppression guard, which has no node behind it, uses
/// [`NO_NODE_LABEL`].
/// crash watcher then reports an intentional stop as a **crash**.
recent_transient: Mutex<HashMap<(String, String), (bool, std::time::Instant)>>,
/// Timestamps of recent unexpected container crashes, keyed by agent.
/// Fed by `crash_watch` each time it classifies a stop as a crash (so
@ -400,57 +391,6 @@ fn fold_tombstones_by_agent<'a>(
out
}
/// Tombstone label for work with **no queue node behind it** — the
/// out-of-band operations (destroy, migration) that hold a
/// [`CrashWatchGuard`] instead of appearing in the derived transient set.
///
/// [`Coordinator::recent_transient`] is keyed by `(agent, label)` so concurrent
/// pills can't overwrite each other's `deliberate_stop`; a guard has no node and
/// therefore no node label, so it needs one of its own. Angle-bracketed to keep
/// it out of the `NodeKind::as_str` namespace — no node can ever render this.
const NO_NODE_LABEL: &str = "<no-node>";
/// RAII handle returned by [`Coordinator::suppress_crash_watch`]. While held,
/// the crash watcher treats this container disappearing as **expected**.
///
/// This is *not* a dashboard pill. Transients are derived from running queue
/// nodes and nothing stores them. But destroy and migration take a container
/// down without a node behind them, so nothing in the graph says the
/// disappearance was intended — and without that, `crash_watch` fires a
/// `ContainerCrash` for every destroy and every migrated agent, and the manager
/// tries to "recover" containers that were removed on purpose.
///
/// It is held rather than stamped once because
/// [`crate::workers::crash_watch`]'s grace window is finite and these
/// operations are not: a long destroy would outlive a single tombstone. The
/// tombstone is stamped on drop, covering the poll that lands just after.
///
/// Goes away entirely once destroy + migration are real queue nodes.
#[must_use = "suppression lasts as long as the guard; bind it for the operation's duration \
(`let _guard = coord.suppress_crash_watch(...)`). An unbound call drops it \
immediately and the very next poll can report a deliberate stop as a crash."]
pub struct CrashWatchSuppression {
coord: Arc<Coordinator>,
name: String,
}
impl Drop for CrashWatchSuppression {
fn drop(&mut self) {
self.coord
.crash_suppressed
.lock()
.unwrap()
.remove(&self.name);
// Tombstone the release so the next poll — which may land in the
// window between the container going away and this guard dropping —
// still reads the stop as deliberate.
self.coord.recent_transient.lock().unwrap().insert(
(self.name.clone(), NO_NODE_LABEL.to_owned()),
(true, std::time::Instant::now()),
);
}
}
/// RAII guard for the `meta-update` in-progress flag, held for the
/// duration of a `run_meta_update` background task. Created by
/// `Coordinator::meta_update_guard`. Drop decrements the active-run
@ -586,7 +526,6 @@ impl Coordinator {
agent_io_weight,
model_prices,
agents: Mutex::new(HashMap::new()),
crash_suppressed: Mutex::new(HashSet::new()),
recent_transient: Mutex::new(HashMap::new()),
recent_crashes: Mutex::new(HashMap::new()),
graceful_stop_pending: Mutex::new(HashSet::new()),
@ -1300,46 +1239,16 @@ impl Coordinator {
map.iter().map(|(k, v)| (k.clone(), v.len())).collect()
}
/// Tell the crash watcher that `name`'s container is going down **on
/// purpose**, for the lifetime of the returned guard. See
/// [`CrashWatchSuppression`] for why this exists at all.
///
/// Only for the operations with no queue node behind them. Anything the
/// job queue runs answers this from the node itself
/// ([`crate::job_queue::NodeKind::takes_container_down`]) and must not come
/// through here.
///
/// The guard's `Drop` runs even on task cancellation, so an aborted HTTP
/// request or a panic mid-destroy can't leave a container permanently
/// exempt from crash reporting.
pub fn suppress_crash_watch(self: &Arc<Self>, name: &str) -> CrashWatchSuppression {
self.crash_suppressed
.lock()
.unwrap()
.insert(name.to_owned());
CrashWatchSuppression {
coord: self.clone(),
name: name.to_owned(),
}
}
/// Whether a no-node operation is currently taking this container down.
#[must_use]
pub fn crash_watch_suppressed(&self, name: &str) -> bool {
self.crash_suppressed.lock().unwrap().contains(name)
}
/// Every live transient, keyed by agent.
///
/// **Derived on read, stored nowhere.** Straight off the running graph, so
/// there is no cached copy to go stale, leak, or disagree with what is
/// actually running.
///
/// Work with no queue node behind it (destroy, migration) therefore shows
/// **no pill** — there is nothing in the graph to derive one from. Its
/// crash-watch suppression is a separate, narrower thing
/// ([`Coordinator::suppress_crash_watch`]); the pill comes back for free
/// once those become real nodes.
/// Migration is the last operation with no queue node behind it, so it
/// shows **no pill** — there is nothing in the graph to derive one from.
/// It gets one for free once it becomes real nodes, the way destroy did.
///
/// ⚠️ **A `Vec` per agent, not one entry.** `running_transients` tests
/// status alone, so a lease-exempt `Prebuild` for `a` and a lease-holding
/// `StopForUpdate` for `a` are both live pills. Collapsing them to one
@ -1567,8 +1476,13 @@ impl Coordinator {
crate::paths::agent_runtime_dir(name).join("mcp.sock")
}
/// Manager-editable proposed config repo. Bind-mounted into the manager
/// container as `/agents/<name>/config/`.
/// The *proposed* config repo: where a config change lands before it is
/// applied, and what an approved deploy promotes into `applied_dir`.
///
/// **Not bind-mounted into any container.** An agent that edits a config
/// clones it from the forge itself; `/agents/<name>/config` shows the
/// applied (deployed) tree instead — see `config_bind_source` in
/// `lifecycle/host_config.rs`.
pub fn agent_proposed_dir(name: &hive_types::Ident) -> PathBuf {
crate::paths::agent_state_dir(name).join("config")
}

View file

@ -28,19 +28,20 @@ use utoipa::ToSchema;
use crate::host_stats::ServerWarning;
#[derive(Serialize, ToSchema)]
struct LiveBody {
status: &'static str,
}
/// Liveness. Always `200`; no further checks.
#[utoipa::path(
get,
path = "/health/live",
responses((status = 200, description = "process is up", body = serde_json::Value)),
responses((status = 200, description = "process is up", body = LiveBody)),
tag = "health"
)]
pub(super) async fn get_health_live() -> Response {
(
StatusCode::OK,
axum::Json(serde_json::json!({ "status": "ok" })),
)
.into_response()
(StatusCode::OK, axum::Json(LiveBody { status: "ok" })).into_response()
}
#[derive(Serialize, ToSchema)]

View file

@ -436,10 +436,9 @@ pub(super) struct DestroyForm {
params(("name" = String, Path, description = "agent name")),
request_body(content = DestroyForm, content_type = "application/x-www-form-urlencoded"),
responses(
(status = 200, description = "destroyed", body = String),
(status = 200, description = "destroy queued", body = String),
(status = 400, description = "bad agent name"),
(status = 404, description = "no such agent"),
(status = 500, description = "destroy failed"),
),
tag = "lifecycle_ops"
)]
@ -453,11 +452,10 @@ pub(super) async fn post_destroy(
}
// Checkbox semantics: any non-empty value (axum sends "on") = purge.
let purge = form.purge.as_deref().is_some_and(|v| !v.is_empty());
// `actions::destroy` rescans the container list on success, so the
// `ContainerRemoved` event lands before we return 200. The matching
// form carries `data-no-refresh`.
match actions::destroy(&state.coord, &name, purge).await {
Ok(()) => (StatusCode::OK, "ok").into_response(),
Err(e) => error_response(&format!("destroy {name} failed: {e:#}")),
}
// Submit-and-return, like every other lifecycle endpoint here. The
// container rescan now runs in the DAG's bookkeeping tail, so
// `ContainerRemoved` arrives *after* this 200 rather than before it — the
// row disappears when the event lands, same as a rebuild's does.
actions::destroy(&state.coord, &name, purge);
(StatusCode::OK, "ok").into_response()
}

View file

@ -8,26 +8,41 @@ use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use super::{AppState, Ident, error_response, scan_validated_paths};
use crate::audit_log::AuditEntry;
use crate::container_stats::ContainerResource;
use crate::hive_stats::HiveStats;
#[derive(Serialize, ToSchema)]
pub(super) struct OperatorInboxItem {
id: i64,
from: String,
body: String,
at: chrono::DateTime<chrono::Utc>,
in_reply_to: Option<i64>,
file_refs: Vec<String>,
}
#[derive(Serialize, ToSchema)]
pub(super) struct OperatorInboxBody {
messages: Vec<OperatorInboxItem>,
}
/// Unread operator-directed messages for the dashboard's Y3R C4LL inbox.
///
/// Returns messages addressed to `"operator"` that haven't been
/// acked yet (the operator clears them via the existing
/// `POST /api/agent/operator/mark-all-read`). Newest-first; path-shaped
/// tokens are validated so the client renders file links like the
/// terminal does. Shape: `{ "messages": [{ id, from, body, at,
/// in_reply_to, file_refs }] }`.
/// terminal does.
#[utoipa::path(
get,
path = "/api/operator-inbox",
responses(
(status = 200, description = "unread operator-directed messages", body = serde_json::Value),
(status = 200, description = "unread operator-directed messages", body = OperatorInboxBody),
(status = 500, description = "broker read failed"),
),
tag = "misc_api"
@ -40,7 +55,7 @@ pub(super) async fn api_operator_inbox(State(state): State<AppState>) -> Respons
.unread_for_recipient("operator", INBOX_LIMIT)
{
Ok(messages) => {
let items: Vec<serde_json::Value> = messages
let messages: Vec<OperatorInboxItem> = messages
.into_iter()
.filter_map(|m| {
let crate::broker::MessageEvent::Sent {
@ -55,17 +70,17 @@ pub(super) async fn api_operator_inbox(State(state): State<AppState>) -> Respons
return None;
};
let file_refs = scan_validated_paths(&body);
Some(serde_json::json!({
"id": id,
"from": from,
"body": body,
"at": hive_sh4re::wire_time::from_secs(at),
"in_reply_to": in_reply_to,
"file_refs": file_refs,
}))
Some(OperatorInboxItem {
id,
from,
at: hive_sh4re::wire_time::from_secs(at),
body,
in_reply_to,
file_refs,
})
})
.collect();
axum::Json(serde_json::json!({ "messages": items })).into_response()
axum::Json(OperatorInboxBody { messages }).into_response()
}
Err(e) => error_response(&format!("operator-inbox failed: {e:#}")),
}
@ -114,18 +129,23 @@ pub(super) async fn api_container_resources() -> Response {
axum::Json(crate::container_stats::gather().await).into_response()
}
#[derive(Serialize, ToSchema)]
pub(super) struct AuditLogBody {
entries: Vec<AuditEntry>,
total: i64,
}
/// Most-recent agent-initiated privileged-action
/// audit entries, newest first (server-clamped to 500).
///
/// Backs the operator dashboard's audit view. Returns
/// `{ "entries": [AuditEntry…], "total": N }` so the UI can show
/// Backs the operator dashboard's audit view. `total` lets the UI show
/// "latest 500 of N" rather than silently capping. `ts_unix` is in
/// **seconds**.
#[utoipa::path(
get,
path = "/api/audit-log",
responses(
(status = 200, description = "recent audit entries + total count", body = serde_json::Value),
(status = 200, description = "recent audit entries + total count", body = AuditLogBody),
(status = 500, description = "sqlite read failed"),
),
tag = "misc_api"
@ -140,7 +160,12 @@ pub(super) async fn api_audit_log(State(state): State<AppState>) -> Response {
Ok(n) => n,
Err(e) => return error_response(&format!("audit-log count: {e:#}")),
};
axum::Json(serde_json::json!({ "entries": entries, "total": total })).into_response()
axum::Json(AuditLogBody { entries, total }).into_response()
}
#[derive(Serialize, ToSchema)]
pub(super) struct MarkAllReadBody {
marked: u64,
}
/// Operator-driven "clear this agent's inbox" — backs the side-panel
@ -148,14 +173,14 @@ pub(super) async fn api_audit_log(State(state): State<AppState>) -> Response {
///
/// Marks every message addressed to the agent as acked (backfilling
/// `delivered_at` for any still-pending rows so vacuum can collect
/// them). Returns `{ "marked": N }` so the frontend can show "cleared
/// N messages" feedback without an extra fetch.
/// them). `marked` lets the frontend show "cleared N messages"
/// feedback without an extra fetch.
#[utoipa::path(
post,
path = "/api/agent/{name}/mark-all-read",
params(("name" = String, Path, description = "agent name")),
responses(
(status = 200, description = "count of messages marked read", body = serde_json::Value),
(status = 200, description = "count of messages marked read", body = MarkAllReadBody),
(status = 400, description = "bad agent name"),
(status = 500, description = "broker write failed"),
),
@ -172,9 +197,9 @@ pub(super) async fn post_mark_all_read(
}
};
match state.coord.broker.mark_all_read(name.as_str()) {
Ok(n) => {
tracing::info!(%name, marked = n, "operator marked all messages read");
axum::Json(serde_json::json!({ "marked": n })).into_response()
Ok(marked) => {
tracing::info!(%name, marked, "operator marked all messages read");
axum::Json(MarkAllReadBody { marked }).into_response()
}
Err(e) => error_response(&format!("mark-all-read {name} failed: {e:#}")),
}

View file

@ -18,6 +18,16 @@ use crate::scheduled_prompts_worker::FireNowReport;
use super::{AppState, error_problem, error_response};
#[derive(serde::Serialize, utoipa::ToSchema)]
pub(super) struct NewScheduleBody {
id: i64,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub(super) struct CancelResultBody {
cancelled: bool,
}
/// Snapshot of every schedule for the
/// scheduled-prompts tab.
///
@ -74,7 +84,7 @@ pub(super) async fn api_schedules(State(state): State<AppState>) -> Response {
// `api_schedules` above.
request_body(content = serde_json::Value, description = "SchedulePromptPayload wire shape"),
responses(
(status = 200, description = "created; body carries the new row id", body = serde_json::Value),
(status = 200, description = "created; body carries the new row id", body = NewScheduleBody),
(status = 400, description = "no targets, empty body, or interval_seconds == 0"),
(status = 500, description = "submit failed"),
),
@ -108,7 +118,7 @@ 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())
Ok(axum::Json(NewScheduleBody { id }).into_response())
}
Err(e) => Err(error_problem(&format!("schedule submit: {e:#}"))),
}
@ -177,7 +187,7 @@ pub(super) async fn post_schedule_fire_now(
post,
path = "/api/rebuild-queue/{id}/cancel",
params(("id" = u64, Path, description = "job-queue node id (a DAG's root cancels the group)")),
responses((status = 200, description = "whether the DAG was cancelled", body = serde_json::Value)),
responses((status = 200, description = "whether the DAG was cancelled", body = CancelResultBody)),
tag = "schedules"
)]
pub(super) async fn post_rebuild_queue_cancel(
@ -188,9 +198,9 @@ pub(super) async fn post_rebuild_queue_cancel(
// Any terminal side effect is the DAG's own spared tail node, which the
// scheduler picks up on its next pass — nothing to fire from here.
state.coord.emit_rebuild_queue_snapshot();
axum::Json(serde_json::json!({"cancelled": true})).into_response()
axum::Json(CancelResultBody { cancelled: true }).into_response()
} else {
axum::Json(serde_json::json!({"cancelled": false})).into_response()
axum::Json(CancelResultBody { cancelled: false }).into_response()
}
}

View file

@ -726,6 +726,18 @@ pub(super) async fn jobq_rollup(
axum::Json(state.coord.job_queue.state_rollup())
}
/// Response body for `/api/dashboard/history`. No `ToSchema` — its
/// `events` field wraps [`crate::dashboard_events::DashboardEvent`],
/// which doesn't derive `ToSchema` either (a large enum with many
/// variants; see that type's doc comment for why annotating it is
/// out of scope here). The `responses(...)` doc below spells out the
/// shape in prose instead of a `body = ...` reference.
#[derive(Serialize)]
struct DashboardHistoryBody {
seq: u64,
events: Vec<crate::dashboard_events::DashboardEvent>,
}
#[utoipa::path(
get,
path = "/api/dashboard/history",
@ -798,7 +810,7 @@ pub(super) async fn dashboard_history(State(state): State<AppState>) -> Response
}
})
.collect();
axum::Json(serde_json::json!({ "seq": seq, "events": events })).into_response()
axum::Json(DashboardHistoryBody { seq, events }).into_response()
}
Err(e) => error_response(&format!("dashboard/history failed: {e:#}")),
}

View file

@ -73,6 +73,15 @@ pub(super) async fn run_node(
NodeKind::RebuildBookkeeping { .. } => run_rebuild_bookkeeping(coord, agent).await,
NodeKind::Provision { .. } => run_provision(coord, agent).await,
NodeKind::Create { .. } => run_create(agent).await,
NodeKind::DestroyContainer { .. } => run_destroy_container(coord, agent).await,
NodeKind::PurgeState { .. } => {
run_purge_state(agent).await;
Ok(())
}
NodeKind::DestroyBookkeeping { purge, .. } => {
run_destroy_bookkeeping(coord, agent, *purge).await;
Ok(())
}
NodeKind::MetaLock {
sweep,
fanout,
@ -163,6 +172,112 @@ async fn run_resolve_approval(
Ok(())
}
/// Rerender the meta flake from whatever containers still exist on disk.
/// Idempotent — a no-op when nothing changed. Lives here because the destroy
/// tail is its only caller; it moved with `destroy` when that became a DAG.
async fn sync_meta_after_lifecycle(coord: &Coordinator) -> Result<()> {
let agents = crate::lifecycle::agents_for_meta_listing().await?;
crate::meta::sync_agents(&coord.hive_env(), &agents).await
}
/// `nixos-container destroy`, then drop the agent from the roster and clear
/// its ephemeral runtime dir (the mcp socket, which does not survive a restart
/// anyway).
///
/// The only fallible step is the destroy itself: once the container is gone the
/// un-registration cannot meaningfully fail, and returning early would strand
/// the roster claiming an agent that no longer exists.
async fn run_destroy_container(coord: &Arc<Coordinator>, agent: &str) -> Result<()> {
crate::lifecycle::destroy(agent).await?;
coord.unregister_agent(agent);
let runtime = crate::paths::agent_runtime_dir(agent);
if runtime.exists() {
let _ = std::fs::remove_dir_all(&runtime);
}
Ok(())
}
/// The `purge = true` half: wipe the agent's persistent trees.
///
/// Every step is best-effort-with-a-warning rather than fatal, and that is
/// deliberate — the container is already destroyed by the time this runs, so
/// failing the node would leave the operator with a half-purged agent and a red
/// DAG, when what they can actually act on is the log line naming the path.
async fn run_purge_state(agent: &str) {
// The state root may be a btrfs subvolume: a subvolume root can't be
// removed with rmdir/`remove_dir_all`, so delete it via hive-priv (root)
// first. No-op for plain-dir agents — the loop below then handles the
// plain-dir state root plus the applied dir.
if let Err(e) = crate::priv_client::delete_agent_subvolume(agent).await {
tracing::warn!(error = ?e, %agent, "purge: delete state subvolume failed");
}
// A malformed name can't have a persistent state tree (the state dir is
// only ever created under a validated Ident), so its removal is a no-op —
// skip the state-dir sweep and just clear the applied dir.
let state_dir = hive_types::Ident::parse(agent)
.ok()
.map(|id| crate::paths::agent_state_dir(&id));
for dir in state_dir
.into_iter()
.chain([crate::paths::applied_dir(agent)])
{
if dir.exists()
&& let Err(e) = std::fs::remove_dir_all(&dir)
{
tracing::warn!(error = ?e, dir = %dir.display(), "purge: remove failed");
}
}
}
/// Post-destroy bookkeeping. Infallible by construction: every step is
/// warn-and-continue, because the destroy it follows has already succeeded and
/// none of this is undoable — a failed meta sync or power-store write is a
/// bookkeeping drift to log, not a reason to red a DAG whose container is
/// already gone.
async fn run_destroy_bookkeeping(coord: &Arc<Coordinator>, agent: &str, purge: bool) {
// Meta flake: drop the agent's input + nixosConfiguration so a future spawn
// under the same name re-seeds cleanly, and so the meta lock doesn't
// reference a vanished applied repo.
if let Err(e) = sync_meta_after_lifecycle(coord).await {
tracing::warn!(error = ?e, %agent, "meta sync after destroy failed");
}
let _ = coord.approvals.fail_pending_for_agent(
agent,
if purge {
"agent purged"
} else {
"agent destroyed"
},
);
// Drop the durable power intent — a future agent of the same name seeds
// fresh from its observed state.
if let Err(e) = coord.power.remove(agent) {
tracing::warn!(%agent, error = ?e, "agent_power: remove on destroy failed");
}
let _ = coord
.push_todo(
hive_sh4re::manager::MANAGER_AGENT,
"core",
Some(format!("destroyed:{agent}")),
format!("agent '{agent}' destroyed"),
None,
false,
)
.await;
// Container row disappeared — rescan so the dashboard fires
// `ContainerRemoved` for the gone row, then emit the tombstones snapshot
// (gained one on destroy, lost one on purge — recompute either way).
coord.rescan_containers_and_emit().await;
crate::dashboard::emit_tombstones_snapshot(coord).await;
// Re-emit the schedules snapshot: the rescan above refreshed the live
// roster, so any schedule that still targets the just-destroyed agent now
// drops that ghost column live (no page reload needed).
coord.emit_schedules_snapshot();
// Update tmpfiles.d to remove the destroyed agent's dirs from the boot-time
// pre-creation list. Best-effort: failure is logged only.
tokio::spawn(crate::lifecycle::sync_tmpfiles());
}
/// Emit this agent's rebuild-complete todo. `ok` is not computed — it is which
/// of the tail pair the graph let run. The failure note comes from the DAG's
/// first failing node, since the branch knows *that* it failed but not *why*.

View file

@ -78,6 +78,36 @@ pub enum NodeKind {
/// First-spawn `nixos-container create` proper. Assumes the
/// upstream `Provision` node already registered the agent in meta.
Create { agent: String },
/// `nixos-container destroy` plus the un-registration that follows it:
/// drop the agent from the coordinator's roster and clear its ephemeral
/// runtime dir.
///
/// **Deliberately not in [`NodeKind::takes_container_down`]**, and that
/// is the design rather than an oversight. This node runs *downstream of
/// a `Stop`*, which already carries the flag honestly, so by the time it
/// claims there is nothing left to take down. A container still live here
/// is a real bug and must page someone — a `true` would absorb exactly
/// that signal, and the flag's whole asymmetry (see that method) is that
/// a wrong `true` silently swallows a crash.
DestroyContainer { agent: String },
/// The `purge = true` half of a destroy: delete the agent's state
/// subvolume (via hive-priv, since a subvolume root defeats
/// `remove_dir_all`) plus its state and applied dirs. Its own node
/// because it is conditional — a plain destroy never inserts it — and
/// because it is the irreversible step, so it earns a distinct row in
/// the graph rather than hiding inside a bookkeeping tail.
PurgeState { agent: String },
/// The post-destroy bookkeeping tail: meta sync, fail the agent's pending
/// approvals, drop the durable power intent, notify the manager, rescan
/// containers, re-emit the tombstone + schedule snapshots, resync
/// tmpfiles. Split from [`NodeKind::DestroyContainer`] for the same
/// reason [`NodeKind::RebuildBookkeeping`] is split from `Swap`:
/// dashboard visibility and retry granularity for work that is pure
/// store/meta bookkeeping and touches no container.
///
/// `purge` only selects the wording of the approval-failure reason and
/// the manager notification; the destructive work is `PurgeState`'s.
DestroyBookkeeping { agent: String, purge: bool },
/// Meta flake lock bump. `sweep = false`: `meta::lock_update`
/// (commit fused, under `META_LOCK`) with this node's own `inputs`;
/// `sweep = true`: `meta::lock_update_hyperhive`, *non-fatal* (a
@ -341,6 +371,9 @@ impl NodeKind {
NodeKind::RebuildBookkeeping { .. } => "rebuild_bookkeeping",
NodeKind::Provision { .. } => "provision",
NodeKind::Create { .. } => "create",
NodeKind::DestroyContainer { .. } => "destroy_container",
NodeKind::PurgeState { .. } => "purge_state",
NodeKind::DestroyBookkeeping { .. } => "destroy_bookkeeping",
NodeKind::MetaLock { .. } => "meta_lock",
NodeKind::Reconcile { .. } => "reconcile",
NodeKind::Start { .. } => "start",
@ -378,6 +411,9 @@ impl NodeKind {
| NodeKind::RebuildBookkeeping { agent }
| NodeKind::Provision { agent }
| NodeKind::Create { agent }
| NodeKind::DestroyContainer { agent }
| NodeKind::PurgeState { agent }
| NodeKind::DestroyBookkeeping { agent, .. }
| NodeKind::Reconcile { agent }
| NodeKind::Start { agent }
| NodeKind::Stop { agent }
@ -435,6 +471,12 @@ impl NodeKind {
// - `Create` / `Start` / `SetWanted{up}` bring a container UP. A
// container disappearing *while starting* is a genuine crash and has
// to keep reporting as one.
// - `DestroyContainer` looks like the most obvious `true` on this list
// and is the one that must stay `false`. It is edged downstream of a
// `Stop`, so the container is already down when it claims; the stop
// that the operator asked for is accounted for by the node that
// performs it. A container found alive at destroy time is a genuine
// bug, and a `true` here would suppress the alert that says so.
// - `Reconcile` is a planner; it fans out `Start` / `Stop`, which carry
// their own answer.
// - `DeployWindow` brackets a deploy without itself stopping anything.

View file

@ -474,6 +474,59 @@ pub fn spawn(builder: &JobBuilder, agent: &str, approval_id: i64) {
resolve_approval_tails(builder, approval_id, provision);
}
/// Teardown: `Stop` → `DestroyContainer` → (`PurgeState`) → `DestroyBookkeeping`.
///
/// The chain is the point, not a decomposition for its own sake. Destroy used
/// to be a straight-line async fn with no queue node behind it, so nothing in
/// the graph could answer "is this container going down on purpose?" — which is
/// why an imperative crash-watch suppression guard existed at all. Reusing the
/// existing [`NodeKind::Stop`] answers it structurally: `Stop` already declares
/// `takes_container_down`, so the suppression is derived from the graph like
/// every other lifecycle op's.
///
/// That also makes the precondition an edge rather than an assertion.
/// `DestroyContainer` runs only after `Stop` succeeded, so it operates on an
/// already-stopped container and carries `takes_container_down = false`
/// permanently — a container still alive at that point is a real bug and stays
/// loud instead of being absorbed by a flag.
///
/// `Stop` is idempotent against an already-down container, so the common
/// "destroy something that isn't running" path costs nothing extra.
///
/// `Stop` is the group root and holds the agent lease for the whole teardown;
/// the rest are `part_of` children that borrow it, so no other op can interleave
/// with a half-destroyed agent. `PurgeState` is inserted only when asked for —
/// the graph shows the irreversible step as its own row when it happens, and
/// omits it entirely when it doesn't.
pub fn destroy(builder: &JobBuilder, agent: &str, purge: bool) {
let a = || agent.to_owned();
let stop = builder
.node(NodeKind::Stop { agent: a() })
.needs(Resource::Agent(a()));
// `part_of` IS the ordering: a child runs once its parent reaches
// `Finishing`, and a node may not also declare a dep on its own parent
// (dep-scope validation rejects it — it would deadlock). So the
// "container is already stopped" precondition is the group edge itself,
// with no explicit `after_ok(stop)` to add.
let destroy = builder
.node(NodeKind::DestroyContainer { agent: a() })
.part_of(stop);
// The bookkeeping tail hangs off the purge when there is one, so the
// irreversible delete lands before the meta sync that stops referencing it.
let last = if purge {
builder
.node(NodeKind::PurgeState { agent: a() })
.part_of(stop)
.after_ok(destroy)
} else {
destroy
};
let _tail = builder
.node(NodeKind::DestroyBookkeeping { agent: a(), purge })
.part_of(stop)
.after_ok(last);
}
/// Perm change: commit the JSON file(s), then the rebuild subgraph so
/// the updated `HIVE_TOOL_GROUPS` / `HIVE_CAPABILITIES` env var takes
/// effect in the container. Group-roots are `WritePermFile` plus the rebuild

View file

@ -1714,6 +1714,97 @@ fn spawn_shape_provision_create_dropin_reconcile() {
);
}
/// The destroy chain, and the reason it is a chain: `Stop` is reused so the
/// crash-watch answer comes from the node that actually stops the container.
///
/// Asserting the *edges* is the point. `destroy_container` runs `after_ok` a
/// `stop`, which is what makes "the container is already down here" a
/// structural fact rather than a convention — see the companion test below for
/// why that matters.
#[test]
fn destroy_shape_stop_then_destroy_then_bookkeeping() {
let q = JobQueue::new(1);
insert(&q, |builder| {
templates::destroy(builder, "doomed", false);
});
assert_eq!(
declared_shape(&q),
vec![
row("stop", None, &[]),
// No explicit edge to `stop`: `part_of` already gates the child on
// its parent reaching `Finishing`, and declaring a dep on your own
// parent is rejected outright (it would deadlock). The precondition
// is the group membership.
row("destroy_container", Some("stop"), &[]),
row(
"destroy_bookkeeping",
Some("stop"),
&[("destroy_container", "done")]
),
]
);
}
/// `purge` inserts the irreversible delete as its own node, between the destroy
/// and the bookkeeping tail — so the meta sync that stops referencing the agent
/// runs *after* its trees are actually gone, and a purge is visibly distinct
/// from a plain destroy on the graph instead of being a hidden boolean.
#[test]
fn destroy_shape_purge_inserts_purge_state_before_the_tail() {
let q = JobQueue::new(1);
insert(&q, |builder| {
templates::destroy(builder, "doomed", true);
});
assert_eq!(
declared_shape(&q),
vec![
row("stop", None, &[]),
row("destroy_container", Some("stop"), &[]),
row(
"purge_state",
Some("stop"),
&[("destroy_container", "done")]
),
row(
"destroy_bookkeeping",
Some("stop"),
&[("purge_state", "done")]
),
]
);
}
/// The counter-case to `rebuild_chain_nodes_suppress_crash_watch`, and the one
/// assertion in this file that exists to stop a *plausible* edit rather than a
/// wrong one.
///
/// `destroy_container` is the most obvious candidate for `takes_container_down`
/// on the whole list and must stay `false`. It is edged downstream of a `Stop`
/// that already carries the flag, so the intentional stop is already accounted
/// for; a container still alive when this node claims is a genuine bug. Since a
/// wrong `true` **silently swallows a real crash** while a wrong `false` only
/// costs a spurious event, this is the asymmetry that has to be pinned.
#[test]
fn destroy_container_must_not_suppress_crash_watch() {
assert!(
!NodeKind::DestroyContainer {
agent: "a".to_owned()
}
.takes_container_down(),
"destroy_container runs after a Stop that already declared the \
container is going down; claiming it again would suppress the alert \
for a container found unexpectedly alive"
);
// The upstream node is where the `true` lives — assert it here too, so the
// pair reads as one property and moving the flag breaks this test.
assert!(
NodeKind::Stop {
agent: "a".to_owned()
}
.takes_container_down()
);
}
#[test]
fn perm_change_shape_prefixes_rebuild_chain() {
let q = JobQueue::new(1);

View file

@ -2,7 +2,7 @@
//! network isolation, forwarded credentials), the systemd resource-limits
//! drop-in, and the `write_dropins` verb that re-applies both.
use std::path::Path;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use hive_priv_sock::{BindMount, CredentialMount};
@ -71,6 +71,19 @@ async fn systemd_daemon_reload() -> Result<()> {
/// inside the container.
pub const CONTAINER_MANAGER_APPLIED_MOUNT: &str = "/applied";
/// Host path behind every `/agents/<name>/config` mount: the **applied**
/// (deployed) repo, not the working clone at `agents/<name>/config`. That
/// clone is where a config change is staged, so it can hold a proposal
/// that is still under review or was rejected outright — mounting it shows
/// an agent a config which does not govern it. Both mounts (an agent's own
/// and a parent's view of a child's) go through here so they cannot drift.
///
/// Never empty under a live container: `provision_container` runs
/// `setup_applied` before `create_only` makes the container at all.
fn config_bind_source(name: &str) -> PathBuf {
crate::paths::applied_dir(name)
}
/// Append bind flags for `child`'s state and config dirs into `binds`.
/// See docs/persistence.md ("Parent access to child state") for what a
/// parent may touch and why. Creates missing host-side directories so
@ -104,8 +117,10 @@ fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
return;
};
let child_root = crate::paths::agent_state_dir(&child);
for (sub, read_only) in [("state", false), ("config", true)] {
let host = child_root.join(sub);
for (sub, host, read_only) in [
("state", child_root.join("state"), false),
("config", config_bind_source(child.as_str()), true),
] {
let _ = std::fs::create_dir_all(&host);
binds.push(BindMount {
host_path: host.to_string_lossy().into_owned(),
@ -249,9 +264,9 @@ async fn set_nspawn_flags(
read_only: false,
});
}
let agent_id = hive_types::Ident::parse(agent_name)
hive_types::Ident::parse(agent_name)
.map_err(|e| anyhow::anyhow!("invalid agent name {agent_name:?}: {e}"))?;
let own_config = crate::paths::agent_state_dir(&agent_id).join("config");
let own_config = config_bind_source(agent_name);
std::fs::create_dir_all(&own_config)
.with_context(|| format!("create {}", own_config.display()))?;
binds.push(BindMount {
@ -381,6 +396,27 @@ mod tests {
assert_eq!(paths, ["/agents/kiddo/state", "/agents/kiddo/config"]);
}
/// The `config` mount names the **deployed** tree, not the working
/// clone the proposal is staged in. Asserted as "outside the child's
/// own dir" rather than by equality: the point is that the two are
/// different objects, which is what makes the mount unable to show a
/// config that was never approved. Equality with `applied_dir` would
/// restate the implementation and pass under any future relocation.
#[test]
fn child_config_mount_is_the_deployed_tree_not_the_working_clone() {
let working_clone =
crate::paths::agent_state_dir(&hive_types::Ident::parse("kiddo").expect("valid ident"));
let config = child_binds()
.into_iter()
.find(|b| b.container_path.ends_with("/config"))
.expect("a config bind");
assert!(
!std::path::Path::new(&config.host_path).starts_with(&working_clone),
"config mount must not come from the child's working clone: {}",
config.host_path
);
}
/// The regression this exists for. `harness` holds the child's own
/// runtime material and was only ever mounted because one loop
/// treated all three dirs alike — re-adding it to that loop is a

View file

@ -144,7 +144,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
handle_start(&coord, &agents, &infra).await?
}
HostRequest::Destroy { name, purge } => {
actions::destroy(&coord, name.as_str(), *purge).await?;
actions::destroy(&coord, name.as_str(), *purge);
HostResponse::success()
}
HostRequest::Rebuild { name } => {

View file

@ -27,6 +27,7 @@ use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use rusqlite::{Connection, params};
use serde::Serialize;
use utoipa::ToSchema;
/// Process-singleton handle, set once at coordinator startup. Mirrors
/// `build_logs::GLOBAL` — lets recording sites write without threading an
@ -80,7 +81,7 @@ impl AuditOutcome {
}
/// One audit row as returned to the dashboard.
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct AuditEntry {
pub id: i64,
pub ts_unix: DateTime<Utc>,

View file

@ -88,17 +88,17 @@ fn emit_crash_transitions(coord: &Coordinator, prev: &HashSet<String>, current:
// guard between two crash-watch polls.
let recent = coord.recent_transient_within(RECENT_TRANSIENT_GRACE);
for stopped in prev.difference(current) {
// Two sources, because a container can go down on purpose either way:
// a running queue node that declared it takes the container down, or a
// no-node operation (destroy, migration) holding a suppression guard.
// One source: a running queue node that declared it takes the container
// down. There used to be a second — an imperative suppression guard for
// operations with no node behind them — and destroy becoming a DAG
// removed the last caller, so intent now has exactly one home.
// `any`, not "the" pill: an agent can have several running nodes at
// once (a lease-exempt build alongside a lease-holding stop), and it
// only takes one of them expecting the container down for this to be
// a deliberate stop rather than a crash.
let active = transients
.get(stopped)
.map(|sts| sts.iter().any(|st| st.takes_container_down))
.or_else(|| coord.crash_watch_suppressed(stopped).then_some(true));
.map(|sts| sts.iter().any(|st| st.takes_container_down));
let recently_cleared = recent.get(stopped).copied();
if is_deliberate_stop(active, recently_cleared) {
continue;

View file

@ -11,6 +11,7 @@ workspace = true
anyhow.workspace = true
hive-priv-sock.workspace = true
libc.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tracing.workspace = true

View file

@ -27,6 +27,7 @@ use hive_priv_sock::{
NetworkIsolation, PAUSED_MARKER_FILE, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse,
PrivStream, PrivStreamLine, SIBLING_CONTAINERS,
};
use serde::Serialize;
use tokio::io::{AsyncWriteExt, BufReader};
use tokio::net::unix::OwnedWriteHalf;
use tokio::net::{UnixListener, UnixStream};
@ -468,7 +469,10 @@ async fn exec(
// when both `account` and `homeserver` are present; the account
// suffix is already validated above.
if let (Some(a), Some(hs)) = (account, homeserver) {
let meta = serde_json::json!({ "homeserver": hs }).to_string();
let meta = serde_json::to_string(&MatrixAccountSidecar {
homeserver: hs.as_str(),
})
.context("serialize matrix account sidecar")?;
write_agent_state_file(agent_name, &format!("matrix-account-{a}.json"), &meta)?;
}
Ok(res)
@ -497,7 +501,10 @@ async fn exec(
)?;
// Sidecar carries the base URL — there's no host-side nix config
// for extra forges, so this is the only place it's persisted.
let meta = serde_json::json!({ "base_url": base_url }).to_string();
let meta = serde_json::to_string(&ForgeSidecar {
base_url: base_url.as_str(),
})
.context("serialize forge account sidecar")?;
write_agent_state_file(agent_name, &format!("forge-{label}.json"), &meta)?;
Ok(res)
}
@ -957,6 +964,32 @@ fn write_state_file_nofollow(dir: &Path, filename: &str, content: &str) -> Resul
Ok(file)
}
/// Sidecar written alongside an extra matrix account's token
/// (`matrix-account-<name>.json`) so `hive-matrix-mcp` can auto-discover
/// the account's homeserver without a static `matrixAccounts` config
/// entry. Read side: `hive-matrix-mcp/src/accounts.rs`'s
/// `read_account_homeserver` (deliberately reads via a bare
/// `serde_json::Value` rather than this shape — that side treats a
/// malformed/missing sidecar as "skip this account" rather than an
/// error, so it stays loosely typed; this side is the one place the
/// file is written, so it gets the precise shape).
#[derive(Serialize)]
struct MatrixAccountSidecar<'a> {
homeserver: &'a str,
}
/// Sidecar written alongside a dashboard-provisioned extra forge
/// account's token (`forge-<label>.json`) so `hive-forge` can resolve
/// the account's base URL. Read side: `hive-forge/src/client.rs`'s own
/// (separately defined, deserialize-only) `ForgeSidecar` — same field
/// name (`base_url`), no shared crate between `hive-priv` and
/// `hive-forge` to hang a common type off, so the two structs are
/// pinned to the same JSON key by convention, not by the compiler.
#[derive(Serialize)]
struct ForgeSidecar<'a> {
base_url: &'a str,
}
/// Shared helper for `WriteAgentForgeToken` and `WriteAgentMatrixToken`.
/// Writes `content` to `AGENT_STATE_ROOT/<agent_name>/state/<filename>`,
/// chowns to the agent user (derived from the state dir's existing owner),
@ -2335,6 +2368,35 @@ fn validate_bind_path(path: &str) -> Result<()> {
/// write network isolation settings, then append `EXTRA_NSPAWN_FLAGS`.
/// When `isolation` is `Some`, writes `PRIVATE_NETWORK=1` + veth wiring;
/// when `None`, writes `PRIVATE_NETWORK=0`.
/// `--tmpfs=<mount>/.git` for every bound git repo, hiding its metadata
/// from inside the container.
///
/// Two kinds of mount qualify, for one reason: **the agent is given a
/// working tree, never a repository.** `/knowledge` is the hive's shared
/// docs — whose `.git/config` has held a credential the host-side worker
/// embedded — and `/agents/<name>/config` is a config repo, an agent's own
/// or a parent's read-only view of a child's. In both cases `.git` carries
/// every branch and the full history of a document whose *currently
/// deployed* value is the only thing a reader may act on, and an abandoned
/// branch is indistinguishable from a live one.
///
/// An overlay rather than an exported copy: there is no second tree to
/// keep in sync, so nothing can go stale, and no code path has to remember
/// to refresh it.
///
/// ⚠️ Ordering matters — these must be appended **after** the `--bind`
/// flags so nspawn mounts them on top of the already-mounted trees.
/// Config mounts are matched by shape, not by a name list: the set is
/// dynamic, growing with each child bound into a parent.
fn git_overlay_flags(binds: &[BindMount]) -> Vec<String> {
binds
.iter()
.map(|b| b.container_path.as_str())
.filter(|p| *p == "/knowledge" || (p.starts_with("/agents/") && p.ends_with("/config")))
.map(|p| format!("--tmpfs={p}/.git"))
.collect()
}
fn write_nspawn_flags(
container: &str,
binds: &[BindMount],
@ -2395,19 +2457,7 @@ fn write_nspawn_flags(
format!("{flag}={}:{}", b.host_path, b.container_path)
})
.collect();
// Defense-in-depth for the knowledge bind-mount: overlay an empty tmpfs
// on /knowledge/.git so the repo metadata (including any credentials the
// host-side git worker embedded in .git/config) is invisible inside agent
// containers. Agents only need the working-tree documents; .git/ has no
// legitimate use in-container. The --tmpfs must come after the --bind-ro
// so nspawn processes it as an overlay on top of the already-mounted tree.
// `crate::knowledge::CONTAINER_MOUNT` is "/knowledge" (hive-c0re const).
if binds
.iter()
.any(|b| b.container_path.as_str() == "/knowledge")
{
flags.push("--tmpfs=/knowledge/.git".to_owned());
}
flags.extend(git_overlay_flags(binds));
// Credential forwarding: nspawn loads each host secret into the
// container's credential store under `<name>`; inner units inherit it
// via `LoadCredential=<name>`. Validated (name charset + bind-path
@ -2573,12 +2623,58 @@ async fn sync_agent_tmpfiles(agents: &[AgentTmpfilesEntry]) -> Result<(String, S
#[cfg(test)]
mod tests {
use super::{
OwnedFd, PAUSED_MARKER_FILE, PrivRequest, check_fd_agreement, contains_secret_shaped_run,
limits_dropin_body, redact_secret_line, remove_marker_in, write_state_file_nofollow,
BindMount, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, check_fd_agreement,
contains_secret_shaped_run, git_overlay_flags, limits_dropin_body, redact_secret_line,
remove_marker_in, write_state_file_nofollow,
};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
fn bind(container_path: &str) -> BindMount {
BindMount {
host_path: "/var/lib/hyperhive/whatever".to_owned(),
container_path: container_path.to_owned(),
read_only: true,
}
}
/// Every bound git repo gets its `.git` overlaid — the knowledge tree
/// and *each* config mount, an agent's own plus every child's.
///
/// The child case is the one worth pinning: that set grows at runtime
/// as agents gain children, so a rule written as a list of names would
/// silently stop covering new ones.
#[test]
fn every_bound_git_repo_gets_its_dot_git_hidden() {
let flags = git_overlay_flags(&[
bind("/knowledge"),
bind("/agents/atlas/config"),
bind("/agents/kiddo/config"),
]);
assert_eq!(
flags,
[
"--tmpfs=/knowledge/.git",
"--tmpfs=/agents/atlas/config/.git",
"--tmpfs=/agents/kiddo/config/.git",
]
);
}
/// ...and nothing else does. A blanket "overlay .git on every bind"
/// would mask a real `.git` under `state/`, where an agent legitimately
/// keeps working clones of its own.
#[test]
fn non_repo_mounts_are_left_alone() {
let flags = git_overlay_flags(&[
bind("/agents/atlas/state"),
bind("/shared"),
bind("/applied"),
bind("/agents/atlas/config-notes"),
]);
assert!(flags.is_empty(), "overlaid a non-repo mount: {flags:?}");
}
/// A request that streams into a caller-supplied descriptor.
fn fd_taking_request() -> PrivRequest {
PrivRequest::SendAgentSnapshotToFd {

View file

@ -1,6 +1,6 @@
# Flake checks: formatting, the clippy gate, the workspace test run,
# the nix-options docs eval, and the hivectl CLI-reference freshness
# check. Imported per system from flake.nix.
# the nix-options docs eval, the hivectl CLI-reference freshness check,
# and the module-eval property table. Imported per system from flake.nix.
{
pkgs,
craneLib,
@ -8,6 +8,7 @@
self,
system,
treefmt-eval,
nixosSystem,
}:
let
inherit (rust) cleanSrc cargoArtifacts nativeBuildInputs;
@ -15,6 +16,16 @@ in
{
formatting = treefmt-eval.config.build.check self;
# The only check here that covers **nix**. Every other one is a Rust
# derivation, so a `.nix`-only diff moves no hash and the whole set is
# cache hits — green without evaluating what changed. See the file's
# header for what belongs in it and what needs something that executes
# rather than evaluates.
module-eval = import ./module-eval.nix {
inherit pkgs self nixosSystem;
inherit (pkgs) lib;
};
# Clippy via crane's first-class `cargoClippy` builder. Reuses the
# shared `cargoArtifacts` (deps already built) and runs
# `cargo clippy --workspace --all-targets` directly.

View file

@ -397,6 +397,39 @@ in
};
config = lib.mkIf config.services.hyperhive.enable {
# This service's own gateway surface: the vhost that fronts it and
# the name the hive resolver answers for. Declared here rather than
# in the gateway so the forge's public face lives with the forge —
# the gateway supplies the primitives (`lib.listen`, `lib.tlsFor`,
# `lib.securityHeaders`) and never needs to know this service by
# name.
#
# Both halves are gated on `behindGateway`: with it off the operator
# fronts forgejo themselves, so this hive must neither claim the
# vhost nor answer DNS for it.
services.hyperhive.gateway.localNames = lib.optional cfg.behindGateway cfg.domain;
# `server_name = forge.domain`, proxies all `/` → forgejo. Tuned for
# git: `client_max_body_size 1G`, `proxy_read_timeout 1h` (multi-GB
# clones). SSH stays direct on `forge.sshPort`. See
# `docs/gateway.md`.
services.nginx.virtualHosts = lib.optionalAttrs cfg.behindGateway {
"${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // {
listen = gatewayCfg.lib.listen;
extraConfig = gatewayCfg.lib.securityHeaders;
locations."/" = {
proxyPass = "http://127.0.0.1:${toString cfg.httpPort}/";
proxyWebsockets = true;
extraConfig = ''
proxy_buffering off;
client_max_body_size 1G;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
'';
};
};
};
assertions = [
{
# Fail at EVAL, not at boot. The alternative failure is a login

View file

@ -22,12 +22,15 @@ let
# same list rather than each deciding what "a swarm service" means.
swarmServiceDomains = config.services.hyperhive.swarm.serviceDomains;
matrixCfg = config.services.hyperhive.swarm.matrix;
autheliaCfg = config.services.hyperhive.swarm.authelia;
uiCfg = config.services.hyperhive.swarm.ui;
controllerCfg = config.services.hyperhive.swarm.controller;
forgeCfg = config.services.hyperhive.swarm.forge;
networkCfg = config.services.hyperhive.network;
# Every vhost claiming `default_server`, ours and the operator's
# alike. Computed once so the assertion below and the message it
# prints cannot disagree about what they found.
defaultVhosts = lib.filter (n: config.services.nginx.virtualHosts.${n}.default or false) (
lib.attrNames config.services.nginx.virtualHosts
);
# Dashboard SPA dist, static-served by nginx.
dashboardDist = "${config.services.hyperhive.c0re.servedFrontend}/dashboard";
@ -65,11 +68,17 @@ let
svcCert = "${tlsDir}/swarm-services.pem";
svcKey = "${tlsDir}/swarm-services-key.pem";
# Styled static error pages. Built once here and reached two ways:
# directly by ./vhosts.nix, and via the published kit by any service
# module that aims an `error_page` at one.
errorPages = import ./error-pages.nix { inherit pkgs; };
# The vhost construction kit (listen set / per-name TLS attrs /
# security headers). Computed here, published as `cfg.lib` below, and
# handed to ./vhosts.nix **as the published value** — so the tree the
# gateway renders and the kit a service module gets are the same
# object by construction, not by two call sites agreeing.
# security headers / error pages). Computed here, published as
# `cfg.lib` below, and handed to ./vhosts.nix **as the published
# value** — so the tree the gateway renders and the kit a service
# module gets are the same object by construction, not by two call
# sites agreeing.
vhostLib = import ./vhost-lib.nix {
inherit
lib
@ -79,6 +88,7 @@ let
svcCert
svcKey
swarmServiceDomains
errorPages
;
};
@ -87,16 +97,12 @@ let
inherit
lib
cfg
forgeCfg
errorPages
matrixCfg
autheliaCfg
uiCfg
controllerCfg
hyperhiveDomain
dashboardDist
swaggerUiTheme
;
errorPages = import ./error-pages.nix { inherit pkgs; };
};
in
{
@ -125,6 +131,58 @@ in
Let's Encrypt needs a contact address for the ACME account.
'';
}
{
# Two modules claiming one hostname is a real possibility now
# that each service contributes its own name, and dnsmasq would
# not complain: duplicate `address=` rules resolve by precedence,
# so the loser simply stops being served with no error anywhere.
# Fail the build instead — a name is owned by exactly one module.
assertion = lib.length (lib.unique cfg.localNames) == lib.length cfg.localNames;
message = ''
services.hyperhive.gateway.localNames contains a duplicate:
${lib.concatStringsSep ", " (
lib.unique (lib.filter (n: lib.count (m: m == n) cfg.localNames > 1) cfg.localNames)
)}
Each hostname the hive resolver answers for is contributed by
exactly one module. Two modules claiming the same name means
two services believe they serve it resolve which one does
rather than letting dnsmasq pick.
'';
}
{
# nginx refuses to start with two `default_server`s on one
# address ("a duplicate default server for 0.0.0.0:<port>",
# exit 1) and nixpkgs asserts nothing — `vhost.default` is a
# plain bool rendered straight into the listen line. So without
# this, an operator adding their own default vhost gets a
# gateway that fails its config test at rebuild time, which
# takes the forge, dashboard, matrix and swarm UI with it, and
# reports a port rather than a cause.
#
# Not covered by our `mkDefault`: an operator's own vhost is a
# different option path, so nothing merges and nothing
# conflicts — priority only helps someone who already knows
# ours exists. Fail at eval and name both, so the fix
# (`services.nginx.virtualHosts.<ours>.default = false`) is
# readable from the error.
assertion = lib.length defaultVhosts <= 1;
message = ''
More than one nginx virtual host is marked `default = true`:
${lib.concatStringsSep ", " defaultVhosts}
nginx allows exactly one default server per listen address
and refuses to start otherwise, so this would fail at
service start rather than here taking every site behind
the gateway down with it.
The gateway's own catch-all (`_`, which returns 444) is set
with `mkDefault`, so to make yours the default server turn
ours off explicitly:
services.nginx.virtualHosts."_".default = false;
'';
}
];
# Ensure the gateway state dirs exist at host boot, before anything
@ -323,7 +381,7 @@ in
recommendedTlsSettings = true;
recommendedGzipSettings = true;
recommendedOptimisation = true;
inherit (nginxTree) appendHttpConfig virtualHosts;
inherit (nginxTree) virtualHosts;
};
# ⚠️ NO `SupplementaryGroups = [ "hive-core" ]` on nginx, and its
@ -353,11 +411,8 @@ in
services.dnsmasq = import ./dnsmasq.nix {
inherit
lib
cfg
networkCfg
forgeCfg
matrixCfg
autheliaCfg
uiCfg
hyperhiveDomain
;
};
@ -371,19 +426,18 @@ in
];
};
# `/etc/hosts` entries for local dev — bare hive domain + any
# sub-domain modules that are on. `lib.unique` dedupes if any
# sub-domain happens to equal another. See `docs/gateway.md`
# `/etc/hosts` entries for local dev — the bare hive domain plus
# every name a service module contributed. See `docs/gateway.md`
# ("Local dev").
#
# This used to restate the per-service list a THIRD time (after the
# vhosts and the dnsmasq records), with its own copy of each
# service's guard. It is the same question — "which names does this
# host answer for" — so it reads the same answer; a service added
# later lands here with no edit, and cannot land here with a
# different condition than it used for DNS.
networking.hosts = lib.mkIf cfg.localHostsEntry {
"127.0.0.1" = lib.unique (
[ hyperhiveDomain ]
++ lib.optional (config.services.hyperhive.swarm.forge.behindGateway or false
) config.services.hyperhive.swarm.forge.domain
++ lib.optional (matrixCfg.enable && matrixCfg.gatewayHost != null) matrixCfg.gatewayHost
++ lib.optional autheliaCfg.enable autheliaCfg.domain
++ lib.optional uiCfg.enable uiCfg.domain
);
"127.0.0.1" = lib.unique ([ hyperhiveDomain ] ++ cfg.localNames);
};
};
}

View file

@ -7,11 +7,8 @@
# are computed by hive-network.
{
lib,
cfg, # services.hyperhive.gateway
networkCfg,
forgeCfg,
matrixCfg,
autheliaCfg,
uiCfg,
hyperhiveDomain,
}:
{
@ -55,31 +52,19 @@
# Hive authoritative records — answer queries for the hive domain
# + its sub-domains with the bridge IP, where nginx is reachable
# from every container netns.
#
# The forge / matrix entries are redundant in the common case
# where `forge.domain` / `matrix.gatewayHost` are sub-domains of
# `hyperhive.domain` — dnsmasq's `/<domain>/` rule already matches
# sub-domains. Kept explicit because operators can override either
# to a cross-domain hostname (e.g. `forge.domain =
# "git.example.com"`); listing them explicitly keeps that case
# routed without needing an extra config block.
address = [
"/${hyperhiveDomain}/${networkCfg.bridgeIp}"
]
++ lib.optional ((forgeCfg.behindGateway or false)) "/${forgeCfg.domain}/${networkCfg.bridgeIp}"
++ lib.optional (
matrixCfg.enable && matrixCfg.gatewayHost != null
) "/${matrixCfg.gatewayHost}/${networkCfg.bridgeIp}"
++ lib.optional autheliaCfg.enable "/${autheliaCfg.domain}/${networkCfg.bridgeIp}"
# The swarm UI's name is the swarm APEX by default — a sibling of
# the three above, not a child of anything this resolver already
# answers for, so the `/<hive domain>/` rule does not cover it.
# Names contributed by the modules that own them
# (`gateway.localNames`). Same address as everything above — the
# bridge IP is the gateway's answer for anything it fronts, and a
# contributing module neither knows nor should know it.
#
# Published to agents deliberately (mara: publishing it is fine).
# Reachability is not the access control here: the vhost's
# `auth_request` + authelia's `group:operators` rule are, and an
# agent that resolves the name still cannot open the page.
++ lib.optional uiCfg.enable "/${uiCfg.domain}/${networkCfg.bridgeIp}";
# `unique` is not tidiness: two modules claiming one name would
# otherwise emit two `address=` rules for it, and dnsmasq resolves
# that by precedence rather than by complaining. An assertion in
# ./default.nix makes the collision loud instead.
++ map (name: "/${name}/${networkCfg.bridgeIp}") (lib.unique cfg.localNames);
# DHCP pool covering all usable host addresses on the bridge
# subnet — bounds computed by hive-network.nix from
# bridgeIp/bridgePrefixLength. All containers (agents and service

View file

@ -91,6 +91,35 @@ in
gateway shape. Off by default operators running with real
DNS shouldn't have a stale `/etc/hosts` entry sticking
around. Requires `services.hyperhive.domain` to be set.
`services.hyperhive.enableAllLocalDefaults` turns this on as
part of saying "this box is the whole deployment": that mode
means there is no real DNS for these names and the operator is
browsing them from the host itself. Set it here explicitly to
override in either direction.
'';
};
localNames = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
internal = true;
description = ''
Extra hostnames the hive's resolver answers with the bridge IP,
contributed by the modules that own those names.
A service module says **which name**; the gateway decides
**where it points** the same split as `lib.tlsFor`. A service
that hardcoded the bridge IP would be one more place to fix when
the network layout changes, and it has no business knowing it.
Contribute a name only when THIS host actually serves it. The
list is not "names the swarm has"
`services.hyperhive.swarm.serviceDomains` is that, and it is
deliberately broader (it drives certificate issuance, so it
includes names this hive may only be a client of). Publishing an
address record for a service you do not run points every agent
on the bridge at a door that isn't there.
'';
};
@ -127,6 +156,22 @@ in
'';
};
errorPages = lib.mkOption {
type = lib.types.attrsOf lib.types.path;
internal = true;
readOnly = true;
description = ''
Read-only: the gateway's styled static error pages, by name
(`notFound`, `unreachable`, `unauthorized`, `ssoUnavailable`).
Published so a service module can aim an `error_page` at one
instead of rendering its own a service that built its own
would drift from the rest of the gateway the first time the
theme changed, and the operator would meet two different
error styles on one hive.
'';
};
securityHeaders = lib.mkOption {
type = lib.types.lines;
internal = true;

View file

@ -22,6 +22,7 @@
svcCert, # swarm-services leaf, for names the hive CA cannot sign
svcKey,
swarmServiceDomains, # which names those are (../swarm.nix derives it)
errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized, ssoUnavailable }
}:
let
# nixos `services.nginx.virtualHosts.<name>` ssl attrs for a vhost
@ -98,4 +99,11 @@ in
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
${lib.optionalString cfg.hsts.enable ''add_header Strict-Transport-Security "${hstsDirectives}" always;''}
'';
# The gateway's styled error pages, re-exported so a service module
# can point an `error_page` at one. Republished rather than imported
# per module for the same reason as everything else in this kit: these
# carry the hive's branding, and a service rendering its own would
# drift from the rest of the gateway the first time the theme changes.
inherit errorPages;
}

View file

@ -3,15 +3,11 @@
# and authelia sub-domain vhosts, and the Accept-header SPA map for the
# matrix GUI. Pure function — called from ./default.nix with the
# outer-scope config values as arguments; returns
# `{ virtualHosts, appendHttpConfig }`.
# `{ virtualHosts }`.
{
lib,
cfg, # services.hyperhive.gateway
forgeCfg,
matrixCfg,
autheliaCfg, # services.hyperhive.swarm.authelia
uiCfg, # services.hyperhive.swarm.ui
controllerCfg, # services.hyperhive.swarm.controller
hyperhiveDomain,
dashboardDist,
swaggerUiTheme, # nix/packages/swagger-ui-theme.nix: has index.html + hyperhive-theme.css
@ -37,239 +33,6 @@ let
publicPort = cfg.httpsPort;
publicPortSuffix = if publicPort == 443 then "" else ":${toString publicPort}";
# Forge sub-domain vhost. `server_name = forge.domain`, proxies
# all `/` → forgejo. Tuned for git: `client_max_body_size 1G`,
# `proxy_read_timeout 1h` (multi-GB clones). SSH stays direct on
# `forge.sshPort`. See `docs/gateway.md`. Empty attrset when the
# forge isn't behind the gateway.
forgeVhost = lib.optionalAttrs (forgeCfg.behindGateway or false) {
"${forgeCfg.domain}" = (vhostTlsFor forgeCfg.domain) // {
listen = vhostListen;
extraConfig = securityHeaders;
locations."/" = {
proxyPass = "http://127.0.0.1:${toString forgeCfg.httpPort}/";
proxyWebsockets = true;
extraConfig = ''
proxy_buffering off;
client_max_body_size 1G;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
'';
};
};
};
# Authelia sub-domain vhost. `server_name = authelia.domain`, all of
# `/` → authelia. Empty attrset unless THIS host runs the container:
# every hive knows the swarm's `authelia.url`, but only the one
# serving it may claim the name — a client hive declaring this vhost
# would answer for a service it does not run.
#
# ⚠️ The server name must be exactly `autheliaCfg.domain`, not a
# near-miss: authelia validates `authelia_url ⊂ session cookie domain`
# at STARTUP, so a mismatch is a container that refuses to boot rather
# than a login that misbehaves.
#
# ⚠️ And deliberately NO `dashboardAuth` here. That block is the
# gateway's `auth_basic`; applying it to the SSO provider would put
# the login page behind the login mechanism it exists to replace.
autheliaVhost = lib.optionalAttrs autheliaCfg.enable {
"${autheliaCfg.domain}" = (vhostTlsFor autheliaCfg.domain) // {
listen = vhostListen;
extraConfig = securityHeaders;
locations."/" = {
proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/";
proxyWebsockets = true;
extraConfig = ''
proxy_buffering off;
# authelia decides by the ORIGINAL request, not by the hop it
# sees — the login redirect and the session cookie's domain
# both derive from these. Without them every request looks
# like it arrived at 127.0.0.1 over plain http.
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# A dead upstream here means "not bootstrapped" far more often
# than "misconfigured proxy", and a bare 502 says the opposite.
proxy_intercept_errors on;
error_page 502 503 504 = /__hive_sso_unavailable;
'';
};
locations."= /__hive_sso_unavailable" = {
extraConfig = ''
internal;
alias ${errorPages.ssoUnavailable};
default_type text/html;
'';
};
};
};
# Swarm UI vhost — the swarm's front page, on the swarm apex, and the
# FIRST `auth_request` anywhere in this gateway (everything else is
# `auth_basic` + htpasswd).
#
# ⚠️ `auth_request` answers "is there a session", not "is this an
# operator". The operator-only part is authelia's `access_control`
# rule (../swarm-authelia.nix) requiring `group:operators` — agents
# are getting authelia accounts of their own, and without that rule a
# session alone would open this page.
#
# ⚠️ Failure mode here is LOCKED OUT, not unprotected: a subrequest
# that wrongly denies takes the whole UI away. That is the reason the
# redirect target and the header set below are copied from a measured
# source rather than from an example.
# ⚠️ `forceSSL`, not `addSSL` like every other vhost — not a hardening
# preference, the only way this page works at all. authelia answers the
# auth subrequest for an `http://` target with **400**, and nginx's
# `auth_request` only understands 2xx/401/403, so a plain-http visit
# dies as "auth request unexpected status: 400" with no hint a login
# exists. `vhostListen` binds :80, so without this the door is open on
# a port the lock cannot work on. Serving forge or matrix over http is
# merely insecure rather than broken, so they keep `addSSL` and the
# asymmetry stays local to the vhost whose correctness depends on the
# scheme. `removeAttrs` because nixos asserts on a vhost declaring both.
# Shared with every swarm-UI-vhost location below (`/`, `/api/`,
# `/api/docs/`) — auth_request does not inherit across sibling
# locations, so each one that should be operator-gated repeats this
# verbatim rather than only the page itself being protected while its
# own API and API docs are reachable unauthenticated.
swarmAuthRequest = ''
auth_request /__hive_authelia;
# Captured BEFORE the error_page jump: inside the 401 handler
# `$request_uri` is the internal one, so building the return
# link there sends the operator back to the auth subrequest
# instead of the page they asked for.
auth_request_set $target_url $scheme://$http_host$request_uri;
error_page 401 =302 https://${autheliaCfg.domain}/?rd=$target_url;
'';
swarmUiVhost = lib.optionalAttrs uiCfg.enable {
"${uiCfg.domain}" = (builtins.removeAttrs (vhostTlsFor uiCfg.domain) [ "addSSL" ]) // {
forceSSL = true;
listen = vhostListen;
extraConfig = securityHeaders;
locations = {
"/" = {
root = "${uiCfg.package}";
extraConfig = ''
${swarmAuthRequest}
# SPA: any path the bundle routes client-side is served the
# entry document rather than a 404 from the filesystem.
try_files $uri /index.html;
'';
};
# swarm-controller's whole HTTP surface, including the live
# `/api/openapi.json` spec — proxied untouched (no URI segment
# after the socket path, same "pass the request through as-is"
# shape as the per-hive dashboard's own `/api/` proxy) so the
# path swarm-controller registered a route at is the path
# nginx forwards, no prefix-stripping to keep in sync by hand.
"/api/" = {
proxyPass = "http://unix:${controllerCfg.socketPath}:";
extraConfig = swarmAuthRequest;
};
# Swagger UI: same "nginx hosts the themed dist straight from
# the store, only /api/openapi.json is dynamic" shape as the
# per-hive gateway's `swaggerUiLocations` — see that block's
# comment for why core-equivalent (here, swarm-controller)
# does not also mount its own copy.
"= /api/docs" = {
extraConfig = ''
return 301 /api/docs/;
'';
};
"/api/docs/" = {
alias = "${swaggerUiTheme}/";
extraConfig = ''
index index.html;
${swarmAuthRequest}
'';
};
# The subrequest itself. `auth-request` is the implementation
# name authelia exposes under `/api/authz/`; `/api/verify` is the
# LEGACY path every older example shows.
#
# Header set measured against the pinned binary (4.39.20), not
# copied: `X-Original-URL` and `X-Original-Method` are present as
# literals and are what this implementation reads —
# `X-Forwarded-Uri` does not appear in it at all, so sending it
# would look like configuration and be dead weight.
"= /__hive_authelia" = {
proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/api/authz/auth-request";
extraConfig = ''
internal;
# A subrequest carries no body, and forwarding one here makes
# authelia read a payload it will never use.
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
'';
};
};
};
};
# Matrix sub-domain vhost. `server_name = matrixCfg.gatewayHost`.
# `/_matrix/*` → tuwunel (CORS *, 50M body cap, 1h long-poll
# timeout). `/` serves fluffychat or 404 if GUI off. nginx
# longer-prefix-wins puts `/_matrix/` ahead of `/`. See
# `docs/gateway.md`. Empty attrset when matrix has no gateway host.
matrixVhost = lib.optionalAttrs (matrixCfg.enable && matrixCfg.gatewayHost != null) {
"${matrixCfg.gatewayHost}" = (vhostTlsFor matrixCfg.gatewayHost) // {
listen = vhostListen;
extraConfig = securityHeaders;
locations = {
"/_matrix/" = {
proxyPass = "http://127.0.0.1:${toString matrixCfg.httpPort}";
proxyWebsockets = true;
extraConfig = ''
proxy_buffering off;
client_max_body_size 50M;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
${securityHeaders}
add_header Access-Control-Allow-Origin *;
'';
};
}
// lib.optionalAttrs (matrixCfg.gui.enable) (
{
# fluffychat at sub-domain root, SPA-fallback via
# the Accept-header `$matrix_spa_target` map.
"/" = {
alias = "${matrixCfg.gui.package}/";
extraConfig = ''
try_files $uri $uri/ $matrix_spa_target =404;
'';
};
}
// {
# FluffyChat boot-config pre-fill so the client's
# `.well-known/matrix/client` lookup hits the
# right delegation endpoint. `domain` is required, so
# this is always present.
"= /config.json" = {
extraConfig = ''
default_type application/json;
return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}';
'';
};
}
)
// lib.optionalAttrs (!matrixCfg.gui.enable) {
"/" = {
return = "404";
};
};
};
};
# `<hive>/matrix/*` → 301 → `matrix.<hive>/$1` (legacy deep-link
# shim during the fluffychat sub-domain move). See `docs/gateway.md`.
matrixRedirectLocations =
@ -455,23 +218,42 @@ let
};
in
{
# Accept-header SPA map for the matrix GUI only (see docs/gateway.md
# "SPA fallback"): text/html → index.html, else a sentinel so
# try_files falls through to 404. The dashboard doesn't use an
# Accept-header map — it routes by path (see dashboardProxyLocation).
appendHttpConfig = lib.optionalString (matrixCfg.enable && matrixCfg.gui.enable) ''
map $http_accept $matrix_spa_target {
default "/__matrix_spa_no_html_fallback";
"~*text/html" "/index.html";
}
'';
virtualHosts = {
# `tlsFor "_"`, not a separate binding: the default server is a
# vhost named `_`, and a name that is not a swarm service domain
# (`_` never is) resolves to the hive's own leaf — which is what
# this vhost has always served.
# The catch-all, and now *only* a catch-all: anything whose `Host`
# matches no vhost gets an immediate 444 (close without a response)
# rather than being served the hive's dashboard.
#
# `_` is the idiomatic spelling because it is not a legal hostname,
# so it can never match a request by name — it serves traffic solely
# by being `default_server`.
#
# ⚠️ It still needs TLS attrs. It listens on the https port, so a
# client connecting by IP completes a TLS handshake *before* nginx
# can look at `Host` and reject it; with no cert the vhost fails to
# load. The certificate will not match what such a client asked for
# — that is unavoidable and correct: nothing can present a valid
# cert for a name the operator never issued one for.
#
# `mkDefault` per the operator: an operator with their own
# `default = true` vhost must be able to win without fighting
# priorities. The assertion in ./default.nix catches the case where
# they add one *without* turning this off, which nginx would
# otherwise only report at runtime as a failed config test.
"_" = (vhostTlsFor "_") // {
listen = vhostListen;
default = lib.mkDefault true;
extraConfig = ''
return 444;
'';
};
# The hive's own surface, now reachable by NAME. This used to be
# served by the `_` vhost above: no vhost was named for the hive
# domain, so every dashboard and agent-UI request matched the
# default server instead (confirmed against 24h of nginx's access
# log — `server: _` on requests whose Host *was* the hive domain).
# Naming it is what lets the catch-all start rejecting.
${hyperhiveDomain} = (vhostTlsFor hyperhiveDomain) // {
listen = vhostListen;
locations =
matrixRedirectLocations
@ -503,9 +285,5 @@ in
include /var/lib/hive-gateway/conf/agents.conf;
'';
};
}
// forgeVhost
// autheliaVhost
// matrixVhost
// swarmUiVhost;
};
}

View file

@ -9,6 +9,7 @@ let
networkCfg = config.services.hyperhive.network;
tlsCfg = config.services.hyperhive.tls;
gatewayCfg = config.services.hyperhive.gateway;
hyperhiveDomain = config.services.hyperhive.domain;
# Same runtime→build-time bridge hive-ci and hive-forge already cross:
# binds the hive trust bundle (which folds in the swarm root) into the
@ -396,6 +397,85 @@ in
};
config = lib.mkIf cfg.enable {
# Matrix's own gateway surface: the sub-domain vhost, the name the
# hive resolver answers for, and the Accept-header map that vhost's
# SPA fallback reads. All three are matrix knowledge and none of
# them is the gateway's business.
#
# `gatewayHost = null` means matrix is reachable directly rather
# than fronted, so there is no name to claim and no vhost to serve —
# every clause below carries that guard.
services.hyperhive.gateway.localNames = lib.optional (cfg.gatewayHost != null) cfg.gatewayHost;
# Accept-header SPA map, used only by the `/` location below (see
# docs/gateway.md "SPA fallback"): text/html → index.html, else a
# sentinel so `try_files` falls through to 404. `appendHttpConfig`
# is a `lines` option, so this merges with anything else the host
# contributes instead of replacing it.
#
# The dashboard needs no equivalent — it routes by path.
services.nginx.appendHttpConfig = lib.optionalString cfg.gui.enable ''
map $http_accept $matrix_spa_target {
default "/__matrix_spa_no_html_fallback";
"~*text/html" "/index.html";
}
'';
# `server_name = gatewayHost`. `/_matrix/*` → tuwunel (CORS `*`, 50M
# body cap, 1h long-poll timeout). `/` serves fluffychat, or 404
# with the GUI off. nginx's longest-prefix rule puts `/_matrix/`
# ahead of `/` with no ordering needed.
#
# ⚠️ The `.well-known/matrix/*` delegation is deliberately NOT here.
# It stays on the hive's own vhost because the spec requires it to
# be served at the *server name*, which is the hive domain — it is
# the hive answering "where is my homeserver", not the homeserver
# answering for itself.
services.nginx.virtualHosts = lib.optionalAttrs (cfg.gatewayHost != null) {
"${cfg.gatewayHost}" = (gatewayCfg.lib.tlsFor cfg.gatewayHost) // {
listen = gatewayCfg.lib.listen;
extraConfig = gatewayCfg.lib.securityHeaders;
locations = {
"/_matrix/" = {
proxyPass = "http://127.0.0.1:${toString cfg.httpPort}";
proxyWebsockets = true;
extraConfig = ''
proxy_buffering off;
client_max_body_size 50M;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
${gatewayCfg.lib.securityHeaders}
add_header Access-Control-Allow-Origin *;
'';
};
}
// lib.optionalAttrs cfg.gui.enable {
# fluffychat at sub-domain root, SPA-fallback via the
# Accept-header `$matrix_spa_target` map above.
"/" = {
alias = "${cfg.gui.package}/";
extraConfig = ''
try_files $uri $uri/ $matrix_spa_target =404;
'';
};
# FluffyChat boot-config pre-fill so the client's
# `.well-known/matrix/client` lookup hits the right delegation
# endpoint. `domain` is required, so this is always present.
"= /config.json" = {
extraConfig = ''
default_type application/json;
return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}';
'';
};
}
// lib.optionalAttrs (!cfg.gui.enable) {
"/" = {
return = "404";
};
};
};
};
# `serverName` is irrevocably embedded in user/room IDs; it derives
# from `services.hyperhive.domain` (required, asserted in
# hive-network.nix) when not set explicitly, so no separate

View file

@ -29,11 +29,15 @@ in
example = true;
description = ''
Run the whole swarm on this host. Turning this on asserts the
swarm-level toggles that an all-on-one-box deployment implies:
the swarm's shared services
toggles that an all-on-one-box deployment implies: the swarm's
shared services
(`services.hyperhive.swarm.enableRequiredServices`), the swarm
CA (`services.hyperhive.swarm.ca.autoConfigure`), and the swarm
controller (`services.hyperhive.swarm.controller.enable`).
CA (`services.hyperhive.swarm.ca.autoConfigure`), the swarm
controller (`services.hyperhive.swarm.controller.enable`), and the
host's `/etc/hosts` entries for the names this hive serves
(`services.hyperhive.gateway.localHostsEntry`) with no real DNS
for those names, the operator is browsing them from the same box
that answers for them.
**Off by default, and that is the load-bearing part.** A swarm's
services and its hives can live on different hosts, and a host has
@ -52,6 +56,21 @@ in
# own `default` (1500) and loses to any explicit definition, which is
# exactly the precedence a deployment mode wants: it fills in for an
# operator who hasn't spoken, and never argues with one who has.
# The gateway's own all-local bit. `localHostsEntry` maps every name
# this hive answers for to 127.0.0.1 in the HOST's /etc/hosts, which is
# exactly what "this box is the whole deployment" implies: there is no
# real DNS for these names, and the operator is browsing them from the
# same machine that serves them.
#
# ⚠️ It does NOT affect what containers resolve. dnsmasq sets
# `no-hosts = true` unconditionally (see hive-gateway/dnsmasq.nix), so
# agents keep getting the bridge IP from the authoritative `address=`
# rules rather than the host's 127.0.0.1 — an entry that would point
# every agent at its own netns. That guard already existing is what
# makes turning this on by default safe; without it this line would
# break every agent's access to the forge.
config.services.hyperhive.gateway.localHostsEntry = lib.mkDefault cfg.enableAllLocalDefaults;
config.services.hyperhive.swarm = {
enableRequiredServices = lib.mkDefault cfg.enableAllLocalDefaults;
ca.autoConfigure = lib.mkDefault cfg.enableAllLocalDefaults;

View file

@ -35,6 +35,7 @@
let
cfg = config.services.hyperhive.swarm.authelia;
hyperhiveCfg = config.services.hyperhive;
gatewayCfg = hyperhiveCfg.gateway;
hyperhiveDomain = hyperhiveCfg.domain;
swarmDomain = hyperhiveCfg.swarm.domain;
uiCfg = hyperhiveCfg.swarm.ui;
@ -402,6 +403,59 @@ in
};
config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) {
# Authelia's own gateway surface: the vhost that fronts it and the
# name the hive resolver answers for. Both live here rather than in
# the gateway, and both are inside `cfg.enable` — that guard is the
# load-bearing part.
#
# ⚠️ Every hive in a swarm knows `authelia.url`, but only the host
# that RUNS the container may claim the name. A client hive
# declaring this vhost would answer for a service it does not run,
# and publishing the DNS record would point every agent on its
# bridge at that wrong answer.
services.hyperhive.gateway.localNames = [ cfg.domain ];
# `server_name = authelia.domain`, all of `/` → authelia.
#
# ⚠️ The server name must be exactly `cfg.domain`, not a near-miss:
# authelia validates `authelia_url ⊂ session cookie domain` at
# STARTUP, so a mismatch is a container that refuses to boot rather
# than a login that misbehaves.
#
# ⚠️ And deliberately NO `dashboardAuth` here. That block is the
# gateway's `auth_basic`; applying it to the SSO provider would put
# the login page behind the login mechanism it exists to replace.
services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // {
listen = gatewayCfg.lib.listen;
extraConfig = gatewayCfg.lib.securityHeaders;
locations."/" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}/";
proxyWebsockets = true;
extraConfig = ''
proxy_buffering off;
# authelia decides by the ORIGINAL request, not by the hop it
# sees — the login redirect and the session cookie's domain
# both derive from these. Without them every request looks
# like it arrived at 127.0.0.1 over plain http.
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# A dead upstream here means "not bootstrapped" far more often
# than "misconfigured proxy", and a bare 502 says the opposite.
proxy_intercept_errors on;
error_page 502 503 504 = /__hive_sso_unavailable;
'';
};
locations."= /__hive_sso_unavailable" = {
extraConfig = ''
internal;
alias ${gatewayCfg.lib.errorPages.ssoUnavailable};
default_type text/html;
'';
};
};
containers.${cfg.machine} = {
autoStart = true;
ephemeral = false;

View file

@ -13,6 +13,24 @@
}:
let
cfg = config.services.hyperhive.swarm.ui;
gatewayCfg = config.services.hyperhive.gateway;
autheliaCfg = config.services.hyperhive.swarm.authelia;
controllerCfg = config.services.hyperhive.swarm.controller;
# Repeated verbatim by every location that should be operator-gated
# (`/`, `/api/`, `/api/docs/`) rather than set once on the server:
# nginx's `auth_request` does NOT inherit across sibling locations, so
# setting it only on the page would leave this UI's own API and API
# docs reachable without a session.
swarmAuthRequest = ''
auth_request /__hive_authelia;
# Captured BEFORE the error_page jump: inside the 401 handler
# `$request_uri` is the internal one, so building the return
# link there sends the operator back to the auth subrequest
# instead of the page they asked for.
auth_request_set $target_url $scheme://$http_host$request_uri;
error_page 401 =302 https://${autheliaCfg.domain}/?rd=$target_url;
'';
swarmCfg = config.services.hyperhive.swarm;
hiveDomain = config.services.hyperhive.domain;
in
@ -93,5 +111,110 @@ in
'';
}
];
# The swarm UI's own gateway surface. Published to agents on the
# bridge deliberately: reachability is not the access control here —
# the `auth_request` below and authelia's `group:operators` rule
# are, and an agent that resolves the name still cannot open the
# page.
#
# The apex is a SIBLING of `forge.<swarm>` / `chat.<swarm>`, not a
# child of anything the resolver already answers for, so the
# `/<hive domain>/` rule does not cover it and this record is what
# makes the name resolve at all.
services.hyperhive.gateway.localNames = [ cfg.domain ];
# The swarm's front page, and the FIRST `auth_request` anywhere in
# this gateway (everything else is `auth_basic` + htpasswd).
#
# ⚠️ `auth_request` answers "is there a session", not "is this an
# operator". The operator-only part is authelia's `access_control`
# rule (./swarm-authelia.nix) requiring `group:operators` — agents
# have authelia accounts of their own, and without that rule a
# session alone would open this page.
#
# ⚠️ Failure mode here is LOCKED OUT, not unprotected: a subrequest
# that wrongly denies takes the whole UI away. That is why the
# redirect target and the header set below come from a measured
# source rather than an example.
#
# ⚠️ `forceSSL`, not `addSSL` like every other vhost — not a
# hardening preference, the only way this page works at all.
# authelia answers the auth subrequest for an `http://` target with
# **400**, and nginx's `auth_request` only understands 2xx/401/403,
# so a plain-http visit dies as "auth request unexpected status:
# 400" with no hint a login exists. The shared listen set binds :80,
# so without this the door is open on a port the lock cannot work
# on. Serving forge or matrix over http is merely insecure rather
# than broken, so they keep `addSSL` and the asymmetry stays local
# to the vhost whose correctness depends on the scheme.
# `removeAttrs` because nixos asserts on a vhost declaring both.
services.nginx.virtualHosts."${cfg.domain}" =
(builtins.removeAttrs (gatewayCfg.lib.tlsFor cfg.domain) [ "addSSL" ])
// {
forceSSL = true;
listen = gatewayCfg.lib.listen;
extraConfig = gatewayCfg.lib.securityHeaders;
locations = {
"/" = {
root = "${cfg.package}";
extraConfig = ''
${swarmAuthRequest}
# SPA: any path the bundle routes client-side is served the
# entry document rather than a 404 from the filesystem.
try_files $uri /index.html;
'';
};
# swarm-controller's whole HTTP surface, including the live
# `/api/openapi.json` spec — proxied untouched (no URI segment
# after the socket path, same "pass the request through as-is"
# shape as the per-hive dashboard's own `/api/` proxy) so the
# path swarm-controller registered a route at is the path
# nginx forwards, no prefix-stripping to keep in sync by hand.
"/api/" = {
proxyPass = "http://unix:${controllerCfg.socketPath}:";
extraConfig = swarmAuthRequest;
};
# Swagger UI: same "nginx hosts the themed dist straight from
# the store, only /api/openapi.json is dynamic" shape as the
# per-hive gateway's `swaggerUiLocations`.
"= /api/docs" = {
extraConfig = ''
return 301 /api/docs/;
'';
};
"/api/docs/" = {
alias = "${gatewayCfg.swaggerUiTheme}/";
extraConfig = ''
index index.html;
${swarmAuthRequest}
'';
};
# The subrequest itself. `auth-request` is the implementation
# name authelia exposes under `/api/authz/`; `/api/verify` is
# the LEGACY path every older example shows.
#
# Header set measured against the pinned binary (4.39.20), not
# copied: `X-Original-URL` and `X-Original-Method` are present
# as literals and are what this implementation reads —
# `X-Forwarded-Uri` does not appear in it at all, so sending it
# would look like configuration and be dead weight.
"= /__hive_authelia" = {
proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/api/authz/auth-request";
extraConfig = ''
internal;
# A subrequest carries no body, and forwarding one here makes
# authelia read a payload it will never use.
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
'';
};
};
};
};
}

119
nix/module-eval.nix Normal file
View file

@ -0,0 +1,119 @@
# `checks.module-eval` — the flake check that covers **nix**.
#
# Why this exists: every other check in ./checks.nix is a Rust
# derivation, so a `.nix`-only diff moves no hash, every check is a
# cache hit, and `nix flake check` reports green **without evaluating
# what changed**. This one's derivation hash is a function of the
# evaluated *results* below, so a nix change that flips a property
# rebuilds it and the builder fails naming that property.
#
# ## What belongs here, and what does not
#
# Anything expressible as a module `assertion` **should be one instead**:
# an assertion fires at deploy time for a real operator, not only in CI.
# What cannot be an assertion is the **absence class** — "a hive that
# hasn't opted in renders exactly what it did before", "this unit does
# not exist unless X". Those are claims about the *rendered config*
# rather than about a config being invalid, so they need an evaluator.
#
# ⚠️ **Cases are named by the PROPERTY they defend, never by the ticket
# that prompted them.** A case named after a ticket has the ticket's
# lifetime; a case named after a property lives as long as the property.
#
# ⚠️ **This check evaluates. It does not execute.** Where the artifact is
# a command line, an HTTP request or a certificate, a value assertion
# cannot stand in — those need something that *runs* them. And a case
# that needs a **rendered file** must stub the packages that file drags
# in (`swarm.ui.package = pkgs.emptyDirectory`), or it costs a full
# frontend build to answer a question about a listen directive.
{
pkgs,
lib,
self,
nixosSystem,
}:
let
# Stub host, same shape ./docs/default.nix already uses: enough for a
# `nixosSystem` to evaluate, nothing that pulls a real disk or
# bootloader in.
hive =
extra:
(nixosSystem {
system = pkgs.stdenv.hostPlatform.system;
modules = [
self.nixosModules.default
{
fileSystems."/" = {
device = "/dev/null";
fsType = "tmpfs";
};
boot.loader.grub.enable = false;
system.stateVersion = "25.11";
services.hyperhive = {
enable = true;
hiveName = "h1";
swarm.domain = "t.local";
swarm.hives.h1.domain = "h1.t.local";
}
// extra;
}
];
}).config;
allLocal = hive { enableAllLocalDefaults = true; };
bare = hive { };
# Each case: a name stating the property, and `ok`.
cases = [
{
name = "a hive that has not opted into all-local runs no swarm controller";
ok = !bare.services.hyperhive.swarm.controller.enable;
}
{
name = "the all-local mode turns the swarm controller on";
ok = allLocal.services.hyperhive.swarm.controller.enable;
}
{
# The gateway's per-name issuer choice. If this ever collapses to a
# constant, every swarm-service vhost serves a certificate its CA
# is name-constrained out of — which evaluates cleanly and fails in
# a browser.
name = "a swarm service name gets the swarm-services leaf and the default server does not";
ok =
let
l = allLocal.services.hyperhive.gateway.lib;
in
(l.tlsFor "t.local").sslCertificate != (l.tlsFor "_").sslCertificate;
}
{
# nixos asserts when a vhost declares both, so this is also a
# statement that the `removeAttrs` upstream of it still happens.
name = "the swarm UI vhost forces TLS instead of merely adding it";
ok =
let
v = allLocal.services.nginx.virtualHosts."t.local";
in
v.forceSSL && !(v.addSSL or false);
}
{
name = "a hive with matrix off serves no matrix discovery endpoint";
ok =
!(builtins.hasAttr "= /.well-known/matrix/client" bare.services.nginx.virtualHosts."_".locations);
}
];
bad = builtins.filter (c: !c.ok) cases;
report = lib.concatMapStringsSep "\n" (c: " echo 'FAILED: ${c.name}' >&2") bad;
in
# The results are embedded in the builder text on purpose: that is what
# makes this derivation's hash depend on them, so a nix-only change that
# flips a case cannot be answered from cache.
pkgs.runCommand "hyperhive-module-eval" { } ''
${report}
${
if bad == [ ] then
"echo '${toString (builtins.length cases)} module properties hold' && touch $out"
else
"echo 'module-eval: ${toString (builtins.length bad)} of ${toString (builtins.length cases)} properties broke' >&2 && exit 1"
}
''