diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc3852af..d6d8a6e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,13 @@ jobs: BUNDLE_DEPLOYMENT: "" BUNDLE_FROZEN: "" run: bundle install --jobs 4 + - name: Live local setup E2E (fresh bundle + daemon/Rails + TUI/web parity) + working-directory: . + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/web/Gemfile + HIVE_LIVE_LOCAL_WEB_E2E: "1" + LOCAL_WEB_E2E_ROOT_BUNDLE_PATH: ${{ github.workspace }}/vendor/root-bundle + run: bundle exec ruby -Itest -Ilib test/integration/local_web_setup_e2e_test.rb --name /live_fresh_setup/ - name: hivebox golden-path E2E (claim → idea → Q&A → daemon → PR gate) env: GOLDEN_E2E_BUNDLE_PATH: ${{ github.workspace }}/vendor/root-bundle diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39fa61fb..3e7de277 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,6 +30,105 @@ jobs: # (tebako/dwarfs/Boost) — the user provides Ruby 3.4 already # because the rest of the toolchain needs it. run: gem build hive.gemspec + - name: Build web app release archive + # Local (non-Docker) installs acquire the Rails app via + # Hive::Web::AppBundle from a version-matched release asset. + # The gem intentionally excludes web/; this archive is the + # companion artifact. Root must contain config/application.rb + # (no versioned wrapper directory) so AppBundle can install it + # without unwrapping. + env: + REF_NAME: ${{ github.ref_name }} + run: | + version="${REF_NAME#v}" + archive="hive-web-${version}.tar.gz" + # Tracked tree only — never vendor/, log/, tmp/, or local + # sqlite. Fail closed if the Rails marker is missing so a + # truncated checkout cannot ship a useless archive. + [[ -f web/config/application.rb ]] || { + echo "web/config/application.rb missing; refusing to build ${archive}" >&2 + exit 1 + } + package_parent="$(mktemp -d)" + # Stage only tracked web files, then replace the checkout-only path + # dependency with this release's exact, unpacked gem. Keeping the + # path inside the archive lets Bundler fetch Rails/platform gems; + # a partial vendor/cache would make deployment mode incorrectly + # require every transitive gem to be cached. + git archive --format=tar HEAD web | tar -xf - -C "${package_parent}" + package_root="${package_parent}/web" + gem_file="hive-cli-${version}.gem" + [[ -s "${gem_file}" ]] || { + echo "${gem_file} missing; refusing to build standalone web archive" >&2 + exit 1 + } + mkdir -p "${package_root}/vendor" + gem unpack "${gem_file}" --target "${package_root}/vendor" + mv "${package_root}/vendor/hive-cli-${version}" \ + "${package_root}/vendor/hive-cli" + gem specification "${gem_file}" --ruby > \ + "${package_root}/vendor/hive-cli/hive-cli.gemspec" + sed -i -E \ + "s|^gem \"hive-cli\", path: \"\\.\\.\"$|gem \"hive-cli\", \"= ${version}\", path: \"vendor/hive-cli\"|" \ + "${package_root}/Gemfile" + grep -Fqx \ + "gem \"hive-cli\", \"= ${version}\", path: \"vendor/hive-cli\"" \ + "${package_root}/Gemfile" || { + echo "failed to pin hive-cli in staged web Gemfile" >&2 + exit 1 + } + BUNDLE_APP_CONFIG="${package_root}/.bundle" \ + BUNDLE_GEMFILE="${package_root}/Gemfile" \ + bundle lock --update hive-cli --conservative + ! grep -Fqx ' remote: ..' "${package_root}/Gemfile.lock" || { + echo "staged web lockfile still points outside the archive" >&2 + exit 1 + } + grep -Fqx ' remote: vendor/hive-cli' "${package_root}/Gemfile.lock" || { + echo "staged web lockfile is missing its contained hive-cli source" >&2 + exit 1 + } + + tar -czf "${archive}" -C "${package_root}" . + tar -tzf "${archive}" | grep -Ex '(\./)?config/application\.rb' >/dev/null || { + echo "${archive} root is missing config/application.rb" >&2 + exit 1 + } + tar -tzf "${archive}" | \ + grep -Ex '(\./)?vendor/hive-cli/hive-cli\.gemspec' >/dev/null || { + echo "${archive} is missing its version-matched contained hive-cli gem" >&2 + exit 1 + } + + # Prove the exact bytes uploaded resolve hive-cli only from the + # contained path, then run the same isolated deployment install that + # AppBundle performs on an end-user machine. + smoke_root="$(mktemp -d)" + tar -xzf "${archive}" -C "${smoke_root}" + EXPECTED_VERSION="${version}" \ + BUNDLE_APP_CONFIG="${smoke_root}/.bundle" \ + BUNDLE_GEMFILE="${smoke_root}/Gemfile" \ + ruby -rbundler -e ' + spec = Bundler.definition.locked_gems.specs.find { |candidate| candidate.name == "hive-cli" } + abort "wrong hive-cli version" unless spec&.version.to_s == ENV.fetch("EXPECTED_VERSION") + source = spec.source + abort "external hive-cli source: #{source}" unless source.respond_to?(:path) && source.path.to_s == "vendor/hive-cli" + abort "missing contained hive-cli gemspec" unless Bundler.root.join(source.path, "hive-cli.gemspec").file? + ' + BUNDLE_APP_CONFIG="${smoke_root}/.bundle" \ + BUNDLE_GEMFILE="${smoke_root}/Gemfile" \ + BUNDLE_PATH="${smoke_root}/vendor/bundle" \ + BUNDLE_DEPLOYMENT=1 \ + BUNDLE_WITHOUT=development:test \ + bundle install + BUNDLE_APP_CONFIG="${smoke_root}/.bundle" \ + BUNDLE_GEMFILE="${smoke_root}/Gemfile" \ + BUNDLE_PATH="${smoke_root}/vendor/bundle" \ + BUNDLE_DEPLOYMENT=1 \ + BUNDLE_WITHOUT=development:test \ + bundle exec ruby -e \ + "require 'hive'; abort 'wrong hive-cli version' unless Hive::VERSION == '${version}'" + ls -la "${archive}" - name: Smoke test built gem # Confirm the gemspec is well-formed and the `hive`/`hv` # executables resolve before we attach the artifact to a @@ -51,6 +150,11 @@ jobs: name: hive-cli-gem path: hive-cli-*.gem if-no-files-found: error + - uses: actions/upload-artifact@v7 + with: + name: hive-web-archive + path: hive-web-*.tar.gz + if-no-files-found: error install-gate: name: gem-install gate / ${{ matrix.runs-on }} @@ -116,10 +220,14 @@ jobs: with: name: hive-cli-gem path: dist + - uses: actions/download-artifact@v8 + with: + name: hive-web-archive + path: dist - name: Build checksums run: | cd dist - sha256sum hive-cli-*.gem > SHA256SUMS + sha256sum hive-cli-*.gem hive-web-*.tar.gz > SHA256SUMS - name: Install cosign uses: sigstore/cosign-installer@v3 - name: Sign checksums diff --git a/.gitignore b/.gitignore index cbcaa5c6..ec2d5bb2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.bundle/ +/.bundle-path/ /vendor/bundle/ /tmp/ *.gem diff --git a/Gemfile.lock b/Gemfile.lock index 571abed1..9648003b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,6 +6,7 @@ PATH faraday (>= 2.14.2, < 3.0) faraday-multipart (~> 1.0) lipgloss (~> 0.2.2) + rexml (>= 3.3, < 4.0) sqlite3 (~> 2.0) telegram-bot-ruby (~> 2.7) thor (~> 1.3) @@ -96,7 +97,6 @@ GEM lipgloss (0.2.2-x86_64-linux-gnu) lipgloss (0.2.2-x86_64-linux-musl) logger (1.7.0) - mini_portile2 (2.8.9) minitest (6.0.6) drb (~> 2.0) prism (~> 1.5) @@ -113,6 +113,7 @@ GEM rainbow (3.1.1) rake (13.4.2) regexp_parser (2.12.0) + rexml (3.4.4) rubocop (1.88.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -144,8 +145,6 @@ GEM ruby-progressbar (1.13.0) securerandom (0.4.1) simpleidn (0.2.3) - sqlite3 (2.9.5) - mini_portile2 (~> 2.8.0) sqlite3 (2.9.5-aarch64-linux-gnu) sqlite3 (2.9.5-arm64-darwin) sqlite3 (2.9.5-x86_64-linux-gnu) @@ -167,7 +166,6 @@ GEM PLATFORMS aarch64-linux-gnu arm64-darwin - ruby x86_64-linux x86_64-linux-musl diff --git a/README.md b/README.md index 2fca3136..4e8bfd13 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,7 @@ The TUI is the recommended human interface and an agent-driven CLI is the recomm | Daemon | `hive daemon install/enable/start/status/tail/stop/disable` | Manage the global daemon service plus per-project enrollment. The service polls `hive status --json` and dispatches workflow verbs for enrolled projects. Read [wiki/operating.md](wiki/operating.md) before going live. See [docs/cli.md#daemon](docs/cli.md#daemon). | | Diagnostics | `hive status`, `hive doctor`, `hive rebase-status`, `hive markers clear`, `hive metrics rollback-rate` | Inspect task state, validate configured stage/reviewer skills, check whether the next run would auto-rebase, clear a recovery marker by name, or report fix-agent rollback rate. See [docs/cli.md#diagnostics](docs/cli.md#diagnostics). | | Registry & lifecycle | `hive init`, `hive update`, `hive uninstall`, `hive forget`, `hive prune`, `hive migrate`, `hive tree` | Attach Hive to a project, upgrade to the latest release, remove the installed CLI, prune the global registry, rename old stage folders, or print the Thor command tree. See [docs/cli.md#lower-level-surface](docs/cli.md#lower-level-surface). | +| Local setup & web | `hive setup [--service]`, `hive web`, `hive web install/start/stop/status` | Provision local daemon + optional managed web UI on loopback (`127.0.0.1:4567`), or run Rails in the foreground. Shares XDG state with the TUI. Docker/hivebox remains a peer path. See [wiki/commands/setup.md](wiki/commands/setup.md) and [wiki/commands/web.md](wiki/commands/web.md). | Full per-command reference, every flag, every envelope field, and every exit code lives in [docs/cli.md](docs/cli.md). diff --git a/examples/launchd/hive-web.plist b/examples/launchd/hive-web.plist new file mode 100644 index 00000000..0a613dce --- /dev/null +++ b/examples/launchd/hive-web.plist @@ -0,0 +1,87 @@ + + + + + + Label + local.hive-web + + + ProgramArguments + + /bin/sh + -c + [ -x "$0" ] || exit 0; exec "$0" "$@" + /Users/YOU/.local/bin/hive + web + + + 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 00000000..c2b937c9 --- /dev/null +++ b/examples/systemd/hive-web.service @@ -0,0 +1,68 @@ +# Sample systemd-user unit for `hive web` (Linux). +# +# This file is installer-managed: `hive web install` rewrites ExecStart= +# and Environment=PATH= to match the resolved binary + Ruby manager +# detected on the host, then enables + starts the unit. You normally do +# not edit this by hand — re-run `hive web install` instead. +# +# BEFORE INSTALLING BY HAND: edit ExecStart= to match where YOUR `hive` +# binary lives. `which hive` shows it. Common paths: +# +# %h/.local/bin/hive ← README / install.sh +# /usr/local/bin/hive ← system gem install +# %h/.local/share/mise/shims/hive ← mise / rbenv / asdf shim +# +# Install: +# hive web install +# # or by hand: +# mkdir -p ~/.config/systemd/user +# cp examples/systemd/hive-web.service ~/.config/systemd/user/ +# $EDITOR ~/.config/systemd/user/hive-web.service +# systemctl --user daemon-reload +# systemctl --user enable --now hive-web +# +# Verify: +# systemctl --user status hive-web +# journalctl --user -u hive-web -n 50 +# curl -sS http://127.0.0.1:4567/health/deep +# +# View logs: +# journalctl --user -u hive-web -f +# +# Stop / restart: +# systemctl --user stop hive-web +# systemctl --user restart hive-web +# +# To survive logout: +# sudo loginctl enable-linger $USER +# +# This unit runs the Rails UI in the foreground (`hive web`). systemd is +# the supervisor; Restart=on-failure brings it back if it crashes. +# StartLimit* caps the respawn loop so a wrong ExecStart= path stops +# cleanly with `failed` instead of relaunching forever. +# +# hive-web is independent of hive-daemon — they share XDG state and the +# project registry but are separate service-manager jobs. + +[Unit] +Description=Hive local web UI (Rails) +After=default.target +# Hard cap on the auto-restart loop. If `hive web` exits non-zero 3 times +# within 5 minutes, systemd marks the unit `failed` instead of respawning +# forever. Safety net for a misconfigured ExecStart= or missing Rails bundle. +StartLimitBurst=3 +StartLimitIntervalSec=300 + +[Service] +Type=simple +# systemd user services do NOT inherit the interactive shell PATH, so +# PATH below covers incidental shell-outs and the gem wrapper's +# `#!/usr/bin/env ruby` shebang. `hive web install` detects +# mise/rbenv/asdf and prepends the matching shim directory automatically. +Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +ExecStart=%h/.local/bin/hive web +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=default.target diff --git a/hive.gemspec b/hive.gemspec index e54a3dec..85b6516c 100644 --- a/hive.gemspec +++ b/hive.gemspec @@ -60,6 +60,9 @@ Gem::Specification.new do |spec| spec.add_dependency "faraday", ">= 2.14.2", "< 3.0" spec.add_dependency "faraday-multipart", "~> 1.0" spec.add_dependency "lipgloss", "~> 0.2.2" + # Ruby 3.4 no longer ships REXML as a default gem; the launchd unit + # parser (daemon binary-drift status) requires it at runtime. + spec.add_dependency "rexml", ">= 3.3", "< 4.0" spec.add_dependency "sqlite3", "~> 2.0" spec.add_dependency "telegram-bot-ruby", "~> 2.7" spec.add_dependency "thor", "~> 1.3" diff --git a/install.md b/install.md index 8ad54e54..265b1af4 100644 --- a/install.md +++ b/install.md @@ -4,7 +4,20 @@ You are installing the `hive` CLI for the user. Treat this prompt as the source ## Goal -Install the latest stable Hive release, install or repair the QMD wiki indexer, verify `hive --version`, set up daemon autostart, offer to run `hive init` in the current project, and report any missing runtime dependencies. Do not auto-install runtime dependencies such as `git`, `gh`, agent CLIs, or Node.js/npm; QMD is the exception once npm is already available because Hive's managed wiki refresh scripts use it. The bash installer reports its own installer prerequisites (Ruby 3.4, `curl`, `jq`, checksum tool) when that channel is used. Hive ships as a rubygem (`hive-cli`) attached to the GitHub Release; all three channels (Homebrew, AUR, install.sh) download the same signed `.gem` and run `gem install` against it. Daemon autostart is global install-time setup; project setup only decides whether that project is enrolled for daemon dispatch. +Install the latest stable Hive release, install or repair the QMD wiki indexer, verify `hive --version`, set up daemon autostart, offer to run `hive init` (or prefer `hive setup` for full local provision) in the current project, and report any missing runtime dependencies. Do not auto-install runtime dependencies such as `git`, `gh`, agent CLIs, or Node.js/npm; QMD is the exception once npm is already available because Hive's managed wiki refresh scripts use it. The bash installer reports its own installer prerequisites (Ruby 3.4, `curl`, `jq`, checksum tool) when that channel is used. Hive ships as a rubygem (`hive-cli`) attached to the GitHub Release; all three channels (Homebrew, AUR, install.sh) download the same signed `.gem` and run `gem install` against it. Release assets also include a version-matched `hive-web-.tar.gz` Rails bundle for local web installs (the gem stays lean and excludes `web/`). Daemon autostart is global install-time setup; project setup only decides whether that project is enrolled for daemon dispatch. + +## Local web (optional) + +After Hive is installed and external CLIs are authenticated, prefer one of: + +```bash +hive setup # diagnose + daemon + enroll; then run: hive web +hive setup --service # also install/start managed hive-web on 127.0.0.1:4567 +``` + +Managed lifecycle (separate from hive-daemon): `hive web install|start|stop|status`. +Default local bind is loopback with no login; non-loopback requires `web.github.owner` or `--unsafe`. +Docker/hivebox remains a supported alternative with its own `/data` + owner-auth flow. ## Detect diff --git a/lib/hive.rb b/lib/hive.rb index b22bfe0e..4e57ef93 100644 --- a/lib/hive.rb +++ b/lib/hive.rb @@ -30,6 +30,12 @@ module Hive "hive-daemon-enroll" => 1, "hive-daemon-reload" => 1, "hive-daemon-install" => 1, + # Local setup and managed Rails lifecycle contracts. These are public + # agent-facing envelopes just like the daemon lifecycle family above; + # keep their producers and checked-in schemas on the same registry. + "hive-setup" => 1, + "hive-web-install" => 1, + "hive-web-status" => 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`. diff --git a/lib/hive/bot/supervisor.rb b/lib/hive/bot/supervisor.rb index c1d0ad4f..d0710091 100644 --- a/lib/hive/bot/supervisor.rb +++ b/lib/hive/bot/supervisor.rb @@ -779,7 +779,9 @@ module Hive # the first request and keep the rest as a daemon-promoted # continuation. The retry command is not visible to the daemon until # `markers clear` exits 0. - if commands.all? { |argv| queue_routable?(argv) } + if commands.all? do |argv| + queue_routable?(argv, project: result.project, slug: result.slug) + end return enqueue_command_sequence(commands, result, update) end @@ -1012,7 +1014,11 @@ module Hive # bump the task state-file mtime — the diagnose refresh only # writes a diagnostics artifact — so they don't cause the # dual-writer bug. - if queue_routable?(result.command_argv) + if queue_routable?( + result.command_argv, + project: result.project, + slug: result.slug + ) return enqueue_dispatch_request(result, update) end @@ -1037,8 +1043,10 @@ module Hive # rewrites this kind of dispatch into a request-file write; the # daemon picks the request up on its next tick and spawns the # child. See plan 2026-05-28-002 for why. - def queue_routable?(argv) - Hive::Daemon::DispatchRequestQueue.valid_argv?(Array(argv)) + def queue_routable?(argv, project: nil, slug: nil) + Hive::Daemon::DispatchRequestQueue.valid_argv?( + Array(argv), project: project, slug: slug + ) end # Write a dispatch request for `result.command_argv` and log diff --git a/lib/hive/bounded_subprocess.rb b/lib/hive/bounded_subprocess.rb new file mode 100644 index 00000000..ba93cad1 --- /dev/null +++ b/lib/hive/bounded_subprocess.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require "open3" +require "timeout" + +module Hive + # Open3-compatible capture for short-lived probes and installers. Children + # start in their own process group so a deadline can terminate the whole + # tree, including grandchildren that inherited stdout/stderr pipes. + module BoundedSubprocess + POLL_INTERVAL = 0.02 + TERM_GRACE_SECONDS = 0.1 + KILL_GRACE_SECONDS = 0.2 + + class TimeoutError < Timeout::Error + attr_reader :stdout, :stderr, :elapsed + + def initialize(stdout:, stderr:, elapsed:) + @stdout = stdout.to_s + @stderr = stderr.to_s + @elapsed = elapsed.to_f + super("subprocess timed out after #{format('%.2f', @elapsed)}s") + end + end + + module_function + + # Mirrors Open3.capture3: an optional environment Hash may be the first + # positional argument and spawn options (for example chdir:) are keyword + # arguments. pgroup is always forced on because it is the cleanup boundary. + def capture3(*command, timeout:, **spawn_options) + timeout = Float(timeout) + raise ArgumentError, "timeout must be positive" unless timeout.positive? + + started = monotonic_now + Open3.popen3(*command, **spawn_options.merge(pgroup: true)) do |stdin, stdout, stderr, wait_thread| + stdin.close + out_reader = Thread.new { read_stream(stdout) } + err_reader = Thread.new { read_stream(stderr) } + deadline = started + timeout + + loop do + process_done = wait_thread.join(POLL_INTERVAL) + if process_done && !out_reader.alive? && !err_reader.alive? + return [ out_reader.value, err_reader.value, wait_thread.value ] + end + + next if monotonic_now < deadline + + terminate_process_group(wait_thread.pid, wait_thread) + elapsed = monotonic_now - started + raise TimeoutError.new( + stdout: finish_reader(out_reader), + stderr: finish_reader(err_reader), + elapsed: elapsed + ) + end + end + end + + def terminate_process_group(pid, wait_thread) + signal_group("TERM", pid) + sleep TERM_GRACE_SECONDS + # Always signal the group after the grace period. The direct child may + # have exited on TERM while a descendant kept inherited pipes open. + signal_group("KILL", pid) + wait_thread.join(KILL_GRACE_SECONDS) + end + private_class_method :terminate_process_group + + def signal_group(signal, pid) + Process.kill(signal, -pid) + rescue Errno::ESRCH + nil + end + private_class_method :signal_group + + def read_stream(stream) + stream.read.to_s + rescue IOError + "" + end + private_class_method :read_stream + + def finish_reader(thread) + thread.join(KILL_GRACE_SECONDS) + thread.kill if thread.alive? + thread.value.to_s + rescue StandardError + "" + end + private_class_method :finish_reader + + def monotonic_now + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + private_class_method :monotonic_now + end +end diff --git a/lib/hive/cli.rb b/lib/hive/cli.rb index cd809068..a3172fbc 100644 --- a/lib/hive/cli.rb +++ b/lib/hive/cli.rb @@ -1332,18 +1332,68 @@ 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 "setup", "Diagnose and provision local Hive (daemon, optional web service, project enrollment)" + long_desc <<~DESC + Full local setup for Linux/macOS: + + 1. Bounded dependency diagnostics (Ruby, git, tmux, gh, agents, Node, qmd, web bundle, SQLite) + 2. Bootstrap Hive-owned assets (qmd + version-matched Rails web bundle) + 3. Install/start hive-daemon with the invoking CLI binary + 4. Initialize or enroll the current repository for automatic dispatch + 5. With --service: also install/start the separate hive-web service + + Bare `hive setup` leaves web as a foreground `hive web` step. + `--no-bootstrap` is diagnose-only (no mutations). + `--no-init` skips repository init/enrollment. + `--json` always emits a complete phase report; exit is non-zero on any hard failure. + DESC + option :service, type: :boolean, default: false, desc: "also install/start the managed hive-web service" + option :no_bootstrap, type: :boolean, default: false, desc: "diagnose only; do not bootstrap or mutate" + option :no_init, type: :boolean, default: false, desc: "skip repository init/enrollment" + def setup + require "hive/commands/setup" + Hive::Commands::Setup.new( + project_path: Dir.pwd, + service: options[:service], + no_bootstrap: options[:no_bootstrap], + no_init: options[:no_init], + json: options[:json] + ).call + end + + desc "web [SUBCOMMAND]", "Run the local/hivebox web UI, or manage the hive-web service" + long_desc <<~DESC + Bare `hive web` boots the Rails control surface in the foreground + (default bind 127.0.0.1:4567). Managed lifecycle (separate from + hive-daemon): + + install [--force] [--json] write/register the per-user service unit + start start the managed hive-web service + stop stop the managed hive-web service + status [--json] report managed service state + + Local installs resolve the Rails app from HIVEBOX_WEB_APP_DIR, the + managed XDG data bundle (`hive setup` / AppBundle), or a source + checkout. Docker/hivebox continues to use /app/web via the env override. + DESC + option :bind, type: :string, desc: "override web.bind (foreground only)" + option :port, type: :numeric, desc: "override web.port (foreground only)" + option :force, type: :boolean, default: false, desc: "overwrite a drifted unit on install" + option :unsafe, type: :boolean, default: false, + desc: "allow non-loopback bind without web.github.owner (foreground only)" + def web(subcommand = nil) + require "hive/commands/web" + + if options[:json] && (subcommand.nil? || subcommand.empty? || + !Hive::Commands::Web::JSON_ALLOWED.include?(subcommand)) require "json" - message = "hive web 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. + message = + if subcommand.nil? || subcommand.empty? + "hive web has no JSON output (it runs a long-lived server). " \ + "Use 'hive web status --json' or 'hive status --json'." + else + "hive web #{subcommand}: --json is only supported for install and status" + end puts JSON.generate( "ok" => false, "error_class" => "InvalidTaskPath", @@ -1354,8 +1404,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: subcommand, + bind: options[:bind], + port: options[:port], + force: options[:force], + json: options[:json], + unsafe: options[:unsafe] + ).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 6c91610b..dda9502c 100644 --- a/lib/hive/commands/daemon.rb +++ b/lib/hive/commands/daemon.rb @@ -60,7 +60,8 @@ module Hive def initialize(subcommand, target = nil, detach: false, dry_run: false, all: false, json: false, force: false, queue_args: [], - hive_home: Hive::Paths.state_home) + hive_home: Hive::Paths.state_home, + emit: true) @subcommand = subcommand @target = target @detach = detach @@ -70,6 +71,9 @@ module Hive @force = force @queue_args = Array(queue_args) @hive_home = hive_home + # Setup and other orchestrators need the mutation/result semantics + # without a nested command writing a second stdout document. + @emit = emit end def call @@ -101,6 +105,17 @@ module Hive private + # Explicit non-emitting programmatic seam. Unqualified `puts`/`warn` + # throughout this command route here; normal CLI construction preserves + # the historical global streams, while orchestrators pass `emit: false`. + def puts(*lines) + $stdout.puts(*lines) if @emit + end + + def warn(*lines) + $stderr.puts(*lines) if @emit + end + def start_daemon warn_unsupported_json_flag if @json FileUtils.mkdir_p(@hive_home) @@ -182,6 +197,10 @@ module Hive ) supervisor = Hive::Daemon::ChildSupervisor.new( dry_run: @dry_run, + maintenance_binary_resolver: lambda { + require "hive/commands/daemon/service_installer" + Hive::Commands::Daemon::ServiceInstaller.new.resolved_binary_for_status + }, default_timeout_sec: daemon_cfg.fetch( "child_timeout_sec", Hive::Config::DEFAULTS.dig("daemon", "child_timeout_sec") ), @@ -355,6 +374,7 @@ module Hive end def status_daemon + require "hive/daemon/status_report" running = false pid = nil uptime_sec = nil @@ -369,27 +389,25 @@ module Hive end end + report = Hive::Daemon::StatusReport.new( + hive_home: @hive_home, + binary_path: current_binary_path, + running_state: [ running, pid, uptime_sec ] + ).to_h + if @json - service_state = probe_service_state - puts JSON.generate( - "schema" => "hive-daemon-status", - "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-daemon-status"), - "ok" => true, - "running" => running, - "pid" => running ? pid : nil, - "uptime_sec" => uptime_sec, - "pid_file" => pid_file, - "log_file" => log_file, - "service_installed" => service_state["service_installed"], - "service_enabled" => service_state["service_enabled"], - "unit_path" => service_state["unit_path"], - # 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 - ) + puts JSON.generate(report) elsif running puts "hive daemon: running (pid #{pid}, uptime #{uptime_sec}s)" + drift = report["binary_drift"] + unless drift == "none" || drift == "not_applicable" + puts "hive daemon: binary drift: #{drift}" \ + " (installed=#{report['installed_binary'].inspect}" \ + " expected=#{report['expected_binary'].inspect}" \ + " installed_version=#{report['installed_version'].inspect}" \ + " current_version=#{report['current_version'].inspect})" + puts "hive daemon: repair with `hive daemon install --force`" + end else puts "hive daemon: not running" end @@ -397,29 +415,6 @@ module Hive raise Hive::Error, "daemon not running" unless running end - # Read-only autostart-state snapshot for the status envelope. A status - # probe must never take down the running/pid reporting that precedes - # it, so any failure degrades the three service fields to null (the - # status schema marks them required-but-nullable) instead of raising - # out of the whole command. - def probe_service_state - require "hive/commands/daemon/service_installer" - Hive::Commands::Daemon::ServiceInstaller.new.service_state - rescue StandardError - { "service_installed" => nil, "service_enabled" => nil, "unit_path" => nil } - end - - # The daemon-written update nudge, as a plain Hash for the status - # envelope (nil when current or unknown). Never raises out of status. - def update_nudge_payload - nudge = Hive::UpdateCheck::State.new.nudge - return nil unless nudge - - { "latest" => nudge.latest, "channel" => nudge.channel, "command" => nudge.command } - rescue StandardError - nil - end - def reload_daemon result = compute_reload_outcome if @json diff --git a/lib/hive/commands/daemon/queue_command.rb b/lib/hive/commands/daemon/queue_command.rb index 66c041b9..7cb9b385 100644 --- a/lib/hive/commands/daemon/queue_command.rb +++ b/lib/hive/commands/daemon/queue_command.rb @@ -163,7 +163,9 @@ module Hive "chat_id" => req.chat_id, "update_id" => req.update_id, "expired" => Hive::Daemon::DispatchRequestQueue.expired?(req), - "allowlisted" => Hive::Daemon::DispatchRequestQueue.valid_argv?(req.argv) + "allowlisted" => Hive::Daemon::DispatchRequestQueue.valid_argv?( + req.argv, project: req.project, slug: req.slug + ) } end @@ -185,7 +187,9 @@ module Hive requests.each do |req| flags = [] flags << "EXPIRED" if Hive::Daemon::DispatchRequestQueue.expired?(req) - flags << "NOT-ALLOWLISTED" unless Hive::Daemon::DispatchRequestQueue.valid_argv?(req.argv) + flags << "NOT-ALLOWLISTED" unless Hive::Daemon::DispatchRequestQueue.valid_argv?( + req.argv, project: req.project, slug: req.slug + ) suffix = flags.empty? ? "" : " [#{flags.join(' ')}]" puts "#{req.request_id} #{(Time.now - req.created_at).to_i}s " \ "#{req.project}/#{req.slug} #{req.argv[1]}#{suffix}" @@ -205,7 +209,9 @@ module Hive puts "chat_id: #{req.chat_id.inspect}" puts "update_id: #{req.update_id.inspect}" puts "expired: #{Hive::Daemon::DispatchRequestQueue.expired?(req)}" - puts "allowlisted:#{Hive::Daemon::DispatchRequestQueue.valid_argv?(req.argv)}" + puts "allowlisted:#{Hive::Daemon::DispatchRequestQueue.valid_argv?( + req.argv, project: req.project, slug: req.slug + )}" end end end diff --git a/lib/hive/commands/init.rb b/lib/hive/commands/init.rb index 7a13eb00..94c3e3c9 100644 --- a/lib/hive/commands/init.rb +++ b/lib/hive/commands/init.rb @@ -75,14 +75,19 @@ module Hive CUSTOM_WORKFLOW_HINT_MESSAGE = "custom workflows live in this project — author one with `#{CUSTOM_WORKFLOW_HINT_COMMAND}`".freeze def initialize(project_path, force: false, json: false, prompts: nil, - workflow: nil, new_workflow: nil, workflow_input: $stdin, workflow_output: $stderr) + workflow: nil, new_workflow: nil, workflow_input: $stdin, workflow_output: $stderr, + emit: true) @project_path = File.expand_path(project_path) @force = force @json = json @workflow_name = workflow @new_workflow = new_workflow @workflow_input = workflow_input - @workflow_output = workflow_output + # Programmatic callers such as `hive setup` compose Init's result into + # their own report. They can disable every Init-owned stdout/stderr + # emission without redirecting process-global streams. + @emit = emit + @workflow_output = @emit ? workflow_output : StringIO.new # Optional Prompts instance for testability. Tests inject a # pre-fed StringIO-backed instance to drive the interactive flow # without touching $stdin. Production keeps this nil so the @@ -151,7 +156,7 @@ module Hive # collect_prompt_answers' contract: USAGE (64), not a generic # InternalError crash, and zero disk state (the prompt is BEFORE any # writes, per ADR-023). - warn "hive: aborted (#{e.message}); no changes made" + write_warn("hive: aborted (#{e.message}); no changes made") exit Hive::ExitCodes::USAGE rescue Hive::Error raise @@ -279,6 +284,8 @@ module Hive end def write_existing_summary(_ops, workflow_choice:, scaffold_paths: nil) + return unless @emit + name = File.basename(@project_path) $stdout.puts "hive: already initialized #{name}" $stdout.puts "workflow: #{workflow_choice.descriptor.id}" @@ -297,6 +304,8 @@ module Hive # of the re-bind. Emit a minimal `already_initialized` hive-init payload # (a distinct oneOf arm from the fresh SuccessPayload in the schema). def emit_existing_json_summary(ops, workflow_choice:, extra: {}) + return unless @emit + puts JSON.generate(existing_payload(ops, workflow_choice: workflow_choice).merge(extra)) rescue Errno::EPIPE nil @@ -733,7 +742,9 @@ module Hive end def write_warn(line) - warn line + return unless @emit + + $stderr.puts line rescue Errno::EPIPE nil end @@ -777,6 +788,8 @@ module Hive end def emit_json_summary(entry:, ops:, answers:, workflow:, extra: {}) + return unless @emit + puts JSON.generate(success_payload(entry: entry, ops: ops, answers: answers, workflow: workflow).merge(extra)) rescue Errno::EPIPE nil @@ -867,6 +880,8 @@ module Hive end def print_summary(entry:, ops:, answers:, workflow: nil, scaffold_paths: nil) + return unless @emit + c = Palette.for($stdout) name = entry["name"] rows = [ @@ -905,22 +920,27 @@ module Hive end def collect_prompt_answers - summary_io = @json ? StringIO.new : $stdout - prompts = @prompts || Hive::Commands::Init::Prompts.new(input: $stdin, output: $stderr, summary_io: summary_io) + summary_io = (@json || !@emit) ? StringIO.new : $stdout + prompt_output = @emit ? $stderr : StringIO.new + prompts = @prompts || Hive::Commands::Init::Prompts.new( + input: $stdin, + output: prompt_output, + summary_io: summary_io + ) prompts.collect rescue Hive::Commands::Init::Prompts::Aborted => e # Distinct exit code (USAGE / 64) from generic crashes (GENERIC / 1) # so a scripted agent can tell "user explicitly declined" from # "init crashed transiently" and decide whether to retry. Closes # ce-code-review F6. - warn "hive: aborted (#{e.message}); no changes made" + write_warn("hive: aborted (#{e.message}); no changes made") exit Hive::ExitCodes::USAGE end def validate_git_repo! out, _err, status = Open3.capture3("git", "-C", @project_path, "rev-parse", "--git-common-dir") unless status.success? - warn "hive: not a git repository: #{@project_path}" + write_warn("hive: not a git repository: #{@project_path}") exit 1 end @@ -928,7 +948,7 @@ module Hive expected = File.join(@project_path, ".git") return if File.expand_path(common) == File.expand_path(expected) - warn "hive: target appears to be inside a worktree (common dir #{common}); init must run on the main checkout" + write_warn("hive: target appears to be inside a worktree (common dir #{common}); init must run on the main checkout") exit 1 end @@ -941,7 +961,7 @@ module Hive modified = out.lines.reject { |l| l.start_with?("??") } return if modified.empty? - warn "hive: uncommitted modifications to tracked files; commit or pass --force" + write_warn("hive: uncommitted modifications to tracked files; commit or pass --force") exit 1 end diff --git a/lib/hive/commands/service_installer/base.rb b/lib/hive/commands/service_installer/base.rb index 04f9ba76..b87218aa 100644 --- a/lib/hive/commands/service_installer/base.rb +++ b/lib/hive/commands/service_installer/base.rb @@ -81,6 +81,35 @@ module Hive } end + # Expected binary path that install! would write into the unit. + # Public so StatusReport can compare without re-resolving PATH. + def resolved_binary_for_status + resolved_binary + end + + # Parse the effective hive executable from the installed unit file. + # Returns nil when the unit is missing or the path cannot be parsed. + # Sets #binary_parse_error on parse failures. + attr_reader :binary_parse_error + + def installed_binary_path + @binary_parse_error = nil + path = target_path + return nil if path.nil? || !File.exist?(path) + + body = File.read(path) + case platform + when :linux then parse_systemd_exec_binary(body) + when :macos then parse_launchd_program_binary(body) + else + @binary_parse_error = "unsupported platform" + nil + end + rescue SystemCallError => e + @binary_parse_error = "unreadable: #{e.class}: #{e.message}" + nil + end + # launchd plist Label for this service. Matches the `Label` # value in the bundled plists (local.hive-daemon / local.hive-bot). def launchd_label @@ -404,6 +433,66 @@ module Hive !!which("launchctl") end + + # systemd: `ExecStart=/path/to/hive daemon start` (possibly + # Shellwords-escaped). Extract the first token after ExecStart=. + def parse_systemd_exec_binary(body) + line = body.each_line.find { |l| l.start_with?("ExecStart=") } + unless line + @binary_parse_error = "no ExecStart= line" + return nil + end + + value = line.sub(/\AExecStart=/, "").strip + # Drop systemd prefix modifiers like `@` / `-` if ever present. + value = value.sub(/\A[@+\-!:]+/, "") + tokens = Shellwords.split(value) + binary = tokens.first + if binary.nil? || binary.empty? + @binary_parse_error = "empty ExecStart binary" + return nil + end + + binary + rescue ArgumentError => e + @binary_parse_error = "unparseable ExecStart: #{e.message}" + nil + end + + # launchd: ProgramArguments array is + # /bin/sh, -c, precheck, , daemon|web|bot, ... + # The hive binary is the first absolute path that is not /bin/sh. + def parse_launchd_program_binary(body) + require "rexml/document" + doc = REXML::Document.new(body) + strings = [] + doc.elements.each("//key") do |key| + next unless key.text.to_s.strip == "ProgramArguments" + + array = key.next_element + next unless array && array.name == "array" + + array.elements.each("string") { |s| strings << s.text.to_s } + break + end + if strings.empty? + @binary_parse_error = "no ProgramArguments" + return nil + end + + # Precheck wrapper: /bin/sh -c '...' ... + binary = strings.find { |s| s != "/bin/sh" && s != "-c" && s.include?("/") && !s.include?(" ") } + binary ||= strings.find { |s| File::SEPARATOR && s.start_with?("/") && s != "/bin/sh" } + if binary.nil? + @binary_parse_error = "could not locate hive binary in ProgramArguments" + return nil + end + + binary + rescue REXML::ParseException, StandardError => e + @binary_parse_error = "unparseable plist: #{e.class}: #{e.message}" + nil + end end end end diff --git a/lib/hive/commands/setup.rb b/lib/hive/commands/setup.rb new file mode 100644 index 00000000..da8201e3 --- /dev/null +++ b/lib/hive/commands/setup.rb @@ -0,0 +1,402 @@ +# frozen_string_literal: true + +require "json" +require "open3" +require "fileutils" +require "net/http" +require "timeout" +require "uri" + +require "hive" +require "hive/invoked_binary" +require "hive/paths" +require "hive/setup/diagnostics" +require "hive/web/app_bundle" + +module Hive + module Commands + # `hive setup` — diagnose the host, bootstrap Hive-owned assets (qmd + + # Rails web bundle), install/start the daemon with the invoking binary, + # initialize or enroll the current repository, and optionally install + # the managed web service (`--service`). + # + # Phases always record outcomes so text, JSON `ok`, and process exit + # cannot disagree after a partial failure. + class Setup + SCHEMA = "hive-setup" + SCHEMA_VERSION = Hive::Schemas::SCHEMA_VERSIONS.fetch(SCHEMA) + QMD_PACKAGE = Hive::Setup::Diagnostics::QMD_PACKAGE + WEB_HEALTH_URL = "http://127.0.0.1:4567/health" + WEB_HEALTH_TIMEOUT = 15.0 + WEB_HEALTH_ATTEMPT_TIMEOUT = 1.0 + WEB_HEALTH_RETRY_INTERVAL = 0.1 + + Phase = Struct.new(:name, :ok, :detail, :error, keyword_init: true) do + def to_h + { + "name" => name, + "ok" => ok, + "detail" => detail, + "error" => error + } + end + end + + def initialize( + project_path: Dir.pwd, + service: false, + no_bootstrap: false, + no_init: false, + json: false, + binary_path: nil, + diagnostics: nil, + app_bundle: nil, + qmd_installer: nil, + daemon_installer: nil, + web_installer: nil, + web_health_probe: nil, + web_health_timeout: WEB_HEALTH_TIMEOUT, + init_runner: nil, + enroll_runner: nil, + output: $stdout, + err: $stderr + ) + @project_path = File.expand_path(project_path) + @service = service + @no_bootstrap = no_bootstrap + @no_init = no_init + @json = json + @binary_path = binary_path || Hive::InvokedBinary.path + @diagnostics = diagnostics + @app_bundle = app_bundle + @qmd_installer = qmd_installer || method(:default_install_qmd) + @daemon_installer = daemon_installer + @web_installer = web_installer + @web_health_probe = web_health_probe || method(:default_web_health_probe) + @web_health_timeout = Float(web_health_timeout) + @init_runner = init_runner || method(:default_init) + @enroll_runner = enroll_runner || method(:default_enroll) + @output = output + @err = err + @phases = [] + @diag_report = nil + @web_bundle_refreshed = false + end + + def call + @diag_report = run_diagnostics + unless @no_bootstrap + bootstrap_qmd if needs_qmd_bootstrap? + bootstrap_web_bundle if needs_web_bootstrap? + install_daemon + enroll_or_init unless @no_init + install_web_service if @service + end + + overall_ok = overall_success? + emit_report(overall_ok) + raise Hive::Error, failure_summary unless overall_ok + + overall_ok + end + + attr_reader :phases, :diag_report + + private + + def run_diagnostics + report = (@diagnostics || Hive::Setup::Diagnostics.new).call + record_phase( + "diagnostics", + ok: report.ok, + detail: "#{report.results.count(&:ok?)}/#{report.results.length} checks ok", + error: report.ok ? nil : report.hard_failures.map { |r| "#{r.name}: #{r.fix || r.detail}" }.join("; ") + ) + report + end + + def needs_qmd_bootstrap? + row = @diag_report.results.find { |r| r.name == "qmd" } + row && row.bootstrappable + end + + def needs_web_bootstrap? + row = @diag_report.results.find { |r| r.name == "web_bundle" } + row && row.bootstrappable + end + + def bootstrap_qmd + begin + detail = @qmd_installer.call + record_phase("bootstrap_qmd", ok: true, detail: detail) + rescue StandardError => e + record_phase("bootstrap_qmd", ok: false, detail: nil, error: "#{e.class}: #{e.message}") + end + end + + def bootstrap_web_bundle + begin + bundle = @app_bundle || Hive::Web::AppBundle.new + path = bundle.ensure_installed! + @web_bundle_refreshed = true + record_phase("bootstrap_web_bundle", ok: true, detail: path) + rescue StandardError => e + record_phase("bootstrap_web_bundle", ok: false, detail: nil, error: "#{e.class}: #{e.message}") + end + end + + def install_daemon + begin + require "hive/commands/daemon/service_installer" + installer = @daemon_installer || Hive::Commands::Daemon::ServiceInstaller.new( + binary_path: @binary_path + ) + result = installer.install!(autostart: true, force: true) + if result.success? + record_phase( + "daemon_install", + ok: true, + detail: "outcome=#{result.wire_outcome} unit=#{installer.target_path} binary=#{@binary_path}" + ) + else + record_phase( + "daemon_install", + ok: false, + detail: result.wire_outcome, + error: installer.messages.last || "daemon install failed" + ) + end + rescue StandardError => e + record_phase("daemon_install", ok: false, error: "#{e.class}: #{e.message}") + end + end + + def enroll_or_init + initialized = project_initialized? + begin + if initialized + detail = @enroll_runner.call(@project_path) + record_phase("enroll", ok: true, detail: detail) + else + detail = @init_runner.call(@project_path) + record_phase("init", ok: true, detail: detail) + end + rescue SystemExit => e + name = initialized ? "enroll" : "init" + record_phase(name, ok: false, error: "SystemExit: exited with status #{e.status}") + rescue StandardError => e + name = initialized ? "enroll" : "init" + record_phase(name, ok: false, error: "#{e.class}: #{e.message}") + end + end + + def install_web_service + begin + require "hive/commands/web/service_installer" + installer = @web_installer || Hive::Commands::Web::ServiceInstaller.new( + binary_path: @binary_path + ) + active_before_install = @web_bundle_refreshed && !!installer.status_snapshot["service_active"] + # An already-active service is restarted explicitly below. Avoid an + # idempotent enable/load here (launchd rejects loading an already + # loaded plist, and systemd enable --now does not reload Rails code). + result = installer.install!(autostart: !active_before_install, force: true) + if result.success? + restarted = result.restarted + if active_before_install + restart_outcome = installer.restart! + unless restart_outcome == :ok + record_phase( + "web_install", + ok: false, + detail: result.wire_outcome, + error: installer.messages.last || "web service restart failed" + ) + return + end + restarted = true + end + if active_before_install && !web_service_healthy? + record_phase( + "web_install", + ok: false, + detail: result.wire_outcome, + error: "health check failed at #{WEB_HEALTH_URL} within #{@web_health_timeout}s" + ) + return + end + record_phase( + "web_install", + ok: true, + detail: "outcome=#{result.wire_outcome} unit=#{installer.target_path}" \ + " restarted=#{restarted} health_checked=#{active_before_install}" + ) + else + record_phase( + "web_install", + ok: false, + detail: result.wire_outcome, + error: installer.messages.last || "web install failed" + ) + end + rescue StandardError => e + record_phase("web_install", ok: false, error: "#{e.class}: #{e.message}") + end + end + + def project_initialized? + File.directory?(File.join(@project_path, ".hive-state")) + end + + def web_service_healthy? + Timeout.timeout(@web_health_timeout) do + @web_health_probe.call(WEB_HEALTH_URL, timeout: @web_health_timeout) + end + rescue Timeout::Error + false + end + + def default_web_health_probe(url, timeout:) + uri = URI.parse(url) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + loop do + remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC) + return false unless remaining.positive? + + begin + attempt_timeout = [ remaining, WEB_HEALTH_ATTEMPT_TIMEOUT ].min + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = attempt_timeout + http.read_timeout = attempt_timeout + http.write_timeout = attempt_timeout if http.respond_to?(:write_timeout=) + response = http.get(uri.request_uri) + return true if response.is_a?(Net::HTTPSuccess) + rescue IOError, SystemCallError, SocketError, Timeout::Error, Net::HTTPBadResponse + # Retry below until the shared deadline. + end + + remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC) + return false unless remaining.positive? + + sleep([ WEB_HEALTH_RETRY_INTERVAL, remaining ].min) + end + end + + def record_phase(name, ok:, detail: nil, error: nil) + @phases << Phase.new(name: name, ok: ok, detail: detail, error: error) + end + + def overall_success? + return false unless @diag_report&.ok + return true if @no_bootstrap + + @phases.all?(&:ok) + end + + def failure_summary + parts = [] + if @diag_report && !@diag_report.ok + parts << "diagnostics failed: " + @diag_report.hard_failures.map { |r| + "#{r.name} (#{r.fix || r.detail})" + }.join("; ") + end + @phases.reject(&:ok).each do |phase| + parts << "#{phase.name}: #{phase.error || phase.detail}" + end + parts.empty? ? "setup failed" : parts.join(" | ") + end + + def emit_report(overall_ok) + payload = { + "schema" => SCHEMA, + "schema_version" => SCHEMA_VERSION, + "ok" => overall_ok, + "service" => @service, + "no_bootstrap" => @no_bootstrap, + "no_init" => @no_init, + "binary_path" => @binary_path, + "project_path" => @project_path, + "web_url" => "http://127.0.0.1:4567", + "diagnostics" => @diag_report&.to_h, + "phases" => @phases.map(&:to_h) + } + + if @json + @output.puts JSON.generate(payload) + else + emit_text(overall_ok, payload) + end + end + + def emit_text(overall_ok, payload) + @output.puts "hive setup: #{overall_ok ? 'ok' : 'failed'}" + if @diag_report + @diag_report.results.each do |row| + mark = row.ok? ? "ok" : (row.bootstrappable ? "bootstrap" : "FAIL") + line = " [#{mark}] #{row.name}: #{row.detail}" + line += " → #{row.fix}" if row.fix + @output.puts line + end + end + @phases.each do |phase| + mark = phase.ok ? "ok" : "FAIL" + @output.puts " phase #{phase.name}: #{mark}" \ + "#{phase.detail ? " — #{phase.detail}" : ""}" \ + "#{phase.error ? " — #{phase.error}" : ""}" + end + if overall_ok + if @service + @output.puts "hive setup: web UI at #{payload['web_url']}" + else + @output.puts "hive setup: run `hive web` in the foreground, or `hive setup --service` " \ + "to install the managed hive-web service" + end + else + @err.puts "hive setup: #{failure_summary}" + end + end + + def default_install_qmd + prefix = File.join(Hive::Paths.data_home, "qmd") + FileUtils.mkdir_p(prefix) + cmd = [ + "npm", "install", "--global", + "--prefix", prefix, + "--no-audit", "--no-fund", + QMD_PACKAGE + ] + out, err, status = Open3.capture3(*cmd) + raise Hive::Error, "qmd install failed: #{err.empty? ? out : err}" unless status.success? + + "installed #{QMD_PACKAGE} under #{prefix}" + end + + def default_init(project_path) + require "hive/commands/init" + # Non-interactive: pin the coding workflow; do not install daemon + # again (setup already did). Capture Init output. + Hive::Commands::Init.new( + project_path, + force: true, + json: true, + workflow: "coding", + workflow_input: StringIO.new("\n"), + workflow_output: StringIO.new, + emit: false + ).call + "initialized #{project_path}" + end + + def default_enroll(project_path) + require "hive/commands/daemon" + name = File.basename(project_path) + # Ensure registry entry exists, then enable daemon for the project. + if Hive::Config.registered_projects.none? { |p| File.expand_path(p["path"].to_s) == project_path } + Hive::Config.register_project(name: name, path: project_path) + end + Hive::Commands::Daemon.new("enable", name, json: true, emit: false).call + "enrolled #{name}" + end + end + end +end diff --git a/lib/hive/commands/web.rb b/lib/hive/commands/web.rb index eb3cd40f..230bc1f6 100644 --- a/lib/hive/commands/web.rb +++ b/lib/hive/commands/web.rb @@ -1,27 +1,173 @@ +# frozen_string_literal: true + +require "json" require "hive/config" +require "hive/invoked_binary" require "hive/web/session_secret" +require "hive/web/app_bundle" +require "hive/web/loopback" module Hive module Commands - # Boots the hivebox web UI — a Rails app living in web/ at the repo root - # (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. + # Boots the Hive web UI — a Rails app living in `web/` (source checkout / + # Docker `/app/web`) or installed under XDG data via AppBundle for gem + # installs. Also manages the optional per-user `hive-web` service + # (systemd-user / launchd), independent of `hive-daemon`. + # + # Subcommands: + # (none) — foreground Rails server (always available) + # install — write/register the managed service unit + # start — start the managed service + # stop — stop the managed service + # status — report managed service state class Web - def initialize(bind: nil, port: nil) + VALID_SUBCOMMANDS = %w[install start stop status].freeze + JSON_ALLOWED = %w[install status].freeze + + def initialize(subcommand: nil, bind: nil, port: nil, force: false, json: false, + binary_path: nil, unsafe: false) + @subcommand = subcommand @bind = bind @port = port + @force = force + @json = json + @binary_path = binary_path + @unsafe = unsafe end def call + if @subcommand.nil? || @subcommand.empty? + run_foreground + elsif VALID_SUBCOMMANDS.include?(@subcommand) + public_send(:"#{@subcommand}_service") + else + raise Hive::InvalidTaskPath, + "hive web: unknown subcommand #{@subcommand.inspect} " \ + "(expected: #{VALID_SUBCOMMANDS.join(', ')}, or bare `hive web` for foreground)" + end + end + + def install_service + require "hive/commands/web/service_installer" + installer = service_installer + result = installer.install!(autostart: true, force: @force) + installer.messages.each { |line| warn "hive: #{line}" } unless @json + emit_install_outcome(installer, result) + end + + def start_service + require "hive/commands/web/service_installer" + installer = service_installer + kind = installer.start! + installer.messages.each { |line| warn "hive: #{line}" } unless @json + case kind + when :ok + puts "hive web: service started" unless @json + when :autostart_unavailable + raise Hive::Error, "systemd-user not available; cannot start hive-web service" + when :unsupported + raise Hive::Error, "web service start is not supported on this platform" + else + raise Hive::Error, installer.messages.last || "hive web start failed" + end + end + + def stop_service + require "hive/commands/web/service_installer" + installer = service_installer + kind = installer.stop! + installer.messages.each { |line| warn "hive: #{line}" } unless @json + case kind + when :ok + puts "hive web: service stopped" unless @json + when :autostart_unavailable + raise Hive::Error, "systemd-user not available; cannot stop hive-web service" + when :unsupported + raise Hive::Error, "web service stop is not supported on this platform" + else + raise Hive::Error, installer.messages.last || "hive web stop failed" + end + end + + def status_service + require "hive/commands/web/service_installer" + installer = service_installer + snap = installer.status_snapshot + active = snap["service_active"] == true + payload = { + "schema" => "hive-web-status", + "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-status"), + "ok" => active, + "platform" => snap["platform"], + "service_installed" => snap["service_installed"], + "service_enabled" => snap["service_enabled"], + "service_active" => snap["service_active"], + "unit_path" => snap["unit_path"], + "error" => active ? nil : "web service not active" + } + if @json + puts JSON.generate(payload) + else + state = + if snap["service_active"] + "active" + elsif snap["service_installed"] + "installed (not active)" + else + "not installed" + end + puts "hive web: #{state}" + puts " unit: #{snap['unit_path']}" if snap["unit_path"] + end + raise Hive::Error, "web service not active" unless active + end + + # Gate non-loopback binds (public for unit tests). + def apply_bind_policy!(bind, cfg) + return if Hive::Web::Loopback.address?(bind) + + owner = cfg.dig("github", "owner").to_s.strip + if !owner.empty? + warn "hive web: WARNING binding #{bind} (non-loopback) with configured owner " \ + "#{owner.inspect} — GitHub login is required; protect this endpoint." + return + end + + if @unsafe + warn "hive web: WARNING binding #{bind} (non-loopback) with --unsafe and no " \ + "configured web.github.owner — existing auth/claim flow still applies; " \ + "this is not safe for untrusted networks." + return + end + + raise Hive::Error, + "hive web: refusing non-loopback bind #{bind.inspect} without " \ + "web.github.owner or --unsafe. Default local bind is 127.0.0.1. " \ + "Set web.github.owner, pass --unsafe, or bind a loopback address." + end + + private + + def run_foreground + if @json + raise Hive::InvalidTaskPath, + "hive web has no JSON output (it runs a long-lived server). " \ + "Use 'hive web status --json' or 'hive status --json'." + end + cfg = Hive::Config.load_global_web bind = @bind || cfg.fetch("bind") port = (@port || cfg.fetch("port")).to_i + + # Bind/auth policy runs BEFORE bundle resolve / db:prepare so a + # refused public bind never mutates state or downloads assets. + apply_bind_policy!(bind, cfg) + app_dir = rails_app_dir unless app_dir - warn "hive web: the hivebox web app (web/) was not found. " \ - "Run from the hivebox Docker image or a source checkout, " \ - "or point HIVEBOX_WEB_APP_DIR at the Rails app." + warn "hive web: the Rails web app was not found. " \ + "Run `hive setup` to install the matching web bundle, use a source " \ + "checkout, the hivebox Docker image, or set HIVEBOX_WEB_APP_DIR." exit 1 end @@ -29,53 +175,42 @@ module Hive env = { "RAILS_ENV" => ENV.fetch("RAILS_ENV", "production"), - # Rails' secret_key_base derives from the same persisted secret the - # session cookies used pre-Rails, so recreating the container keeps - # sessions (the file lives on the /data mount). "SECRET_KEY_BASE" => ENV["SECRET_KEY_BASE"] || Hive::Web::SessionSecret.load_or_create(cfg.fetch("session_secret_file")), "HIVEBOX_ORIGIN" => cfg.fetch("origin"), - # The solid_cable/cache/queue sqlite files must survive image - # upgrades — keep them in state_home (on /data in the container), - # not in the app dir. "HIVEBOX_STORAGE_DIR" => ENV["HIVEBOX_STORAGE_DIR"] || File.join(Hive::Paths.state_home, "web-storage"), "BUNDLE_GEMFILE" => File.join(app_dir, "Gemfile") - } + }.merge(@app_bundle.runtime_env(app_dir)) + # Signal loopback no-auth mode only when bind is loopback and + # config allows it. Rails still re-checks request.remote_ip. + if Hive::Web::Loopback.address?(bind) && cfg.fetch("local_loopback", true) + env[Hive::Web::Loopback::ENV_MODE] = "1" + else + env.delete(Hive::Web::Loopback::ENV_MODE) + end FileUtils.mkdir_p(env.fetch("HIVEBOX_STORAGE_DIR")) Dir.chdir(app_dir) do - # Idempotent: creates/migrates the solid-stack sqlite databases on - # first boot, no-ops afterwards. Array form — no shell involved. - # Typed error so a persistent failure surfaces as guidance, not a - # raw backtrace looping every 5s under the container supervisor. unless system(env, "bin/rails", "db:prepare") raise Hive::Error, "hive web: db:prepare failed — check that " \ - "#{env.fetch("HIVEBOX_STORAGE_DIR")} is writable (the /data mount) " \ + "#{env.fetch("HIVEBOX_STORAGE_DIR")} is writable " \ "and that the web bundle is installed (cd #{app_dir} && bundle install)" end puts "hive web: listening on http://#{bind}:#{port}" - # Replace this process with the Rails server (array form, env hash; - # Kernel#exec never touches a shell when given an argv list). Kernel.exec env, "bin/rails", "server", "-b", bind, "-p", port.to_s end end - private - 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")) } + @app_bundle = Hive::Web::AppBundle.new + @app_bundle.resolve! + rescue Hive::Web::AppBundle::Error => e + warn "hive web: #{e.message}" + nil 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://") @@ -83,6 +218,61 @@ module Hive warn "hive web: WARNING binding 0.0.0.0 without an https origin — " \ "ensure a trusted reverse proxy validates the Host header." end + + def service_installer + require "hive/commands/web/service_installer" + Hive::Commands::Web::ServiceInstaller.new( + binary_path: @binary_path || Hive::InvokedBinary.path + ) + end + + def emit_install_outcome(installer, result) + if @json + if result.success? + puts JSON.generate( + "schema" => "hive-web-install", + "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-install"), + "ok" => true, + "outcome" => result.wire_outcome, + "platform" => installer.envelope_platform, + "unit_path" => installer.target_path, + "backup_path" => result.backup_path, + "restarted" => result.restarted, + "messages" => installer.messages + ) + else + puts JSON.generate( + "schema" => "hive-web-install", + "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-install"), + "ok" => false, + "outcome" => result.wire_outcome, + "platform" => installer.envelope_platform, + "unit_path" => installer.target_path, + "messages" => installer.messages, + "error" => installer.messages.last || "web service install failed" + ) + raise Hive::Error, installer.messages.last || "web service install failed" + end + return + end + + case result.kind + when :written + puts "hive web: installed unit at #{installer.target_path}" + when :upgraded + puts "hive web: upgraded unit at #{installer.target_path}" + 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 :drifted + raise Hive::Error, installer.messages.last || "web unit drifted; re-run with --force" + when :failed + raise Hive::Error, installer.messages.last || "web service install failed" + when :unsupported + # message already warned + end + 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 00000000..e1b13528 --- /dev/null +++ b/lib/hive/commands/web/service_installer.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require "cgi" +require "shellwords" +require "hive/commands/service_installer/base" + +module Hive + module Commands + class Web + # Per-user autostart installer for the local Rails web UI. Independent + # of hive-daemon / hive-bot — separate unit, separate lifecycle. + 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 + + # Start the installed unit via the native service manager. + # Returns :ok, :failed, :autostart_unavailable, or :unsupported. + def start! + case platform + when :linux + return :autostart_unavailable unless systemctl_available? + + ok = @runner.call([ "systemctl", "--user", "start", service_name ]) + unless ok + @messages << "systemctl --user start #{service_name} failed; " \ + "run `systemctl --user status #{service_name}` for details" + return :failed + end + :ok + when :macos + path = target_path + unless path && File.exist?(path) + @messages << "web unit not installed at #{path}; run `hive web install` first" + return :failed + end + ok = @runner.call([ "launchctl", "load", path ]) + unless ok + @messages << "launchctl load failed for #{path}" + return :failed + end + :ok + else + @messages << "web service start is not supported on this platform" + :unsupported + end + end + + # Restart the installed unit so a running Rails process loads a newly + # refreshed managed app bundle even when the service definition itself + # is unchanged. + def restart! + case platform + when :linux + return :autostart_unavailable unless systemctl_available? + + reloaded = @runner.call(%w[systemctl --user daemon-reload]) + restarted = @runner.call([ "systemctl", "--user", "restart", service_name ]) + unless reloaded && restarted + @messages << "systemctl --user restart #{service_name} failed; " \ + "run `systemctl --user status #{service_name}` for details" + return :failed + end + :ok + when :macos + path = target_path + unless path && File.exist?(path) + @messages << "web unit not installed at #{path}; run `hive web install` first" + return :failed + end + unless @runner.call([ "launchctl", "unload", path ]) + @messages << "launchctl unload failed for #{path}" + return :failed + end + unless @runner.call([ "launchctl", "load", path ]) + @messages << "launchctl load failed for #{path}" + return :failed + end + :ok + else + @messages << "web service restart is not supported on this platform" + :unsupported + end + end + + # Stop the installed unit via the native service manager. + def stop! + case platform + when :linux + return :autostart_unavailable unless systemctl_available? + + ok = @runner.call([ "systemctl", "--user", "stop", service_name ]) + unless ok + @messages << "systemctl --user stop #{service_name} failed" + return :failed + end + :ok + when :macos + path = target_path + unless path && File.exist?(path) + @messages << "web unit not installed at #{path}" + return :failed + end + ok = @runner.call([ "launchctl", "unload", path ]) + unless ok + @messages << "launchctl unload failed for #{path} (benign if not loaded)" + return :failed + end + :ok + else + @messages << "web service stop is not supported on this platform" + :unsupported + end + end + + # Non-mutating status snapshot including whether the manager reports + # the job as active/loaded. + def status_snapshot + state = service_state + state.merge( + "service_active" => service_active? + ) + end + + private + + def service_active? + case platform + when :linux + return false unless systemctl_available? + + !!@runner.call([ "systemctl", "--user", "is-active", service_name ]) + when :macos + return false unless launchctl_available? + + !!@runner.call([ "launchctl", "list", launchd_label ]) + else + false + end + end + + def render_systemd + template = File.read(File.expand_path("../../../../examples/systemd/hive-web.service", __dir__)) + escaped = Shellwords.escape(resolved_binary) + template + .sub(/^ExecStart=.*$/, "ExecStart=#{escaped} web") + .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 + 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/config.rb b/lib/hive/config.rb index da8bd31a..3dd88d91 100644 --- a/lib/hive/config.rb +++ b/lib/hive/config.rb @@ -373,6 +373,10 @@ module Hive "bind" => "127.0.0.1", "port" => 4567, "origin" => "http://127.0.0.1:4567", + # Local single-user mode: when the bind is loopback, genuine + # loopback peers may skip GitHub login. Opt out with false. + # Non-loopback binds never use this bypass (see Loopback + Commands::Web). + "local_loopback" => true, "github" => { "owner" => nil, # The shared hivebox OAuth app (device flow only — public by @@ -2291,6 +2295,13 @@ module Hive "web.github.#{key} in #{describe_source(source_path)} must be a non-empty String when set" end + local_loopback = web["local_loopback"] + unless [ true, false ].include?(local_loopback) + raise ConfigError, + "web.local_loopback in #{describe_source(source_path)} must be true or false; " \ + "got #{local_loopback.inspect}" + end + secret_file = web["session_secret_file"] return if secret_file.nil? || (secret_file.is_a?(String) && !secret_file.strip.empty?) diff --git a/lib/hive/daemon/child_supervisor.rb b/lib/hive/daemon/child_supervisor.rb index 1b658816..7ee9fc1d 100644 --- a/lib/hive/daemon/child_supervisor.rb +++ b/lib/hive/daemon/child_supervisor.rb @@ -3,6 +3,7 @@ require "json" require "shellwords" require "time" require "tmpdir" +require "hive/daemon/dispatch_request_queue" module Hive module Daemon @@ -34,12 +35,14 @@ module Hive DEFAULT_KILL_GRACE_SEC = 30 def initialize(hive_bin: ENV.fetch("HIVE_BIN", "hive"), + maintenance_binary_resolver: nil, log_dir_for_task: nil, dry_run: false, default_timeout_sec: 0, verb_timeouts: {}, kill_grace_sec: DEFAULT_KILL_GRACE_SEC) @hive_bin = hive_bin + @maintenance_binary_resolver = maintenance_binary_resolver || -> { @hive_bin } @dry_run = dry_run # Optional injection point: tests pass a lambda taking (project, slug) # → an absolute path to write the child's combined stdout/stderr. @@ -103,9 +106,14 @@ module Hive # An unexpected first token is a dispatcher classifier bug. raise ArgumentError, "ChildSupervisor refuses non-hive command: #{command_string.inspect}" end - # Replace literal "hive" with the configured binary so tests - # can swap in a fixture path via HIVE_BIN. - argv[0] = @hive_bin + # Ordinary children keep using the daemon's configured HIVE_BIN. + # The reserved repair request is different: its purpose is to + # replace that potentially stale service binary, so resolve the + # current install candidate at dispatch time. + maintenance = Hive::Daemon::DispatchRequestQueue.maintenance_request?( + project: project, slug: slug, argv: argv + ) + argv[0] = maintenance ? resolved_maintenance_binary : @hive_bin timeout_sec = timeout_for_verb(argv_verb(argv)) @@ -269,6 +277,13 @@ module Hive private + def resolved_maintenance_binary + binary = @maintenance_binary_resolver.call + return binary unless binary.to_s.empty? + + raise ArgumentError, "ChildSupervisor could not resolve current hive binary for daemon repair" + end + def parse_command(command_string) Shellwords.split(command_string) end diff --git a/lib/hive/daemon/dispatch_request_queue.rb b/lib/hive/daemon/dispatch_request_queue.rb index 00d7aacc..4bffd997 100644 --- a/lib/hive/daemon/dispatch_request_queue.rb +++ b/lib/hive/daemon/dispatch_request_queue.rb @@ -20,6 +20,14 @@ module Hive archive markers ].freeze + # Reserved global project for host-maintenance requests (daemon repair). + # Not a real enrolled project; dispatcher bypasses enrollment only for + # this sentinel + exact allowlisted argv. + GLOBAL_PROJECT = "__hive_host__".freeze + GLOBAL_SLUG = "host-maintenance".freeze + # Exact argv only — no other global/daemon argv may be enqueued. + MAINTENANCE_ARGV = %w[hive daemon install --force].freeze + DIRNAME = "dispatch_requests".freeze CLAIMED_SUFFIX = ".claimed".freeze CLAIM_META_SUFFIX = ".claim".freeze @@ -51,7 +59,7 @@ module Hive def write_request!(project:, slug:, argv:, requestor: "bot", chat_id: nil, update_id: nil, trigger: nil, request_id: generate_request_id, state_home: Hive::Paths.state_home, now: Time.now) - unless valid_argv?(argv) + unless valid_argv?(argv, project: project, slug: slug) raise ArgumentError, "argv #{argv.inspect} is not allowlisted for dispatch requests" end raise ArgumentError, "project is required for dispatch requests" if project.to_s.empty? @@ -339,12 +347,17 @@ module Hive ) end - def valid_argv?(argv) + def valid_argv?(argv, project: nil, slug: nil) return false unless argv.is_a?(Array) return false if argv.length < 2 return false unless argv.all? { |tok| tok.is_a?(String) && !tok.empty? } return false unless argv[0] == "hive" + # Host maintenance is valid only for the complete reserved tuple. + # Keeping this check contextual prevents an exact maintenance argv + # from riding an enabled real project's ordinary dispatch gates. + return maintenance_request?(project: project, slug: slug, argv: argv) if argv == MAINTENANCE_ARGV + verb = argv[1].to_s return false unless ALLOWED_VERBS.include?(verb) return true if argv.length < 3 @@ -354,6 +367,12 @@ module Hive SLUG_RE.match?(argv[2]) end + def maintenance_request?(project:, slug:, argv:) + project.to_s == GLOBAL_PROJECT && + slug.to_s == GLOBAL_SLUG && + argv == MAINTENANCE_ARGV + end + def filename_for(created_at:, request_id:) ts = created_at.utc.strftime("%Y%m%dT%H%M%S%6N") "#{ts}-#{request_id}.json" diff --git a/lib/hive/daemon/dispatcher.rb b/lib/hive/daemon/dispatcher.rb index 1960febf..b9da1a50 100644 --- a/lib/hive/daemon/dispatcher.rb +++ b/lib/hive/daemon/dispatcher.rb @@ -1326,7 +1326,11 @@ module Hive # checks AND the actual spawn. Returns nil; side effects via # @controller, @logger, and the queue's remove() call. def process_dispatch_request_iteration(req, now:) - unless Hive::Daemon::DispatchRequestQueue.valid_argv?(req.argv) + unless Hive::Daemon::DispatchRequestQueue.valid_argv?( + req.argv, + project: req.project, + slug: req.slug + ) reject_request(req, reason: "invalid_argv") return end @@ -1336,27 +1340,34 @@ module Hive return end - unless Hive::Config.find_project(req.project) - reject_request(req, reason: "unknown_project") - return - end + maintenance = Hive::Daemon::DispatchRequestQueue.maintenance_request?( + project: req.project, slug: req.slug, argv: req.argv + ) - # C4 from PR #241 ce-code-review: gate on project_enabled? so a - # disabled project's queued requests don't dispatch. The - # auto-advance path (handle_row) already does this; the - # request path must mirror to keep the single-dispatcher - # invariant honest. - unless project_enabled?(req.project) - @logger.event(:dispatch_request_blocked, - request_id: req.request_id, project: req.project, - slug: req.slug, reason: "project_disabled") - return + unless maintenance + unless Hive::Config.find_project(req.project) + reject_request(req, reason: "unknown_project") + return + end + + # C4 from PR #241 ce-code-review: gate on project_enabled? so a + # disabled project's queued requests don't dispatch. The + # auto-advance path (handle_row) already does this; the + # request path must mirror to keep the single-dispatcher + # invariant honest. + unless project_enabled?(req.project) + @logger.event(:dispatch_request_blocked, + request_id: req.request_id, project: req.project, + slug: req.slug, reason: "project_disabled") + return + end end # Reverse the gate-evaluation order from the previous version: # check the cheap, deterministic running_task? gate first so an # in-flight slug doesn't incur the can_dispatch? scan. Per R-04 - # / M-05 from PR #241 ce-code-review. + # / M-05 from PR #241 ce-code-review. Also dedupes concurrent + # host-maintenance repair requests under the same sentinel slug. if @controller.running_task?(project: req.project, slug: req.slug) @logger.event(:dispatch_request_blocked, request_id: req.request_id, project: req.project, @@ -1364,16 +1375,18 @@ module Hive return end - gate = @controller.can_dispatch?( - project: req.project, slug: req.slug, now: now, - external_global_count: @external_active_agent_total, - external_project_count: external_active_agent_count_for(req.project) - ) - unless gate == :ok - @logger.event(:dispatch_request_blocked, - request_id: req.request_id, project: req.project, - slug: req.slug, reason: gate.to_s) - return + unless maintenance + gate = @controller.can_dispatch?( + project: req.project, slug: req.slug, now: now, + external_global_count: @external_active_agent_total, + external_project_count: external_active_agent_count_for(req.project) + ) + unless gate == :ok + @logger.event(:dispatch_request_blocked, + request_id: req.request_id, project: req.project, + slug: req.slug, reason: gate.to_s) + return + end end dispatch_request!(req, now: now) diff --git a/lib/hive/daemon/status_report.rb b/lib/hive/daemon/status_report.rb new file mode 100644 index 00000000..3435dea4 --- /dev/null +++ b/lib/hive/daemon/status_report.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +require "hive" +require "hive/bounded_subprocess" +require "hive/paths" +require "hive/pid_file" +require "hive/update_check/state" + +module Hive + module Daemon + # Shared producer of the `hive-daemon-status` payload for CLI and web. + # Returns a plain Hash — never captures `$stdout` — so threaded Puma + # can call it safely. Binary version probes are bounded. + class StatusReport + include Hive::PidFile + + VERSION_PROBE_TIMEOUT_SEC = 2 + DRIFT_NONE = "none" + DRIFT_PATH = "path" + DRIFT_VERSION = "version" + DRIFT_UNPARSEABLE = "unparseable" + DRIFT_UNREADABLE = "unreadable" + DRIFT_NOT_APPLICABLE = "not_applicable" + DRIFT_VALUES = [ + DRIFT_NONE, DRIFT_PATH, DRIFT_VERSION, DRIFT_UNPARSEABLE, + DRIFT_UNREADABLE, DRIFT_NOT_APPLICABLE + ].freeze + + def initialize( + hive_home: Hive::Paths.state_home, + binary_path: nil, + installer: nil, + version_probe: nil, + update_state: nil, + running_state: nil + ) + @hive_home = hive_home + @binary_path = binary_path + @installer = installer + @version_probe = version_probe || method(:default_version_probe) + @update_state = update_state + # Optional override: [running, pid, uptime_sec]. Used by Commands::Daemon + # so existing PID ownership stubs on the command still apply. + @running_state_override = running_state + end + + def to_h + running, pid, uptime_sec = @running_state_override || detect_running_state + service = probe_service_state + drift = compute_drift(service) + { + "schema" => "hive-daemon-status", + "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-daemon-status"), + "ok" => true, + "running" => running, + "pid" => running ? pid : nil, + "uptime_sec" => uptime_sec, + "pid_file" => pid_file, + "log_file" => log_file, + "service_installed" => service["service_installed"], + "service_enabled" => service["service_enabled"], + "unit_path" => service["unit_path"], + "current_version" => Hive::VERSION, + "update_nudge" => update_nudge_payload, + "expected_binary" => service["expected_binary"], + "installed_binary" => service["installed_binary"], + "installed_version" => service["installed_version"], + "binary_drift" => drift + } + end + + # Subset safe for dashboard rendering (no filesystem secrets beyond + # already-public status paths). + def safe_payload + to_h + end + + private + + def pid_file + File.join(@hive_home, ".daemon.pid") + end + + def log_file + File.join(@hive_home, "logs", "daemon.log") + end + + def detect_running_state + pid = read_live_pid + return [ false, nil, nil ] unless pid + + uptime = (Time.now - File.stat(pid_file).mtime).to_i + [ true, pid, uptime ] + rescue StandardError + [ false, nil, nil ] + end + + def probe_service_state + installer = resolve_installer + state = installer.service_state + expected = installer.resolved_binary_for_status + installed = installer.installed_binary_path + installed_version = nil + version_probe_failed = false + if installed && File.executable?(installed) + installed_version = probe_installed_version(installed) + version_probe_failed = installed_version.nil? + elsif installed + version_probe_failed = true + end + state.merge( + "expected_binary" => expected, + "installed_binary" => installed, + "installed_version" => installed_version, + "version_probe_failed" => version_probe_failed, + "binary_parse_error" => installer.binary_parse_error + ) + rescue StandardError => e + { + "service_installed" => nil, + "service_enabled" => nil, + "unit_path" => nil, + "expected_binary" => nil, + "installed_binary" => nil, + "installed_version" => nil, + "version_probe_failed" => false, + "binary_parse_error" => "unreadable: #{e.class}: #{e.message}" + } + end + + def resolve_installer + return @installer if @installer + + require "hive/commands/daemon/service_installer" + Hive::Commands::Daemon::ServiceInstaller.new(binary_path: @binary_path) + end + + def compute_drift(service) + if service["binary_parse_error"].to_s.start_with?("unreadable") + return DRIFT_UNREADABLE + end + + unit_path = service["unit_path"] + installed_flag = service["service_installed"] + if unit_path.nil? && installed_flag.nil? + return DRIFT_UNREADABLE + end + if installed_flag == false || unit_path.nil? + return DRIFT_NOT_APPLICABLE + end + + installed = service["installed_binary"] + expected = service["expected_binary"] + if installed.nil? + return service["binary_parse_error"] ? DRIFT_UNPARSEABLE : DRIFT_UNREADABLE + end + + return DRIFT_UNREADABLE if service["version_probe_failed"] + + if expected && paths_differ?(installed, expected) + return DRIFT_PATH + end + + installed_version = service["installed_version"] + if installed_version && installed_version != Hive::VERSION + return DRIFT_VERSION + end + + DRIFT_NONE + end + + def paths_differ?(a, b) + File.expand_path(a.to_s) != File.expand_path(b.to_s) + rescue StandardError + a.to_s != b.to_s + end + + def update_nudge_payload + state = @update_state || Hive::UpdateCheck::State.new + nudge = state.nudge + return nil unless nudge + + { "latest" => nudge.latest, "channel" => nudge.channel, "command" => nudge.command } + rescue StandardError + nil + end + + def default_version_probe(binary) + out, err, status = Hive::BoundedSubprocess.capture3( + binary, + "--version", + timeout: VERSION_PROBE_TIMEOUT_SEC + ) + return nil unless status.success? + + text = "#{out}\n#{err}" + # hive --version prints "hive 0.3.2" or just "0.3.2" + match = text.match(/(\d+\.\d+\.\d+)/) + match && match[1] + rescue Hive::BoundedSubprocess::TimeoutError, + Errno::ENOENT, Errno::EACCES, SystemCallError + nil + end + + def probe_installed_version(binary) + version = @version_probe.call(binary) + return version if version.to_s.match?(/\A\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\z/) + + nil + rescue StandardError + nil + end + end + end +end diff --git a/lib/hive/paths.rb b/lib/hive/paths.rb index 752a5c7d..0bb75dee 100644 --- a/lib/hive/paths.rb +++ b/lib/hive/paths.rb @@ -20,6 +20,18 @@ module Hive hive_home_override || File.join(base_home("XDG_CACHE_HOME", ".cache"), "hive") end + # Managed Rails app tree installed by `Hive::Web::AppBundle` for local + # (non-Docker) web mode. Lives under XDG data — not state — because the + # app is a version-matched release asset, not mutable runtime data. + # SQLite/session storage stays under state_home (see Commands::Web). + def web_app_home + File.join(data_home, "web-app") + end + + def web_app_version_stamp_path + File.join(web_app_home, ".hive-web-version") + end + def task_counter_path File.join(state_home, "task-counter.yml") end diff --git a/lib/hive/setup/diagnostics.rb b/lib/hive/setup/diagnostics.rb new file mode 100644 index 00000000..db4b6b76 --- /dev/null +++ b/lib/hive/setup/diagnostics.rb @@ -0,0 +1,539 @@ +# frozen_string_literal: true + +require "json" +require "rbconfig" + +require "hive" +require "hive/agent_profiles" +require "hive/bounded_subprocess" +require "hive/claude_launcher" +require "hive/paths" +require "hive/web/app_bundle" + +module Hive + module Setup + # Bounded preflight for `hive setup`. Each dependency becomes a Result + # with status, detail, an exact fix command (when applicable), and a + # `bootstrappable` flag. Only Hive-owned assets (qmd, the Rails web + # bundle) may be bootstrapped; external CLIs (gh/claude/codex) are never + # installed or authenticated by setup. + class Diagnostics + PROBE_TIMEOUT_SEC = 5 + MIN_RUBY = "3.4.0" + MIN_TMUX = Hive::ClaudeLauncher::MIN_TMUX_VERSION + QMD_PACKAGE = "@tobilu/qmd" + + STATUSES = %w[ + ok + missing + outdated + unauthenticated + bootstrappable + error + unsupported + ].freeze + + Result = Struct.new( + :name, :status, :detail, :fix, :bootstrappable, :path, :version, + keyword_init: true + ) do + def initialize(**kwargs) + super + self.status = status.to_s + self.bootstrappable = !!bootstrappable + unless STATUSES.include?(status) + raise ArgumentError, "invalid diagnostic status #{status.inspect}" + end + end + + def ok? + status == "ok" + end + + def hard_failure? + # Bootstrappable rows are recoverable by later setup phases. + !ok? && !bootstrappable && status != "unsupported" + end + + def to_h + { + "name" => name, + "status" => status, + "detail" => detail, + "fix" => fix, + "bootstrappable" => bootstrappable, + "path" => path, + "version" => version + } + end + end + + Report = Struct.new(:results, :platform, :ok, keyword_init: true) do + def to_h + { + "schema" => "hive-setup-diagnostics", + "schema_version" => 1, + "ok" => ok, + "platform" => platform, + "results" => results.map(&:to_h) + } + end + + def hard_failures + results.select(&:hard_failure?) + end + + def bootstrappable + results.select(&:bootstrappable) + end + end + + def initialize( + env: ENV, + host_os: RbConfig::CONFIG["host_os"], + home: nil, + which: nil, + runner: nil, + app_bundle: nil, + agent_auth: nil, + gh_auth: nil, + probe_timeout: PROBE_TIMEOUT_SEC + ) + @env = env + @host_os = host_os + @home = File.expand_path(home || env["HOME"] || Dir.home) + @which = which || method(:default_which) + @runner = runner || method(:default_runner) + @app_bundle = app_bundle + @agent_auth = agent_auth || method(:default_agent_logged_in?) + @gh_auth = gh_auth || method(:default_gh_logged_in?) + @probe_timeout = probe_timeout + end + + def call + results = [ + check_ruby, + check_binary("git", fix_missing: install_hint("git")), + check_tmux, + check_gh, + check_agent("claude", fix_login: "claude setup-token"), + check_agent("codex", fix_login: "codex login"), + check_binary("node", fix_missing: install_hint("node")), + check_binary("npm", fix_missing: install_hint("npm")), + check_qmd, + check_web_bundle, + check_sqlite + ] + Report.new( + results: results, + platform: platform_name, + ok: results.none?(&:hard_failure?) + ) + end + + private + + def check_ruby + path = RbConfig.ruby + version = RUBY_VERSION + if (version_tuple(version) <=> version_tuple(MIN_RUBY)).negative? + return result( + "ruby", + status: "outdated", + detail: "ruby #{version} is below minimum #{MIN_RUBY}", + fix: install_hint("ruby"), + path: path, + version: version + ) + end + + result("ruby", status: "ok", detail: "ruby #{version}", path: path, version: version) + end + + def check_tmux + path = @which.call("tmux") + unless path + return result( + "tmux", + status: "missing", + detail: "tmux not found on PATH", + fix: install_hint("tmux") + ) + end + + version = probe_version(path, %w[--version], /tmux\s+(\d+\.\d+(?:\.\d+)?)/i) + if version.nil? + return result( + "tmux", + status: "error", + detail: "could not parse tmux --version", + fix: install_hint("tmux"), + path: path + ) + end + + if (version_tuple(version) <=> version_tuple(MIN_TMUX)).negative? + return result( + "tmux", + status: "outdated", + detail: "tmux #{version} is below minimum #{MIN_TMUX}", + fix: install_hint("tmux"), + path: path, + version: version + ) + end + + result("tmux", status: "ok", detail: "tmux #{version}", path: path, version: version) + end + + def check_gh + path = @which.call("gh") + unless path + return result( + "gh", + status: "missing", + detail: "gh not found on PATH", + fix: install_hint("gh") + ) + end + + version = probe_version(path, %w[--version], /gh version (\d+\.\d+\.\d+)/i) + unless @gh_auth.call + return result( + "gh", + status: "unauthenticated", + detail: "gh is installed but not authenticated", + fix: "gh auth login", + path: path, + version: version + ) + end + + result( + "gh", + status: "ok", + detail: version ? "gh #{version} authenticated" : "gh authenticated", + path: path, + version: version + ) + end + + def check_agent(name, fix_login:) + path = @which.call(name) + unless path + return result( + name, + status: "missing", + detail: "#{name} not found on PATH", + fix: install_hint(name) + ) + end + + version = probe_version(path, %w[--version], /(\d+\.\d+\.\d+)/) + unless @agent_auth.call(name) + return result( + name, + status: "unauthenticated", + detail: "#{name} is installed but not authenticated", + fix: fix_login, + path: path, + version: version + ) + end + + result( + name, + status: "ok", + detail: version ? "#{name} #{version} authenticated" : "#{name} authenticated", + path: path, + version: version + ) + end + + def check_binary(name, fix_missing:) + path = @which.call(name) + unless path + return result(name, status: "missing", detail: "#{name} not found on PATH", fix: fix_missing) + end + + version = probe_version(path, %w[--version], /(\d+\.\d+(?:\.\d+)?)/) + result( + name, + status: "ok", + detail: version ? "#{name} #{version}" : "#{name} found", + path: path, + version: version + ) + end + + def check_qmd + path = find_qmd + if path + version = probe_version(path, %w[--version], /(\d+\.\d+\.\d+)/) + return result( + "qmd", + status: "ok", + detail: version ? "qmd #{version}" : "qmd found", + path: path, + version: version + ) + end + + unless @which.call("npm") + return result( + "qmd", + status: "missing", + detail: "qmd not found and npm is unavailable to bootstrap it", + fix: "#{install_hint('npm')}; then #{qmd_install_command}" + ) + end + + result( + "qmd", + status: "bootstrappable", + detail: "qmd not found; hive can install it under the managed prefix", + fix: qmd_install_command, + bootstrappable: true + ) + end + + def check_web_bundle + bundle = app_bundle + begin + path = bundle.resolve!(install: false) + detail = + if bundle.current_managed_bundle? + "managed web bundle #{Hive::VERSION}" + elsif @env[Hive::Web::AppBundle::ENV_APP_DIR].to_s == path || + File.expand_path(@env[Hive::Web::AppBundle::ENV_APP_DIR].to_s) == path + "Rails app via #{Hive::Web::AppBundle::ENV_APP_DIR}" + else + "Rails app available" + end + return result( + "web_bundle", + status: "ok", + detail: detail, + path: path, + version: bundle.current_managed_bundle? ? Hive::VERSION : bundle.installed_version + ) + rescue Hive::Web::AppBundle::Error + # fall through to bootstrappable + end + + if bundle.rails_app?(bundle.managed_app_dir) + installed = bundle.installed_version + return result( + "web_bundle", + status: "bootstrappable", + detail: "managed web bundle is stale (#{installed.inspect}); needs #{Hive::VERSION}", + fix: "hive setup # refreshes web bundle to #{Hive::VERSION}", + path: bundle.managed_app_dir, + version: installed, + bootstrappable: true + ) + end + + result( + "web_bundle", + status: "bootstrappable", + detail: "Rails web bundle not installed for hive #{Hive::VERSION}", + fix: "hive setup # downloads #{bundle.archive_basename}", + bootstrappable: true + ) + end + + def check_sqlite + # Prefer the sqlite3 gem (already a hive runtime dep) over a CLI. + begin + require "sqlite3" + version = defined?(SQLite3::SQLITE_VERSION) ? SQLite3::SQLITE_VERSION : SQLite3::VERSION + return result( + "sqlite", + status: "ok", + detail: "sqlite3 gem #{version}", + version: version.to_s + ) + rescue LoadError + # fall through to CLI probe + end + + path = @which.call("sqlite3") + unless path + return result( + "sqlite", + status: "missing", + detail: "sqlite3 gem and sqlite3 CLI are unavailable", + fix: install_hint("sqlite3") + ) + end + + version = probe_version(path, %w[--version], /(\d+\.\d+\.\d+)/) + result( + "sqlite", + status: "ok", + detail: version ? "sqlite3 #{version}" : "sqlite3 CLI found", + path: path, + version: version + ) + end + + def result(name, status:, detail:, fix: nil, bootstrappable: false, path: nil, version: nil) + Result.new( + name: name, + status: status, + detail: detail, + fix: fix, + bootstrappable: bootstrappable, + path: path, + version: version + ) + end + + def app_bundle + @app_bundle ||= Hive::Web::AppBundle.new( + env: @env, + data_home: Hive::Paths.data_home, + # Production diagnostics use the real source-checkout fallback. + # Tests inject an AppBundle with source_app_dir pointed at a + # non-existent path so the suite stays hermetic. + downloader: ->(*) { raise "diagnostics must not download" }, + bundler: ->(*) { raise "diagnostics must not bundle install" } + ) + end + + def find_qmd + env_qmd = @env["HIVE_QMD_BIN"].to_s + return env_qmd if !env_qmd.empty? && File.executable?(env_qmd) + + path_qmd = @which.call("qmd") + return path_qmd if path_qmd + + data_home = Hive::Paths.data_home + candidates = [ + File.join(data_home, "qmd", "bin", "qmd"), + File.expand_path("~/.local/share/hive/qmd/bin/qmd", @home) + ] + # Paths.expand with ~ uses HOME; keep explicit home-relative too. + candidates << File.join(@home, ".local/share/hive/qmd/bin/qmd") + + prefix_file = File.join(data_home, "install-prefix") + if File.readable?(prefix_file) + prefix = File.read(prefix_file).lines.first.to_s.strip + candidates << File.join(prefix, "hive", "qmd", "bin", "qmd") unless prefix.empty? + end + + candidates.find { |candidate| File.file?(candidate) && File.executable?(candidate) } + end + + def qmd_install_command + prefix = File.join(Hive::Paths.data_home, "qmd") + "npm install --global --prefix \"#{prefix}\" #{QMD_PACKAGE}" + end + + def install_hint(name) + case platform_name + when "macos" + case name + when "ruby" then "brew install ruby@3.4 # or install Ruby 3.4+ via mise/rbenv/asdf" + when "git" then "brew install git" + when "tmux" then "brew install tmux" + when "gh" then "brew install gh" + when "node", "npm" then "brew install node" + when "sqlite3" then "brew install sqlite" + when "claude" then "Install Claude Code: https://docs.anthropic.com/en/docs/claude-code" + when "codex" then "Install Codex CLI: https://github.com/openai/codex" + else "brew install #{name}" + end + when "linux" + case name + when "ruby" then "Install Ruby >= #{MIN_RUBY} via your package manager or mise/rbenv/asdf" + when "git" then "sudo apt-get install -y git # or equivalent for your distro" + when "tmux" then "sudo apt-get install -y tmux # or equivalent for your distro" + when "gh" then "See https://github.com/cli/cli#installation" + when "node", "npm" then "sudo apt-get install -y nodejs npm # or equivalent for your distro" + when "sqlite3" then "sudo apt-get install -y sqlite3 libsqlite3-dev # or equivalent" + when "claude" then "Install Claude Code: https://docs.anthropic.com/en/docs/claude-code" + when "codex" then "Install Codex CLI: https://github.com/openai/codex" + else "Install #{name} via your package manager" + end + else + "Install #{name} for your platform (Linux/macOS supported for local setup)" + end + end + + def platform_name + case @host_os + when /darwin/i then "macos" + when /linux/i then "linux" + else "unsupported" + end + end + + def probe_version(path, argv, pattern) + out, err, status = @runner.call([ path, *argv ]) + return nil unless status + + text = "#{out}\n#{err}" + match = text.match(pattern) + match && match[1] + rescue StandardError + nil + end + + def default_which(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 default_runner(argv) + out, err, status = Hive::BoundedSubprocess.capture3(*argv, timeout: @probe_timeout) + [ out, err, status.success? ] + rescue Hive::BoundedSubprocess::TimeoutError, Errno::ENOENT, Errno::EACCES, SystemCallError + [ "", "", false ] + end + + def default_agent_logged_in?(name) + return true if api_key_present?(name) + + Hive::AgentProfiles.logged_in?(name, home: @home) + end + + def api_key_present?(name) + case name.to_s + when "claude" + key_set?("ANTHROPIC_API_KEY") || key_set?("CLAUDE_API_KEY") + when "codex" + key_set?("OPENAI_API_KEY") + else + false + end + end + + def key_set?(name) + value = @env[name].to_s.strip + !value.empty? + end + + def default_gh_logged_in? + out, err, ok = default_runner(%w[gh auth status]) + return true if ok + + # Some gh versions write status details to stderr even on success. + text = "#{out}\n#{err}" + return true if text.match?(/Logged in to/i) + + false + rescue StandardError + false + end + + def version_tuple(version) + version.to_s.split(".").map(&:to_i) + end + end + end +end diff --git a/lib/hive/web/app_bundle.rb b/lib/hive/web/app_bundle.rb new file mode 100644 index 00000000..5a2176d3 --- /dev/null +++ b/lib/hive/web/app_bundle.rb @@ -0,0 +1,584 @@ +# frozen_string_literal: true + +require "fileutils" +require "digest" +require "net/http" +require "openssl" +require "rubygems/package" +require "securerandom" +require "timeout" +require "tmpdir" +require "uri" +require "zlib" + +require "hive" +require "hive/bounded_subprocess" +require "hive/paths" + +module Hive + module Web + # Resolves and installs the version-matched Rails application bundle used + # by local (non-Docker) `hive web`. The gem intentionally excludes `web/`; + # gem/Homebrew/AUR installs acquire the matching GitHub release asset + # (`hive-web-.tar.gz`) into `Hive::Paths.web_app_home`. + # + # Resolution order for `#resolve!`: + # 1. `HIVEBOX_WEB_APP_DIR` (explicit override; Docker and tests) + # 2. Current managed bundle (stamped with `Hive::VERSION`) + # 3. Source checkout `web/` next to the gem lib tree + # 4. Download + install the matching release asset + # + # Install is staged, Bundler-verified, then atomically swapped. A failed + # refresh leaves any previous working bundle intact; a failed first + # install leaves no half-provisioned target. + class AppBundle + ENV_APP_DIR = "HIVEBOX_WEB_APP_DIR" + VERSION_STAMP_NAME = ".hive-web-version" + RAILS_MARKER = File.join("config", "application.rb") + ARCHIVE_BASENAME_PREFIX = "hive-web-" + DEFAULT_CONNECT_TIMEOUT = 10 + DEFAULT_READ_TIMEOUT = 120 + DEFAULT_BUNDLE_TIMEOUT = 600 + DEFAULT_CHECKSUM_VERIFY_TIMEOUT = 60 + MAX_ARCHIVE_BYTES = 128 * 1024 * 1024 + MAX_CHECKSUM_ASSET_BYTES = 1024 * 1024 + MAX_ARCHIVE_ENTRIES = 20_000 + MAX_ARCHIVE_ENTRY_BYTES = 64 * 1024 * 1024 + MAX_ARCHIVE_EXPANDED_BYTES = 256 * 1024 * 1024 + + class Error < Hive::Error; end + + attr_reader :messages + + def initialize( + version: Hive::VERSION, + data_home: nil, + source_app_dir: nil, + env: ENV, + downloader: nil, + bundler: nil, + tar_reader: nil, + http_factory: nil, + subprocess_runner: nil, + connect_timeout: DEFAULT_CONNECT_TIMEOUT, + read_timeout: DEFAULT_READ_TIMEOUT, + bundle_timeout: DEFAULT_BUNDLE_TIMEOUT, + max_archive_bytes: MAX_ARCHIVE_BYTES, + max_archive_entries: MAX_ARCHIVE_ENTRIES, + max_archive_entry_bytes: MAX_ARCHIVE_ENTRY_BYTES, + max_archive_expanded_bytes: MAX_ARCHIVE_EXPANDED_BYTES + ) + @version = version.to_s + @data_home = data_home + @source_app_dir = source_app_dir + @env = env + @downloader = downloader + @bundler = bundler || method(:default_bundle_install) + @tar_reader = tar_reader + @http_factory = http_factory + @subprocess_runner = subprocess_runner || Hive::BoundedSubprocess.method(:capture3) + @connect_timeout = connect_timeout + @read_timeout = read_timeout + @bundle_timeout = bundle_timeout + @max_archive_bytes = max_archive_bytes + @max_archive_entries = max_archive_entries + @max_archive_entry_bytes = max_archive_entry_bytes + @max_archive_expanded_bytes = max_archive_expanded_bytes + @messages = [] + end + + # Return a usable Rails app directory, installing the managed bundle + # when needed. Raises `AppBundle::Error` when no path can be resolved. + def resolve!(install: true) + if (explicit = explicit_app_dir) + return explicit + end + + if current_managed_bundle? + return managed_app_dir + end + + if (source = source_checkout_app_dir) + return source + end + + raise Error, missing_bundle_message unless install + + ensure_installed! + managed_app_dir + end + + # True when a stamped managed bundle matching `@version` is present. + def current_managed_bundle? + rails_app?(managed_app_dir) && installed_version == @version + end + + def installed_version + path = version_stamp_path + return nil unless File.file?(path) + + File.read(path).strip + rescue SystemCallError + nil + end + + def managed_app_dir + @data_home ? File.join(@data_home, "web-app") : Hive::Paths.web_app_home + end + + def version_stamp_path + File.join(managed_app_dir, VERSION_STAMP_NAME) + end + + def archive_basename(version = @version) + "#{ARCHIVE_BASENAME_PREFIX}#{version}.tar.gz" + end + + def release_asset_url(version = @version) + "https://github.com/#{Hive::REPO_OWNER}/#{Hive::REPO_NAME}/releases/download/v#{version}/#{archive_basename(version)}" + end + + # Bundler installs managed web gems below the app tree rather than into + # the invoking CLI's GEM_HOME. Source checkouts and explicit Docker/test + # overrides keep their normal development environment. + def runtime_env(app_dir) + return {} unless File.expand_path(app_dir.to_s) == File.expand_path(managed_app_dir) + + bundle_env(managed_app_dir) + end + + # Install or refresh the managed bundle for `@version`. Idempotent when + # the current stamp already matches. + def ensure_installed!(source: nil) + return managed_app_dir if current_managed_bundle? && source.nil? + + if source + install_from_directory!(source) + else + install_from_release! + end + managed_app_dir + end + + # Install from an already-extracted Rails tree (tests / offline fixtures). + def install_from_directory!(source_dir) + source_dir = File.expand_path(source_dir.to_s) + unless rails_app?(source_dir) + raise Error, "web app source at #{source_dir} is missing #{RAILS_MARKER}" + end + + stage_and_swap! do |staging| + copy_tree(source_dir, staging) + end + end + + # Download the matching release archive and install it. + def install_from_release!(url: release_asset_url) + Dir.mktmpdir("hive-web-dl-") do |tmpdir| + archive_path = File.join(tmpdir, archive_basename) + checksums_path = File.join(tmpdir, "SHA256SUMS") + download_asset(checksums_url(url), checksums_path, max_bytes: MAX_CHECKSUM_ASSET_BYTES) + verify_checksums_signature!(url, checksums_path, tmpdir) + download_asset(url, archive_path, max_bytes: @max_archive_bytes) + verify_archive_checksum!(archive_path, checksums_path) + extract_and_install!(archive_path) + end + end + + # Extract a local `.tar.gz` and install it into the managed path. + def extract_and_install!(archive_path) + if File.size(archive_path) > @max_archive_bytes + raise Error, "#{File.basename(archive_path)} archive is too large " \ + "(max #{@max_archive_bytes} bytes)" + end + + Dir.mktmpdir("hive-web-extract-") do |extract_root| + extract_archive!(archive_path, extract_root) + rails_root = find_rails_root(extract_root) + unless rails_root + raise Error, "archive does not contain a Rails app (missing #{RAILS_MARKER})" + end + + stage_and_swap! do |staging| + copy_tree(rails_root, staging) + end + end + end + + def rails_app?(dir) + !dir.to_s.empty? && File.file?(File.join(dir, RAILS_MARKER)) + end + + private + + def explicit_app_dir + raw = @env[ENV_APP_DIR].to_s + return nil if raw.empty? + + dir = File.expand_path(raw) + return dir if rails_app?(dir) + + @messages << "HIVEBOX_WEB_APP_DIR=#{dir} does not contain #{RAILS_MARKER}" + nil + end + + def source_checkout_app_dir + candidates = [] + candidates << @source_app_dir if @source_app_dir + # lib/hive/web/app_bundle.rb → repo_root/web + candidates << File.expand_path("../../../web", __dir__) + candidates.find { |dir| rails_app?(dir) } + end + + def stage_and_swap! + target = managed_app_dir + parent = File.dirname(target) + FileUtils.mkdir_p(parent) + + staging = "#{target}.staging.#{Process.pid}.#{SecureRandom.hex(4)}" + backup = nil + begin + FileUtils.rm_rf(staging) + FileUtils.mkdir_p(staging) + yield staging + + unless rails_app?(staging) + raise Error, "staged web app is missing #{RAILS_MARKER}" + end + + @bundler.call(staging) + + # Stamp last so a partial install is never considered current. + File.write(File.join(staging, VERSION_STAMP_NAME), "#{@version}\n") + + if File.exist?(target) + backup = "#{target}.prev.#{Process.pid}.#{SecureRandom.hex(4)}" + File.rename(target, backup) + end + File.rename(staging, target) + FileUtils.rm_rf(backup) if backup + @messages << "installed web app bundle #{@version} at #{target}" + target + rescue StandardError + FileUtils.rm_rf(staging) if staging && File.exist?(staging) + if backup && File.exist?(backup) && !File.exist?(target) + File.rename(backup, target) + end + raise + ensure + FileUtils.rm_rf(staging) if staging && File.exist?(staging) + FileUtils.rm_rf(backup) if backup && File.exist?(backup) && File.exist?(target) + end + end + + def copy_tree(source, dest) + FileUtils.mkdir_p(dest) + Dir.children(source).each do |entry| + next if entry == VERSION_STAMP_NAME || entry == "." || entry == ".." + + src = File.join(source, entry) + FileUtils.cp_r(src, File.join(dest, entry), preserve: true) + end + end + + def extract_archive!(archive_path, dest_root) + dest_root = File.expand_path(dest_root) + FileUtils.mkdir_p(dest_root) + entry_count = 0 + expanded_bytes = 0 + + open_tar(archive_path) do |tar| + tar.each do |entry| + entry_count += 1 + if entry_count > @max_archive_entries + raise Error, "archive contains too many entries (max #{@max_archive_entries})" + end + validate_tar_entry!(entry, dest_root) + dest = File.join(dest_root, entry.full_name) + + if entry.directory? + FileUtils.mkdir_p(dest) + elsif entry.file? + entry_size = Integer(entry.header.size) + if entry_size > @max_archive_entry_bytes + raise Error, "archive entry is too large: #{entry.full_name.inspect} " \ + "(max #{@max_archive_entry_bytes} bytes)" + end + expanded_bytes += entry_size + if expanded_bytes > @max_archive_expanded_bytes + raise Error, "archive expanded contents are too large " \ + "(max #{@max_archive_expanded_bytes} bytes)" + end + FileUtils.mkdir_p(File.dirname(dest)) + File.open(dest, "wb", entry.header.mode & 0o755) do |out| + while (chunk = entry.read(1024 * 64)) + out.write(chunk) + end + end + # Strip setuid/setgid/sticky; keep owner rwx only as recorded. + File.chmod(entry.header.mode & 0o755, dest) + else + raise Error, "archive contains unsupported entry type for #{entry.full_name.inspect}" + end + end + end + end + + def open_tar(archive_path, &block) + if @tar_reader + @tar_reader.call(archive_path, &block) + return + end + + Zlib::GzipReader.open(archive_path) do |gz| + Gem::Package::TarReader.new(gz, &block) + end + rescue Zlib::Error, Gem::Package::TarInvalidError => e + raise Error, "invalid web app archive: #{e.message}" + end + + def validate_tar_entry!(entry, dest_root) + name = entry.full_name.to_s + if name.empty? + raise Error, "archive contains an entry with an empty path" + end + if name.start_with?("/") || name.match?(%r{\A[A-Za-z]:[\\/]}) + raise Error, "archive entry has absolute path: #{name.inspect}" + end + if name.split("/").include?("..") + raise Error, "archive entry escapes staging root: #{name.inspect}" + end + if entry.symlink? || entry.header.typeflag == "2" || entry.header.typeflag == "1" + raise Error, "archive contains link entry (rejected): #{name.inspect}" + end + + dest = File.expand_path(File.join(dest_root, name)) + unless dest == dest_root || dest.start_with?("#{dest_root}#{File::SEPARATOR}") + raise Error, "archive entry escapes staging root: #{name.inspect}" + end + end + + def find_rails_root(extract_root) + return extract_root if rails_app?(extract_root) + + # Single versioned wrapper directory (e.g. hive-web-0.3.2/...) is OK. + children = Dir.children(extract_root).reject { |n| n.start_with?(".") } + return nil unless children.length == 1 + + candidate = File.join(extract_root, children.first) + return candidate if File.directory?(candidate) && rails_app?(candidate) + + nil + end + + def checksums_url(asset_url) + sibling_asset_url(asset_url, "SHA256SUMS") + end + + def sibling_asset_url(asset_url, basename) + uri = URI.parse(asset_url) + uri.path = File.join(File.dirname(uri.path), basename) + uri.query = nil + uri.fragment = nil + uri.to_s + end + + def download_asset(url, dest_path, max_bytes:) + if @downloader + @downloader.call(url, dest_path) + else + default_download(url, dest_path, max_bytes: max_bytes) + end + unless File.file?(dest_path) + raise Error, "download did not create #{File.basename(dest_path)}" + end + if File.size(dest_path) > max_bytes + FileUtils.rm_f(dest_path) + raise Error, "#{File.basename(dest_path)} download is too large (max #{max_bytes} bytes)" + end + dest_path + end + + def default_download(url, dest_path, max_bytes: @max_archive_bytes) + uri = URI.parse(url) + unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) + raise Error, "web app download URL must be http(s); got #{url.inspect}" + end + + part_path = "#{dest_path}.part.#{Process.pid}.#{SecureRandom.hex(4)}" + fetch_with_redirects(uri) do |response| + unless response.is_a?(Net::HTTPSuccess) + code = response.respond_to?(:code) ? response.code : "?" + raise Error, "failed to download web app bundle from #{url} (HTTP #{code})" + end + + length = Integer(response["content-length"], exception: false) + if length && length > max_bytes + raise Error, "#{File.basename(dest_path)} download is too large (max #{max_bytes} bytes)" + end + + downloaded = 0 + File.open(part_path, "wb", 0o600) do |file| + response.read_body do |chunk| + downloaded += chunk.bytesize + if downloaded > max_bytes + raise Error, "#{File.basename(dest_path)} download is too large (max #{max_bytes} bytes)" + end + file.write(chunk) + end + end + end + File.rename(part_path, dest_path) + dest_path + rescue SocketError, Timeout::Error, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, + Errno::ENETUNREACH, OpenSSL::SSL::SSLError, Net::OpenTimeout, + Net::ReadTimeout => e + raise Error, "failed to download web app bundle from #{url}: #{e.class}: #{e.message}" + ensure + FileUtils.rm_f(part_path) if defined?(part_path) && part_path + end + + def fetch_with_redirects(uri, limit: 5, &block) + raise Error, "too many redirects downloading web app bundle" if limit <= 0 + + http = @http_factory ? @http_factory.call(uri) : Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == "https" + http.open_timeout = @connect_timeout + http.read_timeout = @read_timeout + request = Net::HTTP::Get.new(uri.request_uri) + request["User-Agent"] = "hive-cli/#{@version}" + http.request(request) do |response| + case response + when Net::HTTPRedirection + location = response["location"].to_s + raise Error, "redirect without Location header" if location.empty? + + redirected = URI.join(uri.to_s, location) + unless redirected.is_a?(URI::HTTP) || redirected.is_a?(URI::HTTPS) + raise Error, "web app redirect URL must be http(s); got #{redirected}" + end + if uri.scheme == "https" && redirected.scheme == "http" + raise Error, "refusing HTTPS to HTTP redirect downloading web app bundle" + end + return fetch_with_redirects(redirected, limit: limit - 1, &block) + else + return block.call(response) + end + end + end + + def verify_archive_checksum!(archive_path, checksums_path) + basename = File.basename(archive_path) + pattern = /\A([a-f0-9]{64}) (?:\.\/)?#{Regexp.escape(basename)}\s*\z/ + matches = File.foreach(checksums_path).filter_map { |line| line.match(pattern)&.[](1) }.uniq + if matches.empty? + raise Error, "SHA256SUMS does not contain #{basename}" + end + if matches.length > 1 + raise Error, "SHA256SUMS contains conflicting entries for #{basename}" + end + + actual = ::Digest::SHA256.file(archive_path).hexdigest + return if actual == matches.first + + raise Error, "checksum mismatch for #{basename}" + rescue SystemCallError => e + raise Error, "could not verify #{basename}: #{e.message}" + end + + def verify_checksums_signature!(asset_url, checksums_path, tmpdir) + cosign = find_executable("cosign") + return unless cosign + + signature_path = File.join(tmpdir, "SHA256SUMS.sig") + certificate_path = File.join(tmpdir, "SHA256SUMS.pem") + download_asset( + sibling_asset_url(asset_url, "SHA256SUMS.sig"), signature_path, + max_bytes: MAX_CHECKSUM_ASSET_BYTES + ) + download_asset( + sibling_asset_url(asset_url, "SHA256SUMS.pem"), certificate_path, + max_bytes: MAX_CHECKSUM_ASSET_BYTES + ) + identity = "^https://github\\.com/#{Regexp.escape(Hive::REPO_OWNER)}/#{Regexp.escape(Hive::REPO_NAME)}/" + _out, err, status = @subprocess_runner.call( + cosign, "verify-blob", + "--certificate", certificate_path, + "--signature", signature_path, + "--certificate-identity-regexp", identity, + "--certificate-oidc-issuer", "https://token.actions.githubusercontent.com", + checksums_path, + timeout: DEFAULT_CHECKSUM_VERIFY_TIMEOUT + ) + return if status.success? + + raise Error, "cosign verification failed for SHA256SUMS: #{err.to_s.strip}" + rescue Hive::BoundedSubprocess::TimeoutError + raise Error, "cosign verification timed out after #{DEFAULT_CHECKSUM_VERIFY_TIMEOUT}s" + end + + def find_executable(name) + @env["PATH"].to_s.split(File::PATH_SEPARATOR).each do |dir| + candidate = File.join(dir, name) + return candidate if File.file?(candidate) && File.executable?(candidate) + end + nil + end + + def default_bundle_install(app_dir) + gemfile = File.join(app_dir, "Gemfile") + return unless File.file?(gemfile) + + env = bundle_env(app_dir) + # Prefer frozen install when Gemfile.lock is present; fall back to a + # network-enabled install when the release's vendor/cache does not + # contain every platform-specific Rails dependency. + cmd = + if File.file?(File.join(app_dir, "Gemfile.lock")) + %w[bundle install --local] + else + %w[bundle install] + end + + out, err, status = run_bundle_install(env, cmd, app_dir) + return if status.success? + + # --local can fail when vendor/cache does not hold every dependency; + # retry without it once while preserving the same hard deadline. + if cmd.include?("--local") + out, err, status = run_bundle_install(env, %w[bundle install], app_dir) + return if status.success? + end + + detail = [ out, err ].map(&:to_s).map(&:strip).reject(&:empty?).join("\n") + raise Error, "bundle install failed for web app at #{app_dir}: #{detail}" + end + + def bundle_env(app_dir) + { + # Point Bundler's app-local config lookup at the managed tree. A + # caller-level BUNDLE_APP_CONFIG can otherwise load a path setting + # that outranks BUNDLE_PATH and sends gems back into the CLI bundle. + "BUNDLE_APP_CONFIG" => File.join(app_dir, ".bundle"), + "BUNDLE_GEMFILE" => File.join(app_dir, "Gemfile"), + "BUNDLE_PATH" => File.join(app_dir, "vendor", "bundle"), + "BUNDLE_DEPLOYMENT" => "1", + "BUNDLE_WITHOUT" => "development:test", + "RAILS_ENV" => "production" + } + end + + def run_bundle_install(env, cmd, app_dir) + @subprocess_runner.call(env, *cmd, chdir: app_dir, timeout: @bundle_timeout) + rescue Hive::BoundedSubprocess::TimeoutError + raise Error, "bundle install timed out after #{@bundle_timeout}s for web app at #{app_dir}" + end + + def missing_bundle_message + "hive web: no Rails app found. Install the matching release asset " \ + "(#{archive_basename}) under #{managed_app_dir}, run from a source " \ + "checkout, or set #{ENV_APP_DIR} to a Rails app directory." + end + end + end +end diff --git a/lib/hive/web/dispatcher.rb b/lib/hive/web/dispatcher.rb index 10683729..93c77f4f 100644 --- a/lib/hive/web/dispatcher.rb +++ b/lib/hive/web/dispatcher.rb @@ -11,6 +11,29 @@ require "hive/stages" module Hive module Web class Dispatcher + # Enqueue the only allowlisted host-maintenance command so a running + # daemon rewrites its unit with the current CLI binary. Rails workers + # never shell out to rewrite services themselves. + def queue_daemon_repair! + require "hive/daemon/dispatch_request_queue" + argv = Hive::Daemon::DispatchRequestQueue::MAINTENANCE_ARGV.dup + unless Hive::Daemon::DispatchRequestQueue.valid_argv?( + argv, + project: Hive::Daemon::DispatchRequestQueue::GLOBAL_PROJECT, + slug: Hive::Daemon::DispatchRequestQueue::GLOBAL_SLUG + ) + raise Hive::Error, "daemon repair argv is not allowlisted" + end + + Hive::Daemon::DispatchRequestQueue.write_request!( + project: Hive::Daemon::DispatchRequestQueue::GLOBAL_PROJECT, + slug: Hive::Daemon::DispatchRequestQueue::GLOBAL_SLUG, + argv: argv, + requestor: "bot", + trigger: "daemon_repair" + ) + end + STAGE_VERB_BY_ACTION = { "ready_to_brainstorm" => "brainstorm", "ready_to_plan" => "plan", diff --git a/lib/hive/web/loopback.rb b/lib/hive/web/loopback.rb new file mode 100644 index 00000000..0d97717b --- /dev/null +++ b/lib/hive/web/loopback.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "ipaddr" + +module Hive + module Web + # Shared loopback classifier used by the CLI bind gate (`hive web`) and + # the Rails request auth bypass. One implementation so the two sides + # cannot disagree about what counts as loopback. + module Loopback + module_function + + # Environment flag set by the CLI only when the configured bind is + # loopback AND config `web.local_loopback` is enabled. Rails re-checks + # the actual peer address before skipping login. + ENV_MODE = "HIVE_WEB_LOCAL_LOOPBACK" + + # Hostnames treated as loopback when used as a bind address. + LOOPBACK_HOSTS = %w[localhost].freeze + + def address?(value) + raw = value.to_s.strip + return false if raw.empty? + return true if LOOPBACK_HOSTS.include?(raw.downcase) + + # Strip IPv6 brackets: "[::1]" + host = raw.sub(/\A\[(.*)\]\z/, '\1') + # Strip optional :port for IPv4 ("127.0.0.1:4567") — not for IPv6. + if host.count(":") == 1 && host.match?(/\A[\d.]+:\d+\z/) + host = host.split(":", 2).first + end + + ip = IPAddr.new(host) + ip.loopback? + rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError + false + end + + def mode_enabled?(env = ENV) + %w[1 true yes].include?(env[ENV_MODE].to_s.strip.downcase) + end + + def enable_mode!(env = ENV) + env[ENV_MODE] = "1" + end + + def disable_mode!(env = ENV) + env.delete(ENV_MODE) + end + end + end +end diff --git a/openclaw/skills/hive/SKILL.md b/openclaw/skills/hive/SKILL.md index 4eecc409..f8cd6392 100644 --- a/openclaw/skills/hive/SKILL.md +++ b/openclaw/skills/hive/SKILL.md @@ -41,11 +41,11 @@ That listing installs the `/hive` slash command. First run should normally be: ## Common Paths -- `/hive setup` installs or verifies the Hive CLI, enables the per-user daemon service, and optionally initializes the current repository. +- `/hive setup` installs or verifies the Hive CLI, enables the per-user daemon service, and optionally initializes the current repository. Prefer `hive setup` / `hive setup --service` for full local provision (diagnostics, qmd/web bundle, daemon, enrollment, optional managed web on `127.0.0.1:4567`). - `/hive status --json` shows the task board and next actions. - `/hive new . "build this feature"` creates a new Hive task in the current project. - `/hive plan `, `/hive develop `, and `/hive review ` advance a task through the main coding workflow. -- `/hive web` starts the Hivebox browser surface when a user wants the local web UI. +- `/hive web` starts the local Rails UI in the foreground; `hive web install|start|stop|status` manage the separate hive-web service. Hivebox Docker remains an alternative install path. - `/hive wiki compile-log --check` verifies that `wiki/log.md` matches the fragments in `wiki/log.d/`. - `/hive doctor` checks local runtime and skill configuration. @@ -63,7 +63,7 @@ else fi ``` -If `hive_cmd` is empty, start guided setup instead of failing. Restate that setup will install the Hive CLI, verify it, install or enable the per-user daemon service, and optionally run `hive init` for the current project. Get explicit user confirmation before running installers. +If `hive_cmd` is empty, start guided setup instead of failing. Restate that setup will install the Hive CLI, verify it, run the unified `hive setup` provisioner, install or enable the per-user daemon service, and optionally initialize or enroll the current project. Get explicit user confirmation before running installers. ## Guided Setup @@ -81,7 +81,15 @@ curl -fsSL https://raw.githubusercontent.com/ivankuznetsov/hive/v0.2.0/install.s bash "$tmpdir/hive-install.sh" ``` -After install, run the strict `hive` / `hv` version check again. If neither command prints a bare `X.Y.Z` version, stop and report that setup failed or Apache Hive may be shadowing the command. If verification succeeds, run `"${hive_cmd}" daemon install` once. Then ask whether to initialize the current project; if yes, run `"${hive_cmd}" init . --json "linux", + "unit_path" => target_path, + "service_installed" => File.file?(target_path), + "service_enabled" => !@pid.nil?, + "service_active" => process_alive? + } + end + + def restart! + stop! + start_process! + :ok + rescue SystemCallError => e + @messages << "#{@name} restart failed: #{e.message}" + :failed + end + + def stop! + return unless @pid + + Process.kill("TERM", -@pid) + Process.wait(@pid) + rescue Errno::ESRCH, Errno::ECHILD + nil + ensure + @pid = nil + end + + private + + def start_process! + FileUtils.mkdir_p(File.dirname(@log_path)) + log = File.open(@log_path, "w") + @pid = Process.spawn(@binary_path, *@args, pgroup: true, out: log, err: log) + log.close + end + + def process_alive? + return false unless @pid + + Process.kill(0, @pid) + true + rescue Errno::ESRCH + false + end + end + + # Setup calls `ensure_installed!` without a source because production uses a + # release download. The acceptance uses the checked-out release tree but the + # real AppBundle staging, Bundler install, version stamp, and atomic swap. + class LocalReleaseBundle < Hive::Web::AppBundle + def initialize(source:, **kwargs) + @local_source = source + super(**kwargs) + end + + def ensure_installed!(source: nil) + super(source: source || @local_source) + end + end + + def healthy_results + %w[ruby git tmux gh claude codex node npm qmd web_bundle sqlite].map do |name| + Hive::Setup::Diagnostics::Result.new( + name: name, status: "ok", detail: "#{name} ok", + fix: nil, bootstrappable: false + ) + end + end + + def test_setup_service_installs_both_units_with_one_binary + with_xdg_home do |home| + project = File.join(home, "proj") + FileUtils.mkdir_p(project) + # Pretend already initialized so enroll path is taken. + FileUtils.mkdir_p(File.join(project, ".hive-state")) + + hive_bin = File.join(home, "bin", "hive") + FileUtils.mkdir_p(File.dirname(hive_bin)) + File.write(hive_bin, "#!/bin/sh\n") + FileUtils.chmod(0o755, hive_bin) + + daemon_cmds = [] + web_cmds = [] + daemon = Hive::Commands::Daemon::ServiceInstaller.new( + host_os: "linux", + home: home, + binary_path: hive_bin, + systemctl_available: true, + runner: ->(argv) { daemon_cmds << argv; true } + ) + web = Hive::Commands::Web::ServiceInstaller.new( + host_os: "linux", + home: home, + binary_path: hive_bin, + systemctl_available: true, + runner: ->(argv) { web_cmds << argv; true } + ) + + report = Hive::Setup::Diagnostics::Report.new( + results: healthy_results, platform: "linux", ok: true + ) + probe = Object.new + probe.define_singleton_method(:call) { report } + + out = StringIO.new + cmd = Hive::Commands::Setup.new( + project_path: project, + service: true, + json: true, + binary_path: hive_bin, + diagnostics: probe, + daemon_installer: daemon, + web_installer: web, + init_runner: ->(*) { flunk "should enroll, not init" }, + enroll_runner: ->(path) { + Hive::Config.register_project(name: "proj", path: path) + "enrolled" + }, + qmd_installer: -> { "qmd ok" }, + app_bundle: Hive::Web::AppBundle.new( + data_home: File.join(home, "data", "hive"), + env: {}, + source_app_dir: File.join(home, "nope"), + downloader: ->(*) { }, + bundler: ->(*) { } + ).tap { |b| + FileUtils.mkdir_p(File.join(b.managed_app_dir, "config")) + File.write(File.join(b.managed_app_dir, "config", "application.rb"), "#\n") + File.write(b.version_stamp_path, "#{Hive::VERSION}\n") + }, + output: out, + err: StringIO.new + ) + + assert cmd.call + payload = JSON.parse(out.string) + assert payload["ok"], payload.inspect + assert_equal hive_bin, payload["binary_path"] + assert_equal "http://127.0.0.1:4567", payload["web_url"] + + daemon_unit = File.read(daemon.target_path) + web_unit = File.read(web.target_path) + assert_includes daemon_unit, "ExecStart=#{hive_bin} daemon start" + assert_includes web_unit, "ExecStart=#{hive_bin} web" + refute_equal daemon.target_path, web.target_path + + # Drift: rewrite daemon unit to wrong binary, status reports path drift. + wrong = File.join(home, "wrong-hive") + File.write(wrong, "#!/bin/sh\n") + FileUtils.chmod(0o755, wrong) + File.write( + daemon.target_path, + daemon_unit.gsub("ExecStart=#{hive_bin} daemon start", "ExecStart=#{wrong} daemon start") + ) + assert_equal wrong, daemon.installed_binary_path + assert_equal hive_bin, daemon.resolved_binary_for_status + status = Hive::Daemon::StatusReport.new( + hive_home: Hive::Paths.state_home, + binary_path: hive_bin, + installer: daemon, + version_probe: ->(*) { Hive::VERSION } + ).to_h + assert_equal "path", status["binary_drift"], status.inspect + + # Repair enqueue uses exact allowlist. + request_id = Hive::Web::Dispatcher.new.queue_daemon_repair! + files = Dir[File.join(Hive::Paths.state_home, "dispatch_requests", "*.json")] + body = files.map { |f| File.read(f) }.find { |t| t.include?(request_id) } + parsed = JSON.parse(body) + assert_equal %w[hive daemon install --force], parsed["argv"] + assert_equal "__hive_host__", parsed["project"] + end + end + + def test_foreground_and_managed_share_same_state_home + with_xdg_home do |_home| + state = Hive::Paths.state_home + assert_equal File.join(state, "web-storage"), + File.join(Hive::Paths.state_home, "web-storage") + # Config defaults bind loopback for local mode. + cfg = Hive::Config.load_global_web + assert_equal "127.0.0.1", cfg["bind"] + assert_equal true, cfg["local_loopback"] + end + end + + def test_live_fresh_setup_and_bidirectional_tui_web_dispatch + skip "set #{LIVE_E2E_ENV}=1 to run the live Rails/daemon acceptance" unless ENV[LIVE_E2E_ENV] == "1" + + with_xdg_home do |sandbox| + with_tmp_git_repo do |project_root| + prepare_fast_daemon_config! + prepare_local_release_gem! + hive_bin = write_current_binary_wrapper!(sandbox) + app_bundle = LocalReleaseBundle.new( + source: File.join(REPO_ROOT, "web"), + data_home: Hive::Paths.data_home, + source_app_dir: File.join(sandbox, "missing-source-checkout") + ) + daemon = LiveProcessInstaller.new( + name: "daemon", binary_path: hive_bin, + args: %w[daemon start --foreground], + log_path: File.join(sandbox, "daemon-service.log") + ) + web = LiveProcessInstaller.new( + name: "web", binary_path: hive_bin, args: %w[web], + log_path: File.join(sandbox, "web-service.log") + ) + + with_env("PATH" => [ File.dirname(hive_bin), FAKE_AGENT_BIN, ENV.fetch("PATH", "") ].join(File::PATH_SEPARATOR)) do + report = live_diagnostics_report + probe = Object.new + probe.define_singleton_method(:call) { report } + output = StringIO.new + + begin + assert Hive::Commands::Setup.new( + project_path: project_root, + service: true, + json: true, + binary_path: hive_bin, + diagnostics: probe, + app_bundle: app_bundle, + daemon_installer: daemon, + web_installer: web, + output: output, + err: StringIO.new + ).call + + payload = JSON.parse(output.string) + assert payload["ok"], payload.inspect + assert_equal hive_bin, payload["binary_path"] + assert File.directory?(File.join(project_root, ".hive-state")), + "setup must initialize a genuinely fresh repository" + assert_equal project_root, Hive::Config.find_project(File.basename(project_root)).fetch("path") + assert daemon.status_snapshot["service_active"], "daemon foreground service must be alive" + assert web.status_snapshot["service_active"], "Rails foreground service must be alive" + wait_for_http!("http://127.0.0.1:4567/health?deep=1") + + force_headless_agent!(project_root) + assert_bidirectional_parity!(project_root) + ensure + web.stop! + daemon.stop! + end + end + end + end + end + + private + + def live_diagnostics_report + results = healthy_results.map do |result| + next result unless result.name == "web_bundle" + + Hive::Setup::Diagnostics::Result.new( + name: "web_bundle", status: "bootstrappable", + detail: "install local release bundle", fix: "hive setup", + bootstrappable: true + ) + end + Hive::Setup::Diagnostics::Report.new(results: results, platform: "linux", ok: true) + end + + def prepare_fast_daemon_config! + FileUtils.mkdir_p(Hive::Paths.config_home) + File.write( + Hive::Config.global_config_path, + { + "registered_projects" => [], + "daemon" => { + "poll_interval_sec" => 5, + "fast_poll_sec" => 1, + "edit_debounce_sec" => 1 + } + }.to_yaml + ) + end + + def prepare_local_release_gem! + destination = Hive::Paths.data_home + FileUtils.mkdir_p(destination) + %w[bin lib templates schemas examples].each do |entry| + source = File.join(REPO_ROOT, entry) + FileUtils.cp_r(source, File.join(destination, entry)) if File.exist?(source) + end + %w[hive.gemspec install.md CHANGELOG.md LICENSE README.md].each do |entry| + FileUtils.cp(File.join(REPO_ROOT, entry), File.join(destination, entry)) + end + end + + def write_current_binary_wrapper!(sandbox) + path = File.join(sandbox, "bin", "hive") + root_bundle_path = ENV.fetch( + "LOCAL_WEB_E2E_ROOT_BUNDLE_PATH", + File.join(REPO_ROOT, "vendor", "root-bundle") + ) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, <<~SH) + #!/usr/bin/env bash + exec env \ + BUNDLE_GEMFILE=#{Shellwords.escape(File.join(REPO_ROOT, 'Gemfile'))} \ + BUNDLE_PATH=#{Shellwords.escape(root_bundle_path)} \ + BUNDLE_DEPLOYMENT= BUNDLE_FROZEN= \ + bundle exec ruby -I#{Shellwords.escape(File.join(REPO_ROOT, 'lib'))} \ + #{Shellwords.escape(File.join(REPO_ROOT, 'bin', 'hive'))} "$@" + SH + FileUtils.chmod(0o755, path) + path + end + + def force_headless_agent!(project_root) + path = File.join(project_root, ".hive-state", "config.yml") + config = YAML.safe_load_file(path) + config["claude"] = (config["claude"] || {}).merge("mode" => "headless") + config["execute"] = (config["execute"] || {}).merge("agent" => "claude") + File.write(path, config.to_yaml) + end + + def assert_bidirectional_parity!(project_root) + project = File.basename(project_root) + tui_title = "Live TUI parity idea" + snapshot = Hive::Tui::StateSource.new.refresh_now + bubble = Hive::Tui::BubbleModel.new( + hive_model: Hive::Tui::Model.initial.with( + mode: :new_idea, + snapshot: snapshot, + scope: 0, + new_idea_project_name: project, + new_idea_buffer: tui_title, + new_idea_cursor: tui_title.length + ) + ) + capture_io { bubble.update(Hive::Tui::Messages::NEW_IDEA_SUBMITTED) } + assert_match(/\A\+ /, bubble.hive_model.flash.to_s) + tui_slug = wait_for_task_slug!(project_root, tui_title) + assert_includes wait_for_http!("http://127.0.0.1:4567/"), tui_title + wait_for_daemon_dispatch!(tui_slug) + + web_title = "Live web parity idea" + create_web_idea!(project: project, title: web_title) + web_slug = wait_for_task_slug!(project_root, web_title) + web_snapshot = Hive::Tui::StateSource.new.refresh_now + assert web_snapshot.rows.any? { |row| row.slug == web_slug && row.display_name == web_title }, + "web-created task must be visible through the TUI state source" + wait_for_daemon_dispatch!(web_slug) + end + + def create_web_idea!(project:, title:) + uri = URI("http://127.0.0.1:4567/") + response = Net::HTTP.get_response(uri) + assert response.is_a?(Net::HTTPSuccess), response.inspect + token = response.body[/ CGI.unescapeHTML(token), + "project" => project, + "text" => title + ) + result = Net::HTTP.start(uri.host, uri.port) { |http| http.request(request) } + assert result.is_a?(Net::HTTPRedirection), result.inspect + end + + def wait_for_http!(url, timeout: 45) + uri = URI(url) + wait_until!(timeout: timeout, failure: "#{url} did not become healthy") do + response = Net::HTTP.get_response(uri) + response.body if response.is_a?(Net::HTTPSuccess) + rescue IOError, SystemCallError, SocketError, Timeout::Error + nil + end + end + + def wait_for_task_slug!(project_root, title, timeout: 20) + wait_until!(timeout: timeout, failure: "task #{title.inspect} never appeared") do + folder = Dir[File.join(project_root, ".hive-state", "stages", "*", "*")].find do |candidate| + idea = File.join(candidate, "idea.md") + File.file?(idea) && File.read(idea).include?(title) + end + File.basename(folder) if folder + end + end + + def wait_for_daemon_dispatch!(slug, timeout: 30) + log_path = File.join(Hive::Paths.state_home, "logs", "daemon.log") + wait_until!(timeout: timeout, failure: "daemon never auto-dispatched #{slug}") do + next unless File.file?(log_path) + + File.foreach(log_path).any? do |line| + line.include?(slug) && line.include?('"event":"dispatched"') + end + end + end + + def wait_until!(timeout:, failure:) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + loop do + result = yield + return result if result + raise failure if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline + + sleep 0.1 + end + end +end diff --git a/test/unit/bounded_subprocess_test.rb b/test/unit/bounded_subprocess_test.rb new file mode 100644 index 00000000..307e8129 --- /dev/null +++ b/test/unit/bounded_subprocess_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/bounded_subprocess" +require "rbconfig" + +class BoundedSubprocessTest < Minitest::Test + include HiveTestHelper + + def test_capture3_returns_output_and_status + out, err, status = Hive::BoundedSubprocess.capture3( + RbConfig.ruby, "-e", '$stdout.write("out"); $stderr.write("err")', timeout: 2 + ) + + assert_equal "out", out + assert_equal "err", err + assert status.success? + end + + def test_timeout_terminates_and_reaps_the_process_group + with_tmp_dir do |dir| + child_pid_path = File.join(dir, "child.pid") + script = <<~RUBY + child = spawn(#{RbConfig.ruby.inspect}, "-e", "sleep 30") + File.write(#{child_pid_path.inspect}, child) + sleep 30 + RUBY + + error = assert_raises(Hive::BoundedSubprocess::TimeoutError) do + Hive::BoundedSubprocess.capture3(RbConfig.ruby, "-e", script, timeout: 1.0) + end + + assert_operator error.elapsed, :>=, 1.0 + child_pid = Integer(File.read(child_pid_path)) + refute process_alive?(child_pid), "timeout must kill descendants in the spawned process group" + end + end + + private + + def process_alive?(pid) + Process.kill(0, pid) + true + rescue Errno::ESRCH + false + end +end diff --git a/test/unit/commands/daemon_test.rb b/test/unit/commands/daemon_test.rb index f87b788e..17984300 100644 --- a/test/unit/commands/daemon_test.rb +++ b/test/unit/commands/daemon_test.rb @@ -164,6 +164,36 @@ class HiveCommandsDaemonTest < Minitest::Test "answer_digest.hour must be forwarded into the scheduler" end + def test_start_daemon_wires_current_service_installer_binary_for_repairs + require "hive/commands/daemon/service_installer" + + command = daemon("start", dry_run: true) + dispatcher = FakeDispatcher.new([]) + resolved = nil + installer = Object.new + installer.define_singleton_method(:resolved_binary_for_status) { "/current/bin/hive" } + + with_replaced_singleton_method(Hive::Lock, :process_start_time, ->(pid) { "start-#{pid}" }) do + with_global_start_config(daemon_config) do + with_replaced_singleton_method( + Hive::Commands::Daemon::ServiceInstaller, + :new, + ->(**_kwargs) { installer } + ) do + with_replaced_singleton_method(Hive::Daemon::Dispatcher, :new, lambda { |**kwargs| + supervisor = kwargs.fetch(:supervisor) + resolved = supervisor.send(:resolved_maintenance_binary) + dispatcher + }) do + command.call + end + end + end + end + + assert_equal "/current/bin/hive", resolved + end + def test_start_daemon_invokes_reexec_when_dispatcher_signals_drift command = daemon("start", dry_run: true) dispatcher = FakeDispatcher.new([], true) # signals drift @@ -345,6 +375,9 @@ class HiveCommandsDaemonTest < Minitest::Test } fake = Struct.new(:state) do def service_state = state + def resolved_binary_for_status = "/opt/hive/bin/hive" + def installed_binary_path = "/opt/hive/bin/hive" + def binary_parse_error = nil end.new(state) require "hive/commands/daemon/service_installer" out, _err = with_replaced_singleton_method( @@ -359,6 +392,9 @@ class HiveCommandsDaemonTest < Minitest::Test # Existing fields must survive the merge. assert_equal true, doc.fetch("running") assert_equal 1234, doc.fetch("pid") + assert_equal "unreadable", doc.fetch("binary_drift"), + "a configured binary that cannot be executed must remain actionable" + assert_equal "/opt/hive/bin/hive", doc.fetch("expected_binary") require "json_schemer" schema = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path("hive-daemon-status")))) @@ -383,6 +419,7 @@ class HiveCommandsDaemonTest < Minitest::Test assert_nil doc.fetch("service_enabled") assert_nil doc.fetch("unit_path") assert_equal true, doc.fetch("running") + assert_equal "unreadable", doc.fetch("binary_drift") require "json_schemer" schema = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path("hive-daemon-status")))) diff --git a/test/unit/commands/setup/exit_code_test.rb b/test/unit/commands/setup/exit_code_test.rb new file mode 100644 index 00000000..c07593ff --- /dev/null +++ b/test/unit/commands/setup/exit_code_test.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/commands/setup" +require "hive/setup/diagnostics" +require "stringio" + +class SetupExitCodeTest < Minitest::Test + include HiveTestHelper + + def diag(ok:) + results = [ + Hive::Setup::Diagnostics::Result.new( + name: "ruby", status: "ok", detail: "ok", fix: nil, bootstrappable: false + ) + ] + unless ok + results << Hive::Setup::Diagnostics::Result.new( + name: "gh", status: "unauthenticated", detail: "need login", + fix: "gh auth login", bootstrappable: false + ) + end + # Pad to avoid empty hard-failure edge cases on other names + %w[git tmux claude codex node npm qmd web_bundle sqlite].each do |n| + results << Hive::Setup::Diagnostics::Result.new( + name: n, status: "ok", detail: "ok", fix: nil, bootstrappable: false + ) + end + Hive::Setup::Diagnostics::Report.new( + results: results, + platform: "linux", + ok: ok + ) + end + + def test_success_does_not_raise + out = StringIO.new + report = diag(ok: true) + probe = Object.new + probe.define_singleton_method(:call) { report } + cmd = Hive::Commands::Setup.new( + project_path: Dir.mktmpdir, + no_bootstrap: true, + json: true, + diagnostics: probe, + output: out, + err: StringIO.new + ) + assert cmd.call + assert JSON.parse(out.string)["ok"] + end + + def test_hard_diagnostic_failure_raises_after_json_emit + out = StringIO.new + report = diag(ok: false) + probe = Object.new + probe.define_singleton_method(:call) { report } + cmd = Hive::Commands::Setup.new( + project_path: Dir.mktmpdir, + no_bootstrap: true, + json: true, + diagnostics: probe, + output: out, + err: StringIO.new + ) + assert_raises(Hive::Error) { cmd.call } + payload = JSON.parse(out.string) + refute payload["ok"] + assert payload["diagnostics"]["results"].any? { |r| r["fix"] == "gh auth login" } + end +end diff --git a/test/unit/commands/setup/orchestrator_test.rb b/test/unit/commands/setup/orchestrator_test.rb new file mode 100644 index 00000000..a7157ba9 --- /dev/null +++ b/test/unit/commands/setup/orchestrator_test.rb @@ -0,0 +1,373 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/commands/setup" +require "hive/setup/diagnostics" +require "stringio" + +class SetupOrchestratorTest < Minitest::Test + include HiveTestHelper + + def healthy_diag + results = %w[ + ruby git tmux gh claude codex node npm qmd web_bundle sqlite + ].map do |name| + Hive::Setup::Diagnostics::Result.new( + name: name, status: "ok", detail: "#{name} ok", fix: nil, bootstrappable: false + ) + end + Hive::Setup::Diagnostics::Report.new(results: results, platform: "linux", ok: true) + end + + def bootstrappable_diag + results = healthy_diag.results.map do |r| + if r.name == "qmd" || r.name == "web_bundle" + Hive::Setup::Diagnostics::Result.new( + name: r.name, status: "bootstrappable", detail: "need install", + fix: "install", bootstrappable: true + ) + else + r + end + end + Hive::Setup::Diagnostics::Report.new(results: results, platform: "linux", ok: true) + end + + def unauth_diag + results = healthy_diag.results.map do |r| + if %w[gh claude codex].include?(r.name) + Hive::Setup::Diagnostics::Result.new( + name: r.name, status: "unauthenticated", detail: "need login", + fix: "#{r.name} login", bootstrappable: false + ) + else + r + end + end + Hive::Setup::Diagnostics::Report.new(results: results, platform: "linux", ok: false) + end + + FakeInstaller = Struct.new(:target, :kind, :messages, keyword_init: true) do + def install!(autostart:, force: false) + Hive::Commands::ServiceInstaller::Outcome.new(kind) + end + + def target_path = target + def messages = self[:messages] || [] + def status_snapshot = { "service_active" => false } + def restart! = :ok + end + + def build(dir, **opts) + out = StringIO.new + err = StringIO.new + diag = opts.delete(:diag) || healthy_diag + qmd_calls = [] + web_calls = [] + daemon_calls = [] + web_svc_calls = [] + init_calls = [] + enroll_calls = [] + + cmd = Hive::Commands::Setup.new( + project_path: opts.delete(:project_path) || dir, + service: opts.fetch(:service, false), + no_bootstrap: opts.fetch(:no_bootstrap, false), + no_init: opts.fetch(:no_init, false), + json: opts.fetch(:json, false), + binary_path: opts.delete(:binary_path) || "/opt/hive/bin/hive", + diagnostics: -> { diag }.then { |p| Object.new.tap { |o| o.define_singleton_method(:call) { diag } } }, + app_bundle: Object.new.tap { |o| + o.define_singleton_method(:ensure_installed!) { + web_calls << true + File.join(dir, "web-app") + } + }, + qmd_installer: -> { + qmd_calls << true + "qmd installed" + }, + daemon_installer: FakeInstaller.new(target: "/u/hive-daemon.service", kind: :written).tap { |i| + i.define_singleton_method(:install!) { |**| + daemon_calls << true + Hive::Commands::ServiceInstaller::Outcome.new(:written) + } + }, + web_installer: FakeInstaller.new(target: "/u/hive-web.service", kind: :written).tap { |i| + i.define_singleton_method(:install!) { |**| + web_svc_calls << true + Hive::Commands::ServiceInstaller::Outcome.new(:written) + } + }, + init_runner: ->(path) { init_calls << path; "initialized" }, + enroll_runner: ->(path) { enroll_calls << path; "enrolled" }, + output: out, + err: err + ) + { + cmd: cmd, out: out, err: err, + qmd_calls: qmd_calls, web_calls: web_calls, + daemon_calls: daemon_calls, web_svc_calls: web_svc_calls, + init_calls: init_calls, enroll_calls: enroll_calls + } + end + + def test_fresh_setup_service_bootstraps_and_reports_url + with_tmp_dir do |dir| + ctx = build(dir, service: true, diag: bootstrappable_diag, json: true) + capture_io { ctx[:cmd].call } + payload = JSON.parse(ctx[:out].string) + assert payload["ok"], payload.inspect + assert_equal true, payload["service"] + assert_equal "http://127.0.0.1:4567", payload["web_url"] + assert_equal [ true ], ctx[:qmd_calls] + assert_equal [ true ], ctx[:web_calls] + assert_equal [ true ], ctx[:daemon_calls] + assert_equal [ true ], ctx[:web_svc_calls] + assert_equal [ dir ], ctx[:init_calls] + phase_names = payload["phases"].map { |p| p["name"] } + assert_includes phase_names, "bootstrap_qmd" + assert_includes phase_names, "bootstrap_web_bundle" + assert_includes phase_names, "daemon_install" + assert_includes phase_names, "web_install" + end + end + + def test_rerun_on_initialized_repo_uses_enroll_path + with_tmp_dir do |dir| + FileUtils.mkdir_p(File.join(dir, ".hive-state")) + ctx = build(dir, service: false) + capture_io { ctx[:cmd].call } + assert_empty ctx[:init_calls] + assert_equal [ dir ], ctx[:enroll_calls] + refute ctx[:web_svc_calls].any? + end + end + + def test_no_bootstrap_is_diagnose_only + with_tmp_dir do |dir| + ctx = build(dir, no_bootstrap: true, service: true, diag: bootstrappable_diag, json: true) + capture_io { ctx[:cmd].call } + payload = JSON.parse(ctx[:out].string) + assert payload["ok"] + assert_empty ctx[:qmd_calls] + assert_empty ctx[:web_calls] + assert_empty ctx[:daemon_calls] + assert_empty ctx[:web_svc_calls] + assert_empty ctx[:init_calls] + assert_equal [ "diagnostics" ], payload["phases"].map { |p| p["name"] } + end + end + + def test_no_init_still_provisions_services + with_tmp_dir do |dir| + ctx = build(dir, no_init: true, service: true) + capture_io { ctx[:cmd].call } + assert_equal [ true ], ctx[:daemon_calls] + assert_equal [ true ], ctx[:web_svc_calls] + assert_empty ctx[:init_calls] + assert_empty ctx[:enroll_calls] + end + end + + def test_missing_auth_blocks_success_and_emits_fix_commands + with_tmp_dir do |dir| + ctx = build(dir, diag: unauth_diag, json: true, service: true) + error = assert_raises(Hive::Error) { ctx[:cmd].call } + assert_match(/diagnostics failed/, error.message) + payload = JSON.parse(ctx[:out].string) + refute payload["ok"] + fixes = payload["diagnostics"]["results"].select { |r| r["status"] == "unauthenticated" }.map { |r| r["fix"] } + assert_includes fixes, "gh login" + assert_includes fixes, "claude login" + assert_includes fixes, "codex login" + # Still ran provisioning phases for visibility (diag failed but bootstrap continues) + assert ctx[:daemon_calls].any? || payload["phases"].any? + end + end + + def test_phase_failure_keeps_full_json_and_nonzero + with_tmp_dir do |dir| + ctx = build(dir, service: true, json: true) + ctx[:cmd].instance_variable_set(:@daemon_installer, Object.new.tap { |o| + o.define_singleton_method(:install!) { |**| raise "systemctl down" } + o.define_singleton_method(:target_path) { "/u" } + o.define_singleton_method(:messages) { [] } + }) + error = assert_raises(Hive::Error) { ctx[:cmd].call } + assert_match(/daemon_install|systemctl/, error.message) + payload = JSON.parse(ctx[:out].string) + refute payload["ok"] + daemon_phase = payload["phases"].find { |p| p["name"] == "daemon_install" } + refute daemon_phase["ok"] + # Later phases still recorded when possible + assert payload["phases"].any? { |p| p["name"] == "web_install" } || + payload["phases"].any? { |p| p["name"] == "init" } + end + end + + def test_init_system_exit_is_recorded_and_later_service_phase_still_runs + with_tmp_dir do |dir| + ctx = build(dir, service: true, json: true) + ctx[:cmd].instance_variable_set(:@init_runner, ->(_path) { raise SystemExit.new(64) }) + + error = assert_raises(Hive::Error) { ctx[:cmd].call } + assert_match(/init.*status 64/, error.message) + + payload = JSON.parse(ctx[:out].string) + init_phase = payload["phases"].find { |phase| phase["name"] == "init" } + refute init_phase["ok"] + assert_match(/status 64/, init_phase["error"]) + assert_equal [ true ], ctx[:web_svc_calls], "SystemExit must not skip later setup phases" + end + end + + def test_default_init_uses_non_emitting_programmatic_mode + with_tmp_dir do |dir| + require "hive/commands/init" + options = nil + fake = Object.new.tap { |object| object.define_singleton_method(:call) { true } } + command = Hive::Commands::Setup.new(project_path: dir) + + replacement = lambda do |_path, **kwargs| + options = kwargs + fake + end + with_replaced_singleton_method(Hive::Commands::Init, :new, replacement) do + assert_equal "initialized #{dir}", command.send(:default_init, dir) + end + + assert_equal false, options.fetch(:emit) + end + end + + def test_default_enroll_uses_non_emitting_programmatic_mode + with_tmp_dir do |dir| + require "hive/commands/daemon" + options = nil + fake = Object.new.tap { |object| object.define_singleton_method(:call) { true } } + command = Hive::Commands::Setup.new(project_path: dir) + registered = [ { "name" => File.basename(dir), "path" => dir } ] + + replacement = lambda do |_subcommand, _target, **kwargs| + options = kwargs + fake + end + with_replaced_singleton_method(Hive::Config, :registered_projects, -> { registered }) do + with_replaced_singleton_method(Hive::Commands::Daemon, :new, replacement) do + assert_equal "enrolled #{File.basename(dir)}", command.send(:default_enroll, dir) + end + end + + assert_equal false, options.fetch(:emit) + end + end + + def test_refreshed_bundle_restarts_active_unchanged_service_and_checks_health + with_tmp_dir do |dir| + ctx = build(dir, service: true, diag: bootstrappable_diag, json: true) + restarts = [] + probes = [] + installer = FakeInstaller.new(target: "/u/hive-web.service", kind: :unchanged) + installer.define_singleton_method(:status_snapshot) { { "service_active" => true } } + installer.define_singleton_method(:restart!) { restarts << true; :ok } + ctx[:cmd].instance_variable_set(:@web_installer, installer) + ctx[:cmd].instance_variable_set( + :@web_health_probe, + ->(url, timeout:) { probes << [ url, timeout ]; true } + ) + + ctx[:cmd].call + payload = JSON.parse(ctx[:out].string) + web_phase = payload["phases"].find { |phase| phase["name"] == "web_install" } + + assert web_phase["ok"], web_phase.inspect + assert_equal [ true ], restarts + assert_equal 1, probes.length + assert_equal "http://127.0.0.1:4567/health", probes.first.first + assert_operator probes.first.last, :>, 0 + assert_match(/restarted=true/, web_phase["detail"]) + end + end + + def test_refreshed_active_service_health_failure_fails_web_phase + with_tmp_dir do |dir| + ctx = build(dir, service: true, diag: bootstrappable_diag, json: true) + installer = FakeInstaller.new(target: "/u/hive-web.service", kind: :unchanged) + installer.define_singleton_method(:status_snapshot) { { "service_active" => true } } + installer.define_singleton_method(:restart!) { :ok } + ctx[:cmd].instance_variable_set(:@web_installer, installer) + ctx[:cmd].instance_variable_set(:@web_health_probe, ->(_url, timeout:) { false }) + + error = assert_raises(Hive::Error) { ctx[:cmd].call } + assert_match(/health check failed/, error.message) + payload = JSON.parse(ctx[:out].string) + web_phase = payload["phases"].find { |phase| phase["name"] == "web_install" } + refute web_phase["ok"] + assert_match(%r{127\.0\.0\.1:4567/health}, web_phase["error"]) + end + end + + def test_injected_health_probe_is_bounded_by_setup_deadline + command = Hive::Commands::Setup.new( + web_health_probe: ->(_url, timeout:) { sleep 1; true }, + web_health_timeout: 0.01 + ) + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + refute command.send(:web_service_healthy?) + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started + assert_operator elapsed, :<, 0.5 + end + + def test_bare_setup_prints_foreground_hint + with_tmp_dir do |dir| + ctx = build(dir, service: false) + capture_io { ctx[:cmd].call } + assert_match(/hive web/, ctx[:out].string) + refute_match(/web UI at http/, ctx[:out].string) + end + end + + def test_service_setup_prints_url + with_tmp_dir do |dir| + ctx = build(dir, service: true) + capture_io { ctx[:cmd].call } + assert_match(%r{http://127\.0\.0\.1:4567}, ctx[:out].string) + end + end + + def test_shared_binary_path_passed_through + with_tmp_dir do |dir| + seen = [] + installer = Object.new + installer.define_singleton_method(:install!) { |**| seen << :d; Hive::Commands::ServiceInstaller::Outcome.new(:unchanged) } + installer.define_singleton_method(:target_path) { "/d" } + installer.define_singleton_method(:messages) { [] } + web = Object.new + web.define_singleton_method(:install!) { |**| seen << :w; Hive::Commands::ServiceInstaller::Outcome.new(:unchanged) } + web.define_singleton_method(:target_path) { "/w" } + web.define_singleton_method(:messages) { [] } + + report = healthy_diag + probe = Object.new + probe.define_singleton_method(:call) { report } + cmd = Hive::Commands::Setup.new( + project_path: dir, + service: true, + binary_path: "/exact/hive", + diagnostics: probe, + daemon_installer: installer, + web_installer: web, + init_runner: ->(*) { "ok" }, + qmd_installer: -> { "ok" }, + app_bundle: Object.new.tap { |o| o.define_singleton_method(:ensure_installed!) { "app" } }, + output: StringIO.new, + err: StringIO.new + ) + capture_io { cmd.call } + assert_equal %i[d w], seen + assert_equal "/exact/hive", cmd.instance_variable_get(:@binary_path) + 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 00000000..c9782ae8 --- /dev/null +++ b/test/unit/commands/web/service_installer_test.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/commands/web/service_installer" + +class WebServiceInstallerTest < Minitest::Test + include HiveTestHelper + + def test_linux_template_contains_resolved_binary_and_web_subcommand + with_tmp_dir do |dir| + hive = File.join(dir, "bin", "hive") + FileUtils.mkdir_p(File.dirname(hive)) + File.write(hive, "#!/bin/sh\n") + FileUtils.chmod(0o755, hive) + + installer = Hive::Commands::Web::ServiceInstaller.new( + host_os: "linux-gnu", + home: dir, + binary_path: hive, + systemctl_available: true, + runner: ->(_argv) { true } + ) + installer.install!(autostart: false) + unit = File.read(File.join(dir, ".config/systemd/user/hive-web.service")) + assert_includes unit, "ExecStart=#{hive} web" + assert_includes unit, "Restart=on-failure" + assert_includes unit, "StartLimitBurst=3" + refute_includes unit, "daemon start" + end + end + + def test_macos_template_contains_resolved_binary_and_web_subcommand + 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.read(File.join(dir, "Library/LaunchAgents/local.hive-web.plist")) + assert_includes plist, "/opt/hive/bin/hive" + assert_includes plist, "web" + assert_includes plist, "SuccessfulExit" + assert_includes plist, "[ -x \"$0\" ] || exit 0" + # Exec argv must be `hive web`, not a daemon subcommand. + refute_match(%r{daemon}, plist) + end + end + + def test_install_writes_once_and_noops_when_identical + 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; true } + ) + first = installer.install!(autostart: true) + assert_equal :written, first.kind + second = installer.install!(autostart: true) + assert_equal :unchanged, second.kind + end + end + + def test_install_refuses_drift_without_force_and_backs_up_with_force + with_tmp_dir do |dir| + path = File.join(dir, ".config/systemd/user/hive-web.service") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "custom unit\n") + + installer = Hive::Commands::Web::ServiceInstaller.new( + host_os: "linux", + home: dir, + binary_path: "/tmp/hive", + systemctl_available: true, + runner: ->(_argv) { true } + ) + drifted = installer.install!(autostart: false) + assert_equal :drifted, drifted.kind + assert_equal "custom unit\n", File.read(path) + + upgraded = installer.install!(autostart: false, force: true) + assert_equal :upgraded, upgraded.kind + assert upgraded.backup_path + assert File.exist?(upgraded.backup_path) + assert_includes File.read(path), "ExecStart=" + end + end + + def test_start_stop_status_select_correct_argv + 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; true } + ) + installer.install!(autostart: false) + commands.clear + + assert_equal :ok, installer.start! + assert_includes commands, %w[systemctl --user start hive-web] + + commands.clear + assert_equal :ok, installer.stop! + assert_includes commands, %w[systemctl --user stop hive-web] + + commands.clear + snap = installer.status_snapshot + assert_includes commands, %w[systemctl --user is-active hive-web] + assert snap["service_installed"] + end + end + + def test_linux_restart_uses_service_manager + 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; true } + ) + + assert_equal :ok, installer.restart! + assert_equal [ + %w[systemctl --user daemon-reload], + %w[systemctl --user restart hive-web] + ], commands + end + end + + def test_macos_start_stop_use_launchctl + with_tmp_dir do |dir| + commands = [] + installer = Hive::Commands::Web::ServiceInstaller.new( + host_os: "darwin23", + home: dir, + binary_path: "/opt/hive/bin/hive", + runner: ->(argv) { commands << argv; true } + ) + installer.install!(autostart: false) + commands.clear + path = installer.target_path + + assert_equal :ok, installer.start! + assert_equal [ [ "launchctl", "load", path ] ], commands + + commands.clear + assert_equal :ok, installer.stop! + assert_equal [ [ "launchctl", "unload", path ] ], commands + end + end + + def test_macos_restart_unloads_then_loads_unit + with_tmp_dir do |dir| + commands = [] + installer = Hive::Commands::Web::ServiceInstaller.new( + host_os: "darwin23", + home: dir, + binary_path: "/opt/hive/bin/hive", + runner: ->(argv) { commands << argv; true } + ) + installer.install!(autostart: false) + commands.clear + path = installer.target_path + + assert_equal :ok, installer.restart! + assert_equal [ [ "launchctl", "unload", path ], [ "launchctl", "load", path ] ], commands + end + end + + def test_linux_without_systemd_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, + runner: ->(_argv) { true } + ) + result = installer.install!(autostart: true) + assert_equal :autostart_unavailable, result.kind + assert File.exist?(installer.target_path) + assert_equal :autostart_unavailable, installer.start! + end + end + + def test_unsupported_os_reports_clearly + with_tmp_dir do |dir| + installer = Hive::Commands::Web::ServiceInstaller.new( + host_os: "mingw32", + home: dir, + binary_path: "/tmp/hive", + runner: ->(_argv) { true } + ) + result = installer.install!(autostart: true) + assert_equal :unsupported, result.kind + assert_equal :unsupported, installer.start! + end + end +end diff --git a/test/unit/commands/web_bind_policy_test.rb b/test/unit/commands/web_bind_policy_test.rb new file mode 100644 index 00000000..7a822e40 --- /dev/null +++ b/test/unit/commands/web_bind_policy_test.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/commands/web" +require "hive/web/loopback" + +class WebBindPolicyTest < Minitest::Test + include HiveTestHelper + + class ExecCaught < StandardError + attr_reader :env, :argv + + def initialize(env, argv) + @env = env + @argv = argv + super("exec") + end + end + + def test_loopback_classifier + %w[127.0.0.1 127.1.2.3 127.255.255.254 ::1 localhost 127.0.0.1:4567 [::1]].each do |addr| + assert Hive::Web::Loopback.address?(addr), "#{addr} should be loopback" + end + %w[0.0.0.0 192.168.1.1 10.0.0.1 8.8.8.8 example.com 126.0.0.1 128.0.0.1].each do |addr| + refute Hive::Web::Loopback.address?(addr), "#{addr} should not be loopback" + end + end + + def test_loopback_bind_allowed_without_owner + with_tmp_global_config do + cfg = Hive::Config.load_global_web + cmd = Hive::Commands::Web.new + capture_io { cmd.apply_bind_policy!("127.0.0.1", cfg) } + capture_io { cmd.apply_bind_policy!("::1", cfg) } + capture_io { cmd.apply_bind_policy!("localhost", cfg) } + end + end + + def test_non_loopback_without_owner_or_unsafe_refused + with_tmp_global_config do + cfg = Hive::Config.load_global_web + cmd = Hive::Commands::Web.new + error = assert_raises(Hive::Error) do + capture_io { cmd.apply_bind_policy!("0.0.0.0", cfg) } + end + assert_match(/refusing non-loopback bind/, error.message) + assert_match(/--unsafe/, error.message) + end + end + + def test_non_loopback_with_owner_warns_and_allows + with_tmp_global_config do |home| + File.write(File.join(home, "config.yml"), { + "web" => { "github" => { "owner" => "alice" } } + }.to_yaml) + cfg = Hive::Config.load_global_web + cmd = Hive::Commands::Web.new + _out, err = capture_io { cmd.apply_bind_policy!("0.0.0.0", cfg) } + assert_match(/WARNING binding 0.0.0.0/, err) + assert_match(/alice/, err) + end + end + + def test_non_loopback_with_unsafe_warns_and_allows + with_tmp_global_config do + cfg = Hive::Config.load_global_web + cmd = Hive::Commands::Web.new(unsafe: true) + _out, err = capture_io { cmd.apply_bind_policy!("0.0.0.0", cfg) } + assert_match(/WARNING binding 0.0.0.0/, err) + assert_match(/--unsafe/, err) + end + end + + def test_foreground_sets_loopback_mode_env_only_for_loopback_bind + with_tmp_global_config do + with_stub_rails_app(prepare_exit: 0) do + original = Kernel.method(:exec) + caught = nil + Kernel.define_singleton_method(:exec) do |env, *argv| + raise ExecCaught.new(env, argv) + end + begin + capture_io { Hive::Commands::Web.new(bind: "127.0.0.1").call } + rescue ExecCaught => e + caught = e + ensure + Kernel.define_singleton_method(:exec, original) + end + refute_nil caught + assert_equal "1", caught.env[Hive::Web::Loopback::ENV_MODE] + end + end + end + + def test_docker_style_public_bind_does_not_set_loopback_mode + with_tmp_global_config do |home| + File.write(File.join(home, "config.yml"), { + "web" => { "github" => { "owner" => "hivebox-owner" } } + }.to_yaml) + with_stub_rails_app(prepare_exit: 0) do + original = Kernel.method(:exec) + caught = nil + Kernel.define_singleton_method(:exec) do |env, *argv| + raise ExecCaught.new(env, argv) + end + begin + capture_io { Hive::Commands::Web.new(bind: "0.0.0.0").call } + rescue ExecCaught => e + caught = e + ensure + Kernel.define_singleton_method(:exec, original) + end + refute_nil caught + refute caught.env.key?(Hive::Web::Loopback::ENV_MODE) + end + end + end + + def test_non_loopback_refusal_happens_before_app_resolve + with_tmp_global_config do + cmd = Hive::Commands::Web.new(bind: "0.0.0.0") + resolved = false + cmd.define_singleton_method(:rails_app_dir) do + resolved = true + "/tmp/should-not-run" + end + assert_raises(Hive::Error) { capture_io { cmd.call } } + refute resolved, "bind policy must fail before rails_app_dir / db work" + end + end + + # Minimal rails stub shared with web_command_test patterns. + def with_stub_rails_app(prepare_exit:) + Dir.mktmpdir("hive-webapp") do |dir| + FileUtils.mkdir_p(File.join(dir, "config")) + File.write(File.join(dir, "config", "application.rb"), "# rails app marker") + FileUtils.mkdir_p(File.join(dir, "bin")) + File.write(File.join(dir, "bin", "rails"), <<~SH) + #!/usr/bin/env bash + [ "$1" = "db:prepare" ] && exit #{prepare_exit} + exit 0 + SH + FileUtils.chmod(0o755, File.join(dir, "bin", "rails")) + with_env("HIVEBOX_WEB_APP_DIR" => dir) { yield dir } + end + end +end diff --git a/test/unit/daemon/child_supervisor_test.rb b/test/unit/daemon/child_supervisor_test.rb index 1c68f6d9..a92e4422 100644 --- a/test/unit/daemon/child_supervisor_test.rb +++ b/test/unit/daemon/child_supervisor_test.rb @@ -79,6 +79,37 @@ class HiveDaemonChildSupervisorTest < Minitest::Test end end + def test_maintenance_command_uses_current_binary_resolver_not_stale_daemon_binary + with_tmp_dir do |dir| + stale_marker = File.join(dir, "stale-ran") + current_marker = File.join(dir, "current-ran") + stale = File.join(dir, "stale", "hive") + current = File.join(dir, "current", "hive") + [ [ stale, stale_marker ], [ current, current_marker ] ].each do |path, marker| + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "#!/bin/sh\ntouch #{Shellwords.escape(marker)}\n") + FileUtils.chmod(0o755, path) + end + + sup = Hive::Daemon::ChildSupervisor.new( + hive_bin: stale, + maintenance_binary_resolver: -> { current }, + log_dir_for_task: ->(project, slug) { File.join(dir, project, slug, "child.log") } + ) + sup.spawn( + command_string: "hive daemon install --force", + project: Hive::Daemon::DispatchRequestQueue::GLOBAL_PROJECT, + slug: Hive::Daemon::DispatchRequestQueue::GLOBAL_SLUG, + stage: nil + ) + completed = wait_for_completion(sup, max_attempts: 50) + + assert_equal 1, completed.size + assert File.exist?(current_marker), "repair must execute through the current install binary" + refute File.exist?(stale_marker), "the daemon's stale HIVE_BIN must not repair itself" + end + end + def test_spawn_tmpdir_log_fallback_loads_its_own_dependency script = <<~RUBY require "hive/daemon/child_supervisor" diff --git a/test/unit/daemon/dispatch_request_queue_test.rb b/test/unit/daemon/dispatch_request_queue_test.rb index a3c6f5e7..8b356109 100644 --- a/test/unit/daemon/dispatch_request_queue_test.rb +++ b/test/unit/daemon/dispatch_request_queue_test.rb @@ -327,6 +327,32 @@ class HiveDaemonDispatchRequestQueueTest < Minitest::Test refute Q.valid_argv?([ "hive", :run, "slug" ]) end + def test_maintenance_argv_requires_exact_reserved_context + argv = Q::MAINTENANCE_ARGV + + assert Q.valid_argv?( + argv, + project: Q::GLOBAL_PROJECT, + slug: Q::GLOBAL_SLUG + ) + refute Q.valid_argv?(argv), "context-free validation must reject host maintenance" + refute Q.valid_argv?(argv, project: "real-project", slug: "real-task") + refute Q.valid_argv?(argv, project: Q::GLOBAL_PROJECT, slug: "real-task") + end + + def test_write_request_rejects_maintenance_argv_for_real_project + Dir.mktmpdir("hive-dispatch-queue") do |dir| + assert_raises(ArgumentError) do + Q.write_request!( + project: "real-project", + slug: "real-task", + argv: Q::MAINTENANCE_ARGV, + state_home: dir + ) + end + end + end + def test_filename_for_is_chronologically_sortable a = Q.filename_for(created_at: Time.utc(2026, 5, 28, 18, 11, 44), request_id: "A") b = Q.filename_for(created_at: Time.utc(2026, 5, 28, 18, 13, 9), request_id: "B") diff --git a/test/unit/daemon/dispatcher_test.rb b/test/unit/daemon/dispatcher_test.rb index 50667c8b..d735bad4 100644 --- a/test/unit/daemon/dispatcher_test.rb +++ b/test/unit/daemon/dispatcher_test.rb @@ -3158,6 +3158,30 @@ end end end + def test_dispatch_request_rejects_maintenance_argv_for_real_project + Dir.mktmpdir("hive-dispatch-queue") do |state_home| + dispatcher, sup, _ctrl, logger, _mw = make_dispatcher( + rows: [], dispatch_request_state_home: state_home + ) + write_request_file( + state_home, + slug: "s1", + request_id: "BADMAINT", + project: "p1", + argv: Q::MAINTENANCE_ARGV + ) + + dispatcher.tick(now: T0) + + assert_empty sup.spawned + rejected = logger.events.find do |(name, attrs)| + name == :dispatch_request_rejected && attrs[:request_id] == "BADMAINT" + end + refute_nil rejected + assert_equal "invalid_argv", rejected[1][:reason] + end + end + def test_dispatch_request_rejected_when_project_unknown Dir.mktmpdir("hive-dispatch-queue") do |state_home| dispatcher, sup, _ctrl, logger, _mw = make_dispatcher( diff --git a/test/unit/daemon/status_report_test.rb b/test/unit/daemon/status_report_test.rb new file mode 100644 index 00000000..103831a8 --- /dev/null +++ b/test/unit/daemon/status_report_test.rb @@ -0,0 +1,366 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/daemon/status_report" +require "hive/commands/daemon/service_installer" + +class DaemonStatusReportTest < Minitest::Test + include HiveTestHelper + + FakeInstaller = Struct.new( + :service_state_hash, :expected, :installed, :parse_error, + keyword_init: true + ) do + def service_state = service_state_hash + def resolved_binary_for_status = expected + def installed_binary_path = installed + def binary_parse_error = parse_error + end + + def report_for(dir, installer:, version_probe: ->(_b) { Hive::VERSION }) + Hive::Daemon::StatusReport.new( + hive_home: dir, + installer: installer, + version_probe: version_probe, + update_state: nil + ) + end + + def make_bin(dir, name = "hive") + path = File.join(dir, "bin", name) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "#!/bin/sh\n") + FileUtils.chmod(0o755, path) + path + end + + def test_matching_path_and_version_reports_none + with_tmp_dir do |dir| + hive = make_bin(dir) + installer = FakeInstaller.new( + service_state_hash: { + "platform" => "linux", + "unit_path" => "/u/hive-daemon.service", + "service_installed" => true, + "service_enabled" => true + }, + expected: hive, + installed: hive, + parse_error: nil + ) + payload = report_for(dir, installer: installer).to_h + assert_equal "none", payload["binary_drift"] + assert_equal hive, payload["expected_binary"] + assert_equal hive, payload["installed_binary"] + assert_equal Hive::VERSION, payload["installed_version"] + assert_equal Hive::VERSION, payload["current_version"] + end + end + + def test_different_path_reports_path_drift + with_tmp_dir do |dir| + expected = make_bin(dir, "current-hive") + installed = make_bin(dir, "stale-hive") + installer = FakeInstaller.new( + service_state_hash: { + "platform" => "linux", + "unit_path" => "/u/hive-daemon.service", + "service_installed" => true, + "service_enabled" => true + }, + expected: expected, + installed: installed, + parse_error: nil + ) + payload = report_for(dir, installer: installer).to_h + assert_equal "path", payload["binary_drift"] + end + end + + def test_same_path_different_version_reports_version_drift + with_tmp_dir do |dir| + hive = make_bin(dir) + installer = FakeInstaller.new( + service_state_hash: { + "platform" => "linux", + "unit_path" => "/u/hive-daemon.service", + "service_installed" => true, + "service_enabled" => true + }, + expected: hive, + installed: hive, + parse_error: nil + ) + payload = report_for( + dir, + installer: installer, + version_probe: ->(_b) { "0.0.1" } + ).to_h + assert_equal "version", payload["binary_drift"] + assert_equal "0.0.1", payload["installed_version"] + end + end + + def test_unparseable_unit_reports_unparseable + with_tmp_dir do |dir| + installer = FakeInstaller.new( + service_state_hash: { + "platform" => "linux", + "unit_path" => "/u/hive-daemon.service", + "service_installed" => true, + "service_enabled" => false + }, + expected: "/opt/hive", + installed: nil, + parse_error: "no ExecStart= line" + ) + payload = report_for(dir, installer: installer).to_h + assert_equal "unparseable", payload["binary_drift"] + end + end + + def test_missing_unit_reports_not_applicable + with_tmp_dir do |dir| + installer = FakeInstaller.new( + service_state_hash: { + "platform" => "linux", + "unit_path" => "/u/hive-daemon.service", + "service_installed" => false, + "service_enabled" => false + }, + expected: "/opt/hive", + installed: nil, + parse_error: nil + ) + payload = report_for(dir, installer: installer).to_h + assert_equal "not_applicable", payload["binary_drift"] + end + end + + def test_probe_failure_reports_unreadable + with_tmp_dir do |dir| + bad = Object.new + def bad.service_state + raise "boom" + end + payload = report_for(dir, installer: bad).to_h + assert_equal "unreadable", payload["binary_drift"] + assert_nil payload["service_installed"] + end + end + + def test_systemd_and_launchd_binary_parsing + with_tmp_dir do |dir| + hive = File.join(dir, "bin", "hive") + FileUtils.mkdir_p(File.dirname(hive)) + File.write(hive, "#!/bin/sh\n") + FileUtils.chmod(0o755, hive) + + linux = Hive::Commands::Daemon::ServiceInstaller.new( + host_os: "linux", + home: dir, + binary_path: hive, + systemctl_available: false, + runner: ->(*) { true } + ) + linux.install!(autostart: false) + assert_equal hive, linux.installed_binary_path + + # Escaped path with spaces + spaced = File.join(dir, "my hive", "hive") + FileUtils.mkdir_p(File.dirname(spaced)) + File.write(spaced, "#!/bin/sh\n") + FileUtils.chmod(0o755, spaced) + linux2 = Hive::Commands::Daemon::ServiceInstaller.new( + host_os: "linux", + home: dir, + binary_path: spaced, + systemctl_available: false, + runner: ->(*) { true } + ) + linux2.install!(autostart: false, force: true) + assert_equal spaced, linux2.installed_binary_path + + mac = Hive::Commands::Daemon::ServiceInstaller.new( + host_os: "darwin", + home: dir, + binary_path: hive, + runner: ->(*) { true } + ) + mac.install!(autostart: false) + assert_equal hive, mac.installed_binary_path + end + end + + def test_version_probe_timeout_does_not_hang + with_tmp_dir do |dir| + hive = make_bin(dir) + installer = FakeInstaller.new( + service_state_hash: { + "platform" => "linux", + "unit_path" => "/u", + "service_installed" => true, + "service_enabled" => true + }, + expected: hive, + installed: hive, + parse_error: nil + ) + hung = lambda do |_binary| + raise Timeout::Error + end + payload = report_for(dir, installer: installer, version_probe: hung).to_h + assert_equal "unreadable", payload["binary_drift"] + assert_nil payload["installed_version"] + end + end + + def test_default_version_probe_is_bounded_and_reports_timeout_as_unreadable + with_tmp_dir do |dir| + hive = File.join(dir, "bin", "hive") + FileUtils.mkdir_p(File.dirname(hive)) + File.write(hive, "#!/bin/sh\nsleep 30\n") + FileUtils.chmod(0o755, hive) + installer = FakeInstaller.new( + service_state_hash: { + "platform" => "linux", + "unit_path" => "/u", + "service_installed" => true, + "service_enabled" => true + }, + expected: hive, + installed: hive, + parse_error: nil + ) + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + payload = Hive::Daemon::StatusReport.new( + hive_home: dir, + installer: installer, + update_state: nil + ).to_h + + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started + assert_operator elapsed, :<, 5 + assert_equal "unreadable", payload["binary_drift"] + assert_nil payload["installed_version"] + end + end + + def test_default_version_probe_reports_nonzero_and_malformed_output_as_unreadable + with_tmp_dir do |dir| + scripts = { + "nonzero-hive" => "#!/bin/sh\necho 'hive 9.9.9'\nexit 1\n", + "malformed-hive" => "#!/bin/sh\necho 'unknown version'\n" + } + scripts.each do |name, body| + hive = File.join(dir, "bin", name) + FileUtils.mkdir_p(File.dirname(hive)) + File.write(hive, body) + FileUtils.chmod(0o755, hive) + installer = FakeInstaller.new( + service_state_hash: { + "platform" => "linux", + "unit_path" => "/u", + "service_installed" => true, + "service_enabled" => true + }, + expected: hive, + installed: hive, + parse_error: nil + ) + + payload = Hive::Daemon::StatusReport.new( + hive_home: dir, + installer: installer, + update_state: nil + ).to_h + + assert_equal "unreadable", payload["binary_drift"], name + assert_nil payload["installed_version"], name + end + end + end + + def test_nil_or_malformed_version_probe_result_reports_unreadable + with_tmp_dir do |dir| + hive = make_bin(dir) + installer = FakeInstaller.new( + service_state_hash: { + "platform" => "linux", + "unit_path" => "/u", + "service_installed" => true, + "service_enabled" => true + }, + expected: hive, + installed: hive, + parse_error: nil + ) + + [ nil, "not-a-version" ].each do |probe_result| + payload = report_for( + dir, + installer: installer, + version_probe: ->(_binary) { probe_result } + ).to_h + assert_equal "unreadable", payload["binary_drift"] + assert_nil payload["installed_version"] + end + end + end + + def test_reused_pid_is_not_reported_as_running + with_tmp_dir do |dir| + hive = make_bin(dir) + installer = FakeInstaller.new( + service_state_hash: { + "platform" => "linux", + "unit_path" => "/u", + "service_installed" => true, + "service_enabled" => true + }, + expected: hive, + installed: hive, + parse_error: nil + ) + File.write( + File.join(dir, ".daemon.pid"), + { + "pid" => Process.pid, + "process_start_time" => "definitely-not-this-process", + "started_at" => Time.now.utc.iso8601 + }.to_yaml + ) + + payload = report_for(dir, installer: installer).to_h + + assert_equal false, payload["running"] + assert_nil payload["pid"] + end + end + + def test_schema_fields_present + with_tmp_dir do |dir| + installer = FakeInstaller.new( + service_state_hash: { + "platform" => "linux", + "unit_path" => nil, + "service_installed" => false, + "service_enabled" => false + }, + expected: "/opt/hive", + installed: nil, + parse_error: nil + ) + payload = report_for(dir, installer: installer).to_h + %w[ + schema schema_version ok running pid uptime_sec pid_file log_file + service_installed service_enabled unit_path current_version update_nudge + expected_binary installed_binary installed_version binary_drift + ].each do |key| + assert payload.key?(key), "missing #{key}" + end + assert_includes Hive::Daemon::StatusReport::DRIFT_VALUES, payload["binary_drift"] + end + end +end diff --git a/test/unit/gemspec_test.rb b/test/unit/gemspec_test.rb index a4ddfc6e..fe843721 100644 --- a/test/unit/gemspec_test.rb +++ b/test/unit/gemspec_test.rb @@ -25,9 +25,10 @@ class GemspecTest < Minitest::Test assert_includes spec.files, "bin/hv" end - # The web tier is a Rails app under web/, supported only in the Docker - # image or a source checkout — the gem must stay a lean CLI and not - # package the app or its old Sinatra-era assets. + # The web tier is a Rails app under web/. Local installs acquire a + # version-matched release archive (hive-web-.tar.gz) via + # Hive::Web::AppBundle; the gem must stay a lean CLI and not package + # the app or its old Sinatra-era assets. def test_gem_package_excludes_the_rails_web_app spec = Gem::Specification.load(GEMSPEC_PATH) @@ -36,4 +37,14 @@ class GemspecTest < Minitest::Test refute spec.files.any? { |f| f.start_with?("public/") }, "no Sinatra-era static assets should be packaged" end + + def test_web_release_archive_name_matches_app_bundle_contract + require "hive/web/app_bundle" + bundle = Hive::Web::AppBundle.new(version: Hive::VERSION) + assert_equal "hive-web-#{Hive::VERSION}.tar.gz", bundle.archive_basename + assert_match( + %r{\Ahttps://github.com/#{Regexp.escape(Hive::REPO_OWNER)}/#{Regexp.escape(Hive::REPO_NAME)}/releases/download/v#{Regexp.escape(Hive::VERSION)}/hive-web-#{Regexp.escape(Hive::VERSION)}\.tar\.gz\z}, + bundle.release_asset_url + ) + end end diff --git a/test/unit/openclaw_skills_test.rb b/test/unit/openclaw_skills_test.rb index eb700e97..37a0b21f 100644 --- a/test/unit/openclaw_skills_test.rb +++ b/test/unit/openclaw_skills_test.rb @@ -56,12 +56,28 @@ class OpenClawSkillsTest < Minitest::Test assert_includes body, "yay -S --noconfirm --needed hive-bin" assert_includes body, "v0.2.0/install.sh" assert_includes body, "daemon install" - assert_includes body, "init . --json "linux", "service_installed" => true, + "service_enabled" => true, "service_active" => active, + "unit_path" => "/tmp/hive-web.service" + } + end + command = Hive::Commands::Web.new(subcommand: "status", json: true) + command.define_singleton_method(:service_installer) { installer } + output, = capture_io do + begin + command.call + rescue Hive::Error + nil + end + end + payload = JSON.parse(output) + assert_empty schemer.validate(payload).to_a, "invalid active=#{active} payload: #{payload.inspect}" + end + end + + def test_hive_web_install_success_and_failure_payloads_validate + schemer = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path("hive-web-install")))) + installer = Struct.new(:messages) do + def envelope_platform = "linux" + def target_path = "/tmp/hive-web.service" + end.new([]) + + success = Hive::Commands::ServiceInstaller::Outcome.new(:written) + command = Hive::Commands::Web.new(json: true) + output, = capture_io { command.send(:emit_install_outcome, installer, success) } + assert_empty schemer.validate(JSON.parse(output)).to_a + + installer.messages << "unit drifted" + failure = Hive::Commands::ServiceInstaller::Outcome.new(:drifted) + output, = capture_io do + begin + command.send(:emit_install_outcome, installer, failure) + rescue Hive::Error + nil + end + end + assert_empty schemer.validate(JSON.parse(output)).to_a + end + # ── schema metadata identity ───────────────────────────────────────────── # Copy-pasting vN.json to vN+1.json and only changing the version const is diff --git a/test/unit/setup/diagnostics_test.rb b/test/unit/setup/diagnostics_test.rb new file mode 100644 index 00000000..36e23823 --- /dev/null +++ b/test/unit/setup/diagnostics_test.rb @@ -0,0 +1,448 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/setup/diagnostics" +require "hive/web/app_bundle" +require "rbconfig" + +class SetupDiagnosticsTest < Minitest::Test + include HiveTestHelper + + def healthy_bins(dir) + FileUtils.mkdir_p(dir) + %w[git tmux gh claude codex node npm qmd sqlite3].each do |name| + path = File.join(dir, name) + File.write(path, "#!/bin/sh\nexit 0\n") + FileUtils.chmod(0o755, path) + end + dir + end + + def build(dir, **opts) + bins = opts.delete(:bins) || healthy_bins(File.join(dir, "bin")) + which = opts.delete(:which) || ->(name) { + path = File.join(bins, name) + File.executable?(path) ? path : nil + } + versions = opts.delete(:versions) || { + "tmux" => "tmux 3.3a\n", + "gh" => "gh version 2.40.0 (2024-01-01)\n", + "claude" => "2.1.118 (Claude Code)\n", + "codex" => "codex-cli 0.1.0\n", + "node" => "v20.11.0\n", + "npm" => "10.2.4\n", + "git" => "git version 2.43.0\n", + "qmd" => "0.5.0\n", + "sqlite3" => "3.45.0 2024-01-01\n" + } + runner = opts.delete(:runner) || ->(argv) { + bin = File.basename(argv[0].to_s) + if argv[1] == "auth" && argv[2] == "status" + [ "Logged in to github.com as someone\n", "", true ] + else + [ versions[bin].to_s, "", true ] + end + } + agent_auth = opts.delete(:agent_auth) || ->(_name) { true } + gh_auth = opts.delete(:gh_auth) || -> { true } + app_bundle = opts.delete(:app_bundle) || Hive::Web::AppBundle.new( + data_home: File.join(dir, "data"), + env: {}, + source_app_dir: File.join(dir, "no-source"), + downloader: ->(*) { raise "no net" }, + bundler: ->(*) { raise "no bundle" } + ) + # Pre-install a current managed web bundle so default health is green. + # The one missing-bundle case runs inside this source checkout, so disable + # that production fallback explicitly to exercise the bootstrappable row. + if opts.delete(:skip_web_seed) + app_bundle.define_singleton_method(:source_checkout_app_dir) { nil } + else + FileUtils.mkdir_p(File.join(app_bundle.managed_app_dir, "config")) + File.write(File.join(app_bundle.managed_app_dir, "config", "application.rb"), "# rails\n") + File.write(app_bundle.version_stamp_path, "#{Hive::VERSION}\n") + end + + Hive::Setup::Diagnostics.new( + env: opts.delete(:env) || { "HOME" => dir, "PATH" => bins }, + host_os: opts.delete(:host_os) || "linux-gnu", + home: dir, + which: which, + runner: runner, + app_bundle: app_bundle, + agent_auth: agent_auth, + gh_auth: gh_auth, + **opts + ) + end + + def test_every_healthy_dependency_is_ok + with_tmp_dir do |dir| + report = build(dir).call + assert report.ok, report.results.map { |r| [ r.name, r.status, r.detail ] }.inspect + report.results.each do |row| + assert_equal "ok", row.status, "#{row.name} should be ok: #{row.detail}" + assert_nil row.fix + refute row.bootstrappable + end + end + end + + def test_missing_binary_yields_linux_remediation_without_execution + with_tmp_dir do |dir| + bins = File.join(dir, "bin") + FileUtils.mkdir_p(bins) + # Only provide npm so other missing tools are diagnosed; leave git out. + %w[tmux gh claude codex node npm qmd].each do |name| + path = File.join(bins, name) + File.write(path, "#!/bin/sh\n") + FileUtils.chmod(0o755, path) + end + executed = [] + report = build( + dir, + bins: bins, + runner: ->(argv) { + executed << argv + [ "tmux 3.3a\n", "", true ] + } + ).call + + git = report.results.find { |r| r.name == "git" } + assert_equal "missing", git.status + assert_match(/apt-get install -y git|package manager/, git.fix) + refute git.bootstrappable + refute report.ok + # Diagnostics must not run install commands — only version/auth probes. + refute executed.any? { |argv| argv.join(" ").include?("apt-get") } + end + end + + def test_missing_binary_macos_remediation + with_tmp_dir do |dir| + report = build( + dir, + host_os: "darwin23", + which: ->(name) { name == "git" ? nil : File.join(dir, "bin", name) }, + bins: healthy_bins(File.join(dir, "bin")) + ).call + # Force git missing via which override while keeping path existence for others + report = Hive::Setup::Diagnostics.new( + env: { "HOME" => dir }, + host_os: "darwin23", + home: dir, + which: ->(name) { name == "git" ? nil : "/usr/bin/#{name}" }, + runner: ->(argv) { + bin = File.basename(argv[0]) + if argv.include?("auth") + [ "Logged in\n", "", true ] + else + [ "#{bin} 3.3.0\n", "", true ] + end + }, + agent_auth: ->(*) { true }, + gh_auth: -> { true }, + app_bundle: Hive::Web::AppBundle.new( + data_home: File.join(dir, "data"), + env: {}, + source_app_dir: File.join(dir, "nope"), + downloader: ->(*) { }, + bundler: ->(*) { } + ).tap { |b| + FileUtils.mkdir_p(File.join(b.managed_app_dir, "config")) + File.write(File.join(b.managed_app_dir, "config", "application.rb"), "#\n") + File.write(b.version_stamp_path, "#{Hive::VERSION}\n") + } + ).call + + git = report.results.find { |r| r.name == "git" } + assert_equal "missing", git.status + assert_equal "brew install git", git.fix + end + end + + def test_unauthenticated_external_clis_emit_exact_login_and_are_not_bootstrappable + with_tmp_dir do |dir| + report = build( + dir, + agent_auth: ->(name) { false }, + gh_auth: -> { false } + ).call + + %w[gh claude codex].each do |name| + row = report.results.find { |r| r.name == name } + assert_equal "unauthenticated", row.status, name + refute row.bootstrappable, name + end + assert_equal "gh auth login", report.results.find { |r| r.name == "gh" }.fix + assert_equal "claude setup-token", report.results.find { |r| r.name == "claude" }.fix + assert_equal "codex login", report.results.find { |r| r.name == "codex" }.fix + refute report.ok + end + end + + def test_api_key_authentication_avoids_false_negative + with_tmp_dir do |dir| + # Use real AgentProfiles.logged_in? path with no credential files, but + # ANTHROPIC_API_KEY present so claude is considered authenticated. + env = { + "HOME" => dir, + "PATH" => healthy_bins(File.join(dir, "bin")), + "ANTHROPIC_API_KEY" => "sk-test", + "OPENAI_API_KEY" => "sk-openai" + } + diag = Hive::Setup::Diagnostics.new( + env: env, + host_os: "linux", + home: dir, + which: ->(name) { + path = File.join(env["PATH"], name) + File.executable?(path) ? path : nil + }, + runner: ->(argv) { + if argv[1] == "auth" + [ "", "not logged in", false ] + else + [ "1.0.0\n", "", true ] + end + }, + gh_auth: -> { true }, + app_bundle: Hive::Web::AppBundle.new( + data_home: File.join(dir, "data"), + env: {}, + source_app_dir: File.join(dir, "nope"), + downloader: ->(*) { }, + bundler: ->(*) { } + ).tap { |b| + FileUtils.mkdir_p(File.join(b.managed_app_dir, "config")) + File.write(File.join(b.managed_app_dir, "config", "application.rb"), "#\n") + File.write(b.version_stamp_path, "#{Hive::VERSION}\n") + } + ) + report = diag.call + assert_equal "ok", report.results.find { |r| r.name == "claude" }.status + assert_equal "ok", report.results.find { |r| r.name == "codex" }.status + end + end + + def test_on_disk_agent_credentials_count_as_logged_in + with_tmp_dir do |dir| + FileUtils.mkdir_p(File.join(dir, ".claude")) + File.write(File.join(dir, ".claude", ".credentials.json"), '{"token":"x"}') + FileUtils.mkdir_p(File.join(dir, ".codex")) + File.write(File.join(dir, ".codex", "auth.json"), '{"token":"y"}') + + diag = Hive::Setup::Diagnostics.new( + env: { "HOME" => dir, "PATH" => healthy_bins(File.join(dir, "bin")) }, + host_os: "linux", + home: dir, + which: ->(name) { + path = File.join(dir, "bin", name) + File.executable?(path) ? path : nil + }, + runner: ->(*) { [ "1.0.0\n", "", true ] }, + gh_auth: -> { true }, + app_bundle: Hive::Web::AppBundle.new( + data_home: File.join(dir, "data"), + env: {}, + source_app_dir: File.join(dir, "nope"), + downloader: ->(*) { }, + bundler: ->(*) { } + ).tap { |b| + FileUtils.mkdir_p(File.join(b.managed_app_dir, "config")) + File.write(File.join(b.managed_app_dir, "config", "application.rb"), "#\n") + File.write(b.version_stamp_path, "#{Hive::VERSION}\n") + } + ) + report = diag.call + assert_equal "ok", report.results.find { |r| r.name == "claude" }.status + assert_equal "ok", report.results.find { |r| r.name == "codex" }.status + end + end + + def test_qmd_on_path_or_managed_prefix_accepted_and_absent_is_bootstrappable + with_tmp_dir do |dir| + # Managed prefix without PATH entry. + managed = File.join(Hive::Paths.data_home, "qmd", "bin") + # Use explicit data_home isolation via HIVE_HOME collapse? Paths uses XDG. + # Inject which that misses qmd, but file exists under data_home/qmd. + data = File.join(dir, "data", "hive") + FileUtils.mkdir_p(File.join(data, "qmd", "bin")) + qmd = File.join(data, "qmd", "bin", "qmd") + File.write(qmd, "#!/bin/sh\necho 1.0.0\n") + FileUtils.chmod(0o755, qmd) + + with_env( + "HOME" => dir, + "HIVE_HOME" => nil, + "XDG_DATA_HOME" => File.join(dir, "data"), + "XDG_CONFIG_HOME" => File.join(dir, "config"), + "XDG_STATE_HOME" => File.join(dir, "state"), + "XDG_CACHE_HOME" => File.join(dir, "cache") + ) do + bins = healthy_bins(File.join(dir, "bin")) + FileUtils.rm_f(File.join(bins, "qmd")) + report = Hive::Setup::Diagnostics.new( + env: ENV.to_h.merge("HOME" => dir), + host_os: "linux", + home: dir, + which: ->(name) { + return nil if name == "qmd" + + path = File.join(bins, name) + File.executable?(path) ? path : nil + }, + runner: ->(*) { [ "1.0.0\n", "", true ] }, + agent_auth: ->(*) { true }, + gh_auth: -> { true }, + app_bundle: Hive::Web::AppBundle.new( + data_home: File.join(dir, "data", "hive"), + env: {}, + source_app_dir: File.join(dir, "nope"), + downloader: ->(*) { }, + bundler: ->(*) { } + ).tap { |b| + FileUtils.mkdir_p(File.join(b.managed_app_dir, "config")) + File.write(File.join(b.managed_app_dir, "config", "application.rb"), "#\n") + File.write(b.version_stamp_path, "#{Hive::VERSION}\n") + } + ).call + qmd_row = report.results.find { |r| r.name == "qmd" } + assert_equal "ok", qmd_row.status, qmd_row.detail + assert_equal qmd, qmd_row.path + end + end + + with_tmp_dir do |dir| + bins = healthy_bins(File.join(dir, "bin")) + FileUtils.rm_f(File.join(bins, "qmd")) + report = build(dir, bins: bins, skip_web_seed: true).call + qmd_row = report.results.find { |r| r.name == "qmd" } + assert_equal "bootstrappable", qmd_row.status + assert qmd_row.bootstrappable + assert_match(/npm install --global --prefix/, qmd_row.fix) + + web = report.results.find { |r| r.name == "web_bundle" } + assert_equal "bootstrappable", web.status + assert web.bootstrappable + # qmd + web bootstrappable alone do not fail the report when other deps ok + assert report.ok, report.hard_failures.map(&:to_h).inspect + end + end + + def test_outdated_tmux_is_reported + with_tmp_dir do |dir| + report = build( + dir, + versions: { + "tmux" => "tmux 2.9\n", + "gh" => "gh version 2.40.0\n", + "claude" => "2.1.118\n", + "codex" => "0.1.0\n", + "node" => "v20.0.0\n", + "npm" => "10.0.0\n", + "git" => "git version 2.40.0\n", + "qmd" => "0.5.0\n" + } + ).call + tmux = report.results.find { |r| r.name == "tmux" } + assert_equal "outdated", tmux.status + assert_match(/below minimum/, tmux.detail) + refute report.ok + end + end + + def test_timeout_and_spawn_errors_degrade_without_hang + with_tmp_dir do |dir| + report = build( + dir, + runner: ->(_argv) { raise Timeout::Error, "timed out" } + ).call + # Version probes fail soft; binaries still found so status stays ok with nil version + # or error for parse failures. gh auth will fail → unauthenticated. + gh = report.results.find { |r| r.name == "gh" } + assert_includes %w[unauthenticated error ok], gh.status + # Must return a complete matrix either way. + assert_equal 11, report.results.length + end + end + + def test_default_probe_timeout_kills_descendants_before_returning + with_tmp_dir do |dir| + child_pid_path = File.join(dir, "child.pid") + script = <<~RUBY + child = spawn(#{RbConfig.ruby.inspect}, "-e", "sleep 30") + File.write(#{child_pid_path.inspect}, child) + sleep 30 + RUBY + diagnostics = Hive::Setup::Diagnostics.new( + env: { "HOME" => dir, "PATH" => ENV.fetch("PATH", "") }, + probe_timeout: 1.0 + ) + + out, err, ok = diagnostics.send(:default_runner, [ RbConfig.ruby, "-e", script ]) + + assert_equal "", out + assert_equal "", err + refute ok + child_pid = Integer(File.read(child_pid_path)) + refute process_alive?(child_pid), "probe timeout must not leave a prerequisite child running" + end + end + + def test_invalid_result_status_raises + assert_raises(ArgumentError) do + Hive::Setup::Diagnostics::Result.new( + name: "x", status: "nope", detail: "d", fix: nil, bootstrappable: false + ) + end + end + + def test_unsupported_host_still_emits_install_guidance + with_tmp_dir do |dir| + report = build(dir, host_os: "mingw32", skip_web_seed: false).call + git = report.results.find { |r| r.name == "git" } + # git is present via healthy bins — platform only affects missing fixes. + # Force missing: + report = Hive::Setup::Diagnostics.new( + env: { "HOME" => dir }, + host_os: "mingw32", + home: dir, + which: ->(*) { nil }, + runner: ->(*) { [ "", "", false ] }, + agent_auth: ->(*) { false }, + gh_auth: -> { false }, + app_bundle: Hive::Web::AppBundle.new( + data_home: File.join(dir, "data"), + env: {}, + source_app_dir: File.join(dir, "nope"), + downloader: ->(*) { }, + bundler: ->(*) { } + ) + ).call + git = report.results.find { |r| r.name == "git" } + assert_equal "missing", git.status + assert_match(/Linux\/macOS supported/, git.fix) + end + end + + def test_report_to_h_is_json_serializable + with_tmp_dir do |dir| + report = build(dir).call + json = JSON.generate(report.to_h) + parsed = JSON.parse(json) + assert_equal "hive-setup-diagnostics", parsed["schema"] + assert_equal 11, parsed["results"].length + assert parsed["ok"] + end + end + + private + + def process_alive?(pid) + Process.kill(0, pid) + true + rescue Errno::ESRCH + false + end +end diff --git a/test/unit/web/app_bundle_test.rb b/test/unit/web/app_bundle_test.rb new file mode 100644 index 00000000..a55fad2c --- /dev/null +++ b/test/unit/web/app_bundle_test.rb @@ -0,0 +1,573 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/web/app_bundle" +require "digest" +require "rubygems/package" +require "zlib" + +class WebAppBundleTest < Minitest::Test + include HiveTestHelper + + def build(dir, **opts) + Hive::Web::AppBundle.new( + version: opts.delete(:version) || "9.9.9", + data_home: dir, + source_app_dir: opts.delete(:source_app_dir), + env: opts.delete(:env) || {}, + downloader: opts.delete(:downloader) || ->(_url, _dest) { raise "network unexpected" }, + bundler: opts.delete(:bundler) || ->(_app_dir) { nil }, + **opts + ) + end + + def write_rails_tree(root) + FileUtils.mkdir_p(File.join(root, "config")) + File.write(File.join(root, "config", "application.rb"), "# rails marker\n") + File.write(File.join(root, "Gemfile"), "source 'https://rubygems.org'\n") + root + end + + def build_tar_gz(archive_path, entries) + # entries: { "path" => content_string_or_nil_for_dir } + Zlib::GzipWriter.open(archive_path) do |gz| + Gem::Package::TarWriter.new(gz) do |tar| + entries.each do |name, content| + if content.nil? + tar.mkdir(name, 0o755) + else + tar.add_file_simple(name, 0o644, content.bytesize) { |io| io.write(content) } + end + end + end + end + end + + def checksum_for(path, basename: File.basename(path)) + "#{Digest::SHA256.file(path).hexdigest} #{basename}\n" + end + + def test_current_stamped_bundle_reused_without_network_or_bundler + with_tmp_dir do |dir| + target = File.join(dir, "web-app") + write_rails_tree(target) + File.write(File.join(target, ".hive-web-version"), "9.9.9\n") + + network_called = false + bundler_called = false + bundle = build( + dir, + downloader: ->(*) { network_called = true }, + bundler: ->(*) { bundler_called = true } + ) + + path = bundle.resolve! + assert_equal target, path + refute network_called + refute bundler_called + assert bundle.current_managed_bundle? + end + end + + def test_missing_bundle_installs_from_directory_fixture_and_stamps_version + with_tmp_dir do |dir| + source = write_rails_tree(File.join(dir, "fixture-app")) + bundler_dirs = [] + bundle = build(dir, bundler: ->(app_dir) { bundler_dirs << app_dir }) + + path = bundle.ensure_installed!(source: source) + assert_equal File.join(dir, "web-app"), path + assert File.file?(File.join(path, "config", "application.rb")) + assert_equal "9.9.9", File.read(File.join(path, ".hive-web-version")).strip + assert_equal 1, bundler_dirs.length + assert_match(/web-app\.staging\./, bundler_dirs.first, + "bundle install must run against the staging tree before the atomic swap") + assert bundle.current_managed_bundle? + end + end + + def test_install_from_release_archive_records_version + with_tmp_dir do |dir| + archive = File.join(dir, "hive-web-9.9.9.tar.gz") + build_tar_gz(archive, { + "config/" => nil, + "config/application.rb" => "# rails\n", + "Gemfile" => "source 'https://rubygems.org'\n" + }) + + downloader = lambda do |url, dest| + case File.basename(url) + when "SHA256SUMS" + File.write(dest, checksum_for(archive)) + when "hive-web-9.9.9.tar.gz" + FileUtils.cp(archive, dest) + else + flunk "unexpected download: #{url}" + end + end + bundle = build(dir, downloader: downloader) + path = bundle.ensure_installed! + assert_equal "9.9.9", File.read(File.join(path, ".hive-web-version")).strip + assert File.file?(File.join(path, "config", "application.rb")) + end + end + + def test_release_install_rejects_checksum_mismatch_before_extraction + with_tmp_dir do |dir| + archive = File.join(dir, "hive-web-9.9.9.tar.gz") + build_tar_gz(archive, { + "config/" => nil, + "config/application.rb" => "# rails\n", + "Gemfile" => "source 'https://rubygems.org'\n" + }) + downloader = lambda do |url, dest| + if File.basename(url) == "SHA256SUMS" + File.write(dest, "#{'0' * 64} hive-web-9.9.9.tar.gz\n") + else + FileUtils.cp(archive, dest) + end + end + + error = assert_raises(Hive::Web::AppBundle::Error) do + build(dir, downloader: downloader).ensure_installed! + end + + assert_match(/checksum mismatch/, error.message) + refute File.exist?(File.join(dir, "web-app")) + end + end + + def test_release_install_requires_an_exact_checksum_entry + with_tmp_dir do |dir| + archive = File.join(dir, "hive-web-9.9.9.tar.gz") + build_tar_gz(archive, { "config/application.rb" => "# rails\n" }) + downloader = lambda do |url, dest| + if File.basename(url) == "SHA256SUMS" + File.write(dest, "#{Digest::SHA256.file(archive).hexdigest} another-file.tar.gz\n") + else + FileUtils.cp(archive, dest) + end + end + + error = assert_raises(Hive::Web::AppBundle::Error) do + build(dir, downloader: downloader).ensure_installed! + end + assert_match(/does not contain hive-web-9\.9\.9\.tar\.gz/, error.message) + end + end + + def test_release_install_verifies_signed_checksums_when_cosign_is_available + with_tmp_dir do |dir| + archive = File.join(dir, "hive-web-9.9.9.tar.gz") + build_tar_gz(archive, { + "config/application.rb" => "# rails\n", + "Gemfile" => "source 'https://rubygems.org'\n" + }) + bin = File.join(dir, "bin") + FileUtils.mkdir_p(bin) + cosign = File.join(bin, "cosign") + File.write(cosign, "#!/bin/sh\nexit 0\n") + FileUtils.chmod(0o755, cosign) + downloads = [] + downloader = lambda do |url, dest| + downloads << File.basename(url) + case File.basename(url) + when "SHA256SUMS" + File.write(dest, checksum_for(archive)) + when "SHA256SUMS.sig", "SHA256SUMS.pem" + File.write(dest, "provenance\n") + when "hive-web-9.9.9.tar.gz" + FileUtils.cp(archive, dest) + else + flunk "unexpected download: #{url}" + end + end + success = Struct.new(:success?).new(true) + verify_argv = nil + runner = lambda do |*argv, **kwargs| + verify_argv = argv + assert_equal 60, kwargs.fetch(:timeout) + [ "Verified OK", "", success ] + end + + bundle = build( + dir, + env: { "PATH" => bin }, + downloader: downloader, + subprocess_runner: runner + ) + bundle.ensure_installed! + + assert_equal( + %w[SHA256SUMS SHA256SUMS.sig SHA256SUMS.pem hive-web-9.9.9.tar.gz], + downloads + ) + assert_equal cosign, verify_argv.first + assert_includes verify_argv, "--certificate-identity-regexp" + assert_includes verify_argv, "^https://github\\.com/#{Hive::REPO_OWNER}/#{Hive::REPO_NAME}/" + assert_includes verify_argv, "https://token.actions.githubusercontent.com" + end + end + + def test_failed_refresh_leaves_previous_bundle_intact + with_tmp_dir do |dir| + target = File.join(dir, "web-app") + write_rails_tree(target) + File.write(File.join(target, ".hive-web-version"), "9.9.9\n") + File.write(File.join(target, "keep-me"), "old\n") + + source = write_rails_tree(File.join(dir, "new-source")) + File.write(File.join(source, "new-file"), "new\n") + + bundle = build( + dir, + version: "10.0.0", + bundler: ->(_app_dir) { raise Hive::Web::AppBundle::Error, "bundle explode" } + ) + + error = assert_raises(Hive::Web::AppBundle::Error) do + bundle.ensure_installed!(source: source) + end + assert_match(/bundle explode/, error.message) + + assert File.exist?(target), "previous bundle must remain" + assert_equal "old\n", File.read(File.join(target, "keep-me")) + assert_equal "9.9.9", File.read(File.join(target, ".hive-web-version")).strip + refute File.exist?(File.join(target, "new-file")) + refute Dir.glob(File.join(dir, "web-app.staging.*")).any? + refute Dir.glob(File.join(dir, "web-app.prev.*")).any? + end + end + + def test_failed_first_install_leaves_no_half_provisioned_target + with_tmp_dir do |dir| + source = write_rails_tree(File.join(dir, "fixture-app")) + bundle = build( + dir, + bundler: ->(_app_dir) { raise Hive::Web::AppBundle::Error, "no gems" } + ) + + assert_raises(Hive::Web::AppBundle::Error) { bundle.ensure_installed!(source: source) } + refute File.exist?(File.join(dir, "web-app")) + refute Dir.glob(File.join(dir, "web-app.staging.*")).any? + end + end + + def test_archive_rejects_path_traversal + with_tmp_dir do |dir| + archive = File.join(dir, "bad.tar.gz") + build_tar_gz(archive, { + "../escape/config/application.rb" => "# no\n" + }) + bundle = build(dir) + error = assert_raises(Hive::Web::AppBundle::Error) do + bundle.extract_and_install!(archive) + end + assert_match(/escapes staging root|absolute path/, error.message) + refute File.exist?(File.join(dir, "web-app")) + end + end + + def test_archive_rejects_absolute_paths + with_tmp_dir do |dir| + archive = File.join(dir, "abs.tar.gz") + build_tar_gz(archive, { + "/tmp/evil/config/application.rb" => "# no\n" + }) + bundle = build(dir) + error = assert_raises(Hive::Web::AppBundle::Error) do + bundle.extract_and_install!(archive) + end + assert_match(/absolute path/, error.message) + end + end + + def test_archive_rejects_symlinks + with_tmp_dir do |dir| + archive = File.join(dir, "link.tar.gz") + Zlib::GzipWriter.open(archive) do |gz| + Gem::Package::TarWriter.new(gz) do |tar| + tar.add_symlink("link-to-elsewhere", "/etc/passwd", 0o777) + end + end + bundle = build(dir) + error = assert_raises(Hive::Web::AppBundle::Error) do + bundle.extract_and_install!(archive) + end + assert_match(/link entry/, error.message) + end + end + + def test_archive_rejects_missing_rails_marker + with_tmp_dir do |dir| + archive = File.join(dir, "empty.tar.gz") + build_tar_gz(archive, { + "README.md" => "no rails here\n" + }) + bundle = build(dir) + error = assert_raises(Hive::Web::AppBundle::Error) do + bundle.extract_and_install!(archive) + end + assert_match(/does not contain a Rails app/, error.message) + end + end + + def test_archive_rejects_too_many_entries + with_tmp_dir do |dir| + archive = File.join(dir, "many.tar.gz") + build_tar_gz(archive, { + "config/" => nil, + "config/application.rb" => "# rails\n", + "Gemfile" => "source 'https://rubygems.org'\n" + }) + + error = assert_raises(Hive::Web::AppBundle::Error) do + build(dir, max_archive_entries: 2).extract_and_install!(archive) + end + assert_match(/too many entries/, error.message) + end + end + + def test_archive_rejects_oversized_compressed_input + with_tmp_dir do |dir| + archive = File.join(dir, "oversized.tar.gz") + build_tar_gz(archive, { "config/application.rb" => "# rails\n" }) + + error = assert_raises(Hive::Web::AppBundle::Error) do + build(dir, max_archive_bytes: 1).extract_and_install!(archive) + end + assert_match(/archive is too large/, error.message) + end + end + + def test_archive_rejects_an_oversized_entry + with_tmp_dir do |dir| + archive = File.join(dir, "large-entry.tar.gz") + build_tar_gz(archive, { "config/application.rb" => "12345" }) + + error = assert_raises(Hive::Web::AppBundle::Error) do + build(dir, max_archive_entry_bytes: 4).extract_and_install!(archive) + end + assert_match(/entry is too large/, error.message) + end + end + + def test_archive_rejects_excessive_cumulative_expansion + with_tmp_dir do |dir| + archive = File.join(dir, "expanded.tar.gz") + build_tar_gz(archive, { + "config/application.rb" => "1234", + "Gemfile" => "5678" + }) + + error = assert_raises(Hive::Web::AppBundle::Error) do + build(dir, max_archive_expanded_bytes: 7).extract_and_install!(archive) + end + assert_match(/expanded contents are too large/, error.message) + end + end + + def test_single_versioned_wrapper_directory_is_unwrapped + with_tmp_dir do |dir| + archive = File.join(dir, "wrapped.tar.gz") + build_tar_gz(archive, { + "hive-web-9.9.9/" => nil, + "hive-web-9.9.9/config/" => nil, + "hive-web-9.9.9/config/application.rb" => "# rails\n", + "hive-web-9.9.9/Gemfile" => "source 'https://rubygems.org'\n" + }) + bundle = build(dir) + path = nil + # install via extract path + Dir.mktmpdir do |_tmp| + path = bundle.tap { |b| b.extract_and_install!(archive) }.managed_app_dir + end + assert File.file?(File.join(path, "config", "application.rb")) + refute File.exist?(File.join(path, "hive-web-9.9.9")) + end + end + + def test_explicit_env_override_wins_without_managed_bootstrap + with_tmp_dir do |dir| + override = write_rails_tree(File.join(dir, "override-app")) + network_called = false + bundle = build( + dir, + env: { "HIVEBOX_WEB_APP_DIR" => override }, + downloader: ->(*) { network_called = true } + ) + assert_equal override, bundle.resolve! + refute network_called + refute File.exist?(File.join(dir, "web-app")) + end + end + + def test_source_checkout_wins_over_download_when_present + with_tmp_dir do |dir| + source = write_rails_tree(File.join(dir, "checkout-web")) + network_called = false + bundle = build( + dir, + source_app_dir: source, + downloader: ->(*) { network_called = true } + ) + assert_equal source, bundle.resolve! + refute network_called + end + end + + def test_default_source_checkout_fallback_resolves_repository_web_directory + with_tmp_dir do |dir| + bundle = build(dir, source_app_dir: File.join(dir, "missing")) + expected = File.expand_path("../../../web", File.join(__dir__, "../../../lib/hive/web")) + + assert_equal expected, bundle.resolve! + end + end + + def test_runtime_env_is_private_to_the_managed_bundle + with_tmp_dir do |dir| + bundle = build(dir) + managed = bundle.managed_app_dir + + assert_equal( + { + "BUNDLE_APP_CONFIG" => File.join(managed, ".bundle"), + "BUNDLE_GEMFILE" => File.join(managed, "Gemfile"), + "BUNDLE_PATH" => File.join(managed, "vendor", "bundle"), + "BUNDLE_DEPLOYMENT" => "1", + "BUNDLE_WITHOUT" => "development:test", + "RAILS_ENV" => "production" + }, + bundle.runtime_env(managed) + ) + assert_empty bundle.runtime_env(File.join(dir, "source-web")) + end + end + + def test_https_redirect_to_http_is_rejected + redirect = Net::HTTPFound.new("1.1", "302", "Found") + redirect["location"] = "http://downloads.example.test/hive-web-9.9.9.tar.gz" + http_factory = ->(_uri) { FakeHttp.new(redirect) } + + with_tmp_dir do |dir| + bundle = Hive::Web::AppBundle.new( + version: "9.9.9", data_home: dir, env: {}, http_factory: http_factory, + bundler: ->(*) { nil } + ) + error = assert_raises(Hive::Web::AppBundle::Error) do + bundle.send( + :default_download, + "https://downloads.example.test/hive-web-9.9.9.tar.gz", + File.join(dir, "archive"), + max_bytes: 100 + ) + end + assert_match(/refusing HTTPS to HTTP redirect/, error.message) + end + end + + def test_download_stream_is_bounded_by_explicit_ceiling + response = Net::HTTPOK.new("1.1", "200", "OK") + response.define_singleton_method(:read_body) do |&block| + block.call("1234") + block.call("5678") + end + http_factory = ->(_uri) { FakeHttp.new(response) } + + with_tmp_dir do |dir| + dest = File.join(dir, "archive") + bundle = Hive::Web::AppBundle.new( + version: "9.9.9", data_home: dir, env: {}, http_factory: http_factory, + bundler: ->(*) { nil } + ) + error = assert_raises(Hive::Web::AppBundle::Error) do + bundle.send(:default_download, "https://downloads.example.test/archive", dest, max_bytes: 7) + end + assert_match(/download is too large/, error.message) + refute File.exist?(dest) + end + end + + def test_local_bundle_attempt_honors_timeout_without_retry + runner = lambda do |*_args, **kwargs| + assert_equal 0.25, kwargs.fetch(:timeout) + raise Hive::BoundedSubprocess::TimeoutError.new(stdout: "", stderr: "", elapsed: 0.25) + end + + with_tmp_dir do |dir| + app = write_rails_tree(File.join(dir, "app")) + File.write(File.join(app, "Gemfile.lock"), "GEM\n") + bundle = build(dir, subprocess_runner: runner, bundle_timeout: 0.25) + error = assert_raises(Hive::Web::AppBundle::Error) do + bundle.send(:default_bundle_install, app) + end + assert_match(/timed out after 0\.25s/, error.message) + end + end + + def test_fallback_bundle_attempt_honors_timeout + calls = 0 + failed_status = Struct.new(:success?).new(false) + runner = lambda do |*_args, **kwargs| + calls += 1 + assert_equal 0.25, kwargs.fetch(:timeout) + return [ "", "not cached", failed_status ] if calls == 1 + + raise Hive::BoundedSubprocess::TimeoutError.new(stdout: "", stderr: "", elapsed: 0.25) + end + + with_tmp_dir do |dir| + app = write_rails_tree(File.join(dir, "app")) + File.write(File.join(app, "Gemfile.lock"), "GEM\n") + bundle = build(dir, subprocess_runner: runner, bundle_timeout: 0.25) + error = assert_raises(Hive::Web::AppBundle::Error) do + bundle.send(:default_bundle_install, app) + end + assert_equal 2, calls + assert_match(/timed out after 0\.25s/, error.message) + end + end + + def test_release_asset_naming_and_url_agree + bundle = build(Dir.mktmpdir) + assert_equal "hive-web-9.9.9.tar.gz", bundle.archive_basename + assert_equal( + "https://github.com/#{Hive::REPO_OWNER}/#{Hive::REPO_NAME}/releases/download/v9.9.9/hive-web-9.9.9.tar.gz", + bundle.release_asset_url + ) + end + + def test_paths_web_app_home_under_data_home + with_xdg_home do |_home| + assert_equal File.join(Hive::Paths.data_home, "web-app"), Hive::Paths.web_app_home + assert_equal File.join(Hive::Paths.web_app_home, ".hive-web-version"), + Hive::Paths.web_app_version_stamp_path + end + end + + def test_resolve_without_install_raises_when_nothing_available + with_tmp_dir do |dir| + bundle = build(dir, source_app_dir: File.join(dir, "nope")) + # This test runs inside a source checkout, so isolate the no-candidate + # branch explicitly rather than depending on the old broken traversal. + bundle.define_singleton_method(:rails_app?) { |_path| false } + error = assert_raises(Hive::Web::AppBundle::Error) do + bundle.resolve!(install: false) + end + assert_match(/no Rails app found/, error.message) + end + end + + class FakeHttp + attr_accessor :use_ssl, :open_timeout, :read_timeout + + def initialize(response) + @response = response + end + + def request(_request) + yield @response + end + end +end diff --git a/test/unit/web/config_test.rb b/test/unit/web/config_test.rb index a9ed2389..62f76455 100644 --- a/test/unit/web/config_test.rb +++ b/test/unit/web/config_test.rb @@ -10,10 +10,15 @@ class WebConfigTest < Minitest::Test assert_equal "127.0.0.1", cfg["bind"] assert_equal 4567, cfg["port"] + assert_equal true, cfg["local_loopback"] assert_match(/\.web\.session_secret\z/, cfg["session_secret_file"]) end end + def test_local_loopback_must_be_boolean + assert_web_config_error({ "local_loopback" => "yes" }, /web\.local_loopback/) + end + def test_invalid_web_port_is_rejected with_tmp_global_config do |home| File.write(File.join(home, "config.yml"), { "web" => { "port" => 70_000 } }.to_yaml) diff --git a/test/unit/web/dispatcher_test.rb b/test/unit/web/dispatcher_test.rb index a13e62db..caadb20d 100644 --- a/test/unit/web/dispatcher_test.rb +++ b/test/unit/web/dispatcher_test.rb @@ -426,4 +426,31 @@ Already answered result[:argv] end end +def test_queue_daemon_repair_writes_exact_maintenance_request + with_tmp_global_config do + request_id = Hive::Web::Dispatcher.new.queue_daemon_repair! + files = Dir[File.join(Hive::Paths.state_home, "dispatch_requests", "*.json")] + assert files.any?, "repair must write a dispatch request" + payload = JSON.parse(File.read(files.find { |f| File.read(f).include?(request_id) })) + assert_equal Hive::Daemon::DispatchRequestQueue::GLOBAL_PROJECT, payload["project"] + assert_equal Hive::Daemon::DispatchRequestQueue::GLOBAL_SLUG, payload["slug"] + assert_equal %w[hive daemon install --force], payload["argv"] + assert_equal "daemon_repair", payload["trigger"] + assert_equal "bot", payload["requestor"] + end +end + +def test_non_maintenance_daemon_argv_rejected_at_write + with_tmp_global_config do + assert_raises(ArgumentError) do + Hive::Daemon::DispatchRequestQueue.write_request!( + project: Hive::Daemon::DispatchRequestQueue::GLOBAL_PROJECT, + slug: Hive::Daemon::DispatchRequestQueue::GLOBAL_SLUG, + argv: %w[hive daemon start], + requestor: "web", + trigger: "evil" + ) + end + end +end end diff --git a/test/unit/web/web_command_test.rb b/test/unit/web/web_command_test.rb index 11de4ab0..a645b674 100644 --- a/test/unit/web/web_command_test.rb +++ b/test/unit/web/web_command_test.rb @@ -1,18 +1,18 @@ require "test_helper" require "hive/commands/web" +require "hive/commands/web/service_installer" class WebCommandTest < Minitest::Test include HiveTestHelper - # `hive web` now boots the Rails app under web/; outside the container or - # a source checkout (no web/ dir, no HIVEBOX_WEB_APP_DIR) it must fail - # loudly with guidance instead of exec-ing into a missing app. + # `hive web` boots the Rails app via AppBundle; outside managed install, + # source checkout, or HIVEBOX_WEB_APP_DIR it must fail loudly. def test_missing_rails_app_exits_with_guidance with_tmp_global_config do with_env("HIVEBOX_WEB_APP_DIR" => File.join(Dir.mktmpdir("hive-noapp"), "nope")) do command = Hive::Commands::Web.new - # Singleton override instead of minitest/mock (not bundled): the - # checkout itself contains web/, so the fallback path would resolve. + # Singleton override: the checkout itself contains web/, so the + # fallback path would resolve without this stub. command.define_singleton_method(:rails_app_dir) { nil } err = assert_raises(SystemExit) do capture_io { command.call } @@ -34,6 +34,107 @@ class WebCommandTest < Minitest::Test end end + def test_foreground_does_not_invoke_service_manager + with_tmp_global_config do + with_stub_rails_app(prepare_exit: 0) do + original = Kernel.method(:exec) + Kernel.define_singleton_method(:exec) do |env, *argv| + raise ExecCaught.new(env, argv) + end + # If service installer is required/used, fail. + called = false + Hive::Commands::Web::ServiceInstaller.define_singleton_method(:new) do |**| + called = true + raise "service installer must not run for foreground hive web" + end + + begin + capture_io { Hive::Commands::Web.new.call } + rescue ExecCaught + # expected + ensure + Kernel.define_singleton_method(:exec, original) + Hive::Commands::Web::ServiceInstaller.singleton_class.send(:remove_method, :new) + end + refute called + end + end + end + + def test_json_rejected_for_foreground + with_tmp_global_config do + error = assert_raises(Hive::InvalidTaskPath) do + capture_io { Hive::Commands::Web.new(json: true).call } + end + assert_match(/no JSON output|long-lived server/, error.message) + end + end + + def test_install_subcommand_writes_unit + with_tmp_dir do |dir| + with_env("HOME" => dir) do + commands = [] + installer = Hive::Commands::Web::ServiceInstaller.new( + host_os: "linux", + home: dir, + binary_path: "/tmp/hive", + systemctl_available: true, + runner: ->(argv) { commands << argv; true } + ) + cmd = Hive::Commands::Web.new(subcommand: "install") + cmd.define_singleton_method(:service_installer) { installer } + out, = capture_io { cmd.call } + assert_match(/installed unit|up to date|upgraded/, out) + assert File.exist?(installer.target_path) + end + end + end + + def test_json_status_reports_active_service_as_success + installer = Object.new + installer.define_singleton_method(:status_snapshot) do + { + "platform" => "linux", "service_installed" => true, + "service_enabled" => true, "service_active" => true, + "unit_path" => "/tmp/hive-web.service" + } + end + command = Hive::Commands::Web.new(subcommand: "status", json: true) + command.define_singleton_method(:service_installer) { installer } + + out, = capture_io { command.call } + payload = JSON.parse(out) + assert payload["ok"] + assert_nil payload["error"] + end + + def test_json_status_reports_inactive_service_and_raises + installer = Object.new + installer.define_singleton_method(:status_snapshot) do + { + "platform" => "linux", "service_installed" => true, + "service_enabled" => true, "service_active" => false, + "unit_path" => "/tmp/hive-web.service" + } + end + command = Hive::Commands::Web.new(subcommand: "status", json: true) + command.define_singleton_method(:service_installer) { installer } + + error = nil + out, = capture_io do + begin + command.call + rescue Hive::Error => e + error = e + end + end + assert_instance_of Hive::Error, error + assert_equal "web service not active", error.message + payload = JSON.parse(out) + refute payload["ok"] + assert_equal "web service not active", payload["error"] + end + def test_public_bind_without_https_origin_warns with_tmp_global_config do command = Hive::Commands::Web.new @@ -115,4 +216,41 @@ class WebCommandTest < Minitest::Test end end end + + def test_managed_bundle_runtime_env_reaches_database_prepare_and_rails + with_xdg_home do + app_dir = Hive::Web::AppBundle.new.managed_app_dir + FileUtils.mkdir_p(File.join(app_dir, "config")) + FileUtils.mkdir_p(File.join(app_dir, "bin")) + File.write(File.join(app_dir, "config", "application.rb"), "# rails app marker\n") + File.write(File.join(app_dir, "Gemfile"), "source \"https://rubygems.org\"\n") + File.write(File.join(app_dir, Hive::Web::AppBundle::VERSION_STAMP_NAME), "#{Hive::VERSION}\n") + File.write(File.join(app_dir, "bin", "rails"), <<~SH) + #!/usr/bin/env bash + [ "$1" = "db:prepare" ] || exit 1 + [ "$BUNDLE_PATH" = "#{File.join(app_dir, 'vendor', 'bundle')}" ] || exit 2 + exit 0 + SH + FileUtils.chmod(0o755, File.join(app_dir, "bin", "rails")) + + original = Kernel.method(:exec) + Kernel.define_singleton_method(:exec) do |env, *argv| + raise ExecCaught.new(env, argv) + end + + caught = nil + begin + capture_io { Hive::Commands::Web.new.call } + rescue ExecCaught => e + caught = e + ensure + Kernel.define_singleton_method(:exec, original) + end + + refute_nil caught + assert_equal File.join(app_dir, "vendor", "bundle"), caught.env["BUNDLE_PATH"] + assert_equal "1", caught.env["BUNDLE_DEPLOYMENT"] + assert_equal File.join(app_dir, "Gemfile"), caught.env["BUNDLE_GEMFILE"] + end + end end diff --git a/web/Gemfile b/web/Gemfile index 53710d90..1ef3aaa5 100644 --- a/web/Gemfile +++ b/web/Gemfile @@ -70,4 +70,7 @@ end # The hive control plane: status payloads, gate approval, daemon dispatch, # the GitHub device-flow gate, the agent OAuth relay, and Telegram # validation all come from the gem — the web tier adds no pipeline logic. +# Source checkouts use the sibling gemspec. release.yml rewrites only its +# tracked staging copy to an exact version and an unpacked vendor/hive-cli +# path, so the published web archive has no parent-path dependency. gem "hive-cli", path: ".." diff --git a/web/app/controllers/application_controller.rb b/web/app/controllers/application_controller.rb index 2a1e5e28..b1749a0f 100644 --- a/web/app/controllers/application_controller.rb +++ b/web/app/controllers/application_controller.rb @@ -50,6 +50,8 @@ class ApplicationController < ActionController::Base end def require_login + return if local_loopback_request? + return redirect_to login_path unless current_login # Sessions must track the CURRENT owner, not the owner at sign-in time: @@ -65,6 +67,21 @@ class ApplicationController < ActionController::Base redirect_to login_path, alert: "Signed out: this box's owner changed." end + # Local single-user mode: CLI sets HIVE_WEB_LOCAL_LOOPBACK only for a + # loopback bind, and we re-check both the actual peer and the HTTP Host. + # The Host check is load-bearing: accepting an arbitrary hostname merely + # because its TCP peer is loopback would let DNS rebinding turn a browser + # on the operator's machine into an unauthenticated deputy. + def local_loopback_request? + return false unless Hive::Web::Loopback.mode_enabled? + return false unless Hive::Config.load_global_web.fetch("local_loopback", true) + + Hive::Web::Loopback.address?(request.remote_ip) && + Hive::Web::Loopback.address?(request.host) + rescue StandardError + false + end + def registered_projects @registered_projects ||= Hive::Config.registered_projects end diff --git a/web/app/controllers/daemon_controller.rb b/web/app/controllers/daemon_controller.rb new file mode 100644 index 00000000..a6e5d1aa --- /dev/null +++ b/web/app/controllers/daemon_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class DaemonController < ApplicationController + # POST /daemon/repair — enqueue exact allowlisted + # `hive daemon install --force` for a running daemon to consume. + def repair + require "hive/daemon/status_report" + report = Hive::Daemon::StatusReport.new.safe_payload + + unless report["running"] + raise Hive::Error, + "daemon is not running — repair cannot be queued. " \ + "Run `hive daemon install --force` from a terminal, then `hive daemon start`." + end + + drift = report["binary_drift"].to_s + unless %w[path version unparseable].include?(drift) + raise Hive::Error, + "daemon binary drift is #{drift.inspect}; repair is only offered for " \ + "path/version/unparseable drift" + end + + request_id = Hive::Web::Dispatcher.new.queue_daemon_repair! + redirect_to root_path, + notice: "Daemon repair queued (#{request_id}). " \ + "The running daemon will reinstall its unit with the current binary." + end +end diff --git a/web/app/controllers/status_controller.rb b/web/app/controllers/status_controller.rb index c44440a8..a217f00b 100644 --- a/web/app/controllers/status_controller.rb +++ b/web/app/controllers/status_controller.rb @@ -2,5 +2,20 @@ class StatusController < ApplicationController def index @payload = StatusBroadcaster.snapshot @projects = @payload.fetch("projects", []) + @daemon = daemon_status_payload + end + + private + + def daemon_status_payload + require "hive/daemon/status_report" + Hive::Daemon::StatusReport.new.safe_payload + rescue StandardError => e + { + "running" => false, + "binary_drift" => "unreadable", + "error" => "#{e.class}: #{e.message}" + } end end + diff --git a/web/app/views/status/_daemon.html.erb b/web/app/views/status/_daemon.html.erb new file mode 100644 index 00000000..8297f179 --- /dev/null +++ b/web/app/views/status/_daemon.html.erb @@ -0,0 +1,61 @@ +<%# Locals: daemon (Hash from StatusReport#safe_payload) %> +<% daemon = local_assigns.fetch(:daemon) { {} } %> +<% running = daemon["running"] %> +<% drift = daemon["binary_drift"].to_s %> +<% actionable = running && %w[path version unparseable].include?(drift) %> +
+

