diff --git a/CLAUDE.md b/CLAUDE.md index 85486055..ed4e52ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -295,6 +295,10 @@ docs/ boundary.md operator/agent trust model rationale agent-hierarchy.md tree-shape topology design + manager-privilege audit (#361) damocles-migration.md future migration plan for damocles → hyperhive + gateway.md nginx vhost map, matrix discovery flow, firewall posture, + HIVE_FORGE_URL loopback rationale (#764, #772, #793) + matrix.md matrix container shape, serverName/gatewayHost split, + firewall + federation, provisioning flow, fluffychat-web build ``` ## Reading paths @@ -316,6 +320,11 @@ read them à la carte. pattern."** → [`docs/conventions.md`](docs/conventions.md). - **"Why does the nspawn flag look like that?"** → [`docs/gotchas.md`](docs/gotchas.md). +- **"What nginx vhosts does the gateway serve? How does matrix + discovery work?"** → [`docs/gateway.md`](docs/gateway.md). +- **"How does the matrix-tuwunel container work? What about + fluffychat-web and per-agent matrix accounts?"** → + [`docs/matrix.md`](docs/matrix.md). ## Conventions & process diff --git a/docs/agent-hierarchy.md b/docs/agent-hierarchy.md index c914499c..e7f16b9a 100644 --- a/docs/agent-hierarchy.md +++ b/docs/agent-hierarchy.md @@ -1,7 +1,7 @@ # Agent hierarchy & privileges -Design + audit doc for milestone #6 (the -[issue](http://localhost:3000/hyperhive/hyperhive/issues/361) tree). +Design + audit doc for the agent-privileges + tree-shape milestone +(the [issue tree](http://localhost:3000/hyperhive/hyperhive/issues/361)). The implementation lands in pieces; this doc tracks what's done, what's planned, and what currently special-cases the manager. @@ -20,7 +20,7 @@ Topology lives in the hive-c0re-owned **meta repo**, alongside `null` = root-level agent. Today only the manager qualifies by default. Other agents land under `"manager"` on first sync. Re-parenting is -operator-driven (#486 / #487): +operator-driven: - CLI: `hive-c0re set-parent --parent ` (or `--root` to promote). Exactly one of `--parent` / `--root` is required. @@ -34,11 +34,11 @@ validation rules to a pure `apply_set_parent` helper. Refuses: - self-parenting, - cycles (32-hop ancestor walk, mirroring `is_descendant_of`). -Post-#743 the manager is reparentable like any other agent — the -"structurally root" carve-out was historical paranoia; the manager's -privileges live on its MCP socket, not its tree position, and the -cycle walk above catches the only real safety concern (moving the -manager under one of its own descendants). +The manager is reparentable like any other agent — there's no +"structurally root" carve-out; the manager's privileges live on its +MCP socket, not its tree position, and the cycle walk above catches +the only real safety concern (moving the manager under one of its +own descendants). Idempotent no-op fast path skips the disk write when the parent is already what's requested. After a successful write the surfaces call @@ -46,13 +46,12 @@ already what's requested. After a successful write the surfaces call viewers see the tree repaint without polling (`ContainerView.parent` is sourced from `topology.json`). -**Today's caveat (#361 follow-up):** the move is purely a JSON edit. -Only the top-level manager (`hm1nd`) gets `/var/lib/hyperhive/agents` +**Today's caveat:** the move is purely a JSON edit. Only the +top-level manager (`hm1nd`) gets `/var/lib/hyperhive/agents` bind-mounted at `/agents` in its container, so sub-agents don't yet see their would-be children's state. Once sub-manager bind mounts -land alongside #361 enforcement, `set_parent` grows a companion -umount-old / mount-new / restart-cascade step (tracked via the -cross-ref comment on #361). +land alongside cap enforcement, `set_parent` grows a companion +umount-old / mount-new / restart-cascade step. ### Why meta, not per-agent `agent.nix` @@ -78,7 +77,7 @@ where system-level facts live. so the harness / claude prompts can see it. 4. **Surface**: `container_view::build_all` reads `topology.json` and populates `ContainerView.parent: Option` on every rescan. - The dashboard renders the field as a tree (#363 follow-up). + The dashboard renders the field as a tree. ## Target topology semantics @@ -150,7 +149,7 @@ Tree-shape version: - `request_update_meta_inputs` is the only path that actually writes `flake.lock`, gated by the cap; everyone else stays RO. -### D — drop legacy `/state` for manager ✓ done (#604) +### D — drop legacy `/state` for manager ✓ done `lifecycle.rs` no longer binds `/state` for the manager. `HYPERHIVE_STATE_DIR` is now injected uniformly via @@ -165,8 +164,8 @@ read. - `prompts/system.md` with `` / `` marker blocks, assembled by `hive_ag3nt::prompt::render` based on - flavor (closes #519). **Per-agent cap list** of what the agent can - do — already a single parametrised prompt; once #513 lands the + flavor. **Per-agent cap list** of what the agent can do — already + a single parametrised prompt; once per-agent cap groups land the marker grammar grows `cap:` blocks the renderer reads from the per-agent ToolGroup set. - `mcp.rs::Flavor::{Agent, Manager}` controls which MCP tools claude @@ -182,8 +181,7 @@ read. descendants'. - `operator_questions.rs` + `broker.rs`: "manager can cancel any question" override on the owner check. **Topology** — agents can - moderate threads of their descendants. (per mara's - https://localhost:3000/hyperhive/hyperhive/issues/361#issuecomment-3344) + moderate threads of their descendants. - `reminder_scheduler.rs`: same override pattern for reminder cancel. **Topology** — descendants only. - `actions.rs`: `destroy` refuses to act on `MANAGER_NAME` (no @@ -196,9 +194,9 @@ read. ### G — sub-agents inside the same container -Future work mentioned in #361: when enabled for an agent, it can spawn -temporary "sub-agents" that run inside its own container. Lighter than -a full nspawn agent. Open questions, not yet wired: +Future work: when enabled for an agent, it can spawn temporary +"sub-agents" that run inside its own container. Lighter than a full +nspawn agent. Open questions, not yet wired: - Inherit caps from parent, or take an explicit narrower set? - Survive container restart, or always ephemeral? @@ -206,9 +204,102 @@ a full nspawn agent. Open questions, not yet wired: - Filesystem: share parent's `/state` RW, or a sub-dir? - Identity: distinct broker recipient name, or address the parent? +## Harness systemd unit shape (per-role) + +One harness binary (`hive`), one `harness-base.nix` template, two +systemd units depending on `hyperhive.role`: + +- `agent-base.nix` (`role = "agent"`) → `systemd.services.hive-ag3nt` +- `manager.nix` (`role = "manager"`) → `systemd.services.hive-m1nd` + +The unit names diverge but the binary is the same. `HIVE_ROLE` env +var picks the surface at startup (agent vs manager); naming the +units after the historical per-role binaries keeps dashboard log +queries, ExecStartPre paths, and ancestor PR diffs working without a +rename cascade. + +### Manager-only defaults + +`harness-base.nix` flips these when `hyperhive.role == "manager"`, +via `lib.mkDefault` so any agent can invert if needed: + +- `hyperhive.forge.keepSubscriptions = false` +- `hyperhive.forge.skipNotifyReasons = [ "subscribed" "participating" ]` + +Skips the subscription / participation firehose so the manager's +inbox only carries direct mentions, reviews, and assignments. Sub- +agents keep the noisier defaults so they see anything aimed at the +repos they're working on. + +### Standalone-eval fallbacks + +`nixosConfigurations.manager` must build standalone (without the +meta-flake's per-agent flake.nix wrapper). For the manager unit +that means hardcoded `HIVE_PORT` / `HIVE_LABEL` env values: + +- `HIVE_PORT = "8875"` — FNV-1a(`"hm1nd"`) % 900 + 8100, matching + `lifecycle::agent_web_port`. Sub-agents have the same shape via + the meta-flake-generated `applied//flake.nix`. +- `HIVE_LABEL = "hm1nd"` — container name; matches what `meta.rs` + injects at deploy time. + +Real deploys never read these — `meta::render_flake` overrides them +via the generated wrapper. They exist so the manager +`nixosConfigurations` evaluates cleanly even outside the meta-flake +boundary. + +### Environment variables set on the unit + +- `HOME = /home/` — systemd defaults `HOME` to `/` for + services without `User=` set; with the per-agent user (#658) the + harness needs the right home so claude finds its bind-mounted + `~/.claude/` session dir. +- `HIVE_STATIC_DIR = ` — `tower_http::ServeDir` root for + the per-agent web UI; merged dist = agent default + every + `hyperhive.frontend.extraFiles` overlay. +- `HIVE_ASSETS_DIR = pkgs.hyperhive-assets/share/hyperhive` — set + directly on the unit, **not** via `environment.variables`, because + the latter only populates `/etc/profile` which systemd services + don't inherit. +- `HIVE_ROLE = config.hyperhive.role` — picks the binary surface + (agent / manager) at startup. + +### `PATH` setup (the wrapper-dir trick) + +```nix +path = [ "/run/wrappers" "/run/current-system/sw" ]; +``` + +`/run/wrappers` comes first so setuid wrappers (notably `sudo`) +resolve before bare nix-store binaries. NixOS's +`systemd.services..path` appends `/bin` to every entry via +`lib.makeBinPath`; passing `/run/wrappers/bin` directly produces +`/run/wrappers/bin/bin` which doesn't exist (`docs/gotchas.md:: +systemd.services.*.path appends /bin to every entry`). Post-#658 +when the harness runs as the per-agent user this matters: without +the wrapper dir on PATH, `sudo` resolves to the un-setuid nix-store +binary and rejects with `must be owned by uid 0 and have the setuid +bit set` regardless of `hyperhive.user.passwordlessSudo`. + +### `serviceConfig` highlights + +- `ExecStart = pkgs.hyperhive/bin/hive serve` — single binary, + surface picked from `HIVE_ROLE`. +- `Restart = on-failure`, `RestartSec = 2` — keeps the harness + resilient across transient crashes without thundering retries. +- `RuntimeDirectory = "hive-config"` → `/run/hive-config/` owned by + `User=`, auto-cleared on stop. The harness writes regenerated + `claude-{mcp-config,settings,system-prompt}` files there + (`paths::config_dir`). Deliberately separate from `/run/hive`, + which the host bind-mounts in root-owned and which holds + hive-c0re's `mcp.sock` (#658 fixup). +- `User = Group = userName` — drops root inside the container; sudo + is the explicit escalation surface + (`hyperhive.user.passwordlessSudo`). + ## Cross-references -- Milestone: [#361 "Agent privileges and sub-agents"](http://localhost:3000/hyperhive/hyperhive/issues/361) -- Dashboard render: [#363 "show agent topology in container list"](http://localhost:3000/hyperhive/hyperhive/issues/363) -- Audit table source: [comment 3335 on #361](http://localhost:3000/hyperhive/hyperhive/issues/361#issuecomment-3335) +- Milestone: ["Agent privileges and sub-agents"](http://localhost:3000/hyperhive/hyperhive/issues/361) +- Dashboard render: ["show agent topology in container list"](http://localhost:3000/hyperhive/hyperhive/issues/363) +- Audit table source: [milestone comment](http://localhost:3000/hyperhive/hyperhive/issues/361#issuecomment-3335) - Operator/agent trust boundary (orthogonal axis): [`boundary.md`](boundary.md) diff --git a/docs/approvals.md b/docs/approvals.md index 34645bb1..0f92ccfa 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -30,7 +30,7 @@ happens after a decision lands. applied. 3a. **Flake validation (ApplyCommit only):** after the proposal tag is planted, hive-c0re reads `proposal/:flake.lock` and - runs two checks (closes #317). If either check fails, no + runs two checks. If either check fails, no pending approval is created for the operator — the row is marked failed and surfaces on the dashboard with the validation message: @@ -66,7 +66,7 @@ happens after a decision lands. ### Withdrawing a pending approval The manager can call `cancel_loose_end(kind: "approval", id)` to -withdraw an approval that hasn't been acted on yet (closes #250). +withdraw an approval that hasn't been acted on yet. The row transitions to `ApprovalStatus::Cancelled` (distinct from `Denied`/`Failed`), the dashboard pulls the card out of the pending pane, and `ApprovalResolved { status: "cancelled" }` fires @@ -202,7 +202,7 @@ approval id to retry. Because tags are first-class git objects, rejected and failed trees stay browsable forever — `git log --tags` in the applied repo is the audit trail. -### Dispatch via `rebuild_queue` (#441) +### Dispatch via `rebuild_queue` Long-running approval work — `ApplyCommit`, `UpdateMetaInputs`, `Spawn` — no longer runs inline inside `actions::approve`. Instead @@ -344,7 +344,7 @@ Differences from sub-agents: (vs `agent-base`). - Container name is `hm1nd` (no `h-` prefix). - Web UI port via `lifecycle::agent_web_port("hm1nd")` — same - FNV-1a hash as every other agent (8100..8999 range) since #753. + FNV-1a hash as every other agent (8100..8999 range). - `set_nspawn_flags` adds two extra binds: `/var/lib/hyperhive/agents` → `/agents` (RW) so the manager can edit per-agent proposed repos, and `/var/lib/hyperhive/applied` → `/applied` (RO) so the manager @@ -424,8 +424,8 @@ regular claude turn so the manager can react. Variants such transient was cleared in the last 30s (`RECENT_TRANSIENT_GRACE` tombstone, three `POLL_INTERVAL`s — closes the race where a lifecycle op finishes between two crash-watch polls and the - container shows briefly as "stopped without transient" before the - next start, #425). Manager can `start` it again or escalate. + container shows briefly as "stopped without transient" before + the next start). Manager can `start` it again or escalate. - `NeedsLogin { agent }` — sub-agent has no claude session yet. Manager can't act directly (interactive OAuth); typically flags the operator. diff --git a/docs/conventions.md b/docs/conventions.md index 16c2bbb5..c92bcb56 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -10,7 +10,7 @@ exist because something already went wrong without them. - The manager is `hm1nd` (no `h-` prefix, fixed name). - `MAX_AGENT_NAME` in `lifecycle.rs` enforces the cap. - Per-agent web UI port = `WEB_PORT_BASE + FNV1a(name) % WEB_PORT_RANGE` - (8100..8999) for every agent including the manager (#753); dashboard + (8100..8999) for every agent including the manager; dashboard `cfg.dashboardPort` (default 7000). ## Identity = socket @@ -32,12 +32,12 @@ angle-bracket and asterisk shapes below are structurally safe. (`agent_server::handle_send` fans out via `Coordinator::broadcast_send`). - `operator` — the human at the dashboard. Messages accumulate in the inbox view; no agent ever `recv`'s them. -- `` — the sender's parent per `topology.json` (`#692`). - Rewritten at send time by `topology::resolve_recipient`: looks up +- `` — the sender's parent per `topology.json`. Rewritten at + send time by `topology::resolve_recipient`: looks up `parent_of(sender)` and falls back to `operator` when the sender is a root agent (or absent from topology entirely). Lets agents address their parent without learning the label, so runtime reparenting - (`#486`) propagates with zero agent-side restart. + propagates with zero agent-side restart. When the resolver rewrites ``, the broker stores the *resolved* label as the message's recipient — the dashboard and @@ -54,6 +54,43 @@ each frame carries a `seq` field for the snapshot-dedupe dance — change them in one place. The dashboard event vocabulary lives in `hive-c0re::dashboard_events::DashboardEvent`. +### Broker delivery + ack cycle + +`AgentRequest::Recv` is the only path that delivers messages to an +agent. Always returns a list (`Messages { messages }`) — empty when +nothing's pending, single-pop when `max = None` (default 1, the +single-message behaviour), batched up to `max` when caller asks for +more (server-side cap is 32; values above clamp silently). +`wait_seconds` long-polls for the first message; once one arrives — +or one is already pending — the call drains up to `max` in total +before returning, so a single `Recv` call coalesces a burst. + +Per-row bookkeeping inside the broker: + +- `delivered_at = NOW` set on every popped row. +- Each recipient has an in-memory `unacked_ids` list of every row + delivered since the last `AckTurn`. +- `redelivered = true` on a row if `RequeueInflight` resurfaced it + (the harness prepends a "may already be handled" hint when this + flag is set so the per-message warning is visible). + +`AgentRequest::AckTurn` closes out the in-memory list — the harness +fires it after `TurnOutcome::Ok`, marking every message popped since +the last ack as fully handled. Claude doesn't see this surface; it's +strictly a harness↔broker pairing. On `TurnOutcome::Failed` the +harness intentionally skips the ack so the unacked rows stay +in-flight in the DB and get picked up by the next requeue sweep. + +`AgentRequest::RequeueInflight` is the recovery pair: fired by the +harness exactly once at boot, before the serve loop starts. Catches +the crashed-mid-turn / OOM-killed / container-restarted cases where +a previous harness session popped messages but never drove them to +a clean turn-end. Resets `delivered_at` back to NULL on every +unacked row (so the next `Recv` pops them again), and remembers +each id in a per-recipient in-memory set so the next `Recv` can tag +the row with `redelivered: true`. Idempotent + cheap when there's +nothing in flight, so the at-boot fire is unconditional. + ## Async forms Dashboard + per-agent mutating forms carry `data-async`; a delegated diff --git a/docs/gateway.md b/docs/gateway.md index 698d681c..2dd53ef6 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -76,3 +76,92 @@ SSH for forge stays direct on `cfg.sshPort` — separate listener protocol, not - #772 / #775 — fluffychat hops from `/matrix/` to `matrix./`. Next-up tracked separately: #14 (container netns isolation), TLS (#594). + +## Firewall posture (host-level) + +`hive-c0re.nix` opens the per-agent web-port range +`8100..8999` in the host firewall **only when +`services.hyperhive.gateway.enable = false`**. With the gateway on +(default), it's the sole external entry point and proxies to +`127.0.0.1:` internally — leaving the per-agent ports +firewall-open would defeat the single-front-door story (closes +#621). + +Manager hashes into the same range since #753 (no more +"manager pinned at 8000" special case), so one range opening covers +every container. + +The dashboard port (`cfg.dashboardPort`, default 7000) is *not* +listed in either case — since #652 it binds `127.0.0.1` only, so a +firewall hole would be a no-op. Remote dashboard access flows +through the gateway. Operators who opt out of the gateway lose +external dashboard reach by design — the surface is privileged +(approve / deny / destroy) and must not be exposed without a real +reverse proxy in front. + +## `HIVE_FORGE_URL`: loopback for in-cluster, sub-domain for the operator + +Agents poll `HIVE_FORGE_URL` for Forgejo notifications + run all +`hive-forge` calls against it. `hive-c0re.nix` pins this to +`http://127.0.0.1:` for the in-cluster path: every +agent container shares the host's network namespace, so loopback +reaches the forge container directly with no DNS lookup needed +(closes #761). + +The post-#754 sub-domain default (`forge.`) is for +**operator browsers + cross-host clients**, not in-cluster traffic. +Using the sub-domain URL inside agent containers would fail every +`hive-forge` invocation with "Name or service not known" — the +agent's nspawn doesn't have DNS for the external hostname. + +## hive-forge container shape + +Private Forgejo wrapped in a nixos-container (`hive-forge`, not +`h-*` — keeps c0re's lifecycle scanner out of the picture; the +operator manages it via the standard `nixos-container` CLI). The +container also keeps hive-forge from fighting any `services.forgejo` +the operator already runs on the host — separate systemd namespace, +separate state dir, separate port unless the operator deliberately +collides. + +Container shares the host network namespace +(`privateNetwork = false`) so agents reach the forge at +`http://localhost:` without extra plumbing — nixos-container +is here for state + systemd-unit isolation, not network isolation. + +State lives at `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` +and survives container restart / host reboot. To wipe, destroy the +container. + +## Per-agent error pages + +`/agent//` requests hit two failure modes; both get static +HTML pages instead of nginx's default error chrome (#755): + +- **Agent not found** (`/agent//...`) — name isn't in + `agentPortsTable`. nginx's prefix match falls back to the bare + `/agent/` catch-all, which `return 404`s and `error_page 404` rewrites + to `/__hive_agent_not_found` → serves `not-found.html` with a link + back to the dashboard. + +- **Agent unreachable** (`502 / 503 / 504` from `proxy_pass`) — the + per-agent harness isn't responding (container restarting, crash + recovery, etc.). `proxy_intercept_errors on` + `error_page 502 503 + 504 = /__hive_agent_unreachable` rewrites to `unreachable.html`. + +Both pages are built at deploy time via `pkgs.runCommand` (one nix +derivation `hyperhive-agent-error-pages` with `not-found.html` + +`unreachable.html` inside) and served via two `internal` nginx +locations with `alias` to the exact file. `internal` keeps the +files from being directly request-able by operators — only nginx's +own error-handling can reach them. + +Page styling: minimal inline CSS matching the dashboard's catppuccin +palette (`#1e1e2e` bg, `#cdd6f4` text, `#cba6f7` heading). No +dependencies on the frontend dist — these pages render even when +hive-c0re itself is down. + +Scope is intentionally narrow per mara on #755: "only for routes +already special cased in the nginx config". Other gateway routes +(forge / matrix / fluffychat) get nginx defaults — extending the +custom-error pattern there is a separate follow-up. diff --git a/docs/gotchas.md b/docs/gotchas.md index 5cf01a20..b061d6dd 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -87,8 +87,7 @@ propagate in. Operators don't need to set anything on their side. ## Claude credentials are per-agent `/var/lib/hyperhive/agents//claude/` bind-mounts to -`/home//.claude` (RW; was `/root/.claude` pre-#658 when every -harness ran as root). Sharing one dir across agents is NOT viable — +`/home//.claude` (RW). Sharing one dir across agents is NOT viable — OAuth refresh tokens rotate, so any sibling refresh invalidates all the others. Login flow runs from the per-agent web UI; creds persist across `destroy`/recreate (`--purge` wipes them). @@ -96,8 +95,8 @@ across `destroy`/recreate (`--purge` wipes them). ## Persistent notes dir per agent `/var/lib/hyperhive/agents//state/` bind-mounts to -`/agents//state` (RW; uniform for sub-agents + manager -post-#604, was `/state` pre-#604). The harness exposes the same path +`/agents//state` (RW; uniform for sub-agents + manager). +The harness exposes the same path via `$HYPERHIVE_STATE_DIR`. System prompts tell agents to keep durable knowledge here (`notes.md`, anything else). The harness also writes its events log here (`hyperhive-events.sqlite`). @@ -112,8 +111,8 @@ unlucky. Operator resolves a collision by renaming the offending agent (different hash → different port) and rebuilding. No state file, no probing, no port-allocation drift — the value is reproducible from just the name. Every agent — including the -manager — hashes into 8100..8999 via the same FNV-1a since #753; -dashboard at `cfg.dashboardPort` (default 7000). +manager — hashes into 8100..8999 via the same FNV-1a; dashboard +at `cfg.dashboardPort` (default 7000). ## Restart races on TCP bind @@ -146,13 +145,12 @@ files in subdirectories) fails with `EPERM`. Fix: pass The naive nginx pattern for a path-prefix SPA (`try_files $uri $uri/ /matrix/index.html`) silently swallows asset 404s — a missing JS file returns `index.html` with a 200, so the JS runtime never loads and the -page renders blank with no visible error (#685; fixed in PR #684; -#686 filed the follow-up edge-case, addressed in PR #729). -Extension allowlists (tried in #686 → PR #729) have the same maintenance -problem: any new file extension the SPA ships breaks silently. +page renders blank with no visible error. Extension allowlists (tried +as an alternative) have the same maintenance problem: any new file +extension the SPA ships breaks silently. -The correct pattern (landed in PR #729, `hive-gateway.nix`) keys the -fallback on the HTTP `Accept` header: +The pattern that works (`hive-gateway.nix`) keys the fallback on the +HTTP `Accept` header: ```nginx # Outside the server block (appendHttpConfig): @@ -188,22 +186,22 @@ nix build /var/lib/hyperhive/meta#argus.config.system.build.toplevel nix build /var/lib/hyperhive/meta#nixosConfigurations.argus.config.system.build.toplevel ``` -`lifecycle::prebuild_toplevel` hit this in #721 (fixed in #738) by -constructing the attr path as `{flake_ref}.config…` — which produced -`meta#argus.config…` instead of `meta#nixosConfigurations.argus.config…`. -The fix: `split_once('#')` to separate flake path from name, then -template `{path}#nixosConfigurations.{name}.config.system.build.toplevel`. +`lifecycle::prebuild_toplevel` hit this once by constructing the attr +path as `{flake_ref}.config…` — which produced `meta#argus.config…` +instead of `meta#nixosConfigurations.argus.config…`. The fix: +`split_once('#')` to separate flake path from name, then template +`{path}#nixosConfigurations.{name}.config.system.build.toplevel`. ## `hive-forge`: prefer over raw curl pipelines Every agent container has `hive-forge` in PATH (installed via -`harness-base.nix`; lives in `/hive-forge` as a proper Rust binary -since #280). Use it instead of ad-hoc curl pipelines: +`harness-base.nix`; lives in `/hive-forge` as a proper Rust binary). +Use it instead of ad-hoc curl pipelines: ```bash hive-forge view 42 # title + body + comments hive-forge comments 42 # list all comments (human-readable) -hive-forge --json comments 42 # same as above, JSON array (global flag, closes #421) +hive-forge --json comments 42 # same as above, JSON array (global flag) hive-forge comment 42 --body "..." # post comment (inline body) hive-forge comment 42 --body-file - <`. + +- **Port allocation**: deterministic FNV-1a of the agent name + (read from `/etc/hostname`, leading `h-` stripped) mapped into + `[15900, 16799]`. Mirrors the agent web-UI port pattern from + `docs/gotchas.md::Web UI ports collide on hash` — same FNV-1a + constant, different range. The compositor's startup script writes + `/etc/hyperhive/gui.json = {"vnc_port":N,"auth":"none"}` so the + harness reads the port at runtime; no nix-side / harness-side hash + duplication. +- **VNC bind address**: weston's VNC backend has no CLI + bind-address flag (unlike the RDP backend's `--address`), so the + listener binds `0.0.0.0`. The harness relay only connects via + `127.0.0.1`; the host firewall blocks the per-agent VNC port range + from external access. A future weston.ini `[vnc] address=` will + let us restrict the bind directly once upstream supports it. +- **PAM service name**: literal `weston-remote-access` — that's the + string libweston passes to `pam_start()` in `libweston/auth.c`. + Using `weston` falls back to the system default PAM stack and + rejects auth. The service is configured to `pam_permit.so` for + all three module types (auth / account / session) so the + browser's empty Apple-DH credentials (type 30) always pass — + neatvnc ≥ 0.9 calls the PAM auth callback regardless of + `weston.ini` `auth-method=none`, so the permit fallback is what + actually lets the empty-cred client through. +- **`Type = "simple"` (not `notify`)**: `switch-to-configuration` + must never block on weston signalling readiness. A misconfigured + weston degrades to a `Restart=on-failure` loop visible in + `journalctl`, it does not abort the `nixos-container update`. + Same reasoning as the `tea-login` unit in `harness-base.nix`. +- **`[core] idle-time=0`**: disables weston's 300-second idle + timeout. Without it the VNC desktop fades to black and + desktop-shell shows its click-to-unlock screen — useless for an + agent desktop viewed over `/screen`. `idle-time=0` updates the + idle timer with a 0ms delay, which + `wl_event_source_timer_update` treats as "disarm", so the + compositor never goes idle and never locks. + +## Nix options reference (`nix/docs/default.nix`) + +`pkgs.nixosOptionsDoc` over two evaluated module trees: +`hostEval` (a stub NixOS system loading `self.nixosModules.default` +with every hyperhive subsystem `mkForce false` so heavy build +inputs stay out of the eval) and `agentEval` (reuses the already-evaluated +`agent-base` container config so the per-agent options tree is +identical to what a real agent container sees). + +Three output trees consumed by `flake.nix`: + +- `docs-host` — operator-facing host module options + (`services.hyperhive.*`) +- `docs-agent` — per-agent harness options (`hyperhive.*` + declared in `nix/templates/harness-base.nix`) +- `docs` — bundled static site (`index.html` + `host.html` + + `agent.html`, plus `.md` source-of-truth versions of each + options page) + +Rendering pipeline: + +- CommonMark from `nixosOptionsDoc.optionsCommonMark` — source of + truth, kept as `.md` in the bundle. +- HTML via `pkgs.cmark-gfm` over the CommonMark, wrapped in a + minimal inline-CSS template. `cmark-gfm` (not plain `cmark`) so + any future tables / autolinks Just Work without revisiting. +- Inline ` + + +

◆ agent not found

+

No agent matches the requested /agent/<name>/ path on this hive.

+

Operator: check the agent name in the dashboard — the gateway picks up new agents on the next nixos-rebuild switch.

+ + + EOF + cat > $out/unreachable.html <<'EOF' + + + + + agent unreachable ◆ hyperhive + + + +

◆ agent unreachable

+

The agent's harness web server isn't responding. Container restarting, or the agent crashed.

+

Operator: dashboard → check the container status / journal; the page will recover on retry once the harness is back up.

+ + + EOF + ''; in { # Single nginx in front of every hyperhive web surface — dashboard, @@ -268,8 +318,11 @@ in # per entry in `agentPortsTable`. Trailing-slash pair # strips the prefix; `X-Forwarded-Prefix` lets the # harness build absolute URLs when relative isn't - # enough. See `docs/gateway.md` for the vhost map - # + tuning rationale. + # enough. `proxy_intercept_errors` + `error_page` rewrite + # upstream 502/503/504 (container down / restarting) to + # the static `unreachable.html` instead of nginx's + # default Bad Gateway page (#755). See + # `docs/gateway.md` for the vhost map + tuning. lib.mapAttrs' (name: port: { name = "/agent/${name}/"; value = { @@ -279,9 +332,44 @@ in proxy_set_header X-Forwarded-Prefix /agent/${name}; proxy_buffering off; proxy_read_timeout 1d; + proxy_intercept_errors on; + error_page 502 503 504 = /__hive_agent_unreachable; ''; }; }) agentPortsTable + // + # `/agent/` catch-all (#755): hits when an operator + # requests `/agent//...` — a name not in + # `agentPortsTable`. Without this it falls through to + # `/` (c0re dashboard upstream) which returns 404 + # with no useful context. Custom 404 page instead. + { + "/agent/" = { + extraConfig = '' + error_page 404 = /__hive_agent_not_found; + return 404; + ''; + }; + # Internal static-file locations the error_page + # directives above point at. `internal` keeps + # operators from hitting the file directly (only + # nginx's error-handling can reach it); `alias` + # serves the exact file regardless of request URI. + "= /__hive_agent_not_found" = { + extraConfig = '' + internal; + alias ${agentErrorPagesDir}/not-found.html; + default_type text/html; + ''; + }; + "= /__hive_agent_unreachable" = { + extraConfig = '' + internal; + alias ${agentErrorPagesDir}/unreachable.html; + default_type text/html; + ''; + }; + } // { # Everything else proxies to hive-c0re. Upgrade # headers stay set so SSE (`/dashboard/stream`, diff --git a/nix/modules/hive-matrix.nix b/nix/modules/hive-matrix.nix index d4964534..f0480736 100644 --- a/nix/modules/hive-matrix.nix +++ b/nix/modules/hive-matrix.nix @@ -9,26 +9,16 @@ let hyperhiveDomain = config.services.hyperhive.domain; effectiveServerName = if cfg.serverName != null then cfg.serverName else hyperhiveDomain; - # Three files are missing from `pkgs.fluffychat-web` because - # `flutter341.buildFlutterApplication` doesn't run the dart - # web-worker compile pass + doesn't run the native_imaging emscripten - # build (#685). `fluffychat-web-imaging` below builds the latter from - # source via `pkgs.emscripten`; the worker compile is inline in - # `fluffychat-web-fixed.postInstall`. Drop both when nixpkgs's - # flutter builder grows worker + emcc support upstream. + # fluffychat-web build fixes: nixpkgs's `flutter341.buildFlutterApplication` + # skips the dart web-worker compile + the emscripten native_imaging + # build. Two derivations below cover both. Full rationale (why + # passthru.pubspecLock.dependencySources, why `dontConfigure`, why + # `make -C js`, why build-CWD-relative dart path): docs/matrix.md:: + # fluffychat-web build fixes. - # `Imaging.{js,wasm}` built from `native_imaging`'s C source via - # emscripten. Source comes from - # `pkgs.fluffychat-web.passthru.pubspecLock.dependencySources` so - # there's no parallel hash pin — version auto-syncs with nixpkgs - # bumps. Build closure +~3.6 GiB (emscripten LLVM); runtime closure - # is just the two output files. fluffychat-web-imaging = pkgs.stdenv.mkDerivation { pname = "fluffychat-web-imaging"; version = pkgs.fluffychat-web.passthru.pubspecLock.dependencyVersions.native_imaging; - - # The pub-cache derivation that fluffychat-web's flutter build uses. - # Already in the build closure; no `fetchurl` or own hash pin. src = pkgs.fluffychat-web.passthru.pubspecLock.dependencySources.native_imaging; nativeBuildInputs = with pkgs; [ @@ -38,21 +28,18 @@ let jq ]; - # cmake config runs inside `js/Makefile` (via `emcmake cmake`) — - # skip the default `configurePhase` which would try to invoke - # cmake against the package root and fail (no CMakeLists at top). + # cmake runs inside js/Makefile via `emcmake cmake`; the default + # configurePhase would invoke cmake at the package root (no + # CMakeLists) and fail. dontConfigure = true; buildPhase = '' runHook preBuild - # emscripten needs HOME + a writable cache dir for its sysroot - # build (libc, libc++, etc. compiled to wasm on demand). + # emscripten on-demand sysroot build needs writable HOME + cache. export HOME=$TMPDIR export EM_CACHE=$TMPDIR/.emscriptencache mkdir -p $EM_CACHE - # `make -C js` keeps the build phase pwd at the source root so - # installPhase doesn't have to know about the cd (argus 🟡 on - # PR #697 v2 — robust against future reorders / `dontBuild`). + # `make -C js` keeps pwd at source root for the installPhase. make -C js Imaging.js Imaging.wasm runHook postBuild ''; @@ -66,71 +53,37 @@ let ''; meta = with pkgs.lib; { - description = "Imaging.js + Imaging.wasm built from the native_imaging dart package for fluffychat-web (#685)"; + description = "Imaging.js + Imaging.wasm built from the native_imaging dart package for fluffychat-web"; homepage = "https://pub.dev/packages/native_imaging"; license = licenses.agpl3Plus; }; }; - # `pkgs.fluffychat-web` with #685's three missing files patched in - # via postInstall. Mount point is `matrix./` (#772); upstream - # `--base-href "/"` is correct at sub-domain root, no override. fluffychat-web-fixed = pkgs.fluffychat-web.overrideAttrs (old: { - # `dart` from the flutter341 closure (already pulled, no - # incremental closure cost) so we can compile the web-worker - # entry point that buildFlutterApplication skips. + # dart from the flutter341 closure (already pulled, no incremental + # cost) to compile the web-worker entry point. nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.flutter341.dart ]; - postInstall = - (old.postInstall or "") - + '' - # `web/...` is relative to build CWD so dart's package_config - # walk-up hits buildFlutterApplication's pub-get output (#685 - # / #733 fixup — `$src/web/...` would walk up to a read-only - # store path with no `.dart_tool/`). - ${pkgs.flutter341.dart}/bin/dart compile js \ - -o $out/native_executor.js \ - web/native_executor.dart + postInstall = (old.postInstall or "") + '' + # `web/...` is BUILD-CWD-relative (not `$src/...`) so dart's + # package_config walk-up hits buildFlutterApplication's + # pub-get output `.dart_tool/`. + ${pkgs.flutter341.dart}/bin/dart compile js \ + -o $out/native_executor.js \ + web/native_executor.dart - install -m 644 ${fluffychat-web-imaging}/Imaging.js $out/Imaging.js - install -m 644 ${fluffychat-web-imaging}/Imaging.wasm $out/Imaging.wasm - ''; + install -m 644 ${fluffychat-web-imaging}/Imaging.js $out/Imaging.js + install -m 644 ${fluffychat-web-imaging}/Imaging.wasm $out/Imaging.wasm + ''; }); in { - # Private Matrix homeserver (matrix-tuwunel — the official conduwuit - # successor) for hyperhive agents, wrapped in a nixos-container so it - # doesn't fight any existing `services.matrix-*` the operator may - # already run on the host. Same shape as `nix/modules/hive-forge.nix`: - # shared host netns (`privateNetwork = false`) so agents reach it at - # `http://localhost:` (or via the configured server_name - # for federation), nixos-container only here for state + systemd-unit - # isolation. - # - # Container name `hive-matrix` (not `h-*`) so the lifecycle scanner - # ignores it; operator manages via the standard `nixos-container` CLI. - # - # Persistent state at `/var/lib/nixos-containers/hive-matrix/var/lib/ - # matrix-tuwunel/` (survives container restart / host reboot). To - # wipe, destroy the container. - # - # Initial rollout (#548): federation enabled (needed for multi-hive - # swarms; trusted_servers starts empty so no actual federation traffic - # leaves until peers are explicitly listed), registration enabled via - # a `registration_token_file` known only to hive-c0re (so agents can't - # self-register without going through the coordinator), e2ee disabled - # per operator call (tracked for follow-up at #551). - # - # Provisioning model (matches `nix/modules/hive-forge.nix` shape): - # hive-c0re generates a 32-byte random `registration_token` on first - # boot, writes it to `/var/lib/hyperhive/matrix-register-token` (mode - # 0600, root-only), and bind-mounts that file read-only into the - # tuwunel container at the same path so tuwunel can read it via - # `registration_token_file`. hive-c0re then uses the token to register - # each agent account via the matrix-spec UIAA registration flow, and - # persists the returned `access_token` to `/matrix-token` - # so the agent's matrix MCP client can authenticate without ever - # seeing the shared registration token. + # Private matrix-tuwunel homeserver wrapped in a nixos-container, + # optional fluffychat-web client at matrix./. Container shape, + # serverName vs gatewayHost split, provisioning flow (registration + # token + LoadCredential), assertion rationale, initial rollout + # settings: docs/matrix.md. Vhost map + discovery flow + tuning + # knobs: docs/gateway.md. options.services.hyperhive.matrix = { enable = lib.mkOption { @@ -322,12 +275,10 @@ in }; config = lib.mkIf cfg.enable { - # mara on #548: "there is no default, but it is required. add - # assertion." — fail eval with a helpful message rather than - # spawning a homeserver with a bogus server_name we can never - # change later. `services.hyperhive.domain` is host-wide; matrix derives - # the server_name from it (or from `cfg.serverName` if the - # operator wants to override). + # serverName must exist (mara on #548 — irrevocably embedded in + # user/room IDs); gatewayHost may not be "" (argus 🟡 on #764 — + # same footgun as forge.domain). docs/matrix.md::Assertion + # rationale. assertions = [ { assertion = hyperhiveDomain != null || cfg.serverName != null; @@ -344,11 +295,6 @@ in ''; } { - # Same footgun as forge.domain (#754): empty string renders - # `.` shaped garbage in both nginx server_name (treated - # as wildcard catch-all, surprising) and /etc/hosts (invalid - # entry). Argus 🟡 on #764 — fail loud here rather than ship - # the surprising behaviour. assertion = cfg.gatewayHost == null || cfg.gatewayHost != ""; message = '' services.hyperhive.matrix.gatewayHost = "" is rejected. The @@ -361,22 +307,10 @@ in } ]; - # Generate the registration token at system activation time, BEFORE - # the hive-matrix container would otherwise start with an empty - # bind-mount target (argus nit on #565: nspawn creates an empty - # file when the host path is missing, tuwunel reads it as - # `registration_token_file=""` and rejects every registration - # until the next restart). Idempotent: only writes when the file - # doesn't exist. 32-byte hex = 64 chars, same shape hive-c0re's - # `matrix::ensure_register_token` would produce. - # - # Ownership: plain `root:root 0600` — tuwunel inside the container - # runs as a hardened dynamic user (#644) and reads the token via - # systemd's `LoadCredential=` mechanism (see container config - # below), so it never needs direct read access on the host-side - # file. No `chown :tuwunel` / `chmod 0640` / GID-pin gymnastics - # required (per iris on #644 8043, dropping the shape #649 - # shipped with). + # Activation-time token generation (argus #565: the bind-mount + # would otherwise hand tuwunel an empty file on first boot and + # break every registration until restart). Idempotent; + # docs/matrix.md::Provisioning flow. system.activationScripts.hive-matrix-register-token = lib.stringAfter [ "var" ] '' tokenFile=${lib.escapeShellArg (toString cfg.registrationTokenFile)} if [ ! -s "$tokenFile" ]; then @@ -385,25 +319,17 @@ in echo >> "$tokenFile" echo "hive-matrix: generated registration token at $tokenFile" fi - # Always re-apply 0600 (idempotent on already-correct files; - # also normalises any 0640 / world-readable carry-over from - # pre-LoadCredential deployments). + # Re-apply 0600 (normalises any pre-LoadCredential carry-over). chmod 0600 "$tokenFile" ''; containers.hive-matrix = { autoStart = true; ephemeral = false; - # Share host netns — tuwunel's listeners look exactly like - # host-side services, no port-forward plumbing, and agent - # containers (also host netns) reach it via plain `localhost`. + # Shared host netns — agents reach tuwunel at localhost:. privateNetwork = false; - # Read-only bind of the host-managed registration token so - # tuwunel can resolve `registration_token_file` to a real - # file inside the container. The activation script above - # ensures the host path exists with a valid 64-char hex token - # before any container starts, so the bind always finds real - # content (no first-boot empty-file race; argus #565 nit). + # Read-only bind of the host-managed registration token; tuwunel + # reads it via systemd LoadCredential below (not directly). bindMounts.${cfg.registrationTokenFile} = { hostPath = cfg.registrationTokenFile; isReadOnly = true; @@ -417,44 +343,28 @@ in package = cfg.package; settings.global = { server_name = effectiveServerName; - # `address` is `listOf nonEmptyStr` upstream (multi-bind - # support). Single-host bind goes through as a one-element list. + # `address` + `port` are upstream `listOf` — wrap singles. address = [ "0.0.0.0" ]; - # `port` is `listOf port` upstream. Same shape. port = [ cfg.httpPort ]; max_request_size = cfg.maxRequestSize; - # Federation enabled at the protocol level so swarms - # can be wired up later by extending `trustedServers` - # without a homeserver restart. Empty trusted_servers - # keeps it effectively closed until peers are listed. + # Federation enabled at the protocol level; empty + # trustedServers keeps it effectively closed. allow_federation = true; trusted_servers = cfg.trustedServers; - # Token-gated registration: hive-c0re holds the token, - # agents never see it. allow_registration must be true - # for the token flow to engage; the absent + # Token-gated registration. The absent # `yes_i_am_very_very_sure_…_open_registration_…` flag # keeps the server closed to anyone without the token. allow_registration = true; - # Read the registration token via systemd's - # `LoadCredential=` mechanism (wired below) instead of - # the bind-mount path directly. systemd copies the host- - # owned 0600 root:root file into a per-service - # credentials dir owned by tuwunel's dynamic user with - # mode 0400 — keeps `DynamicUser=true` + `PrivateUsers=true` - # intact, no host-side `chown :tuwunel` / GID-pin - # gymnastics required (#644 / iris on 8043). + # LoadCredential below copies the host file into a + # 0400 dynamic-user-owned path; tuwunel reads from there. registration_token_file = "/run/credentials/tuwunel.service/registration_token"; - # E2EE disabled in initial rollout per operator call - # (#548) — re-enabling tracked at #551. + # E2EE disabled in initial rollout (#548); re-enable at #551. allow_encryption = false; }; }; - # `LoadCredential=:` makes systemd copy the - # bind-mounted host file into `/run/credentials/tuwunel.service/` - # owned by the service's (dynamic) user with mode 0400 at - # service start. The hardcoded path in `registration_token_file` - # above is the systemd-stable credentials dir; see - # `man systemd.exec` → LoadCredential. + # Keeps DynamicUser=true + PrivateUsers=true intact — no + # host-side chown :tuwunel / GID-pin gymnastics needed (#644 / + # iris on 8043). See `man systemd.exec` → LoadCredential. systemd.services.tuwunel.serviceConfig.LoadCredential = [ "registration_token:${toString cfg.registrationTokenFile}" ]; diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 0c433b59..41528e26 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -75,6 +75,40 @@ in ''; }; + options.hyperhive.web.useUnixSocket = lib.mkOption { + type = lib.types.bool; + default = false; + example = true; + description = '' + When `true`, set `HIVE_WEB_SOCKET=/run/hive-agent/${userName}/web.sock` + on the harness service env, which makes `web_ui::serve` bind a + `UnixListener` at that path instead of the legacy TCP listener + on `HIVE_PORT`. Closes the third hop of the #784 rollout: PR + #800 added the harness-side opt-in, #809 / #813 added the c0re + bind-mount + JSON-map plumbing, this is the per-agent flip + that activates the unix-domain path. + + Default `false` so an agent's web UI keeps binding TCP until + the per-agent flip is explicit. Rollout shape: + + 1. flip one canary agent (atlas volunteered) to `true` via its + `agent.nix` once #813 lands; + 2. validate the gateway's `proxy_pass http://unix:.../web.sock` + end-to-end against that canary (atlas's step 3); + 3. flip remaining agents per-agent as the gateway side soaks; + 4. eventually drop this option once every agent's on unix + + atlas's gateway is the only path — step 4 of #784 drops the + harness's TCP fallback at the same time. + + Sub-agent-only by design: the manager's UI serves at `/` via + the c0re dashboard upstream, not via `/agent//`, so this + option has no effect when `hyperhive.role = "manager"` (the + env var is set unconditionally for clarity, but the manager's + web UI doesn't route through the gateway's per-agent unix + upstream — its bind socket would just sit unused). + ''; + }; + options.hyperhive.role = lib.mkOption { type = lib.types.enum [ "agent" @@ -537,6 +571,64 @@ in ''; }; + # Internal accumulator for shell snippets that should land in + # `/etc/hyperhive/bash-env.sh`. Per-feature hooks set this via + # `lib.mkIf` gated on their own option; the lines type merges + # all contributions across modules into one file. Loaded via + # `$BASH_ENV` for non-interactive shells (claude's `Bash` tool + # runs `bash -c`) and via `programs.bash.interactiveShellInit` + # for interactive shells. Generic by design (mara on #779) so + # future hooks don't need to either rename this file or invent + # a parallel dispatcher. + options.hyperhive._bashEnvFragments = lib.mkOption { + type = lib.types.lines; + default = ""; + internal = true; + description = '' + Shell snippets concatenated into `/etc/hyperhive/bash-env.sh`. + Feature hooks contribute via `lib.mkIf` gated on their own + option. When empty, the file isn't created, `BASH_ENV` stays + unset, and the interactive bashrc hook is omitted — zero cost + when no feature is on. Internal — set indirectly via the + per-feature options that own the gate (e.g. + `hyperhive.cargo.shortMessages`). + ''; + }; + + options.hyperhive.cargo.shortMessages = lib.mkOption { + type = lib.types.bool; + default = true; + example = false; + description = '' + Auto-inject `--message-format short` on cargo compile + subcommands (`build`, `check`, `clippy`, `test`, `run`, + `doc`, `bench`, `install`, `rustc`, `fix`) when claude (or + anything else) invokes `cargo` inside this container. + Saves tokens + context — the verbose default output floods + the response window with per-crate progress lines that + carry no signal beyond the warning/error summary (#777). + + Implementation: contributes a `cargo` shell function to + `/etc/hyperhive/bash-env.sh` (see `hyperhive._bashEnvFragments`). + Loaded via `BASH_ENV` for non-interactive shells (`bash -c` — + what the claude `Bash` tool runs) and sourced from + `programs.bash.interactiveShellInit` for interactive shells. + The function: + + - handles the `+toolchain` selector prefix (`cargo +nightly + build` works); + - passes through cleanly when the caller already specified + `--message-format` (any form); + - leaves non-compile subcommands (`new`, `add`, `search`, + third-party `cargo-*` subcommands) untouched so they + don't error on the unknown flag. + + Set to `false` for agents that need full cargo output (e.g. + tooling that parses `--message-format json` programmatically + and doesn't pass the flag explicitly). + ''; + }; + options.hyperhive.autoCompact = lib.mkOption { type = lib.types.bool; default = true; @@ -656,46 +748,20 @@ in } ]; - # First-boot migration from the legacy root-run shape (#658). - # Runs on every activation; marker-guarded so the move only - # happens once. The bind mount that hive-c0re sets up has - # already moved from `/root/.claude` to `${homeDir}/.claude` - # by the time we get here (per `lifecycle::CONTAINER_CLAUDE_MOUNT` - # — the host-side path stays the same, the container-side - # mount target shifts), so the bulk of the data is already at - # the new location. This script just: - # - # - ensures `${homeDir}` exists with correct ownership (covers - # the very first boot before useradd's `createHome` has - # anything to chown); - # - migrates any leftover `/root/.claude` content that an - # operator might have populated before #658 deployed (the - # bind mount didn't exist in that lifecycle, so claude - # would have written into the root user's empty home — - # nothing important typically, but safer to move than to - # strand); - # - chowns the bind-mounted state dir (`/agents/*/state`) so - # the agent user can read/write it. + # Post-#658 first-boot migration to the per-agent unix user — + # creates the home dir, chowns the bind-mounted state + + # `~/.claude/`, and (marker-guarded) moves any leftover + # `/root/.claude` content from the pre-#658 root-run shape. See + # `docs/persistence.md::First-boot agent-user migration` for the + # step-by-step rationale; this script implements it. system.activationScripts.hive-agent-user-migrate = lib.stringAfter [ "users" "specialfs" ] '' homeDir=${lib.escapeShellArg homeDir} userName=${lib.escapeShellArg userName} - # Always ensure the home dir exists with the right ownership — - # useradd's createHome handles the very first creation but - # doesn't re-chown if a rebuild changes the user name (rare - # but possible if the meta-flake's per-agent name evolves). mkdir -p "$homeDir" chown "$userName:$userName" "$homeDir" - # One-time migration of pre-#658 /root/.claude content into the - # new home. Marker-guarded so the move only runs once per - # container lifetime — subsequent activations skip the legacy - # path even if claude were to repopulate /root/.claude for any - # reason. marker=/var/lib/hive-agent-user-migrated if [ ! -e "$marker" ] && [ -d /root/.claude ] && [ "$(ls -A /root/.claude 2>/dev/null)" ]; then mkdir -p "$homeDir/.claude" - # `mv -n` (no-clobber) so any pre-existing files at the - # destination (e.g. from the bind mount) win — we never - # blow over data already at the new location. if cp -an /root/.claude/. "$homeDir/.claude/" 2>/dev/null; then rm -rf /root/.claude echo "hive-agent-user-migrate: moved /root/.claude → $homeDir/.claude" @@ -703,25 +769,10 @@ in fi mkdir -p "$(dirname "$marker")" : > "$marker" - # Chown the bind-mounted state dir so the agent user can - # read/write it. `/agents/*/state` is the canonical mount - # point set by hive-c0re's `set_nspawn_flags`. Wildcard - # because each container only sees its own - # `/agents//state` (one match); -h to avoid following - # any symlinks the agent might have planted in there. for stateDir in /agents/*/state; do [ -d "$stateDir" ] || continue chown -hR "$userName:$userName" "$stateDir" 2>/dev/null || true done - # Same treatment for the bind-mounted `~/.claude/` dir. Pre-#658 - # the harness ran as root and `claude` wrote `.credentials.json` - # there 0600 root:root; post-#658 the harness reads - # `~/.claude/` as the agent user to decide Online vs - # NeedsLogin (`login::has_session`), and the host-side bind - # source is still root-owned 0700 from those legacy writes. - # Chown recursively so the existing credentials are readable - # under the new identity instead of getting silently treated - # as "no session" and re-prompting login every boot. if [ -d "$homeDir/.claude" ]; then chown -hR "$userName:$userName" "$homeDir/.claude" 2>/dev/null || true fi @@ -753,6 +804,55 @@ in source = config.hyperhive.icon; }; + # Cargo `--message-format short` injector (#777). Contributes a + # `cargo` shell function to `hyperhive._bashEnvFragments`; the + # bash-env infrastructure below packages that into a single file + # sourced by both non-interactive and interactive shells. + # `command cargo …` falls back to the un-wrapped binary in PATH + # (the rust toolchain's cargo — either from `environment.systemPackages` + # or from whatever `nix develop` shell the agent's working in). + hyperhive._bashEnvFragments = lib.mkIf config.hyperhive.cargo.shortMessages '' + # Auto-injects --message-format short on cargo compile + # subcommands so per-crate progress lines don't flood + # claude's context (#777). Bypassed when the caller + # already passes --message-format (any form). + cargo() { + # Strip leading +toolchain selectors (cargo +nightly …). + local pre=() + while [ "''${1:0:1}" = "+" ] && [ -n "''${1:-}" ]; do + pre+=("$1") + shift + done + case "''${1:-}" in + build|check|clippy|test|run|doc|bench|install|rustc|fix) + local sub="$1" + shift + local arg + for arg in "$@"; do + case "$arg" in + --message-format|--message-format=*) + command cargo "''${pre[@]}" "$sub" "$@" + return $? + ;; + esac + done + command cargo "''${pre[@]}" "$sub" --message-format short "$@" + ;; + *) + command cargo "''${pre[@]}" "$@" + ;; + esac + } + ''; + + # Single bash-env file with all configured shell fragments. + # Wiring is gated on at least one fragment being active so a + # fully feature-disabled agent has neither the file nor the + # `BASH_ENV` / interactive sourcing — zero cost in that case. + environment.etc."hyperhive/bash-env.sh" = lib.mkIf (config.hyperhive._bashEnvFragments != "") { + text = config.hyperhive._bashEnvFragments; + }; + environment.etc."hyperhive/bash-allow.json".text = builtins.toJSON config.hyperhive.allowedBashPatterns; @@ -814,8 +914,28 @@ in } // lib.optionalAttrs (config.hyperhive.forge.skipNotifyReasons != [ ]) { HIVE_FORGE_NOTIFY_SKIP_REASONS = lib.concatStringsSep "," config.hyperhive.forge.skipNotifyReasons; + } + // lib.optionalAttrs (config.hyperhive._bashEnvFragments != "") { + # Non-interactive bash invocations (claude's `Bash` tool runs + # `bash -c`) source $BASH_ENV at startup — drops every active + # feature hook's snippet into scope without touching + # `/etc/profile` (login-only). Interactive shells source the + # same file via the `interactiveShellInit` hook below so + # behaviour matches across both modes (#777). + BASH_ENV = "/etc/hyperhive/bash-env.sh"; }; + # Interactive shells don't honour BASH_ENV — wire the same file + # in via the bashrc hook so operator SSH sessions get the same + # hook surface as claude's non-interactive calls. Gated on at + # least one fragment being active so we don't write a no-op + # source line into `/etc/bashrc` on fully-feature-disabled agents. + programs.bash.interactiveShellInit = lib.mkIf (config.hyperhive._bashEnvFragments != "") '' + if [ -r /etc/hyperhive/bash-env.sh ]; then + . /etc/hyperhive/bash-env.sh + fi + ''; + boot.isNspawnContainer = true; # Every agent gets flakes + the modern `nix` CLI out of the box. @@ -829,15 +949,11 @@ in "flakes" ]; - # Containers bind-mount the host's nix-daemon socket. The host daemon - # may be configured with remote builders or strict sandbox settings - # (sandbox-fallback = false) that make local `nix build` invocations - # fail inside the container. Enable sandbox-fallback so builds that - # can't set up the sandbox (no user-namespaces in nspawn) fall back - # to unsandboxed local builds rather than failing outright. - # mkForce overrides the nixpkgs nix module which sets this to false - # at normal priority -- without it agents get a conflicting definition - # error on rebuild. Security implications: see docs/security.md. + # `lib.mkForce` overrides nixpkgs's normal-priority `false` so + # in-container `nix build` invocations fall back to unsandboxed + # local builds rather than failing on the missing user-namespace. + # See `docs/gotchas.md::Containerized nix-daemon needs + # sandbox-fallback = true` + `docs/security.md` for the rationale. nix.settings.sandbox-fallback = lib.mkForce true; # `claude-code` is unfree. Each per-agent container's nixosConfiguration @@ -1005,14 +1121,12 @@ in ''; }; - # Long-running matrix-sdk Client + sync per agent (#548 phase 3). - # Holds the unix socket the stdio `hive-matrix-mcp` bridge talks - # to, and emits hyperhive wake signals on incoming room events - # via `/run/hive/mcp.sock`. Conditional on `hyperhive.matrix.enable` - # AND token-file presence (the daemon binary itself exits 0 on - # missing token, but the path watcher below restarts it the - # moment the token lands — same first-boot-ordering pattern as - # matrix-avatar-sync.path / #571). + # Long-running matrix-sdk client + sync per agent. Holds the unix + # socket the stdio `hive-matrix-mcp` bridge connects to + emits + # hyperhive wake signals on incoming room events via + # `/run/hive/mcp.sock`. See + # `docs/persistence.md::Matrix per-agent daemon + token-arrival + # trigger` for the socket-path / first-boot-ordering rationale. systemd.services.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable { description = "long-running matrix-sdk Client + MCP daemon socket"; wantedBy = [ "multi-user.target" ]; @@ -1020,12 +1134,6 @@ in wants = [ "network-online.target" ]; environment = { HIVE_MATRIX_URL = config.hyperhive.matrix.url; - # Socket path lives inside the systemd-managed runtime dir - # (`RuntimeDirectory = "hive-matrix"` → `/run/hive-matrix/`, - # owned by the agent user) so the daemon can bind it without - # needing root over `/run/` itself (#658). The stdio bridge - # picks up the same path via its own `HIVE_MATRIX_SOCKET` env - # in `extraMcpServers.matrix` below. HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket"; RUST_LOG = "info"; }; @@ -1033,26 +1141,17 @@ in ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon"; Restart = "on-failure"; RestartSec = 5; - # Run as the per-agent unix user (#658). The runtime dir - # (`/run/hive-matrix/`) is owned by that user via - # `RuntimeDirectory`; claude (also as that user) can - # connect to the socket inside it when the stdio bridge - # spawns per turn. User = userName; Group = userName; RuntimeDirectory = "hive-matrix"; }; }; - # Path-trigger sibling so hive-matrix-daemon fires the moment - # `/matrix-token` appears (#548 phase 3, mirrors the - # matrix-avatar-sync.path pattern from #571). On clean boot - # hive-c0re provisions the token AFTER agent containers come up; - # without the trigger the daemon would exit 0 quietly and the - # MCP would have no backend until next restart. With the watcher - # the daemon comes alive in the same boot cycle as provisioning. - # The glob matches every agent (manager sees its own state at - # `/agents/hm1nd/state/` via the `/agents` bind). + # Re-fire the daemon when the matrix token appears (hive-c0re + # provisions it after agent containers come up). Without this + # the daemon would exit 0 silently on first boot and the MCP + # would have no backend until next restart. See + # `docs/persistence.md` (same section as above). systemd.paths.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable { description = "trigger hive-matrix-daemon when matrix-token appears"; wantedBy = [ "multi-user.target" ]; @@ -1217,12 +1316,10 @@ in }; }; - # Manager-only forge defaults (#671): skip the - # subscription/participation firehose so the manager's inbox - # only carries direct mentions, reviews, and assignments. Sub- - # agents keep the noisier defaults (`keepSubscriptions = true`, - # `skipNotifyReasons = [ ]`). `mkDefault` so any agent that - # wants to invert it can. + # Manager-only forge defaults: subscription/participation + # firehose stays off so the manager's inbox isn't drowned in + # noise. Full rationale + sub-agent contrast: + # docs/agent-hierarchy.md::Manager-only defaults. hyperhive.forge = lib.mkIf (config.hyperhive.role == "manager") { keepSubscriptions = lib.mkDefault false; skipNotifyReasons = lib.mkDefault [ @@ -1231,91 +1328,49 @@ in ]; }; - # Harness systemd unit. Role-driven so the same `harness-base.nix` - # covers both `nixosConfigurations.agent-base` (`hive-ag3nt serve`) - # and `nixosConfigurations.manager` (`hive-m1nd serve`) without a - # second template file (#671). Per-agent HIVE_PORT / HIVE_LABEL - # come from the meta-flake's generated `applied//flake.nix`; - # the manager has hardcoded fallbacks here so `nixosConfigurations.manager` - # still builds standalone. + # Role-driven harness systemd unit: one binary, two unit names + # for log/ExecStartPre stability. Unit shape (PATH wrapper-dir + # trick, env vars, RuntimeDirectory, User=, standalone-eval + # fallbacks): docs/agent-hierarchy.md::Harness systemd unit + # shape (per-role). PATH /bin auto-append behaviour: + # docs/gotchas.md::systemd.services.*.path appends /bin to + # every entry. systemd.services.${if config.hyperhive.role == "manager" then "hive-m1nd" else "hive-ag3nt"} = let isManager = config.hyperhive.role == "manager"; - # Post-#598 there is exactly one harness binary (`hive`), and - # it picks its surface from `HIVE_ROLE` at startup. We still - # name the systemd unit `hive-ag3nt` / `hive-m1nd` so dashboard - # log queries + ExecStartPre paths + ancestor PR diffs keep - # working without a unit rename cascade. binary = "hive"; in { description = "${binary}${lib.optionalString isManager " manager"} harness"; wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; - # systemd units get a minimal PATH by default and don't inherit - # `environment.systemPackages`. Pointing at `/run/current-system/sw` - # gives the harness (and any tools claude shells out to via Bash) - # access to everything declared in `systemPackages` — including - # anything an agent adds to its own `agent.nix` — without having - # to touch the service definition. - # - # `/run/wrappers/bin` prepended so the `security.wrappers` - # setuid shims (notably `sudo`) resolve before the bare - # nix-store binaries in `/run/current-system/sw/bin`. - # Post-#658 the harness runs as the per-agent user — without - # the wrapper dir on PATH, `sudo` resolves to the un-setuid - # nix-store binary and refuses with "must be owned by uid 0 - # and have the setuid bit set" even when - # `hyperhive.user.passwordlessSudo = true` is configured - # (#672 fixup pulled forward into this PR to avoid the - # regression argus flagged on #676). - # - # `systemd.services..path` appends `/bin` to each entry, - # so the bare prefixes here resolve to `/run/wrappers/bin` + - # `/run/current-system/sw/bin` inside the unit's PATH. Passing - # the trailing `/bin` ourselves (the natural-looking spelling) - # would yield `/run/wrappers/bin/bin` + `/run/current-system/sw/bin/bin`, - # neither of which exists — that's how #672 originally landed - # broken: every agent had a PATH pointing at non-existent dirs - # and `which sudo` kept falling back to the un-setuid binary. + # `/run/wrappers` before `/run/current-system/sw` so setuid + # `sudo` resolves first. Passing the bare prefixes (no trailing + # `/bin`) is intentional — see docs pointer above. path = [ "/run/wrappers" "/run/current-system/sw" ]; environment = { SHELL = "${pkgs.bashInteractive}/bin/bash"; - # `HOME` defaults to `/` for systemd services without a User= - # set. With #658 the harness runs as the agent user — set HOME - # explicitly so claude (which the harness spawns) finds its - # `~/.claude/` session dir at the bind-mounted location. HOME = homeDir; - # Path to the merged agent static dist. The harness serves this - # via `tower_http::ServeDir` for any request it doesn't route to - # an API endpoint. `mergedDist` is the agent-default dist with - # `hyperhive.frontend.extraFiles` layered on top. HIVE_STATIC_DIR = "${config.hyperhive.frontend.mergedDist}"; - # Static runtime assets (branding + claude prompts). Set on the - # unit directly — `environment.variables` only populates - # /etc/profile, which systemd services don't inherit. HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive"; - # Post-#598: the unified `hive` binary picks its surface from - # this env var at startup. Default (`"agent"`) matches the - # binary's standalone fallback when this is unset. HIVE_ROLE = config.hyperhive.role; } + // lib.optionalAttrs config.hyperhive.web.useUnixSocket { + # Per-agent unix-socket flip for the web UI (#784 phase 2 + # step 2c). When set, the harness's `web_ui::serve` binds + # a `UnixListener` at this path instead of TCP. Path + # matches `hive_c0re::agent_sockets::socket_path_for(name)` + # so the lifecycle bind-mount (#813) and the gateway's + # upstream config all derive from the same canonical + # `/run/hive-agent//web.sock` shape — no triangulation. + HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock"; + } // lib.optionalAttrs isManager { - # Standalone-eval fallbacks for `nixosConfigurations.manager`. - # meta.rs overrides both via the per-agent generated - # `applied/hm1nd/flake.nix` (see `lifecycle::setup_applied`); - # the values here keep the container sensible if anyone - # evaluates the standalone config. - # - # `HIVE_PORT` = FNV-1a("hm1nd") % 900 + 8100 = 8875 per - # `lifecycle::agent_web_port` (#753 dropped the - # pre-#753 "manager pinned at 8000" special case). Hardcoded - # here because the standalone-eval path doesn't go through - # `meta::render_flake`; real deploys pick up the rust-computed - # value via meta and never touch this fallback. + # Standalone-eval fallbacks; meta.rs overrides at deploy time. + # HIVE_PORT = FNV-1a("hm1nd") % 900 + 8100. HIVE_PORT = "8875"; HIVE_LABEL = "hm1nd"; }; @@ -1323,20 +1378,11 @@ in ExecStart = "${pkgs.hyperhive}/bin/${binary} serve"; Restart = "on-failure"; RestartSec = 2; - # `/run/hive-config/` is a per-service runtime dir owned by - # the agent user (`User=` below), auto-cleared by systemd on - # stop. The harness writes its regenerated - # claude-{mcp-config,settings,system-prompt} files there - # (see `paths::config_dir`). Kept separate from `/run/hive` - # — that bind comes in root-owned from the host and holds - # hive-c0re's `mcp.sock` we only connect to (#658 fixup). + # Per-service runtime dir owned by `User=` below; the harness + # writes its regenerated claude-{mcp-config,settings,system-prompt} + # files here (`paths::config_dir`). Separate from /run/hive, + # which holds hive-c0re's mcp.sock. RuntimeDirectory = "hive-config"; - # Run the harness as the per-agent user (#658). claude itself - # spawned by the harness then runs as that user too — drops - # root inside the container while sudo (`NOPASSWD: ALL` by - # default, see `hyperhive.user.passwordlessSudo`) keeps the - # previous root-by-default surface available explicitly for - # tools that need it. User = userName; Group = userName; }; diff --git a/nix/templates/weston-vnc.nix b/nix/templates/weston-vnc.nix index 07739242..1efcd962 100644 --- a/nix/templates/weston-vnc.nix +++ b/nix/templates/weston-vnc.nix @@ -5,66 +5,41 @@ ... }: { - # Optional Weston (the reference Wayland compositor) with the VNC - # backend, surfaced as a per-agent hyperhive option. An agent turns - # it on from its own `agent.nix`: + # Optional Weston (Wayland compositor) with the VNC backend, + # surfaced as a per-agent `hyperhive.gui.enable` option. Imported + # from harness-base.nix so every sub-agent + the manager sees the + # option; only those that flip it on get the service. # - # hyperhive.gui.enable = true; - # - # Imported by `harness-base.nix`, so every sub-agent + the manager - # has the option available; only those that flip it on get the - # service. This is a flat per-agent option (evaluated inside that - # agent's own container build) — NOT a `hyperhive.agents..*` - # registry, which can't work: each agent is its own - # nixosConfiguration and has no cross-agent view. - # - # VNC port selection: a deterministic FNV-1a hash of the agent name - # (derived from the container hostname at runtime) maps into the - # range [15900, 16799], mirroring lifecycle::agent_web_port. The - # computed port is written to `/etc/hyperhive/gui.json` at service - # start; the harness (issue #51) reads that file to know where to - # relay WebSocket connections. - # - # Note: weston's VNC backend does not expose a CLI bind-address flag - # (unlike the RDP backend's `--address`), so VNC listens on all - # interfaces. The harness WebSocket relay (issue #51) connects only - # via 127.0.0.1, and the host firewall should block external access - # to the VNC port range. A future weston.ini `[vnc] address=` can - # restrict this once upstream supports it. + # Port allocation, weston bind-address quirk, PAM service name, the + # Type=simple choice, idle-time=0: all in + # docs/gotchas.md::Weston VNC compositor. + # Harness-side WebSocket relay shape: docs/web-ui.md::Per-agent + # endpoints (`/screen` + `/screen/ws`). options.hyperhive.gui.enable = lib.mkOption { type = lib.types.bool; default = false; description = '' Run Weston with the VNC backend as a systemd service, for - in-browser GUI access via the harness WebSocket relay (see - issue #51). Renders in software (pixman) — no GPU, DRM, - or VT access, so no extra container capabilities are needed. + in-browser GUI access via the harness `/screen/ws` WebSocket + relay. Renders in software (pixman) — no GPU, DRM, or VT + access, so no extra container capabilities are needed. - The VNC port is deterministic: FNV-1a hash of the agent name - (taken from the container hostname) mapped into [15900, 16799]. - The port and auth mode are written to `/etc/hyperhive/gui.json` - at service start so the harness can relay connections. - - The unit is deliberately built so enabling it can NEVER abort - the agent's `nixos-container update`: `Type = "simple"` (so - `switch-to-configuration` doesn't block on weston readiness) - and the ExecStart script always tries to exec weston after - setup — a misconfigured weston degrades to a restart loop - visible in `journalctl`, it does not block the rebuild. (Same - reasoning as the `tea-login` unit in `harness-base.nix`.) + The VNC port is a deterministic FNV-1a hash of the agent name + mapped into `[15900, 16799]`, written to + `/etc/hyperhive/gui.json` at service start so the harness can + relay connections without a separate config flag. The unit is + `Type = "simple"` so a misconfigured weston degrades to a + restart loop instead of blocking `nixos-container update`. ''; }; config = lib.mkIf config.hyperhive.gui.enable { - # neatvnc 0.9 always calls the PAM auth callback (weston_authenticate_user) - # for Apple-DH (type 30), regardless of weston.ini auth-method=none. - # pam_permit.so makes the PAM service accept any credentials so the - # browser's empty Apple-DH credentials always pass. - # - # The service name is "weston-remote-access" — that is the literal string - # passed to pam_start() inside libweston (libweston/auth.c). Using "weston" - # instead silently falls back to the system default and rejects auth. + # neatvnc ≥ 0.9 always calls the PAM auth callback for Apple-DH + # (type 30), regardless of weston.ini auth-method=none. + # pam_permit.so accepts the browser's empty Apple-DH credentials. + # Service name MUST be the literal `weston-remote-access` — that's + # the string libweston passes to pam_start() in libweston/auth.c. security.pam.services."weston-remote-access".text = '' auth sufficient pam_permit.so account sufficient pam_permit.so @@ -76,29 +51,21 @@ after = [ "network.target" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { - # `simple`, not `notify`: switch-to-configuration must not - # wait on weston signalling readiness (same reasoning as the - # `tea-login` unit in harness-base.nix). Type = "simple"; - # Creates /var/lib/weston (0700 root) at start. StateDirectory = "weston"; Environment = "XDG_RUNTIME_DIR=/run/user/0"; # Wrapper script: computes the deterministic VNC port, writes - # /etc/hyperhive/gui.json for the harness (issue #51), then - # execs weston. Using `exec` keeps the PID stable so systemd - # tracks the weston process correctly under Type=simple. - # Any failure before the exec triggers Restart=on-failure - # (graceful degradation) rather than blocking the rebuild. + # /etc/hyperhive/gui.json for the harness, then execs weston. + # `exec` keeps the PID stable so systemd tracks the weston + # process correctly under Type=simple. ExecStart = pkgs.writeShellScript "weston-vnc" '' mkdir -p /run/user/0 && chmod 700 /run/user/0 || true # --- Compute deterministic VNC port via FNV-1a --- - # Agent name = container hostname with leading "h-" stripped, - # mirroring lifecycle::agent_web_port in hive-c0re/src/lifecycle.rs. + # Agent name = container hostname with leading `h-` stripped. # Read from /etc/hostname (always present in NixOS containers) - # to avoid a dependency on the `hostname` binary (which lives in - # pkgs.inetutils, not pkgs.coreutils). - # VNC_PORT_BASE=15900, VNC_PORT_RANGE=900 → [15900, 16799]. + # to avoid depending on `hostname` (lives in pkgs.inetutils, + # not pkgs.coreutils). RAW_HOST=$(${pkgs.coreutils}/bin/cat /etc/hostname) AGENT_NAME=$(${pkgs.coreutils}/bin/printf '%s' "$RAW_HOST" \ | ${pkgs.gnused}/bin/sed 's/^h-//') @@ -111,30 +78,15 @@ done VNC_PORT=$((15900 + hash % 900)) - # --- Write gui.json marker --- - # The harness reads this at startup (issue #51) to know the - # VNC port and auth mode for the WebSocket relay. + # Marker file the harness reads at startup. ${pkgs.coreutils}/bin/mkdir -p /etc/hyperhive ${pkgs.coreutils}/bin/printf '{"vnc_port":%d,"auth":"none"}\n' \ "$VNC_PORT" > /etc/hyperhive/gui.json || true - # neatvnc ≥ 0.9 advertises RSA-AES and Apple-DH security types - # when auth is compiled in. The browser client handles Apple-DH - # (type 30) with empty credentials. - # - # weston.ini [vnc] auth-method=none: weston uses an always-accept - # auth callback instead of PAM. Without this, weston defaults to - # PAM authentication which rejects empty credentials (SecurityResult=1). - # - # --disable-transport-layer-security prevents the VeNCrypt TLS - # wrapper; plain auth types (incl. type 30) are advertised directly. - # [core] idle-time=0 disables weston's idle timeout (default - # 300s). Without it the VNC desktop fades to black after 5 min - # idle and desktop-shell shows its click-to-unlock lock screen - # — useless for an agent desktop viewed over /screen (issue - # #180). idle-time=0 → the idle timer is updated with a 0ms - # delay, which wl_event_source_timer_update treats as "disarm", - # so the compositor never goes idle and never locks. + # --disable-transport-layer-security: skips the VeNCrypt TLS + # wrapper so plain auth types (incl. Apple-DH type 30) are + # advertised directly. [core] idle-time=0 disables the + # compositor's 300s idle/lock screen. WESTON_INI=$(${pkgs.coreutils}/bin/mktemp /tmp/weston-XXXXXX.ini) ${pkgs.coreutils}/bin/printf '[core]\nidle-time=0\n\n[vnc]\nauth-method=none\n' > "$WESTON_INI" @@ -150,8 +102,8 @@ }; }; - # weston on the agent's interactive PATH too, so claude can run - # Wayland clients / `weston-info` against the compositor. + # weston on the agent's interactive PATH so claude can run Wayland + # clients / weston-info against the compositor. environment.systemPackages = [ pkgs.weston ]; }; }