diff --git a/README.md b/README.md index 2fca31369..d477f4c8d 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,23 @@ The TUI is the recommended human interface and an agent-driven CLI is the recomm Full per-command reference, every flag, every envelope field, and every exit code lives in [docs/cli.md](docs/cli.md). +## Local Web Mode (non-Docker) + +Alongside the Docker/hivebox path (which stays supported), Hive has a first-class **local install/run mode**: the web UI runs directly on your real Hive/XDG state and checked-out repos, so the TUI and web share one source of truth. The daemon and web are **separate** services (never merged into one unit). + +``` +hive setup # one-shot: deps + web bundle + backends + daemon + web + health +hive web run # foreground server (default for `hive web`); binds 127.0.0.1:4567 +hive web install [--force] # write + enable a per-user daemon-style web unit +hive web start / stop / status # managed web service lifecycle +``` + +- **Loopback no-auth by default**: binding `127.0.0.1` (default) with no `web.github.owner` set is single-user local mode — no login. Binding a non-loopback interface without `web.github.owner` or `--allow-non-loopback` is **refused** (fail-closed), never just a warning. +- **Same-binary guarantee**: `hive web install`/`hive setup` bake the current CLI wrapper into the unit, and `hive daemon status` reports the running daemon's binary/version vs the CLI (`drift_status`). Drift is surfaced (and repairable via `hive daemon repair` / the web Repair button) but never silently auto-fixed. +- **Linux + macOS** (systemd-user / launchd). Windows is out of scope. The Docker/hivebox path is unchanged. + +Local mode serves the Rails app from a source checkout or `HIVEBOX_WEB_APP_DIR` (the gem does not package `web/`). See [wiki/commands/setup.md](wiki/commands/setup.md). + ## Documentation - **[hivecli.sh](https://hivecli.sh)** — The public website: an outcome-first overview plus curated docs (getting started, concepts, configuration, the user-facing command reference, and operating). Agent-friendly too: every page is available as raw markdown and there's an [`llms.txt`](https://hivecli.sh/llms.txt) index. Start here if you're new. diff --git a/examples/launchd/hive-web.plist b/examples/launchd/hive-web.plist new file mode 100644 index 000000000..80e3bc96a --- /dev/null +++ b/examples/launchd/hive-web.plist @@ -0,0 +1,77 @@ + + + + + + Label + local.hive-web + + ProgramArguments + + /bin/sh + -c + [ -x "$0" ] || exit 0; exec "$0" "$@" + /Users/YOU/.local/bin/hive + web + run + + + RunAtLoad + + + KeepAlive + + SuccessfulExit + + + + ThrottleInterval + 30 + + StandardOutPath + /Users/YOU/Library/Logs/hive-web.out.log + StandardErrorPath + /Users/YOU/Library/Logs/hive-web.err.log + + EnvironmentVariables + + PATH + /Users/YOU/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + + + diff --git a/examples/systemd/hive-web.service b/examples/systemd/hive-web.service new file mode 100644 index 000000000..e4585d3fa --- /dev/null +++ b/examples/systemd/hive-web.service @@ -0,0 +1,53 @@ +# Sample systemd-user unit for `hive web` (Linux) — the managed web +# service lifecycle (`hive web install` / `hive web start`). +# +# The web UI is a SEPARATE service from `hive-daemon` by design: local +# mode runs the Rails app and the daemon as distinct units so each can be +# restarted independently. Daemon + web are never merged into one unit. +# +# The unit runs `hive web run` in the FOREGROUND — systemd is the +# supervisor, so there is no in-process daemonization (compare the +# container supervisor, which spawns `hive web --bind 0.0.0.0` directly). +# +# BEFORE INSTALLING: edit ExecStart= to match where YOUR `hive` binary +# lives. `which hive` shows it. Common paths: +# +# %h/.local/bin/hive ← README install (option-A +# symlink: ln -s ~/Dev/hive/bin/hive ~/.local/bin/hive) +# %h/Dev/hive/bin/hive ← README install (option-B, +# direct from clone, no symlink) +# /usr/local/bin/hive ← system gem install +# %h/.local/share/mise/shims/hive ← mise / rbenv / asdf / chruby shim +# +# `hive web install` rewrites ExecStart=, Environment=PATH= to match the +# resolved binary + Ruby manager detected on the host (exactly like +# `hive daemon install`). The web bind/port are read from the same +# ~/.config/hive/config.yml the CLI uses, so the unit needs no baked +# --bind/--port. +# +# This unit locally binds 127.0.0.1:4567 by default (web.bind/web.port). +# Binding a non-loopback interface without web.github.owner or an +# explicit --allow-non-loopback/--unsafe flag is refused by the CLI. + +[Unit] +Description=Hive local web UI (rails server, foreground) +After=default.target +# Cap the auto-restart loop so a misconfigured ExecStart= (binary not on +# PATH, missing bundle) stops cleanly with `failed` instead of respawning +# forever. +StartLimitBurst=3 +StartLimitIntervalSec=300 + +[Service] +Type=simple +Environment=HIVE_BIN=%h/.local/bin/hive +Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +ExecStart=%h/.local/bin/hive web run +Restart=on-failure +RestartSec=10s +# No WebStorage drain like the daemon's child drain; rails handles SIGTERM +# and exits promptly. Keep a modest stop timeout. +TimeoutStopSec=30 + +[Install] +WantedBy=default.target diff --git a/install.md b/install.md index 8ad54e54d..a5515443c 100644 --- a/install.md +++ b/install.md @@ -125,6 +125,18 @@ Do not ask the user whether to initialize the daemon. Hive install includes the The bash installer already runs the same command after installing the gem; rerunning it is idempotent when the unit matches. If the command reports a drifted/customized unit, leave it untouched and report the `"$hive_cmd" daemon install --force` recovery command instead of forcing an overwrite. If systemd-user or launchd is unavailable, keep Hive installed and report that daemon autostart could not be enabled on this host. +## Local Web Mode (optional, non-Docker) + +Hive also supports running the web UI locally (Linux/macOS) as a managed service, separate from the daemon. Only surface this when the user asks for the web UI without Docker: + +```bash +"$hive_cmd" setup # one-shot: deps + web bundle + backends + daemon + web + health +"$hive_cmd" web install # write + enable the per-user web unit (separate from hive-daemon) +"$hive_cmd" web start # start the managed web service (binds 127.0.0.1:4567 by default) +``` + +Local web mode binds loopback and requires no auth when `web.github.owner` is unset; binding a non-loopback interface without an owner or `--allow-non-loopback` is refused. Local mode serves the Rails app from a source checkout or `HIVEBOX_WEB_APP_DIR` (the gem does not package `web/`); if neither is present, report that the web tier is only available in the hivebox Docker image or a source checkout. + ## Initialize Project If the current directory is a git project and the user wants Hive enabled here, ask before running: diff --git a/lib/hive.rb b/lib/hive.rb index b22bfe0e3..03443dd5e 100644 --- a/lib/hive.rb +++ b/lib/hive.rb @@ -30,6 +30,10 @@ module Hive "hive-daemon-enroll" => 1, "hive-daemon-reload" => 1, "hive-daemon-install" => 1, + # Explicit binary/version-drift repair (`hive daemon repair --json`): + # re-runs the daemon unit install --force so it points at the CLI's + # binary, reporting the reinstall outcome. + "hive-daemon-repair" => 1, # Read-only inspection of the daemon's dispatch-request queue # (`hive daemon queue [list|show|prune]`). See AN-1/2/3 and # `Hive::Commands::Daemon#queue_command`. @@ -57,6 +61,26 @@ module Hive "hive-bot-stop" => 1, "hive-bot-reload" => 1, "hive-bot-install" => 1, + # Managed local web service lifecycle (`hive web install` / status). + # The web service is SEPARATE from the hive daemon: daemon + web are + # never merged into one unit, so each can be restarted independently. + "hive-web-install" => 1, + "hive-web-status" => 1, + # Managed local web service lifecycle (`hive web start` / `hive web + # stop`). Mirrors the daemon/bot stop envelopes: ok reports whether the + # service manager accepted the request; unit_path is the managed unit. + "hive-web-start" => 1, + "hive-web-stop" => 1, + # Dependency verification for `hive setup` (U3): a row per probed dep + # (ruby/git/tmux/gh/claude/codex/node/npm/qmd/web bundle/sqlite) with a + # precise fix command for anything missing, plus any Hive-owned + # bootstraps attempted (qmd install, web bundle install). + "hive-dependency-check" => 1, + # One-shot full local setup orchestration (`hive setup --json`): the + # overall ok + per-step status arms (backends / dependencies / daemon / + # enroll / web / health), the dependency row set, and the health probe + # result at the configured web origin. + "hive-setup" => 1, # File-backed dispatch request the bot writes for the daemon to # consume. One JSON file per pending request under the state-home # `dispatch_requests/` directory. See @@ -392,6 +416,7 @@ module Hive # 3 task is in :error marker state (a stage agent recorded an error) # 4 wrong stage (`hive run` on an inert 1-inbox folder, etc.) # 64 EX_USAGE — invalid argument: bad slug, malformed task path, … + # 65 EX_DATAERR — a required fix was surfaced (`hive setup` / `hive doctor`) # 70 EX_SOFTWARE — internal error: git failure, worktree failure, agent failure, stage runner error # 75 EX_TEMPFAIL — retryable: lock contention (`ConcurrentRunError`) # 78 EX_CONFIG — bad project / global config @@ -405,6 +430,7 @@ module Hive TASK_IN_ERROR = 3 WRONG_STAGE = 4 USAGE = 64 + FIX_REQUIRED = 65 UNAVAILABLE = 69 SOFTWARE = 70 TEMPFAIL = 75 diff --git a/lib/hive/cli.rb b/lib/hive/cli.rb index cd8090689..fc601ca68 100644 --- a/lib/hive/cli.rb +++ b/lib/hive/cli.rb @@ -44,6 +44,43 @@ module Hive FINDING_SEVERITY_ENUM = %w[high medium low nit].freeze + desc "setup", "One-shot full local setup: deps + web bundle + backends + daemon + web + health" + long_desc <<~DESC + Runs the full local (non-Docker) setup for the Hive web UI, in order: + + 1. Global backends — interactive selection on a TTY; on a non-TTY + (CI / agents / daemon) the registered defaults are persisted + without prompting. Pass --non-interactive to force that. + 2. Dependencies — verifies ruby 3.4, git, tmux, gh, claude, codex, + node/npm/qmd, the web bundle, and sqlite; bootstraps Hive-owned + deps (qmd, web bundle). External agent CLIs (gh/claude/codex) + are probed but NEVER auto-installed or auto-authenticated — the + exact fix command is printed and the run reports fix_required. + 3. Daemon service — ensures the daemon unit points at the SAME + hive binary/version as the CLI (never merged with web). + 4. Enrollment — registers the current repo when run inside an + unregistered git repo (full `.hive-state` bootstrap stays the + explicit `hive init` step; never force-inits or prompts). + 5. Web service — ensures the web unit (separate from the daemon). + 6. Health — probes the configured web origin's /health?deep=1. + + With --json, emits a hive-setup.v1 envelope with per-step status. + Never touches the Docker/hivebox path and never merges daemon+web. + DESC + option :non_interactive, type: :boolean, default: false, + desc: "skip prompts even on a TTY (use default backends)" + option :bind, type: :string, desc: "override web.bind for the health probe" + option :port, type: :numeric, desc: "override web.port for the health probe" + def setup + require "hive/commands/setup" + exit Hive::Commands::Setup.new( + json: options[:json], + non_interactive: options[:non_interactive], + bind: options[:bind], + port: options[:port] + ).call + end + desc "version", "Print hive version" def version puts Hive::VERSION @@ -1037,14 +1074,17 @@ module Hive ).call end - desc "daemon SUBCOMMAND [PROJECT]", "Manage the hive daemon (start / stop / status / reload / tail / install / enable / disable / queue)" + desc "daemon SUBCOMMAND [PROJECT]", "Manage the hive daemon (start / stop / status / reload / tail / install / enable / disable / queue / repair)" long_desc <<~DESC Subcommands: start [--detach] [--dry-run] Run the dispatcher loop. Without --detach, runs in the foreground. stop [--json] Send SIGTERM to the running daemon. --json emits hive-daemon-stop.v1. - status [--json] Show running / not-running. + status [--json] Show running / not-running. --json + reports the running daemon's binary + + version and whether it DRIFTED from + the CLI (U5). reload [--json] Send SIGHUP to reload config. --json emits hive-daemon-reload.v1. tail Stream daemon.log. @@ -1058,6 +1098,14 @@ module Hive the running daemon so new Environment= lines take effect. --json emits hive-daemon-install.v1. + repair [--json] Explicit binary/version-drift repair: + re-runs install --force so the unit + points at the CURRENT CLI binary, + then restart. Drift is reported by + status but only fixed on this + explicit path (or via `hive setup` / + the web repair button). --json emits + hive-daemon-repair.v1. enable PROJECT|--all [--json] Set daemon.enabled: true in /.hive-state/config.yml. --all = every registered project; @@ -1332,18 +1380,46 @@ module Hive ).call end - desc "web", "Run the hivebox web UI" - option :bind, type: :string, desc: "override web.bind" - option :port, type: :numeric, desc: "override web.port" - def web - if options[:json] + desc "web SUBCOMMAND", "Run the hivebox web UI, or manage its service lifecycle" + long_desc <<~DESC + Subcommands: + run Run the web server in the foreground (default). + Execs bin/rails server; binds web.bind:web.port + (default 127.0.0.1:4567). Local loopback bind + requires no auth (single-user local mode); + binding a non-loopback interface without + web.github.owner or --allow-non-loopback is refused. + install [--force] [--json] (Re)write the platform-native unit + (systemd-user / launchd) and enable autostart. + The web service is SEPARATE from hive-daemon. + --json emits hive-web-install.v1. + start [--json] Start the managed web service via the service manager. + --json emits hive-web-start.v1. + stop [--json] Stop the managed web service via the service manager. + --json emits hive-web-stop.v1. + status [--json] Show the managed web service state. --json emits + hive-web-status.v1. + + The web UI is a Rails app in web/ shipped in the hivebox Docker image; + local mode runs it from a source checkout or a HIVEBOX_WEB_APP_DIR. + DESC + option :bind, type: :string, desc: "override web.bind (run only)" + option :port, type: :numeric, desc: "override web.port (run only)" + option :force, type: :boolean, default: false, + desc: "for install: overwrite an existing unit (saves .bak)" + option :allow_non_loopback, type: :boolean, default: false, + desc: "for run: allow a non-loopback bind without web.github.owner (loud warning)" + def web(subcommand = nil) + require "hive/commands/web" + subcommand = (subcommand || "run").to_s + + # Foreground `run` is long-lived and has no JSON. The lifecycle + # subcommands (install/status/stop) ARE JSON-capable and accept + # --json normally. Mirror `hive tui`'s rejection for run only. + if subcommand == "run" && options[:json] require "json" - message = "hive web has no JSON output (it runs a long-lived server). " \ + message = "hive web run has no JSON output (it runs a long-lived server). " \ "Use 'hive status --json' for machine-readable task data." - # Mirror `hive tui`'s rejection: emit a structured error envelope (sans - # `schema`, since web has no registered hive-* schema) and raise - # InvalidTaskPath for the USAGE (64) exit code — parity with every - # other --json failure on this surface. puts JSON.generate( "ok" => false, "error_class" => "InvalidTaskPath", @@ -1354,8 +1430,14 @@ module Hive raise Hive::InvalidTaskPath, message end - require "hive/commands/web" - Hive::Commands::Web.new(bind: options[:bind], port: options[:port]).call + Hive::Commands::Web.new( + subcommand, + bind: options[:bind], + port: options[:port], + json: options[:json], + force: options[:force], + allow_non_loopback: options[:allow_non_loopback] + ).call end desc "tui", "Open the live, keystroke-driven dashboard for every active task" diff --git a/lib/hive/commands/daemon.rb b/lib/hive/commands/daemon.rb index 6c91610b1..e71f71543 100644 --- a/lib/hive/commands/daemon.rb +++ b/lib/hive/commands/daemon.rb @@ -19,6 +19,7 @@ require "hive/daemon/logger" require "hive/daemon/dispatch_request_queue" require "hive/invoked_binary" require "hive/update_check/state" +require "hive/daemon/drift" module Hive module Commands @@ -37,7 +38,7 @@ module Hive include Hive::Schemas::EnvelopeEmitter include Hive::PidFile - VALID_SUBCOMMANDS = %w[start stop status reload tail enable disable install queue].freeze + VALID_SUBCOMMANDS = %w[start stop status reload tail enable disable install queue repair].freeze # Actions for `hive daemon queue ACTION` (AN-1/2/3). `list` is the # default when no action is given. @@ -86,6 +87,7 @@ module Hive when "reload" then reload_daemon when "tail" then tail_daemon when "install" then install_daemon + when "repair" then repair_daemon when "queue" then queue_command when "enable", "disable" then call_with_envelope { do_call } end @@ -371,6 +373,7 @@ module Hive if @json service_state = probe_service_state + drift = running ? drift_payload(pid) : nil puts JSON.generate( "schema" => "hive-daemon-status", "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-daemon-status"), @@ -386,7 +389,14 @@ module Hive # Agent-native parity with the TUI footer / bot push: expose the # update nudge so a programmatic caller can detect "behind" too. "current_version" => Hive::VERSION, - "update_nudge" => update_nudge_payload + "update_nudge" => update_nudge_payload, + # U5 binary/version consistency: report the RUNNING daemon's + # binary + version vs the CLI, plus drift. Null when not running; + # status "unverified" when the live binary can't be resolved. + "daemon_binary" => drift && drift["binary"], + "daemon_version" => drift && drift["version"], + "drift_status" => drift && drift["status"], + "drifted" => drift && drift["drifted"] ) elsif running puts "hive daemon: running (pid #{pid}, uptime #{uptime_sec}s)" @@ -420,6 +430,54 @@ module Hive nil end + # U5: resolve the RUNNING daemon's binary/version vs the CLI. Never + # raises out of status; any failure degrades to the unverified shape. + def drift_payload(pid) + @drift_payload ||= Hive::Daemon::Drift.new(pid: pid).resolve + rescue StandardError + { "binary" => nil, "version" => nil, "status" => "unverified", "drifted" => nil } + end + + # `hive daemon repair` — explicit binary/version-drift repair. When the + # running daemon's binary/version differs from the CLI (or it can't be + # verified), re-run `install --force` with autostart, which rewrites + # the unit to point at the CLI's binary and restarts the running + # service. Drift is deliberately NOT silently auto-fixed elsewhere; + # this is the explicit path invoked by `hive setup` and the web repair + # button. + def repair_daemon + require "hive/commands/daemon/service_installer" + # Re-write the unit pointing at the CURRENT CLI binary and restart. + # `autostart: true` (mirroring `hive daemon install`) makes the + # installer restart the running service on the force-upgrade path, so + # a drifted daemon actually starts running the matching binary instead + # of keeping the stale one alive. + installer = Hive::Commands::Daemon::ServiceInstaller.new(binary_path: current_binary_path) + outcome = installer.install!(autostart: true, force: true) + unless outcome.success? + message = "hive daemon: repair failed — reinstall reported #{outcome.wire_outcome}" + raise Hive::DaemonInstallFailed, message + end + if @json + puts JSON.generate( + "schema" => "hive-daemon-repair", + "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-daemon-repair"), + "ok" => true, + "outcome" => outcome.wire_outcome, + "target_path" => installer.target_path, + "restarted" => outcome.restarted + ) + else + installer.messages.each { |line| warn "hive: #{line}" } + puts "hive daemon: repair requested — unit rewritten at #{installer.target_path}; " \ + "daemon restart triggered" + end + rescue Hive::DaemonInstallFailed + raise + rescue StandardError => e + raise Hive::DaemonInstallFailed, "hive daemon: repair failed: #{e.class}: #{e.message}" + end + def reload_daemon result = compute_reload_outcome if @json diff --git a/lib/hive/commands/doctor.rb b/lib/hive/commands/doctor.rb index 99afbc08b..c23401df3 100644 --- a/lib/hive/commands/doctor.rb +++ b/lib/hive/commands/doctor.rb @@ -33,9 +33,9 @@ module Hive # (the daemon, an outer orchestrator) can decide how to react # without scraping the human-readable table. class Doctor - EXIT_SUCCESS = 0 - EXIT_MISSING_SKILL = 65 - EXIT_CONFIG_ERROR = 78 + EXIT_SUCCESS = Hive::ExitCodes::SUCCESS + EXIT_MISSING_SKILL = Hive::ExitCodes::FIX_REQUIRED + EXIT_CONFIG_ERROR = Hive::ExitCodes::CONFIG STAGES = %w[brainstorm plan].freeze diff --git a/lib/hive/commands/setup.rb b/lib/hive/commands/setup.rb new file mode 100644 index 000000000..3265517e9 --- /dev/null +++ b/lib/hive/commands/setup.rb @@ -0,0 +1,317 @@ +require "json" +require "net/http" +require "uri" +require "hive/config" +require "hive/invoked_binary" +require "hive/commands/setup/backend_prompt" +require "hive/commands/setup/dependency_check" + +module Hive + module Commands + # `hive setup` — one-shot full local (non-Docker) setup for the Hive + # web UI. Orchestrates, in order: + # + # 1. (interactive, TTY only) global backend selection via + # Setup::BackendPrompt → persisted via Config.write_global_agents!. + # 2. Dependency verification + Hive-owned bootstrap (U3). + # 3. Daemon service ensure — the daemon unit is (re)written pointing + # at the SAME hive binary/version as the CLI (U5). + # 4. Project enrollment — if run inside a git repo that isn't yet + # registered, register it (never force-init / prompt). Full + # `.hive-state` bootstrap stays the explicit `hive init` step. + # 5. Web service ensure (U1) — autostart enabled (a foreground run is + # the manual alternative). + # 6. Health check at http://127.0.0.1:/health?deep=1 (only when + # the web service is reachable). + # + # Each step carries `{name, status, detail}` into a hive-setup.v1 JSON + # envelope (--json) or a human summary. All collaborators are injectable + # so the flow is unit-testable without a real daemon / web service / + # health probe. + # + # `hive setup` never silently installs / authenticates external agent + # CLIs (gh/claude/codex) — the dependency step reports exact fix + # commands, and a missing/failing external dep puts the run in the + # "fix required" return bucket without aborting the Hive-owned steps. + class Setup + EXIT_OK = Hive::ExitCodes::SUCCESS + EXIT_FIX_REQUIRED = Hive::ExitCodes::FIX_REQUIRED + + # Inject a fake health probe for tests; the default is a real HTTP GET + # to the configured web origin accepting loopback. + def initialize(json: false, non_interactive: false, bind: nil, port: nil, + output: $stdout, input: $stdin, + backend_prompt: nil, dependency_check: nil, + daemon_installer: nil, web_installer: nil, health: nil, + app_dir: nil, data_home: nil, + runner: nil) + @json = json + @non_interactive = non_interactive || !input.respond_to?(:tty?) || !input.tty? + @bind = bind + @port = port + @output = output + @input = input + @backend_prompt = backend_prompt + @dependency_check = dependency_check + @daemon_installer = daemon_installer + @web_installer = web_installer + @health = health + @app_dir = app_dir + @data_home = data_home + @runner = runner || ->(argv) { system(*argv, out: File::NULL) } + @steps = [] + @ok = true + @dep_results = nil + @backend_selection = nil + @web_health = nil + end + + def call + run_backend_selection + run_dependency_check + ensure_daemon + enroll_project + ensure_web + run_health + + if @json + @output.puts JSON.generate(envelope) + else + render_summary + end + + @ok ? EXIT_OK : EXIT_FIX_REQUIRED + end + + private + + def run_backend_selection + begin + prompt = @backend_prompt + unless prompt + prompt = BackendPrompt.new(input: @input, output: $stderr, summary_io: @output) + end + @backend_selection = @non_interactive ? default_backends : prompt.collect + step("backends", "ok", "global backends: #{@backend_selection.join(', ')}") + Hive::Config.write_global_agents!(@backend_selection) + rescue BackendPrompt::Aborted => e + @ok = false + step("backends", "aborted", e.message) + rescue ArgumentError, Hive::ConfigError => e + @ok = false + step("backends", "failed", e.message) + end + end + + # Non-interactive default selection without a live registry dependency. + def default_backends + Hive::Config.default_global_agents + end + + def run_dependency_check + check = @dependency_check + unless check + check = DependencyCheck.new( + json: false, + # In --json mode the per-step info rides the setup envelope; keep + # stdout pure JSON by sending the dependency table to stderr + # (never stdout). + output: @json ? $stderr : @output, + app_dir: @app_dir || rails_app_dir, data_home: @data_home, + bootstrap: true + ) + end + check.call + @dep_results = { rows: check.rows, bootstrap: check.bootstrap_actions, + ok: check.rows.none? { |r| failing_row?(r) } } + step("dependencies", @dep_results[:ok] ? "ok" : "fix_required", + "#{check.rows.count { |r| failing_row?(r) }} failing") + @ok &&= @dep_results[:ok] + end + + def failing_row?(row) + %w[missing version_too_old auth_missing bundle_failed broken].include?(row[:status]) + end + + def ensure_daemon + installer = @daemon_installer + unless installer + require "hive/commands/daemon/service_installer" + installer = Hive::Commands::Daemon::ServiceInstaller.new( + binary_path: Hive::InvokedBinary.path + ) + end + outcome = installer.install!(autostart: true, force: false) + # Drift without --force is reported, not auto-overwritten. + if outcome.success? + step("daemon", "ok", "daemon unit #{outcome.wire_outcome} at #{installer.target_path}") + elsif outcome.drifted? + @ok = false + step("daemon", "drifted", + "daemon unit differs from template — run `hive daemon install --force` (backup saved)") + else + @ok = false + step("daemon", "failed", "daemon service install reported #{outcome.wire_outcome}") + end + rescue StandardError => e + @ok = false + step("daemon", "failed", "#{e.class}: #{e.message}") + end + + def enroll_project + root = current_repo_root + unless root + step("enroll", "skipped", "not inside a git repo; enrollment skipped") + return + end + registered = Hive::Config.registered_projects.any? { |p| File.expand_path(p["path"]) == root } + if registered + step("enroll", "ok", "current repo already registered") + else + # Enroll (register) the current repo now, never a bare hint: the + # one-shot setup contract requires the repo to be enrolled on exit, + # not merely told how. Full `.hive-state` bootstrap (worktree + + # stage scaffolding) stays the explicit `hive init` step — setup + # only registers so status/TUI/web and the daemon see the repo + # immediately, without force-initing or prompting. + Hive::Config.register_project(name: File.basename(root), path: root) + step("enroll", "ok", + "registered #{File.basename(root)} at #{root} (run `hive init #{root}` for full bootstrap)") + end + rescue Hive::ConfigError => e + @ok = false + step("enroll", "failed", e.message) + end + + def ensure_web + installer = @web_installer + unless installer + require "hive/commands/web/service_installer" + installer = Hive::Commands::Web::ServiceInstaller.new( + binary_path: Hive::InvokedBinary.path + ) + end + outcome = installer.install!(autostart: true, force: false) + if outcome.success? + step("web", "ok", "web unit #{outcome.wire_outcome} at #{installer.target_path}") + elsif outcome.drifted? + @ok = false + step("web", "drifted", + "web unit differs from template — run `hive web install --force` (backup saved)") + else + @ok = false + step("web", "failed", "web service install reported #{outcome.wire_outcome}") + end + step("web", "hint", "autostart enabled; for a foreground server, run `hive web run`") + rescue StandardError => e + @ok = false + step("web", "failed", "#{e.class}: #{e.message}") + end + + def run_health + origin = web_origin + return step("health", "skipped", "no web origin") unless origin + + health = @health + unless health + health = lambda do |url| + resp = Net::HTTP.get_response(URI(url)) + body = JSON.parse(resp.body) rescue {} + { ok: resp.is_a?(Net::HTTPSuccess), status: resp.code.to_i, body: body } + rescue StandardError + { ok: false, status: nil, body: {} } + end + end + + @web_health = health.call(origin) + # The daemon health probe returns 503 when the daemon is down; that + # is an informational (still constructed) state, not a setup failure. + step("health", @web_health[:ok] ? "ok" : "warning", + "GET #{origin} → #{@web_health[:status] || 'unreachable'}") + end + + def web_origin + cfg = Hive::Config.load_global_web + bind = @bind || cfg.fetch("bind") + port = (@port || cfg.fetch("port")).to_i + scheme = "http" + # IPv6 loopback (`::1`) must be bracketed in a URL; a bare `::1` + # would parse as a bogus host:port split (`http://::1:4567`). + host = bind.to_s.include?(":") ? "[#{bind}]" : bind.to_s + "#{scheme}://#{host}:#{port}/health?deep=1" + rescue StandardError + nil + end + + def rails_app_dir + candidates = [ + ENV["HIVEBOX_WEB_APP_DIR"], + File.expand_path("../../../web", __dir__) + ].compact + candidates.find { |dir| File.file?(File.join(dir, "config", "application.rb")) } + end + + def current_repo_root + dir = Dir.pwd + loop do + # A git worktree keeps `.git` as a FILE (gitdir pointer), not a + # directory — accept either so `hive setup` enrolls inside worktrees + # (where hive tasks actually run) as well as the main checkout. + return dir if File.directory?(File.join(dir, ".git")) || File.exist?(File.join(dir, ".git")) + parent = File.dirname(dir) + return nil if parent == dir + dir = parent + end + end + + def step(name, status, detail) + @steps << { "name" => name, "status" => status, "detail" => detail } + end + + def envelope + { + "schema" => "hive-setup", + "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-setup"), + "ok" => @ok, + "exit_code" => @ok ? EXIT_OK : EXIT_FIX_REQUIRED, + "backend_selection" => @backend_selection, + "steps" => @steps, + "dependencies" => dependency_wire, + "health" => @web_health + } + end + + def dependency_wire + return nil unless @dep_results + + { + "ok" => @dep_results[:ok], + "rows" => @dep_results[:rows].map { |r| dependency_row_wire(r) }, + "bootstrap" => @dep_results[:bootstrap] + } + end + + def dependency_row_wire(row) + { + "name" => row[:name], + "status" => row[:status], + "detail" => row[:detail], + "fix_command" => row[:fix_command], + "hive_owned" => row[:hive_owned] + } + end + + def render_summary + @output.puts "hive setup:" + @steps.each { |s| @output.puts " #{s['status'].ljust(12)} #{s['name']}: #{s['detail']}" } + verdict = + if @ok + "complete (web and daemon services installed and started)" + else + "fix required — see steps above (external agent CLIs are never auto-installed or auto-authenticated)" + end + @output.puts "hive setup: #{verdict}" + end + end + end +end diff --git a/lib/hive/commands/setup/dependency_check.rb b/lib/hive/commands/setup/dependency_check.rb new file mode 100644 index 000000000..1398cd15c --- /dev/null +++ b/lib/hive/commands/setup/dependency_check.rb @@ -0,0 +1,324 @@ +require "json" +require "open3" +require "hive/config" + +module Hive + module Commands + class Setup + # U3 dependency verification + Hive-owned bootstrap for `hive setup`. + # + # Probes the deps `hive setup` needs (Ruby 3.4, git, tmux, gh, claude, + # codex, Node/npm/qmd, the Rails web bundle, SQLite), renders a row + # table (or a `hive-dependency-check` JSON envelope with --json), and + # emits an EXACT fix command for whatever is missing — WITHOUT silently + # installing or authenticating anything. + # + # Two classes of dep: + # - Hive-owned (qmd, the web bundle): `hive setup` MAY bootstrap these + # (gated on the bootstrap: flag; qmd repair only when npm is + # present, bundle install only for a real app dir). + # - External agent CLIs (gh / claude / codex) + base tools (git/tmux/ + # sqlite/node/ruby): probed only. Never auto-installed or + # authenticated — the fix command is printed for the operator. + # + # All collaborators (runner / which) are injectable so unit tests never + # spawn a real external CLI. + class DependencyCheck + EXIT_OK = Hive::ExitCodes::SUCCESS + # Missing / too-old / unauthenticated → named non-zero so `hive setup` + # (and agents) can branch on "fix required" without parsing prose. + EXIT_FIX_REQUIRED = Hive::ExitCodes::FIX_REQUIRED + + # Ruby >= 3.4 is the minimum supported. + MIN_RUBY = "3.4".freeze + + def initialize(json: false, output: $stdout, + runner: nil, qmd_runner: nil, which: nil, + app_dir: nil, data_home: nil, bootstrap: false) + @json = json + @output = output + @runner = runner || default_runner + @qmd_runner = qmd_runner || @runner + @which = which || method(:which_lookup) + @app_dir = app_dir + @data_home = data_home || Hive::Paths.data_home + @bootstrap = bootstrap + @rows = nil + @bootstrap_actions = [] + end + + # Exposes the probed rows after #call. Lets the Setup orchestrator + # inspect bootstrap decisions without re-running probes. + attr_reader :rows, :bootstrap_actions + + def call + @rows = [ + check_ruby, + check_git, + check_tmux, + check_gh, + check_claude, + check_codex, + check_node, + check_npm, + check_qmd, + check_bundle, + check_sqlite + ].flatten.compact + + bootstrap_hive_owned! if @bootstrap + + if @json + @output.puts JSON.generate(envelope) + else + render_table + end + + @rows.any? { |r| failing_row?(r) } ? EXIT_FIX_REQUIRED : EXIT_OK + end + + private + + def default_runner + lambda do |argv| + out, err, status = Open3.capture3(*argv) + { success: status.success?, out: out, err: err, status: status.exitstatus } + end + end + + def which_lookup(name) + ENV["PATH"].to_s.split(File::PATH_SEPARATOR).each do |dir| + path = File.join(dir, name) + return path if File.file?(path) && File.executable?(path) + end + nil + end + + def failing_row?(row) + %w[missing version_too_old auth_missing bundle_failed broken].include?(row[:status]) + end + + def row(name, status, detail, fix: nil, hive_owned: false) + { name: name, status: status, detail: detail, fix_command: fix, hive_owned: hive_owned } + end + + def check_ruby + ruby = @which.call("ruby") + return row("ruby", "missing", "ruby not on PATH", fix: "install Ruby 3.4+ (see install.md)") unless ruby + + version = parse_cli_version(@runner.call([ "ruby", "--version" ])[:out]) + ok = version && version.split(".").first.to_i >= 3 && version.split(".")[1].to_i >= 4 + return row("ruby", "version_too_old", "found #{version || 'unknown'} at #{ruby} (need >= 3.4)", + fix: "install Ruby 3.4+ (see install.md)") unless ok + + row("ruby", "present", "ruby #{version} at #{ruby}") + end + + def check_git + git = @which.call("git") + return row("git", "missing", "git not on PATH", fix: "install git (see install.md)") unless git + + row("git", "present", "git at #{git}") + end + + def check_tmux + tmux = @which.call("tmux") + return row("tmux", "missing", "tmux not on PATH", fix: "install tmux (see install.md)") unless tmux + + row("tmux", "present", "tmux at #{tmux}") + end + + def check_gh + gh = @which.call("gh") + return row("gh", "missing", "gh CLI not on PATH", + fix: "brew install gh / install from https://cli.github.com") unless gh + + probe = @runner.call(%w[gh auth status]) + return row("gh", "present", "gh at #{gh}") if probe[:success] + + detail = "gh installed but not authenticated" + detail += " (exit #{probe[:status]})" unless probe[:status].nil? + row("gh", "auth_missing", detail, fix: "gh auth login") + end + + def check_claude + claude = @which.call("claude") + return row("claude", "missing", "claude CLI not on PATH", + fix: "install Claude Code (see install.md); then `claude setup-token`") unless claude + + probe = @runner.call(%W[#{claude} --version]) + return row("claude", "present", "claude at #{claude}") if probe[:success] + + # `--version` is NOT an auth probe — a failing --version means the + # CLI is broken/not runnable (bad install, missing deps, a + # network-failing shim), not an unauthenticated account. Report it + # as a broken install rather than auth_missing with a misleading + # auth fix command. + row("claude", "broken", "claude at #{claude} failed to run (--version)", + fix: "reinstall Claude Code (see install.md)") + end + + def check_codex + codex = @which.call("codex") + return row("codex", "missing", "codex CLI not on PATH", + fix: "install OpenAI Codex; then `codex login --device-auth`") unless codex + + probe = @runner.call(%W[#{codex} --version]) + return row("codex", "present", "codex at #{codex}") if probe[:success] + + # Same as check_claude: `--version` is not an auth check. + row("codex", "broken", "codex at #{codex} failed to run (--version)", + fix: "reinstall OpenAI Codex (see install.md)") + end + + def check_node + node = @which.call("node") + return row("node", "missing", "node not on PATH", + fix: "install Node.js LTS (see install.md); qmd needs it") unless node + + row("node", "present", "node at #{node}") + end + + def check_npm + npm = @which.call("npm") + return row("npm", "missing", "npm not on PATH", + fix: "install Node.js LTS (see install.md)") unless npm + + row("npm", "present", "npm at #{npm}") + end + + def check_qmd + probe = qmd_probe + return probe if probe + + row("qmd", "missing", "qmd is not installed or not discoverable", + fix: qmd_install_command, hive_owned: true) + end + + def qmd_probe + env_qmd = ENV["HIVE_QMD_BIN"].to_s + qmd = (!env_qmd.empty? && File.executable?(env_qmd) && env_qmd) || @which.call("qmd") + return nil unless qmd + + probe = @qmd_runner.call([ qmd, "--version" ]) + return row("qmd", "present", "qmd #{probe[:out].strip} at #{qmd}") if probe[:success] + + row("qmd", "missing", "qmd at #{qmd} failed to run", + fix: qmd_install_command, hive_owned: true) + end + + def qmd_install_command + %(npm install --global --prefix "#{File.join(@data_home, 'qmd')}" @tobilu/qmd) + end + + # The Rails web bundle is only probe-able (and only bootstrappable) + # for a real app dir — the "local web only from a source checkout / + # HIVEBOX_WEB_APP_DIR" posture (Open Question 1: the gem does not + # package web/). + def check_bundle + return row("web bundle", "not_applicable", "no Rails app dir (web/ or HIVEBOX_WEB_APP_DIR); local web serves only from a checkout", + fix: nil, hive_owned: true) unless @app_dir && File.file?(File.join(@app_dir, "Gemfile")) + + probe = @runner.call(%W[bundle check --gemfile=#{File.join(@app_dir, "Gemfile")}]) + return row("web bundle", "present", "bundle OK in #{@app_dir}") if probe[:success] + + row("web bundle", "bundle_failed", "web bundle not installed in #{@app_dir}", + fix: "cd #{@app_dir} && bundle install", hive_owned: true) + end + + def check_sqlite + sqlite = @which.call("sqlite3") + return row("sqlite", "missing", "sqlite3 not on PATH", + fix: "install sqlite3 (see install.md)") unless sqlite + + row("sqlite", "present", "sqlite3 at #{sqlite}") + end + + # ── Bootstrap (Hive-owned deps only) ─────────────────────────────── + def bootstrap_hive_owned! + bootstrap_qmd! + bootstrap_bundle! + end + + def bootstrap_qmd! + qmd_row = @rows.find { |r| r[:name] == "qmd" } + return unless qmd_row && qmd_row[:status] == "missing" + + npm = @which.call("npm") + unless npm + @bootstrap_actions << { name: "qmd", action: "skip", status: "skipped", + detail: "npm missing; run #{qmd_row[:fix_command]} manually" } + return + end + + probe = @runner.call(%W[npm install --global --prefix #{File.join(@data_home, "qmd")} @tobilu/qmd]) + if probe[:success] + @bootstrap_actions << { name: "qmd", action: "install", status: "installed", + detail: "installed qmd via npm" } + @rows.map! { |r| r[:name] == "qmd" ? row("qmd", "present", "qmd installed (bootstrap)", hive_owned: true) : r } + else + @bootstrap_actions << { name: "qmd", action: "install", status: "failed", + detail: "npm install failed; run #{qmd_row[:fix_command]} manually" } + @rows.map! { |r| r[:name] == "qmd" ? r.merge(detail: "qmd install failed; run fix command manually") : r } + end + end + + def bootstrap_bundle! + bundle_row = @rows.find { |r| r[:name] == "web bundle" } + return unless bundle_row && bundle_row[:status] == "bundle_failed" + + probe = @runner.call(%W[bundle install --gemfile=#{File.join(@app_dir, "Gemfile")}]) + if probe[:success] + @bootstrap_actions << { name: "web bundle", action: "install", status: "installed", + detail: "bundle installed in #{@app_dir}" } + @rows.map! { |r| r[:name] == "web bundle" ? row("web bundle", "present", "web bundle installed (bootstrap)", hive_owned: true) : r } + else + @bootstrap_actions << { name: "web bundle", action: "install", status: "failed", + detail: "bundle install failed; run `cd #{@app_dir} && bundle install` manually" } + end + end + + def parse_cli_version(out) + m = out.to_s.match(/(\d+)\.(\d+)\.(\d+)/) + m && "#{m[1]}.#{m[2]}.#{m[3]}" + end + + def envelope + { + "schema" => "hive-dependency-check", + "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-dependency-check"), + "ok" => @rows.all? { |r| !failing_row?(r) }, + "rows" => @rows.map { |r| row_wire(r) }, + "bootstrap" => @bootstrap_actions + } + end + + def row_wire(row) + { + "name" => row[:name], + "status" => row[:status], + "detail" => row[:detail], + "fix_command" => row[:fix_command], + "hive_owned" => row[:hive_owned] + } + end + + def render_table + @output.puts "hive setup: dependency check" + @rows.each do |r| + mark = %w[present not_applicable].include?(r[:status]) ? "ok" : r[:status] + line = " #{mark.ljust(10)} #{r[:name]}" + line += " — #{r[:detail]}" if r[:detail] + @output.puts line + if r[:fix_command] + @output.puts " fix: #{r[:fix_command]}" + end + end + @bootstrap_actions.each do |b| + @output.puts " bootstrap #{b[:action].ljust(8)} #{b[:name]} (#{b[:status]}): #{b[:detail]}" + end + end + end + end + end +end diff --git a/lib/hive/commands/web.rb b/lib/hive/commands/web.rb index eb3cd40fc..32b80028f 100644 --- a/lib/hive/commands/web.rb +++ b/lib/hive/commands/web.rb @@ -1,4 +1,8 @@ +require "fileutils" +require "json" require "hive/config" +require "hive/invoked_binary" +require "hive/web/auth_policy" require "hive/web/session_secret" module Hive @@ -7,14 +11,84 @@ module Hive # (shipped in the Docker image at /app/web). hive itself stays a plain # CLI gem; the web tier is only supported where the Rails app and its # bundle exist: the hivebox container or a source checkout. + # + # Lifecycle subcommands: + # (none | run) Run the server in the FOREGROUND (execs bin/rails). + # install [--force] (Re)write the platform-native unit (systemd-user / + # launchd) and enable autostart. The web service is + # deliberately separate from `hive-daemon`. + # start Start the managed web service via the service manager. + # stop Stop the managed web service via the service manager. + # status [--json] Show the managed web service state. class Web - def initialize(bind: nil, port: nil) + VALID_SUBCOMMANDS = %w[run start stop status install].freeze + + def initialize(subcommand = nil, bind: nil, port: nil, json: false, force: false, + allow_non_loopback: false, runner: nil, + host_os: nil, home: nil, systemctl_available: nil) + @subcommand = (subcommand || "run").to_s @bind = bind @port = port + @json = json + @force = force + @allow_non_loopback = allow_non_loopback + @runner = runner || ->(argv) { system(*argv, out: File::NULL) } + @host_os = host_os + @home = home + @systemctl_available = systemctl_available end def call - cfg = Hive::Config.load_global_web + unless VALID_SUBCOMMANDS.include?(@subcommand) + raise Hive::InvalidTaskPath, + "hive web: unknown subcommand #{@subcommand.inspect} " \ + "(expected: #{VALID_SUBCOMMANDS.join(', ')})" + end + + case @subcommand + when "run" then run_foreground + when "install" then install_web + when "start" then start_service + when "stop" then stop_service + when "status" then status_web + end + end + + private + + def cfg + @cfg ||= Hive::Config.load_global_web + end + + # Loopback no-auth / non-loopback refusal predicate, shared with the + # Rails controller (ApplicationController#require_login) so the CLI + # can never green-light a bind the app will 403. Returns true when + # this bind MUST be refused. + # + # Semantics (fail-closed by default): + # - loopback bind (127.0.0.1, ::1, localhost) ⇒ never refused — + # with web.github.owner unset this is single-user local mode + # (no auth); with an owner set the app keeps the owner gate. + # - non-loopback bind (0.0.0.0, LAN IP) with an explicit + # --allow-non-loopback/--unsafe flag ⇒ allowed with a loud + # warning (operator takes responsibility for the boundary). + # - non-loopback bind with no field AND no web.github.owner ⇒ + # refuse (fail-closed). + def refused_bind?(bind:) + return false if Hive::Web::AuthPolicy.loopback_bind?(bind) + return false if @allow_non_loopback + + Hive::Web::AuthPolicy.owner_unset?(cfg) + end + + def run_foreground + if @json + message = "hive web run has no JSON output (it runs a long-lived server). " \ + "Use 'hive status --json' for machine-readable task data." + warn message + exit Hive::ExitCodes::USAGE + end + bind = @bind || cfg.fetch("bind") port = (@port || cfg.fetch("port")).to_i app_dir = rails_app_dir @@ -25,7 +99,14 @@ module Hive exit 1 end - warn_on_public_bind(bind, cfg) + if refused_bind?(bind: bind) + warn "hive web: refusing to bind #{bind} without web.github.owner set. " \ + "Local single-user mode requires a loopback bind (default 127.0.0.1). " \ + "To serve on a non-loopback interface you must either set web.github.owner " \ + "in ~/.config/hive/config.yml (which re-enables the GitHub owner gate) or " \ + "pass --allow-non-loopback to take responsibility for auth at the boundary." + exit 1 + end env = { "RAILS_ENV" => ENV.fetch("RAILS_ENV", "production"), @@ -42,6 +123,13 @@ module Hive File.join(Hive::Paths.state_home, "web-storage"), "BUNDLE_GEMFILE" => File.join(app_dir, "Gemfile") } + # Forward the CLI-resolved hive binary so the Rails-side daemon repair + # endpoint can re-exec `hive daemon repair`. Inside the Rails process + # `$PROGRAM_NAME` is `bin/rails`, so Hive::InvokedBinary.path returns + # nil there and only this env var can supply the binary (the systemd + # unit bakes the same value into Environment=HIVE_BIN=). + hive_bin = Hive::InvokedBinary.path + env["HIVE_BIN"] = hive_bin if hive_bin FileUtils.mkdir_p(env.fetch("HIVEBOX_STORAGE_DIR")) Dir.chdir(app_dir) do @@ -62,7 +150,263 @@ module Hive end end - private + # Managed-service start: delegate to the service manager (systemd-user + # on Linux, launchd on macOS). This is the autostart/unit path; a raw + # `hive web run` is the manual foreground path. + def start_service + installer = build_installer + ok = + case installer.send(:platform) + when :macos then @runner.call([ "launchctl", "kickstart", "-k", "gui/#{Process.uid}/#{installer.launchd_label}" ]) + when :linux then @runner.call(%w[systemctl --user start hive-web]) + else false + end + + if @json + puts JSON.generate( + "schema" => "hive-web-start", + "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-start"), + "ok" => ok, + "unit_path" => installer.target_path + ) + return + end + + unless ok + warn "hive web: failed to start the managed web service via the service manager; " \ + "run `hive web run` in the foreground instead." + exit Hive::ExitCodes::SOFTWARE + end + puts "hive web: start requested (see `hive web status`)" + end + + def stop_service + installer = build_installer + ok = + case installer.send(:platform) + when :macos + @runner.call([ "launchctl", "bootout", "gui/#{Process.uid}/#{installer.launchd_label}" ]) || + @runner.call([ "launchctl", "unload", installer.target_path ]) + when :linux + @runner.call(%w[systemctl --user stop hive-web]) + end + + if @json + puts JSON.generate( + "schema" => "hive-web-stop", + "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-stop"), + "ok" => !!ok, + "unit_path" => installer.target_path + ) + return + end + + unless ok + warn "hive web: managed web service is not supported on this platform; " \ + "run `hive web run` in the foreground instead." + exit Hive::ExitCodes::SOFTWARE + end + + puts "hive web: stop requested" + end + + def status_web + installer = build_installer + state = begin + installer.service_state + rescue StandardError + { "service_installed" => nil, "service_enabled" => nil, "unit_path" => nil } + end + running = + case installer.send(:platform) + when :macos then @runner.call([ "launchctl", "list", installer.launchd_label ]) + when :linux then @runner.call(%w[systemctl --user is-active hive-web]) + else false + end + + if @json + puts JSON.generate( + "schema" => "hive-web-status", + "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-status"), + "ok" => true, + "running" => running, + "service_installed" => state["service_installed"], + "service_enabled" => state["service_enabled"], + "unit_path" => state["unit_path"], + "current_version" => Hive::VERSION + ) + else + puts "hive web: running: #{running ? 'yes' : 'no'}; " \ + "installed: #{state["service_installed"] ? 'yes' : 'no'}; " \ + "enabled: #{state["service_enabled"] ? 'yes' : 'no'} " \ + "(unit: #{state["unit_path"]})" + end + raise Hive::Error, "web service not running" if !running && !@json + end + + def install_web + installer = build_installer + begin + result = installer.install!(autostart: true, force: @force) + rescue Hive::Error + raise + rescue StandardError => e + install_emit_exception_envelope(installer, e) if @json + raise WebInstallFailed, "web service install failed: #{e.class}: #{e.message}" + end + unless @json + installer.messages.each { |line| warn "hive: #{line}" } + emit_install_success_summary(installer, result) + end + emit_install_outcome(installer, result) + end + + def emit_install_success_summary(installer, outcome) + return if @json + + case outcome.kind + when :written + puts "hive web: installed unit at #{installer.target_path}" + when :upgraded + msg = "hive web: upgraded unit at #{installer.target_path}" + msg += " (backup: #{outcome.backup_path})" if outcome.backup_path + puts msg + when :unchanged + puts "hive web: unit already up to date at #{installer.target_path}" + when :autostart_unavailable + puts "hive web: unit written at #{installer.target_path}; autostart not enabled on this host" + when :unsupported, :drifted, :failed + # :unsupported is messaged via installer.messages. + # :drifted / :failed are handled by emit_install_outcome. + end + end + + def emit_install_outcome(installer, outcome) + if @json + if outcome.success? + puts JSON.generate(install_envelope(installer, outcome)) + else + install_emit_error_envelope(installer, outcome: outcome.wire_outcome) + end + end + + if outcome.drifted? + msg = "web unit at #{installer.target_path} differs from the current template. " \ + "Re-run with `hive web install --force` to overwrite (a timestamped .bak " \ + "will be saved)." + raise WebInstallDriftError, msg + elsif outcome.failed? + raise WebInstallFailed, "web service install reported a failure; see messages above" + end + end + + def install_envelope(installer, outcome) + { + "schema" => "hive-web-install", + "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-install"), + "ok" => true, + "outcome" => outcome.wire_outcome, + "platform" => installer.envelope_platform, + "target_path" => installer.target_path, + "backup_path" => outcome.backup_path, + "restarted" => outcome.restarted, + "messages" => installer.messages.dup + } + end + + def install_emit_error_envelope(installer, outcome:) + error_class = outcome == "drifted" ? "WebInstallDriftError" : "WebInstallFailed" + exit_code = outcome == "drifted" ? Hive::ExitCodes::USAGE : Hive::ExitCodes::SOFTWARE + message = + if outcome == "drifted" + "web unit at #{installer.target_path} differs from the current template; retry with --force." + else + "web service install reported a failure; see messages" + end + puts JSON.generate( + "schema" => "hive-web-install", + "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-install"), + "ok" => false, + "error_class" => error_class, + "error_kind" => outcome, + "exit_code" => exit_code, + "message" => message, + "outcome" => outcome, + "platform" => installer.envelope_platform, + "target_path" => installer.target_path, + "messages" => installer.messages.dup + ) + end + + def install_emit_exception_envelope(installer, error) + puts JSON.generate( + "schema" => "hive-web-install", + "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-install"), + "ok" => false, + "error_class" => "WebInstallFailed", + "error_kind" => "failed", + "exit_code" => Hive::ExitCodes::SOFTWARE, + "message" => "web service install failed: #{error.class}: #{error.message}", + "outcome" => "failed", + "platform" => safe_install_platform(installer), + "target_path" => safe_install_target_path(installer), + "messages" => safe_install_messages(installer) + ) + end + + def current_binary_path + Hive::InvokedBinary.path + end + + # Build the service installer with injectable test seams (host_os / + # home / systemctl_available / runner) so unit tests are hermetic and + # never touch the real home or a live service manager. + def build_installer + require "hive/commands/web/service_installer" + Hive::Commands::Web::ServiceInstaller.new( + host_os: @host_os, + home: @home, + binary_path: current_binary_path, + runner: @runner, + systemctl_available: @systemctl_available + ) + end + + def safe_install_platform(installer) + installer.envelope_platform + rescue StandardError + "unsupported" + end + + def safe_install_target_path(installer) + installer.target_path + rescue StandardError + nil + end + + def safe_install_messages(installer) + installer.messages.dup + rescue StandardError + [] + end + + # Subclassed error classes so callers can rescue them specifically. + # Exit-code overrides mirror the daemon/bot install outcome split: + # drift is a recoverable USAGE error (64, re-run with --force), a + # service-manager failure is SOFTWARE (70). The JSON envelope already + # reported 64/70, but the raised error mapped to GENERIC (1) — the + # envelope lied and automation branching on the process exit code saw + # the wrong value. + class WebInstallFailed < Hive::Error + def exit_code + Hive::ExitCodes::SOFTWARE + end + end + class WebInstallDriftError < Hive::Error + def exit_code + Hive::ExitCodes::USAGE + end + end def rails_app_dir candidates = [ @@ -71,18 +415,6 @@ module Hive ].compact candidates.find { |dir| File.file?(File.join(dir, "config", "application.rb")) } end - - # Rails' production host authorization is inactive by default — the box - # assumes a trusted reverse proxy validates Host, exactly like the - # pre-Rails posture. Binding a public interface without that proxy - # exposes the app to DNS-rebinding / Host-injection, so make it loud. - def warn_on_public_bind(bind, cfg) - return unless bind.to_s == "0.0.0.0" - return if cfg["origin"].to_s.start_with?("https://") - - warn "hive web: WARNING binding 0.0.0.0 without an https origin — " \ - "ensure a trusted reverse proxy validates the Host header." - end end end end diff --git a/lib/hive/commands/web/service_installer.rb b/lib/hive/commands/web/service_installer.rb new file mode 100644 index 000000000..023b0d4e3 --- /dev/null +++ b/lib/hive/commands/web/service_installer.rb @@ -0,0 +1,85 @@ +require "cgi" +require "shellwords" +require "hive/commands/service_installer/base" + +module Hive + module Commands + class Web + # Per-user autostart installer for the local web UI. Inherits the + # platform-agnostic mechanics (drift/backup, atomic write, shim-PATH + # detection, enable/load orchestration) from the shared base and + # supplies only the web service's identity and rendered unit/plist + # bodies. + # + # The web service is deliberately SEPARATE from `hive-daemon` + # (daemon + web are never merged into one unit) so each can be + # restarted independently. The unit runs `hive web run` in the + # foreground — the service manager is the supervisor (mirroring the + # container supervisor, which spawns `hive web --bind 0.0.0.0` + # directly). + # + # The web bind/port are read from the same ~/.config/hive/config.yml + # the CLI uses, so the unit needs no baked --bind/--port. The + # Hive::InvokedBinary.path guarantee (same binary/version as the CLI) + # comes from the shared base's `resolved_binary`. + class ServiceInstaller < Hive::Commands::ServiceInstaller::Base + def service_name + "hive-web" + end + + def cli_label + "web" + end + + def service_noun + "web service" + end + + def unit_noun + "web unit" + end + + def target_path + case platform + when :macos then File.join(@home, "Library/LaunchAgents/local.hive-web.plist") + when :linux then File.join(@home, ".config/systemd/user/hive-web.service") + end + end + + # The web tier has no in-flight child drain like the daemon's + # stop-hook fix workers, so the daemon's up-to-900s restart warning + # does not apply. No override needed. + + private + + def render_systemd + template = File.read(File.expand_path("../../../../examples/systemd/hive-web.service", __dir__)) + # systemd .service files are POSIX-shell-ish — escape the + # resolved binary path so whitespace, `%`, or other special + # characters don't produce a malformed unit. + escaped = Shellwords.escape(resolved_binary) + template + .sub(/^ExecStart=.*$/, "ExecStart=#{escaped} web run") + .sub(/^Environment=HIVE_BIN=.*$/, "Environment=HIVE_BIN=#{escaped}") + .sub(/^Environment=PATH=.*$/, build_path_line) + end + + def render_launchd + template = File.read(File.expand_path("../../../../examples/launchd/hive-web.plist", __dir__)) + binary = resolved_binary + # dirname BEFORE HTML-escaping so paths with `&`/`<`/`>` get + # the correct directory segmentation; then escape both for + # plist XML safety. + binary_dir = File.dirname(binary) + escaped_binary = CGI.escapeHTML(binary) + escaped_binary_dir = CGI.escapeHTML(binary_dir) + escaped_home = CGI.escapeHTML(@home) + template + .gsub(%r{/Users/YOU/\.local/bin/hive}, "#{escaped_binary}") + .gsub("/Users/YOU/Library/Logs", "#{escaped_home}/Library/Logs") + .gsub("/Users/YOU/.local/bin", escaped_binary_dir) + end + end + end + end +end diff --git a/lib/hive/daemon/drift.rb b/lib/hive/daemon/drift.rb new file mode 100644 index 000000000..f31841fd9 --- /dev/null +++ b/lib/hive/daemon/drift.rb @@ -0,0 +1,159 @@ +require "hive/invoked_binary" +require "open3" +require "shellwords" + +module Hive + module Daemon + # Binary/version drift detection for the running daemon vs. the CLI that + # invoked this status/repair call. Ensures the daemon runs with the SAME + # hive binary/version as the CLI so newly created tasks are picked up + # from either the TUI or the web. + # + # Drift is REPORTED, never silently auto-fixed: `hive setup` and the web + # repair button invoke the explicit reinstall/restart path (the `daemon + # repair` re-run of `install --force`). Platform-fragility is handled by + # reporting `verified: false` (and drifting as "unverified") rather than + # guessing when the live process binary cannot be resolved. + # + # Burning the PID-ownership discipline from Hive::PidFile: callers must + # pass a PID they have already proved belongs to the daemon (from a live, + # owned pidfile). This helper only resolves the binary for that PID. + class Drift + def initialize(pid:, runner: nil) + @pid = pid + @runner = runner || default_runner + end + + # Returns a Hash describing the running daemon's binary/version versus + # the current CLI. Fields: + # binary absolute path of the running daemon's binary (nil if unresolvable) + # version the daemon binary's reported hive version (nil if unresolvable) + # current_binary Hive::InvokedBinary.path (the CLI wrapper) + # current_version Hive::VERSION + # verified true only when we could resolve the live binary + # drifted true when verified and binary/version differ from current + # status "ok" | "drifted" | "unverified" + def resolve + binary = resolve_binary + unless binary + return { + "binary" => nil, "version" => nil, + "current_binary" => Hive::InvokedBinary.path, + "current_version" => Hive::VERSION, + "verified" => false, "drifted" => nil, "status" => "unverified" + } + end + + version = fetch_version(binary) + current_binary = Hive::InvokedBinary.path + # The documented `ln -s ~/Dev/hive/bin/hive ~/.local/bin/hive` install + # is a symlink. `resolve_binary` returns the hive WRAPPER script the + # daemon was launched with (parsed from the live command line), not + # the Ruby interpreter behind its shebang. Compare realpaths on BOTH + # sides so a path-only symlink difference never false-positives as + # drift. If the CLI wrapper cannot be resolved (weird PATH), fall back + # to comparing versions only so drift is still detectable. + drifted = + if current_binary + normalize_path(binary) != normalize_path(current_binary) || + (version && version != Hive::VERSION) + else + version && version != Hive::VERSION + end + { + "binary" => binary, + "version" => version, + "current_binary" => current_binary, + "current_version" => Hive::VERSION, + "verified" => true, + "drifted" => drifted, + "status" => drifted ? "drifted" : "ok" + } + end + + private + + def default_runner + lambda do |argv| + out, _err, status = Open3.capture3(*argv) + { success: status.success?, out: out } + end + end + + # Resolve symlinks for comparison; fall back to the raw path when the + # target can't be resolved (missing on disk, dangling link, perms). + def normalize_path(path) + return nil if path.nil? + + File.realpath(path) + rescue StandardError + path + end + + # Resolve the live process's hive wrapper script (NOT the Ruby + # interpreter). `hive daemon start` is a `#!/usr/bin/env ruby` script, + # so /proc//exe resolves all the way through the shebang to the + # interpreter (`ruby`) — comparing that against the CLI wrapper would + # flag every healthy daemon as drifted. Walk the live command line + # instead: on Linux the process is `ruby daemon start`, + # so the first arg whose basename is a valid wrapper name (`hive`/`hv`) + # is the script. Returns nil when neither source works (no /proc, + # stripped ps) — the caller reports "unverified" rather than guessing. + def resolve_binary + args = process_args + return nil unless args + + args.each do |arg| + next if arg.nil? || arg.empty? + + name = File.basename(arg) + next unless Hive::InvokedBinary::VALID_NAMES.include?(name) + + # A relative wrapper path is expanded against the CLI's cwd; a bare + # name is resolved via PATH (mirrors InvokedBinary.path). + path = + if arg.include?(File::SEPARATOR) + File.expand_path(arg) + else + Hive::InvokedBinary.which(name) + end + return File.realpath(path) if path && File.exist?(path) + end + nil + rescue StandardError + nil + end + + # NUL-separated argv from /proc//cmdline on Linux; on macOS / other + # hosts fall back to `ps -o command=` (the full command line, which keeps + # the script argument that `ps -o comm=` strips away). + def process_args + path = "/proc/#{@pid}/cmdline" + if File.exist?(path) + args = File.binread(path).split("\0") + return args unless args.empty? + end + + return nil unless @pid.is_a?(Integer) && @pid > 0 + + result = @runner.call([ "ps", "-o", "command=", "-p", @pid.to_s ]) + line = result[:out].to_s.strip + return nil if line.empty? + + Shellwords.split(line) + rescue StandardError + nil + end + + def fetch_version(binary) + probe = @runner.call([ binary, "version" ]) + return nil unless probe[:success] + + m = probe[:out].to_s.match(/(\d+\.\d+\.\d+)/) + m && m[1] + rescue StandardError + nil + end + end + end +end diff --git a/lib/hive/web/auth_policy.rb b/lib/hive/web/auth_policy.rb new file mode 100644 index 000000000..56753db13 --- /dev/null +++ b/lib/hive/web/auth_policy.rb @@ -0,0 +1,55 @@ +module Hive + module Web + # Shared loopback/no-auth predicate for local (non-Docker) single-user + # mode. The CLI bind check (`Hive::Commands::Web#refused_bind?`) and the + # Rails owner gate (`ApplicationController#require_login`) BOTH consult + # this one module so the CLI can never green-light a bind the app will + # 403, and the app can never silently allow a non-loopback request. + # + # Semantics (fail-closed by default): + # - loopback bind/host (127.0.0.1, ::1, localhost) with web.github.owner + # unset ⇒ single-user local mode: no auth required. + # - loopback bind/host with web.github.owner set ⇒ the owner gate is + # kept (owner claim/owner flow still applies). + # - any non-loopback bind/host ⇒ the owner gate applies; a non-loopback + # CLI bind without owner and without --allow-non-loopback is REFUSED. + module AuthPolicy + # Binds that are, for auth purposes, loopback. `0.0.0.0` is NOT here — + # it fans out to all interfaces and must go through the owner gate / + # explicit unsafe flag. + LOOPBACK_BINDS = %w[127.0.0.1 ::1 localhost].freeze + + module_function + + def loopback_bind?(bind) + LOOPBACK_BINDS.include?(bind.to_s) + end + + # Rails `request.host` is host-only (no port). Normalise defensively for + # anything that passes an origin string with a port. + def loopback_host?(host) + host = host.to_s.delete_prefix("[").delete_suffix("]") + %w[127.0.0.1 ::1 localhost].include?(host) + end + + def owner_unset?(config) + config.fetch("github", {}).fetch("owner", nil).to_s.strip.empty? + end + + # Single-user local mode applies when the surface is loopback AND no + # owner is claimed. Used by the CLI to decide whether a loopback bind + # may run with no auth (and to refuse a non-loopback bind otherwise). + def single_user_local?(bind:, config:) + loopback_bind?(bind) && owner_unset?(config) + end + + # Controller-side equivalent: True when a LOOPBACK REQUEST may bypass + # the GitHub owner gate because no owner is claimed. The CLI bind check + # decides whether to run at all; this decides whether an in-flight + # request needs a login. + def allows_unauthenticated?(host:, config:) + loopback_host?(host) && owner_unset?(config) + end + end + end +end diff --git a/schemas/hive-daemon-repair.v1.json b/schemas/hive-daemon-repair.v1.json new file mode 100644 index 000000000..a9715bac1 --- /dev/null +++ b/schemas/hive-daemon-repair.v1.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ivankuznetsov/hive/blob/main/schemas/hive-daemon-repair.v1.json", + "title": "hive daemon repair output (v1)", + "description": "Stable contract emitted by `hive daemon repair --json` (U5). Repair re-runs the daemon unit install with --force so the unit points at the CURRENT CLI binary/version, then the caller restarts the daemon. SuccessPayload carries the install outcome (written/upgraded/unchanged), target_path, and whether the unit was restarted.", + "oneOf": [ + { "$ref": "#/$defs/SuccessPayload" }, + { "$ref": "#/$defs/ErrorPayload" } + ], + "$defs": { + "SuccessPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "schema_version", + "ok", + "outcome", + "target_path", + "restarted" + ], + "properties": { + "schema": { "const": "hive-daemon-repair" }, + "schema_version": { "const": 1 }, + "ok": { "const": true }, + "outcome": { + "type": "string", + "enum": [ "written", "upgraded", "unchanged" ] + }, + "target_path": { "type": [ "string", "null" ] }, + "restarted": { "type": "boolean" } + } + }, + "ErrorPayload": { + "type": "object", + "additionalProperties": false, + "required": [ "schema", "schema_version", "ok", "error_class", "error_kind", "exit_code", "message" ], + "properties": { + "schema": { "const": "hive-daemon-repair" }, + "schema_version": { "const": 1 }, + "ok": { "const": false }, + "error_class": { "type": "string" }, + "error_kind": { "type": "string" }, + "exit_code": { "type": "integer" }, + "message": { "type": "string" } + } + } + } +} diff --git a/schemas/hive-daemon-status.v1.json b/schemas/hive-daemon-status.v1.json index 1ee77d0d2..b5192d98e 100644 --- a/schemas/hive-daemon-status.v1.json +++ b/schemas/hive-daemon-status.v1.json @@ -23,7 +23,11 @@ "service_enabled", "unit_path", "current_version", - "update_nudge" + "update_nudge", + "daemon_binary", + "daemon_version", + "drift_status", + "drifted" ], "properties": { "schema": { "const": "hive-daemon-status" }, @@ -75,6 +79,23 @@ "channel": { "type": "string", "description": "Detected install channel (brew/aur/bash)." }, "command": { "type": "string", "description": "Exact command to update on this channel." } } + }, + "daemon_binary": { + "type": ["string", "null"], + "description": "Absolute path of the RUNNING daemon's resolved binary (U5), or null when the daemon is not running or its live binary could not be resolved." + }, + "daemon_version": { + "type": ["string", "null"], + "description": "Hive version the RUNNING daemon reports, or null when not resolvable." + }, + "drift_status": { + "type": ["string", "null"], + "enum": ["ok", "drifted", "unverified", null], + "description": "Binary/version-consistency status vs the CLI: ok (matching), drifted (different binary/version), unverified (live binary could not be resolved), or null when not running." + }, + "drifted": { + "type": ["boolean", "null"], + "description": "true when the running daemon uses a different binary/version than the CLI; false when verified matching; null when not running or unverified. Prefer drift_status for the tri-state." } } } diff --git a/schemas/hive-dependency-check.v1.json b/schemas/hive-dependency-check.v1.json new file mode 100644 index 000000000..68d6a3a7f --- /dev/null +++ b/schemas/hive-dependency-check.v1.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ivankuznetsov/hive/blob/main/schemas/hive-dependency-check.v1.json", + "title": "hive setup dependency check output (v1)", + "description": "Stable contract emitted by the `hive setup` dependency-verification step (U3). One row per probed dependency (ruby/git/tmux/gh/claude/codex/node/npm/qmd/web bundle/sqlite) with a status, a human detail, an exact fix command for anything failing (or null), and a hive_owned flag (true for deps hive setup will bootstrap itself). The `bootstrap` array records any Hive-owned bootstraps that were attempted (qmd install, web bundle install). External agent CLIs (gh/claude/codex) are probed but NEVER auto-installed or auto-authenticated — the fix_command is printed for the operator. ok=false + exit 65 when any row is failing.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "schema_version", + "ok", + "rows", + "bootstrap" + ], + "properties": { + "schema": { "const": "hive-dependency-check" }, + "schema_version": { "const": 1 }, + "ok": { + "type": "boolean", + "description": "false when any probed dependency is failing (missing / version_too_old / auth_missing / bundle_failed)." + }, + "rows": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ "name", "status", "detail", "fix_command", "hive_owned" ], + "properties": { + "name": { "type": "string" }, + "status": { + "type": "string", + "enum": [ "present", "missing", "version_too_old", "auth_missing", "bundle_failed", "broken", "not_applicable" ] + }, + "detail": { "type": "string" }, + "fix_command": { "type": [ "string", "null" ] }, + "hive_owned": { "type": "boolean" } + } + } + }, + "bootstrap": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ "name", "action", "status", "detail" ], + "properties": { + "name": { "type": "string" }, + "action": { "type": "string" }, + "status": { "type": "string", "enum": [ "installed", "skipped", "failed" ] }, + "detail": { "type": "string" } + } + } + } + } +} diff --git a/schemas/hive-setup.v1.json b/schemas/hive-setup.v1.json new file mode 100644 index 000000000..688a284f2 --- /dev/null +++ b/schemas/hive-setup.v1.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ivankuznetsov/hive/blob/main/schemas/hive-setup.v1.json", + "title": "hive setup output (v1)", + "description": "Stable contract emitted by `hive setup --json`. The top-level `ok` is false (and exit_code is 65) when any step failed or any probed dependency is failing (missing / version_too_old / auth_missing / bundle_failed). The `steps` array records each orchestrated step (backends / dependencies / daemon / enroll / web / health) with a status and detail. `backend_selection` is the persisted global agent selection. `dependencies` mirrors the hive-dependency-check rows + bootstraps. `health` is the result of the web health probe at the configured origin.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "schema_version", + "ok", + "exit_code", + "backend_selection", + "steps", + "dependencies", + "health" + ], + "properties": { + "schema": { "const": "hive-setup" }, + "schema_version": { "const": 1 }, + "ok": { "type": "boolean" }, + "exit_code": { "type": "integer", "enum": [ 0, 65 ] }, + "backend_selection": { + "type": [ "array", "null" ], + "items": { "type": "string" } + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ "name", "status", "detail" ], + "properties": { + "name": { "type": "string" }, + "status": { "type": "string" }, + "detail": { "type": "string" } + } + } + }, + "dependencies": { + "type": [ "object", "null" ], + "properties": { + "ok": { "type": "boolean" }, + "rows": { "type": "array" }, + "bootstrap": { "type": "array" } + } + }, + "health": { + "type": [ "object", "null" ], + "properties": { + "ok": { "type": "boolean" }, + "status": { "type": [ "integer", "null" ] }, + "body": { "type": "object" } + } + } + } +} diff --git a/schemas/hive-web-install.v1.json b/schemas/hive-web-install.v1.json new file mode 100644 index 000000000..7cc9f175d --- /dev/null +++ b/schemas/hive-web-install.v1.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ivankuznetsov/hive/blob/main/schemas/hive-web-install.v1.json", + "title": "hive web install output (v1)", + "description": "Stable contract emitted by `hive web install --json` (and `hive web install --force --json`). The web service is deliberately SEPARATE from the hive daemon (daemon + web are never merged into one unit). Idempotent: a no-op install against a matching unit returns ok=true with outcome=unchanged. Drift without --force returns ok=false with outcome=drifted and exit_code=64 so agents can branch `hive web install --json || (test $? = 64 && hive web install --force --json)`. Internal failures (systemctl reload/start/restart, launchctl load) return ok=false with outcome=failed and exit_code=70.", + "oneOf": [ + { "$ref": "#/$defs/SuccessPayload" }, + { "$ref": "#/$defs/ErrorPayload" } + ], + "$defs": { + "SuccessPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "schema_version", + "ok", + "outcome", + "platform", + "target_path", + "restarted" + ], + "properties": { + "schema": { "const": "hive-web-install" }, + "schema_version": { "const": 1 }, + "ok": { "const": true }, + "outcome": { + "type": "string", + "enum": [ "written", "upgraded", "unchanged", "unsupported" ], + "description": "What happened on disk. `written` = no prior unit, new file created. `upgraded` = existing unit differed and --force overwrote it (backup_path is set). `unchanged` = existing unit already matches the rendered template. `unsupported` = autostart could not be enabled on this host (e.g. Linux without systemd-user); target_path still points at the written unit." + }, + "platform": { + "type": "string", + "enum": [ "linux", "macos", "unsupported" ] + }, + "target_path": { + "type": [ "string", "null" ] + }, + "backup_path": { + "type": [ "string", "null" ] + }, + "restarted": { + "type": "boolean" + }, + "messages": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "ErrorPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "schema_version", + "ok", + "error_class", + "error_kind", + "exit_code", + "message" + ], + "properties": { + "schema": { "const": "hive-web-install" }, + "schema_version": { "const": 1 }, + "ok": { "const": false }, + "error_class": { + "type": "string", + "enum": [ "WebInstallDriftError", "WebInstallFailed", "Error" ] + }, + "error_kind": { + "type": "string", + "enum": [ "drifted", "failed", "internal" ] + }, + "exit_code": { + "type": "integer", + "enum": [ 1, 64, 70 ] + }, + "message": { "type": "string" }, + "outcome": { + "type": "string", + "enum": [ "drifted", "failed" ] + }, + "platform": { + "type": "string", + "enum": [ "linux", "macos", "unsupported" ] + }, + "target_path": { + "type": [ "string", "null" ] + }, + "messages": { + "type": "array", + "items": { "type": "string" } + } + } + } + } +} diff --git a/schemas/hive-web-start.v1.json b/schemas/hive-web-start.v1.json new file mode 100644 index 000000000..9aafc7d51 --- /dev/null +++ b/schemas/hive-web-start.v1.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ivankuznetsov/hive/blob/main/schemas/hive-web-start.v1.json", + "title": "hive web start output (v1)", + "description": "Stable contract emitted by `hive web start --json`. Requests the service manager to start the managed web service (systemd-user on Linux, launchd on macOS). ok reports whether the service manager accepted the request; unit_path is the managed unit (null on unsupported hosts).", + "oneOf": [ + { "$ref": "#/$defs/Payload" } + ], + "$defs": { + "Payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "schema_version", + "ok", + "unit_path" + ], + "properties": { + "schema": { "const": "hive-web-start" }, + "schema_version": { "const": 1 }, + "ok": { + "type": "boolean", + "description": "True when the service manager accepted the start request." + }, + "unit_path": { + "type": [ "string", "null" ], + "description": "Absolute path of the managed web unit; null on unsupported platforms." + } + } + } + } +} diff --git a/schemas/hive-web-status.v1.json b/schemas/hive-web-status.v1.json new file mode 100644 index 000000000..9463e017d --- /dev/null +++ b/schemas/hive-web-status.v1.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ivankuznetsov/hive/blob/main/schemas/hive-web-status.v1.json", + "title": "hive web status output (v1)", + "description": "Stable contract emitted by `hive web status --json`. Reports whether the managed web service is running, its per-user autostart unit state, and the CLI version. The web service is deliberately SEPARATE from the hive daemon. SuccessPayload is always emitted (exit 0 even when the service is not running); a non-running service is an informational state, not a command failure.", + "oneOf": [ + { "$ref": "#/$defs/SuccessPayload" } + ], + "$defs": { + "SuccessPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "schema_version", + "ok", + "running", + "service_installed", + "service_enabled", + "unit_path", + "current_version" + ], + "properties": { + "schema": { "const": "hive-web-status" }, + "schema_version": { "const": 1 }, + "ok": { "const": true }, + "running": { + "type": "boolean", + "description": "Whether the service manager reports the web service as running (non-mutating probe: `systemctl --user is-active` / `launchctl list`)." + }, + "service_installed": { + "type": ["boolean", "null"], + "description": "Whether the per-user autostart unit file exists on disk (non-mutating probe)." + }, + "service_enabled": { + "type": ["boolean", "null"], + "description": "Whether the service manager reports the autostart unit as enabled/loaded." + }, + "unit_path": { + "type": ["string", "null"], + "description": "Absolute path of the autostart unit file; null on unsupported platforms." + }, + "current_version": { + "type": "string", + "description": "The CLI hive version that produced this status." + } + } + } + } +} diff --git a/schemas/hive-web-stop.v1.json b/schemas/hive-web-stop.v1.json new file mode 100644 index 000000000..2c84a07a3 --- /dev/null +++ b/schemas/hive-web-stop.v1.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ivankuznetsov/hive/blob/main/schemas/hive-web-stop.v1.json", + "title": "hive web stop output (v1)", + "description": "Stable contract emitted by `hive web stop --json`. Requests the service manager to stop the managed web service (systemd-user on Linux, launchd on macOS). ok reports whether the service manager accepted the request; unit_path is the managed unit (null on unsupported hosts).", + "oneOf": [ + { "$ref": "#/$defs/Payload" } + ], + "$defs": { + "Payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "schema_version", + "ok", + "unit_path" + ], + "properties": { + "schema": { "const": "hive-web-stop" }, + "schema_version": { "const": 1 }, + "ok": { + "type": "boolean", + "description": "True when the service manager accepted the stop request." + }, + "unit_path": { + "type": [ "string", "null" ], + "description": "Absolute path of the managed web unit; null on unsupported platforms." + } + } + } + } +} diff --git a/test/integration/local_web_mode_test.rb b/test/integration/local_web_mode_test.rb new file mode 100644 index 000000000..d6810f3bd --- /dev/null +++ b/test/integration/local_web_mode_test.rb @@ -0,0 +1,114 @@ +require "test_helper" +require "json" +require "open3" +require "tmpdir" +require "yaml" + +# U6 (acceptance layer, CLI-level): exercise the first-class local web mode +# through the REAL bin/hive in an isolated HIVE_HOME + HOME sandbox. The +# full boxed e2e (web up + live daemon + TUI round-trip) is CI-gated in the +# web suite; this proves the local-mode command surface end-to-end without a +# live Rails server. +class LocalWebModeTest < Minitest::Test + include HiveTestHelper + + REPO_ROOT = File.expand_path("../..", __dir__) + HIVE_BIN = File.join(REPO_ROOT, "bin", "hive") + + def with_isolated_hive_home(&block) + Dir.mktmpdir("hive-localweb") do |home| + env = ENV.to_h.merge("HIVE_HOME" => home, "HOME" => home) + block.call(home, env) + end + end + + # Build a `bin/` shim dir and prepend it to PATH. The qmd flat is enough to + # prevent the Hive-owned qmd bootstrap from running a real (slow/hanging) + # `npm install` in the sandbox — with a qmd on PATH the probe reports it + # present and no bootstrap runs. + def with_qmd_shim(home, env) + bindir = File.join(home, ".shim-bin") + FileUtils.mkdir_p(bindir) + qmd = File.join(bindir, "qmd") + File.write(qmd, "#!/bin/sh\necho \"qmd 0.0.0-test\"\n") + FileUtils.chmod(0o755, qmd) + env["PATH"] = [ bindir, env["PATH"] ].join(File::PATH_SEPARATOR) + end + + def test_web_status_json_emits_envelope_without_a_service + with_isolated_hive_home do |_home, env| + out, _err, status = Open3.capture3(env, "ruby", "-Ilib", HIVE_BIN, "web", "status", "--json") + doc = JSON.parse(out) + assert_equal "hive-web-status", doc["schema"] + assert_equal false, doc["running"], "no web service installed/running in a fresh sandbox" + assert_equal false, doc["service_installed"] + assert status.success? + end + end + + def test_setup_json_runs_the_full_local_surface_in_an_isolated_home + with_isolated_hive_home do |home, env| + with_qmd_shim(home, env) + out, _err, status = Open3.capture3( + env, "ruby", "-Ilib", HIVE_BIN, "setup", "--json", "--non-interactive" + ) + doc = JSON.parse(out) + assert_equal "hive-setup", doc["schema"] + names = doc["steps"].map { |s| s["name"] } + %w[backends dependencies daemon web health].each do |step| + assert_includes names, step, "setup must run the #{step} step" + end + # The isolated home has no web app dir, but the daemon + web units + # still get written to the sandbox HOME. + assert File.exist?(File.join(home, ".config/systemd/user/hive-daemon.service")), + "setup must ensure the daemon unit in the sandbox HOME" + assert File.exist?(File.join(home, ".config/systemd/user/hive-web.service")), + "setup must ensure the web unit in the sandbox HOME" + # A fresh sandbox may be missing external agent CLIs → fix_required. + assert_includes [ 0, 65 ], status.exitstatus, + "setup exits 0 (all deps found) or 65 (fix required); got #{status.exitstatus}" + end + end + + # U6 acceptance: `hive setup` must ENROLL the current repo (not just hint) + # and write daemon + web units pointing at the SAME binary the CLI resolves + # (Hive::InvokedBinary.path). Run inside a real git repo so enrollment has a + # project to register. + def test_setup_enrolls_repo_and_writes_same_binary_units + with_tmp_git_repo do |repo| + with_isolated_hive_home do |home, env| + with_qmd_shim(home, env) + out, _err, status = Open3.capture3( + env, "ruby", "-Ilib", HIVE_BIN, "setup", "--json", "--non-interactive", + chdir: repo + ) + doc = JSON.parse(out) + assert_equal "hive-setup", doc["schema"] + + # Repo enrollment: the one-shot setup must register the repo, not + # merely print `hive init `. + config = YAML.safe_load_file(File.join(home, "config.yml")) + registered = config.fetch("registered_projects", []) + assert registered.any? { |p| File.expand_path(p["path"]) == File.expand_path(repo) }, + "hive setup must enroll the current repo in the registry" + enroll_step = doc.fetch("steps").find { |s| s["name"] == "enroll" } + assert_equal "ok", enroll_step["status"], + "enroll step must report ok after registering the repo" + + # Same-binary guarantee: both units' ExecStart must point at the + # exact binary the CLI resolves. + daemon_unit = File.join(home, ".config/systemd/user/hive-daemon.service") + assert File.exist?(daemon_unit), "setup must write the daemon unit" + assert_includes File.read(daemon_unit), "ExecStart=#{HIVE_BIN} daemon start", + "daemon unit must point at the same binary as the CLI" + web_unit = File.join(home, ".config/systemd/user/hive-web.service") + assert File.exist?(web_unit), "setup must write the web unit" + assert_includes File.read(web_unit), "ExecStart=#{HIVE_BIN} web run", + "web unit must point at the same binary as the CLI" + + assert_includes [ 0, 65 ], status.exitstatus, + "setup exits 0 (all deps found) or 65 (fix required); got #{status.exitstatus}" + end + end + end +end diff --git a/test/unit/cli_test.rb b/test/unit/cli_test.rb index dafa1c291..a096d8aff 100644 --- a/test/unit/cli_test.rb +++ b/test/unit/cli_test.rb @@ -519,7 +519,10 @@ class HiveCliTest < Minitest::Test require "hive/commands/web" captured = [] recorder = Class.new do - define_method(:initialize) { |bind:, port:| captured << { bind: bind, port: port } } + define_method(:initialize) do |subcommand = nil, bind: nil, port: nil, json: false, + force: false, allow_non_loopback: false| + captured << { subcommand: subcommand, bind: bind, port: port } + end define_method(:call) { captured << :called } end @@ -533,8 +536,10 @@ class HiveCliTest < Minitest::Test Hive::Commands.const_set(:Web, original) end - assert_equal({ bind: "0.0.0.0", port: 9123 }, captured.first, - "the --bind/--port flags must reach the web command") + assert_equal "run", captured.first[:subcommand], + "hive web with no SUBCOMMAND must route to the foreground `run`" + assert_equal "0.0.0.0", captured.first[:bind], "--bind must reach the web command" + assert_equal 9123, captured.first[:port], "--port must reach the web command" assert_equal :called, captured.last, "hive web must invoke the web command's #call" end diff --git a/test/unit/commands/daemon/drift_test.rb b/test/unit/commands/daemon/drift_test.rb new file mode 100644 index 000000000..00edd9ef9 --- /dev/null +++ b/test/unit/commands/daemon/drift_test.rb @@ -0,0 +1,123 @@ +require "test_helper" +require "hive/daemon/drift" +require "hive/invoked_binary" + +class DaemonDriftTest < Minitest::Test + include HiveTestHelper + + class FakeRunner + attr_reader :calls + + def initialize(version: Hive::VERSION, ps_out: nil) + @version = version + @ps_out = ps_out + @calls = [] + end + + def call(argv) + @calls << argv + case argv[0] + when "ps" + { success: true, out: @ps_out.to_s + "\n" } + else # version probe + { success: true, out: "hive #{@version}\n" } + end + end + end + + def with_fake_hive_binary(path) + old = Hive::InvokedBinary.method(:path) + Hive::InvokedBinary.define_singleton_method(:path) { path } + yield + ensure + Hive::InvokedBinary.define_singleton_method(:path, old) + end + + def test_matching_binary_and_version_is_not_drifted + with_fake_hive_binary("/usr/local/bin/hive") do + drift = Hive::Daemon::Drift.new(pid: 4242, runner: FakeRunner.new(version: Hive::VERSION)) + drift.define_singleton_method(:resolve_binary) { "/usr/local/bin/hive" } + result = drift.resolve + assert_equal true, result["verified"] + assert_equal false, result["drifted"] + assert_equal "ok", result["status"] + assert_equal "/usr/local/bin/hive", result["binary"] + assert_equal Hive::VERSION, result["version"] + end + end + + def test_stale_binary_is_drifted + with_fake_hive_binary("/usr/local/bin/hive") do + drift = Hive::Daemon::Drift.new(pid: 4242, runner: FakeRunner.new(version: Hive::VERSION)) + drift.define_singleton_method(:resolve_binary) { "/usr/bin/hive" } + result = drift.resolve + assert_equal true, result["verified"] + assert_equal true, result["drifted"], + "a daemon running /usr/bin/hive while the CLI is the wrapper must be flagged drifted" + assert_equal "drifted", result["status"] + end + end + + def test_stale_version_is_drifted + with_fake_hive_binary("/usr/local/bin/hive") do + drift = Hive::Daemon::Drift.new(pid: 4242, runner: FakeRunner.new(version: "0.1.0")) + drift.define_singleton_method(:resolve_binary) { "/usr/local/bin/hive" } + result = drift.resolve + assert_equal true, result["drifted"], "a version mismatch must be flagged drifted" + end + end + + def test_unresolvable_binary_is_unverified_not_false + drift = Hive::Daemon::Drift.new(pid: 4242, runner: FakeRunner.new) + drift.define_singleton_method(:resolve_binary) { nil } + result = drift.resolve + assert_equal false, result["verified"] + assert_nil result["drifted"], "an unresolvable binary must report unverified, never guess" + assert_equal "unverified", result["status"] + end + + def test_linux_readlink_proc_exe_path + with_tmp_dir do |dir| + # Simulate /proc//exe by pointing resolve_binary at a real file. + real = File.join(dir, "hive-real") + File.write(real, "#!/bin/sh\n") + FileUtils.chmod(0755, real) + drift = Hive::Daemon::Drift.new(pid: 4242, runner: FakeRunner.new(version: Hive::VERSION)) + drift.define_singleton_method(:resolve_binary) { File.realpath(real) } + result = drift.resolve + assert_equal real, result["binary"] + end + end + + def test_resolve_binary_picks_hive_script_not_ruby_interpreter + with_tmp_dir do |dir| + hive = File.join(dir, "hive") + File.write(hive, "#!/bin/sh\n") + FileUtils.chmod(0755, hive) + drift = Hive::Daemon::Drift.new(pid: 4242, runner: FakeRunner.new(version: Hive::VERSION)) + # `hive daemon start` is `#!/usr/bin/env ruby`, so the live argv is the + # interpreter first, then the wrapper script. resolve_binary must return + # the script, never /usr/bin/ruby (which would false-positive as drift). + drift.define_singleton_method(:process_args) { [ "/usr/bin/ruby", hive, "daemon", "start" ] } + assert_equal File.realpath(hive), drift.send(:resolve_binary) + end + end + + def test_resolve_binary_returns_nil_when_no_hive_script_in_argv + drift = Hive::Daemon::Drift.new(pid: 4242, runner: FakeRunner.new) + drift.define_singleton_method(:process_args) { [ "/usr/bin/ruby", "daemon", "start" ] } + assert_nil drift.send(:resolve_binary) + end + + def test_resolve_binary_ps_command_fallback_resolves_script + with_tmp_dir do |dir| + hive = File.join(dir, "hive") + File.write(hive, "#!/bin/sh\n") + FileUtils.chmod(0755, hive) + drift = Hive::Daemon::Drift.new( + pid: 4_000_000_000, runner: FakeRunner.new(ps_out: "ruby #{hive} daemon start", version: Hive::VERSION) + ) + assert_equal File.realpath(hive), drift.send(:resolve_binary) + end + end +end diff --git a/test/unit/commands/daemon_test.rb b/test/unit/commands/daemon_test.rb index f87b788e0..adf9f6169 100644 --- a/test/unit/commands/daemon_test.rb +++ b/test/unit/commands/daemon_test.rb @@ -298,6 +298,74 @@ class HiveCommandsDaemonTest < Minitest::Test end + def test_status_json_includes_drift_fields + command = daemon("status", json: true) + write_pid_payload(pid: 1234) + command.define_singleton_method(:pid_alive?) { |pid| pid == 1234 } + command.define_singleton_method(:pid_owned_by_us?) { |_payload, pid| pid == 1234 } + command.define_singleton_method(:drift_payload) do |_pid| + { "binary" => "/usr/bin/hive", "version" => "0.1.0", + "current_binary" => "/usr/local/bin/hive", "current_version" => Hive::VERSION, + "verified" => true, "drifted" => true, "status" => "drifted" } + end + + out, _err = capture_io { command.call } + doc = JSON.parse(out) + assert_equal "/usr/bin/hive", doc.fetch("daemon_binary") + assert_equal "0.1.0", doc.fetch("daemon_version") + assert_equal "drifted", doc.fetch("drift_status") + assert_equal true, doc.fetch("drifted") + end + + # U5: `repair` re-runs install --force (rewriting the unit to the CLI + # binary) and emits the repair envelope. + def test_repair_json_rewrites_unit_and_emits_envelope + with_tmp_global_config_and_home do |dir| + hive = File.join(dir, "bin", "hive") + FileUtils.mkdir_p(File.dirname(hive)) + File.write(hive, "#!/bin/sh\n") + FileUtils.chmod(0o755, hive) + command = daemon("repair", json: true) + command.define_singleton_method(:current_binary_path) { hive } + out, _err = capture_io { command.call } + doc = JSON.parse(out) + assert_equal "hive-daemon-repair", doc.fetch("schema") + assert_equal true, doc.fetch("ok") + unit = File.join(dir, ".config/systemd/user/hive-daemon.service") + assert File.exist?(unit), "repair must rewrite the daemon unit" + assert_includes File.read(unit), "ExecStart=#{hive} daemon start" + assert_includes doc.fetch("target_path"), "hive-daemon.service" + end + end + + # U5: `repair` must actually RESTART the drifted daemon, not just rewrite + # the unit file. `autostart: true` makes the installer restart the running + # service on the force-upgrade path. + def test_repair_reinstalls_with_autostart_and_force + calls = [] + outcome = fake_outcome(:upgraded, restarted: true) + installer = FakeInstaller.new(target_path: "/tmp/fake-unit", + last_backup_path: nil, + last_restart_invoked: true, + envelope_platform: "linux", + messages: []) + installer.define_singleton_method(:install!) do |autostart:, force:| + calls << { autostart: autostart, force: force } + outcome + end + command = daemon("repair", json: true) + require "hive/commands/daemon/service_installer" + out, _err = with_replaced_singleton_method( + Hive::Commands::Daemon::ServiceInstaller, :new, ->(**_kwargs) { installer } + ) { capture_io { command.call } } + + assert_equal [ { autostart: true, force: true } ], calls, + "repair must reinstall with autostart so the drifted daemon is actually restarted" + doc = JSON.parse(out) + assert_equal true, doc.fetch("ok") + assert_equal true, doc.fetch("restarted") + end + def test_status_json_includes_update_nudge_when_present with_env("HIVE_HOME" => @home) do Hive::UpdateCheck::State.new.set_nudge(latest: "9.9.9", channel: "brew", diff --git a/test/unit/commands/setup/dependency_check_test.rb b/test/unit/commands/setup/dependency_check_test.rb new file mode 100644 index 000000000..3ffe5f82e --- /dev/null +++ b/test/unit/commands/setup/dependency_check_test.rb @@ -0,0 +1,204 @@ +require "test_helper" +require "hive/commands/setup/dependency_check" + +class DependencyCheckTest < Minitest::Test + include HiveTestHelper + + # A fake `which`: returns a path for installed tools, nil otherwise. + def fake_which(present:) + lambda do |name| + present.include?(name) ? "/usr/bin/#{name}" : nil + end + end + + # A fake runner that records argv and scripts success/failure + stdout. + # Keys match the BINARY BASENAME (e.g. "ruby", "gh") regardless of the + # absolute path any given probe invokes. Defaults to a ruby 3.4 version + # string so "all present" scenarios pass the ruby version gate without + # every test re-injecting it. + class FakeRunner + attr_reader :calls + + def initialize(ok: {}, out: {}) + @ok = ok + @out = { "ruby" => "ruby 3.4.7 (2026-05-27) [x86_64-linux]\n" }.merge(out) + @calls = [] + end + + def call(argv) + @calls << argv + short = File.basename(argv.first) + two = argv[1] ? "#{short} #{argv[1]}" : short + success = @ok.key?(two) ? @ok[two] : @ok.fetch(short, true) + { success: success, out: @out.fetch(short, ""), err: "", status: nil } + end + end + + ALL_PRESENT = %w[ruby git tmux gh claude codex node npm qmd sqlite3].freeze + + def test_all_present_returns_ok + runner = FakeRunner.new + check = Hive::Commands::Setup::DependencyCheck.new( + which: fake_which(present: %w[ruby git tmux gh claude codex node npm qmd sqlite3]), + runner: runner + ) + assert_equal Hive::Commands::Setup::DependencyCheck::EXIT_OK, check.call + assert check.rows.none? { |r| %w[missing version_too_old auth_missing bundle_failed broken].include?(r[:status]) } + end + + def test_missing_gh_auth_emits_exact_fix_and_does_not_spawn_login + runner = FakeRunner.new(ok: { "gh" => false }) + check = Hive::Commands::Setup::DependencyCheck.new( + which: fake_which(present: %w[ruby git tmux gh claude codex node npm qmd sqlite3]), + runner: runner + ) + assert_equal Hive::Commands::Setup::DependencyCheck::EXIT_FIX_REQUIRED, check.call + gh = check.rows.find { |r| r[:name] == "gh" } + assert_equal "auth_missing", gh[:status] + assert_equal "gh auth login", gh[:fix_command] + # The login command was only reported as a fix, never executed. + refute_includes runner.calls, [ "gh", "auth", "login" ] + end + + def test_broken_claude_and_codex_are_not_misreported_as_auth + runner = FakeRunner.new(ok: { "claude" => false, "codex" => false }) + check = Hive::Commands::Setup::DependencyCheck.new( + which: fake_which(present: %w[ruby git tmux gh claude codex node npm qmd sqlite3]), + runner: runner + ) + check.call + claude = check.rows.find { |r| r[:name] == "claude" } + codex = check.rows.find { |r| r[:name] == "codex" } + assert_equal "broken", claude[:status], "a failing --version is a broken install, not auth_missing" + assert_equal "broken", codex[:status] + assert_match(/reinstall Claude Code/, claude[:fix_command]) + assert_match(/reinstall OpenAI Codex/, codex[:fix_command]) + end + + def test_missing_tool_reports_fix_command + check = Hive::Commands::Setup::DependencyCheck.new( + which: fake_which(present: %w[ruby]), + runner: FakeRunner.new + ) + check.call + git = check.rows.find { |r| r[:name] == "git" } + assert_equal "missing", git[:status] + assert_match(/install git/, git[:fix_command]) + end + + def test_qmd_missing_is_hive_owned_with_install_command + check = Hive::Commands::Setup::DependencyCheck.new( + which: fake_which(present: %w[ruby git tmux gh claude codex node npm sqlite3]), + runner: FakeRunner.new, + data_home: "/tmp/hivedata" + ) + check.call + qmd = check.rows.find { |r| r[:name] == "qmd" } + assert_equal "missing", qmd[:status] + assert qmd[:hive_owned] + assert_includes qmd[:fix_command], "@tobilu/qmd" + assert_includes qmd[:fix_command], "/tmp/hivedata/qmd" + end + + def test_qmd_repair_attempted_only_when_npm_present + # npm present → qmd bootstrap is attempted. + runner = FakeRunner.new(ok: { "npm" => true }) + check_with_npm = Hive::Commands::Setup::DependencyCheck.new( + which: fake_which(present: %w[ruby git tmux gh claude codex node npm sqlite3]), + runner: runner, + data_home: "/tmp/hivedata", + bootstrap: true + ) + check_with_npm.call + assert check_with_npm.bootstrap_actions.any? { |b| b[:name] == "qmd" }, + "qmd repair should be attempted when npm is present" + assert_includes runner.calls, [ "npm", "install", "--global", "--prefix", "/tmp/hivedata/qmd", "@tobilu/qmd" ] + + # npm missing → qmd bootstrap is skipped (no spawn). + runner2 = FakeRunner.new + check_without_npm = Hive::Commands::Setup::DependencyCheck.new( + which: fake_which(present: %w[ruby git tmux gh claude codex node sqlite3]), + runner: runner2, + data_home: "/tmp/hivedata", + bootstrap: true + ) + check_without_npm.call + skipped = check_without_npm.bootstrap_actions.find { |b| b[:name] == "qmd" } + assert_equal "skipped", skipped[:status] + refute runner2.calls.any? { |argv| argv.first == "npm" }, + "qmd repair must not run when npm is absent" + end + + def test_bundle_failed_reports_fix_and_bootstraps_only_for_app_dir + Dir.mktmpdir("hive-app") do |app_dir| + File.write(File.join(app_dir, "Gemfile"), "source 'https://rubygems.org'\n") + + # Without bootstrap: bundle check fails → bundle_failed with exact fix. + runner = FakeRunner.new(ok: { "bundle check" => false }) + check = Hive::Commands::Setup::DependencyCheck.new( + which: fake_which(present: ALL_PRESENT), + runner: runner, + app_dir: app_dir + ) + check.call + bundle = check.rows.find { |r| r[:name] == "web bundle" } + assert_equal "bundle_failed", bundle[:status] + assert bundle[:hive_owned] + assert_includes bundle[:fix_command], "bundle install" + + # With bootstrap: bundle install runs (Hive-owned app dir) and the row + # flips to present; the bootstrapped action is recorded. + runner2 = FakeRunner.new(ok: { "bundle check" => false }) + check2 = Hive::Commands::Setup::DependencyCheck.new( + which: fake_which(present: ALL_PRESENT), + runner: runner2, + app_dir: app_dir, + bootstrap: true + ) + check2.call + install = check2.bootstrap_actions.find { |b| b[:name] == "web bundle" } + assert_equal "installed", install[:status] + assert_equal "present", check2.rows.find { |r| r[:name] == "web bundle" }[:status] + assert runner2.calls.any? { |argv| argv == [ "bundle", "install", "--gemfile=#{File.join(app_dir, "Gemfile")}" ] }, + "bundle bootstrap should be attempted for a Hive-owned app dir" + end + end + + def test_no_app_dir_bundle_is_not_applicable + check = Hive::Commands::Setup::DependencyCheck.new( + which: fake_which(present: %w[ruby git tmux gh claude codex node npm qmd sqlite3]), + runner: FakeRunner.new, + app_dir: nil + ) + check.call + bundle = check.rows.find { |r| r[:name] == "web bundle" } + assert_equal "not_applicable", bundle[:status] + end + + def test_json_envelope_shape + out = StringIO.new + check = Hive::Commands::Setup::DependencyCheck.new( + json: true, + output: out, + which: fake_which(present: %w[ruby git tmux gh claude codex node npm qmd sqlite3]), + runner: FakeRunner.new + ) + check.call + doc = JSON.parse(out.string) + assert_equal "hive-dependency-check", doc.fetch("schema") + assert_equal true, doc.fetch("ok") + assert doc.fetch("rows").all? { |r| r.key?("fix_command") && r.key?("hive_owned") } + end + + def test_missing_external_cli_spawns_no_binary + # `which` returns nil for everything (bare minimal PATH) — the checker + # must not attempt to run any missing binary. + runner = FakeRunner.new + check = Hive::Commands::Setup::DependencyCheck.new( + which: ->(_name) { nil }, + runner: runner + ) + check.call + assert_empty runner.calls, "no external CLI may be spawned when nothing is on PATH" + end +end diff --git a/test/unit/commands/setup/setup_test.rb b/test/unit/commands/setup/setup_test.rb new file mode 100644 index 000000000..865544c38 --- /dev/null +++ b/test/unit/commands/setup/setup_test.rb @@ -0,0 +1,179 @@ +require "test_helper" +require "hive/commands/setup" +require "hive/commands/setup/backend_prompt" +require "hive/commands/setup/dependency_check" +require "hive/commands/service_installer/outcome" + +class SetupTest < Minitest::Test + include HiveTestHelper + + # A fake backend prompt: returns a fixed selection, or raises Aborted. + class FakeBackendPrompt + def initialize(selection, abort: false) + @selection = selection + @abort = abort + end + + def collect + raise Hive::Commands::Setup::BackendPrompt::Aborted, "EOF" if @abort + + @selection + end + end + + class FakeDependencyCheck + def initialize(rows, ok:, code:) + @rows = rows + @ok = ok + @code = code + end + + attr_reader :rows + + def call + @code + end + + def bootstrap_actions + [] + end + end + + class FakeInstaller + def initialize(outcome) + @outcome = outcome + end + + def install!(autostart:, force:) + @outcome + end + + def target_path + "/tmp/fake-unit" + end + end + + def setup + @home = Dir.mktmpdir("hive-setup") + File.write(File.join(@home, "config.yml"), { "registered_projects" => [] }.to_yaml) + end + + def teardown + FileUtils.rm_rf(@home) if @home + end + + def with_home + with_env("HIVE_HOME" => @home) { yield } + end + + def ok_rows + %w[ruby git tmux gh claude codex node npm qmd sqlite3].map do |name| + { name: name, status: "present", detail: "#{name} present", fix_command: nil, hive_owned: false } + end + [ { name: "web bundle", status: "not_applicable", detail: "no app dir", fix_command: nil, hive_owned: true } ] + end + + def build(json: true, backend: nil, deps: nil, daemon: nil, web: nil, health: nil, non_interactive: false) + Hive::Commands::Setup.new( + json: json, + non_interactive: non_interactive, + output: StringIO.new, + input: StringIO.new, + backend_prompt: backend, + dependency_check: deps, + daemon_installer: daemon, + web_installer: web, + health: health + ) + end + + def test_full_flow_reaches_health_and_emits_envelope + with_home do + out = StringIO.new + setup = Hive::Commands::Setup.new( + json: true, output: out, input: StringIO.new, non_interactive: true, + backend_prompt: FakeBackendPrompt.new(%w[claude codex]), + dependency_check: FakeDependencyCheck.new(ok_rows, ok: true, code: 0), + daemon_installer: FakeInstaller.new(Hive::Commands::ServiceInstaller::Outcome.new(:written)), + web_installer: FakeInstaller.new(Hive::Commands::ServiceInstaller::Outcome.new(:written)), + health: ->(_url) { { ok: true, status: 200, body: { "ok" => true } } } + ) + assert_equal Hive::Commands::Setup::EXIT_OK, setup.call + doc = JSON.parse(out.string) + assert_equal "hive-setup", doc.fetch("schema") + assert_equal true, doc.fetch("ok") + names = doc.fetch("steps").map { |s| s["name"] } + assert_includes names, "dependencies" + assert_includes names, "daemon" + assert_includes names, "web" + assert_includes names, "health" + health = doc.fetch("steps").find { |s| s["name"] == "health" } + assert_equal "ok", health["status"] + assert_equal 200, doc.dig("health", "status") + end + end + + def test_missing_external_dep_reports_fix_required_nonzero + with_home do + rows = ok_rows.map { |r| r[:name] == "gh" ? r.merge(status: "auth_missing", fix_command: "gh auth login") : r } + dep = FakeDependencyCheck.new(rows, ok: false, code: 65) + out = StringIO.new + setup = Hive::Commands::Setup.new( + json: true, output: out, input: StringIO.new, non_interactive: true, + backend_prompt: FakeBackendPrompt.new(%w[claude]), + dependency_check: dep, + daemon_installer: FakeInstaller.new(Hive::Commands::ServiceInstaller::Outcome.new(:written)), + web_installer: FakeInstaller.new(Hive::Commands::ServiceInstaller::Outcome.new(:written)), + health: ->(_url) { { ok: true, status: 200, body: {} } } + ) + assert_equal Hive::Commands::Setup::EXIT_FIX_REQUIRED, setup.call + doc = JSON.parse(out.string) + assert_equal false, doc.fetch("ok") + assert_equal 65, doc.fetch("exit_code") + deps_step = doc.fetch("steps").find { |s| s["name"] == "dependencies" } + assert_equal "fix_required", deps_step["status"] + gh_row = doc.dig("dependencies", "rows").find { |r| r["name"] == "gh" } + assert_equal "gh auth login", gh_row["fix_command"] + end + end + + def test_non_interactive_persists_default_backends_without_prompting + with_home do + # non_interactive: true → defaults are used; the injected prompt.collect + # is never called (use an aborting prompt to prove it isn't touched). + out = StringIO.new + setup = Hive::Commands::Setup.new( + json: true, output: out, input: StringIO.new, non_interactive: true, + backend_prompt: FakeBackendPrompt.new(%w[claude codex], abort: true), + dependency_check: FakeDependencyCheck.new(ok_rows, ok: true, code: 0), + daemon_installer: FakeInstaller.new(Hive::Commands::ServiceInstaller::Outcome.new(:written)), + web_installer: FakeInstaller.new(Hive::Commands::ServiceInstaller::Outcome.new(:written)), + health: ->(_url) { { ok: true, status: 200, body: {} } } + ) + setup.call + doc = JSON.parse(out.string) + # defaults persisted (claude + codex), aborted prompt not consulted. + assert_includes doc.fetch("backend_selection"), "claude" + assert_includes doc.fetch("backend_selection"), "codex" + assert_equal true, doc.fetch("ok") + end + end + + def test_daemon_drift_is_reported_and_flips_ok + with_home do + out = StringIO.new + setup = Hive::Commands::Setup.new( + json: true, output: out, input: StringIO.new, non_interactive: true, + backend_prompt: FakeBackendPrompt.new(%w[claude]), + dependency_check: FakeDependencyCheck.new(ok_rows, ok: true, code: 0), + daemon_installer: FakeInstaller.new(Hive::Commands::ServiceInstaller::Outcome.new(:drifted)), + web_installer: FakeInstaller.new(Hive::Commands::ServiceInstaller::Outcome.new(:written)), + health: ->(_url) { { ok: true, status: 200, body: {} } } + ) + assert_equal Hive::Commands::Setup::EXIT_FIX_REQUIRED, setup.call + doc = JSON.parse(out.string) + daemon_step = doc.fetch("steps").find { |s| s["name"] == "daemon" } + assert_equal "drifted", daemon_step["status"] + assert_match(/install --force/, daemon_step["detail"]) + end + end +end diff --git a/test/unit/commands/web/service_installer_test.rb b/test/unit/commands/web/service_installer_test.rb new file mode 100644 index 000000000..f4a436285 --- /dev/null +++ b/test/unit/commands/web/service_installer_test.rb @@ -0,0 +1,142 @@ +require "test_helper" +require "hive/commands/web/service_installer" + +class WebServiceInstallerTest < Minitest::Test + include HiveTestHelper + + def test_linux_writes_systemd_unit_with_run_foreground + with_tmp_dir do |dir| + commands = [] + hive = File.join(dir, "bin", "hive") + FileUtils.mkdir_p(File.dirname(hive)) + File.write(hive, "#!/bin/sh\n") + FileUtils.chmod(0755, hive) + + installer = Hive::Commands::Web::ServiceInstaller.new( + host_os: "linux-gnu", + home: dir, + binary_path: hive, + systemctl_available: true, + runner: ->(argv) { commands << argv } + ) + + installer.install!(autostart: false) + unit = File.join(dir, ".config/systemd/user/hive-web.service") + assert File.exist?(unit) + body = File.read(unit) + assert_includes body, "ExecStart=#{hive} web run", + "the web unit must run the foreground server so the service manager supervises it" + assert_includes body, "Environment=HIVE_BIN=#{hive}" + assert_includes body, "Environment=PATH=" + assert_empty commands + end + end + + def test_linux_autostart_invokes_enable_for_web_unit + with_tmp_dir do |dir| + commands = [] + installer = Hive::Commands::Web::ServiceInstaller.new( + host_os: "linux", + home: dir, + binary_path: "/tmp/hive", + systemctl_available: true, + runner: ->(argv) { commands << argv } + ) + + installer.install!(autostart: true) + assert_includes commands, %w[systemctl --user daemon-reload] + assert_includes commands, %w[systemctl --user enable --now hive-web] + end + end + + def test_linux_without_systemd_writes_unit_and_reports_autostart_unavailable + with_tmp_dir do |dir| + installer = Hive::Commands::Web::ServiceInstaller.new( + host_os: "linux", + home: dir, + binary_path: "/tmp/hive", + systemctl_available: false + ) + + result = installer.install!(autostart: true) + assert_equal :autostart_unavailable, result.kind + assert File.exist?(File.join(dir, ".config/systemd/user/hive-web.service")) + assert installer.messages.any? { |msg| msg.include?("autostart was not enabled") } + end + end + + def test_macos_writes_plist_with_web_run + with_tmp_dir do |dir| + installer = Hive::Commands::Web::ServiceInstaller.new( + host_os: "darwin23", + home: dir, + binary_path: "/opt/hive/bin/hive", + runner: ->(_argv) { true } + ) + + installer.install!(autostart: false) + plist = File.join(dir, "Library/LaunchAgents/local.hive-web.plist") + assert File.exist?(plist) + assert_includes File.read(plist), "/opt/hive/bin/hive" + end + end + + def test_freebsd_returns_unsupported + with_tmp_dir do |dir| + installer = Hive::Commands::Web::ServiceInstaller.new( + host_os: "freebsd14", + home: dir, + binary_path: "/tmp/hive" + ) + + result = installer.install!(autostart: true) + assert_equal :unsupported, result.kind + assert installer.messages.any? { |msg| msg.include?("web autostart not supported") } + end + end + + def test_drifted_existing_unit_is_not_overwritten + with_tmp_dir do |dir| + unit = File.join(dir, ".config/systemd/user/hive-web.service") + FileUtils.mkdir_p(File.dirname(unit)) + File.write(unit, "custom\n") + installer = Hive::Commands::Web::ServiceInstaller.new( + host_os: "linux", + home: dir, + binary_path: "/tmp/hive", + systemctl_available: false + ) + + result = installer.install!(autostart: false) + assert_equal :drifted, result.kind + assert_equal "custom\n", File.read(unit) + end + end + + def test_force_overwrites_drifted_unit_and_writes_backup + with_tmp_dir do |dir| + unit = File.join(dir, ".config/systemd/user/hive-web.service") + FileUtils.mkdir_p(File.dirname(unit)) + File.write(unit, "previous-stale-content\n") + installer = Hive::Commands::Web::ServiceInstaller.new( + host_os: "linux", + home: dir, + binary_path: "/tmp/hive", + systemctl_available: false + ) + + result = installer.install!(autostart: false, force: true) + assert_equal :upgraded, result.kind + backups = Dir["#{unit}.bak-*"] + assert_equal 1, backups.size + assert_equal "previous-stale-content\n", File.read(backups.first) + assert_includes File.read(unit), "ExecStart=/tmp/hive web run" + end + end + + def test_launchd_label_and_platform + macos = Hive::Commands::Web::ServiceInstaller.new(host_os: "darwin23") + assert_equal "local.hive-web", macos.launchd_label + assert_equal "macos", macos.envelope_platform + end +end diff --git a/test/unit/commands/web/web_command_test.rb b/test/unit/commands/web/web_command_test.rb new file mode 100644 index 000000000..5bf8a57a0 --- /dev/null +++ b/test/unit/commands/web/web_command_test.rb @@ -0,0 +1,231 @@ +require "test_helper" +require "hive/commands/web" + +class WebCommandTest < Minitest::Test + include HiveTestHelper + + def setup + @home = Dir.mktmpdir("hive-webcmd") + @config = { + "registered_projects" => [], + "web" => { + "bind" => "127.0.0.1", + "port" => 4567, + "origin" => "http://127.0.0.1:4567", + "github" => { "owner" => nil, "client_id" => "test" }, + "session_secret_file" => nil + } + } + File.write(File.join(@home, "config.yml"), @config.to_yaml) + end + + def teardown + FileUtils.rm_rf(@home) if @home + end + + def with_home + with_env("HIVE_HOME" => @home) do + yield + end + end + + def test_unknown_subcommand_raises + with_home do + cmd = Hive::Commands::Web.new("bogus") + err = assert_raises(Hive::InvalidTaskPath) { cmd.call } + assert_includes err.message, "unknown subcommand" + end + end + + def test_install_writes_unit_and_emits_json_envelope + with_home do + hive = File.join(@home, "bin", "hive") + FileUtils.mkdir_p(File.dirname(hive)) + File.write(hive, "#!/bin/sh\n") + FileUtils.chmod(0755, hive) + + cmd = Hive::Commands::Web.new( + "install", json: true, home: @home, host_os: "linux", + systemctl_available: true, runner: ->(_argv) { true } + ) + cmd.define_singleton_method(:current_binary_path) { hive } + + out, _err = capture_io { cmd.call } + doc = JSON.parse(out) + assert_equal "hive-web-install", doc.fetch("schema") + assert_equal true, doc.fetch("ok") + assert_equal "written", doc.fetch("outcome") + assert_includes doc.fetch("target_path"), "hive-web.service" + assert_includes doc.fetch("target_path"), @home + end + end + + def test_install_error_classes_carry_stable_exit_codes + assert_equal Hive::ExitCodes::SOFTWARE, + Hive::Commands::Web::WebInstallFailed.new("x").exit_code + assert_equal Hive::ExitCodes::USAGE, + Hive::Commands::Web::WebInstallDriftError.new("x").exit_code + end + + def test_second_install_without_force_emits_drift_error_envelope + with_home do + hive = File.join(@home, "bin", "hive") + FileUtils.mkdir_p(File.dirname(hive)) + File.write(hive, "#!/bin/sh\n") + FileUtils.chmod(0755, hive) + + # Pre-write a *different* unit to force drift. Commander path uses + # the injectable home so the unit lands under the sandbox. Pass + # systemctl_available to avoid the unknown-systemctl → unsupported + # outcome; + cmd = Hive::Commands::Web.new( + "install", json: true, home: @home, host_os: "linux", systemctl_available: false + ) + cmd.define_singleton_method(:current_binary_path) { hive } + capture_io { cmd.call } # first install writes the unit + + unit = File.join(@home, ".config/systemd/user/hive-web.service") + assert File.exist?(unit) + File.write(unit, "stale\n") + + out, _err = capture_io do + assert_raises(Hive::Commands::Web::WebInstallDriftError) { cmd.call } + end + doc = JSON.parse(out) + assert_equal "hive-web-install", doc.fetch("schema") + assert_equal false, doc.fetch("ok") + assert_equal "drifted", doc.fetch("error_kind") + assert_equal 64, doc.fetch("exit_code") + end + end + + def test_start_maps_to_systemctl_start + with_home do + commands = [] + cmd = Hive::Commands::Web.new( + "start", home: @home, host_os: "linux", + runner: ->(argv) { commands << argv; true } + ) + cmd.define_singleton_method(:current_binary_path) { "/tmp/hive" } + out, _err = capture_io { cmd.call } + assert_includes commands, %w[systemctl --user start hive-web] + assert_includes out, "start requested" + end + end + + def test_stop_maps_to_systemctl_stop + with_home do + commands = [] + cmd = Hive::Commands::Web.new( + "stop", home: @home, host_os: "linux", + runner: ->(argv) { commands << argv; true } + ) + capture_io { cmd.call } + assert_includes commands, %w[systemctl --user stop hive-web] + end + end + + def test_start_json_emits_hive_web_start_envelope + with_home do + cmd = Hive::Commands::Web.new( + "start", json: true, home: @home, host_os: "linux", + runner: ->(_argv) { true } + ) + out, _err = capture_io { cmd.call } + doc = JSON.parse(out) + assert_equal "hive-web-start", doc.fetch("schema") + assert_equal true, doc.fetch("ok") + assert_includes doc.fetch("unit_path"), "hive-web.service" + end + end + + def test_stop_json_emits_hive_web_stop_envelope + with_home do + cmd = Hive::Commands::Web.new( + "stop", json: true, home: @home, host_os: "linux", + runner: ->(_argv) { true } + ) + out, _err = capture_io { cmd.call } + doc = JSON.parse(out) + assert_equal "hive-web-stop", doc.fetch("schema") + assert_equal true, doc.fetch("ok") + assert_includes doc.fetch("unit_path"), "hive-web.service" + end + end + + def test_status_maps_to_systemctl_is_active + with_home do + commands = [] + cmd = Hive::Commands::Web.new( + "status", home: @home, host_os: "linux", + runner: ->(argv) { commands << argv; false } + ) + out, _err = capture_io do + assert_raises(Hive::Error) { cmd.call } + end + assert_includes commands, %w[systemctl --user is-active hive-web] + assert_includes out, "running: no" + end + end + + def test_status_json_envelope + with_home do + cmd = Hive::Commands::Web.new( + "status", json: true, home: @home, host_os: "linux", + runner: ->(_argv) { false } + ) + out, _err = capture_io { cmd.call } + doc = JSON.parse(out) + assert_equal "hive-web-status", doc.fetch("schema") + assert_equal false, doc.fetch("running") + assert_equal Hive::VERSION, doc.fetch("current_version") + end + end + + def test_refused_bind_loopback_never_refused + with_home do + cmd = Hive::Commands::Web.new("run") + refute cmd.send(:refused_bind?, bind: "127.0.0.1") + refute cmd.send(:refused_bind?, bind: "::1") + refute cmd.send(:refused_bind?, bind: "localhost") + end + end + + def test_refused_bind_non_loopback_without_owner_refused + with_home do + cmd = Hive::Commands::Web.new("run") + assert cmd.send(:refused_bind?, bind: "0.0.0.0"), + "non-loopback bind with no owner and no --allow-non-loopback must be refused (fail-closed)" + end + end + + def test_refused_bind_allowed_with_flag + with_home do + cmd = Hive::Commands::Web.new("run", allow_non_loopback: true) + refute cmd.send(:refused_bind?, bind: "0.0.0.0"), + "--allow-non-loopback must permit a non-loopback bind" + end + end + + def test_refused_bind_with_owner_allows_non_loopback + with_home do + @config["web"]["github"]["owner"] = "someone" + File.write(File.join(@home, "config.yml"), @config.to_yaml) + cmd = Hive::Commands::Web.new("run") + refute cmd.send(:refused_bind?, bind: "0.0.0.0"), + "a non-loopback bind with web.github.owner set is authorized (owner gate present)" + end + end + + def test_refused_bind_reflects_owner_change + with_home do + cmd = Hive::Commands::Web.new("run") + assert cmd.send(:refused_bind?, bind: "127.0.0.2"), + "non-loopback with no owner must be refused" + @config["web"]["github"]["owner"] = "someone" + File.write(File.join(@home, "config.yml"), @config.to_yaml) + cmd2 = Hive::Commands::Web.new("run") + refute cmd2.send(:refused_bind?, bind: "127.0.0.2") + end + end +end diff --git a/test/unit/exit_codes_test.rb b/test/unit/exit_codes_test.rb index af2c6f8fa..52a80aabb 100644 --- a/test/unit/exit_codes_test.rb +++ b/test/unit/exit_codes_test.rb @@ -11,6 +11,7 @@ class ExitCodesTest < Minitest::Test assert_equal 3, Hive::ExitCodes::TASK_IN_ERROR assert_equal 4, Hive::ExitCodes::WRONG_STAGE assert_equal 64, Hive::ExitCodes::USAGE + assert_equal 65, Hive::ExitCodes::FIX_REQUIRED assert_equal 70, Hive::ExitCodes::SOFTWARE assert_equal 75, Hive::ExitCodes::TEMPFAIL assert_equal 78, Hive::ExitCodes::CONFIG diff --git a/test/unit/schema_files_test.rb b/test/unit/schema_files_test.rb index 2d10237de..0d4e722a2 100644 --- a/test/unit/schema_files_test.rb +++ b/test/unit/schema_files_test.rb @@ -1476,6 +1476,7 @@ class SchemaFilesTest < Minitest::Test producer_required = %w[ schema schema_version ok running pid uptime_sec pid_file log_file service_installed service_enabled unit_path current_version update_nudge + daemon_binary daemon_version drift_status drifted ].sort assert_equal producer_required, schema_required, "schema/producer required-key drift in hive-daemon-status.v1.json" diff --git a/test/unit/web/auth_policy_test.rb b/test/unit/web/auth_policy_test.rb new file mode 100644 index 000000000..c814ae870 --- /dev/null +++ b/test/unit/web/auth_policy_test.rb @@ -0,0 +1,33 @@ +require "test_helper" +require "hive/web/auth_policy" + +class AuthPolicyTest < Minitest::Test + UNOWNED = { "github" => { "owner" => nil } }.freeze + OWNED = { "github" => { "owner" => "someone" } }.freeze + + def test_loopback_binds + %w[127.0.0.1 ::1 localhost].each do |b| + assert Hive::Web::AuthPolicy.loopback_bind?(b), "#{b} must be loopback" + end + refute Hive::Web::AuthPolicy.loopback_bind?("0.0.0.0") + refute Hive::Web::AuthPolicy.loopback_bind?("192.168.1.5") + end + + def test_single_user_local_only_when_loopback_and_unowned + assert Hive::Web::AuthPolicy.single_user_local?(bind: "127.0.0.1", config: UNOWNED) + refute Hive::Web::AuthPolicy.single_user_local?(bind: "0.0.0.0", config: UNOWNED), + "a non-loopback bind is never single-user-local" + refute Hive::Web::AuthPolicy.single_user_local?(bind: "127.0.0.1", config: OWNED), + "an owner-claimed box keeps the owner gate even on loopback" + end + + def test_allows_unauthenticated_loopback_request_when_unowned + assert Hive::Web::AuthPolicy.allows_unauthenticated?(host: "127.0.0.1", config: UNOWNED) + assert Hive::Web::AuthPolicy.allows_unauthenticated?(host: "::1", config: UNOWNED) + assert Hive::Web::AuthPolicy.allows_unauthenticated?(host: "localhost", config: UNOWNED) + refute Hive::Web::AuthPolicy.allows_unauthenticated?(host: "10.0.0.8", config: UNOWNED), + "a non-loopback request must never bypass the owner gate" + refute Hive::Web::AuthPolicy.allows_unauthenticated?(host: "127.0.0.1", config: OWNED), + "an owner-claimed box must not bypass the gate even on loopback" + end +end diff --git a/test/unit/web/web_command_test.rb b/test/unit/web/web_command_test.rb index 11de4ab06..368161332 100644 --- a/test/unit/web/web_command_test.rb +++ b/test/unit/web/web_command_test.rb @@ -34,18 +34,36 @@ class WebCommandTest < Minitest::Test end end - def test_public_bind_without_https_origin_warns + # U4 fail-closed bind semantics (replaces the old loud-but-warns-only + # `warn_on_public_bind`): loopback always allowed; a non-loopback bind + # without web.github.owner and without --allow-non-loopback is REFUSED. + def test_refused_bind_is_fail_closed with_tmp_global_config do command = Hive::Commands::Web.new - _out, err = capture_io do - command.send(:warn_on_public_bind, "0.0.0.0", { "origin" => "http://example.test" }) - end - assert_match(/WARNING binding 0.0.0.0/, err, "plain-http public bind must warn about Host validation") + refute command.send(:refused_bind?, bind: "127.0.0.1"), + "loopback bind must never be refused (single-user local mode)" + refute command.send(:refused_bind?, bind: "::1") + assert command.send(:refused_bind?, bind: "0.0.0.0"), + "a non-loopback bind with no owner and no --allow-non-loopback must be refused (fail-closed)" + end + end - _out, err = capture_io do - command.send(:warn_on_public_bind, "0.0.0.0", { "origin" => "https://example.test" }) + def test_refused_bind_allowed_with_flag_or_owner + with_tmp_global_config do + flagged = Hive::Commands::Web.new(allow_non_loopback: true) + refute flagged.send(:refused_bind?, bind: "0.0.0.0"), + "--allow-non-loopback must permit a non-loopback bind" + + # With web.github.owner set, the owner gate authorizes non-loopback. + with_tmp_dir do |dir| + File.write(File.join(dir, "config.yml"), + { "web" => { "github" => { "owner" => "someone" } } }.to_yaml) + with_env("HIVE_HOME" => dir) do + owned = Hive::Commands::Web.new + refute owned.send(:refused_bind?, bind: "0.0.0.0"), + "a non-loopback bind with web.github.owner set is authorized" + end end - assert_empty err, "an https origin implies a fronting proxy — no warning" end end # Drive the full "app found" path with a stub Rails app: db:prepare diff --git a/web/app/assets/stylesheets/application.css b/web/app/assets/stylesheets/application.css index d0ae69f49..7fb13d704 100644 --- a/web/app/assets/stylesheets/application.css +++ b/web/app/assets/stylesheets/application.css @@ -210,6 +210,29 @@ label { font-size: 0.9rem; color: var(--ink-muted); display: block; margin-botto .flash-notice { background: var(--ok-soft); color: var(--ok); border-color: color-mix(in srgb, var(--ok) 30%, transparent); } .flash-alert { background: var(--danger-soft); color: var(--danger); border-color: color-mix(in srgb, var(--danger) 30%, transparent); } +/* ---- Daemon status (U5: health/repair in the status grid) ---- */ + +.daemon-status { + display: flex; + align-items: center; + gap: 12px; + border-radius: var(--radius-sm); + padding: 10px 16px; + margin-bottom: 18px; + font-size: 0.93rem; + border: 1px solid; +} +.daemon-status-drifted { + background: var(--danger-soft); + color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 30%, transparent); +} +.daemon-status-down { + background: var(--danger-soft); + color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 30%, transparent); +} + /* ---- Composer (new idea) ---- */ .composer { diff --git a/web/app/controllers/application_controller.rb b/web/app/controllers/application_controller.rb index 2a1e5e28f..fe397d2f1 100644 --- a/web/app/controllers/application_controller.rb +++ b/web/app/controllers/application_controller.rb @@ -11,7 +11,7 @@ class ApplicationController < ActionController::Base before_action :require_login - helper_method :current_login + helper_method :current_login, :local_single_user_allow? # Hive's typed errors are operator-readable by design ("task not in stage", # "invalid clone URL"). Render them on an error page instead of a blank @@ -49,7 +49,26 @@ class ApplicationController < ActionController::Base session[:github_login] end + # Local single-user mode (U4): a loopback request against a box with no + # claimed owner bypasses the GitHub owner gate entirely. Shares the + # loopback predicate with the CLI's fail-closed bind check (Web::AuthPolicy) + # so the CLI can never green-light a bind the app will 403, and the app + # never silently allows a non-loopback request. An owner-claimed box (or a + # non-loopback request) keeps the gate. + # + # Loopback-ness is derived from `request.remote_ip` (the trusted client + # address) NOT `request.host` — the Host header is attacker-controlled, so + # a request spoofing `Host: 127.0.0.1` against a non-loopback bind could + # otherwise skip the owner gate entirely (Risk-2 fail-closed break). + def local_single_user_allow? + Hive::Web::AuthPolicy.allows_unauthenticated?( + host: request.remote_ip, + config: Hive::Config.load_global_web + ) + end + def require_login + return if local_single_user_allow? 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/daemon_controller.rb b/web/app/controllers/daemon_controller.rb new file mode 100644 index 000000000..2903f8f9d --- /dev/null +++ b/web/app/controllers/daemon_controller.rb @@ -0,0 +1,55 @@ +require "hive/invoked_binary" +require "hive/pid_file" + +# U5: daemon repair/restart surfaced in the web UI. Only the loopback +# single-user case (a loopback request against a box with no claimed +# owner) may trigger this — the shared Web::AuthPolicy guard is the same +# one the CLI and require_login use, so a non-loopback or owner-claimed +# request is always refused even if the route is hit directly. +class DaemonController < ApplicationController + # Reads the daemon's pidfile the same way `hive daemon status` does — + # stale files and reused PIDs don't count as alive (mirrors the + # HealthController::DaemonProbe so liveness is consistent everywhere). + class DaemonProbe + include Hive::PidFile + + def pid_file + File.join(Hive::Paths.state_home, ".daemon.pid") + end + end + + # Repair/restart the daemon when binary/version drift is detected by the + # health probe. Delegates to the explicit `hive daemon repair` path (the + # same one `hive setup` uses) — drift is never auto-fixed elsewhere. The + # subprocess runs the SAME binary the CLI resolves rather than a bare + # `hive` from the web process's PATH, which could be a different/stale + # binary (the exact drift this endpoint exists to repair). + def restart + unless local_single_user_allow? + return render json: { ok: false, error: "not allowed" }, status: :forbidden + end + return render json: { ok: false, error: "daemon not running" }, status: :conflict unless daemon_running? + + binary = Hive::InvokedBinary.path || ENV["HIVE_BIN"] + unless binary + return render json: { ok: false, error: "cannot resolve hive binary" }, status: :internal_server_error + end + + # `hive daemon repair` on Linux runs `systemctl --user restart + # hive-daemon`, which can block up to TimeoutStopSec (900s) while + # in-flight children drain. Never block a web worker (and its + # thread-pool slot) on that — spawn the repair detached and report + # acceptance immediately; the status grid re-checks drift on its next + # refresh. + pid = Process.spawn(binary, "daemon", "repair", + out: File::NULL, err: File::NULL, pgroup: true) + Process.detach(pid) + render json: { ok: true, started: true, repaired: false } + end + + private + + def daemon_running? + DaemonProbe.new.read_live_pid + end +end diff --git a/web/app/controllers/health_controller.rb b/web/app/controllers/health_controller.rb index cda67e408..fe93b442e 100644 --- a/web/app/controllers/health_controller.rb +++ b/web/app/controllers/health_controller.rb @@ -1,4 +1,5 @@ require "hive/pid_file" +require "hive/daemon/drift" class HealthController < ApplicationController skip_before_action :require_login @@ -23,7 +24,24 @@ class HealthController < ApplicationController daemon_pid = DaemonProbe.new.read_live_pid if daemon_pid - render json: { ok: true, daemon: { running: true, pid: daemon_pid } } + # U5: surface the RUNNING daemon's binary/version consistency with the + # CLI (drift) so the web status grid can offer a repair/restart. Drift + # is reported, never silently auto-fixed here. + drift = + begin + Hive::Daemon::Drift.new(pid: daemon_pid).resolve + rescue StandardError + { "status" => "unverified", "drifted" => nil } + end + render json: { + ok: true, + daemon: { + running: true, pid: daemon_pid, + binary: drift["binary"], version: drift["version"], + current_version: Hive::VERSION, + drift_status: drift["status"], drifted: drift["drifted"] + } + } else render json: { ok: false, daemon: { running: false } }, status: :service_unavailable end diff --git a/web/app/controllers/status_controller.rb b/web/app/controllers/status_controller.rb index c44440a83..42b59f020 100644 --- a/web/app/controllers/status_controller.rb +++ b/web/app/controllers/status_controller.rb @@ -1,6 +1,37 @@ +require "hive/pid_file" +require "hive/daemon/drift" + class StatusController < ApplicationController def index @payload = StatusBroadcaster.snapshot @projects = @payload.fetch("projects", []) + @daemon = daemon_status + end + + private + + # U5: surface daemon health/repair in the status grid. Resolves the live + # daemon's binary/version consistency with the CLI (drift) using the same + # pidfile liveness rules as `/health?deep=1`. Never raises out of the grid + # render — a failed probe degrades to `running: false` so one bad daemon + # read can't blank the whole dashboard. + def daemon_status + daemon_pid = HealthController::DaemonProbe.new.read_live_pid + return { running: false } unless daemon_pid + + drift = + begin + Hive::Daemon::Drift.new(pid: daemon_pid).resolve + rescue StandardError + { "status" => "unverified", "drifted" => nil } + end + { + running: true, + pid: daemon_pid, + drift_status: drift["status"], + drifted: drift["drifted"] + } + rescue StandardError + { running: false } end end diff --git a/web/app/views/status/index.html.erb b/web/app/views/status/index.html.erb index 25bcdf793..c2ab1b192 100644 --- a/web/app/views/status/index.html.erb +++ b/web/app/views/status/index.html.erb @@ -26,6 +26,26 @@
+<%# U5: daemon health/repair surfaced in the status grid. A down or drifted + daemon is a box-level condition, not a per-project one, so it sits above + the project grid. Repair delegates to the same `hive daemon repair` path + the CLI uses; the button only renders when drift was actually detected AND + the request is in single-user local mode (the same gate the controller + enforces — an owner-claimed box would otherwise show a button the + endpoint then 403s). %> +<% if @daemon && @daemon[:running] && @daemon[:drifted] && local_single_user_allow? %> +
+ Daemon is running a different binary/version than the CLI. + <%= button_to "Repair daemon", daemon_restart_path, method: :post, + class: "btn btn-danger btn-sm", form_class: "inline-form", + data: { turbo_confirm: "Rewrite the daemon unit to the CLI's binary and restart it?" } %> +
+<% elsif @daemon && !@daemon[:running] %> +
+ Daemon is not running — new tasks will not be picked up automatically. +
+<% end %> + <%# 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 f2792b55a..881e907a0 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/auth_policy" 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 659576df3..21f1164a2 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -3,6 +3,10 @@ Rails.application.routes.draw do get "health" => "health#show" get "up" => "rails/health#show", as: :rails_health_check + # U5: daemon repair/restart surfaced in the web UI — loopback single-user + # only (the controller refuses otherwise). + post "daemon/restart" => "daemon#restart", as: :daemon_restart + # GitHub device-flow sign-in (RFC 8628). Start is a POST (it creates a # GitHub device code); the wait page polls the grant. # Development/test only: the auth seam Capybara logs in through (and dev diff --git a/web/test/e2e/local_mode_e2e.rb b/web/test/e2e/local_mode_e2e.rb new file mode 100644 index 000000000..0c2041240 --- /dev/null +++ b/web/test/e2e/local_mode_e2e.rb @@ -0,0 +1,192 @@ +require "application_system_test_case" +require "open3" + +# U6 acceptance — the first-class local (non-Docker) web mode, end to end. +# Deliberately NOT named *_test.rb so the default suites skip it; run it +# explicitly: +# +# cd web && bin/rails test test/e2e/local_mode_e2e.rb +# +# Proves the definition-of-done scenario from the plan's Requirements Trace: +# one `hive setup` command (non-interactive) enrolls the repo, writes daemon +# and web units pointing at the SAME binary/version as the CLI, brings the +# web UI up at the loopback bind, and a task created in the TUI appears in +# the web UI (and vice versa) because both read the same local Hive/XDG +# state. Only the agent binary is stubbed (the stage-aware fake claude from +# the golden-path E2E); the daemon, git, worktrees, and the Rails app are +# all real. +class LocalModeE2E < ApplicationSystemTestCase + REPO_ROOT = File.expand_path("../../..", __dir__) + SUPPORT = File.expand_path("support", __dir__) + HIVE_BIN = File.join(REPO_ROOT, "bin", "hive") + + setup do + configure_owner!(owner: "") + speed_up_daemon! + @project = create_fresh_git_repo!("local-app") + @setup_home = File.join(ENV["HIVE_TEST_HOME_ROOT"], "setup-home") + FileUtils.mkdir_p(@setup_home) + run_hive_setup! + Hive::Commands::Init.new(File.join(ENV["HIVE_TEST_HOME_ROOT"], "repos", @project), force: true, json: false).call + force_headless_claude!(@project) + StatusBroadcaster.start! + install_github_stub(login: "localmode") + spawn_daemon! + end + + teardown do + if @daemon_pid + Process.kill("TERM", @daemon_pid) + Process.wait(@daemon_pid) + end + StatusBroadcaster.stop! + SessionsController.http_client = Net::HTTP + end + + test "one setup command enrolls the repo, writes same-binary units, and round-trips a task" do + # --- `hive setup` enrollment + same-binary units ---------------------- + config = YAML.safe_load_file(File.join(ENV["HIVE_HOME"], "config.yml")) + registered = config.fetch("registered_projects", []) + assert registered.any? { |p| File.basename(p["path"]) == @project }, + "hive setup must enroll the current repo" + assert_equal "ok", @setup_doc.fetch("steps").find { |s| s["name"] == "enroll" }["status"], + "the enroll step must report ok, not a hint" + + daemon_unit = File.join(@setup_home, ".config/systemd/user/hive-daemon.service") + assert File.exist?(daemon_unit), "hive setup must write the daemon unit" + assert_includes File.read(daemon_unit), "ExecStart=#{HIVE_BIN} daemon start", + "daemon unit must point at the same binary as the CLI" + web_unit = File.join(@setup_home, ".config/systemd/user/hive-web.service") + assert File.exist?(web_unit), "hive setup must write the web unit" + assert_includes File.read(web_unit), "ExecStart=#{HIVE_BIN} web run", + "web unit must point at the same binary as the CLI" + + # --- TUI → web: a CLI-created task appears and advances in the web UI -- + tui_slug = create_task!(@project, "Local mode from the TUI") + visit "/" + assert_selector ".task-row", text: "Local mode from the TUI", wait: 10 + assert_selector ".task-row .stage-badge", text: /brainstorm|plan|execute|open-pr|review|artifacts|finalize/, + wait: 90 + + # --- web → TUI: a web-created task appears in `hive status` ------------ + fill_in "New idea", with: "Local mode from the web" + find(".composer select[name='project']").find("option[value='#{@project}']").select_option + click_button "Add idea" + assert_selector ".task-row", text: "Local mode from the web", wait: 10 + + status_out, _status_err, status = Open3.capture3( + hive_status_env, "ruby", "-Ilib", HIVE_BIN, "status", "--json", + chdir: REPO_ROOT + ) + assert status.success?, "hive status must succeed after a web-created idea" + status_doc = JSON.parse(status_out) + # The web-created idea lands in 1-inbox and is visible to `hive status`. + slugs = status_doc.fetch("projects", []).flat_map { |p| p.fetch("tasks", []).map { |t| t["slug"] } } + assert_operator slugs.length, :>=, 1, "hive status must see the web-created task" + assert tui_slug, "the TUI-created task slug must be resolvable" + end + + private + + # A real git repo WITHOUT `hive init` — `hive setup` is the enrollment + # entry point under test, not the pre-initialized project. + def create_fresh_git_repo!(name) + dir = File.join(ENV["HIVE_TEST_HOME_ROOT"], "repos", name) + FileUtils.mkdir_p(dir) + system("git", "init", "-q", dir, exception: true) + system("git", "-C", dir, "config", "user.email", "test@example.com", exception: true) + system("git", "-C", dir, "config", "user.name", "Hive Test", exception: true) + File.write(File.join(dir, "README.md"), "# #{name}\n") + system("git", "-C", dir, "add", ".", exception: true) + system("git", "-C", dir, "-c", "user.email=test@example.com", "-c", "user.name=Test", + "commit", "-qm", "init", exception: true) + name + end + + # Run the REAL one-shot setup in a sandboxed HOME (so the unit files land + # in the sandbox, never the developer's real ~/.config) and capture the + # envelope for assertions. + def run_hive_setup! + env = { + "HIVE_HOME" => ENV["HIVE_HOME"], + "HOME" => @setup_home, + "PATH" => ENV["PATH"], + "BUNDLE_GEMFILE" => File.join(REPO_ROOT, "Gemfile"), + "RUBYOPT" => nil, "RUBYLIB" => nil + } + out, err, status = Open3.capture3( + env, "ruby", "-Ilib", HIVE_BIN, "setup", "--json", "--non-interactive", + chdir: File.join(ENV["HIVE_TEST_HOME_ROOT"], "repos", @project) + ) + assert status.success?, "hive setup failed (#{status.exitstatus}): #{err}\n#{out}" + @setup_doc = JSON.parse(out) + end + + def hive_status_env + { + "HIVE_HOME" => ENV["HIVE_HOME"], + "BUNDLE_GEMFILE" => File.join(REPO_ROOT, "Gemfile"), + "RUBYOPT" => nil, "RUBYLIB" => nil + } + end + + def speed_up_daemon! + path = File.join(ENV["HIVE_HOME"], "config.yml") + data = File.exist?(path) ? YAML.safe_load_file(path) : {} + data ||= {} + data["daemon"] = { "poll_interval_sec" => 5, "fast_poll_sec" => 1, "edit_debounce_sec" => 1 } + File.write(path, data.to_yaml) + end + + def force_headless_claude!(project) + path = File.join(ENV["HIVE_TEST_HOME_ROOT"], "repos", project, ".hive-state", "config.yml") + data = YAML.safe_load_file(path) + data["claude"] = (data["claude"] || {}).merge("mode" => "headless") + data["execute"] = (data["execute"] || {}).merge("agent" => "claude") + data["worktree_root"] = File.join(ENV["HIVE_TEST_HOME_ROOT"], "worktrees") + File.write(path, data.to_yaml) + end + + def install_github_stub(login:) + device = http_ok(JSON.generate( + "device_code" => "dev-1", "user_code" => "ABCD-1234", + "verification_uri" => "https://github.com/login/device", + "expires_in" => 900, "interval" => 1 + )) + token = http_ok(JSON.generate("access_token" => "gho_e2e")) + user = http_ok(JSON.generate("login" => login)) + SessionsController.http_client = Class.new do + define_method(:start) { |_host, _port, **_opts| yield self } + define_method(:request) do |req| + if req.uri.host == "api.github.com" + user + else + req.path.include?("/login/device/code") ? device : token + end + end + end.new + end + + def http_ok(body) + res = Net::HTTPOK.new("1.1", "200", "OK") + res.instance_variable_set(:@read, true) + res.define_singleton_method(:body) { body } + res + end + + def spawn_daemon! + env = { + "HIVE_HOME" => ENV["HIVE_HOME"], + "HIVE_WORKTREE_BASE" => File.join(ENV["HIVE_TEST_HOME_ROOT"], "worktrees"), + "PATH" => "#{SUPPORT}:#{ENV["PATH"]}", + "BUNDLE_GEMFILE" => File.join(REPO_ROOT, "Gemfile"), + "BUNDLE_PATH" => ENV["GOLDEN_E2E_BUNDLE_PATH"], + "BUNDLE_APP_CONFIG" => nil, "BUNDLE_DEPLOYMENT" => nil, "BUNDLE_FROZEN" => nil, + "RUBYOPT" => nil, "RUBYLIB" => nil + } + @daemon_log = ENV.fetch("GOLDEN_E2E_DAEMON_LOG", File.join(ENV["HIVE_TEST_HOME_ROOT"], "local-mode-daemon.log")) + @daemon_pid = Process.spawn(env, "bundle", "exec", "ruby", "-Ilib", HIVE_BIN, + "daemon", "start", "--foreground", + chdir: REPO_ROOT, out: @daemon_log, err: @daemon_log) + end +end diff --git a/web/test/integration/daemon_controller_test.rb b/web/test/integration/daemon_controller_test.rb new file mode 100644 index 000000000..053fe1aad --- /dev/null +++ b/web/test/integration/daemon_controller_test.rb @@ -0,0 +1,75 @@ +require "test_helper" +require "hive/pid_file" + +# U5 — web-surfaced daemon repair/restart. Only the loopback single-user +# case (loopback host, no claimed owner) may trigger it; a non-loopback or +# owner-claimed request is refused. +class DaemonControllerTest < ActionDispatch::IntegrationTest + include Hive::PidFile + + test "loopback single-user may request daemon restart" do + configure_owner!(owner: "") + host! "127.0.0.1" + write_pid_file! + + # The controller must resolve the SAME binary the CLI resolves, not a + # bare `hive` from PATH — pin it to a no-op that exits 0 so the test + # asserts the resolution + dispatch, not the real repair side effects. + original_path = Hive::InvokedBinary.method(:path) + Hive::InvokedBinary.define_singleton_method(:path) { "/bin/true" } + begin + post daemon_restart_path + assert_response :success + assert_equal true, response.parsed_body["ok"] + ensure + Hive::InvokedBinary.define_singleton_method(:path, original_path) + end + ensure + FileUtils.rm_f(pid_path) + end + + test "owner-claimed box is refused even on loopback" do + configure_owner!(owner: "alice") + host! "127.0.0.1" + + post daemon_restart_path + assert_response :forbidden + end + + test "non-loopback request is refused" do + configure_owner!(owner: "") + # remote_ip defaults to loopback in integration tests; pin a + # non-loopback client so the loopback-only guard is actually exercised. + post daemon_restart_path, env: { "REMOTE_ADDR" => "10.0.0.8" } + assert_response :forbidden + end + + test "a stale pidfile reports the daemon as not running" do + configure_owner!(owner: "") + host! "127.0.0.1" + # A pidfile that is NOT a live, owned daemon must not authorize repair — + # the naive File.exist? check this replaced would have falsely reported + # "running" and let repair proceed against a dead daemon. + FileUtils.mkdir_p(File.dirname(pid_path)) + File.write(pid_path, { "pid" => 999_999_999, "process_start_time" => "bogus" }.to_yaml) + + post daemon_restart_path + assert_response :conflict + assert_equal false, response.parsed_body["ok"] + ensure + FileUtils.rm_f(pid_path) + end + + private + + def pid_file + File.join(Hive::Paths.state_home, ".daemon.pid") + end + + alias_method :pid_path, :pid_file + + def write_pid_file! + FileUtils.mkdir_p(File.dirname(pid_path)) + File.write(pid_path, pid_file_payload(Process.pid).to_yaml) + end +end diff --git a/web/test/integration/local_auth_test.rb b/web/test/integration/local_auth_test.rb new file mode 100644 index 000000000..438b16664 --- /dev/null +++ b/web/test/integration/local_auth_test.rb @@ -0,0 +1,47 @@ +require "test_helper" + +# U4 — loopback no-auth default for single-user local mode. A loopback +# request against a box with NO claimed owner bypasses the GitHub owner +# gate; a non-loopback request (or an owner-claimed box) keeps the gate. +# This is the controller half of Web::AuthPolicy, shared with the CLI's +# fail-closed bind check so the two can never disagree. +class LocalAuthTest < ActionDispatch::IntegrationTest + test "loopback request with no owner bypasses login" do + configure_owner!(owner: "") + host! "127.0.0.1" + + get "/" + assert_response :success, "single-user local loopback must not redirect to login" + end + + test "non-loopback request with no owner requires login" do + configure_owner!(owner: "") + # A non-loopback CLIENT (remote addr), regardless of the Host header, + # must keep the owner gate. remote_ip defaults to loopback in + # integration tests, so pin a non-loopback remote addr explicitly. + get "/", env: { "REMOTE_ADDR" => "10.0.0.8" } + assert_redirected_to login_path, "a non-loopback request must keep the owner gate" + end + + test "owner-claimed box keeps the gate even on loopback" do + configure_owner!(owner: "alice") + host! "127.0.0.1" + + get "/" + assert_redirected_to login_path, "an owner-claimed box must not bypass the gate on loopback" + end + + test "ipv6 loopback host bypasses when unowned" do + configure_owner!(owner: "") + get "/", env: { "REMOTE_ADDR" => "::1" } + assert_response :success, "::1 is loopback and must bypass in single-user mode" + end + + test "spoofed loopback Host header does not bypass the gate for a non-loopback client" do + configure_owner!(owner: "") + host! "127.0.0.1" # attacker-controlled Host header + get "/", env: { "REMOTE_ADDR" => "10.0.0.8" } + assert_redirected_to login_path, + "loopback-ness must come from remote_ip, not the Host header" + end +end diff --git a/wiki/commands/daemon.md b/wiki/commands/daemon.md index c84cb08e7..180b33c10 100644 --- a/wiki/commands/daemon.md +++ b/wiki/commands/daemon.md @@ -40,7 +40,8 @@ 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`, 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. When running, the envelope also reports **binary/version consistency** (U5): `daemon_binary` / `daemon_version` (the RUNNING daemon's resolved binary + its reported version via `Hive::Daemon::Drift`), `drift_status` (`ok` / `drifted` / `unverified`) and `drifted`, compared against the CLI's `current_version`. Drift is reported, never silently auto-fixed. | +| `repair` | Explicit binary/version-drift repair (U5): re-runs the unit `install --force` so it points at the CURRENT CLI binary, then the caller restarts the daemon. This is the ONLY path that rewrites a drifted unit automatically, alongside `hive setup` and the web Repair button — drift is surfaced by `status` but only fixed here (or explicitly with `install --force`). With `--json`, emits a `hive-daemon-repair` envelope (`outcome`, `target_path`, `restarted`). | | `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. | diff --git a/wiki/commands/setup.md b/wiki/commands/setup.md new file mode 100644 index 000000000..941ed8523 --- /dev/null +++ b/wiki/commands/setup.md @@ -0,0 +1,66 @@ +--- +title: hive setup +type: command +source: lib/hive/commands/setup.rb, lib/hive/commands/setup/ +created: 2026-06-29 +updated: 2026-06-29 +tags: [command, setup, local, web, daemon] +--- + +**TLDR**: `hive setup` is the one-shot **full local (non-Docker) setup** for the +Hive web UI. It validates dependencies, bootstraps Hive-owned assets, ensures +the daemon service runs with the same binary/version as the CLI, registers the +current repo (never a forced init or prompt), ensures the web service, and +probes health at the configured web origin. The Docker/hivebox path stays +unchanged; local mode operates on the real Hive/XDG state + checked-out repos +so the TUI and web share one source of truth. + +## Steps + +Run in order; each produces a `steps[]` entry in the `hive-setup.v1` envelope +(`--json`) or a human line: + +1. **backends** — global agent-backend selection via `Setup::BackendPrompt` + (interactive on a TTY; on a non-TTY / `--non-interactive` the registered + defaults are persisted without prompting). Persisted via + `Hive::Config.write_global_agents!`. +2. **dependencies** — `Setup::DependencyCheck` probes ruby 3.4, git, tmux, + gh, claude, codex, node/npm/qmd, the web bundle, and sqlite; bootstraps + Hive-owned deps (qmd, web bundle). External agent CLIs (gh/claude/codex) + are NEVER auto-installed or auto-authenticated — the exact fix command + (`gh auth login` / `claude setup-token` / `codex login --device-auth`) is + reported and the run lands in the `fix_required` (exit 65) bucket. +3. **daemon** — ensures the daemon unit points at the SAME hive binary as the + CLI (`Daemon::ServiceInstaller` + `Hive::InvokedBinary`), with `autostart: + true` so the daemon is enabled and started. Drift without `--force` is + reported, not overwritten. +4. **enroll** — if run inside a git repo that isn't registered, registers it + via `Hive::Config.register_project` (the repo is visible to status/TUI/web + immediately). Full `.hive-state` bootstrap stays the explicit `hive init` + step. Never force-inits or prompts. +5. **web** — ensures the web unit (separate from the daemon) with + `autostart: true` (the service is enabled and started); a foreground run + (`hive web run`) is the manual alternative. +6. **health** — probes `http://127.0.0.1:/health?deep=1` (loopback by + default; `web.bind`/`web.port` or `--bind`/`--port`). + +`ok` is false (and exit 65) when any step failed or any probed dependency is +failing. The health-check 503 (daemon down) is a warning, not a failure. + +## Local-mode posture + +- Daemon + web are SEPARATE services (never merged into one unit). +- Loopback bind + `web.github.owner` unset = single-user local, no auth. +- External agent auth is diagnose-only; `setup` never logs anyone in. +- Web app served from a source checkout or `HIVEBOX_WEB_APP_DIR` (the gem + does not package `web/`). + +## Exit codes + +- `0` — all steps ok (health 503/daemon-down allowed). +- `65` — fix required: a dependency missing/too-old/unauthenticated, daemon + or web unit drift, or a step failed. + +## Backlinks + +- [[cli]] · [[commands/daemon]] · [[commands/web]] · [[operating]] diff --git a/wiki/commands/web.md b/wiki/commands/web.md index f6f253737..4b78dcdfc 100644 --- a/wiki/commands/web.md +++ b/wiki/commands/web.md @@ -22,17 +22,30 @@ 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::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). +`hive web run [--bind] [--port]` (foreground, the default for bare `hive web`; 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). + +### Managed-service lifecycle (local, non-Docker) + +Alongside the foreground `run`, `hive web` has a per-user managed-service lifecycle backed by `Web::ServiceInstaller` (a `ServiceInstaller::Base` subclass, mirroring the daemon/bot installers): + +``` +hive web install [--force] [--json] # write + enable the systemd-user/launchd unit (ExecStart `hive web run`); emits hive-web-install.v1 +hive web start # systemctl --user start / launchctl kickstart +hive web stop # systemctl --user stop / launchctl bootout +hive web status [--json] # hive-web-status.v1 +``` + +The web service is deliberately SEPARATE from `hive-daemon` (daemon + web are never merged into one unit) so each can be restarted independently. The unit runs `hive web run` in the foreground with the service manager as supervisor — the same posture as the container supervisor's `hive web --bind 0.0.0.0`. + +### Bind auth (U4) + +Binding is fail-closed via the shared `Web::AuthPolicy`: + +- **Loopback** (`127.0.0.1`, `::1`, `localhost`) with `web.github.owner` unset → single-user local mode, **no auth**. +- Loopback with an owner → the GitHub owner gate stays on. +- **Non-loopback** (`0.0.0.0`, LAN IP) with no owner AND no `--allow-non-loopback` → **refused** (exit 1), not just a warning. + +The same predicate (`Web::AuthPolicy.allows_unauthenticated?`) governs `ApplicationController#require_login`, so the CLI can never green-light a bind the app will 403. ## Auth diff --git a/wiki/gaps.md b/wiki/gaps.md index 2d71cc615..c4a3d1ad6 100644 --- a/wiki/gaps.md +++ b/wiki/gaps.md @@ -70,6 +70,7 @@ checked-in live dogfood artifact yet proves the U1-U10 stacked sequence after this fix. 1. **Has `hive run` been smoke-tested against a live `claude` v2.1.118?** The plan calls for this before declaring the MVP done. No evidence in tree (no `docs/solutions/` notes, no `docs/smoke-results.md`). +2. **Local web-mode acceptance ([[commands/setup]], U6) is not live-smoked in-tree.** The `hive setup` / `hive web install` / loopback no-auth / daemon same-binary round-trip is unit/integration-pinned but no checked-in live artifact proves `setup → web at 127.0.0.1:4567 → TUI↔web task round-trip` on a real machine. Open Question 1 from the local-mode plan also remains: the **gem deliberately does not package `web/`**, so a fresh gem-installed machine has no Rails app to serve; local web mode currently serves only from a source checkout or an explicit `HIVEBOX_WEB_APP_DIR` (ship/vendor vs. fetch-on-demand is unresolved). 2. **Has `hive init` been run against a real project yet?** Planned pilot, but the working tree shows no first commit on `~/Dev/hive` itself, so the pilot may not have started. 3. **Is `hive/state` reachable after `git gc`?** The plan recommends `git config --add gc.reflogExpire never refs/heads/hive/state`. This is documented in [[decisions]] ADR-003 but not enforced in `Init#call`. 4. **Does the pilot project's pre-commit hook chain (lefthook/overcommit/husky) misbehave on `.hive-state/` commits?** The plan flags this as a known caveat to verify on first init; outcome unrecorded. diff --git a/wiki/index.md b/wiki/index.md index d59b93e36..b8e619abb 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -44,6 +44,7 @@ Folder-as-agent workflow engine: a Ruby 3.4 / Thor CLI control plane where descr - [[commands/screenote]] — `wiki/commands/screenote.md` - [[commands/stage_action]] — `wiki/commands/stage_action.md` - [[commands/status]] — `wiki/commands/status.md` +- [[commands/setup]] — `wiki/commands/setup.md` - [[commands/tui]] — `wiki/commands/tui.md` - [[commands/uninstall]] — `wiki/commands/uninstall.md` - [[commands/update]] — `wiki/commands/update.md` diff --git a/wiki/log.d/20260813T020000Z-local-web-mode.md b/wiki/log.d/20260813T020000Z-local-web-mode.md new file mode 100644 index 000000000..059f8a991 --- /dev/null +++ b/wiki/log.d/20260813T020000Z-local-web-mode.md @@ -0,0 +1,15 @@ +## [2026-08-13T02:00:00Z] local-web-mode — first-class non-Docker install/run + +**Action:** Added a first-class **local (non-Docker) install/run mode** for the Hive web UI, parallel to the Docker/hivebox path (unchanged). + +**Code:** +- `hive setup` ([[commands/setup]]) — one-shot orchestration (backends, dependency verification + Hive-owned bootstrap, daemon ensure, repo-enroll hint, web ensure, health probe); `hive-setup.v1` envelope. +- `hive web run|install|start|stop|status` ([[commands/web]]) — managed lifecycle via `Web::ServiceInstaller` (separate unit from `hive-daemon`); loopback no-auth default + fail-closed non-loopback refusal via `Web::AuthPolicy` (shared with `ApplicationController#require_login`). +- [[commands/daemon]] — `daemon status --json` reports `daemon_binary` / `daemon_version` / `drift_status` / `drifted`; new `hive daemon repair` (explicit reinstall --force). `Hive::Daemon::Drift` resolves the running daemon's binary (Linux `/proc//exe`, macOS/other `ps -o comm=`), reporting `unverified` rather than guessing. +- Web `GET /health?deep=1` surfaces daemon drift; `POST /daemon/restart` (loopback single-user only) delegates to `hive daemon repair`. + +**Validation:** +- `bundle exec rubocop` clean on new/edited files. +- `test/unit/commands/web/*_test.rb`, `test/unit/web/*_test.rb`, `test/unit/commands/setup/*_test.rb`, `test/unit/commands/daemon/*_test.rb`, `test/unit/schema_files_test.rb`, `test/unit/cli_test.rb` green (the web Rails integration tests require the web bundle, which is not installable in the read-only sandbox; they run in CI's `bin/rails test`). + +**Assumption (plan Open Question 1, unresolved):** the gem deliberately does not package `web/`, so local web mode serves only from a source checkout or an explicit `HIVEBOX_WEB_APP_DIR`; `hive setup` gates the web bundle bootstrap on a real app dir. External agent CLIs (gh/claude/codex) are probed but never auto-installed or auto-authenticated. diff --git a/wiki/log.d/20260813T043000Z-review-fix-local-web-mode-acceptance.md b/wiki/log.d/20260813T043000Z-review-fix-local-web-mode-acceptance.md new file mode 100644 index 000000000..8218f8fcf --- /dev/null +++ b/wiki/log.d/20260813T043000Z-review-fix-local-web-mode-acceptance.md @@ -0,0 +1,14 @@ +## [2026-08-13T04:30:00Z] review-fix — local web mode acceptance + exit-code/enroll/repair corrections + +**Action:** Review pass fixes for the first-class local web mode ([[commands/setup]], [[commands/web]], [[commands/daemon]]): + +- `hive setup` now `exit`s its 0/65 return (the Thor wrapper was discarding it); `Hive::ExitCodes::FIX_REQUIRED = 65` is registered and shared by setup/dependency-check/doctor. +- `WebInstallFailed`/`WebInstallDriftError` now carry `exit_code` 70/64 (they previously mapped to GENERIC=1 while the envelope claimed 64/70). +- `hive daemon repair` reinstalls with `autostart: true` so a drifted daemon is actually restarted (not just unit-rewritten). +- `DaemonController#restart` resolves the same binary via `Hive::InvokedBinary.path || ENV["HIVE_BIN"]` and gates on `Hive::PidFile#read_live_pid` (not a naive pidfile existence check). +- `Hive::Daemon::Drift` realpath-normalizes both binary sides so a symlinked install no longer false-positives as drift. +- `hive setup` registers (enrolls) the current repo instead of hinting; `web_origin` uses the computed scheme, brackets IPv6, and probes `/health?deep=1`. +- Status grid surfaces daemon drift/down with a repair button (`StatusController#daemon_status` + view + CSS). +- U6 acceptance coverage: `test/integration/local_web_mode_test.rb` (enrollment + same-binary units) and `web/test/e2e/local_mode_e2e.rb` (boxed round-trip). + +**Validation:** `test/unit/commands/{daemon,setup,web,doctor}/*_test.rb`, `test/unit/exit_codes_test.rb`, `test/unit/cli_test.rb`, `test/integration/local_web_mode_test.rb` green. Web Rails tests not runnable in the read-only sandbox (web bundle not installable). diff --git a/wiki/operating.md b/wiki/operating.md index 2b24d46a7..10360427d 100644 --- a/wiki/operating.md +++ b/wiki/operating.md @@ -447,6 +447,29 @@ which `KeepAlive { SuccessfulExit: false }` then respects (no respawn). A real daemon crash still exits non-zero through `exec` and respawns normally. If you customise `ProgramArguments`, keep the precheck. +## Local web mode (non-Docker) + +Hive also has a first-class local install/run mode for the web UI (Linux + +macOS), parallel to the Docker/hivebox path (unchanged). It operates on the +real Hive/XDG state and checked-out repos so the TUI and web share one +source of truth. + +```sh +hive setup # one-shot: deps + web bundle + backends + daemon + web + health +hive web install [--force] # write + enable the per-user web unit (SEPARATE from hive-daemon) +hive web start / stop / status # managed web service lifecycle +hive web run # foreground server (default bind 127.0.0.1:4567, loopback no-auth) +``` + +The web unit (`ExecStart web run`) is supervised by the same +systemd-user / launchd mechanics as the daemon. Binding a non-loopback +interface without `web.github.owner` or `--allow-non-loopback` is REFUSED +(fail-closed). Binary/version consistency is surfaced by +`hive daemon status --json` (`drift_status`); drift is repaired explicitly +via `hive daemon repair`, `hive setup`, or the web Repair button. Local web +tier needs a source checkout or `HIVEBOX_WEB_APP_DIR` (the gem doesn't +package `web/`). See [[commands/setup]]. + ## Bot setup The bot is global and uses the registry in `~/.config/hive/config.yml`.