fix(docker): supervise profile gateways outside the root s6 scandir

The image's root s6 scandir (/run/service) is watched by the PID-1
s6-svscan, so every slot in it runs under a root s6-supervise that
executes the slot's run/finish/log scripts as root. 02-reconcile-profiles
chowned that directory to hermes so per-profile gateway slots could be
registered at runtime, and 015-supervise-perms chowned the static
services' supervise/ and event/ trees to hermes. Both handed the
unprivileged hermes user control over what the root supervisor runs
and writes.

- Per-profile gateway slots move to /run/hermes-service, watched by a
  nested s6-svscan that runs as hermes (new s6-rc longrun
  profile-gateways). Gateways, their loggers and their supervisors now
  all run as hermes; the rendered run/log scripts already skip the
  privilege drop when non-root.
- 02-reconcile-profiles creates that scandir hermes-owned (refusing a
  symlink) and no longer touches /run/service.
- 015-supervise-perms keeps the static services' supervise/ trees
  root-owned and only grants group hermes read on status/lock and write
  on the control FIFO, leaving event/ as s6 creates it.
- hermes doctor probes the static services in the root scandir via a
  new s6_service_up() helper; S6_DYNAMIC_SCANDIR now points at the
  gateway scandir and S6_STATIC_SCANDIR names the root one.
- Docker integration tests, docs and the s6 skill follow the new
  gateway slot path.
This commit is contained in:
unsupportedpastels authored and Mark S. committed 2026-11-04 23:37:25 -05:00
1 parent 1fcc57e6d2
commit c08c485305
27 files changed
+380 -149

No files matched your search

