+<%= render "status/daemon", daemon: @daemon || {} %>
+
<%# data-turbo-permanent: a morph must never touch the composer — it holds
typed-but-unsent idea text and staged image attachments (Stimulus state
the server can't re-render). %>
diff --git a/web/config/initializers/hive.rb b/web/config/initializers/hive.rb
index f2792b55..12b51250 100644
--- a/web/config/initializers/hive.rb
+++ b/web/config/initializers/hive.rb
@@ -3,6 +3,7 @@
require "hive"
require "hive/media_manifest"
require "hive/web/github_auth"
+require "hive/web/loopback"
require "hive/web/status_feed"
require "hive/web/dispatcher"
require "hive/web/agents_auth"
diff --git a/web/config/routes.rb b/web/config/routes.rb
index 659576df..230524d0 100644
--- a/web/config/routes.rb
+++ b/web/config/routes.rb
@@ -17,6 +17,8 @@ Rails.application.routes.draw do
root "status#index"
+ post "daemon/repair" => "daemon#repair", as: :daemon_repair
+
post "ideas" => "ideas#create", as: :ideas
# Task pages are addressed by project name + task slug, mirroring the CLI.
diff --git a/web/test/e2e/golden_path_e2e.rb b/web/test/e2e/golden_path_e2e.rb
index 5d6c8302..0bc5bfe9 100644
--- a/web/test/e2e/golden_path_e2e.rb
+++ b/web/test/e2e/golden_path_e2e.rb
@@ -1,5 +1,7 @@
require "application_system_test_case"
require "open3"
+require "hive/tui/bubble_model"
+require "hive/tui/state_source"
# The hivebox golden path, end to end, in a real browser — deliberately NOT
# named *_test.rb so the default suites skip it; run it explicitly:
@@ -36,6 +38,8 @@ class GoldenPathE2E < ApplicationSystemTestCase
end
setup do
+ @original_path = ENV["PATH"]
+ ENV["PATH"] = [ File.join(REPO_ROOT, "bin"), @original_path ].compact.join(File::PATH_SEPARATOR)
configure_owner!(owner: "") # claimable box
speed_up_daemon!
@project = create_hive_project!("golden-app")
@@ -52,6 +56,7 @@ class GoldenPathE2E < ApplicationSystemTestCase
end
StatusBroadcaster.stop!
SessionsController.http_client = Net::HTTP
+ ENV["PATH"] = @original_path
# The sandbox vanishes with the process — on failure, keep the daemon's
# own event log and the task tree where a human can read them.
unless passed?
@@ -149,8 +154,66 @@ class GoldenPathE2E < ApplicationSystemTestCase
"the fake agent's commit must be a real commit in a real worktree"
end
+ test "TUI and web ideas share one state tree and both auto-dispatch" do
+ sign_in!(login: "goldenpath")
+
+ tui_title = "TUI parity idea"
+ submit_tui_idea!(tui_title)
+ visit "/"
+ assert_selector ".task-row", text: tui_title, wait: 10,
+ message: "an idea submitted through the real TUI model must appear in Rails"
+ tui_slug = task_slug_from_grid!(tui_title)
+ wait_for_daemon_dispatch!(tui_slug)
+
+ web_title = "Web parity idea"
+ fill_in "New idea", with: web_title
+ find(".composer select[name='project']").find("option[value='#{@project}']").select_option
+ click_button "Add idea"
+ assert_selector ".task-row", text: web_title, wait: 10
+ web_slug = task_slug_from_grid!(web_title)
+
+ snapshot = Hive::Tui::StateSource.new.refresh_now
+ assert snapshot.rows.any? { |row| row.slug == web_slug && row.display_name == web_title },
+ "an idea submitted through Rails must appear in the TUI's real StateSource snapshot"
+ wait_for_daemon_dispatch!(web_slug)
+ end
+
private
+ def submit_tui_idea!(title)
+ snapshot = Hive::Tui::StateSource.new.refresh_now
+ bubble = Hive::Tui::BubbleModel.new(
+ hive_model: Hive::Tui::Model.initial.with(
+ mode: :new_idea,
+ snapshot: snapshot,
+ scope: 0,
+ new_idea_project_name: @project,
+ new_idea_buffer: title,
+ new_idea_cursor: title.length
+ )
+ )
+
+ capture_io { bubble.update(Hive::Tui::Messages::NEW_IDEA_SUBMITTED) }
+ assert_equal :grid, bubble.hive_model.mode
+ assert_match(/\A\+ /, bubble.hive_model.flash.to_s,
+ "the TUI must report a successful real `hive new` subprocess")
+ end
+
+ def wait_for_daemon_dispatch!(slug, timeout: 30)
+ events = File.join(ENV["HIVE_HOME"], "logs", "daemon.log")
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
+ loop do
+ log = File.exist?(events) ? File.read(events) : ""
+ return if log.lines.any? { |line| line.include?(slug) && line.include?('"event":"dispatched"') }
+
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
+ raise "daemon never auto-dispatched #{slug}"
+ end
+
+ sleep 0.1
+ end
+ end
+
# The status grid is Turbo-replaced while the daemon advances tasks. Read the
# slug from a single current-DOM query instead of retaining a Capybara element.
def task_slug_from_grid!(title, timeout: 10)
diff --git a/web/test/integration/local_loopback_auth_test.rb b/web/test/integration/local_loopback_auth_test.rb
new file mode 100644
index 00000000..6f84bb7c
--- /dev/null
+++ b/web/test/integration/local_loopback_auth_test.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class LocalLoopbackAuthTest < ActionDispatch::IntegrationTest
+ setup do
+ @previous_mode = ENV[Hive::Web::Loopback::ENV_MODE]
+ end
+
+ teardown do
+ if @previous_mode.nil?
+ ENV.delete(Hive::Web::Loopback::ENV_MODE)
+ else
+ ENV[Hive::Web::Loopback::ENV_MODE] = @previous_mode
+ end
+ end
+
+ test "loopback peer with mode enabled reaches protected page without session" do
+ ENV[Hive::Web::Loopback::ENV_MODE] = "1"
+ # Default remote_ip in integration tests is 127.0.0.1
+ get root_path
+ assert_response :success
+ assert_nil session[:github_login]
+ end
+
+ test "local_loopback false restores login redirect even when mode env is set" do
+ ENV[Hive::Web::Loopback::ENV_MODE] = "1"
+ path = Hive::Config.global_config_path
+ FileUtils.mkdir_p(File.dirname(path))
+ existing = File.exist?(path) ? YAML.safe_load(File.read(path)) || {} : {}
+ existing = {} unless existing.is_a?(Hash)
+ existing["web"] = (existing["web"] || {}).merge("local_loopback" => false)
+ File.write(path, existing.to_yaml)
+
+ get root_path
+ assert_redirected_to login_path
+ ensure
+ # Leave config as tests usually use isolated HIVE_HOME; best-effort restore.
+ if defined?(path) && File.exist?(path)
+ data = YAML.safe_load(File.read(path)) || {}
+ if data.is_a?(Hash) && data["web"].is_a?(Hash)
+ data["web"].delete("local_loopback")
+ File.write(path, data.to_yaml)
+ end
+ end
+ end
+
+ test "non-loopback peer never bypasses login even with mode env" do
+ ENV[Hive::Web::Loopback::ENV_MODE] = "1"
+ get root_path, headers: { "REMOTE_ADDR" => "8.8.8.8" }
+ assert_redirected_to login_path
+ end
+
+ test "non-loopback Host never bypasses login for a loopback peer" do
+ ENV[Hive::Web::Loopback::ENV_MODE] = "1"
+ get root_path, headers: { "HOST" => "attacker.example" }
+ assert_redirected_to login_path
+ end
+
+ test "loopback Host with an explicit port keeps the local bypass" do
+ ENV[Hive::Web::Loopback::ENV_MODE] = "1"
+ get root_path, headers: { "HOST" => "127.0.0.1:4567" }
+ assert_response :success
+ end
+
+ test "mode disabled requires login on loopback" do
+ ENV.delete(Hive::Web::Loopback::ENV_MODE)
+ get root_path
+ assert_redirected_to login_path
+ end
+end
diff --git a/wiki/commands.md b/wiki/commands.md
index e69836e5..9f7f8f36 100644
--- a/wiki/commands.md
+++ b/wiki/commands.md
@@ -3,19 +3,19 @@ title: Interaction Surface
type: commands
source: bin/hive, bin/hv, bin/hive-e2e, lib/hive/cli.rb, lib/hive/commands/connect.rb, lib/hive/commands/disconnect.rb, lib/hive/commands/bench_submit.rb, lib/hive/commands/digest.rb, lib/hive/digest.rb, lib/hive/digest/, lib/hive/web/, public/, hive.gemspec, packaging/docker/, .github/workflows/release.yml, openclaw/skills/hive/SKILL.md, openclaw/README.md
created: 2026-05-14
-updated: 2026-06-22
+updated: 2026-07-15
tags: [commands, api]
---
**TLDR**: Hive's external interaction surface is the Thor CLI (`hive` plus the
-`hv` fallback launcher), the opt-in e2e harness, the hivebox web command/routes
-documented in [[commands/web]], `hive connect screenote` as the Screenote OAuth
-setup surface for artifacts MCP uploads, `hive bench submit` as the hive-bench
-corpus producer, `hive digest` as the daily shipped digest producer, and the
-single ClawHub `hive-cli` OpenClaw skill whose installed slash command is `/hive`.
-The Ruby command/API contract lives in [[cli]] and the
-per-command pages. OpenClaw does not add a second runtime and does not publish
-one ClawHub listing per Hive verb.
+`hv` fallback launcher), the opt-in e2e harness, local `hive setup` / `hive web`
+(and hivebox Docker) documented in [[commands/setup]] and [[commands/web]],
+`hive connect screenote` as the Screenote OAuth setup surface for artifacts MCP
+uploads, `hive bench submit` as the hive-bench corpus producer, `hive digest` as
+the daily shipped digest producer, and the single ClawHub `hive-cli` OpenClaw
+skill whose installed slash command is `/hive`. The Ruby command/API contract
+lives in [[cli]] and the per-command pages. OpenClaw does not add a second
+runtime and does not publish one ClawHub listing per Hive verb.
## Source Files
diff --git a/wiki/commands/daemon.md b/wiki/commands/daemon.md
index c84cb08e..1019d6b2 100644
--- a/wiki/commands/daemon.md
+++ b/wiki/commands/daemon.md
@@ -3,7 +3,7 @@ title: hive daemon
type: command
source: lib/hive/commands/daemon.rb, lib/hive/daemon/*
created: 2026-05-06
-updated: 2026-06-18
+updated: 2026-07-15
tags: [command, daemon, automation, json]
---
@@ -40,13 +40,13 @@ hive daemon queue [list | show
| prune] [--json]
|-----------|----------|
| `start` | Acquires the PID file (`~/Dev/hive/.daemon.pid`); without `--detach` runs in the foreground. With `--detach` calls `Process.daemon(true, true)` and the parent returns immediately. With `--dry-run` logs every dispatch decision but does NOT spawn child `hive ...` processes. Refuses with exit `75 (TEMPFAIL)` if a live daemon already holds the PID file. |
| `stop` | Sends `SIGTERM` to the running daemon's PID. Waits up to `daemon.shutdown_grace_sec` (default 600s) for the daemon to exit, then escalates to `SIGKILL`. Idempotent: `stop` with no PID file exits 0 with `daemon not running` on stderr; a stale PID file (process gone) is removed and the call exits 0. With `--json`, emits a `hive-daemon-stop` envelope (fields: `running`, `was_running`, `stale_pid?`, `reason?` — `pid_reused` / `unverified` for safety bailouts). |
-| `status` | Reports running / not running. Exit code 0 if running, 1 if not. With `--json`, emits a `hive-daemon-status` envelope with `running`, `pid`, `uptime_sec`, `pid_file`, `log_file`, plus the autostart-service state `service_installed`, `service_enabled`, and `unit_path` (read-only probe) so an agent can tell whether `hive daemon install` has run without a mutating call. |
+| `status` | Reports running / not running. Exit code 0 if running, 1 if not. With `--json`, emits a `hive-daemon-status` envelope with `running`, `pid`, `uptime_sec`, `pid_file`, `log_file`, autostart-service state (`service_installed`, `service_enabled`, `unit_path`), and **binary drift** fields (`expected_binary`, `installed_binary`, `installed_version`, `binary_drift` enum: `none` / `path` / `version` / `unparseable` / `unreadable` / `not_applicable`). The shared `StatusReport` (also used by Rails) verifies pid ownership/start time rather than trusting `kill(0)`, and its version child uses the process-group-bounded probe; timeout, spawn/non-zero, malformed, and non-executable results are actionable `unreadable`, never `none`. Text mode prints a repair hint (`hive daemon install --force`) when drift is actionable. |
| `reload` | Sends `SIGHUP` to the running daemon's PID, which triggers config reload at the next tick boundary. In-flight children continue uninterrupted. Exit 1 if no daemon running. With `--json`, emits a `hive-daemon-reload` envelope (`ok`, `reason`, `pid`, `message`). |
| `tail` | `tail -F` semantics on `~/Dev/hive/logs/daemon.log` (self-implemented; doesn't shell out to the `tail` binary). Exit 1 if the log file doesn't exist. |
| `install` | (Re)writes the platform-native unit file (`~/.config/systemd/user/hive-daemon.service` on Linux, `~/Library/LaunchAgents/local.hive-daemon.plist` on macOS) and starts/enables the service. Installers and agent-assisted setup run this by default so daemon autostart is global install-time infrastructure, independent of any project. Without `--force`, refuses to overwrite a pre-existing unit (preserving operator hand-edits); exit `64` (USAGE) with a message pointing at `--force` so automation can branch without clobbering local changes. With `--force`, saves the previous content to a timestamped `.bak-YYYYMMDDTHHMMSSZ` (rotated, never overwritten) via atomic write, then — only when an existing unit was actually overwritten (the `upgraded` outcome) — restarts the running daemon on Linux / unloads-then-loads on macOS so new `Environment=` lines take effect (a first-time `--force` install with no prior unit just starts/enables, no restart). A service-manager failure (systemctl reload/enable, or launchctl load rejecting the unit) exits `70` (SOFTWARE). A host with no systemd-user manager at all is different: the unit is still written, but autostart cannot be enabled, so it exits `0` with the `unsupported` outcome (and `target_path` set to the written unit) — a known-platform limitation, not a failure. With `--json`, every outcome (success and error) emits a `hive-daemon-install.v1` envelope. Units point at the user-facing wrapper path when installers provide it, so bash/Homebrew installs preserve the GEM_HOME/GEM_PATH wrapper across login/reboot; `hv` invocations remain valid when Apache Hive shadows `hive`. Use this after upgrading hive when the unit template has changed or when autostart needs repair. |
| `enable` | Sets `daemon.enabled: true` in `/.hive-state/config.yml`. This enrolls a project for dispatch; it does not install, start, or autostart the global daemon service. Surgical line-level YAML editor (upsert) preserves comments, key order, and file-mode bits across enable/disable flips; rejects inline-flow `daemon: { ... }`, CRLF endings, and 4-space-indented children before any write. Atomic write goes via tempfile + `flock(LOCK_EX)` + `fsync` + rename; tempfile is ensure-cleaned on rename failure (ENOSPC / EACCES / EXDEV). Pre-flight (`preflight_targets`) validates every target before any write so `--all` cannot half-flip the registry on a bad middle project. Pass a registered project name OR `--all` (mutually exclusive — passing both raises USAGE 64). Exit 64 on missing/unknown target / not-initialised project / no registered projects. With `--json`, emits a `hive-daemon-enroll` envelope on success and an `EnrollErrorKind` JSON error envelope on failure (`missing_project` / `unknown_project` / `project_and_all` / `not_initialised` / `no_projects` / `config` / `internal`); YAML parse failures surface as `Hive::ConfigError` (exit 78). |
| `disable` | Same shape as `enable`, sets `daemon.enabled: false`. The next dispatcher tick honours the change automatically (per-tick enable-cache invalidation); `hive daemon reload` is optional for instant pickup. |
-| `queue` | Read-only inspection of the dispatch-request queue the bot/web producers and `3-plan` healer write and the daemon consumes. Runs in the CLI process (no daemon contact); reads the same `/dispatch_requests/` directory. Current pending request files use `hive-dispatch-request.v2`, whose `requestor` enum is `bot|healer`; older/wrong versions are reported as malformed and pruned like other bad files. `list` (default) prints each pending request with `request_id age project/slug verb` plus `[EXPIRED]` / `[NOT-ALLOWLISTED]` flags and any malformed files. `show ` dumps one request's full payload (errors with exit 1 if the id is unknown; missing id is a USAGE error). `prune` removes expired + malformed request files (the daemon also does this lazily on its own tick) and reports the count. With `--json`, emits a `hive-daemon-queue.v1` envelope (`action`, `requests[]`, `request`, `malformed[]`, `pruned_count`). Unknown actions, missing `show` request ids, and unexpected queue-command exceptions emit the schema's `ErrorPayload` arm with `ok:false`, `error_kind` (`unknown_action` / `missing_request_id` / `internal`), and `message` before exiting non-zero. Claimed in-flight requests (`*.json.claimed`) are intentionally not listed — they are daemon-managed; see [[modules/daemon]] §"At-most-once dispatch via atomic claim". |
+| `queue` | Read-only inspection of the dispatch-request queue the bot/web producers and `3-plan` healer write and the daemon consumes. Runs in the CLI process (no daemon contact); reads the same `/dispatch_requests/` directory. Current pending request files use `hive-dispatch-request.v2`, whose `requestor` enum is `bot|healer`; web repair uses the schema-valid `bot` value. Older/wrong versions are reported as malformed and pruned like other bad files. `list` (default) prints each pending request with `request_id age project/slug verb` plus `[EXPIRED]` / `[NOT-ALLOWLISTED]` flags and any malformed files. The host-maintenance argv is valid only with the exact `__hive_host__/host-maintenance` sentinel tuple; it cannot ride an enabled real project. At spawn, that repair alone resolves the current installer binary instead of substituting the running daemon's potentially stale `HIVE_BIN`. `show ` dumps one request's full payload (errors with exit 1 if the id is unknown; missing id is a USAGE error). `prune` removes expired + malformed request files (the daemon also does this lazily on its own tick) and reports the count. With `--json`, emits a `hive-daemon-queue.v1` envelope (`action`, `requests[]`, `request`, `malformed[]`, `pruned_count`). Unknown actions, missing `show` request ids, and unexpected queue-command exceptions emit the schema's `ErrorPayload` arm with `ok:false`, `error_kind` (`unknown_action` / `missing_request_id` / `internal`), and `message` before exiting non-zero. Claimed in-flight requests (`*.json.claimed`) are intentionally not listed — they are daemon-managed; see [[modules/daemon]] §"At-most-once dispatch via atomic claim". |
## Global Digest
diff --git a/wiki/commands/setup.md b/wiki/commands/setup.md
new file mode 100644
index 00000000..3954edad
--- /dev/null
+++ b/wiki/commands/setup.md
@@ -0,0 +1,48 @@
+---
+title: hive setup
+type: command
+source: lib/hive/commands/setup.rb, lib/hive/setup/diagnostics.rb
+created: 2026-07-15
+updated: 2026-07-15
+tags: [command, setup, web, daemon, local]
+---
+
+**TLDR**: `hive setup` is the local (non-Docker) provisioner. It diagnoses
+host dependencies, bootstraps Hive-owned assets (qmd + version-matched Rails
+web bundle), installs/starts `hive-daemon` with the invoking CLI binary,
+initializes or enrolls the current repository, and optionally installs the
+separate `hive-web` service when `--service` is passed.
+
+## CLI
+
+```bash
+hive setup [--service] [--no-bootstrap] [--no-init] [--json]
+```
+
+| Flag | Effect |
+|---|---|
+| `--service` | Also install/start the managed `hive-web` service (systemd-user / launchd). |
+| `--no-bootstrap` | Diagnose only; no npm, download, service manager, init, or enrollment mutations. |
+| `--no-init` | Skip repository init/enrollment; still provision services when not diagnose-only. |
+| `--json` | Emit a complete diagnostics + phases report; `ok` and exit reflect all failures. |
+
+## Phases
+
+1. **diagnostics** — bounded probes for Ruby 3.4, git, tmux, gh, claude, codex, Node, npm, qmd, web bundle, SQLite. Probe children run in their own process groups and a deadline terminates descendants as well as the direct child, so inherited capture pipes cannot strand setup. Only qmd and the web bundle are bootstrappable; external CLIs get exact fix commands and are never silently installed/authenticated.
+2. **bootstrap_qmd** — `npm install --global --prefix "$XDG_DATA_HOME/hive/qmd" @tobilu/qmd` when missing.
+3. **bootstrap_web_bundle** — `Hive::Web::AppBundle` installs `hive-web-.tar.gz` under XDG data.
+4. **daemon_install** — `hive-daemon` unit with `Hive::InvokedBinary.path`, force-refresh on drift.
+5. **init / enroll** — fresh repo runs `hive init` (coding workflow, non-interactive); existing `.hive-state` runs `hive daemon enable`. Both commands use non-emitting programmatic APIs, so `--json` writes exactly one `hive-setup.v1` document; even a nested `SystemExit` becomes a failed phase and the complete report is still emitted.
+6. **web_install** (only with `--service`) — separate `hive-web` unit running `hive web`. When setup refreshed the bundle and the service was already active, it explicitly restarts the unit and waits for bounded `/health` success before reporting the phase green.
+
+Bare setup (no `--service`) ends with a reminder to run foreground `hive web`.
+With `--service`, reports `http://127.0.0.1:4567`.
+`hive-setup.v1.json`, `hive-web-install.v1.json`, and
+`hive-web-status.v1.json` are published contracts registered in
+`Hive::Schemas::SCHEMA_VERSIONS`.
+
+## Related
+
+- [[commands/web]] — foreground and managed web lifecycle
+- [[commands/daemon]] — daemon install/status/binary drift
+- [[operating]] — XDG paths and autostart
diff --git a/wiki/commands/web.md b/wiki/commands/web.md
index f6f25373..8a937cac 100644
--- a/wiki/commands/web.md
+++ b/wiki/commands/web.md
@@ -3,8 +3,8 @@ title: hive web
type: command
source: lib/hive/commands/web.rb, lib/hive/web/, web/, packaging/docker/, .github/workflows/release.yml
created: 2026-06-04
-updated: 2026-06-25
-tags: [command, web, hivebox, rails, turbo]
+updated: 2026-07-15
+tags: [command, web, hivebox, rails, turbo, local]
---
**TLDR**: `hive web` boots the hivebox web UI — a vanilla **Rails 8** app
@@ -20,19 +20,60 @@ Retry button and Telegram Autofix share the same guarded clear plus rerun
contract; the TUI's Recover has its own subprocess-based clear + `hive run`
path with separate gates.
+
+## Local install mode (peer to hivebox)
+
+Local non-Docker installs are first-class and share the operator's real XDG
+state and enrolled repositories with the TUI and daemon:
+
+```bash
+hive setup # diagnose + bootstrap + daemon + enroll
+hive setup --service # also managed hive-web on http://127.0.0.1:4567
+hive web # foreground Rails (no unit required)
+hive web install|start|stop|status
+```
+
+App resolution: `HIVEBOX_WEB_APP_DIR` → managed XDG bundle
+(`Hive::Web::AppBundle` / `hive-web-.tar.gz`) → source `web/` →
+download. The gem excludes `web/`. Default bind is loopback with optional
+no-auth (`web.local_loopback`); the bypass requires both a loopback peer and a
+loopback Host, closing DNS-rebinding access through attacker-controlled host
+names. Non-loopback requires owner or `--unsafe`.
+Dashboard shows daemon drift and can queue `hive daemon install --force`
+via the allowlisted host-maintenance dispatch request. See [[commands/setup]].
+
## CLI
-`hive web [--bind] [--port]` (defaults from the `web:` config block). The
-command locates the Rails app (`HIVEBOX_WEB_APP_DIR` override, else `web/`
-next to `lib/`), exports `SECRET_KEY_BASE` (derived from the same persisted
-`Hive::Web::SessionSecret` file as before — sessions survive container
-recreation), `HIVEBOX_ORIGIN` (extra Action Cable origin allow; same-origin
-host traffic is accepted without config), and
-`HIVEBOX_STORAGE_DIR` (the solid-stack sqlite files, under
-`Hive::Paths.state_home/web-storage` so they live on the `/data` mount), runs
-`bin/rails db:prepare`, then execs `bin/rails server`. Outside the container
-or a source checkout the command exits 1 with guidance — the gem itself does
-not package the Rails app (`test/unit/gemspec_test.rb` pins that).
+```bash
+hive web [--bind] [--port] [--unsafe] # foreground
+hive web install [--force] [--json]
+hive web start | stop | status [--json]
+```
+
+Defaults come from the `web:` config block. Bare `hive web` locates the Rails
+app (`HIVEBOX_WEB_APP_DIR` → managed XDG bundle → source `web/` → download),
+exports `SECRET_KEY_BASE` (from the persisted `Hive::Web::SessionSecret` file),
+`HIVEBOX_ORIGIN`, and `HIVEBOX_STORAGE_DIR` (solid-stack sqlite under
+`Hive::Paths.state_home/web-storage`), runs `bin/rails db:prepare`, then execs
+`bin/rails server`. The gem still excludes `web/` (`test/unit/gemspec_test.rb`);
+local gem installs use the release archive via `AppBundle`.
+
+The release archive is standalone: release staging replaces the checkout-only
+`gem "hive-cli", path: ".."` with an exact-version contained
+`vendor/hive-cli` source, regenerates the lock, and performs an isolated
+deployment install before upload. Downloads stream to disk with compressed,
+entry-count, per-entry, and cumulative-expanded size ceilings; reject
+HTTPS→HTTP redirects, traversal, and link entries; and match the asset against
+the release `SHA256SUMS` (plus pinned GitHub Actions identity/issuer cosign
+verification when cosign is installed, matching `install.sh` policy). Managed
+Bundler installs have a hard deadline and persist `BUNDLE_PATH` plus the other
+deployment variables into `db:prepare` and the final Rails exec; source and
+Docker trees keep their normal environment.
+
+`hive web status --json` emits `ok:false` plus
+`error:"web service not active"` and exits non-zero when inactive, matching
+text lifecycle semantics. Its contract and the install contract are published
+as `hive-web-status.v1.json` and `hive-web-install.v1.json`.
## Auth
@@ -244,6 +285,10 @@ browser-visible Demo gallery images and failed-capture banner states. CI runs
both in the `web` job (`.github/workflows/ci.yml`) plus the web app's own
rubocop, and it explicitly runs `web/test/e2e/golden_path_e2e.rb`; the golden
path's daemon/Turbo row-replacement retry contract is covered in [[testing]].
+The same job also runs the opt-in live local-setup acceptance: a fresh git repo,
+real managed AppBundle/Bundler install, independent real daemon and Rails
+process groups, deep health, and bidirectional TUI/web creation with automatic
+daemon dispatch against one state tree.
`web/script/record_box_demo.rb` is a manual demo recorder, not a test. It
stages a temporary local repo, boots the real Rails app and real `hive daemon`,
diff --git a/wiki/index.md b/wiki/index.md
index d59b93e3..98bf716e 100644
--- a/wiki/index.md
+++ b/wiki/index.md
@@ -42,6 +42,7 @@ Folder-as-agent workflow engine: a Ruby 3.4 / Thor CLI control plane where descr
- [[commands/rebase-status]] — `wiki/commands/rebase-status.md`
- [[commands/run]] — `wiki/commands/run.md`
- [[commands/screenote]] — `wiki/commands/screenote.md`
+- [[commands/setup]] — `wiki/commands/setup.md`
- [[commands/stage_action]] — `wiki/commands/stage_action.md`
- [[commands/status]] — `wiki/commands/status.md`
- [[commands/tui]] — `wiki/commands/tui.md`
diff --git a/wiki/log.d/20260715T031500Z-local-web-setup.md b/wiki/log.d/20260715T031500Z-local-web-setup.md
new file mode 100644
index 00000000..7d365167
--- /dev/null
+++ b/wiki/log.d/20260715T031500Z-local-web-setup.md
@@ -0,0 +1,15 @@
+---
+title: Local Hive web install and run
+date: 2026-07-15
+tags: [web, setup, daemon, local]
+---
+
+Added first-class local (non-Docker) install/run for the Rails UI:
+
+- `Hive::Web::AppBundle` installs version-matched `hive-web-.tar.gz` under XDG data; gem stays web-free; release workflow attaches the archive.
+- `Hive::Setup::Diagnostics` + `hive setup [--service]` provision qmd, web bundle, daemon (InvokedBinary), project enroll, optional hive-web service.
+- Independent `hive web install|start|stop|status` via ServiceInstaller sibling; foreground `hive web` preserved.
+- Loopback no-auth (`web.local_loopback` + peer re-check); non-loopback fail-closed without owner/`--unsafe`.
+- Daemon binary drift in `StatusReport` / schema; web dashboard repair queues exact `hive daemon install --force` under `__hive_host__` allowlist.
+
+See [[commands/setup]], [[commands/web]], [[commands/daemon]].
diff --git a/wiki/log.d/20260715T041711Z-local-web-review-fixes.md b/wiki/log.d/20260715T041711Z-local-web-review-fixes.md
new file mode 100644
index 00000000..0b1d5e5d
--- /dev/null
+++ b/wiki/log.d/20260715T041711Z-local-web-review-fixes.md
@@ -0,0 +1,16 @@
+---
+title: Local web setup review hardening
+date: 2026-07-15
+tags: [web, setup, daemon, release, security, testing]
+---
+
+Hardened the local web install/setup feature after review:
+
+- release archives now contain an exact-version Hive gem source, pass an isolated deployment install, and are checksum-verified with bounded download/extraction resources;
+- managed Rails processes retain their private Bundler environment, and refreshed active services restart plus pass health before setup reports success;
+- diagnostics and daemon-version probes share process-group-aware deadlines, while status verifies PID ownership and reports every failed installed-binary probe as `unreadable`;
+- loopback auth requires a loopback Host as well as peer, and host maintenance is restricted to its sentinel tuple while repair resolves the current binary;
+- setup/init/enrollment preserve a single complete JSON report, the setup/web envelopes now have published schemas, and inactive JSON web status remains a non-zero lifecycle result;
+- CI now runs a live fresh-install with real Bundler, daemon, and Rails processes plus bidirectional TUI/web visibility and automatic dispatch.
+
+See [[commands/setup]], [[commands/web]], [[commands/daemon]], and [[testing]].
diff --git a/wiki/operating.md b/wiki/operating.md
index 2b24d46a..2e18d4ac 100644
--- a/wiki/operating.md
+++ b/wiki/operating.md
@@ -124,6 +124,24 @@ unit on disk for manual repair. Manual package users should run
package hooks cannot reliably start a per-user systemd/launchd service for every
host setup.
+### Local web UI (peer to hivebox)
+
+Local (non-Docker) web is a first-class mode sharing the operator's real XDG
+state and enrolled repositories with the TUI/daemon:
+
+```bash
+hive setup # diagnose, bootstrap qmd/web bundle, daemon, enroll
+hive setup --service # also install/start managed hive-web (127.0.0.1:4567)
+hive web # foreground Rails (always works without a unit)
+hive web install|start|stop|status
+hive daemon status --json # includes binary_drift; repair via install --force
+```
+
+Loopback binds skip GitHub login by default (`web.local_loopback: true`);
+non-loopback binds require `web.github.owner` or `--unsafe`. Docker/hivebox
+continues to use `/app/web`, `/data`, and the owner claim flow unchanged.
+See [[commands/setup]] and [[commands/web]].
+
Updates and uninstall:
```bash
diff --git a/wiki/testing.md b/wiki/testing.md
index 3ee77fea..9831fa81 100644
--- a/wiki/testing.md
+++ b/wiki/testing.md
@@ -167,7 +167,7 @@ and only falls back to apt provisioning when missing. If that fallback's
errors, the workflow disables those Microsoft source files and retries so an
unrelated third-party apt outage does not hide the verifier's actual behavior.
-The browser layer lives in the Rails app: `web/test/integration/*` (device-flow auth via the http DI seam, ownerless first-login claim and later non-owner refusal, plain `/health` versus daemon-backed `/health?deep=1`, ideas with uploads, task Q&A/actions including Advanced Drop, stale-stage 422, red-task Retry recovery queueing, task artifact ordering/markdown rendering/log layout, bounded oversized task diff rendering, media route streaming/refusal plus captured/skipped/failed Demo gallery rendering, repos questionnaire, Repos SSH-origin normalization, non-directory clone-target refusal, Agents-page binary PTY rendering plus operator-ward login polling, favicon/icon serving, Telegram setup guide, and strict blank/@handle chat-ID rejection) and `web/test/system/pipeline_flow_test.rb` (Capybara + Playwright: login gate, composer image attach both paths, Turbo Stream live update, status-grid scroll and composer draft preservation across a live broadcast, Q&A round replacement plus typed-answer survival across morph refreshes, both approve outcomes, log-tail follow/pause/resume, node-preserving log-frame morph reloads, artifact open-state preservation across broadcast-triggered morphs with live content refresh, visible Demo media, and failed-capture banners). CI runs them in the `web` job, installs the root bundle into `vendor/root-bundle`, passes that path as `GOLDEN_E2E_BUNDLE_PATH`, and explicitly runs `web/test/e2e/golden_path_e2e.rb`. The golden-path E2E pins `BUNDLE_GEMFILE`, points `BUNDLE_PATH` at the supplied root bundle, deletes inherited web-bundle deployment/config keys, and preflights the daemon spawn environment with `bundle exec ruby -Ilib bin/hive --version` before starting the foreground daemon, so a broken Bundler/Ruby env fails with the real stderr/stdout instead of a later browser timeout.
+The browser layer lives in the Rails app: `web/test/integration/*` (device-flow auth via the http DI seam, ownerless first-login claim and later non-owner refusal, loopback peer+Host auth bypass checks, plain `/health` versus daemon-backed `/health?deep=1`, ideas with uploads, task Q&A/actions including Advanced Drop, stale-stage 422, red-task Retry recovery queueing, task artifact ordering/markdown rendering/log layout, bounded oversized task diff rendering, media route streaming/refusal plus captured/skipped/failed Demo gallery rendering, repos questionnaire, Repos SSH-origin normalization, non-directory clone-target refusal, Agents-page binary PTY rendering plus operator-ward login polling, favicon/icon serving, Telegram setup guide, and strict blank/@handle chat-ID rejection) and `web/test/system/pipeline_flow_test.rb` (Capybara + Playwright: login gate, composer image attach both paths, Turbo Stream live update, status-grid scroll and composer draft preservation across a live broadcast, Q&A round replacement plus typed-answer survival across morph refreshes, both approve outcomes, log-tail follow/pause/resume, node-preserving log-frame morph reloads, artifact open-state preservation across broadcast-triggered morphs with live content refresh, visible Demo media, and failed-capture banners). CI runs them in the `web` job, installs the root bundle into `vendor/root-bundle`, and runs `test/integration/local_web_setup_e2e_test.rb` with `HIVE_LIVE_LOCAL_WEB_E2E=1`: the test starts with a repo that has no `.hive-state`, uses the real AppBundle/Bundler staging path, boots independent daemon and Rails process groups through one wrapper, waits for deep health, and proves TUI→web and web→TUI visibility plus automatic dispatch. CI then passes the root bundle as `GOLDEN_E2E_BUNDLE_PATH` and runs `web/test/e2e/golden_path_e2e.rb`; its second browser scenario independently repeats the bidirectional parity assertion against a real foreground daemon. The golden-path E2E pins `BUNDLE_GEMFILE`, points `BUNDLE_PATH` at the supplied root bundle, deletes inherited web-bundle deployment/config keys, and preflights the daemon spawn environment with `bundle exec ruby -Ilib bin/hive --version` before starting the foreground daemon, so a broken Bundler/Ruby env fails with the real stderr/stdout instead of a later browser timeout.
`web/test/test_helper.rb#create_task!` wraps real `Hive::Commands::New`
task creation. Because generated task slugs use a 16-bit random suffix and
many web tests intentionally reuse the same task title inside a persistent