Daemon

+
+
+
Status
+
+ <% if running %> + running + <% if daemon["pid"] %>pid <%= daemon["pid"] %><% end %> + <% else %> + down + <% end %> +
+
+
+
Service
+
+ <% if daemon["service_installed"] %> + installed<%= daemon["service_enabled"] ? ", enabled" : "" %> + <% elsif daemon["service_installed"].nil? %> + unknown + <% else %> + not installed + <% end %> +
+
+
+
Binary drift
+
+ <%= drift.empty? ? "unknown" : drift %> + <% if daemon["installed_binary"] || daemon["expected_binary"] %> +
+ installed: <%= daemon["installed_binary"].inspect %>
+ expected: <%= daemon["expected_binary"].inspect %>
+ installed version: <%= daemon["installed_version"].inspect %> / + current: <%= daemon["current_version"].inspect %> +
+ <% end %> +
+
+
+ + <% if actionable %> + <%= button_to "Repair daemon unit", daemon_repair_path, method: :post, + class: "btn btn-sm", + form: { data: { turbo_confirm: "Queue hive daemon install --force on the running daemon?" } } %> + <% elsif !running %> +

+ Daemon is down — repair cannot be queued. Run + hive daemon install --force then + hive daemon start from a terminal. +

+ <% elsif drift == "none" %> +

Daemon unit matches this CLI binary.

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