chore: import upstream snapshot with attribution
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1 @@
|
||||
You are Hermes Agent, an intelligent AI assistant created by Nous Research. You are helpful, knowledgeable, and direct. You assist users with a wide range of tasks including answering questions, writing and editing code, analyzing information, creative work, and executing actions via your tools. You communicate clearly, admit uncertainty when appropriate, and prioritize being genuinely useful over being verbose unless otherwise directed below. Be targeted and efficient in your exploration and investigations.
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/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).
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# Two fixes, one in each registration path:
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# 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).
|
||||
|
||||
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.
|
||||
SVC_ROOT=/run/s6-rc/servicedirs
|
||||
|
||||
if [ ! -d "$SVC_ROOT" ]; then
|
||||
echo "[supervise-perms] $SVC_ROOT not present; skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
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).
|
||||
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
|
||||
|
||||
# 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
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[supervise-perms] chowned supervise/ trees for static s6-rc services"
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/command/with-contenv sh
|
||||
# shellcheck shell=sh
|
||||
# Container-boot reconciliation of per-profile gateway s6 services.
|
||||
#
|
||||
# Runs as root after 01-hermes-setup (the stage2 hook) has chowned
|
||||
# the volume and seeded $HERMES_HOME, but before s6-rc starts user
|
||||
# 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.
|
||||
#
|
||||
# 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).
|
||||
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
|
||||
|
||||
# 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
|
||||
fi
|
||||
|
||||
# Skip the drop when already non-root.
|
||||
[ "$(id -u)" = 0 ] || exec /opt/hermes/.venv/bin/python -m hermes_cli.container_boot
|
||||
exec s6-setuidgid hermes /opt/hermes/.venv/bin/python -m hermes_cli.container_boot
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
# s6-overlay shim. The real logic lives in docker/stage2-hook.sh, invoked
|
||||
# by /etc/cont-init.d/01-hermes-setup (installed by the Dockerfile). This
|
||||
# file exists so external references to docker/entrypoint.sh still work,
|
||||
# but it's no longer the ENTRYPOINT — /init is.
|
||||
#
|
||||
# When called directly (e.g. by an old wrapper script that hard-coded
|
||||
# docker/entrypoint.sh as the container ENTRYPOINT, or by an external
|
||||
# orchestration script that invokes it inside the container), forward to
|
||||
# the stage2 hook for parity with the pre-s6 entrypoint behavior. The
|
||||
# stage2 hook only handles cont-init bootstrap (UID remap, chown, config
|
||||
# seed, skills sync); it does NOT exec the CMD. Callers that depended
|
||||
# on the pre-s6 contract "entrypoint.sh sets up state then execs hermes"
|
||||
# will see the bootstrap happen but the CMD will not run from this shim.
|
||||
#
|
||||
# Deprecation: this shim is preserved for one release cycle to give
|
||||
# downstream users time to migrate their wrappers to the image's real
|
||||
# ENTRYPOINT (`/init`). It will be removed in a future major release.
|
||||
# Surface a warning to stderr so anyone still invoking this path
|
||||
# sees the migration notice in their logs.
|
||||
echo "[hermes] WARNING: docker/entrypoint.sh is a deprecated shim under " \
|
||||
"s6-overlay. The container's real ENTRYPOINT is /init + " \
|
||||
"main-wrapper.sh; this script only runs the stage2 cont-init hook " \
|
||||
"and does NOT exec the CMD. If you hard-coded docker/entrypoint.sh " \
|
||||
"as your ENTRYPOINT, drop the override — docker will use the image's " \
|
||||
"default ENTRYPOINT (/init), which handles bootstrap AND CMD." >&2
|
||||
exec /opt/hermes/docker/stage2-hook.sh "$@"
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/bin/sh
|
||||
# shellcheck shell=sh
|
||||
# /opt/hermes/bin/hermes — `docker exec` privilege-drop shim.
|
||||
#
|
||||
# Background
|
||||
# ----------
|
||||
# The s6 image runs the supervised gateway/main process as the unprivileged
|
||||
# `hermes` user (UID 10000). When an operator runs `docker exec <c> hermes ...`
|
||||
# the default UID is root (0), and any file the command writes under
|
||||
# $HERMES_HOME — auth.json, .env, config.yaml — ends up root-owned and
|
||||
# unreadable to the supervised gateway. The most common manifestation: the
|
||||
# user runs `docker exec <c> hermes login`, this writes
|
||||
# /opt/data/auth.json as root:root mode 0600, and from then on the gateway
|
||||
# returns "Provider authentication failed: Hermes is not logged into Nous
|
||||
# Portal" on every incoming message — even though `docker exec <c> hermes
|
||||
# chat -q ping` (also running as root) succeeds because root happens to be
|
||||
# able to read its own root-owned file. See systematic-debugging skill
|
||||
# notes attached to this fix.
|
||||
#
|
||||
# Fix
|
||||
# ---
|
||||
# This shim sits at /opt/hermes/bin/hermes and is placed earliest on PATH.
|
||||
# When invoked as root, it drops to the hermes user (via s6-setuidgid)
|
||||
# before exec'ing the real venv binary, so anything that writes under
|
||||
# $HERMES_HOME is uid-aligned with the supervised processes. When invoked
|
||||
# as any non-root UID — including the supervised processes themselves,
|
||||
# `docker exec --user hermes`, kanban subagents, etc. — it short-circuits
|
||||
# straight to the venv binary with no privilege change. Net: one extra
|
||||
# fork on the docker-exec-as-root path, zero behavioral change on every
|
||||
# other path.
|
||||
#
|
||||
# Recursion safety: the shim exec's the venv binary by *absolute path*
|
||||
# (/opt/hermes/.venv/bin/hermes), so the second hop cannot re-enter this
|
||||
# shim regardless of PATH state. No sentinel env var needed.
|
||||
#
|
||||
# Opt-out: set HERMES_DOCKER_EXEC_AS_ROOT=1 (1/true/yes, case-insensitive)
|
||||
# to keep running as root. Reserved for diagnostic sessions where the
|
||||
# operator deliberately wants root semantics — e.g. inspecting root-only
|
||||
# state via the hermes CLI. Default is to drop.
|
||||
|
||||
set -e
|
||||
|
||||
REAL=/opt/hermes/.venv/bin/hermes
|
||||
|
||||
# Defensive: if the venv binary is missing (corrupted image, partial
|
||||
# install), fail loudly rather than silently masking it.
|
||||
if [ ! -x "$REAL" ]; then
|
||||
echo "hermes-shim: $REAL not found or not executable" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
# Already non-root? Just exec the real binary. This is the hot path for
|
||||
# supervised processes (uid 10000) and for `docker exec --user hermes`.
|
||||
if [ "$(id -u)" != "0" ]; then
|
||||
exec "$REAL" "$@"
|
||||
fi
|
||||
|
||||
# Root, with opt-out set? Honor it.
|
||||
case "${HERMES_DOCKER_EXEC_AS_ROOT:-}" in
|
||||
1|true|TRUE|True|yes|YES|Yes)
|
||||
exec "$REAL" "$@"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Root, no opt-out. Drop to the hermes user.
|
||||
#
|
||||
# s6-setuidgid lives under /command/ which is NOT on `docker exec`'s PATH
|
||||
# (s6-overlay only puts /command/ on PATH for supervision-tree children).
|
||||
# Reference it by absolute path so the drop is robust against PATH
|
||||
# manipulation.
|
||||
S6_SUID=/command/s6-setuidgid
|
||||
if [ ! -x "$S6_SUID" ]; then
|
||||
# Non-s6 image (someone stripped s6-overlay, or a hand-built variant).
|
||||
# Fail loud rather than silently re-execing as root and leaking the
|
||||
# bug this shim exists to prevent.
|
||||
echo "hermes-shim: $S6_SUID not found; refusing to silently run as root." >&2
|
||||
echo "hermes-shim: re-run with --user hermes or set HERMES_DOCKER_EXEC_AS_ROOT=1." >&2
|
||||
exit 126
|
||||
fi
|
||||
|
||||
# Reset HOME to the hermes user's home before dropping privileges. Without
|
||||
# this, $HOME stays /root and any library that resolves paths off $HOME
|
||||
# (XDG caches, lockfiles, .config writes) will try to write to /root and
|
||||
# fail with EACCES. Mirrors main-wrapper.sh.
|
||||
export HOME=/opt/data
|
||||
|
||||
exec "$S6_SUID" hermes "$REAL" "$@"
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/command/with-contenv sh
|
||||
# shellcheck shell=sh
|
||||
# /opt/hermes/docker/main-wrapper.sh — wraps the container's CMD with
|
||||
# the same argument-routing logic the pre-s6 entrypoint.sh used. Runs
|
||||
# as /init's "main program" (Docker CMD) so it inherits stdin/stdout/
|
||||
# stderr from the container.
|
||||
#
|
||||
# Shebang note: /init scrubs env before invoking CMD, so a plain
|
||||
# `#!/bin/sh` wrapper sees an empty environ and `ENV HERMES_HOME=/opt/data`
|
||||
# from the Dockerfile never reaches `hermes`. with-contenv repopulates
|
||||
# the env from /run/s6/container_environment before exec'ing, which is
|
||||
# what s6-supervised services use too (see main-hermes/run).
|
||||
#
|
||||
# Routing:
|
||||
# no args → exec `hermes` (the default)
|
||||
# first arg is an executable → exec it directly (sleep, bash, sh, …)
|
||||
# first arg is anything else → exec `hermes <args>` (subcommand passthrough)
|
||||
#
|
||||
# Drop to hermes via s6-setuidgid, but skip it when already non-root.
|
||||
set -e
|
||||
|
||||
drop() { [ "$(id -u)" = 0 ] && set -- s6-setuidgid hermes "$@"; exec "$@"; }
|
||||
|
||||
# --- Reject the unsupported `docker run --user <uid>:<gid>` start ---
|
||||
# Mirror the guard in stage2-hook.sh (cont-init). This is the surface the
|
||||
# user actually sees in `docker run` output: when the container is pinned to
|
||||
# an arbitrary non-root, non-hermes UID, the bootstrap was skipped and the
|
||||
# baked image dirs (owned by the hermes build UID) are unwritable, so fail
|
||||
# fast here with actionable guidance rather than crashing on `cd`/EACCES
|
||||
# further down. See stage2-hook.sh for the full rationale.
|
||||
cur_uid="$(id -u)"
|
||||
if [ "$cur_uid" != 0 ] && [ "$cur_uid" != "$(id -u hermes)" ]; then
|
||||
cat >&2 <<EOF
|
||||
[hermes] ERROR: container started with --user $cur_uid (an arbitrary, non-hermes UID) — not supported.
|
||||
|
||||
To make container-written files match your HOST user, don't use --user.
|
||||
Start as root (the default) and pass your host UID/GID instead:
|
||||
|
||||
docker run -e HERMES_UID=\$(id -u) -e HERMES_GID=\$(id -g) ...
|
||||
|
||||
NAS users (Synology / unRAID / UGOS) can use the PUID/PGID aliases:
|
||||
|
||||
docker run -e PUID=\$(id -u) -e PGID=\$(id -g) ...
|
||||
|
||||
The image remaps the hermes user to that UID/GID at boot and chowns the data
|
||||
volume, so files land owned by your host user — the same outcome --user gave,
|
||||
without breaking the s6 supervision tree.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# HOME comes through with-contenv as /root (the /init context). Override
|
||||
# to the hermes user's home before dropping privileges so libraries that
|
||||
# resolve paths via $HOME (e.g. discord lockfile under XDG_STATE_HOME)
|
||||
# don't try to write to /root.
|
||||
export HOME=/opt/data
|
||||
|
||||
# Save the Docker -w (or default) working directory before init
|
||||
# scripts cd to /opt/data, so the container starts in the
|
||||
# directory the user requested.
|
||||
_hermes_orig_cwd="${HERMES_ORIG_CWD:-$PWD}"
|
||||
|
||||
cd /opt/data
|
||||
# shellcheck disable=SC1091
|
||||
. /opt/hermes/.venv/bin/activate
|
||||
|
||||
# Restore the original working directory before handing off to
|
||||
# the user's command so `hermes chat` starts in the Docker -w
|
||||
# directory, not /opt/data.
|
||||
cd "$_hermes_orig_cwd"
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
drop hermes
|
||||
fi
|
||||
|
||||
if command -v "$1" >/dev/null 2>&1; then
|
||||
# Bare executable — pass through directly.
|
||||
drop "$@"
|
||||
fi
|
||||
|
||||
# Hermes subcommand pass-through.
|
||||
drop hermes "$@"
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/command/with-contenv sh
|
||||
# shellcheck shell=sh
|
||||
# Dashboard finish script. Companion to ./run.
|
||||
#
|
||||
# When HERMES_DASHBOARD is unset (or falsy), ./run exits 0 immediately.
|
||||
# Without this finish script, s6-supervise would just restart the run
|
||||
# script in a tight loop. By exiting 125 here, we tell s6-supervise
|
||||
# "this service has permanently failed; do not restart" — equivalent
|
||||
# to `s6-svc -O`. The supervise slot reports as down, matching reality
|
||||
# (no dashboard process is running).
|
||||
#
|
||||
# When HERMES_DASHBOARD IS enabled and the run script later exits or
|
||||
# is killed, we want s6-supervise to restart it (the whole point of
|
||||
# supervised lifecycle). So we exit non-125 in that case.
|
||||
|
||||
# Arguments passed to a finish script: $1=run-exit-code, $2=signal-num,
|
||||
# $3=service-dir-name, $4=run-pgid. See servicedir(7).
|
||||
|
||||
case "${HERMES_DASHBOARD:-}" in
|
||||
1|true|TRUE|True|yes|YES|Yes)
|
||||
# Dashboard was enabled — let s6-supervise restart on crash by
|
||||
# exiting non-125. (Pass-through any sensible default.)
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
# Dashboard disabled — permanent-failure marker so s6-supervise
|
||||
# leaves the slot in 'down' state and s6-svstat reflects that.
|
||||
exit 125
|
||||
;;
|
||||
esac
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/command/with-contenv sh
|
||||
# shellcheck shell=sh
|
||||
# Dashboard service. Always declared so s6 has a supervised slot; if
|
||||
# HERMES_DASHBOARD isn't truthy the run script exits cleanly and the
|
||||
# companion finish script returns 125 (s6's "permanent failure, do
|
||||
# not restart" marker), so s6-svstat reports the slot as down. See
|
||||
# also docker/s6-rc.d/dashboard/finish.
|
||||
|
||||
case "${HERMES_DASHBOARD:-}" in
|
||||
1|true|TRUE|True|yes|YES|Yes) ;;
|
||||
*)
|
||||
# Exit 0; the finish script will exit 125 → s6-supervise won't
|
||||
# restart us and the slot reports down. Using a clean exit
|
||||
# (rather than `exec sleep infinity`) means s6-svstat reflects
|
||||
# reality: when HERMES_DASHBOARD is unset, the service is NOT
|
||||
# running, just supervised-with-permanent-failure. See PR
|
||||
# #30136 review item I3.
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# with-contenv repopulates HOME from /init as /root. Reset it before
|
||||
# dropping privileges so HOME-anchored state lands under /opt/data.
|
||||
export HOME=/opt/data
|
||||
|
||||
cd /opt/data
|
||||
# shellcheck disable=SC1091
|
||||
. /opt/hermes/.venv/bin/activate
|
||||
|
||||
dash_host="${HERMES_DASHBOARD_HOST:-0.0.0.0}"
|
||||
dash_port="${HERMES_DASHBOARD_PORT:-9119}"
|
||||
|
||||
# The dashboard's auth gate engages automatically on non-loopback binds and
|
||||
# REQUIRES a DashboardAuthProvider to be registered, else start_server fails
|
||||
# closed. Two zero-infra ways to satisfy it in a container:
|
||||
# • Password: set HERMES_DASHBOARD_BASIC_AUTH_USERNAME + _PASSWORD (bundled
|
||||
# dashboard_auth/basic provider — no external IDP).
|
||||
# • OAuth: set HERMES_DASHBOARD_OAUTH_CLIENT_ID (bundled nous provider).
|
||||
#
|
||||
# HERMES_DASHBOARD_INSECURE no longer disables the gate (June 2026 hardening:
|
||||
# unauthenticated public dashboards were the entry point for the MCP-config
|
||||
# persistence campaign). It is accepted but ignored; warn if set so operators
|
||||
# migrate to a real provider.
|
||||
case "${HERMES_DASHBOARD_INSECURE:-}" in
|
||||
1|true|TRUE|True|yes|YES|Yes)
|
||||
echo "[dashboard] HERMES_DASHBOARD_INSECURE no longer disables the auth gate." >&2
|
||||
echo "[dashboard] A non-loopback dashboard requires an auth provider:" >&2
|
||||
echo "[dashboard] set HERMES_DASHBOARD_BASIC_AUTH_USERNAME + _PASSWORD (password)" >&2
|
||||
echo "[dashboard] or HERMES_DASHBOARD_OAUTH_CLIENT_ID (OAuth)." >&2
|
||||
;;
|
||||
esac
|
||||
|
||||
# Skip the drop when already non-root.
|
||||
[ "$(id -u)" = 0 ] || exec hermes dashboard --host "$dash_host" --port "$dash_port" --no-open
|
||||
exec s6-setuidgid hermes hermes dashboard \
|
||||
--host "$dash_host" --port "$dash_port" --no-open
|
||||
@@ -0,0 +1 @@
|
||||
longrun
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/command/with-contenv sh
|
||||
# shellcheck shell=sh
|
||||
# Main hermes service.
|
||||
#
|
||||
# IMPORTANT — this is NOT how the user's CMD runs.
|
||||
#
|
||||
# We chose Architecture B from the plan: the container's CMD (the bare
|
||||
# command the user passes to `docker run <image> …`) runs as /init's
|
||||
# "main program" via Docker's CMD mechanism, NOT as an s6-supervised
|
||||
# service. This is the canonical s6-overlay pattern for "container
|
||||
# exits when the program exits" semantics, and it lets us preserve
|
||||
# every pre-s6 invocation contract (chat passthrough, sleep infinity,
|
||||
# bash, --tui) without re-implementing argument routing through
|
||||
# /run/s6/container_environment.
|
||||
#
|
||||
# So why does this service exist at all? Two reasons:
|
||||
# 1. s6-rc requires at least one user service for the "user" bundle
|
||||
# to be valid. We can't ship an empty bundle.
|
||||
# 2. Future work may want to supervise a long-lived hermes process
|
||||
# (e.g. for gateway-server containers); having the slot already
|
||||
# wired in keeps that change small.
|
||||
#
|
||||
# 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).
|
||||
exec sleep infinity
|
||||
@@ -0,0 +1 @@
|
||||
longrun
|
||||
Executable
+566
@@ -0,0 +1,566 @@
|
||||
#!/bin/sh
|
||||
# s6-overlay stage2 hook — runs as root after the supervision tree is
|
||||
# up but before user services start. Handles UID/GID remap, volume
|
||||
# chown, config seeding, and skills sync.
|
||||
#
|
||||
# Per-service privilege drop happens inside each service's `run` script
|
||||
# (and in main-wrapper.sh) via s6-setuidgid, not here.
|
||||
#
|
||||
# Wired into the image as /etc/cont-init.d/01-hermes-setup by the
|
||||
# Dockerfile. The shim at docker/entrypoint.sh forwards to this script
|
||||
# so external references to docker/entrypoint.sh still work.
|
||||
#
|
||||
# NB: cont-init.d scripts run with no arguments — the user's CMD args
|
||||
# are NOT visible here. That's fine: we use Architecture B (s6-overlay
|
||||
# main-program model), so main-wrapper.sh runs the CMD with full
|
||||
# stdin/stdout/stderr access and handles arg parsing there.
|
||||
|
||||
set -eu
|
||||
|
||||
HERMES_HOME="${HERMES_HOME:-/opt/data}"
|
||||
INSTALL_DIR="/opt/hermes"
|
||||
|
||||
# Drop to hermes via s6-setuidgid, but skip it when already non-root.
|
||||
as_hermes() { [ "$(id -u)" = 0 ] || { "$@"; return; }; s6-setuidgid hermes "$@"; }
|
||||
|
||||
# --- Reject the unsupported `docker run --user <uid>:<gid>` start ---
|
||||
# Detect the case where the container was launched with `--user` pinned to an
|
||||
# arbitrary host UID (the classic `--user $(id -u):$(id -g)` invocation people
|
||||
# used in the tini era to make container-written files match their host user).
|
||||
#
|
||||
# Under s6-overlay this no longer works: the bootstrap (UID remap, data-volume
|
||||
# ownership, config seeding) requires root, and it is skipped when the container
|
||||
# starts non-root. The baked install tree under /opt/hermes is intentionally
|
||||
# root-owned and non-writable; mutable runtime state must live under
|
||||
# $HERMES_HOME. An arbitrary `--user` UID therefore cannot repair or populate
|
||||
# the data volume, and startup fails with EACCES. See #34837 for the
|
||||
# supervision-tree side of this.
|
||||
#
|
||||
# The supported way to match host-side ownership is to start as root (the image
|
||||
# default) and pass HERMES_UID/HERMES_GID — or the PUID/PGID aliases — which the
|
||||
# remap block below consumes via usermod/groupmod + targeted chown. That gives
|
||||
# the exact same outcome (files owned by your host UID) without breaking s6.
|
||||
#
|
||||
# preinit runs setuid-root (euid=0) but cont-init.d hooks run with the real UID
|
||||
# the container was started as, so `id -u` here is the host UID (e.g. 1000), and
|
||||
# `id -u hermes` is the unremapped build UID (10000) because no root-only remap
|
||||
# could run. root starts (id -u = 0) and the normal supervised drop to the
|
||||
# hermes UID are both unaffected.
|
||||
cur_uid="$(id -u)"
|
||||
if [ "$cur_uid" != 0 ] && [ "$cur_uid" != "$(id -u hermes)" ]; then
|
||||
cat >&2 <<EOF
|
||||
[stage2] ERROR: container started with --user $cur_uid (an arbitrary, non-hermes UID).
|
||||
|
||||
This is not supported under the s6-overlay image. The container bootstrap
|
||||
(UID remap, data-volume ownership, config seeding) needs to start as root,
|
||||
and the baked /opt/hermes install tree is intentionally root-owned and
|
||||
non-writable, so a pinned --user UID cannot repair startup state — startup
|
||||
will fail.
|
||||
|
||||
To make container-written files match your HOST user, DON'T use --user.
|
||||
Start the container as root (the default) and pass your host UID/GID instead:
|
||||
|
||||
docker run -e HERMES_UID=\$(id -u) -e HERMES_GID=\$(id -g) ...
|
||||
|
||||
NAS users (Synology / unRAID / UGOS) can use the PUID/PGID aliases:
|
||||
|
||||
docker run -e PUID=\$(id -u) -e PGID=\$(id -g) ...
|
||||
|
||||
The image remaps the hermes user to that UID/GID at boot and chowns the data
|
||||
volume accordingly, so files land owned by your host user — the same outcome
|
||||
--user was being used for, without breaking the supervision tree.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Bootstrap HERMES_HOME as root ---
|
||||
# Create the directory (and any missing parents) while we still have root
|
||||
# privileges so the chown checks below see real metadata and the later
|
||||
# `s6-setuidgid hermes mkdir -p` block doesn't EACCES on root-owned
|
||||
# ancestors. Without this, custom HERMES_HOME paths whose parents only
|
||||
# root can create (e.g. `HERMES_HOME=/home/hermes/.hermes` in a Compose
|
||||
# file, or any path under a fresh / not pre-populated by the image)
|
||||
# fail on first boot with `mkdir: cannot create directory '/...': Permission
|
||||
# denied` and the cont-init hook exits non-zero. Idempotent — `mkdir -p`
|
||||
# is a no-op if the dir already exists. (#18482, salvages #18488)
|
||||
mkdir -p "$HERMES_HOME"
|
||||
|
||||
# Numeric UID/GID validation: must be digits only, non-root, 1-65534.
|
||||
# NAS hosts such as Unraid commonly use low non-root IDs (99:100).
|
||||
validate_uid_gid() {
|
||||
case "$1" in
|
||||
''|*[!0-9]*) return 1 ;;
|
||||
*) [ "$1" -ge 1 ] && [ "$1" -le 65534 ] ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# --- UID/GID remap ---
|
||||
# Accept PUID/PGID as aliases for HERMES_UID/HERMES_GID. NAS users (UGOS,
|
||||
# Synology, unRAID) expect the LinuxServer.io PUID/PGID convention and
|
||||
# bind-mount /opt/data from a host directory owned by their own UID; without
|
||||
# this alias those vars are silently ignored and the s6-setuidgid drop to
|
||||
# UID 10000 leaves the runtime unable to read the volume. HERMES_UID/
|
||||
# HERMES_GID still win when both are set. See #15290, salvages #25872.
|
||||
HERMES_UID="${HERMES_UID:-${PUID:-}}"
|
||||
HERMES_GID="${HERMES_GID:-${PGID:-}}"
|
||||
|
||||
if [ -n "${HERMES_UID:-}" ] && validate_uid_gid "$HERMES_UID" && [ "$HERMES_UID" != "$(id -u hermes)" ]; then
|
||||
echo "[stage2] Changing hermes UID to $HERMES_UID"
|
||||
usermod -u "$HERMES_UID" hermes
|
||||
fi
|
||||
if [ -n "${HERMES_GID:-}" ] && validate_uid_gid "$HERMES_GID" && [ "$HERMES_GID" != "$(id -g hermes)" ]; then
|
||||
echo "[stage2] Changing hermes GID to $HERMES_GID"
|
||||
# -o allows non-unique GID (e.g. macOS GID 20 "staff" may already
|
||||
# exist as "dialout" in the Debian-based container image).
|
||||
groupmod -o -g "$HERMES_GID" hermes 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# --- Docker socket group membership (docker-in-docker / DooD) ---
|
||||
# When the user bind-mounts the host Docker daemon socket
|
||||
# (`-v /var/run/docker.sock:/var/run/docker.sock`) to use the `docker`
|
||||
# terminal backend from inside the container, the socket is owned by the
|
||||
# host's `docker` group (or root). The supervised hermes user (UID 10000)
|
||||
# is not a member of any group that matches the socket's GID, so every
|
||||
# `docker` invocation EACCES'es and `check_terminal_requirements()` fails.
|
||||
# See #16703.
|
||||
#
|
||||
# Granting the supp group via `docker run --group-add <gid>` alone is
|
||||
# NOT sufficient with our s6-setuidgid privilege drop: s6-setuidgid (and
|
||||
# gosu, the older shim) calls initgroups() for the target user, which
|
||||
# rebuilds the supplementary group list from /etc/group. Without an
|
||||
# /etc/group entry whose GID matches the socket, the kernel-granted
|
||||
# supp group is silently wiped between PID 1 and the dropped process.
|
||||
# Confirmed empirically: `--group-add 998` alone leaves the dropped
|
||||
# hermes process with `Groups: 10000` (998 gone); after this hook adds
|
||||
# the entry, the dropped process has `Groups: 998 10000` as expected.
|
||||
#
|
||||
# Fix: detect the socket's GID at boot and ensure /etc/group has a
|
||||
# matching entry that includes hermes. Idempotent across container
|
||||
# restarts. Skipped silently when no socket is bind-mounted.
|
||||
#
|
||||
# Handles the awkward corner cases:
|
||||
# - socket owned by GID 0 (root) — some Podman setups; usermod -aG root
|
||||
# - socket GID already used by a known container group (e.g. tty=5):
|
||||
# reuse that group's name rather than creating a duplicate
|
||||
# - hermes is already a member of the right group (idempotent restart)
|
||||
# - chown/groupadd failures under rootless containers — non-fatal
|
||||
for sock in /var/run/docker.sock /run/docker.sock; do
|
||||
[ -S "$sock" ] || continue
|
||||
sock_gid=$(stat -c '%g' "$sock" 2>/dev/null) || continue
|
||||
[ -n "$sock_gid" ] || continue
|
||||
# Already a member? Nothing to do.
|
||||
if id -G hermes 2>/dev/null | tr ' ' '\n' | grep -qx "$sock_gid"; then
|
||||
echo "[stage2] hermes already in group $sock_gid for $sock"
|
||||
break
|
||||
fi
|
||||
# Resolve or create a group name for this GID.
|
||||
sock_group=$(getent group "$sock_gid" 2>/dev/null | cut -d: -f1)
|
||||
if [ -z "$sock_group" ]; then
|
||||
sock_group="hostdocker"
|
||||
if ! groupadd -g "$sock_gid" "$sock_group" 2>/dev/null; then
|
||||
echo "[stage2] Warning: groupadd -g $sock_gid $sock_group failed; skipping docker socket group setup"
|
||||
break
|
||||
fi
|
||||
echo "[stage2] Created group $sock_group (GID $sock_gid) for Docker socket"
|
||||
fi
|
||||
if usermod -aG "$sock_group" hermes 2>/dev/null; then
|
||||
echo "[stage2] Added hermes to group $sock_group (GID $sock_gid) for $sock"
|
||||
else
|
||||
echo "[stage2] Warning: usermod -aG $sock_group hermes failed; docker backend may fail with EACCES"
|
||||
fi
|
||||
break
|
||||
done
|
||||
|
||||
# --- Fix ownership of data volume ---
|
||||
# When HERMES_UID is remapped or the top-level $HERMES_HOME isn't owned by
|
||||
# the runtime hermes UID, restore ownership to hermes — but ONLY for the
|
||||
# directories hermes actually writes to. The full $HERMES_HOME may be a
|
||||
# host-mounted bind containing unrelated user files; `chown -R` would
|
||||
# silently destroy host ownership of those (see issue #19788).
|
||||
#
|
||||
# The canonical list of hermes-owned subdirs is the same one the s6-setuidgid
|
||||
# mkdir -p block below seeds. Keep them in sync if the seed list changes.
|
||||
actual_hermes_uid=$(id -u hermes)
|
||||
|
||||
path_has_symlink_component() {
|
||||
path="$1"
|
||||
root="${2:-$HERMES_HOME}"
|
||||
while [ -n "$path" ] && [ "$path" != "/" ]; do
|
||||
if [ -L "$path" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$path" = "$root" ]; then
|
||||
break
|
||||
fi
|
||||
parent="$(dirname "$path")"
|
||||
if [ "$parent" = "$path" ]; then
|
||||
break
|
||||
fi
|
||||
path="$parent"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
refuse_symlinked_path() {
|
||||
action="$1"
|
||||
target="$2"
|
||||
if path_has_symlink_component "$target"; then
|
||||
echo "[stage2] Warning: refusing $action through symlinked path $target — continuing"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
chown_hermes_tree() {
|
||||
target="$1"
|
||||
if refuse_symlinked_path "recursive chown" "$target"; then
|
||||
return 0
|
||||
fi
|
||||
chown -R hermes:hermes "$target" 2>/dev/null || \
|
||||
echo "[stage2] Warning: chown $target failed (rootless container?) — continuing"
|
||||
}
|
||||
|
||||
needs_chown=false
|
||||
if [ "$(stat -c %u "$HERMES_HOME" 2>/dev/null)" != "$actual_hermes_uid" ]; then
|
||||
needs_chown=true
|
||||
fi
|
||||
if [ "$needs_chown" = true ]; then
|
||||
echo "[stage2] Fixing ownership of $HERMES_HOME (targeted) to hermes ($actual_hermes_uid)"
|
||||
# In rootless Podman the container's "root" is mapped to an
|
||||
# unprivileged host UID — chown will fail. That's fine: the volume
|
||||
# is already owned by the mapped user on the host side.
|
||||
#
|
||||
# Top-level $HERMES_HOME: chown the directory itself (not its contents)
|
||||
# so hermes can mkdir new subdirs but bind-mounted host files keep
|
||||
# their existing ownership.
|
||||
if refuse_symlinked_path "chown" "$HERMES_HOME"; then
|
||||
:
|
||||
else
|
||||
chown hermes:hermes "$HERMES_HOME" 2>/dev/null || \
|
||||
echo "[stage2] Warning: chown $HERMES_HOME failed (rootless container?) — continuing"
|
||||
fi
|
||||
# Hermes-owned subdirs: recursive chown is safe here because these are
|
||||
# created and managed exclusively by hermes (see the s6-setuidgid mkdir
|
||||
# -p block below for the canonical list).
|
||||
for sub in cron sessions logs hooks memories skills skins plans workspace home profiles pairing platforms/pairing lazy-packages; do
|
||||
if [ -e "$HERMES_HOME/$sub" ]; then
|
||||
chown_hermes_tree "$HERMES_HOME/$sub"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# --- Immutable install tree ---
|
||||
# Do not chown runtime code or dependency trees under $INSTALL_DIR back to the
|
||||
# hermes user. Hosted/container instances keep mutable state under
|
||||
# $HERMES_HOME (/opt/data) and run with PYTHONDONTWRITEBYTECODE plus
|
||||
# HERMES_DISABLE_LAZY_INSTALLS=1. Keeping /opt/hermes root-owned and
|
||||
# non-writable prevents an agent session from self-modifying the installed
|
||||
# source, venv, TUI bundle, or node_modules and bricking the gateway.
|
||||
#
|
||||
# Lazy-installable optional backends (Firecrawl, Exa, Feishu, etc.) cannot
|
||||
# install into the sealed venv, so they are redirected to the writable
|
||||
# $HERMES_HOME/lazy-packages dir on the data volume (Dockerfile sets
|
||||
# HERMES_LAZY_INSTALL_TARGET). That dir is appended to the END of sys.path,
|
||||
# so a package installed there can only ADD modules — it can never shadow or
|
||||
# break a core module, which is what keeps the sealed-venv guarantee intact
|
||||
# even though installs are re-enabled. The dir is seeded + chowned to hermes
|
||||
# in the mkdir/chown blocks above so first-use installs succeed as the
|
||||
# unprivileged runtime user, and it persists across container recreates /
|
||||
# image updates (an ABI stamp wipes it if a rebuild bumps the interpreter).
|
||||
|
||||
# Always reset ownership of $HERMES_HOME/profiles to hermes on every
|
||||
# boot. Profile dirs and files can land owned by root when commands
|
||||
# are invoked via `docker exec <container> hermes …` (which defaults
|
||||
# to root unless `-u` is passed), and that breaks the cont-init
|
||||
# reconciler (02-reconcile-profiles) which runs as hermes and walks
|
||||
# the profiles dir. Idempotent; skipped on rootless containers where
|
||||
# chown would fail.
|
||||
if [ -d "$HERMES_HOME/profiles" ]; then
|
||||
chown_hermes_tree "$HERMES_HOME/profiles"
|
||||
fi
|
||||
|
||||
# Always reset ownership of $HERMES_HOME/cron on every boot for the same
|
||||
# docker-exec/root-write reason as profiles/. The cron scheduler state
|
||||
# (jobs.json) must stay readable by the unprivileged hermes runtime even
|
||||
# after root-context maintenance commands or scheduler writes.
|
||||
if [ -d "$HERMES_HOME/cron" ]; then
|
||||
chown_hermes_tree "$HERMES_HOME/cron"
|
||||
fi
|
||||
|
||||
# Always reset ownership of pairing data on every boot, same docker-exec/
|
||||
# root-write reason as profiles/ and cron/. `docker exec <container>
|
||||
# hermes pairing approve …` defaults to uid=0 and writes 0600 root-owned
|
||||
# approval files that the unprivileged hermes gateway cannot read,
|
||||
# silently leaving the approved user unauthorized (#10270). The targeted
|
||||
# data-volume chown above only runs when the top-level $HERMES_HOME is
|
||||
# mis-owned, so warm boots skip it — this block makes a container restart
|
||||
# self-heal. Tiny directory (a handful of small JSON files), so the cost
|
||||
# is negligible.
|
||||
if [ -d "$HERMES_HOME/platforms/pairing" ]; then
|
||||
chown_hermes_tree "$HERMES_HOME/platforms/pairing"
|
||||
fi
|
||||
# Legacy location (pre-consolidated layout).
|
||||
if [ -d "$HERMES_HOME/pairing" ]; then
|
||||
chown_hermes_tree "$HERMES_HOME/pairing"
|
||||
fi
|
||||
|
||||
# Reset ownership of hermes-owned top-level state files on every boot.
|
||||
# The targeted data-volume chown above only covers hermes-owned
|
||||
# *subdirectories*; loose state files living directly under $HERMES_HOME
|
||||
# are missed. When those files are created or rewritten by
|
||||
# `docker exec <container> hermes …` (root unless `-u` is passed) they
|
||||
# land root-owned, and the unprivileged hermes runtime then hits
|
||||
# PermissionError on next startup (e.g. gateway.lock / state.db /
|
||||
# auth.json), producing a gateway restart loop.
|
||||
#
|
||||
# We use an explicit allowlist rather than a blanket `find -user root`
|
||||
# sweep so host-owned files in a bind-mounted $HERMES_HOME are never
|
||||
# touched — same targeted-ownership contract as the subdir chown above
|
||||
# (issue #19788, PR #19795). The list mirrors the top-level *file*
|
||||
# entries of hermes_cli.profile_distribution.USER_OWNED_EXCLUDE plus the
|
||||
# runtime lock files; keep them in sync if that set changes.
|
||||
for f in \
|
||||
auth.json auth.lock .env \
|
||||
state.db state.db-shm state.db-wal \
|
||||
hermes_state.db \
|
||||
response_store.db response_store.db-shm response_store.db-wal \
|
||||
gateway.pid gateway.lock gateway_state.json processes.json \
|
||||
active_profile; do
|
||||
if [ -e "$HERMES_HOME/$f" ]; then
|
||||
if refuse_symlinked_path "chown" "$HERMES_HOME/$f"; then
|
||||
:
|
||||
else
|
||||
chown hermes:hermes "$HERMES_HOME/$f" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# --- config.yaml permissions ---
|
||||
# Ensure config.yaml is readable by the hermes runtime user even if it
|
||||
# was edited on the host after initial ownership setup.
|
||||
if [ -f "$HERMES_HOME/config.yaml" ]; then
|
||||
if refuse_symlinked_path "chown/chmod" "$HERMES_HOME/config.yaml"; then
|
||||
:
|
||||
else
|
||||
chown hermes:hermes "$HERMES_HOME/config.yaml" 2>/dev/null || true
|
||||
chmod 640 "$HERMES_HOME/config.yaml" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Seed directory structure as hermes user ---
|
||||
# Run as hermes via s6-setuidgid so dirs end up owned correctly (matters
|
||||
# under rootless Podman where chown back to root would fail).
|
||||
#
|
||||
# Use direct `mkdir -p` invocation (no `sh -c "..."` wrapper) so the
|
||||
# shell isn't a second interpreter — defends against $HERMES_HOME values
|
||||
# containing shell metacharacters. PR #30136 review item O2.
|
||||
as_hermes mkdir -p \
|
||||
"$HERMES_HOME/backups" \
|
||||
"$HERMES_HOME/cron" \
|
||||
"$HERMES_HOME/sessions" \
|
||||
"$HERMES_HOME/logs" \
|
||||
"$HERMES_HOME/logs/gateways" \
|
||||
"$HERMES_HOME/hooks" \
|
||||
"$HERMES_HOME/memories" \
|
||||
"$HERMES_HOME/skills" \
|
||||
"$HERMES_HOME/skins" \
|
||||
"$HERMES_HOME/plans" \
|
||||
"$HERMES_HOME/workspace" \
|
||||
"$HERMES_HOME/home" \
|
||||
"$HERMES_HOME/pairing" \
|
||||
"$HERMES_HOME/platforms/pairing" \
|
||||
"$HERMES_HOME/lazy-packages"
|
||||
|
||||
# --- Install-method stamp ---
|
||||
# The 'docker' stamp is baked into the immutable install tree at
|
||||
# /opt/hermes/.install_method (see Dockerfile), NOT written here into
|
||||
# $HERMES_HOME. detect_install_method() reads the code-scoped stamp first.
|
||||
#
|
||||
# Why we no longer stamp $HERMES_HOME: it is a shared DATA volume, commonly
|
||||
# bind-mounted from the host (~/.hermes:/opt/data) and sometimes shared with a
|
||||
# host-side Desktop/CLI install. Stamping 'docker' here clobbered that host
|
||||
# install's marker, so its in-app updater read 'docker' and refused to run
|
||||
# 'hermes update'. To heal homes already poisoned by older images, remove a
|
||||
# stale 'docker' stamp from $HERMES_HOME if one is present (the host install's
|
||||
# own installer re-creates its code-scoped stamp; a genuine container relies on
|
||||
# the baked /opt/hermes stamp, so deleting the data-dir copy is safe).
|
||||
if [ -f "$HERMES_HOME/.install_method" ]; then
|
||||
stamped="$(tr -d '[:space:]' < "$HERMES_HOME/.install_method" 2>/dev/null || true)"
|
||||
if [ "$stamped" = "docker" ]; then
|
||||
rm -f "$HERMES_HOME/.install_method" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Seed config files (only on first boot) ---
|
||||
seed_one() {
|
||||
dest=$1
|
||||
src=$2
|
||||
if [ ! -f "$HERMES_HOME/$dest" ] && [ -f "$INSTALL_DIR/$src" ]; then
|
||||
if refuse_symlinked_path "seed" "$HERMES_HOME/$dest"; then
|
||||
:
|
||||
else
|
||||
as_hermes cp "$INSTALL_DIR/$src" "$HERMES_HOME/$dest"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
seed_one ".env" ".env.example"
|
||||
seed_one "config.yaml" "cli-config.yaml.example"
|
||||
seed_one "SOUL.md" "docker/SOUL.md"
|
||||
|
||||
# .env holds API keys and secrets — restrict to owner-only access. Applied
|
||||
# unconditionally (not only on first-seed) so a host-mounted .env that was
|
||||
# created with a permissive umask gets tightened on every container start.
|
||||
if [ -f "$HERMES_HOME/.env" ]; then
|
||||
if refuse_symlinked_path "chown/chmod" "$HERMES_HOME/.env"; then
|
||||
:
|
||||
else
|
||||
chown hermes:hermes "$HERMES_HOME/.env" 2>/dev/null || true
|
||||
chmod 600 "$HERMES_HOME/.env" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Migrate persisted config schema ---
|
||||
# Docker image upgrades replace the code under $INSTALL_DIR but preserve
|
||||
# $HERMES_HOME on the mounted volume. Run the same safe, non-interactive
|
||||
# config-schema migrations that `hermes update` runs for non-Docker installs,
|
||||
# after first-boot seeding and before supervised gateway services start.
|
||||
# Set HERMES_SKIP_CONFIG_MIGRATION=1 for controlled/manual migrations.
|
||||
if [ -f "$HERMES_HOME/config.yaml" ]; then
|
||||
s6-setuidgid hermes "$INSTALL_DIR/.venv/bin/python" "$INSTALL_DIR/scripts/docker_config_migrate.py" \
|
||||
|| echo "[stage2] Warning: docker_config_migrate.py failed; continuing"
|
||||
fi
|
||||
|
||||
# auth.json: bootstrap from env on first boot only. Same semantics as the
|
||||
# pre-s6 entrypoint — the [ ! -f ] guard is critical to avoid clobbering
|
||||
# rotated refresh tokens on container restart.
|
||||
if [ ! -f "$HERMES_HOME/auth.json" ] && [ -n "${HERMES_AUTH_JSON_BOOTSTRAP:-}" ]; then
|
||||
if refuse_symlinked_path "seed" "$HERMES_HOME/auth.json"; then
|
||||
:
|
||||
else
|
||||
printf '%s' "$HERMES_AUTH_JSON_BOOTSTRAP" > "$HERMES_HOME/auth.json"
|
||||
chown hermes:hermes "$HERMES_HOME/auth.json" 2>/dev/null || true
|
||||
chmod 600 "$HERMES_HOME/auth.json"
|
||||
fi
|
||||
fi
|
||||
|
||||
# auth.json: re-seed a TERMINALLY-DEAD Nous bootstrap session (self-heal).
|
||||
#
|
||||
# The [ ! -f ] guard above deliberately refuses to clobber an existing
|
||||
# auth.json, so a container whose Nous bootstrap session took a terminal
|
||||
# invalid_grant (tokens cleared, providers.nous.last_auth_error.relogin_required
|
||||
# stamped) can NOT recover from a plain restart — it stays unauthenticated until
|
||||
# the credential is replaced. An orchestrator that manages the container can
|
||||
# supply a freshly-issued session via HERMES_AUTH_JSON_REBOOTSTRAP (distinct
|
||||
# from the create-only *_BOOTSTRAP var); this helper swaps ONLY the
|
||||
# providers.nous entry, and ONLY when the on-disk entry is provably terminal.
|
||||
# Every other case (healthy, rotating, absent, or unparseable auth.json) is a
|
||||
# no-op, so it is safe to leave the env set across restarts and never risks
|
||||
# clobbering a good/rotated token. Runs as its own stdlib-only subprocess (no
|
||||
# app imports) and always exits 0.
|
||||
if [ -f "$HERMES_HOME/auth.json" ] && [ -n "${HERMES_AUTH_JSON_REBOOTSTRAP:-}" ]; then
|
||||
if refuse_symlinked_path "reseed" "$HERMES_HOME/auth.json"; then
|
||||
:
|
||||
else
|
||||
s6-setuidgid hermes "$INSTALL_DIR/.venv/bin/python" \
|
||||
"$INSTALL_DIR/scripts/docker_rebootstrap_nous_session.py" \
|
||||
"$HERMES_HOME/auth.json" \
|
||||
|| echo "[stage2] Warning: docker_rebootstrap_nous_session.py failed; continuing"
|
||||
fi
|
||||
fi
|
||||
|
||||
# gateway_state.json: declare the gateway's INITIAL supervised state on a
|
||||
# fresh volume. Same first-boot-only env-seed pattern as auth.json above.
|
||||
#
|
||||
# On a blank volume there is no gateway_state.json, so the boot reconciler
|
||||
# (cont-init.d/02-reconcile-profiles → container_boot.reconcile_profile_gateways)
|
||||
# registers the gateway-default s6 slot but leaves it DOWN — it only
|
||||
# auto-starts when the last recorded state was "running". That means a
|
||||
# freshly-provisioned container comes up with the gateway down until
|
||||
# someone starts it (e.g. from the dashboard). An orchestrator that
|
||||
# provisions a fresh volume and wants the gateway running from first boot
|
||||
# can set HERMES_GATEWAY_BOOTSTRAP_STATE=running; we seed the state file
|
||||
# here, BEFORE 02-reconcile-profiles runs (cont-init.d scripts run in
|
||||
# lexicographic order), so the reconciler sees prior_state=running and
|
||||
# brings the supervised slot up on the very first boot.
|
||||
#
|
||||
# This is a generic container contract, not specific to any host: it seeds
|
||||
# the SAME gateway_state.json the reconciler already consults, exactly as
|
||||
# HERMES_AUTH_JSON_BOOTSTRAP seeds auth.json. The [ ! -f ] guard is the
|
||||
# load-bearing part — on every subsequent boot the persisted state wins,
|
||||
# so a gateway the operator deliberately stopped stays stopped across
|
||||
# restarts and we never clobber real runtime state.
|
||||
#
|
||||
# Only a literal "running" is honoured (the sole value in the reconciler's
|
||||
# _AUTOSTART_STATES); any other value is ignored so a typo can't write a
|
||||
# bogus state the reconciler would treat as "no prior state" anyway.
|
||||
if [ ! -f "$HERMES_HOME/gateway_state.json" ] && \
|
||||
[ "${HERMES_GATEWAY_BOOTSTRAP_STATE:-}" = "running" ]; then
|
||||
if refuse_symlinked_path "seed" "$HERMES_HOME/gateway_state.json"; then
|
||||
:
|
||||
else
|
||||
printf '{"gateway_state":"running"}\n' > "$HERMES_HOME/gateway_state.json"
|
||||
chown hermes:hermes "$HERMES_HOME/gateway_state.json" 2>/dev/null || true
|
||||
chmod 644 "$HERMES_HOME/gateway_state.json"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Sync bundled skills ---
|
||||
# Invoke the venv's python by absolute path so we don't need a `sh -c`
|
||||
# wrapper to source the activate script. This is safe because
|
||||
# skills_sync.py doesn't depend on any environment exports beyond what
|
||||
# the python binary's own bin-stub already sets up (sys.path is rooted
|
||||
# at the venv's site-packages by virtue of running .venv/bin/python).
|
||||
if [ -d "$INSTALL_DIR/skills" ]; then
|
||||
as_hermes "$INSTALL_DIR/.venv/bin/python" "$INSTALL_DIR/tools/skills_sync.py" \
|
||||
|| echo "[stage2] Warning: skills_sync.py failed; continuing"
|
||||
fi
|
||||
|
||||
# --- Discover agent-browser's Chromium binary ---
|
||||
# The image's Dockerfile runs `npx playwright install chromium`, which
|
||||
# populates ``$PLAYWRIGHT_BROWSERS_PATH`` (=/opt/hermes/.playwright) with
|
||||
# a ``chromium_headless_shell-<build>/chrome-headless-shell-linux64/``
|
||||
# directory. agent-browser (the runtime CLI Hermes spawns for the
|
||||
# browser tool) doesn't recognise this layout in its own cache scan and
|
||||
# fails with "Auto-launch failed: Chrome not found" — even though the
|
||||
# binary is right there (#15697).
|
||||
#
|
||||
# Fix: locate the binary at boot and export ``AGENT_BROWSER_EXECUTABLE_PATH``
|
||||
# via /run/s6/container_environment so the `with-contenv` shebang on
|
||||
# main-wrapper.sh propagates it into the supervised ``hermes`` process
|
||||
# and thence to agent-browser subprocesses.
|
||||
#
|
||||
# - Skipped when the user has already set ``AGENT_BROWSER_EXECUTABLE_PATH``
|
||||
# (lets users override with a system Chrome install).
|
||||
# - Filename-matched (not path-matched): the chromium dir contains many
|
||||
# shared libraries (libGLESv2.so, libEGL.so, ...) which inherit the
|
||||
# executable bit from Playwright's tarball but are NOT browser binaries.
|
||||
# We only accept files whose basename is chrome / chromium /
|
||||
# chrome-headless-shell / headless_shell / chromium-browser. Compare
|
||||
# PR #18635's earlier ``find | grep -Ei 'chrome|chromium'`` which would
|
||||
# match the path ``.../chrome-headless-shell-linux64/libGLESv2.so`` and
|
||||
# pick a .so.
|
||||
# - Quietly skipped when $PLAYWRIGHT_BROWSERS_PATH doesn't exist (e.g.
|
||||
# custom builds that strip Playwright).
|
||||
if [ -z "${AGENT_BROWSER_EXECUTABLE_PATH:-}" ] && \
|
||||
[ -n "${PLAYWRIGHT_BROWSERS_PATH:-}" ] && \
|
||||
[ -d "$PLAYWRIGHT_BROWSERS_PATH" ]; then
|
||||
browser_bin=$(find "$PLAYWRIGHT_BROWSERS_PATH" -type f -executable \
|
||||
\( -name 'chrome' -o -name 'chromium' \
|
||||
-o -name 'chrome-headless-shell' -o -name 'headless_shell' \
|
||||
-o -name 'chromium-browser' \) \
|
||||
2>/dev/null | head -n 1)
|
||||
if [ -n "$browser_bin" ]; then
|
||||
echo "[stage2] Found agent-browser Chromium binary: $browser_bin"
|
||||
# Write to s6's container_environment so with-contenv picks it
|
||||
# up for all supervised services (main-hermes, dashboard, etc.).
|
||||
# Idempotent: each boot overwrites with the current path.
|
||||
# Some container runtimes / s6-overlay versions do not create the
|
||||
# envdir before cont-init hooks run, so create it defensively.
|
||||
mkdir -p /run/s6/container_environment
|
||||
printf '%s' "$browser_bin" > /run/s6/container_environment/AGENT_BROWSER_EXECUTABLE_PATH
|
||||
else
|
||||
echo "[stage2] Warning: no Chromium binary under $PLAYWRIGHT_BROWSERS_PATH; browser tool may fail"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[stage2] Setup complete; starting user services"
|
||||
Reference in New Issue
Block a user