diff --git a/README-FreeBSD.md b/README-FreeBSD.md index 06dcfe1ee..60bd13a3c 100644 --- a/README-FreeBSD.md +++ b/README-FreeBSD.md @@ -14,6 +14,32 @@ Three targeted changes for FreeBSD native support: Clipboard (xclip) and voice (ffplay) work on FreeBSD without code changes — xclip and ffmpeg are available via `pkg`. +## Service (rc.d) + +After installing, run Hermes as a persistent system service under `daemon(8)`: + +```sh +# One-time setup +sudo pw groupadd hermes +sudo pw useradd hermes -g hermes -d /var/db/hermes -s /usr/sbin/nologin +sudo cp packaging/freebsd/hermes_daemon.in /usr/local/etc/rc.d/hermes_daemon +sudo chmod 555 /usr/local/etc/rc.d/hermes_daemon + +# Configure Hermes before first start +sudo mkdir -p /var/db/hermes +sudo chown hermes:hermes /var/db/hermes +sudo -u hermes HERMES_HOME=/var/db/hermes hermes setup +sudo -u hermes HERMES_HOME=/var/db/hermes hermes model + +# Enable and start +sudo sysrc hermes_daemon_enable=YES +sudo service hermes_daemon start +sudo service hermes_daemon status +``` + +The service stores config in `/var/db/hermes` (persistent) instead of +`~/.hermes` (tmpfs on live USB). Logs go to `/var/log/hermes/gateway.log`. + ## Install ```sh diff --git a/packaging/freebsd/hermes_daemon.in b/packaging/freebsd/hermes_daemon.in new file mode 100644 index 000000000..92e341b78 --- /dev/null +++ b/packaging/freebsd/hermes_daemon.in @@ -0,0 +1,145 @@ +#!/bin/sh +# +# Hermes Agent — FreeBSD rc.d service +# +# Hermes Agent gateway runs in the FOREGROUND — it does not self-daemonize or +# write a pidfile. rc.d runs it under daemon(8), which backgrounds it, writes +# the child pidfile, restarts on crash, drops privileges to the hermes user, +# and redirects stdout/stderr to a logfile. +# +# Setup (one-time, as root): +# pw groupadd hermes +# pw useradd hermes -g hermes -d /var/db/hermes -s /usr/sbin/nologin +# cp packaging/freebsd/hermes_daemon.in /usr/local/etc/rc.d/hermes_daemon +# chmod 555 /usr/local/etc/rc.d/hermes_daemon +# sysrc hermes_daemon_enable=YES +# +# Before first start, configure Hermes: +# HERMES_HOME=/var/db/hermes su -m hermes -c "hermes setup" +# HERMES_HOME=/var/db/hermes su -m hermes -c "hermes model" +# +# Runtime: +# service hermes_daemon start +# service hermes_daemon status +# service hermes_daemon stop +# +# Requires: +# - hermes-agent Python package installed (pip/uv) +# - hermes binary on PATH at /usr/local/bin/hermes +# - hermes user/group (privilege drop target) + +# PROVIDE: hermes_daemon +# REQUIRE: LOGIN cleanvar +# KEYWORD: shutdown + +. /etc/rc.subr + +name="hermes_daemon" +rcvar="hermes_daemon_enable" + +load_rc_config $name + +: ${hermes_daemon_enable:="NO"} +: ${hermes_daemon_user:="hermes"} +: ${hermes_daemon_group:="hermes"} +: ${hermes_daemon_program:="/usr/local/bin/hermes"} +: ${hermes_daemon_home:="/var/db/hermes"} +: ${hermes_daemon_run_dir:="/var/run/hermes"} +: ${hermes_daemon_logfile:="/var/log/hermes/gateway.log"} + +pidfile="${hermes_daemon_run_dir}/hermes-gateway.pid" +# Supervisor pidfile (the daemon(8) parent). Kept distinct from the child +# pidfile so `stop` can target the supervisor. +supervisor_pidfile="${hermes_daemon_run_dir}/hermes-gateway-supervisor.pid" + +# Run hermes gateway under daemon(8): +# -P supervisor pidfile (the daemon(8) parent — used by stop) +# -p child pidfile (writes gateway PID — used by start/status) +# -r restart on crash, -t process title, -u drop to the hermes user, +# -o append stdout/stderr to log. +command="/usr/sbin/daemon" +command_args="-P ${supervisor_pidfile} -p ${pidfile} -r -t ${name} -u ${hermes_daemon_user} \ + -o ${hermes_daemon_logfile} ${hermes_daemon_program} gateway run" + +# Use the child's process name so rc.subr can find the right process via the +# child pidfile. +procname="hermes" + +start_precmd="hermes_daemon_prestart" +stop_cmd="hermes_daemon_stop" +extra_commands="health" + +hermes_daemon_prestart() +{ + # /var/run is tmpfs on FreeBSD (wiped each boot) — recreate every start. + install -d -o "${hermes_daemon_user}" -g "${hermes_daemon_group}" -m 0750 \ + "${hermes_daemon_run_dir}" + install -d -o "${hermes_daemon_user}" -g "${hermes_daemon_group}" -m 0750 \ + "${hermes_daemon_home}" + install -d -o "${hermes_daemon_user}" -g "${hermes_daemon_group}" -m 0750 \ + "$(/usr/bin/dirname "${hermes_daemon_logfile}")" + + # Redirect Hermes config away from the service user's home directory + # (which may be tmpfs or absent) to a persistent path. + export HERMES_HOME="${hermes_daemon_home}" + + # Verify Hermes is configured before starting. The gateway exits + # immediately if no provider/model is configured, and daemon(8) -r would + # then respawn it in a tight crash loop. Abort the start instead so the + # operator sees a clear, actionable error. + if [ ! -f "${hermes_daemon_home}/config.yaml" ]; then + echo "ERROR: Hermes config not found at ${hermes_daemon_home}/config.yaml" + echo " Run as operator: HERMES_HOME=${hermes_daemon_home} hermes setup" + echo " Then: HERMES_HOME=${hermes_daemon_home} hermes model" + return 1 + fi +} + +hermes_daemon_stop() +{ + # daemon(8) -r restarts the child if it is killed directly, so stop + # the supervisor instead: on SIGTERM it forwards the signal to the child + # and exits without restarting it. + local _sup="" + [ -f "${supervisor_pidfile}" ] && _sup=$(cat "${supervisor_pidfile}" 2>/dev/null) + if [ -n "${_sup}" ] && kill -0 "${_sup}" 2>/dev/null; then + echo "Stopping ${name} (daemon(8) supervisor pid ${_sup})." + kill -TERM "${_sup}" 2>/dev/null + local _n=0 + while kill -0 "${_sup}" 2>/dev/null && [ ${_n} -lt 30 ]; do + sleep 1 + _n=$((_n + 1)) + done + if kill -0 "${_sup}" 2>/dev/null; then + echo "Supervisor did not exit in time; sending SIGKILL." + kill -KILL "${_sup}" 2>/dev/null + fi + else + echo "${name} is not running." + fi + # Belt-and-suspenders: terminate the child if it somehow outlived the + # supervisor. + local _ch="" + [ -f "${pidfile}" ] && _ch=$(cat "${pidfile}" 2>/dev/null) + if [ -n "${_ch}" ] && kill -0 "${_ch}" 2>/dev/null; then + kill -TERM "${_ch}" 2>/dev/null + fi + rm -f "${supervisor_pidfile}" "${pidfile}" +} + +health_cmd="hermes_daemon_health" +hermes_daemon_health() +{ + if [ -f "${pidfile}" ]; then + local _pid + _pid=$(cat "${pidfile}" 2>/dev/null) + if [ -n "${_pid}" ] && kill -0 "${_pid}" 2>/dev/null; then + echo "hermes-daemon is healthy (pid ${_pid} alive)" + return 0 + fi + fi + echo "hermes-daemon not running" + return 1 +} + +run_rc_command "$1" diff --git a/scripts/install-freebsd.sh b/scripts/install-freebsd.sh index 50a333da6..c6be1d810 100755 --- a/scripts/install-freebsd.sh +++ b/scripts/install-freebsd.sh @@ -113,4 +113,6 @@ echo "FreeBSD notes:" echo " - Packages live in /usr/local/bin (ensure it's in PATH)" echo " - Voice mode: install espeak-ng + ffmpeg (pkg install espeak-ng ffmpeg)" echo " - Clipboard: install xclip (pkg install xclip)" -echo " - No systemd — use rc.d or run hermes gateway as a foreground service" +echo " - Service: copy packaging/freebsd/hermes_daemon.in to /usr/local/etc/rc.d/" +echo " See README-FreeBSD.md for full rc.d setup" +echo " - Foreground: hermes gateway run (or tmux/screen for persistence)"