+7 -5
View File
@@ -447,10 +447,12 @@ RUN set -eu; \
python3 -c 'import json, pathlib, tomllib; project = tomllib.loads(pathlib.Path("/opt/hermes/pyproject.toml").read_text(encoding="utf-8"))["project"]; stamp = json.loads(pathlib.Path("/opt/hermes/install-stamp.json").read_text(encoding="utf-8")); commit = stamp.get("commit"); revision = commit if commit and set(commit) != {"0"} else None; marker = pathlib.Path("/etc/hermes/image-provenance.json"); marker.write_text(json.dumps({"schema": 1, "deployment_kind": "image", "manager": "docker", "image": "nousresearch/hermes-agent", "version": project["version"], "revision": revision}, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8"); marker.chmod(0o444)'
# ---------- s6-overlay service wiring ----------
# Static services declared at build time: main-hermes + dashboard.
# Per-profile gateway services are registered dynamically at runtime by
# the profile create/delete hooks (Phase 4); they live under
# /run/service/ (tmpfs) and are reconciled on container restart by
# Static services declared at build time: main-hermes, dashboard and
# profile-gateways. Per-profile gateway services are registered
# dynamically at runtime by the profile create/delete hooks (Phase 4);
# they live under /run/hermes-service/ (tmpfs), supervised by the
# hermes-owned s6-svscan of the profile-gateways service, never in the
# root /run/service scandir, and are reconciled on container restart by
# /etc/cont-init.d/02-reconcile-profiles (Phase 4 Task 4.0).
COPY docker/s6-rc.d/ /etc/s6-overlay/s6-rc.d/
@@ -461,7 +463,7 @@ COPY docker/s6-rc.d/ /etc/s6-overlay/s6-rc.d/
#
# 02-reconcile-profiles re-creates per-profile gateway s6 service
# slots from $HERMES_HOME/profiles/<name>/ after a container restart
# (the /run/service/ scandir is tmpfs and wiped on restart). Phase 4.
# (the /run/hermes-service/ scandir is tmpfs and wiped on restart). Phase 4.
RUN mkdir -p /etc/cont-init.d && \
printf '#!/command/with-contenv sh\nexec /opt/hermes/docker/stage2-hook.sh\n' \
> /etc/cont-init.d/01-hermes-setup && \
+42 -57
View File
@@ -1,47 +1,37 @@
#!/command/with-contenv sh
# shellcheck shell=sh
# Make supervise/ trees for ALL declared s6 services queryable and
# controllable by the unprivileged hermes user (UID 10000).
# Let the unprivileged hermes group query and control the STATIC s6-rc
# services (dashboard, main-hermes, profile-gateways) without handing
# it any part of the root supervision tree.
#
# Background (PR #30136 review item I4): the entire s6 lifecycle
# (s6-svc, s6-svstat, s6-svwait) is dispatched as the hermes user
# inside the container (every Hermes runtime path runs under
# ``s6-setuidgid hermes``). But s6-supervise creates each service's
# ``supervise/`` and top-level ``event/`` directory with mode 0700
# owned by its effective UID — which is root, because s6-supervise
# is spawned by s6-svscan running as PID 1. So unprivileged clients
# get EACCES on every probe / control call against the slot.
# s6-supervise creates each service's ``supervise/`` 0700 and its
# control FIFO 0600, owned by its effective UID — root, because it is
# spawned by the root s6-svscan (PID 1). Hermes runtime paths
# (``hermes doctor`` etc.) run as hermes and need ``s6-svstat`` and
# ``s6-svc`` against these slots, i.e. group search on ``supervise/``,
# group read on ``supervise/status`` + ``supervise/lock`` and group
# write on ``supervise/control``.
#
# Two fixes, one in each registration path:
# Ownership stays root: root s6-supervise keeps writing ``status``
# (via ``status.new``), ``death_tally`` and the event FIFOs, so a
# hermes-owned or hermes-writable directory there would let the hermes
# user redirect those root writes. The service-root ``event/`` fifodir
# is left as s6 made it — nothing in Hermes subscribes to static
# services' events.
#
# 1. For RUNTIME-registered profile gateways (created via the s6
# runtime register hooks in profiles.py): the Python helper
# ``_seed_supervise_skeleton`` pre-creates supervise/ + event/ +
# supervise/control owned by hermes BEFORE s6-svscanctl -a fires.
# s6-supervise's mkdir/mkfifo are EEXIST-safe, so it inherits our
# ownership and never tries to chown back to root.
#
# 2. For STATIC s6-rc services (dashboard, main-hermes) declared at
# image-build time under /etc/s6-overlay/s6-rc.d/*: these are
# compiled by s6-rc at boot, and s6-supervise spawns BEFORE
# cont-init.d gets to run — so by the time we're here, the
# supervise/ tree is already there as root:root 0700. We chown
# it here. s6-supervise will keep using the same files; it never
# re-asserts ownership on a running service.
# Per-profile gateway slots are not affected: they live in their own
# scandir under an s6-svscan that runs as hermes (see
# 02-reconcile-profiles and the profile-gateways service).
#
# This script runs as root after 01-hermes-setup but before
# 02-reconcile-profiles, so the chowns are settled before the
# Python reconciler walks the scandir. Lexicographic ordering
# guarantees this — the suffix is unusual because we want to slot
# in between 01 and the existing 02-reconcile-profiles without
# renumbering both (which would be a churn-noise patch on its own).
# 02-reconcile-profiles. s6-rc static services' supervisors are
# already running by now, so their supervise/ trees exist.
set -eu
# /run/s6-rc/servicedirs holds the live, compiled service directories
# for every static (s6-rc) service. Symlinks under /run/service/*
# point here. Per-service supervise/ + event/ both need hermes
# ownership for s6-svstat etc. to work as hermes.
# point here.
SVC_ROOT=/run/s6-rc/servicedirs
if [ ! -d "$SVC_ROOT" ]; then
@@ -53,38 +43,33 @@ for svc in "$SVC_ROOT"/*; do
[ -d "$svc" ] || continue
name=$(basename "$svc")
# Skip s6-overlay-internal services (they need to stay root-only;
# the s6rc-* helpers manage the supervision tree itself).
# Skip s6-overlay-internal services (they stay root-only; the
# s6rc-* helpers manage the supervision tree itself).
case "$name" in
s6rc-*|s6-linux-*)
continue
;;
esac
# supervise/ tree — needed by s6-svstat / s6-svc.
if [ -d "$svc/supervise" ]; then
chown -R hermes:hermes "$svc/supervise" 2>/dev/null || \
echo "[supervise-perms] could not chown $svc/supervise"
# 0710 = group searchable. ``s6-svstat`` only needs to openat
# status, not list the dir, but giving the hermes group +x is
# the minimum that lets group members access the contents.
chmod 0710 "$svc/supervise" 2>/dev/null || true
# supervise/control is a FIFO that s6-svc writes commands
# into; the hermes user needs +w. Owner is already hermes
# after the recursive chown above; widen perms to 0660 so
# ``s6-svc`` works for any member of the hermes group too.
if [ -p "$svc/supervise/control" ]; then
chmod 0660 "$svc/supervise/control" 2>/dev/null || true
fi
fi
sup="$svc/supervise"
[ -d "$sup" ] && [ ! -L "$sup" ] || continue
# Top-level event/ dir — s6-svlisten1 / s6-svwait subscribe here.
if [ -d "$svc/event" ]; then
chown hermes:hermes "$svc/event" 2>/dev/null || \
echo "[supervise-perms] could not chown $svc/event"
# Preserve s6's 03730 mode (setgid + g+rwx + sticky).
chmod 03730 "$svc/event" 2>/dev/null || true
chown root:hermes "$sup" 2>/dev/null || \
echo "[supervise-perms] could not chgrp $sup"
chmod 0750 "$sup" 2>/dev/null || true
# s6-svc writes commands into the control FIFO.
if [ -p "$sup/control" ]; then
chown root:hermes "$sup/control" 2>/dev/null || true
chmod 0660 "$sup/control" 2>/dev/null || true
fi
# s6-svstat reads status and probes lock.
for f in lock status; do
if [ -f "$sup/$f" ] && [ ! -L "$sup/$f" ]; then
chown root:hermes "$sup/$f" 2>/dev/null || true
chmod 0640 "$sup/$f" 2>/dev/null || true
fi
done
done
echo "[supervise-perms] chowned supervise/ trees for static s6-rc services"
echo "[supervise-perms] static s6-rc services: supervise/ readable/controllable by group hermes"
+19 -29
View File
@@ -7,40 +7,30 @@
# services. /etc/cont-init.d/* scripts run in lexicographic order,
# so the `02-` prefix guarantees ordering.
#
# Service directories under /run/service/ live on tmpfs and are
# wiped on every container restart. Profile directories under
# $HERMES_HOME/profiles/ live on the persistent VOLUME. This script
# walks the persistent profiles, recreates the s6 service slots,
# and auto-starts only those whose last recorded state was
# `running` — see hermes_cli/container_boot.py.
# Service directories live on tmpfs and are wiped on every container
# restart. Profile directories under $HERMES_HOME/profiles/ live on
# the persistent VOLUME. This script walks the persistent profiles,
# recreates the s6 service slots, and auto-starts only those whose
# last recorded state was `running` — see hermes_cli/container_boot.py.
#
# Phase 4 also needs hermes-user writes to /run/service/ (so the
# profile create/delete hooks can register/unregister at runtime),
# so we chown the scandir before invoking the reconciler. We
# additionally chown the s6-svscan control FIFO so the hermes user
# can send rescan signals via ``s6-svscanctl -a``; without this the
# entire runtime-registration path is inert under UID 10000 (the
# Python wrapper catches the resulting EACCES, prints a warning,
# and swallows the failure).
# Per-profile gateway slots are registered at runtime by the hermes
# user (profile create/delete hooks, `hermes gateway start`), so they
# live in their own scandir, $GATEWAY_SCANDIR, watched by an s6-svscan
# that itself runs as hermes (the `profile-gateways` s6-rc service).
# The root scandir /run/service and its .s6-svscan control files stay
# root-owned: every slot in it is supervised by a root s6-supervise,
# which executes the slot's run/finish/log scripts as root.
set -e
# Make the dynamic scandir hermes-writable. The directory itself
# starts root-owned by s6-overlay.
chown hermes:hermes /run/service 2>/dev/null || true
GATEWAY_SCANDIR=/run/hermes-service
# Make the svscan control FIFO hermes-writable so s6-svscanctl -a
# / -an work for the hermes user. The FIFO is created by s6-svscan
# at PID-1 startup, so by the time this cont-init.d script runs it
# already exists. Both ``control`` and ``lock`` need to be writable
# for the various svscanctl operations; the directory itself stays
# root-owned (we only need to touch the two FIFOs/locks inside).
if [ -d /run/service/.s6-svscan ]; then
for entry in control lock; do
if [ -e "/run/service/.s6-svscan/$entry" ]; then
chown hermes:hermes "/run/service/.s6-svscan/$entry" 2>/dev/null || true
fi
done
if [ -L "$GATEWAY_SCANDIR" ]; then
echo "[reconcile] refusing symlinked $GATEWAY_SCANDIR" >&2
exit 1
fi
mkdir -p "$GATEWAY_SCANDIR"
chown hermes:hermes "$GATEWAY_SCANDIR"
chmod 0755 "$GATEWAY_SCANDIR"
# Skip the drop when already non-root.
[ "$(id -u)" = 0 ] || exec /opt/hermes/.venv/bin/python -m hermes_cli.container_boot
+1 -1
View File
@@ -23,5 +23,5 @@
# For now this service is a no-op: it sleeps forever, doing nothing.
# The dashboard runs as a real s6 service alongside it (see
# ../dashboard/run) and per-profile gateways register dynamically via
# /run/service/ at runtime (Phase 4).
# /run/hermes-service/ at runtime (Phase 4; see ../profile-gateways/run).
exec sleep infinity
Whitespace-only changes.
+22
View File
@@ -0,0 +1,22 @@
#!/command/with-contenv sh
# shellcheck shell=sh
# Unprivileged supervision tree for per-profile gateways.
#
# Gateway slots are created at runtime by the hermes user (profile
# create/delete hooks, `hermes gateway start`, boot reconciliation), so
# their run/finish/log scripts are hermes-writable. They therefore must
# not sit in the root scandir (/run/service), where a root s6-supervise
# would execute them. Instead this service runs a nested s6-svscan AS
# HERMES over its own scandir; every s6-supervise it spawns, and every
# gateway it supervises, runs as hermes.
#
# The scandir is created hermes-owned by cont-init.d/02-reconcile-profiles,
# which also publishes the slots restored from the data volume, before
# s6-rc starts this service. Keep the path in sync with
# hermes_cli/service_manager.py::S6_DYNAMIC_SCANDIR.
scandir=/run/hermes-service
# Skip the drop when already non-root (setgroups() lacks CAP_SETGID).
[ "$(id -u)" = 0 ] || exec s6-svscan "$scandir"
exec s6-setuidgid hermes s6-svscan "$scandir"
+1
View File
@@ -0,0 +1 @@
longrun
Whitespace-only changes.
+4 -2
View File
@@ -94,7 +94,7 @@ def reconcile_profile_gateways(
so it is what ``hermes gateway start`` (no ``-p``) targets.
Without it, bare ``hermes gateway start`` inside the container would land on ``s6-svc -u
/run/service/gateway-default`` → uncaught ``CalledProcessError`` → traceback to the user (PR #30136
/run/hermes-service/gateway-default`` → uncaught ``CalledProcessError`` → traceback to the user (PR #30136
review).
"""
actions: list[ReconcileAction] = []
@@ -361,8 +361,10 @@ def main() -> int:
print("reconcile: skipping (dashboard container — does not need per-profile gateways)")
return 0
from hermes_cli.service_manager import S6_DYNAMIC_SCANDIR
hermes_home = Path(os.environ.get("HERMES_HOME", "/opt/data"))
scandir = Path(os.environ.get("S6_PROFILE_GATEWAY_SCANDIR", "/run/service"))
scandir = Path(os.environ.get("S6_PROFILE_GATEWAY_SCANDIR", str(S6_DYNAMIC_SCANDIR)))
actions = reconcile_profile_gateways(hermes_home=hermes_home, scandir=scandir)
folded = [a.profile for a in actions if a.profile != "default" and a.folded_into_root]
if folded:
+4 -3
View File
@@ -184,15 +184,16 @@ def _check_s6_supervision(issues: list[str]) -> None:
"""Under our s6 /init, report static services and the ONE host gateway slot; no-op elsewhere.
Counterpart to :func:`_check_gateway_service_linger` (systemd-on-host)."""
try:
from hermes_cli.service_manager import S6ServiceManager, detect_service_manager
from hermes_cli.service_manager import (
S6_STATIC_SCANDIR, S6ServiceManager, detect_service_manager, s6_service_up)
except Exception:
return
if detect_service_manager() != "s6":
return
_section("s6 Supervision")
mgr = S6ServiceManager()
for static in ("main-hermes", "dashboard"): # s6-rc symlinks under /run/service/, same s6-svstat probe
up = mgr.is_running(static)
for static in ("main-hermes", "dashboard"): # s6-rc symlinks in the root scandir, not the gateway one
up = s6_service_up(S6_STATIC_SCANDIR / static)
(check_ok if up else check_info)(f"{static}: up" if up else f"{static}: down (expected if not enabled via env)")
_report_host_gateway_slot(mgr, issues)
+1 -1
View File
@@ -4844,7 +4844,7 @@ def _dispatch_via_service_manager_if_s6(action: str, profile: str | None = None)
getattr(mgr, action)(service)
except GatewayNotRegisteredError:
# A profile created from the HOST against a bind-mounted home has a directory but no
# slot (`profile create` cannot reach the container's /run/service). Only `start`
# slot (`profile create` cannot reach the container's /run/hermes-service). Only `start`
# repairs that; stop/restart on a missing slot stay an error.
if action != "start" or not register_unregistered_profile_gateway(mgr, profile):
raise
+1 -1
View File
@@ -1,6 +1,6 @@
"""s6 leg of the one-gateway-per-host convergence, shared by container boot and in-process migration.
Inside the official image every profile has an s6 slot (``/run/service/gateway-<profile>``). The
Inside the official image every profile has an s6 slot (``/run/hermes-service/gateway-<profile>``). The
container's boot (``hermes_cli/container_boot.py``) registers every NAMED slot down and lets the
root slot inherit their autostart intent; ``hermes gateway migrate --multiplex`` (and the hook
``hermes update`` runs) must be able to do the same thing to a slot that is UP, from inside the
+18 -5
View File
@@ -222,9 +222,12 @@ def get_service_manager() -> ServiceManager:
# /etc/s6-overlay/s6-rc.d/ as part of the image and are NOT managed here.
# ---------------------------------------------------------------------------
# s6-overlay's dynamic scandir (tmpfs) that s6-svscan watches; writes here trigger supervision
# on the next rescan.
S6_DYNAMIC_SCANDIR = Path("/run/service")
# s6-overlay's root scandir, watched by the PID-1 s6-svscan: every slot in it runs under a root
# s6-supervise, so it stays root-owned and holds only the image's static s6-rc services.
S6_STATIC_SCANDIR = Path("/run/service")
# Per-profile gateway scandir (tmpfs), watched by a nested s6-svscan running as hermes (the
# ``profile-gateways`` s6-rc service); writes here trigger supervision on the next rescan.
S6_DYNAMIC_SCANDIR = Path("/run/hermes-service")
S6_SERVICE_PREFIX = "gateway-"
@@ -277,7 +280,7 @@ def register_unregistered_profile_gateway(mgr: ServiceManager, profile: str) ->
`hermes profile create` can only register a slot when it runs inside the container; created
from the host against a bind-mounted home, the directory lands where the container reads it
but no ``/run/service/gateway-<name>`` exists, and the boot reconciler only notices on the
but no ``/run/hermes-service/gateway-<name>`` exists, and the boot reconciler only notices on the
next container restart. Returns False without touching anything unless the directory carries
``SOUL.md`` — the reconciler's own "real profile" marker — so a mistyped ``-p`` name cannot
mint a phantom slot. ``start_now=False``: the caller's ordinary ``start`` stays the single
@@ -306,8 +309,18 @@ def _s6_run(cmd: str, *args: str, timeout: float = 5, check: bool = False):
)
def s6_service_up(service_dir: Path) -> bool:
"""``s6-svstat -o up`` for any s6 service directory; False on any failure."""
try:
result = _s6_run("s6-svstat", "-o", "up", str(service_dir))
except (OSError, subprocess.SubprocessError):
return False
return result.returncode == 0 and result.stdout.split() == ["true"]
# UID/GID of the in-image ``hermes`` user; hardcoded to match what ``stage2-hook.sh`` enforces
# (tests/docker/test_uid_remap.py). s6-supervise starts as root and drops via ``s6-setuidgid``.
# (tests/docker/test_uid_remap.py). Only matters for root callers; the gateway scandir's
# s6-supervise already runs as hermes.
_HERMES_UID = 10000
_HERMES_GID = 10000
@@ -37,16 +37,17 @@ If you're just running the Hermes Agent and want to use Docker, see `website/doc
│ │ ├── seed .env / config.yaml / SOUL.md
│ │ └── skills_sync.py
│ └── 02-reconcile-profiles ← hermes_cli.container_boot
│ ├── chown /run/service (hermes-writable for runtime register)
│ ├── mkdir+chown /run/hermes-service (hermes-owned gateway scandir; /run/service stays root)
│ └── walk $HERMES_HOME/profiles/<name>/gateway_state.json
│ → recreate /run/service/gateway-<name>/
│ → recreate /run/hermes-service/gateway-<name>/
│ → auto-start only those with prior_state == "running"
│
├── s6-rc.d (static services, in /etc/s6-overlay/s6-rc.d/)
│ ├── main-hermes/run ← exec sleep infinity (no-op slot)
│ └── dashboard/run ← if HERMES_DASHBOARD=1, runs `hermes dashboard`
│ ├── dashboard/run ← if HERMES_DASHBOARD=1, runs `hermes dashboard`
│ └── profile-gateways/run ← `s6-setuidgid hermes s6-svscan /run/hermes-service`
│
├── /run/service (s6-svscan watches; tmpfs)
├── /run/hermes-service (hermes s6-svscan from s6-rc `profile-gateways` watches; tmpfs)
│ ├── gateway-coder/ ← runtime-registered per-profile
│ │ ├── type ("longrun")
│ │ ├── run ("#!/command/with-contenv sh ... exec s6-setuidgid hermes hermes -p coder gateway run")
@@ -84,7 +85,7 @@ The original plan (v1–v3) called for main hermes to run as a supervised s6-rc
So we use the s6-overlay-native CMD pattern via the dispatcher: `ENTRYPOINT ["/opt/hermes/docker/entrypoint-dispatch.sh"]`, which under PID 1 exec's `/init /opt/hermes/docker/main-wrapper.sh "$@"`. The wrapper is prepended to user args automatically — so `docker run <image> --version` becomes `/init main-wrapper.sh --version`, and `--version` doesn't get intercepted by /init's POSIX shell. The wrapper drops to hermes via `s6-setuidgid`, then exec's the chosen program. The program's exit code becomes the container exit code, exactly matching the pre-s6 tini contract. When the entrypoint is NOT PID 1 (Fly Machines, `docker run --init`), the dispatcher skips `/init` entirely (it would abort with `can only run as pid 1`), restores the s6 helper PATH, runs stage2-hook.sh, and exec's main-wrapper.sh directly — no supervised services on that path (#38349).
Trade-off: main hermes is unsupervised under s6. That exactly matches its behavior under tini (the pre-s6 image). Dashboard supervision is the only **new** guarantee — and per-profile gateways under `/run/service/` get full supervision.
Trade-off: main hermes is unsupervised under s6. That exactly matches its behavior under tini (the pre-s6 image). Dashboard supervision is the only **new** guarantee — and per-profile gateways under `/run/hermes-service/` get full supervision.
## Quick recipes
@@ -99,7 +100,7 @@ docker exec <c> sh -c 'cat /proc/1/comm; readlink /proc/1/exe'
```sh
# /command/ isn't on docker-exec PATH — use absolute path
docker exec <c> /command/s6-svstat /run/service/gateway-<name>
docker exec <c> /command/s6-svstat /run/hermes-service/gateway-<name>
# "up (pid …) … seconds" → running
# "down (exitcode N) … seconds, normally up, want up, …" → s6 wants it up but the process keeps exiting (crash loop)
# "down … normally up, ready …" → user stopped it
@@ -108,9 +109,9 @@ docker exec <c> /command/s6-svstat /run/service/gateway-<name>
### Bring a service up/down manually
```sh
docker exec <c> /command/s6-svc -u /run/service/gateway-<name> # up
docker exec <c> /command/s6-svc -d /run/service/gateway-<name> # down
docker exec <c> /command/s6-svc -t /run/service/gateway-<name> # SIGTERM (restart)
docker exec <c> /command/s6-svc -u /run/hermes-service/gateway-<name> # up
docker exec <c> /command/s6-svc -d /run/hermes-service/gateway-<name> # down
docker exec <c> /command/s6-svc -t /run/hermes-service/gateway-<name> # SIGTERM (restart)
```
### Watch the cont-init reconciler log
+3 -3
View File
@@ -1,6 +1,6 @@
"""Container-restart survives per-profile gateway registrations.
The s6 dynamic scandir at /run/service/ lives on tmpfs and is wiped
The s6 gateway scandir at /run/hermes-service/ lives on tmpfs and is wiped
on every container restart. Phase 4 Task 4.0's container_boot module
+ cont-init.d/02-reconcile-profiles regenerate the service slots from
$HERMES_HOME/profiles/<name>/gateway_state.json on every boot and
@@ -96,11 +96,11 @@ def test_stopped_gateway_stays_stopped_after_restart(restart_container: str) ->
# Slot exists.
assert wait_for_path(
container, "/run/service/gateway-writer", kind="d", deadline_s=10.0,
container, "/run/hermes-service/gateway-writer", kind="d", deadline_s=10.0,
)
# Down marker present.
r = docker_exec_sh(container, "test -f /run/service/gateway-writer/down")
r = docker_exec_sh(container, "test -f /run/hermes-service/gateway-writer/down")
assert r.returncode == 0, "down marker missing despite prior_state=stopped"
+1 -1
View File
@@ -31,7 +31,7 @@ from tests.docker.conftest import (
def _svstat(container: str, slot: str = "gateway-default") -> str:
r = docker_exec_sh(container, f"/command/s6-svstat /run/service/{slot}")
r = docker_exec_sh(container, f"/command/s6-svstat /run/hermes-service/{slot}")
return r.stdout if r.returncode == 0 else ""
+2 -2
View File
@@ -33,7 +33,7 @@ def _svstat(container: str) -> str:
"""Returns the raw s6-svstat output for the test profile's slot.
/command/s6-svstat is called by absolute path because /command/
isn't on PATH for docker-exec sessions."""
r = _sh(container, f"/command/s6-svstat /run/service/gateway-{PROFILE}")
r = _sh(container, f"/command/s6-svstat /run/hermes-service/gateway-{PROFILE}")
return r.stdout if r.returncode == 0 else ""
@@ -86,7 +86,7 @@ def test_named_profile_gateway_start_refuses_without_force(
assert r.returncode == 0, f"profile create failed: {r.stderr}"
# Profile create's s6-register hook still produces the slot (registered DOWN).
r = _sh(container_name, f"test -d /run/service/gateway-{PROFILE}")
r = _sh(container_name, f"test -d /run/hermes-service/gateway-{PROFILE}")
assert r.returncode == 0, "s6 service slot not created on profile create"
r = _sh(container_name, f"hermes -p {PROFILE} gateway start", timeout=60)
+1 -1
View File
@@ -16,7 +16,7 @@ import time
from tests.docker.conftest import docker_exec_sh, start_container, wait_for_docker_logs
SLOT = "/run/service/gateway-default"
SLOT = "/run/hermes-service/gateway-default"
def _svstat_field(container: str, field: str) -> str:
@@ -13,9 +13,10 @@ hooks land.
Every ``docker exec`` here runs as the unprivileged ``hermes`` user
(via :func:`docker_exec` in conftest); see the conftest module
docstring. ``/run/service`` is chowned hermes-writable by the
``02-reconcile-profiles`` cont-init.d script, so register/unregister
operations work correctly under UID 10000.
docstring. The gateway scandir ``/run/hermes-service`` is created
hermes-owned by the ``02-reconcile-profiles`` cont-init.d script and
watched by a hermes s6-svscan, so register/unregister operations work
under UID 10000 while the root ``/run/service`` stays root-owned.
"""
from __future__ import annotations
@@ -39,7 +40,7 @@ def test_s6_register_creates_service_dir_in_live_container(
built_image: str, container_name: str,
) -> None:
"""S6ServiceManager.register_profile_gateway must create
``/run/service/gateway-<profile>/`` and trigger s6-svscan rescan
``/run/hermes-service/gateway-<profile>/`` and trigger s6-svscan rescan
against the real s6 supervision tree."""
start_container(built_image, container_name, cmd="sleep 120")
@@ -49,21 +50,21 @@ def test_s6_register_creates_service_dir_in_live_container(
)
# Service directory exists with the expected structure.
r = docker_exec(container_name, "test", "-d", "/run/service/gateway-phase3test")
r = docker_exec(container_name, "test", "-d", "/run/hermes-service/gateway-phase3test")
assert r.returncode == 0, "service directory not created"
r = docker_exec(container_name, "test", "-f", "/run/service/gateway-phase3test/run")
r = docker_exec(container_name, "test", "-f", "/run/hermes-service/gateway-phase3test/run")
assert r.returncode == 0, "run script not created"
r = docker_exec(container_name, "test", "-f",
"/run/service/gateway-phase3test/log/run")
"/run/hermes-service/gateway-phase3test/log/run")
assert r.returncode == 0, "log/run script not created"
# s6-svscan picked it up — s6-svstat works against the dir.
# `docker exec` doesn't put /command/ on PATH (only the supervision
# tree does), so call s6-svstat by absolute path.
r = docker_exec(container_name, "/command/s6-svstat",
"/run/service/gateway-phase3test")
"/run/hermes-service/gateway-phase3test")
assert r.returncode == 0, f"s6-svstat failed: {r.stderr or r.stdout}"
# list_profile_gateways picks it up.
@@ -0,0 +1,42 @@
"""``hermes doctor``'s s6 section probes the image's static s6-rc services in the root scandir and
lists profile gateway slots from the separate, unprivileged gateway scandir."""
from __future__ import annotations
import subprocess
from pathlib import Path
import pytest
from hermes_cli import doctor_platform, service_manager
ROOT_SCANDIR = Path("/run/service")
def test_static_services_and_gateway_slots_use_distinct_scandirs(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
) -> None:
svstat_targets: list[Path] = []
listed: list[Path] = []
def _fake_s6_run(cmd: str, *args: str, **_kw):
if cmd == "s6-svstat":
svstat_targets.append(Path(args[-1]))
return subprocess.CompletedProcess([cmd, *args], 0, "true\n", "")
def _list(self) -> list[str]:
listed.append(self.scandir)
return []
monkeypatch.setattr(service_manager, "detect_service_manager", lambda: "s6")
monkeypatch.setattr(service_manager, "_s6_run", _fake_s6_run)
monkeypatch.setattr(service_manager.S6ServiceManager, "list_profile_gateways", _list)
monkeypatch.setattr("gateway.host_topology.host_gateway_topology", lambda: None)
doctor_platform._check_s6_supervision([])
assert ROOT_SCANDIR / "main-hermes" in svstat_targets
assert ROOT_SCANDIR / "dashboard" in svstat_targets
assert "main-hermes: up" in capsys.readouterr().out
# Gateway slots are hermes-registered, so they must not live in the root-supervised scandir.
assert len(listed) == 1 and listed[0] != ROOT_SCANDIR
assert ROOT_SCANDIR not in listed[0].parents
@@ -0,0 +1,170 @@
"""The image's root s6 supervision tree must stay out of the unprivileged ``hermes`` user's reach.
Everything under the root ``s6-svscan`` scandir (``/run/service``) is supervised by a root
``s6-supervise``, which executes each slot's ``run``/``finish``/``log/run`` as root and writes
its ``supervise/`` state as root. The cont-init scripts and the static s6-rc services are run
here against fake privileged binaries so their ownership decisions can be checked without a
container.
"""
from __future__ import annotations
import os
import re
import shutil
import stat
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
DOCKER_DIR = REPO_ROOT / "docker"
ROOT_SCANDIR = "/run/service"
def _sh() -> str:
shell = shutil.which("sh")
if shell is None:
pytest.skip("sh not available")
return shell
def _fake_bin(tmp_path: Path, log: Path, names: dict[str, str], sub: str = "fakebin") -> Path:
"""Fake binaries that append ``<name> <args>`` to ``log`` and print a canned stdout."""
bindir = tmp_path / sub
bindir.mkdir()
for name, stdout in names.items():
script = bindir / name
script.write_text(
"#!/bin/sh\n"
f'printf "%s\\n" "{name} $*" >> "{log}"\n'
+ (f"printf '%s\\n' '{stdout}'\n" if stdout else "")
+ "exit 0\n",
encoding="utf-8",
)
script.chmod(0o755)
return bindir
def _run(script: Path, bindir: Path, *args: str) -> subprocess.CompletedProcess[str]:
env = {k: v for k, v in os.environ.items() if k != "HERMES_DASHBOARD"}
env["PATH"] = f"{bindir}{os.pathsep}{os.environ.get('PATH', '')}"
return subprocess.run([_sh(), str(script), *args], capture_output=True, text=True, env=env, check=False)
def _calls(log: Path, name: str) -> list[list[str]]:
if not log.exists():
return []
return [line.split()[1:] for line in log.read_text().splitlines() if line.split()[:1] == [name]]
def _mode(path: Path) -> int:
return stat.S_IMODE(path.stat().st_mode)
def _under_root_scandir(path: str) -> bool:
return path == ROOT_SCANDIR or path.startswith(ROOT_SCANDIR + "/")
@pytest.mark.platforms("posix")
def test_reconcile_cont_init_leaves_root_scandir_root_owned(tmp_path: Path) -> None:
"""02-reconcile-profiles must not hand /run/service (or its svscan control) to hermes:
a hermes-writable root scandir lets hermes publish a slot that root s6-supervise executes."""
log = tmp_path / "calls.log"
bindir = _fake_bin(
tmp_path, log, {"chown": "", "chmod": "", "mkdir": "", "id": "0", "s6-setuidgid": ""}
)
proc = _run(DOCKER_DIR / "cont-init.d" / "02-reconcile-profiles", bindir)
assert proc.returncode == 0, proc.stderr
for args in _calls(log, "chown") + _calls(log, "chmod"):
assert not any(_under_root_scandir(a) for a in args), args
# The reconciler itself still runs, dropped to hermes.
assert any(args[:1] == ["hermes"] and "hermes_cli.container_boot" in args
for args in _calls(log, "s6-setuidgid"))
@pytest.mark.platforms("posix")
def test_profile_gateway_scandir_is_supervised_unprivileged(tmp_path: Path) -> None:
"""Per-profile gateway slots are hermes-writable, so the s6-svscan watching them must run as
hermes (never as root), and on the same directory the service manager registers into."""
from hermes_cli.service_manager import S6ServiceManager
gateway_scandir = str(S6ServiceManager().scandir)
log = tmp_path / "calls.log"
bindir = _fake_bin(tmp_path, log, {"id": "0", "s6-setuidgid": "", "s6-svscan": "", "sleep": ""})
rc_dir = DOCKER_DIR / "s6-rc.d"
runners = sorted(rc_dir.glob("*/run"))
supervisors = []
for run in runners:
log.unlink(missing_ok=True)
proc = _run(run, bindir)
assert proc.returncode == 0, (run, proc.stderr)
for args in _calls(log, "s6-setuidgid"):
if args[1:2] == ["s6-svscan"]:
supervisors.append((run.parent.name, args))
assert not _calls(log, "s6-svscan"), f"{run} runs s6-svscan as root"
assert len(supervisors) == 1, supervisors
name, args = supervisors[0]
assert args[0] == "hermes"
assert args[-1] == gateway_scandir
assert not _under_root_scandir(gateway_scandir)
# Wired into the user bundle so s6-rc starts it after cont-init has seeded the scandir.
assert (rc_dir / name / "type").read_text().strip() == "longrun"
assert (rc_dir / "user" / "contents.d" / name).exists()
assert (rc_dir / name / "dependencies.d" / "base").exists()
# cont-init seeds that same directory hermes-owned before s6-rc starts the supervisor.
log.unlink(missing_ok=True)
bindir2 = _fake_bin(
tmp_path, log, {"chown": "", "chmod": "", "mkdir": "", "id": "0", "s6-setuidgid": ""},
sub="fakebin-cont-init",
)
proc = _run(DOCKER_DIR / "cont-init.d" / "02-reconcile-profiles", bindir2)
assert proc.returncode == 0, proc.stderr
assert ["hermes:hermes", gateway_scandir] in _calls(log, "chown")
@pytest.mark.platforms("posix")
def test_static_supervise_trees_stay_root_owned(tmp_path: Path) -> None:
"""015-supervise-perms may let the hermes group query/control static services, but must not
make hermes the owner of anything a root s6-supervise writes to."""
svc_root = tmp_path / "servicedirs"
for name in ("dashboard", "s6rc-oneshot-runner"):
sup = svc_root / name / "supervise"
sup.mkdir(parents=True)
sup.chmod(0o700)
os.mkfifo(sup / "control", 0o600)
for f in ("lock", "status"):
(sup / f).write_bytes(b"")
(sup / f).chmod(0o600)
(svc_root / name / "event").mkdir()
(svc_root / name / "event").chmod(0o700)
text = (DOCKER_DIR / "cont-init.d" / "015-supervise-perms").read_text(encoding="utf-8")
script_text, n = re.subn(r"(?m)^SVC_ROOT=.*$", f"SVC_ROOT={svc_root}", text)
assert n == 1
script = tmp_path / "015-supervise-perms"
script.write_text(script_text, encoding="utf-8")
log = tmp_path / "calls.log"
bindir = _fake_bin(tmp_path, log, {"chown": ""})
proc = _run(script, bindir)
assert proc.returncode == 0, proc.stderr
chowns = _calls(log, "chown")
assert chowns, "static services were not made reachable for the hermes group"
for args in chowns:
owner = next(a for a in args if not a.startswith("-"))
assert owner.split(":")[0] == "root", args
assert not any("/event" in a or "s6rc-" in a for a in args), args
sup = svc_root / "dashboard" / "supervise"
assert _mode(sup) & 0o002 == 0 and _mode(sup) & 0o020 == 0 # nobody but root may create entries
assert _mode(sup / "control") & 0o020 # hermes group can s6-svc
assert _mode(sup / "status") & 0o040 and _mode(sup / "lock") & 0o040 # and s6-svstat
assert _mode(svc_root / "dashboard" / "event") == 0o700 # event fifodir untouched
assert _mode(svc_root / "s6rc-oneshot-runner" / "supervise") == 0o700
+6 -6
View File
@@ -275,12 +275,12 @@ Hermes supports [multiple profiles](../reference/profile-commands.md) — separa
Each profile created with `hermes profile create <name>` gets:
- A dedicated s6 service slot at `/run/service/gateway-<name>/`, registered dynamically by the runtime — no container rebuild required.
- A dedicated s6 service slot at `/run/hermes-service/gateway-<name>/`, registered dynamically by the runtime — no container rebuild required.
- Auto-restart on crash, backoff-managed by `s6-supervise`.
- Per-profile rotated logs at `${HERMES_HOME}/logs/gateways/<name>/current` (10 archives × 1 MB each).
- State persistence across container restarts: the boot-time reconciler reads `gateway_state.json` from each profile directory and brings the slot back up only for profiles whose last recorded state was `running`. Only a gateway you explicitly stopped (`hermes gateway stop`) stays down across a restart — a container restart, image upgrade, or unexpected exit leaves the recorded state as `running`, so the gateway auto-starts on the next boot.
A profile created from the **host** against a bind-mounted `~/.hermes` gets its directory but no slot (the host process cannot reach the container's `/run/service`). Inside the container, `hermes -p <name> gateway start` registers the missing slot on demand and starts it — no `docker restart` needed. Only `start` does this, and only for a real profile directory (one carrying `SOUL.md`); `stop`/`restart` on an unregistered profile and a mistyped `-p` name still fail with `✗ no such gateway`.
A profile created from the **host** against a bind-mounted `~/.hermes` gets its directory but no slot (the host process cannot reach the container's `/run/hermes-service`). Inside the container, `hermes -p <name> gateway start` registers the missing slot on demand and starts it — no `docker restart` needed. Only `start` does this, and only for a real profile directory (one carrying `SOUL.md`); `stop`/`restart` on an unregistered profile and a mistyped `-p` name still fail with `✗ no such gateway`.
The lifecycle commands you'd run on the host work the same way from inside the container:
@@ -300,7 +300,7 @@ docker exec hermes hermes -p coder gateway status
docker exec hermes hermes profile delete coder
```
Under the hood, `hermes gateway start/stop/restart` inside the container is intercepted and routed to `s6-svc` against the right service directory; you don't need to learn the s6 commands directly. For raw supervisor state, use `/command/s6-svstat /run/service/gateway-<name>` (note `/command/` is on PATH only for processes spawned by the supervision tree — when calling from `docker exec`, pass the absolute path).
Under the hood, `hermes gateway start/stop/restart` inside the container is intercepted and routed to `s6-svc` against the right service directory; you don't need to learn the s6 commands directly. For raw supervisor state, use `/command/s6-svstat /run/hermes-service/gateway-<name>` (note `/command/` is on PATH only for processes spawned by the supervision tree — when calling from `docker exec`, pass the absolute path).
### Reaching more than one profile from outside the container
@@ -594,7 +594,7 @@ The container's `ENTRYPOINT` is a small dispatcher (`docker/entrypoint-dispatch.
On the PID-1 path, `/init`:
1. Runs `/etc/cont-init.d/01-hermes-setup` (= `docker/stage2-hook.sh`) as root: optional UID/GID remap, fixes volume ownership, seeds `.env` / `config.yaml` / `SOUL.md` on first boot, runs non-interactive config-schema migrations unless `HERMES_SKIP_CONFIG_MIGRATION=1`, syncs bundled skills.
2. Runs `/etc/cont-init.d/02-reconcile-profiles` (= `hermes_cli.container_boot`): walks `$HERMES_HOME/profiles/<name>/`, recreates the per-profile gateway s6 service slot under `/run/service/gateway-<profile>/`, and auto-starts only those whose last recorded state was `running` (see [Per-profile gateway supervision](#per-profile-gateway-supervision)).
2. Runs `/etc/cont-init.d/02-reconcile-profiles` (= `hermes_cli.container_boot`): walks `$HERMES_HOME/profiles/<name>/`, recreates the per-profile gateway s6 service slot under `/run/hermes-service/gateway-<profile>/`, and auto-starts only those whose last recorded state was `running` (see [Per-profile gateway supervision](#per-profile-gateway-supervision)).
3. Starts the static `main-hermes` and `dashboard` s6-rc services.
4. Exec's the container's CMD as the main program (`/opt/hermes/docker/main-wrapper.sh`), which routes the arguments the user passed to `docker run`:
- no args → `hermes` (the default)
@@ -641,7 +641,7 @@ The shim accepts `1` / `true` / `yes` (case-insensitive). Anything else — incl
### Per-profile gateway supervision
Each profile created with `hermes profile create <name>` automatically gets an s6-supervised gateway service registered at `/run/service/gateway-<name>/`, with state-persistent auto-restart across container restarts. See [Multi-profile support](#multi-profile-support) above for the user-facing workflow and the lifecycle commands.
Each profile created with `hermes profile create <name>` automatically gets an s6-supervised gateway service registered at `/run/hermes-service/gateway-<name>/`, with state-persistent auto-restart across container restarts. See [Multi-profile support](#multi-profile-support) above for the user-facing workflow and the lifecycle commands.
**Supervision benefits over the pre-s6 image:**
@@ -650,7 +650,7 @@ Each profile created with `hermes profile create <name>` automatically gets an s
- `docker restart`, image upgrades (`docker compose up -d --force-recreate`), and unexpected exits preserve running gateways: the cont-init reconciler reads `$HERMES_HOME/profiles/<name>/gateway_state.json` and brings the slot back up if the last recorded state was `running`. Only an explicit `hermes gateway stop` records `stopped` and keeps the gateway down across the restart; the container/s6 SIGTERM sent on a restart or upgrade is treated as "still running" and auto-starts.
- Per-profile gateway logs persist under `$HERMES_HOME/logs/gateways/<profile>/current` (rotated by `s6-log`), and the reconciler's actions are appended to `$HERMES_HOME/logs/container-boot.log` per boot. See [Where the logs go](#where-the-logs-go) for the full routing map.
`hermes status --full` inside the container reports `Manager: s6 (container supervisor)`. Use `/command/s6-svstat /run/service/gateway-<name>` for the raw supervisor view (note `/command/` is on PATH for supervision-tree processes only; pass the absolute path when calling from `docker exec`).
`hermes status --full` inside the container reports `Manager: s6 (container supervisor)`. Use `/command/s6-svstat /run/hermes-service/gateway-<name>` for the raw supervisor view (note `/command/` is on PATH for supervision-tree processes only; pass the absolute path when calling from `docker exec`).
## Upgrading
@@ -1120,7 +1120,7 @@ the host gateway with `gateway.standalone: true` (see
### Docker / Hermes Cloud (s6-supervised container)
Inside the official image every profile has an s6 slot
(`/run/service/gateway-<profile>`). The container's boot registers every *named*
(`/run/hermes-service/gateway-<profile>`). The container's boot registers every *named*
slot down and folds its autostart intent into the root slot, so a fresh boot
already multiplexes. An **in-place** update no longer needs a container restart
to converge either: `hermes gateway migrate --multiplex` (and the hook `hermes
+1 -1
View File
@@ -286,7 +286,7 @@ assistant gateway install # creates hermes-gateway-assistant service
Each profile gets its own service name. They run independently.
:::note Inside the official Docker image
Per-profile gateways are supervised by [s6-overlay](https://github.com/just-containers/s6-overlay) (PID 1 in the container), so `hermes profile create <name>` automatically registers an s6 service slot at `/run/service/gateway-<name>/`. `hermes -p <name> gateway start/stop/restart` dispatches to `s6-svc` instead of spawning a bare process — crashes are auto-restarted and `docker restart` preserves the previously-running set of gateways. See [Per-profile gateway supervision](./docker.md#per-profile-gateway-supervision) for details.
Per-profile gateways are supervised by [s6-overlay](https://github.com/just-containers/s6-overlay) (PID 1 in the container), so `hermes profile create <name>` automatically registers an s6 service slot at `/run/hermes-service/gateway-<name>/`. `hermes -p <name> gateway start/stop/restart` dispatches to `s6-svc` instead of spawning a bare process — crashes are auto-restarted and `docker restart` preserves the previously-running set of gateways. See [Per-profile gateway supervision](./docker.md#per-profile-gateway-supervision) for details.
:::
## Configuring profiles
@@ -55,16 +55,17 @@ If you're just running the Hermes Agent and want to use Docker, see `website/doc
│ │ ├── seed .env / config.yaml / SOUL.md
│ │ └── skills_sync.py
│ └── 02-reconcile-profiles ← hermes_cli.container_boot
│ ├── chown /run/service (hermes-writable for runtime register)
│ ├── mkdir+chown /run/hermes-service (hermes-owned gateway scandir; /run/service stays root)
│ └── walk $HERMES_HOME/profiles/<name>/gateway_state.json
│ → recreate /run/service/gateway-<name>/
│ → recreate /run/hermes-service/gateway-<name>/
│ → auto-start only those with prior_state == "running"
│
├── s6-rc.d (static services, in /etc/s6-overlay/s6-rc.d/)
│ ├── main-hermes/run ← exec sleep infinity (no-op slot)
│ └── dashboard/run ← if HERMES_DASHBOARD=1, runs `hermes dashboard`
│ ├── dashboard/run ← if HERMES_DASHBOARD=1, runs `hermes dashboard`
│ └── profile-gateways/run ← `s6-setuidgid hermes s6-svscan /run/hermes-service`
│
├── /run/service (s6-svscan watches; tmpfs)
├── /run/hermes-service (hermes s6-svscan from s6-rc `profile-gateways` watches; tmpfs)
│ ├── gateway-coder/ ← runtime-registered per-profile
│ │ ├── type ("longrun")
│ │ ├── run ("#!/command/with-contenv sh ... exec s6-setuidgid hermes hermes -p coder gateway run")
@@ -103,7 +104,7 @@ The original plan (v1–v3) called for main hermes to run as a supervised s6-rc
So we use the s6-overlay-native CMD pattern via the dispatcher: `ENTRYPOINT ["/opt/hermes/docker/entrypoint-dispatch.sh"]`, which under PID 1 exec's `/init /opt/hermes/docker/main-wrapper.sh "$@"`. The wrapper is prepended to user args automatically — so `docker run <image> --version` becomes `/init main-wrapper.sh --version`, and `--version` doesn't get intercepted by /init's POSIX shell. The wrapper drops to hermes via `s6-setuidgid`, then exec's the chosen program. The program's exit code becomes the container exit code, exactly matching the pre-s6 tini contract. When the entrypoint is NOT PID 1 (Fly Machines, `docker run --init`), the dispatcher skips `/init` entirely (it would abort with `can only run as pid 1`), restores the s6 helper PATH, runs stage2-hook.sh, and exec's main-wrapper.sh directly — no supervised services on that path (#38349).
Trade-off: main hermes is unsupervised under s6. That exactly matches its behavior under tini (the pre-s6 image). Dashboard supervision is the only **new** guarantee — and per-profile gateways under `/run/service/` get full supervision.
Trade-off: main hermes is unsupervised under s6. That exactly matches its behavior under tini (the pre-s6 image). Dashboard supervision is the only **new** guarantee — and per-profile gateways under `/run/hermes-service/` get full supervision.
## Quick recipes
@@ -118,7 +119,7 @@ docker exec <c> sh -c 'cat /proc/1/comm; readlink /proc/1/exe'
```sh
# /command/ isn't on docker-exec PATH — use absolute path
docker exec <c> /command/s6-svstat /run/service/gateway-<name>
docker exec <c> /command/s6-svstat /run/hermes-service/gateway-<name>
# "up (pid …) … seconds" → running
# "down (exitcode N) … seconds, normally up, want up, …" → s6 wants it up but the process keeps exiting (crash loop)
# "down … normally up, ready …" → user stopped it
@@ -127,9 +128,9 @@ docker exec <c> /command/s6-svstat /run/service/gateway-<name>
### Bring a service up/down manually
```sh
docker exec <c> /command/s6-svc -u /run/service/gateway-<name> # up
docker exec <c> /command/s6-svc -d /run/service/gateway-<name> # down
docker exec <c> /command/s6-svc -t /run/service/gateway-<name> # SIGTERM (restart)
docker exec <c> /command/s6-svc -u /run/hermes-service/gateway-<name> # up
docker exec <c> /command/s6-svc -d /run/hermes-service/gateway-<name> # down
docker exec <c> /command/s6-svc -t /run/hermes-service/gateway-<name> # SIGTERM (restart)
```
### Watch the cont-init reconciler log
@@ -176,7 +176,7 @@ Hermes 支持[多个 profile](../reference/profile-commands.md)——独立的 `
每个通过 `hermes profile create <name>` 创建的 profile 都会获得:
- 一个专用的 s6 服务槽位 `/run/service/gateway-<name>/`,运行时动态注册,无需重建镜像。
- 一个专用的 s6 服务槽位 `/run/hermes-service/gateway-<name>/`,运行时动态注册,无需重建镜像。
- 崩溃后的自动重启,由 `s6-supervise` 管理退避。
- 每个 profile 独立的轮转日志:`${HERMES_HOME}/logs/gateways/<name>/current`。
- 跨容器重启的状态持久化:启动协调器会读取该 profile 的 `gateway_state.json`,仅在上次记录状态为 `running` 时自动拉起。
@@ -309,7 +309,7 @@ PID-1 路径先准备数据卷和配置,重建各 profile 的 gateway 服务
### Per-profile gateway 监管 {#per-profile-gateway-supervision}
在容器内,每个通过 `hermes profile create <name>` 创建的 profile 都会自动在 `/run/service/gateway-<name>/` 注册一个受 s6 监管的 gateway 服务。你在宿主机上运行的生命周期命令在此同样适用:
在容器内,每个通过 `hermes profile create <name>` 创建的 profile 都会自动在 `/run/hermes-service/gateway-<name>/` 注册一个受 s6 监管的 gateway 服务。你在宿主机上运行的生命周期命令在此同样适用:
```sh
hermes profile create coder # 注册 gateway-coder s6 槽
@@ -326,7 +326,7 @@ hermes profile delete coder # 拆除 s6 槽
- `docker restart` 保留运行中的 gateway:cont-init 协调器读取 `$HERMES_HOME/profiles/<name>/gateway_state.json`,若上次记录状态为 `running` 则恢复该槽。已停止的 gateway 保持停止状态。
- 各 profile 的 gateway 日志持久化于 `$HERMES_HOME/logs/gateways/<profile>/current`(由 `s6-log` 轮转),协调器的操作记录在每次启动时追加到 `$HERMES_HOME/logs/container-boot.log`。
在容器内执行 `hermes status` 会显示 `Manager: s6 (container supervisor)`。使用 `/command/s6-svstat /run/service/gateway-<name>` 查看原始 supervisor 状态(注意 `/command/` 仅在监管树进程的 PATH 中;从 `docker exec` 调用时请传入绝对路径)。
在容器内执行 `hermes status` 会显示 `Manager: s6 (container supervisor)`。使用 `/command/s6-svstat /run/hermes-service/gateway-<name>` 查看原始 supervisor 状态(注意 `/command/` 仅在监管树进程的 PATH 中;从 `docker exec` 调用时请传入绝对路径)。
## 升级
@@ -197,7 +197,7 @@ assistant gateway install # 创建 hermes-gateway-assistant 服务
每个 profile 拥有独立的服务名称,各自独立运行。
:::note 在官方 Docker 镜像中
各 profile 的 gateway 由 [s6-overlay](https://github.com/just-containers/s6-overlay)(容器中的 PID 1)监管,因此 `hermes profile create <name>` 会自动在 `/run/service/gateway-<name>/` 注册 s6 服务槽。`hermes -p <name> gateway start/stop/restart` 会调度到 `s6-svc` 而非直接启动裸进程——崩溃后自动重启,`docker restart` 会保留之前运行的 gateway 集合。详见 [各 profile gateway 监管](./docker.md#per-profile-gateway-supervision)。
各 profile 的 gateway 由 [s6-overlay](https://github.com/just-containers/s6-overlay)(容器中的 PID 1)监管,因此 `hermes profile create <name>` 会自动在 `/run/hermes-service/gateway-<name>/` 注册 s6 服务槽。`hermes -p <name> gateway start/stop/restart` 会调度到 `s6-svc` 而非直接启动裸进程——崩溃后自动重启,`docker restart` 会保留之前运行的 gateway 集合。详见 [各 profile gateway 监管](./docker.md#per-profile-gateway-supervision)。
:::
## 配置 profile