diff --git a/CLAUDE.md b/CLAUDE.md index ed4e52ab..85486055 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -295,10 +295,6 @@ 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 @@ -320,11 +316,6 @@ 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 e7f16b9a..c914499c 100644 --- a/docs/agent-hierarchy.md +++ b/docs/agent-hierarchy.md @@ -1,7 +1,7 @@ # Agent hierarchy & privileges -Design + audit doc for the agent-privileges + tree-shape milestone -(the [issue tree](http://localhost:3000/hyperhive/hyperhive/issues/361)). +Design + audit doc for milestone #6 (the +[issue](http://localhost:3000/hyperhive/hyperhive/issues/361) tree). 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: +operator-driven (#486 / #487): - 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`). -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). +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). 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,12 +46,13 @@ 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:** the move is purely a JSON edit. Only the -top-level manager (`hm1nd`) gets `/var/lib/hyperhive/agents` +**Today's caveat (#361 follow-up):** 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 cap enforcement, `set_parent` grows a companion -umount-old / mount-new / restart-cascade step. +land alongside #361 enforcement, `set_parent` grows a companion +umount-old / mount-new / restart-cascade step (tracked via the +cross-ref comment on #361). ### Why meta, not per-agent `agent.nix` @@ -77,7 +78,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. + The dashboard renders the field as a tree (#363 follow-up). ## Target topology semantics @@ -149,7 +150,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 +### D — drop legacy `/state` for manager ✓ done (#604) `lifecycle.rs` no longer binds `/state` for the manager. `HYPERHIVE_STATE_DIR` is now injected uniformly via @@ -164,8 +165,8 @@ read. - `prompts/system.md` with `` / `` marker blocks, assembled by `hive_ag3nt::prompt::render` based on - flavor. **Per-agent cap list** of what the agent can do — already - a single parametrised prompt; once per-agent cap groups land the + flavor (closes #519). **Per-agent cap list** of what the agent can + do — already a single parametrised prompt; once #513 lands 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 @@ -181,7 +182,8 @@ 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. + moderate threads of their descendants. (per mara's + https://localhost:3000/hyperhive/hyperhive/issues/361#issuecomment-3344) - `reminder_scheduler.rs`: same override pattern for reminder cancel. **Topology** — descendants only. - `actions.rs`: `destroy` refuses to act on `MANAGER_NAME` (no @@ -194,9 +196,9 @@ read. ### G — sub-agents inside the same container -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: +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: - Inherit caps from parent, or take an explicit narrower set? - Survive container restart, or always ephemeral? @@ -204,102 +206,9 @@ 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: ["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) +- 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) - Operator/agent trust boundary (orthogonal axis): [`boundary.md`](boundary.md) diff --git a/docs/approvals.md b/docs/approvals.md index 0f92ccfa..34645bb1 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. If either check fails, no + runs two checks (closes #317). 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. +withdraw an approval that hasn't been acted on yet (closes #250). 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` +### Dispatch via `rebuild_queue` (#441) 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). + FNV-1a hash as every other agent (8100..8999 range) since #753. - `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). Manager can `start` it again or escalate. + container shows briefly as "stopped without transient" before the + next start, #425). 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 c92bcb56..16c2bbb5 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; dashboard + (8100..8999) for every agent including the manager (#753); 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`. Rewritten at - send time by `topology::resolve_recipient`: looks up +- `` — the sender's parent per `topology.json` (`#692`). + 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 - propagates with zero agent-side restart. + (`#486`) 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,43 +54,6 @@ 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 2dd53ef6..698d681c 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -76,92 +76,3 @@ 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 b061d6dd..5cf01a20 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -87,7 +87,8 @@ 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). Sharing one dir across agents is NOT viable — +`/home//.claude` (RW; was `/root/.claude` pre-#658 when every +harness ran as root). 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). @@ -95,8 +96,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). -The harness exposes the same path +`/agents//state` (RW; uniform for sub-agents + manager +post-#604, was `/state` pre-#604). 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`). @@ -111,8 +112,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; dashboard -at `cfg.dashboardPort` (default 7000). +manager — hashes into 8100..8999 via the same FNV-1a since #753; +dashboard at `cfg.dashboardPort` (default 7000). ## Restart races on TCP bind @@ -145,12 +146,13 @@ 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. Extension allowlists (tried -as an alternative) have the same maintenance problem: any new file -extension the SPA ships breaks silently. +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. -The pattern that works (`hive-gateway.nix`) keys the fallback on the -HTTP `Accept` header: +The correct pattern (landed in PR #729, `hive-gateway.nix`) keys the +fallback on the HTTP `Accept` header: ```nginx # Outside the server block (appendHttpConfig): @@ -186,22 +188,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 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`. +`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`. ## `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). -Use it instead of ad-hoc curl pipelines: +`harness-base.nix`; lives in `/hive-forge` as a proper Rust binary +since #280). 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) +hive-forge --json comments 42 # same as above, JSON array (global flag, closes #421) 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, @@ -318,11 +268,8 @@ 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. `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. + # enough. See `docs/gateway.md` for the vhost map + # + tuning rationale. lib.mapAttrs' (name: port: { name = "/agent/${name}/"; value = { @@ -332,44 +279,9 @@ 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 f0480736..d4964534 100644 --- a/nix/modules/hive-matrix.nix +++ b/nix/modules/hive-matrix.nix @@ -9,16 +9,26 @@ let hyperhiveDomain = config.services.hyperhive.domain; effectiveServerName = if cfg.serverName != null then cfg.serverName else hyperhiveDomain; - # 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. + # 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. + # `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; [ @@ -28,18 +38,21 @@ let jq ]; - # cmake runs inside js/Makefile via `emcmake cmake`; the default - # configurePhase would invoke cmake at the package root (no - # CMakeLists) and fail. + # 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). dontConfigure = true; buildPhase = '' runHook preBuild - # emscripten on-demand sysroot build needs writable HOME + cache. + # emscripten needs HOME + a writable cache dir for its sysroot + # build (libc, libc++, etc. compiled to wasm on demand). export HOME=$TMPDIR export EM_CACHE=$TMPDIR/.emscriptencache mkdir -p $EM_CACHE - # `make -C js` keeps pwd at source root for the installPhase. + # `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 Imaging.js Imaging.wasm runHook postBuild ''; @@ -53,37 +66,71 @@ let ''; meta = with pkgs.lib; { - description = "Imaging.js + Imaging.wasm built from the native_imaging dart package for fluffychat-web"; + description = "Imaging.js + Imaging.wasm built from the native_imaging dart package for fluffychat-web (#685)"; 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 - # cost) to compile the web-worker entry point. + # `dart` from the flutter341 closure (already pulled, no + # incremental closure cost) so we can compile the web-worker + # entry point that buildFlutterApplication skips. nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.flutter341.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 + 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 - 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-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. + # 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. options.services.hyperhive.matrix = { enable = lib.mkOption { @@ -275,10 +322,12 @@ in }; config = lib.mkIf cfg.enable { - # 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. + # 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). assertions = [ { assertion = hyperhiveDomain != null || cfg.serverName != null; @@ -295,6 +344,11 @@ 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 @@ -307,10 +361,22 @@ in } ]; - # 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. + # 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). system.activationScripts.hive-matrix-register-token = lib.stringAfter [ "var" ] '' tokenFile=${lib.escapeShellArg (toString cfg.registrationTokenFile)} if [ ! -s "$tokenFile" ]; then @@ -319,17 +385,25 @@ in echo >> "$tokenFile" echo "hive-matrix: generated registration token at $tokenFile" fi - # Re-apply 0600 (normalises any pre-LoadCredential carry-over). + # Always re-apply 0600 (idempotent on already-correct files; + # also normalises any 0640 / world-readable carry-over from + # pre-LoadCredential deployments). chmod 0600 "$tokenFile" ''; containers.hive-matrix = { autoStart = true; ephemeral = false; - # Shared host netns — agents reach tuwunel at localhost:. + # 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`. privateNetwork = false; - # Read-only bind of the host-managed registration token; tuwunel - # reads it via systemd LoadCredential below (not directly). + # 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). bindMounts.${cfg.registrationTokenFile} = { hostPath = cfg.registrationTokenFile; isReadOnly = true; @@ -343,28 +417,44 @@ in package = cfg.package; settings.global = { server_name = effectiveServerName; - # `address` + `port` are upstream `listOf` — wrap singles. + # `address` is `listOf nonEmptyStr` upstream (multi-bind + # support). Single-host bind goes through as a one-element list. 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; empty - # trustedServers keeps it effectively closed. + # 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. allow_federation = true; trusted_servers = cfg.trustedServers; - # Token-gated registration. The absent + # 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 # `yes_i_am_very_very_sure_…_open_registration_…` flag # keeps the server closed to anyone without the token. allow_registration = true; - # LoadCredential below copies the host file into a - # 0400 dynamic-user-owned path; tuwunel reads from there. + # 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). registration_token_file = "/run/credentials/tuwunel.service/registration_token"; - # E2EE disabled in initial rollout (#548); re-enable at #551. + # E2EE disabled in initial rollout per operator call + # (#548) — re-enabling tracked at #551. allow_encryption = false; }; }; - # Keeps DynamicUser=true + PrivateUsers=true intact — no - # host-side chown :tuwunel / GID-pin gymnastics needed (#644 / - # iris on 8043). See `man systemd.exec` → LoadCredential. + # `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. 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 41528e26..0c433b59 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -75,40 +75,6 @@ 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" @@ -571,64 +537,6 @@ 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; @@ -748,20 +656,46 @@ in } ]; - # 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. + # 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. 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" @@ -769,10 +703,25 @@ 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 @@ -804,55 +753,6 @@ 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; @@ -914,28 +814,8 @@ 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. @@ -949,11 +829,15 @@ in "flakes" ]; - # `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. + # 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. nix.settings.sandbox-fallback = lib.mkForce true; # `claude-code` is unfree. Each per-agent container's nixosConfiguration @@ -1121,12 +1005,14 @@ in ''; }; - # 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. + # 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). systemd.services.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable { description = "long-running matrix-sdk Client + MCP daemon socket"; wantedBy = [ "multi-user.target" ]; @@ -1134,6 +1020,12 @@ 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"; }; @@ -1141,17 +1033,26 @@ 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"; }; }; - # 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). + # 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). systemd.paths.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable { description = "trigger hive-matrix-daemon when matrix-token appears"; wantedBy = [ "multi-user.target" ]; @@ -1316,10 +1217,12 @@ in }; }; - # 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. + # 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. hyperhive.forge = lib.mkIf (config.hyperhive.role == "manager") { keepSubscriptions = lib.mkDefault false; skipNotifyReasons = lib.mkDefault [ @@ -1328,49 +1231,91 @@ in ]; }; - # 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. + # 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. 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" ]; - # `/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. + # 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. 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; meta.rs overrides at deploy time. - # HIVE_PORT = FNV-1a("hm1nd") % 900 + 8100. + # 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. HIVE_PORT = "8875"; HIVE_LABEL = "hm1nd"; }; @@ -1378,11 +1323,20 @@ in ExecStart = "${pkgs.hyperhive}/bin/${binary} serve"; Restart = "on-failure"; RestartSec = 2; - # 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. + # `/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). 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 1efcd962..07739242 100644 --- a/nix/templates/weston-vnc.nix +++ b/nix/templates/weston-vnc.nix @@ -5,41 +5,66 @@ ... }: { - # 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. + # 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`: # - # 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`). + # 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. 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 `/screen/ws` WebSocket - relay. Renders in software (pixman) — no GPU, DRM, or VT - access, so no extra container capabilities are needed. + 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. - 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`. + 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`.) ''; }; config = lib.mkIf config.hyperhive.gui.enable { - # 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. + # 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. security.pam.services."weston-remote-access".text = '' auth sufficient pam_permit.so account sufficient pam_permit.so @@ -51,21 +76,29 @@ 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, then execs weston. - # `exec` keeps the PID stable so systemd tracks the weston - # process correctly under Type=simple. + # /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. 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. + # Agent name = container hostname with leading "h-" stripped, + # mirroring lifecycle::agent_web_port in hive-c0re/src/lifecycle.rs. # Read from /etc/hostname (always present in NixOS containers) - # to avoid depending on `hostname` (lives in pkgs.inetutils, - # not pkgs.coreutils). + # 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]. RAW_HOST=$(${pkgs.coreutils}/bin/cat /etc/hostname) AGENT_NAME=$(${pkgs.coreutils}/bin/printf '%s' "$RAW_HOST" \ | ${pkgs.gnused}/bin/sed 's/^h-//') @@ -78,15 +111,30 @@ done VNC_PORT=$((15900 + hash % 900)) - # Marker file the harness reads at startup. + # --- Write gui.json marker --- + # The harness reads this at startup (issue #51) to know the + # VNC port and auth mode for the WebSocket relay. ${pkgs.coreutils}/bin/mkdir -p /etc/hyperhive ${pkgs.coreutils}/bin/printf '{"vnc_port":%d,"auth":"none"}\n' \ "$VNC_PORT" > /etc/hyperhive/gui.json || true - # --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. + # 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. 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" @@ -102,8 +150,8 @@ }; }; - # weston on the agent's interactive PATH so claude can run Wayland - # clients / weston-info against the compositor. + # weston on the agent's interactive PATH too, so claude can run + # Wayland clients / `weston-info` against the compositor. environment.systemPackages = [ pkgs.weston ]; }; }