/
+ provisioner = build_provisioner(dir)
+
+ capture_io { provisioner.provision }
+
+ assert File.file?(File.join(provisioner.target_dir, "config", "application.rb"))
+ end
+ end
+
+ def test_gem_without_hive_cli_dependency_cannot_be_rewritten
+ with_xdg_home do |dir|
+ @fixture_options = { gemfile_without_hive_cli: true }
+ provisioner = build_provisioner(dir)
+
+ e = assert_raises(Hive::Error) { capture_io { provisioner.provision } }
+
+ assert_match(/hive-cli path dependency/, e.message)
+ ensure
+ @fixture_options = nil
+ end
+ end
+
+ private
+
+ def fixture_tarball(version)
+ root = File.join(Dir.mktmpdir("hive-web-fixture"), "hive-web-#{version}")
+ FileUtils.mkdir_p(File.join(root, "config"))
+ FileUtils.mkdir_p(File.join(root, "bin"))
+ File.write(File.join(root, "config", "application.rb"), "# fixture\n")
+ File.write(File.join(root, "bin", "rails"), "#!/bin/sh\n")
+ if @fixture_options&.fetch(:gemfile_without_hive_cli, false)
+ File.write(File.join(root, "Gemfile"), "gem \"rails\"\n")
+ else
+ File.write(File.join(root, "Gemfile"), "gem \"rails\"\ngem \"hive-cli\", path: \"..\"\n")
+ end
+ tarball = File.join(File.dirname(root), "hive-web-#{version}.tar.gz")
+ system("tar", "-czf", tarball, "-C", File.dirname(root), "hive-web-#{version}", exception: true)
+ tarball
+ end
+
+ def build_provisioner(xdg_root, version: Hive::VERSION, sha: nil, fail_download: false)
+ fixture = fixture_tarball(version)
+ digest = sha || Digest::SHA256.file(fixture).hexdigest
+ @runner_calls = []
+ @runner_envs = []
+
+ downloader = lambda do |url, dest|
+ raise Net::HTTPError.new("offline", nil) if fail_download
+
+ @download_count += 1
+ if url.end_with?(".sha256")
+ File.write(dest, "#{digest} hive-web.tar.gz\n")
+ @downloads[:sha] = url
+ else
+ FileUtils.cp(fixture, dest)
+ @downloads[:tarball] = url
+ end
+ dest
+ end
+
+ runner = lambda do |env, *argv|
+ @runner_calls << argv
+ @runner_envs << env
+ # Real extraction (hermetic — the archive is our fixture), but the
+ # bundler step is stubbed so no gems are installed.
+ argv[0] == "tar" ? system(*argv) : true
+ end
+
+ Hive::Web::AppProvisioner.new(
+ version: version,
+ data_home: File.join(xdg_root, "data", "hive"),
+ download: downloader,
+ runner: runner
+ )
+ end
+end
diff --git a/web/app/controllers/admin/daemon_controller.rb b/web/app/controllers/admin/daemon_controller.rb
new file mode 100644
index 000000000..3a3cb7c7a
--- /dev/null
+++ b/web/app/controllers/admin/daemon_controller.rb
@@ -0,0 +1,102 @@
+require "hive/setup/daemon_guard"
+
+# U5 — web-visible daemon health + one-click repair. Health reuses the same
+# DaemonGuard the CLI setup flow uses (pidfile liveness + unit binary
+# check); repair re-runs the daemon installer with --force in a BOUNDED
+# subprocess so a hung systemctl can never wedge a Puma thread forever.
+#
+# Gating: this controller sits behind the application-wide require_login,
+# which is a no-op only when the effective auth mode is `none` — and that
+# mode is boot-refused on non-loopback binds. As defense in depth, repair
+# additionally refuses when auth mode is none AND the request did not
+# originate from a loopback address.
+class Admin::DaemonController < ApplicationController
+ REPAIR_TIMEOUT_SEC = 120
+
+ def show
+ render json: health_payload
+ end
+
+ def repair
+ unless repair_permitted?
+ return render json: { ok: false, error: "repair refused in no-auth mode off loopback" },
+ status: :forbidden
+ end
+
+ result = bounded_repair_subprocess
+ if result[:ok]
+ render json: { ok: true, guard: post_repair_health }
+ else
+ render json: { ok: false, error: result[:error] }, status: :internal_server_error
+ end
+ end
+
+ private
+
+ def guard
+ @guard ||= Hive::Setup::DaemonGuard.new
+ end
+
+ def health_payload
+ result = guard.check
+ {
+ "ok" => result.healthy?,
+ "status" => result.status,
+ "running" => result.running,
+ "pid" => result.pid,
+ "unit_path" => result.unit_path,
+ "service_binary" => result.service_binary,
+ "expected_binary" => result.expected_binary,
+ "detail" => result.detail
+ }
+ end
+
+ # Repair mutates a system service — never allow it implicitly. The CLI's
+ # U1 decision already refuses `none`-mode non-loopback binds; this check
+ # closes the residual case of an explicit unsafe-public no-auth box being
+ # reached from another machine.
+ def repair_permitted?
+ return true unless ENV["HIVEBOX_AUTH_MODE"] == "none"
+
+ request.remote_ip.to_s =~ /\A(127\.|::1\z)/ ? true : false
+ end
+
+ # Run `hive daemon install --force --json` as a child process with a hard
+ # wall clock. waitpid in a polling loop so Timeout semantics actually kill
+ # something (a blocking waitpid would ignore Ruby-level timeouts).
+ def bounded_repair_subprocess
+ hive_bin = guard.expected_binary
+ reader, writer = IO.pipe
+ pid = Process.spawn(hive_bin, "daemon", "install", "--force", "--json",
+ out: writer, err: writer)
+ writer.close
+ deadline = Time.now + REPAIR_TIMEOUT_SEC
+ status = nil
+ output = +""
+ loop do
+ remaining = deadline - Time.now
+ if remaining <= 0
+ Process.kill("KILL", pid) rescue nil
+ Process.wait(pid) rescue nil
+ return { ok: false, error: "daemon repair timed out after #{REPAIR_TIMEOUT_SEC}s" }
+ end
+ ready = IO.select([reader], nil, nil, [remaining, 0.5].min)
+ output << reader.read_nonblock(65_536) if ready && ready[0].include?(reader)
+ _, status = Process.waitpid2(pid, Process::WNOHANG)
+ break if status
+ end
+ reader.close
+ if status.success?
+ { ok: true, output: output }
+ else
+ { ok: false, error: "daemon repair exited #{status.exitstatus}: #{output.lines.last.to_s.strip}" }
+ end
+ rescue StandardError => e
+ { ok: false, error: "#{e.class}: #{e.message}" }
+ end
+
+ def post_repair_health
+ @guard = nil # force a fresh probe after the subprocess rewrote the unit
+ health_payload
+ end
+end
diff --git a/web/app/controllers/application_controller.rb b/web/app/controllers/application_controller.rb
index 2a1e5e28f..969684b79 100644
--- a/web/app/controllers/application_controller.rb
+++ b/web/app/controllers/application_controller.rb
@@ -50,6 +50,13 @@ class ApplicationController < ActionController::Base
end
def require_login
+ # Local (non-Docker) mode: `hive web` resolves the effective auth mode
+ # from web.auth + the bind address and exports it. `none` means a
+ # loopback-only UI with no sign-in — skip the gate entirely. The
+ # non-loopback + none combination is refused at boot by the CLI, so
+ # this env can only be "none" on a loopback bind in practice.
+ return if ENV["HIVEBOX_AUTH_MODE"] == "none"
+
return redirect_to login_path unless current_login
# Sessions must track the CURRENT owner, not the owner at sign-in time:
diff --git a/web/app/controllers/status_controller.rb b/web/app/controllers/status_controller.rb
index c44440a83..65ba4172d 100644
--- a/web/app/controllers/status_controller.rb
+++ b/web/app/controllers/status_controller.rb
@@ -1,6 +1,24 @@
+require "hive/setup/daemon_guard"
+
class StatusController < ApplicationController
def index
@payload = StatusBroadcaster.snapshot
@projects = @payload.fetch("projects", [])
end
+
+ private
+
+ # Cheap, non-mutating probe for the U5 banner: pidfile liveness + unit
+ # binary comparison. Nil on any failure so a broken local install can
+ # never take the dashboard down (the banner simply does not render).
+ def daemon_banner_health
+ @daemon_banner_health ||= begin
+ Hive::Setup::DaemonGuard.new.check
+ rescue StandardError => e
+ Rails.logger.warn("daemon banner probe failed: #{e.class}: #{e.message}")
+ nil
+ end
+ end
+
+ helper_method :daemon_banner_health
end
diff --git a/web/app/views/status/index.html.erb b/web/app/views/status/index.html.erb
index 25bcdf793..67e22b65a 100644
--- a/web/app/views/status/index.html.erb
+++ b/web/app/views/status/index.html.erb
@@ -9,6 +9,28 @@
<% end %>
<%= turbo_stream_from StatusBroadcaster::CHANNEL %>
+<%# Local-mode daemon banner (U5): red strip with a Repair button when the
+ daemon is down (running unit, dead pidfile) or its service unit drifted
+ onto a different hive binary. Deliberately silent on not_installed /
+ unsupported hosts so the hivebox container and pre-install machines keep
+ today's clean dashboard. %>
+<% if (health = daemon_banner_health) && (health.status == "drifted" || (health.status == "ok" && !health.running)) %>
+
+
+ <% if health.status == "drifted" %>
+ Daemon binary drift detected — the configured daemon unit runs a different hive install.
+ <% else %>
+ Daemon is not running — tasks will not dispatch automatically.
+ <% end %>
+ <%= button_to "Repair", admin_daemon_repair_path, method: :post,
+ class: "btn btn-sm btn-danger",
+ form: { data: { daemon_repair_target: "form" } },
+ data: { confirm: "Reinstall and restart the hive-daemon service with this CLI's binary?" } %>
+
+ <%= health.detail %>
+
+<% end %>
+
<%# TUI left-pane parity: the rail filters the grid client-side (buttons,
not links — a navigation would discard the permanent composer's typed
text). The controller wraps rail AND grid; it re-applies the filter
diff --git a/web/config/routes.rb b/web/config/routes.rb
index 659576df3..608f5497a 100644
--- a/web/config/routes.rb
+++ b/web/config/routes.rb
@@ -52,4 +52,10 @@ Rails.application.routes.draw do
get "telegram" => "telegram#show", as: :telegram
post "telegram" => "telegram#update", as: :update_telegram
post "telegram/test" => "telegram#test", as: :test_telegram
+
+ # Local-mode daemon health/repair (U5). Gated by require_login like every
+ # other page; repair additionally refuses no-auth non-loopback requests
+ # (defense in depth — the CLI already refuses to boot that combination).
+ get "admin/daemon" => "admin/daemon#show", as: :admin_daemon
+ post "admin/daemon/repair" => "admin/daemon#repair", as: :admin_daemon_repair
end
diff --git a/web/test/integration/admin_daemon_test.rb b/web/test/integration/admin_daemon_test.rb
new file mode 100644
index 000000000..176e8815f
--- /dev/null
+++ b/web/test/integration/admin_daemon_test.rb
@@ -0,0 +1,138 @@
+require "test_helper"
+require "hive/setup/daemon_guard"
+
+# U5 — the web-visible daemon health/repair surface: the dashboard banner
+# renders when the daemon is down/drifted, the JSON health endpoint reports
+# guard status, and the repair POST is gated by the effective auth mode.
+class AdminDaemonTest < ActionDispatch::IntegrationTest
+ setup do
+ create_hive_project!
+ configure_owner!(owner: "alice")
+ ENV.delete("HIVEBOX_AUTH_MODE")
+ # DaemonGuard anchors service-unit paths on HOME; sandbox it so tests
+ # never touch the developer's real LaunchAgents/systemd dirs.
+ @home_sandbox = Dir.mktmpdir("hive-web-home")
+ @old_home = ENV["HOME"]
+ ENV["HOME"] = @home_sandbox
+ end
+
+ teardown do
+ ENV["HOME"] = @old_home
+ FileUtils.rm_rf(@home_sandbox)
+ FileUtils.rm_f(File.join(Hive::Paths.state_home, ".daemon.pid"))
+ end
+
+ def install_daemon_unit(binary)
+ Hive::Commands::Daemon::ServiceInstaller.new(
+ host_os: "linux", home: @home_sandbox, binary_path: binary,
+ systemctl_available: true, runner: ->(_argv) { true }
+ ).install!(autostart: false)
+ end
+
+ def write_live_daemon_pidfile
+ path = File.join(Hive::Paths.state_home, ".daemon.pid")
+ FileUtils.mkdir_p(File.dirname(path))
+ payload = { "pid" => Process.pid, "process_start_time" => Hive::Lock.send(:process_start_time, Process.pid) }
+ File.write(path, payload.to_yaml)
+ end
+
+ test "health endpoint reports not_running when no daemon unit exists" do
+ sign_in!
+ get "/admin/daemon"
+ assert_response :success
+ body = response.parsed_body
+ assert_equal "not_installed", body["status"]
+ assert_equal false, body["running"]
+ assert body["detail"].present?
+ end
+
+ test "health endpoint reports a drifted unit with both binaries" do
+ install_daemon_unit("/opt/other/hive")
+ sign_in!
+ get "/admin/daemon"
+ assert_response :success
+ body = response.parsed_body
+ assert_equal "drifted", body["status"]
+ assert_equal "/opt/other/hive", body["service_binary"]
+ assert body["expected_binary"].present?
+ end
+
+ test "dashboard shows the banner with Repair on binary drift" do
+ install_daemon_unit("/opt/other/hive")
+ sign_in!
+ get "/"
+ assert_response :success
+ assert_match(/daemon-banner/, response.body)
+ assert_match(/Daemon binary drift/, response.body)
+ assert_match(admin_daemon_repair_path, response.body)
+ end
+
+ def install_matching_daemon_unit
+ # Install a unit whose binary matches what the web-tier guard resolves
+ # as "this CLI" (InvokedBinary.path → PATH lookup fallback).
+ expected = Hive::Setup::DaemonGuard.new(home: @home_sandbox).expected_binary
+ Hive::Commands::Daemon::ServiceInstaller.new(
+ host_os: "linux", home: @home_sandbox, binary_path: expected,
+ systemctl_available: true, runner: ->(_argv) { true }
+ ).install!(autostart: false)
+ end
+
+ test "dashboard shows the banner when the daemon is stopped" do
+ # A matching unit (status ok) but no live pidfile → down daemon.
+ install_matching_daemon_unit
+
+ sign_in!
+ get "/"
+ assert_response :success
+ assert_match(/Daemon is not running/, response.body)
+ end
+
+ test "dashboard hides the banner when the daemon is healthy" do
+ install_matching_daemon_unit
+ write_live_daemon_pidfile
+
+ sign_in!
+ get "/"
+ assert_response :success
+ refute_match(/daemon-banner/, response.body)
+ end
+
+ test "repair refuses in no-auth mode from a non-loopback request" do
+ # The CLI boot-refuses this combination (auth none + non-loopback bind);
+ # this pins the controller's defense-in-depth gate anyway.
+ ENV["HIVEBOX_AUTH_MODE"] = "none"
+ controller = Admin::DaemonController.new
+ remote = ActionDispatch::TestRequest.create(
+ "REMOTE_ADDR" => "203.0.113.7", "HTTP_X_FORWARDED_FOR" => "203.0.113.7"
+ )
+ controller.set_request!(remote)
+ refute controller.send(:repair_permitted?),
+ "repair must refuse no-auth requests from non-loopback addresses"
+
+ loopback = ActionDispatch::TestRequest.create("REMOTE_ADDR" => "127.0.0.1")
+ controller.set_request!(loopback)
+ assert controller.send(:repair_permitted?), "loopback no-auth repair stays allowed"
+ end
+
+ test "repair invokes hive daemon install --force in a bounded subprocess" do
+ stub_hive = File.join(ENV["HIVE_TEST_HOME_ROOT"], "stub-hive-#{SecureRandom.hex(4)}")
+ log_file = "#{stub_hive}.log"
+ File.write(stub_hive, <<~SH)
+ #!/bin/sh
+ echo "$@" >> #{log_file}
+ echo '{"schema":"hive-daemon-install","ok":true,"outcome":"written"}'
+ exit 0
+ SH
+ FileUtils.chmod(0o755, stub_hive)
+
+ Hive::Setup::DaemonGuard.stub(:new, -> { Hive::Setup::DaemonGuard.new(binary_path: stub_hive, host_os: "unsupported") }) do
+ sign_in!
+ post "/admin/daemon/repair"
+ assert_response :success
+ assert_equal true, response.parsed_body["ok"], response.parsed_body.inspect
+ end
+
+ assert_match(/daemon install --force --json/, File.read(log_file)),
+ "repair must invoke `hive daemon install --force`"
+ end
+end
diff --git a/web/test/integration/local_auth_mode_test.rb b/web/test/integration/local_auth_mode_test.rb
new file mode 100644
index 000000000..2ba011ffb
--- /dev/null
+++ b/web/test/integration/local_auth_mode_test.rb
@@ -0,0 +1,41 @@
+require "test_helper"
+
+# U1 — the Rails require_login gate honors HIVEBOX_AUTH_MODE=none (local
+# loopback mode) while the default github gate stays active.
+class LocalAuthModeTest < ActionDispatch::IntegrationTest
+ def with_auth_mode(mode)
+ old = ENV["HIVEBOX_AUTH_MODE"]
+ ENV["HIVEBOX_AUTH_MODE"] = mode
+ yield
+ ensure
+ old.nil? ? ENV.delete("HIVEBOX_AUTH_MODE") : ENV["HIVEBOX_AUTH_MODE"] = old
+ end
+
+ test "auth mode none reaches the dashboard without a session" do
+ create_hive_project!
+ with_auth_mode("none") do
+ get "/"
+ assert_response :success
+ end
+ end
+
+ test "default github gate still redirects an anonymous visitor" do
+ create_hive_project!
+ # No HIVEBOX_AUTH_MODE set — production parity: anonymous → login.
+ ENV.delete("HIVEBOX_AUTH_MODE")
+ configure_owner!(owner: "alice")
+ get "/"
+ assert_redirected_to login_path
+ end
+
+ test "auth mode github still gates even when a stale local session exists" do
+ create_hive_project!
+ configure_owner!(owner: "alice")
+ with_auth_mode("github") do
+ # Dev/test seam signs in a NON-owner; the owner check must still fire.
+ get "/dev_login", params: { as: "mallory" }
+ get "/"
+ assert_redirected_to login_path
+ end
+ end
+end
diff --git a/wiki/commands/setup.md b/wiki/commands/setup.md
new file mode 100644
index 000000000..3b1037388
--- /dev/null
+++ b/wiki/commands/setup.md
@@ -0,0 +1,68 @@
+---
+title: hive setup
+type: command
+created: 2026-08-21
+updated: 2026-08-21
+tags: [command, setup, web, daemon, local-mode]
+see_also: [[commands/web]], [[commands/daemon]], [[commands/doctor]], [[dependencies]]
+---
+
+**TLDR**: `hive setup` provisions and validates the whole local (non-Docker) stack in one idempotent pass — dependency preflight, version-matched web app, binary-pinned daemon service, project enrollment, and a verified web launch — then prints `http://127.0.0.1:4567`.
+
+# hive setup
+
+## Usage
+
+```
+hive setup [PROJECT] [--json] [--doctor-only]
+ [--skip-preflight|--skip-web-app|--skip-daemon|--skip-enroll|--skip-web]
+ [--all]
+```
+
+## Steps (in order)
+
+1. **preflight** — `Hive::Setup::Preflight` reports Ruby 3.4, git, tmux, gh,
+ claude, codex, Node/npm, qmd, bundler, SQLite as
+ `present / missing / version_too_old`, each with the exact fix command.
+ External agent CLIs are diagnose-only: an unauthenticated `gh` is
+ detected via a read-only `gh auth status` probe; nothing is ever
+ installed or authenticated silently (R12). Only hard dependencies
+ (Ruby, git, bundler) fail the run.
+2. **web_app** — resolves the Rails app: `HIVEBOX_WEB_APP_DIR` → source
+ checkout next to `lib/` → provisioned copy under
+ `${XDG_DATA_HOME:-~/.local/share}/hive/web-app/` ([[commands/web]] U2
+ provisioning).
+3. **daemon** — `Hive::Setup::DaemonGuard` compares the installed
+ `hive-daemon` unit's binary against the invoking CLI. Drift repairs via
+ the daemon installer's `--force` path (unit rewrite + restart). On hosts
+ without systemd-user/launchd the step degrades to "run `hive daemon start`
+ manually" instead of failing.
+4. **enroll** — unregistered project → runs `hive init` (which defaults
+ `daemon.enabled: true`); initialized-but-disabled → flips it on via the
+ existing daemon enable machinery; already enrolled → no-op. `--all`
+ delegates to `hive daemon enable --all`.
+5. **web** — starts the managed service (`hive web install/start` machinery)
+ and polls `GET /health?deep=1`. Port conflicts are reported with the
+ owning pid/command and FAIL — hive never kills listeners and never
+ silently picks another port.
+
+## Envelope
+
+`--json` emits `hive-setup.v1`: `{ok, url?, steps:[{name,status,detail,fix}]}`.
+Exit code 0 only when every run step succeeded.
+
+## Notes
+
+- Idempotent: re-runs skip completed work (provisioned app marker file,
+ matching daemon unit, enrolled project, healthy web).
+- `--doctor-only` prints just the preflight report (plus `hive-setup-doctor`
+ JSON under `--json`).
+- Docker/hivebox is untouched by this command surface (R16).
+
+## Related decisions
+
+- The plan's scenario "`hive web --bind 0.0.0.0` with default auth ⇒ refusal"
+ was implemented as "resolves to github exactly as today": the hivebox
+ supervisor literally runs `hive web --bind 0.0.0.0` with default config, so
+ refusing there would break Docker (R16). The refusal fires when the
+ EFFECTIVE auth mode is `none` on a non-loopback bind. See wiki/gaps.md.
diff --git a/wiki/commands/web.md b/wiki/commands/web.md
index f6f253737..133f9784e 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-08-21
+tags: [command, web, hivebox, rails, turbo, local-mode]
---
**TLDR**: `hive web` boots the hivebox web UI — a vanilla **Rails 8** app
@@ -22,17 +22,59 @@ path with separate gates.
## 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 [SUBCOMMAND] [--bind] [--port] [--json]` (defaults from the `web:`
+config block). Subcommands (local mode, U4):
+
+- bare / `run` — foreground server (existing contract; no service needed).
+- `install [--force]` — per-user autostart unit (`hive-web.service` under
+ systemd-user, `local.hive-web.plist` under LaunchAgents), a SEPARATE
+ service from the daemon. Same backup/force/unsupported-host mechanics as
+ the daemon installer.
+- `start` / `stop` — drive the installed service; on hosts without a user
+ service manager they fall back to a detached pidfile-tracked process
+ (`/.web.pid`, logs in `/logs/web.log`).
+- `status [--json]` — non-mutating report: running (pidfile or port probe),
+ `service_installed`, `service_enabled`, `unit_path`, `resolved_binary`
+ (`hive-web-status.v1` envelope).
+
+The command locates the Rails app (`HIVEBOX_WEB_APP_DIR` override, else `web/`
+next to `lib/`, else provisioned copy — see Provisioning below), 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
+host traffic is accepted without config), `HIVEBOX_AUTH_MODE` (the resolved
+effective auth mode — see Auth below), 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).
+`bin/rails db:prepare`, then execs `bin/rails server`. Outside the container,
+a source checkout, or a provisioned install the command exits 1 with guidance
+— the gem itself does not package the Rails app (`test/unit/gemspec_test.rb`
+pins that).
+
+## Local provisioning (gem installs)
+
+When no app dir resolves, `Hive::Web::AppProvisioner` downloads
+`hive-web-.tar.gz` (+ `.sha256`) from the GitHub release matching
+`Hive::VERSION` into `${XDG_DATA_HOME:-~/.local/share}/hive/web-app/`,
+verifies the checksum, extracts atomically, rewrites the embedded
+`gem "hive-cli", path: ".."` to the installed gem's real path, and runs
+`bundle install` with `BUNDLE_PATH` scoped inside the app dir. A marker file
+(`.hive-provisioned`) makes re-runs idempotent; version bumps provision a new
+dir and leave the old one for rollback. Failure is always a typed error naming
+the `HIVEBOX_WEB_APP_DIR` escape hatch. The release job builds the tarball from
+web/ (minus dev/test content) in the SAME job as the gem, so versions cannot
+drift.
+
+## Auth modes (local vs Docker)
+
+`web.auth` selects the effective auth mode: `auto` (default) resolves at boot
+to `none` on a loopback bind (`127.0.0.1` / `localhost` / `::1`) and to
+`github` otherwise — hivebox binds `0.0.0.0` via its supervisor argv, so it
+resolves to github exactly as before this key existed (R16). `none` skips the
+login gate entirely (`HIVEBOX_AUTH_MODE=none` short-circuits `require_login`);
+that combination is REFUSED at boot on a non-loopback bind unless
+`--unsafe-public` or `web.allow_public_unsafe: true`. `github` forces the owner
+gate regardless of bind.
## Auth
diff --git a/wiki/dependencies.md b/wiki/dependencies.md
index 6a8a6082b..11ec7e2ac 100644
--- a/wiki/dependencies.md
+++ b/wiki/dependencies.md
@@ -3,7 +3,7 @@ title: Dependencies
type: dependencies
source: Gemfile, hive.gemspec, Gemfile.lock, web/Gemfile, web/Gemfile.lock
created: 2026-04-25
-updated: 2026-06-25
+updated: 2026-08-21
tags: [dependencies, gems, runtime]
---
@@ -145,3 +145,15 @@ These are not gems but the CLI tools the runtime invokes:
- [[modules/agent]]
- [[commands/bot]]
- [[e2e]]
+
+## Local web-mode toolchain (`hive setup` preflight, 2026-08-21)
+
+`hive setup` runs `Hive::Setup::Preflight`, which reports the local-mode
+toolchain with present/missing/version_too_old rows plus exact fix
+commands: Ruby 3.4, git, bundler (hard failures), tmux 3.2+, Node 18+ /
+npm, qmd, sqlite3, and — diagnose-only, never installed or
+authenticated by hive — `claude` (>= `Hive::MIN_CLAUDE_VERSION`),
+`codex` (fix hint: `codex login --device-auth`), and `gh` (unauthenticated
+detected via read-only `gh auth status`; fix hint: `gh auth login`).
+Hive-owned items (qmd via npm, the web bundle via U2 provisioning) are
+bootstrap-eligible; everything else is diagnose + fix-command only.
diff --git a/wiki/gaps.md b/wiki/gaps.md
index 2d71cc615..48e06228c 100644
--- a/wiki/gaps.md
+++ b/wiki/gaps.md
@@ -317,3 +317,18 @@ genuine clean verdict could fail to match and `:error`/retry (worst case
emit the strict `## High/Medium/Nit` + `No findings.` format so the prose path
is never exercised; until then, watch `reviews/errors-NN.md` tails for
clean-but-rejected verdicts and extend `CLEAN_VERDICT` as new phrasings appear.
+
+## Local web install (2026-08-21) — plan scenario vs R16 conflict
+
+The add-local-hive-web-install plan's U1 test scenario 2 says
+"`hive web --bind 0.0.0.0` with default auth ⇒ typed refusal". That
+conflicts with R16 (Docker untouched) AND with the plan's own Approach
+text ("hivebox binds 0.0.0.0, which resolves to github exactly as
+today"), because `Hive::Web::Supervisor#run` literally spawns
+`hive web --bind 0.0.0.0` inside the container with default config.
+Implemented per the Approach text: `auto` + non-loopback → `github`
+(no refusal); the typed refusal fires only when the EFFECTIVE auth mode
+is `none` on a non-loopback bind without `--unsafe-public` /
+`web.allow_public_unsafe`. If a hard refusal for public binds is ever
+wanted, the hivebox supervisor must first switch to `web.auth: github`
+in the container's generated config.
diff --git a/wiki/index.md b/wiki/index.md
index d59b93e36..0dc857cf3 100644
--- a/wiki/index.md
+++ b/wiki/index.md
@@ -3,7 +3,7 @@ title: hive Wiki
type: index
source: wiki/**/*.md
created: 2026-05-14
-updated: 2026-06-25
+updated: 2026-08-21
tags: [index, wiki]
---
@@ -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/20260821T000000Z-local-hive-web-install.md b/wiki/log.d/20260821T000000Z-local-hive-web-install.md
new file mode 100644
index 000000000..f35383e35
--- /dev/null
+++ b/wiki/log.d/20260821T000000Z-local-hive-web-install.md
@@ -0,0 +1,55 @@
+---
+title: "log: first-class local (non-Docker) install/run mode for Hive web"
+type: log-fragment
+created: 2026-08-21
+tags: [web, setup, daemon, local-mode]
+---
+
+# 2026-08-21 — Local web install/run mode (`hive setup`, managed `hive web`)
+
+## What changed
+
+- **U1** — `web.auth` (`auto|none|github`) + `web.allow_public_unsafe`
+ config keys; `hive web` resolves the effective auth mode (`auto` ⇒
+ `none` on loopback, `github` otherwise) and exports
+ `HIVEBOX_AUTH_MODE` to Rails; non-loopback binds with effective
+ `none` are refused unless `--unsafe-public`; Rails
+ `require_login` short-circuits in `none` mode.
+- **U2** — `Hive::Web::AppProvisioner`: gem installs download the
+ version-matched `hive-web-.tar.gz` release asset (built in
+ the same release job as the gem), checksum-verify, extract atomically
+ under XDG data home, rewrite the embedded hive-cli path dep to the
+ installed gem, and bundle inside the app dir. Idempotent via marker.
+- **U3** — `Hive::Setup::Preflight`: dependency report with
+ present/missing/version_too_old rows + exact fix commands; diagnose-
+ only for claude/codex/gh; hard failures limited to Ruby/git/bundle.
+- **U4** — `hive web install|start|stop|status [--json]`: separate
+ `hive-web` systemd-user / launchd service via ServiceInstaller::Base;
+ detached pidfile fallback on hosts without a user service manager;
+ foreground `hive web` unchanged.
+- **U5** — `Hive::Setup::DaemonGuard`: daemon unit binary vs CLI binary
+ drift check with one-command repair (daemon installer `--force`);
+ web `Admin::DaemonController` health/repair endpoints (bounded
+ subprocess, no-auth non-loopback refusal) and dashboard banner.
+- **U6** — `hive setup [PROJECT]` enrollment: init-if-needed /
+ enable-if-disabled / no-op; composition over existing machinery only.
+- **U7** — `hive setup` orchestrator: ordered idempotent steps,
+ `--skip-*`, `--doctor-only`, `--all`, `--json` (`hive-setup.v1`),
+ deep-health verification of `/health?deep=1`, truthful port-conflict
+ failure (never kills listeners, never moves ports).
+
+## Deviations
+
+- Plan scenario "`hive web --bind 0.0.0.0` default auth ⇒ refusal" was
+ NOT implemented literally: the hivebox supervisor runs exactly that
+ argv with default config, so it would break Docker (R16). Refusal is
+ for effective `none` + non-loopback instead. See [[gaps]].
+
+## Tests
+
+Root suite additions: config_web_test, web_auth_test (commands),
+app_provisioner_test, web service_installer_test, web_lifecycle_test,
+daemon_guard_test, setup_preflight_test, setup_enroll_test (integration),
+setup_orchestration_test (integration). Web suite addition:
+local_auth_mode_test, admin_daemon_test (requires a runnable Rails
+bundle; not executable in the local sandbox).