2026-04-12 00:26:08 -07:00
|
|
|
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
|
feat(docker): upgrade Node to 22 LTS via multi-stage from node:22-bookworm-slim (#4977)
Debian trixie's bundled `nodejs` package is pinned to 20.19.2, which
reached LTS EOL in April 2026. Trixie won't upgrade in place; Debian 14
(forky) — where the apt nodejs is 24.x — isn't released until ~mid-2027.
To stay on a supported LTS without waiting for Debian 14, copy node + npm
+ corepack from the upstream `node:22-bookworm-slim` image as a
multi-stage source, matching the existing `uv_source` and `gosu_source`
patterns in the Dockerfile. Bookworm-based slim image is used so the
produced binary links against glibc 2.36, which runs cleanly on Debian 13
(trixie, glibc 2.41).
Changes:
- Add `FROM node:22-bookworm-slim@sha256:... AS node_source` stage
- Remove `nodejs npm` from `apt-get install` (now sourced from node_source)
- Add `ca-certificates` explicitly to apt install (was a transitive of
the apt nodejs package; removing nodejs broke the chain and curl
inside the build failed with "error setting certificate file")
- COPY node binary + npm + corepack from node_source; recreate the
symlinks at /usr/local/bin/{npm,npx,corepack}
- Update the npm_config_install_links=false comment block — npm 10's
default is already `install-links=false`, but we keep the env as
defense-in-depth against future Node-source-version regressions
Future bumps to Node 24/26 are a one-line ARG change.
Validation:
- Built --no-cache against current origin/main; build succeeds in 1m42s
- Image size: 3.27 GB (pre-salvage-1 baseline) → 3.14 GB (this PR);
net 130 MiB savings (60 MiB from this change alone vs current main —
removing apt nodejs+transitive deps that duplicated what node bundles)
- Node 22.22.3 / npm 10.9.8 / esbuild 0.27.7 all run cleanly under
trixie's glibc 2.41
- Standard image smoke (6/6), Node-version E2E (8/8), chown E2E from
#19788 (6/6), TUI UID-remap E2E from #28851 (4/4) — 24 checks total
Co-authored-by: Prithvi Monangi <8312237+Prithvi1994@users.noreply.github.com>
2026-05-27 16:10:34 +10:00
|
|
|
# Node 22 LTS source stage. Debian trixie's bundled nodejs is pinned to 20.x
|
|
|
|
|
# which reached EOL in April 2026 — we copy node + npm + corepack from the
|
|
|
|
|
# upstream node:22 image instead so we can stay on a supported LTS without
|
|
|
|
|
# waiting for Debian 14 (forky, ~mid-2027). Bookworm-based slim image used
|
|
|
|
|
# so the produced binary links against glibc 2.36, which runs cleanly on
|
|
|
|
|
# our Debian 13 (trixie, glibc 2.41) runtime. Bumping to a new Node major
|
|
|
|
|
# is a one-line ARG change; see #4977.
|
|
|
|
|
FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source
|
2026-04-12 00:46:01 -07:00
|
|
|
FROM debian:13.4
|
feat(docker): add Docker container for the agent (salvage #1841) (#3668)
Adds a complete Docker packaging for Hermes Agent:
- Dockerfile based on debian:13.4 with all deps
- Entrypoint that bootstraps .env, config.yaml, SOUL.md on first run
- CI workflow to build, test, and push to DockerHub
- Documentation for interactive, gateway, and upgrade workflows
Closes #850, #913.
Changes vs original PR:
- Removed pre-created legacy cache/platform dirs from entrypoint
(image_cache, audio_cache, pairing, whatsapp/session) — these are
now created on demand by the application using the consolidated
layout from get_hermes_dir()
- Moved docs from docs/docker.md to website/docs/user-guide/docker.md
and added to Docusaurus sidebar
Co-authored-by: benbarclay <benbarclay@users.noreply.github.com>
2026-03-28 22:21:48 -07:00
|
|
|
|
2026-06-18 09:09:21 +10:00
|
|
|
# Disable Python stdout buffering to ensure logs are printed immediately.
|
|
|
|
|
# Do not write .pyc files at runtime: /opt/hermes is immutable in the
|
|
|
|
|
# published container and writable state belongs under /opt/data.
|
2026-04-08 22:13:11 +03:00
|
|
|
ENV PYTHONUNBUFFERED=1
|
2026-06-18 09:09:21 +10:00
|
|
|
ENV PYTHONDONTWRITEBYTECODE=1
|
2026-04-08 22:13:11 +03:00
|
|
|
|
2026-04-12 00:26:08 -07:00
|
|
|
# Store Playwright browsers outside the volume mount so the build-time
|
|
|
|
|
# install survives the /opt/data volume overlay at runtime.
|
|
|
|
|
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
|
|
|
|
|
feat(docker)!: replace tini with s6-overlay as PID 1
BREAKING CHANGE: the container ENTRYPOINT is now /init (s6-overlay)
instead of /usr/bin/tini. Main hermes runs as the container CMD with
TTY inherited (preserving --tui), dashboard runs as a supervised s6-rc
service (HERMES_DASHBOARD=1 starts it; crashes auto-restart), and the
ground is laid for per-profile gateway supervision (Phase 3+4).
All five pre-s6 docker run invocation patterns continue to work
identically — verified by the Phase 0 docker harness:
docker run <image> → `hermes` with no args
docker run <image> chat -q "..." → `hermes chat -q ...` passthrough
docker run <image> sleep infinity → `sleep infinity` direct
docker run <image> bash → interactive bash
docker run -it <image> --tui → interactive Ink TUI
Phase 2 harness result: 12 passed, 2 xfailed (Phase 4 target). Hadolint
+ shellcheck pass cleanly.
Architecture pivot from plan v3 (documented in main-hermes/run header):
the plan called for main hermes to be an s6-supervised service, but
two real s6-overlay v3 mechanics blocked that — cont-init.d scripts
receive no arguments (CMD args are not visible to stage2-hook), and
`/run/s6/basedir/bin/halt` after writing the exit code did not
propagate the desired exit code (container exits 143). We use the
s6-overlay-native CMD pattern instead: main-wrapper.sh is the
container's main program (ENTRYPOINT prepends it so leading-dash
args like --version aren't intercepted by /init), exec's the final
program with stdin/stdout/stderr inherited, and the program's exit
code becomes the container exit code. main-hermes is now a no-op
`sleep infinity` slot kept for future supervised-gateway-container
modes. This trades "supervised restart of main hermes" for arg-
parity with the pre-s6 contract — main hermes was already unsupervised
under tini, so we lose nothing functional. Dashboard supervision is
the only new guarantee added by this phase.
Files added:
docker/main-wrapper.sh # arg routing + s6-setuidgid drop
docker/stage2-hook.sh # gosu-equivalent + chown + seed
docker/s6-rc.d/main-hermes/{type,run,dependencies.d/base}
docker/s6-rc.d/dashboard/{type,run,dependencies.d/base}
docker/s6-rc.d/user/contents.d/{main-hermes,dashboard}
Files changed:
Dockerfile: tini → s6-overlay install + ENTRYPOINT flip + service wiring
docker/entrypoint.sh: thin shim to stage2-hook.sh for back-compat
tests/docker/test_dashboard.py: add test_dashboard_restarts_after_crash
Refs: docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md
2026-05-21 15:33:25 +10:00
|
|
|
# Install system dependencies in one layer, clear APT cache.
|
|
|
|
|
# tini was previously PID 1 to reap orphaned zombie processes (MCP stdio
|
|
|
|
|
# subprocesses, git, bun, etc.) that would otherwise accumulate when hermes
|
|
|
|
|
# ran as PID 1. See #15012. Phase 2 of the s6-overlay supervision plan
|
|
|
|
|
# replaces tini with s6-overlay's /init (PID 1 = s6-svscan), which reaps
|
|
|
|
|
# zombies non-blockingly on SIGCHLD and additionally supervises the main
|
|
|
|
|
# hermes process, the dashboard, and per-profile gateways.
|
2026-03-30 15:19:52 -05:00
|
|
|
RUN apt-get update && \
|
|
|
|
|
apt-get install -y --no-install-recommends \
|
2026-06-10 19:23:06 +10:00
|
|
|
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
|
2026-03-30 15:19:52 -05:00
|
|
|
rm -rf /var/lib/apt/lists/*
|
feat(docker): add Docker container for the agent (salvage #1841) (#3668)
Adds a complete Docker packaging for Hermes Agent:
- Dockerfile based on debian:13.4 with all deps
- Entrypoint that bootstraps .env, config.yaml, SOUL.md on first run
- CI workflow to build, test, and push to DockerHub
- Documentation for interactive, gateway, and upgrade workflows
Closes #850, #913.
Changes vs original PR:
- Removed pre-created legacy cache/platform dirs from entrypoint
(image_cache, audio_cache, pairing, whatsapp/session) — these are
now created on demand by the application using the consolidated
layout from get_hermes_dir()
- Moved docs from docs/docker.md to website/docs/user-guide/docker.md
and added to Docusaurus sidebar
Co-authored-by: benbarclay <benbarclay@users.noreply.github.com>
2026-03-28 22:21:48 -07:00
|
|
|
|
feat(docker)!: replace tini with s6-overlay as PID 1
BREAKING CHANGE: the container ENTRYPOINT is now /init (s6-overlay)
instead of /usr/bin/tini. Main hermes runs as the container CMD with
TTY inherited (preserving --tui), dashboard runs as a supervised s6-rc
service (HERMES_DASHBOARD=1 starts it; crashes auto-restart), and the
ground is laid for per-profile gateway supervision (Phase 3+4).
All five pre-s6 docker run invocation patterns continue to work
identically — verified by the Phase 0 docker harness:
docker run <image> → `hermes` with no args
docker run <image> chat -q "..." → `hermes chat -q ...` passthrough
docker run <image> sleep infinity → `sleep infinity` direct
docker run <image> bash → interactive bash
docker run -it <image> --tui → interactive Ink TUI
Phase 2 harness result: 12 passed, 2 xfailed (Phase 4 target). Hadolint
+ shellcheck pass cleanly.
Architecture pivot from plan v3 (documented in main-hermes/run header):
the plan called for main hermes to be an s6-supervised service, but
two real s6-overlay v3 mechanics blocked that — cont-init.d scripts
receive no arguments (CMD args are not visible to stage2-hook), and
`/run/s6/basedir/bin/halt` after writing the exit code did not
propagate the desired exit code (container exits 143). We use the
s6-overlay-native CMD pattern instead: main-wrapper.sh is the
container's main program (ENTRYPOINT prepends it so leading-dash
args like --version aren't intercepted by /init), exec's the final
program with stdin/stdout/stderr inherited, and the program's exit
code becomes the container exit code. main-hermes is now a no-op
`sleep infinity` slot kept for future supervised-gateway-container
modes. This trades "supervised restart of main hermes" for arg-
parity with the pre-s6 contract — main hermes was already unsupervised
under tini, so we lose nothing functional. Dashboard supervision is
the only new guarantee added by this phase.
Files added:
docker/main-wrapper.sh # arg routing + s6-setuidgid drop
docker/stage2-hook.sh # gosu-equivalent + chown + seed
docker/s6-rc.d/main-hermes/{type,run,dependencies.d/base}
docker/s6-rc.d/dashboard/{type,run,dependencies.d/base}
docker/s6-rc.d/user/contents.d/{main-hermes,dashboard}
Files changed:
Dockerfile: tini → s6-overlay install + ENTRYPOINT flip + service wiring
docker/entrypoint.sh: thin shim to stage2-hook.sh for back-compat
tests/docker/test_dashboard.py: add test_dashboard_restarts_after_crash
Refs: docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md
2026-05-21 15:33:25 +10:00
|
|
|
# ---------- s6-overlay install ----------
|
|
|
|
|
# s6-overlay provides supervision for the main hermes process, the dashboard,
|
|
|
|
|
# and per-profile gateways. /init becomes PID 1 below — see ENTRYPOINT.
|
2026-05-23 14:58:06 +10:00
|
|
|
#
|
|
|
|
|
# Multi-arch: BuildKit auto-populates TARGETARCH (amd64 / arm64). s6-overlay
|
|
|
|
|
# uses tarball names keyed on the kernel arch string (x86_64 / aarch64), so
|
|
|
|
|
# we map between them inline. The noarch + symlinks tarballs are
|
|
|
|
|
# architecture-independent and reused as-is.
|
|
|
|
|
#
|
|
|
|
|
# We use `curl` instead of `ADD` for the per-arch tarball because `ADD`
|
|
|
|
|
# evaluates its URL at parse time, before any ARG / TARGETARCH substitution
|
|
|
|
|
# — splitting one URL per arch into two ADDs would download both on every
|
|
|
|
|
# build and leave dead bytes in the cache. A single curl + arch-keyed URL
|
|
|
|
|
# is simpler and cache-friendlier.
|
2026-05-23 14:59:42 +10:00
|
|
|
#
|
|
|
|
|
# Supply-chain integrity: every tarball is checksum-verified against the
|
|
|
|
|
# upstream-published SHA256. To bump S6_OVERLAY_VERSION, fetch the four
|
|
|
|
|
# `.sha256` files from the corresponding release and update the ARGs. The
|
|
|
|
|
# checksum lookup happens during build, so a compromised release artifact
|
|
|
|
|
# fails the build loudly instead of silently producing a tampered image.
|
2026-05-23 14:58:06 +10:00
|
|
|
ARG TARGETARCH
|
feat(docker)!: replace tini with s6-overlay as PID 1
BREAKING CHANGE: the container ENTRYPOINT is now /init (s6-overlay)
instead of /usr/bin/tini. Main hermes runs as the container CMD with
TTY inherited (preserving --tui), dashboard runs as a supervised s6-rc
service (HERMES_DASHBOARD=1 starts it; crashes auto-restart), and the
ground is laid for per-profile gateway supervision (Phase 3+4).
All five pre-s6 docker run invocation patterns continue to work
identically — verified by the Phase 0 docker harness:
docker run <image> → `hermes` with no args
docker run <image> chat -q "..." → `hermes chat -q ...` passthrough
docker run <image> sleep infinity → `sleep infinity` direct
docker run <image> bash → interactive bash
docker run -it <image> --tui → interactive Ink TUI
Phase 2 harness result: 12 passed, 2 xfailed (Phase 4 target). Hadolint
+ shellcheck pass cleanly.
Architecture pivot from plan v3 (documented in main-hermes/run header):
the plan called for main hermes to be an s6-supervised service, but
two real s6-overlay v3 mechanics blocked that — cont-init.d scripts
receive no arguments (CMD args are not visible to stage2-hook), and
`/run/s6/basedir/bin/halt` after writing the exit code did not
propagate the desired exit code (container exits 143). We use the
s6-overlay-native CMD pattern instead: main-wrapper.sh is the
container's main program (ENTRYPOINT prepends it so leading-dash
args like --version aren't intercepted by /init), exec's the final
program with stdin/stdout/stderr inherited, and the program's exit
code becomes the container exit code. main-hermes is now a no-op
`sleep infinity` slot kept for future supervised-gateway-container
modes. This trades "supervised restart of main hermes" for arg-
parity with the pre-s6 contract — main hermes was already unsupervised
under tini, so we lose nothing functional. Dashboard supervision is
the only new guarantee added by this phase.
Files added:
docker/main-wrapper.sh # arg routing + s6-setuidgid drop
docker/stage2-hook.sh # gosu-equivalent + chown + seed
docker/s6-rc.d/main-hermes/{type,run,dependencies.d/base}
docker/s6-rc.d/dashboard/{type,run,dependencies.d/base}
docker/s6-rc.d/user/contents.d/{main-hermes,dashboard}
Files changed:
Dockerfile: tini → s6-overlay install + ENTRYPOINT flip + service wiring
docker/entrypoint.sh: thin shim to stage2-hook.sh for back-compat
tests/docker/test_dashboard.py: add test_dashboard_restarts_after_crash
Refs: docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md
2026-05-21 15:33:25 +10:00
|
|
|
ARG S6_OVERLAY_VERSION=3.2.3.0
|
2026-05-23 14:59:42 +10:00
|
|
|
ARG S6_OVERLAY_NOARCH_SHA256=b720f9d9340efc8bb07528b9743813c836e4b02f8693d90241f047998b4c53cf
|
|
|
|
|
ARG S6_OVERLAY_X86_64_SHA256=a93f02882c6ed46b21e7adb5c0add86154f01236c93cd82c7d682722e8840563
|
|
|
|
|
ARG S6_OVERLAY_AARCH64_SHA256=0952056ff913482163cc30e35b2e944b507ba1025d78f5becbb89367bf344581
|
|
|
|
|
ARG S6_OVERLAY_SYMLINKS_SHA256=a60dc5235de3ecbcf874b9c1f18d73263ab99b289b9329aa950e8729c4789f0e
|
feat(docker)!: replace tini with s6-overlay as PID 1
BREAKING CHANGE: the container ENTRYPOINT is now /init (s6-overlay)
instead of /usr/bin/tini. Main hermes runs as the container CMD with
TTY inherited (preserving --tui), dashboard runs as a supervised s6-rc
service (HERMES_DASHBOARD=1 starts it; crashes auto-restart), and the
ground is laid for per-profile gateway supervision (Phase 3+4).
All five pre-s6 docker run invocation patterns continue to work
identically — verified by the Phase 0 docker harness:
docker run <image> → `hermes` with no args
docker run <image> chat -q "..." → `hermes chat -q ...` passthrough
docker run <image> sleep infinity → `sleep infinity` direct
docker run <image> bash → interactive bash
docker run -it <image> --tui → interactive Ink TUI
Phase 2 harness result: 12 passed, 2 xfailed (Phase 4 target). Hadolint
+ shellcheck pass cleanly.
Architecture pivot from plan v3 (documented in main-hermes/run header):
the plan called for main hermes to be an s6-supervised service, but
two real s6-overlay v3 mechanics blocked that — cont-init.d scripts
receive no arguments (CMD args are not visible to stage2-hook), and
`/run/s6/basedir/bin/halt` after writing the exit code did not
propagate the desired exit code (container exits 143). We use the
s6-overlay-native CMD pattern instead: main-wrapper.sh is the
container's main program (ENTRYPOINT prepends it so leading-dash
args like --version aren't intercepted by /init), exec's the final
program with stdin/stdout/stderr inherited, and the program's exit
code becomes the container exit code. main-hermes is now a no-op
`sleep infinity` slot kept for future supervised-gateway-container
modes. This trades "supervised restart of main hermes" for arg-
parity with the pre-s6 contract — main hermes was already unsupervised
under tini, so we lose nothing functional. Dashboard supervision is
the only new guarantee added by this phase.
Files added:
docker/main-wrapper.sh # arg routing + s6-setuidgid drop
docker/stage2-hook.sh # gosu-equivalent + chown + seed
docker/s6-rc.d/main-hermes/{type,run,dependencies.d/base}
docker/s6-rc.d/dashboard/{type,run,dependencies.d/base}
docker/s6-rc.d/user/contents.d/{main-hermes,dashboard}
Files changed:
Dockerfile: tini → s6-overlay install + ENTRYPOINT flip + service wiring
docker/entrypoint.sh: thin shim to stage2-hook.sh for back-compat
tests/docker/test_dashboard.py: add test_dashboard_restarts_after_crash
Refs: docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md
2026-05-21 15:33:25 +10:00
|
|
|
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /tmp/
|
|
|
|
|
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-symlinks-noarch.tar.xz /tmp/
|
2026-05-23 14:58:06 +10:00
|
|
|
RUN set -eu; \
|
|
|
|
|
case "${TARGETARCH:-amd64}" in \
|
2026-05-23 14:59:42 +10:00
|
|
|
amd64) s6_arch="x86_64"; s6_arch_sha="${S6_OVERLAY_X86_64_SHA256}" ;; \
|
|
|
|
|
arm64) s6_arch="aarch64"; s6_arch_sha="${S6_OVERLAY_AARCH64_SHA256}" ;; \
|
2026-05-23 14:58:06 +10:00
|
|
|
*) echo "Unsupported TARGETARCH=${TARGETARCH} for s6-overlay" >&2; exit 1 ;; \
|
|
|
|
|
esac; \
|
|
|
|
|
curl -fsSL --retry 3 -o /tmp/s6-overlay-arch.tar.xz \
|
|
|
|
|
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${s6_arch}.tar.xz"; \
|
2026-05-23 14:59:42 +10:00
|
|
|
{ \
|
|
|
|
|
printf '%s %s\n' "${S6_OVERLAY_NOARCH_SHA256}" /tmp/s6-overlay-noarch.tar.xz; \
|
|
|
|
|
printf '%s %s\n' "${s6_arch_sha}" /tmp/s6-overlay-arch.tar.xz; \
|
|
|
|
|
printf '%s %s\n' "${S6_OVERLAY_SYMLINKS_SHA256}" /tmp/s6-overlay-symlinks-noarch.tar.xz; \
|
|
|
|
|
} > /tmp/s6-overlay.sha256; \
|
|
|
|
|
sha256sum -c /tmp/s6-overlay.sha256; \
|
2026-05-23 14:58:06 +10:00
|
|
|
tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz; \
|
|
|
|
|
tar -C / -Jxpf /tmp/s6-overlay-arch.tar.xz; \
|
|
|
|
|
tar -C / -Jxpf /tmp/s6-overlay-symlinks-noarch.tar.xz; \
|
2026-05-31 22:32:55 -05:00
|
|
|
rm /tmp/s6-overlay-*.tar.xz /tmp/s6-overlay.sha256; \
|
|
|
|
|
# #34192: backward-compat shim for orchestration templates that still\
|
|
|
|
|
# reference the legacy /usr/bin/tini entrypoint (e.g. Hostinger's\
|
|
|
|
|
# 'Hermes WebUI' catalog). The image has moved to s6-overlay /init\
|
|
|
|
|
# as PID 1 (see ENTRYPOINT below + the migration comment at the top\
|
|
|
|
|
# of this file), but external wrappers pinned to /usr/bin/tini will\
|
|
|
|
|
# crash with 'tini: No such file or directory' on startup. The shim\
|
|
|
|
|
# symlinks /usr/bin/tini -> /init so legacy wrappers exec the right\
|
|
|
|
|
# PID-1 reaper without behavior change for users on the current\
|
|
|
|
|
# ENTRYPOINT. Safe to drop once the affected catalogs are updated.\
|
|
|
|
|
ln -sf /init /usr/bin/tini
|
feat(docker)!: replace tini with s6-overlay as PID 1
BREAKING CHANGE: the container ENTRYPOINT is now /init (s6-overlay)
instead of /usr/bin/tini. Main hermes runs as the container CMD with
TTY inherited (preserving --tui), dashboard runs as a supervised s6-rc
service (HERMES_DASHBOARD=1 starts it; crashes auto-restart), and the
ground is laid for per-profile gateway supervision (Phase 3+4).
All five pre-s6 docker run invocation patterns continue to work
identically — verified by the Phase 0 docker harness:
docker run <image> → `hermes` with no args
docker run <image> chat -q "..." → `hermes chat -q ...` passthrough
docker run <image> sleep infinity → `sleep infinity` direct
docker run <image> bash → interactive bash
docker run -it <image> --tui → interactive Ink TUI
Phase 2 harness result: 12 passed, 2 xfailed (Phase 4 target). Hadolint
+ shellcheck pass cleanly.
Architecture pivot from plan v3 (documented in main-hermes/run header):
the plan called for main hermes to be an s6-supervised service, but
two real s6-overlay v3 mechanics blocked that — cont-init.d scripts
receive no arguments (CMD args are not visible to stage2-hook), and
`/run/s6/basedir/bin/halt` after writing the exit code did not
propagate the desired exit code (container exits 143). We use the
s6-overlay-native CMD pattern instead: main-wrapper.sh is the
container's main program (ENTRYPOINT prepends it so leading-dash
args like --version aren't intercepted by /init), exec's the final
program with stdin/stdout/stderr inherited, and the program's exit
code becomes the container exit code. main-hermes is now a no-op
`sleep infinity` slot kept for future supervised-gateway-container
modes. This trades "supervised restart of main hermes" for arg-
parity with the pre-s6 contract — main hermes was already unsupervised
under tini, so we lose nothing functional. Dashboard supervision is
the only new guarantee added by this phase.
Files added:
docker/main-wrapper.sh # arg routing + s6-setuidgid drop
docker/stage2-hook.sh # gosu-equivalent + chown + seed
docker/s6-rc.d/main-hermes/{type,run,dependencies.d/base}
docker/s6-rc.d/dashboard/{type,run,dependencies.d/base}
docker/s6-rc.d/user/contents.d/{main-hermes,dashboard}
Files changed:
Dockerfile: tini → s6-overlay install + ENTRYPOINT flip + service wiring
docker/entrypoint.sh: thin shim to stage2-hook.sh for back-compat
tests/docker/test_dashboard.py: add test_dashboard_restarts_after_crash
Refs: docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md
2026-05-21 15:33:25 +10:00
|
|
|
|
2026-04-12 00:26:08 -07:00
|
|
|
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
|
|
|
|
|
RUN useradd -u 10000 -m -d /opt/data hermes
|
|
|
|
|
|
|
|
|
|
COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/
|
|
|
|
|
|
feat(docker): upgrade Node to 22 LTS via multi-stage from node:22-bookworm-slim (#4977)
Debian trixie's bundled `nodejs` package is pinned to 20.19.2, which
reached LTS EOL in April 2026. Trixie won't upgrade in place; Debian 14
(forky) — where the apt nodejs is 24.x — isn't released until ~mid-2027.
To stay on a supported LTS without waiting for Debian 14, copy node + npm
+ corepack from the upstream `node:22-bookworm-slim` image as a
multi-stage source, matching the existing `uv_source` and `gosu_source`
patterns in the Dockerfile. Bookworm-based slim image is used so the
produced binary links against glibc 2.36, which runs cleanly on Debian 13
(trixie, glibc 2.41).
Changes:
- Add `FROM node:22-bookworm-slim@sha256:... AS node_source` stage
- Remove `nodejs npm` from `apt-get install` (now sourced from node_source)
- Add `ca-certificates` explicitly to apt install (was a transitive of
the apt nodejs package; removing nodejs broke the chain and curl
inside the build failed with "error setting certificate file")
- COPY node binary + npm + corepack from node_source; recreate the
symlinks at /usr/local/bin/{npm,npx,corepack}
- Update the npm_config_install_links=false comment block — npm 10's
default is already `install-links=false`, but we keep the env as
defense-in-depth against future Node-source-version regressions
Future bumps to Node 24/26 are a one-line ARG change.
Validation:
- Built --no-cache against current origin/main; build succeeds in 1m42s
- Image size: 3.27 GB (pre-salvage-1 baseline) → 3.14 GB (this PR);
net 130 MiB savings (60 MiB from this change alone vs current main —
removing apt nodejs+transitive deps that duplicated what node bundles)
- Node 22.22.3 / npm 10.9.8 / esbuild 0.27.7 all run cleanly under
trixie's glibc 2.41
- Standard image smoke (6/6), Node-version E2E (8/8), chown E2E from
#19788 (6/6), TUI UID-remap E2E from #28851 (4/4) — 24 checks total
Co-authored-by: Prithvi Monangi <8312237+Prithvi1994@users.noreply.github.com>
2026-05-27 16:10:34 +10:00
|
|
|
# Node 22 LTS: copy the node binary plus the bundled npm + corepack JS
|
|
|
|
|
# installs from the upstream image. npm and npx are recreated as symlinks
|
|
|
|
|
# because they're symlinks in the source image (and need to live on PATH).
|
|
|
|
|
# See node_source stage at the top of the file for the version-bump
|
|
|
|
|
# rationale (#4977).
|
|
|
|
|
COPY --chmod=0755 --from=node_source /usr/local/bin/node /usr/local/bin/
|
|
|
|
|
COPY --from=node_source /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
|
|
|
|
|
COPY --from=node_source /usr/local/lib/node_modules/corepack /usr/local/lib/node_modules/corepack
|
|
|
|
|
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
|
|
|
|
|
ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx && \
|
|
|
|
|
ln -sf /usr/local/lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack
|
|
|
|
|
|
feat(docker): add Docker container for the agent (salvage #1841) (#3668)
Adds a complete Docker packaging for Hermes Agent:
- Dockerfile based on debian:13.4 with all deps
- Entrypoint that bootstraps .env, config.yaml, SOUL.md on first run
- CI workflow to build, test, and push to DockerHub
- Documentation for interactive, gateway, and upgrade workflows
Closes #850, #913.
Changes vs original PR:
- Removed pre-created legacy cache/platform dirs from entrypoint
(image_cache, audio_cache, pairing, whatsapp/session) — these are
now created on demand by the application using the consolidated
layout from get_hermes_dir()
- Moved docs from docs/docker.md to website/docs/user-guide/docker.md
and added to Docusaurus sidebar
Co-authored-by: benbarclay <benbarclay@users.noreply.github.com>
2026-03-28 22:21:48 -07:00
|
|
|
WORKDIR /opt/hermes
|
|
|
|
|
|
2026-04-18 10:14:31 -07:00
|
|
|
# ---------- Layer-cached dependency install ----------
|
|
|
|
|
# Copy only package manifests first so npm install + Playwright are cached
|
|
|
|
|
# unless the lockfiles themselves change.
|
2026-05-01 16:29:46 +10:00
|
|
|
#
|
|
|
|
|
# ui-tui/packages/hermes-ink/ is copied IN FULL (not just its manifests)
|
|
|
|
|
# because it is referenced as a `file:` workspace dependency from
|
|
|
|
|
# ui-tui/package.json. Copying the tree up front lets npm resolve the
|
|
|
|
|
# workspace to real content instead of stopping at a bare package.json.
|
2026-04-18 10:14:31 -07:00
|
|
|
COPY package.json package-lock.json ./
|
2026-05-31 19:48:07 -04:00
|
|
|
COPY web/package.json web/
|
|
|
|
|
COPY ui-tui/package.json ui-tui/
|
2026-05-01 16:29:46 +10:00
|
|
|
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
|
2026-06-28 21:43:56 -05:00
|
|
|
# apps/shared/ is copied IN FULL because web/package.json references it as a
|
|
|
|
|
# `file:` workspace dependency (same pattern as hermes-ink above).
|
|
|
|
|
COPY apps/shared/ apps/shared/
|
2026-05-01 16:29:46 +10:00
|
|
|
|
|
|
|
|
# `npm_config_install_links=false` forces npm to install `file:` deps as
|
feat(docker): upgrade Node to 22 LTS via multi-stage from node:22-bookworm-slim (#4977)
Debian trixie's bundled `nodejs` package is pinned to 20.19.2, which
reached LTS EOL in April 2026. Trixie won't upgrade in place; Debian 14
(forky) — where the apt nodejs is 24.x — isn't released until ~mid-2027.
To stay on a supported LTS without waiting for Debian 14, copy node + npm
+ corepack from the upstream `node:22-bookworm-slim` image as a
multi-stage source, matching the existing `uv_source` and `gosu_source`
patterns in the Dockerfile. Bookworm-based slim image is used so the
produced binary links against glibc 2.36, which runs cleanly on Debian 13
(trixie, glibc 2.41).
Changes:
- Add `FROM node:22-bookworm-slim@sha256:... AS node_source` stage
- Remove `nodejs npm` from `apt-get install` (now sourced from node_source)
- Add `ca-certificates` explicitly to apt install (was a transitive of
the apt nodejs package; removing nodejs broke the chain and curl
inside the build failed with "error setting certificate file")
- COPY node binary + npm + corepack from node_source; recreate the
symlinks at /usr/local/bin/{npm,npx,corepack}
- Update the npm_config_install_links=false comment block — npm 10's
default is already `install-links=false`, but we keep the env as
defense-in-depth against future Node-source-version regressions
Future bumps to Node 24/26 are a one-line ARG change.
Validation:
- Built --no-cache against current origin/main; build succeeds in 1m42s
- Image size: 3.27 GB (pre-salvage-1 baseline) → 3.14 GB (this PR);
net 130 MiB savings (60 MiB from this change alone vs current main —
removing apt nodejs+transitive deps that duplicated what node bundles)
- Node 22.22.3 / npm 10.9.8 / esbuild 0.27.7 all run cleanly under
trixie's glibc 2.41
- Standard image smoke (6/6), Node-version E2E (8/8), chown E2E from
#19788 (6/6), TUI UID-remap E2E from #28851 (4/4) — 24 checks total
Co-authored-by: Prithvi Monangi <8312237+Prithvi1994@users.noreply.github.com>
2026-05-27 16:10:34 +10:00
|
|
|
# symlinks instead of copies. This is the default since npm 10+, which is
|
|
|
|
|
# what the image ships now (via the node:22 source stage). We set it
|
|
|
|
|
# explicitly anyway as defense-in-depth: the previous Debian-bundled npm
|
|
|
|
|
# 9.x defaulted to install-as-copy, which produced a hidden
|
|
|
|
|
# node_modules/.package-lock.json that permanently disagreed with the root
|
|
|
|
|
# lock on the @hermes/ink entry, tripped the TUI launcher's
|
|
|
|
|
# `_tui_need_npm_install()` check on every startup, and triggered a
|
|
|
|
|
# runtime `npm install` that then failed with EACCES. Keeping the env
|
|
|
|
|
# guards against a future regression if the source npm version changes.
|
2026-05-01 16:29:46 +10:00
|
|
|
ENV npm_config_install_links=false
|
2026-04-18 10:14:31 -07:00
|
|
|
|
2026-04-12 00:26:08 -07:00
|
|
|
RUN npm install --prefer-offline --no-audit && \
|
2026-03-30 17:38:07 -05:00
|
|
|
npx playwright install --with-deps chromium --only-shell && \
|
2026-03-30 15:27:11 -05:00
|
|
|
npm cache clean --force
|
feat(docker): add Docker container for the agent (salvage #1841) (#3668)
Adds a complete Docker packaging for Hermes Agent:
- Dockerfile based on debian:13.4 with all deps
- Entrypoint that bootstraps .env, config.yaml, SOUL.md on first run
- CI workflow to build, test, and push to DockerHub
- Documentation for interactive, gateway, and upgrade workflows
Closes #850, #913.
Changes vs original PR:
- Removed pre-created legacy cache/platform dirs from entrypoint
(image_cache, audio_cache, pairing, whatsapp/session) — these are
now created on demand by the application using the consolidated
layout from get_hermes_dir()
- Moved docs from docs/docker.md to website/docs/user-guide/docker.md
and added to Docusaurus sidebar
Co-authored-by: benbarclay <benbarclay@users.noreply.github.com>
2026-03-28 22:21:48 -07:00
|
|
|
|
2026-05-08 16:16:53 -04:00
|
|
|
# ---------- Layer-cached Python dependency install ----------
|
|
|
|
|
# Copy only pyproject.toml + uv.lock so the Python dep resolve + wheel
|
|
|
|
|
# download + native-extension compile layer is cached unless those inputs
|
|
|
|
|
# change. Before this split the Python install sat after `COPY . .`, so
|
|
|
|
|
# every source-only commit re-did ~4-5 min of dep work on cold builds.
|
|
|
|
|
#
|
|
|
|
|
# README.md is referenced by pyproject.toml's `readme =` field, but it's
|
|
|
|
|
# excluded from the build context by .dockerignore's `*.md`. uv's build
|
|
|
|
|
# frontend stats the readme path during dep resolution, so we `touch` an
|
|
|
|
|
# empty placeholder — the real README is restored by `COPY . .` below.
|
|
|
|
|
#
|
2026-05-15 10:27:07 -04:00
|
|
|
# `uv sync --frozen --no-install-project --extra all --extra messaging`
|
|
|
|
|
# installs the deps reachable through the composite `[all]` extra
|
fix(docker): optimize image size — .dockerignore, drop dev deps, split build layers (#38749)
* fix(docker): optimize image size with .dockerignore, drop dev deps, split build layers
Three changes to reduce the Docker image size and speed up rebuilds:
1. .dockerignore — exclude ~69 MB of files that are never needed inside
the container: apps/ (desktop Tauri source), tests/, website/
(Docusaurus), docs/, infographic/, nix/, plans/, packaging/, and
various dotfiles (.envrc, .hadolint.yaml, .mailmap, etc.). The
existing .dockerignore already covered node_modules and .git; these
additions prevent the remaining non-runtime content from inflating
both the build context and the final image (COPY . .).
2. pyproject.toml — add a [docker] extra that mirrors [all] but omits
[dev] (debugpy, pytest, pytest-asyncio, pytest-timeout, ty, ruff,
setuptools). The published image doesn't need test/debug tooling.
Estimated savings: ~30-50 MB of Python packages.
3. Dockerfile — use --extra docker instead of --extra all in the
uv sync layer. Also split the COPY + npm run build so that the
web/ and ui-tui/ frontend builds are cached independently from
Python source changes (COPY . .). A Python-only commit no longer
invalidates the (slower) frontend build layer.
Note: the build-only apt packages (gcc, python3-dev, libffi-dev,
libolm-dev) are still installed in the final image. Removing them
requires a true multi-stage build (builder → runtime), which is a
larger refactor tracked separately.
* fix(docker): remove redundant [docker] extra, revert to --extra all
The [docker] extra was identical to [all] on main — the PR had added [dev]
to [all] then created [docker] as [all] minus [dev], a no-op round-trip.
Revert [all] to its original form and drop the [docker] extra.
Keep the .dockerignore additions and frontend build layer reordering.
2026-06-10 03:08:00 -07:00
|
|
|
# (handpicked set intended for the production image — excludes `[dev]`),
|
|
|
|
|
# plus gateway messaging adapters that should work in the published image
|
|
|
|
|
# without a first-boot lazy install. We do NOT use `--all-extras`:
|
2026-05-08 16:16:53 -04:00
|
|
|
# that would pull in `[rl]` (atroposlib + tinker + torch + wandb from
|
|
|
|
|
# git), `[yc-bench]` (another git dep), and `[termux-all]` (Android
|
|
|
|
|
# redundancy), none of which belong in the published container.
|
|
|
|
|
#
|
2026-05-22 23:58:55 +08:00
|
|
|
# Provider packages (anthropic, bedrock, azure-identity) are included
|
|
|
|
|
# so Docker users can use these providers without requiring runtime
|
|
|
|
|
# lazy-install access to PyPI (often blocked in containerized envs).
|
|
|
|
|
#
|
2026-06-04 09:17:35 +10:00
|
|
|
# The hindsight memory provider's client (hindsight-client) is baked in
|
|
|
|
|
# for the same reason: it lazy-installs into /opt/hermes/.venv at first
|
|
|
|
|
# use, which lives inside the (immutable) image layer rather than the
|
|
|
|
|
# mounted /opt/data volume, so it is lost on every container recreate /
|
|
|
|
|
# image update and recall/retain then fails with
|
|
|
|
|
# `ModuleNotFoundError: No module named 'hindsight_client'` (#38128).
|
|
|
|
|
#
|
2026-06-10 19:23:06 +10:00
|
|
|
# The Matrix gateway's deps ([matrix] extra) are baked in because
|
|
|
|
|
# python-olm (transitive via mautrix[encryption]) builds from source on
|
|
|
|
|
# Python/image combinations without usable wheels. The Docker image is
|
|
|
|
|
# Linux-only, so keeping the native libolm/build-toolchain packages here
|
|
|
|
|
# avoids the cross-platform failures that kept [matrix] out of [all]
|
|
|
|
|
# while still making Matrix work in the published container. Fixes #30399.
|
|
|
|
|
#
|
2026-05-08 16:16:53 -04:00
|
|
|
# The editable link is created after the source copy below.
|
|
|
|
|
COPY pyproject.toml uv.lock ./
|
|
|
|
|
RUN touch ./README.md
|
2026-06-10 19:23:06 +10:00
|
|
|
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight --extra matrix
|
2026-05-08 16:16:53 -04:00
|
|
|
|
fix(docker): optimize image size — .dockerignore, drop dev deps, split build layers (#38749)
* fix(docker): optimize image size with .dockerignore, drop dev deps, split build layers
Three changes to reduce the Docker image size and speed up rebuilds:
1. .dockerignore — exclude ~69 MB of files that are never needed inside
the container: apps/ (desktop Tauri source), tests/, website/
(Docusaurus), docs/, infographic/, nix/, plans/, packaging/, and
various dotfiles (.envrc, .hadolint.yaml, .mailmap, etc.). The
existing .dockerignore already covered node_modules and .git; these
additions prevent the remaining non-runtime content from inflating
both the build context and the final image (COPY . .).
2. pyproject.toml — add a [docker] extra that mirrors [all] but omits
[dev] (debugpy, pytest, pytest-asyncio, pytest-timeout, ty, ruff,
setuptools). The published image doesn't need test/debug tooling.
Estimated savings: ~30-50 MB of Python packages.
3. Dockerfile — use --extra docker instead of --extra all in the
uv sync layer. Also split the COPY + npm run build so that the
web/ and ui-tui/ frontend builds are cached independently from
Python source changes (COPY . .). A Python-only commit no longer
invalidates the (slower) frontend build layer.
Note: the build-only apt packages (gcc, python3-dev, libffi-dev,
libolm-dev) are still installed in the final image. Removing them
requires a true multi-stage build (builder → runtime), which is a
larger refactor tracked separately.
* fix(docker): remove redundant [docker] extra, revert to --extra all
The [docker] extra was identical to [all] on main — the PR had added [dev]
to [all] then created [docker] as [all] minus [dev], a no-op round-trip.
Revert [all] to its original form and drop the [docker] extra.
Keep the .dockerignore additions and frontend build layer reordering.
2026-06-10 03:08:00 -07:00
|
|
|
# ---------- Frontend build (cached independently from Python source) ----------
|
|
|
|
|
# Copy only the frontend source trees first so that Python-only changes don't
|
|
|
|
|
# invalidate the (relatively slow) web + ui-tui build layer.
|
|
|
|
|
COPY web/ web/
|
|
|
|
|
COPY ui-tui/ ui-tui/
|
2026-06-28 21:43:56 -05:00
|
|
|
COPY apps/shared/ apps/shared/
|
fix(docker): optimize image size — .dockerignore, drop dev deps, split build layers (#38749)
* fix(docker): optimize image size with .dockerignore, drop dev deps, split build layers
Three changes to reduce the Docker image size and speed up rebuilds:
1. .dockerignore — exclude ~69 MB of files that are never needed inside
the container: apps/ (desktop Tauri source), tests/, website/
(Docusaurus), docs/, infographic/, nix/, plans/, packaging/, and
various dotfiles (.envrc, .hadolint.yaml, .mailmap, etc.). The
existing .dockerignore already covered node_modules and .git; these
additions prevent the remaining non-runtime content from inflating
both the build context and the final image (COPY . .).
2. pyproject.toml — add a [docker] extra that mirrors [all] but omits
[dev] (debugpy, pytest, pytest-asyncio, pytest-timeout, ty, ruff,
setuptools). The published image doesn't need test/debug tooling.
Estimated savings: ~30-50 MB of Python packages.
3. Dockerfile — use --extra docker instead of --extra all in the
uv sync layer. Also split the COPY + npm run build so that the
web/ and ui-tui/ frontend builds are cached independently from
Python source changes (COPY . .). A Python-only commit no longer
invalidates the (slower) frontend build layer.
Note: the build-only apt packages (gcc, python3-dev, libffi-dev,
libolm-dev) are still installed in the final image. Removing them
requires a true multi-stage build (builder → runtime), which is a
larger refactor tracked separately.
* fix(docker): remove redundant [docker] extra, revert to --extra all
The [docker] extra was identical to [all] on main — the PR had added [dev]
to [all] then created [docker] as [all] minus [dev], a no-op round-trip.
Revert [all] to its original form and drop the [docker] extra.
Keep the .dockerignore additions and frontend build layer reordering.
2026-06-10 03:08:00 -07:00
|
|
|
RUN cd web && npm run build && \
|
|
|
|
|
cd ../ui-tui && npm run build
|
|
|
|
|
|
2026-04-18 10:14:31 -07:00
|
|
|
# ---------- Source code ----------
|
|
|
|
|
# .dockerignore excludes node_modules, so the installs above survive.
|
2026-06-19 11:56:30 -04:00
|
|
|
# --link decouples this layer from parents for cache purposes; --chmod bakes
|
|
|
|
|
# the final read-only permissions at copy time so we skip the separate
|
|
|
|
|
# `chmod -R` pass that previously walked ~30k files across the venv +
|
|
|
|
|
# node_modules + source (21s amd64 / 222s arm64 — #49113). `a+rX,go-w`
|
|
|
|
|
# gives the non-root hermes user read + traverse but no write; root retains
|
|
|
|
|
# write so the build steps below don't need chmod u+w dances.
|
|
|
|
|
COPY --link --chmod=a+rX,go-w . .
|
2026-04-19 00:50:24 +08:00
|
|
|
|
2026-04-21 19:12:15 +08:00
|
|
|
# ---------- Permissions ----------
|
2026-06-18 09:09:21 +10:00
|
|
|
# Link hermes-agent itself (editable). Deps are already installed in the
|
|
|
|
|
# cached layer above; `--no-deps` makes this a fast egg-link creation with no
|
|
|
|
|
# resolution or downloads.
|
|
|
|
|
RUN uv pip install --no-cache-dir --no-deps -e "."
|
|
|
|
|
|
2026-06-19 11:56:30 -04:00
|
|
|
# Wire the exec shim and install-method stamp. Files under /opt/hermes are
|
|
|
|
|
# already root-owned (COPY, uv sync, npm install all run as root) and
|
|
|
|
|
# read-only for the hermes user (go-w from the --chmod above).
|
|
|
|
|
|
2026-04-21 19:12:15 +08:00
|
|
|
USER root
|
2026-06-18 09:09:21 +10:00
|
|
|
RUN mkdir -p /opt/hermes/bin && \
|
|
|
|
|
cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \
|
|
|
|
|
chmod 0755 /opt/hermes/bin/hermes && \
|
2026-06-19 11:56:30 -04:00
|
|
|
printf 'docker\n' > /opt/hermes/.install_method
|
2026-06-18 14:14:41 +10:00
|
|
|
# The ``.install_method`` stamp is baked next to the running code (the install
|
|
|
|
|
# tree), NOT into $HERMES_HOME. $HERMES_HOME (/opt/data) is a shared data
|
|
|
|
|
# volume that is commonly bind-mounted from the host and even shared with a
|
|
|
|
|
# host-side Desktop/CLI install; stamping it at boot used to clobber that
|
|
|
|
|
# host install's marker and wrongly block its ``hermes update``. A code-scoped
|
|
|
|
|
# stamp is read first by detect_install_method() and is immune to the share.
|
2026-05-22 10:43:57 +10:00
|
|
|
# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
|
|
|
|
|
# the data volume. Each supervised service then drops to the hermes user via
|
|
|
|
|
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
|
|
|
|
|
# run as the default hermes user (UID 10000).
|
2026-04-21 19:12:15 +08:00
|
|
|
|
fix(docker): bake build-time git SHA into the image
`hermes dump` and the startup banner both call `git rev-parse HEAD` to
report the running commit, but `.dockerignore` line 2 excludes `.git` —
so inside the published image `hermes dump` shows
`version: ... [(unknown)]` and the banner drops its `· upstream <sha>`
suffix entirely. That makes support triage from container bug reports
impossible: we can't tell which commit the user is actually running.
Fix: thread the build-time SHA through as a Docker build-arg, write it
to `/opt/hermes/.hermes_build_sha` in the image, and have a new
`hermes_cli/build_info.get_build_sha()` read it as a fallback after the
existing live-git lookup fails. Output format is unchanged in both
callsites — same 8-char short SHA whether resolved live or baked.
Wiring:
- Dockerfile: `ARG HERMES_GIT_SHA=` + write-file step after the source
copy. Empty/missing arg → no file written → callers fall through to
live git (so local `docker build` without --build-arg is unchanged).
- docker-publish.yml: passes `HERMES_GIT_SHA=${{ github.sha }}` on all
four build-push-action steps (amd64/arm64, smoke-test + final push).
- dump.py:_get_git_commit() / banner.py:get_git_banner_state(): try
live git first, fall back to baked SHA, then to legacy `(unknown)`
/ None. Banner returns `upstream == local, ahead=0` because a built
image is by definition pinned to one commit.
Coverage:
- Unit tests cover build_info (file present/absent/empty/error,
truncation, whitespace), dump (live-git wins, both fallbacks,
identical output-format regression guard), and banner (no-repo +
baked, no-repo + no-sha, shallow-clone fallback).
- tests/docker/test_dump_build_sha.py is an integration regression
guard that runs against the real image, reads
`/opt/hermes/.hermes_build_sha`, and asserts `hermes dump` surfaces
its content (or stays at `(unknown)` if no file).
- Verified end-to-end: `docker build --build-arg HERMES_GIT_SHA=abc...`
→ `docker run ... dump` reports `[abc12345]`; without the build-arg
it reports `[(unknown)]` as before.
2026-05-28 14:28:45 +10:00
|
|
|
# ---------- Bake build-time git revision ----------
|
|
|
|
|
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
|
|
|
|
|
# container always returns nothing — meaning `hermes dump` reports
|
|
|
|
|
# "(unknown)" and the startup banner drops its `· upstream <sha>` suffix.
|
|
|
|
|
# That makes support triage from container bug reports impossible:
|
|
|
|
|
# we can't tell which commit the user is actually running.
|
|
|
|
|
#
|
|
|
|
|
# Fix: write the commit SHA passed via the HERMES_GIT_SHA build-arg to
|
|
|
|
|
# /opt/hermes/.hermes_build_sha at build time, and have
|
|
|
|
|
# hermes_cli/build_info.py read it at runtime. Both `hermes dump` and
|
|
|
|
|
# banner.get_git_banner_state() try the baked SHA first, then fall back
|
|
|
|
|
# to live `git rev-parse` for source installs (unchanged behaviour).
|
|
|
|
|
#
|
|
|
|
|
# The arg is optional — local `docker build` without --build-arg simply
|
|
|
|
|
# omits the file, and the runtime falls back to live-git lookup. CI
|
2026-06-23 19:32:33 -04:00
|
|
|
# (.github/workflows/docker.yml) passes ${{ github.sha }} so
|
fix(docker): bake build-time git SHA into the image
`hermes dump` and the startup banner both call `git rev-parse HEAD` to
report the running commit, but `.dockerignore` line 2 excludes `.git` —
so inside the published image `hermes dump` shows
`version: ... [(unknown)]` and the banner drops its `· upstream <sha>`
suffix entirely. That makes support triage from container bug reports
impossible: we can't tell which commit the user is actually running.
Fix: thread the build-time SHA through as a Docker build-arg, write it
to `/opt/hermes/.hermes_build_sha` in the image, and have a new
`hermes_cli/build_info.get_build_sha()` read it as a fallback after the
existing live-git lookup fails. Output format is unchanged in both
callsites — same 8-char short SHA whether resolved live or baked.
Wiring:
- Dockerfile: `ARG HERMES_GIT_SHA=` + write-file step after the source
copy. Empty/missing arg → no file written → callers fall through to
live git (so local `docker build` without --build-arg is unchanged).
- docker-publish.yml: passes `HERMES_GIT_SHA=${{ github.sha }}` on all
four build-push-action steps (amd64/arm64, smoke-test + final push).
- dump.py:_get_git_commit() / banner.py:get_git_banner_state(): try
live git first, fall back to baked SHA, then to legacy `(unknown)`
/ None. Banner returns `upstream == local, ahead=0` because a built
image is by definition pinned to one commit.
Coverage:
- Unit tests cover build_info (file present/absent/empty/error,
truncation, whitespace), dump (live-git wins, both fallbacks,
identical output-format regression guard), and banner (no-repo +
baked, no-repo + no-sha, shallow-clone fallback).
- tests/docker/test_dump_build_sha.py is an integration regression
guard that runs against the real image, reads
`/opt/hermes/.hermes_build_sha`, and asserts `hermes dump` surfaces
its content (or stays at `(unknown)` if no file).
- Verified end-to-end: `docker build --build-arg HERMES_GIT_SHA=abc...`
→ `docker run ... dump` reports `[abc12345]`; without the build-arg
it reports `[(unknown)]` as before.
2026-05-28 14:28:45 +10:00
|
|
|
# every published image has it.
|
|
|
|
|
ARG HERMES_GIT_SHA=
|
|
|
|
|
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
|
2026-06-19 11:56:30 -04:00
|
|
|
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha; \
|
fix(docker): bake build-time git SHA into the image
`hermes dump` and the startup banner both call `git rev-parse HEAD` to
report the running commit, but `.dockerignore` line 2 excludes `.git` —
so inside the published image `hermes dump` shows
`version: ... [(unknown)]` and the banner drops its `· upstream <sha>`
suffix entirely. That makes support triage from container bug reports
impossible: we can't tell which commit the user is actually running.
Fix: thread the build-time SHA through as a Docker build-arg, write it
to `/opt/hermes/.hermes_build_sha` in the image, and have a new
`hermes_cli/build_info.get_build_sha()` read it as a fallback after the
existing live-git lookup fails. Output format is unchanged in both
callsites — same 8-char short SHA whether resolved live or baked.
Wiring:
- Dockerfile: `ARG HERMES_GIT_SHA=` + write-file step after the source
copy. Empty/missing arg → no file written → callers fall through to
live git (so local `docker build` without --build-arg is unchanged).
- docker-publish.yml: passes `HERMES_GIT_SHA=${{ github.sha }}` on all
four build-push-action steps (amd64/arm64, smoke-test + final push).
- dump.py:_get_git_commit() / banner.py:get_git_banner_state(): try
live git first, fall back to baked SHA, then to legacy `(unknown)`
/ None. Banner returns `upstream == local, ahead=0` because a built
image is by definition pinned to one commit.
Coverage:
- Unit tests cover build_info (file present/absent/empty/error,
truncation, whitespace), dump (live-git wins, both fallbacks,
identical output-format regression guard), and banner (no-repo +
baked, no-repo + no-sha, shallow-clone fallback).
- tests/docker/test_dump_build_sha.py is an integration regression
guard that runs against the real image, reads
`/opt/hermes/.hermes_build_sha`, and asserts `hermes dump` surfaces
its content (or stays at `(unknown)` if no file).
- Verified end-to-end: `docker build --build-arg HERMES_GIT_SHA=abc...`
→ `docker run ... dump` reports `[abc12345]`; without the build-arg
it reports `[(unknown)]` as before.
2026-05-28 14:28:45 +10:00
|
|
|
fi
|
|
|
|
|
|
feat(docker)!: replace tini with s6-overlay as PID 1
BREAKING CHANGE: the container ENTRYPOINT is now /init (s6-overlay)
instead of /usr/bin/tini. Main hermes runs as the container CMD with
TTY inherited (preserving --tui), dashboard runs as a supervised s6-rc
service (HERMES_DASHBOARD=1 starts it; crashes auto-restart), and the
ground is laid for per-profile gateway supervision (Phase 3+4).
All five pre-s6 docker run invocation patterns continue to work
identically — verified by the Phase 0 docker harness:
docker run <image> → `hermes` with no args
docker run <image> chat -q "..." → `hermes chat -q ...` passthrough
docker run <image> sleep infinity → `sleep infinity` direct
docker run <image> bash → interactive bash
docker run -it <image> --tui → interactive Ink TUI
Phase 2 harness result: 12 passed, 2 xfailed (Phase 4 target). Hadolint
+ shellcheck pass cleanly.
Architecture pivot from plan v3 (documented in main-hermes/run header):
the plan called for main hermes to be an s6-supervised service, but
two real s6-overlay v3 mechanics blocked that — cont-init.d scripts
receive no arguments (CMD args are not visible to stage2-hook), and
`/run/s6/basedir/bin/halt` after writing the exit code did not
propagate the desired exit code (container exits 143). We use the
s6-overlay-native CMD pattern instead: main-wrapper.sh is the
container's main program (ENTRYPOINT prepends it so leading-dash
args like --version aren't intercepted by /init), exec's the final
program with stdin/stdout/stderr inherited, and the program's exit
code becomes the container exit code. main-hermes is now a no-op
`sleep infinity` slot kept for future supervised-gateway-container
modes. This trades "supervised restart of main hermes" for arg-
parity with the pre-s6 contract — main hermes was already unsupervised
under tini, so we lose nothing functional. Dashboard supervision is
the only new guarantee added by this phase.
Files added:
docker/main-wrapper.sh # arg routing + s6-setuidgid drop
docker/stage2-hook.sh # gosu-equivalent + chown + seed
docker/s6-rc.d/main-hermes/{type,run,dependencies.d/base}
docker/s6-rc.d/dashboard/{type,run,dependencies.d/base}
docker/s6-rc.d/user/contents.d/{main-hermes,dashboard}
Files changed:
Dockerfile: tini → s6-overlay install + ENTRYPOINT flip + service wiring
docker/entrypoint.sh: thin shim to stage2-hook.sh for back-compat
tests/docker/test_dashboard.py: add test_dashboard_restarts_after_crash
Refs: docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md
2026-05-21 15:33:25 +10:00
|
|
|
# ---------- 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
|
|
|
|
|
# /etc/cont-init.d/02-reconcile-profiles (Phase 4 Task 4.0).
|
|
|
|
|
COPY docker/s6-rc.d/ /etc/s6-overlay/s6-rc.d/
|
|
|
|
|
|
|
|
|
|
# stage2-hook handles UID/GID remap, volume chown, config seeding,
|
2026-05-22 10:43:57 +10:00
|
|
|
# skills sync — all the work the old entrypoint.sh did before
|
|
|
|
|
# `exec hermes`. Wired in as cont-init.d/01- so it
|
feat(docker): per-profile s6 supervision + container-restart reconciliation
Phase 4 of the s6-overlay supervision plan. Activates the Phase 3
S6ServiceManager by hooking it into the profile lifecycle and the
`hermes gateway start/stop/restart` dispatcher, and adds a cont-
init.d-time reconciliation pass that survives `docker restart`.
Task 4.0 — container-boot reconciliation:
/run/service/ is tmpfs, so every `docker restart` wipes every
per-profile gateway slot. /etc/cont-init.d/02-reconcile-profiles
invokes hermes_cli.container_boot.reconcile_profile_gateways() on
every boot, which walks $HERMES_HOME/profiles/<name>/, reads each
gateway_state.json, recreates the s6 service slot, and auto-starts
only those whose last state was 'running'. Other states
(stopped, starting, startup_failed, missing) register the slot
in the down state — avoiding crash-loops across restarts for a
gateway that was broken last boot. Per-profile outcome is recorded
to $HERMES_HOME/logs/container-boot.log.
Implementation: hermes_cli/container_boot.py + 12 unit tests.
Profile-marker is SOUL.md, not config.yaml, because `hermes profile
create` only seeds SOUL.md by default (config.yaml comes from
`hermes setup`).
Task 4.1 / 4.2 — profile create/delete hooks:
hermes_cli/profiles.py::create_profile now calls
_maybe_register_gateway_service(<canon>) at the end, which routes
through ServiceManager.register_profile_gateway when running on s6
and no-ops on host backends. delete_profile mirrors with
_maybe_unregister_gateway_service. _allocate_gateway_port produces
a deterministic SHA-256-derived port in [9200, 9800).
Task 4.3 — gateway dispatch + remove rejection arms:
_dispatch_via_service_manager_if_s6(action) intercepts
start/stop/restart at the top of each subcommand and routes them
through S6ServiceManager.{start,stop,restart}. The pre-Phase-4
`elif is_container():` rejection arms are kept as fallback for
pre-s6 containers / unsupported runtimes, but only ever fire when
detect_service_manager() != 's6'. install/uninstall under s6
print informational guidance pointing users at profile create/delete.
Removed the two xfail(strict=True) markers from
tests/docker/test_profile_gateway.py — both tests now pass strictly.
Task 4.4 — status reporting:
get_gateway_runtime_snapshot() reports
Manager: 's6 (container supervisor)' inside an s6 container instead
of 'docker (foreground)'.
Plan-vs-reality drift fixed in this commit:
- Plan's S6ServiceManager._render_run_script used
`gateway start --foreground --port {port}` — invented args; the
real CLI is `gateway run`. Switched accordingly. port arg
retained for API parity but now documented as 'currently ignored'.
- Plan's reconciler keyed on config.yaml; switched to SOUL.md
(config.yaml is created by hermes setup, not by hermes profile
create, so the original gate caught nothing).
- The plan's _dispatch helper used _profile_arg() which returns
'--profile <name>' (i.e. with the flag prefix). Switched to
_profile_suffix() which returns the bare name.
- Architecture B's docker exec doesn't get /command on PATH or
the venv on PATH; Dockerfile's runtime PATH now includes
/opt/hermes/.venv/bin so 'docker exec <c> hermes ...' works
without sourcing the venv.
- stage2-hook now chowns $HERMES_HOME/profiles to hermes on every
boot, not just on the UID-remap path. Without this, files created
by docker-exec-as-root accumulate and the next reconciler run
fails with PermissionError reading SOUL.md.
Test harness:
19 passed, 0 xfailed (the two pre-Phase-4 xfail targets flip to
passing). 78 unit tests across service_manager + container_boot +
profiles_s6_hooks + gateway_s6_dispatch. Hadolint + shellcheck
pass cleanly.
Refs: docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md
2026-05-21 16:56:51 +10:00
|
|
|
# runs before user services start.
|
|
|
|
|
#
|
|
|
|
|
# 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.
|
feat(docker)!: replace tini with s6-overlay as PID 1
BREAKING CHANGE: the container ENTRYPOINT is now /init (s6-overlay)
instead of /usr/bin/tini. Main hermes runs as the container CMD with
TTY inherited (preserving --tui), dashboard runs as a supervised s6-rc
service (HERMES_DASHBOARD=1 starts it; crashes auto-restart), and the
ground is laid for per-profile gateway supervision (Phase 3+4).
All five pre-s6 docker run invocation patterns continue to work
identically — verified by the Phase 0 docker harness:
docker run <image> → `hermes` with no args
docker run <image> chat -q "..." → `hermes chat -q ...` passthrough
docker run <image> sleep infinity → `sleep infinity` direct
docker run <image> bash → interactive bash
docker run -it <image> --tui → interactive Ink TUI
Phase 2 harness result: 12 passed, 2 xfailed (Phase 4 target). Hadolint
+ shellcheck pass cleanly.
Architecture pivot from plan v3 (documented in main-hermes/run header):
the plan called for main hermes to be an s6-supervised service, but
two real s6-overlay v3 mechanics blocked that — cont-init.d scripts
receive no arguments (CMD args are not visible to stage2-hook), and
`/run/s6/basedir/bin/halt` after writing the exit code did not
propagate the desired exit code (container exits 143). We use the
s6-overlay-native CMD pattern instead: main-wrapper.sh is the
container's main program (ENTRYPOINT prepends it so leading-dash
args like --version aren't intercepted by /init), exec's the final
program with stdin/stdout/stderr inherited, and the program's exit
code becomes the container exit code. main-hermes is now a no-op
`sleep infinity` slot kept for future supervised-gateway-container
modes. This trades "supervised restart of main hermes" for arg-
parity with the pre-s6 contract — main hermes was already unsupervised
under tini, so we lose nothing functional. Dashboard supervision is
the only new guarantee added by this phase.
Files added:
docker/main-wrapper.sh # arg routing + s6-setuidgid drop
docker/stage2-hook.sh # gosu-equivalent + chown + seed
docker/s6-rc.d/main-hermes/{type,run,dependencies.d/base}
docker/s6-rc.d/dashboard/{type,run,dependencies.d/base}
docker/s6-rc.d/user/contents.d/{main-hermes,dashboard}
Files changed:
Dockerfile: tini → s6-overlay install + ENTRYPOINT flip + service wiring
docker/entrypoint.sh: thin shim to stage2-hook.sh for back-compat
tests/docker/test_dashboard.py: add test_dashboard_restarts_after_crash
Refs: docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md
2026-05-21 15:33:25 +10:00
|
|
|
RUN mkdir -p /etc/cont-init.d && \
|
fix(docker): propagate container env through s6 to cont-init and main CMD
s6-overlay's /init scrubs the environment before invoking both
/etc/cont-init.d/* scripts and the container's CMD wrapper. As a
result, ENV directives from the Dockerfile (HERMES_HOME=/opt/data,
HERMES_WEB_DIST, …) and compose-time `environment:` entries
(HERMES_UID, HERMES_GID) never reached the scripts that actually
use them. Three concrete failures observed on macOS Docker Desktop
with `~/.hermes:/opt/data`:
* stage2-hook.sh ran with HERMES_UID unset → no UID remap, hermes
user stayed at UID 10000 instead of the host user's UID.
* skills_sync.py (invoked from stage2-hook) ran with HERMES_HOME
unset → get_hermes_home() fell back to Path.home()/.hermes,
populating a shadow $HERMES_HOME/.hermes/skills tree on the
mounted volume (visible on the host as ~/.hermes/.hermes/skills).
* The main `hermes gateway run` process inherited HOME=/root from
the /init context (s6-setuidgid doesn't update HOME), so
libraries resolving XDG_STATE_HOME via $HOME tried to write to
/root/.local/state/hermes/gateway-locks/ and failed with EACCES,
preventing the Discord adapter from acquiring its bot-token lock.
Three surgical changes restore correct env flow:
1. The auto-generated /etc/cont-init.d/01-hermes-setup wrapper now
uses `#!/command/with-contenv sh`, matching the pattern already
used by docker/cont-init.d/02-reconcile-profiles. The container
env (Dockerfile ENV + compose `environment:`) now reaches
stage2-hook.sh and the skills_sync.py subprocess it spawns.
2. docker/main-wrapper.sh also switches to `#!/command/with-contenv
sh`. The container CMD (`gateway run`, `chat`, `setup`, …) now
sees HERMES_HOME and the other container-level env vars.
3. docker/main-wrapper.sh exports HOME=/opt/data before
`s6-setuidgid hermes`. with-contenv populates HOME from the
/init context (/root); s6-setuidgid drops privileges but does
not update HOME. The hermes user's home per /etc/passwd is
/opt/data, so the explicit override matches passwd.
No behavior change for the non-buggy paths: the s6-supervised
services already used with-contenv, and HOME=/opt/data only affects
processes that resolved $HOME-based paths to /root (silently
broken).
2026-05-26 13:41:21 +09:00
|
|
|
printf '#!/command/with-contenv sh\nexec /opt/hermes/docker/stage2-hook.sh\n' \
|
feat(docker)!: replace tini with s6-overlay as PID 1
BREAKING CHANGE: the container ENTRYPOINT is now /init (s6-overlay)
instead of /usr/bin/tini. Main hermes runs as the container CMD with
TTY inherited (preserving --tui), dashboard runs as a supervised s6-rc
service (HERMES_DASHBOARD=1 starts it; crashes auto-restart), and the
ground is laid for per-profile gateway supervision (Phase 3+4).
All five pre-s6 docker run invocation patterns continue to work
identically — verified by the Phase 0 docker harness:
docker run <image> → `hermes` with no args
docker run <image> chat -q "..." → `hermes chat -q ...` passthrough
docker run <image> sleep infinity → `sleep infinity` direct
docker run <image> bash → interactive bash
docker run -it <image> --tui → interactive Ink TUI
Phase 2 harness result: 12 passed, 2 xfailed (Phase 4 target). Hadolint
+ shellcheck pass cleanly.
Architecture pivot from plan v3 (documented in main-hermes/run header):
the plan called for main hermes to be an s6-supervised service, but
two real s6-overlay v3 mechanics blocked that — cont-init.d scripts
receive no arguments (CMD args are not visible to stage2-hook), and
`/run/s6/basedir/bin/halt` after writing the exit code did not
propagate the desired exit code (container exits 143). We use the
s6-overlay-native CMD pattern instead: main-wrapper.sh is the
container's main program (ENTRYPOINT prepends it so leading-dash
args like --version aren't intercepted by /init), exec's the final
program with stdin/stdout/stderr inherited, and the program's exit
code becomes the container exit code. main-hermes is now a no-op
`sleep infinity` slot kept for future supervised-gateway-container
modes. This trades "supervised restart of main hermes" for arg-
parity with the pre-s6 contract — main hermes was already unsupervised
under tini, so we lose nothing functional. Dashboard supervision is
the only new guarantee added by this phase.
Files added:
docker/main-wrapper.sh # arg routing + s6-setuidgid drop
docker/stage2-hook.sh # gosu-equivalent + chown + seed
docker/s6-rc.d/main-hermes/{type,run,dependencies.d/base}
docker/s6-rc.d/dashboard/{type,run,dependencies.d/base}
docker/s6-rc.d/user/contents.d/{main-hermes,dashboard}
Files changed:
Dockerfile: tini → s6-overlay install + ENTRYPOINT flip + service wiring
docker/entrypoint.sh: thin shim to stage2-hook.sh for back-compat
tests/docker/test_dashboard.py: add test_dashboard_restarts_after_crash
Refs: docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md
2026-05-21 15:33:25 +10:00
|
|
|
> /etc/cont-init.d/01-hermes-setup && \
|
|
|
|
|
chmod +x /etc/cont-init.d/01-hermes-setup
|
fix(docker): make s6 lifecycle work for the unprivileged hermes user
Resolves the explicit "Known follow-up" left by commit 2f8ceeab9 and
the resulting CI failures in tests/docker/test_dashboard.py and
tests/docker/test_s6_profile_gateway_integration.py.
The product gap
---------------
Every hermes runtime operation inside the container runs as the
hermes user (UID 10000) via s6-setuidgid. But s6-supervise — spawned
by s6-svscan running as PID 1 — creates each service's supervise/
and top-level event/ directories with mode 0700 owned by its
effective UID (root). That left every s6-svc / s6-svstat / s6-svwait
call from hermes hitting EACCES on the supervise/control FIFO and
supervise/status — i.e. the entire S6ServiceManager lifecycle
(register, start, stop, unregister) was inert in production.
The 2f8ceeab9 commit message called this out and deferred the fix.
The audit changes that landed alongside it (defaulting docker_exec
to -u hermes) made the integration tests reproduce the bug
deterministically; the fix below resolves it.
The fix: pre-create the supervise/ skeleton hermes-owned
----------------------------------------------------------
Reading s6's source (src/supervision/s6-supervise.c::trymkdir +
control_init), the mkdir and mkfifo calls that build the supervise
tree are EEXIST-safe: if the directory or FIFO is already present,
s6-supervise reuses it and skips the chown/chmod fix-up that would
normally make event/ 03730 root:root. So if we lay the skeleton
down with hermes ownership before triggering s6-svscanctl -a,
s6-supervise inherits our layout and never touches it. The
death_tally / lock / status regular files written later by
s6-supervise (still as root) land mode 0644 — world-readable —
which is all s6-svstat needs.
New module-level helper _seed_supervise_skeleton(svc_dir) in
hermes_cli/service_manager.py lays down:
svc_dir/event/ hermes:hermes 03730
svc_dir/supervise/ hermes:hermes 0755
svc_dir/supervise/event/ hermes:hermes 03730
svc_dir/supervise/control hermes:hermes 0660 (FIFO)
svc_dir/log/event/ hermes:hermes 03730 (if log/ present)
svc_dir/log/supervise/ hermes:hermes 0755
svc_dir/log/supervise/event/ hermes:hermes 03730
svc_dir/log/supervise/control hermes:hermes 0660 (FIFO)
The log/ branch matters because the logger is a second
s6-supervise instance — without it, unregister rmtree races on
the logger's root-owned supervise dir even after the parent
slot's supervise/ is hermes-owned. The helper is idempotent and
swallows PermissionError on chown so it works equally well when
called from root (cont-init.d) or hermes (runtime register).
Wiring
------
1. S6ServiceManager.register_profile_gateway calls
_seed_supervise_skeleton(tmp_dir) just before publishing the
slot via Path.replace. Runtime-registered profile gateways are
set up by hermes.
2. container_boot._register_service does the same in the cont-init.d
reconciliation path so boot-time-restored profile slots inherit
the same layout.
3. New cont-init.d/015-supervise-perms script chowns the supervise/
and event/ trees for STATIC s6-rc services (dashboard,
main-hermes). These are spawned by s6-rc before cont-init.d
gets to run, so the EEXIST-trick doesn't apply; we chown the
already-existing tree instead. s6-supervise keeps using the
same files; it never re-asserts ownership on a running service.
The script skips s6-overlay internal services (s6rc-*,
s6-linux-*) so the supervision tree itself stays root-only.
015- slot is intentional: lex-sorts between 01-hermes-setup
and 02-reconcile-profiles in the container's C-locale, so
the chown finishes before the reconciler walks the scandir.
Unregister teardown reordering
------------------------------
S6ServiceManager.unregister_profile_gateway now fires
s6-svscanctl -an BEFORE rmtree (with a 200ms grace), so
s6-svscan reaps the supervise child and releases its file
handles on supervise/lock + supervise/status before we try to
remove the directory. Previously rmtree raced s6-supervise on a
set of files inside the supervise dir, and even with the parent
supervise/ now hermes-owned, the contained files (death_tally,
lock, status, written by root) could still be in use.
Dashboard down-state redesign
-----------------------------
The original PR #30136 review fix wrote a 'down' marker file
into /run/service/dashboard/ via cont-init.d/03-dashboard-toggle.
That approach was broken in two ways:
(a) /run/service/dashboard is a symlink to a TRANSIENT
/run/s6-rc:s6-rc-init:<tmpdir>/ directory while s6-rc is
mid-transaction; the touch landed in a soon-to-be-discarded
tmp.
(b) Even when written to the final /run/s6-rc/servicedirs/
location, the 'down' file is only consulted by s6-supervise
at slot startup. s6-rc's user-bundle explicitly transitions
'dashboard' to 'up' on every boot, overriding any down
marker.
The right fix is the canonical s6 pattern: when HERMES_DASHBOARD
is unset, the dashboard run script exits 0 and a companion
finish script exits 125. Per s6-supervise(8), exit code 125 from
the finish script is the 'permanent failure, do not restart'
marker — equivalent to s6-svc -O. The slot reports as 'down' to
s6-svstat, matching the reality that no dashboard process is
running. When HERMES_DASHBOARD IS truthy, finish exits 0 and
restart-on-crash semantics apply.
03-dashboard-toggle is removed (its function is now subsumed by
the run/finish pair).
Tests
-----
Adds four unit tests for _seed_supervise_skeleton covering the
produced layout, the log/ subservice case, the skip-when-no-log
case, and idempotency. The live-container verification continues
to live in tests/docker/test_s6_profile_gateway_integration.py and
tests/docker/test_dashboard.py — both now pass against the
rebuilt image.
References
----------
* Skarnet skaware mailing list 2020-02-02 (Laurent Bercot
+ Guillermo Diaz Hartusch) on unprivileged s6 tool semantics:
http://skarnet.org/lists/skaware/1424.html
* just-containers/s6-overlay#130 — same EEXIST-preseed pattern,
community-validated 2016 onward
* https://skarnet.org/software/s6/servicedir.html — exit-code 125
semantics in finish scripts
(cherry picked from commit c41f908ad46043728d884f4b1929435636cf1bcb)
2026-05-25 11:21:31 +10:00
|
|
|
COPY --chmod=0755 docker/cont-init.d/015-supervise-perms /etc/cont-init.d/015-supervise-perms
|
feat(docker): per-profile s6 supervision + container-restart reconciliation
Phase 4 of the s6-overlay supervision plan. Activates the Phase 3
S6ServiceManager by hooking it into the profile lifecycle and the
`hermes gateway start/stop/restart` dispatcher, and adds a cont-
init.d-time reconciliation pass that survives `docker restart`.
Task 4.0 — container-boot reconciliation:
/run/service/ is tmpfs, so every `docker restart` wipes every
per-profile gateway slot. /etc/cont-init.d/02-reconcile-profiles
invokes hermes_cli.container_boot.reconcile_profile_gateways() on
every boot, which walks $HERMES_HOME/profiles/<name>/, reads each
gateway_state.json, recreates the s6 service slot, and auto-starts
only those whose last state was 'running'. Other states
(stopped, starting, startup_failed, missing) register the slot
in the down state — avoiding crash-loops across restarts for a
gateway that was broken last boot. Per-profile outcome is recorded
to $HERMES_HOME/logs/container-boot.log.
Implementation: hermes_cli/container_boot.py + 12 unit tests.
Profile-marker is SOUL.md, not config.yaml, because `hermes profile
create` only seeds SOUL.md by default (config.yaml comes from
`hermes setup`).
Task 4.1 / 4.2 — profile create/delete hooks:
hermes_cli/profiles.py::create_profile now calls
_maybe_register_gateway_service(<canon>) at the end, which routes
through ServiceManager.register_profile_gateway when running on s6
and no-ops on host backends. delete_profile mirrors with
_maybe_unregister_gateway_service. _allocate_gateway_port produces
a deterministic SHA-256-derived port in [9200, 9800).
Task 4.3 — gateway dispatch + remove rejection arms:
_dispatch_via_service_manager_if_s6(action) intercepts
start/stop/restart at the top of each subcommand and routes them
through S6ServiceManager.{start,stop,restart}. The pre-Phase-4
`elif is_container():` rejection arms are kept as fallback for
pre-s6 containers / unsupported runtimes, but only ever fire when
detect_service_manager() != 's6'. install/uninstall under s6
print informational guidance pointing users at profile create/delete.
Removed the two xfail(strict=True) markers from
tests/docker/test_profile_gateway.py — both tests now pass strictly.
Task 4.4 — status reporting:
get_gateway_runtime_snapshot() reports
Manager: 's6 (container supervisor)' inside an s6 container instead
of 'docker (foreground)'.
Plan-vs-reality drift fixed in this commit:
- Plan's S6ServiceManager._render_run_script used
`gateway start --foreground --port {port}` — invented args; the
real CLI is `gateway run`. Switched accordingly. port arg
retained for API parity but now documented as 'currently ignored'.
- Plan's reconciler keyed on config.yaml; switched to SOUL.md
(config.yaml is created by hermes setup, not by hermes profile
create, so the original gate caught nothing).
- The plan's _dispatch helper used _profile_arg() which returns
'--profile <name>' (i.e. with the flag prefix). Switched to
_profile_suffix() which returns the bare name.
- Architecture B's docker exec doesn't get /command on PATH or
the venv on PATH; Dockerfile's runtime PATH now includes
/opt/hermes/.venv/bin so 'docker exec <c> hermes ...' works
without sourcing the venv.
- stage2-hook now chowns $HERMES_HOME/profiles to hermes on every
boot, not just on the UID-remap path. Without this, files created
by docker-exec-as-root accumulate and the next reconciler run
fails with PermissionError reading SOUL.md.
Test harness:
19 passed, 0 xfailed (the two pre-Phase-4 xfail targets flip to
passing). 78 unit tests across service_manager + container_boot +
profiles_s6_hooks + gateway_s6_dispatch. Hadolint + shellcheck
pass cleanly.
Refs: docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md
2026-05-21 16:56:51 +10:00
|
|
|
COPY --chmod=0755 docker/cont-init.d/02-reconcile-profiles /etc/cont-init.d/02-reconcile-profiles
|
feat(docker)!: replace tini with s6-overlay as PID 1
BREAKING CHANGE: the container ENTRYPOINT is now /init (s6-overlay)
instead of /usr/bin/tini. Main hermes runs as the container CMD with
TTY inherited (preserving --tui), dashboard runs as a supervised s6-rc
service (HERMES_DASHBOARD=1 starts it; crashes auto-restart), and the
ground is laid for per-profile gateway supervision (Phase 3+4).
All five pre-s6 docker run invocation patterns continue to work
identically — verified by the Phase 0 docker harness:
docker run <image> → `hermes` with no args
docker run <image> chat -q "..." → `hermes chat -q ...` passthrough
docker run <image> sleep infinity → `sleep infinity` direct
docker run <image> bash → interactive bash
docker run -it <image> --tui → interactive Ink TUI
Phase 2 harness result: 12 passed, 2 xfailed (Phase 4 target). Hadolint
+ shellcheck pass cleanly.
Architecture pivot from plan v3 (documented in main-hermes/run header):
the plan called for main hermes to be an s6-supervised service, but
two real s6-overlay v3 mechanics blocked that — cont-init.d scripts
receive no arguments (CMD args are not visible to stage2-hook), and
`/run/s6/basedir/bin/halt` after writing the exit code did not
propagate the desired exit code (container exits 143). We use the
s6-overlay-native CMD pattern instead: main-wrapper.sh is the
container's main program (ENTRYPOINT prepends it so leading-dash
args like --version aren't intercepted by /init), exec's the final
program with stdin/stdout/stderr inherited, and the program's exit
code becomes the container exit code. main-hermes is now a no-op
`sleep infinity` slot kept for future supervised-gateway-container
modes. This trades "supervised restart of main hermes" for arg-
parity with the pre-s6 contract — main hermes was already unsupervised
under tini, so we lose nothing functional. Dashboard supervision is
the only new guarantee added by this phase.
Files added:
docker/main-wrapper.sh # arg routing + s6-setuidgid drop
docker/stage2-hook.sh # gosu-equivalent + chown + seed
docker/s6-rc.d/main-hermes/{type,run,dependencies.d/base}
docker/s6-rc.d/dashboard/{type,run,dependencies.d/base}
docker/s6-rc.d/user/contents.d/{main-hermes,dashboard}
Files changed:
Dockerfile: tini → s6-overlay install + ENTRYPOINT flip + service wiring
docker/entrypoint.sh: thin shim to stage2-hook.sh for back-compat
tests/docker/test_dashboard.py: add test_dashboard_restarts_after_crash
Refs: docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md
2026-05-21 15:33:25 +10:00
|
|
|
|
2026-04-18 10:14:31 -07:00
|
|
|
# ---------- Runtime ----------
|
|
|
|
|
ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
|
2026-06-03 15:30:45 +10:00
|
|
|
# Point the TUI launcher at the prebuilt bundle baked at build time (Layer 8:
|
|
|
|
|
# `ui-tui && npm run build`). This makes _make_tui_argv take the prebuilt-bundle
|
|
|
|
|
# fast path (`node --expose-gc /opt/hermes/ui-tui/dist/entry.js`) and skip the
|
|
|
|
|
# _tui_need_npm_install / runtime `npm install` branch entirely — exactly the
|
|
|
|
|
# nix/packaged-release path the launcher was designed for.
|
|
|
|
|
#
|
|
|
|
|
# Why this is required (not just an optimization): the root package-lock.json
|
|
|
|
|
# describes the WHOLE monorepo workspace set (root + web + ui-tui + apps/*),
|
|
|
|
|
# but the image only installs root/web/ui-tui (apps/* — the desktop app — is
|
|
|
|
|
# never `npm install`ed here). So the actualized node_modules permanently
|
|
|
|
|
# disagrees with the canonical lock, _tui_need_npm_install() returns True on
|
|
|
|
|
# every launch, and the runtime `npm install` it triggers (a) can never
|
|
|
|
|
# converge against the partial monorepo and (b) races itself across concurrent
|
|
|
|
|
# embedded-chat (/api/pty) connections → ENOTEMPTY → the chat tab dies with a
|
|
|
|
|
# 502 / "[session ended]". Pointing at the prebuilt bundle sidesteps the whole
|
|
|
|
|
# check. (A separate launcher hardening is tracked independently.)
|
|
|
|
|
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
|
feat(docker): add Docker container for the agent (salvage #1841) (#3668)
Adds a complete Docker packaging for Hermes Agent:
- Dockerfile based on debian:13.4 with all deps
- Entrypoint that bootstraps .env, config.yaml, SOUL.md on first run
- CI workflow to build, test, and push to DockerHub
- Documentation for interactive, gateway, and upgrade workflows
Closes #850, #913.
Changes vs original PR:
- Removed pre-created legacy cache/platform dirs from entrypoint
(image_cache, audio_cache, pairing, whatsapp/session) — these are
now created on demand by the application using the consolidated
layout from get_hermes_dir()
- Moved docs from docs/docker.md to website/docs/user-guide/docker.md
and added to Docusaurus sidebar
Co-authored-by: benbarclay <benbarclay@users.noreply.github.com>
2026-03-28 22:21:48 -07:00
|
|
|
ENV HERMES_HOME=/opt/data
|
2026-06-18 09:09:21 +10:00
|
|
|
ENV HERMES_WRITE_SAFE_ROOT=/opt/data
|
|
|
|
|
ENV HERMES_DISABLE_LAZY_INSTALLS=1
|
fix(docker): redirect lazy installs to a durable target so opt-in backends work in the immutable image (#51136)
The published Docker image seals the agent venv (root-owned, read-only
/opt/hermes) and sets HERMES_DISABLE_LAZY_INSTALLS=1 so a runtime install
can't mutate and brick the core. But opt-in backends (Firecrawl web search,
Exa, Feishu, ...) deliberately keep their SDKs in tools/lazy_deps.py and out
of [all] (pyproject policy 2026-05-12: one quarantined release must not break
every install). The two policies collided: the SDK isn't baked in AND can't
lazy-install, so the default Firecrawl web_search/web_extract fail out of the
box in Docker (#51136), as do Exa (#49445) and Feishu (#50205).
Fix the whole class instead of baking in one backend: when
HERMES_LAZY_INSTALL_TARGET is set, lazy installs are redirected to a writable
dir on the durable /opt/data volume via `pip/uv install --target`, and that
dir is APPENDED to the end of sys.path. Because the core venv always wins
name collisions, a package installed this way can only ADD new modules — it
can never shadow, downgrade, or break a module the core ships. The worst a
bad/incompatible backend package can do is fail to import and report itself
unavailable; the agent core stays healthy. That structural guarantee is what
made it safe to seal the venv, and it is preserved here even with installs
re-enabled.
- tools/lazy_deps.py: durable-target mode — `--target` install + core-pinned
`--constraint` file (shared deps resolve to core's versions, conflicts fail
loudly at install time), append-only sys.path activation, ABI/Python-version
stamp that wipes the store if an image rebuild bumps the interpreter, and a
reworked gate so HERMES_DISABLE_LAZY_INSTALLS=1 redirects (rather than hard-
blocks) when a target is set. security.allow_lazy_installs=false still
disables installs in every mode.
- hermes_bootstrap.py: activate the durable target on sys.path at first import
(before any backend imports its SDK) so packages installed on a previous run
are importable on this run.
- Dockerfile: set HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages.
- docker/stage2-hook.sh: seed + chown the dir on the data volume.
- tests: real-install E2E proving installs land in the target, import cleanly,
don't leak into the sealed venv, and that a core package is never shadowed;
ABI-stamp wipe/preserve; gate matrix; Dockerfile/stage2 contract test.
Fixes #51136
2026-06-23 20:47:01 +10:00
|
|
|
# The published image seals /opt/hermes (root-owned, read-only) so a runtime
|
|
|
|
|
# lazy install can't mutate the agent's own venv and brick it. But opt-in
|
|
|
|
|
# backends (Firecrawl web search, Exa, Feishu, …) keep their SDKs in
|
|
|
|
|
# tools/lazy_deps.py — deliberately NOT baked into [all] (see pyproject.toml
|
|
|
|
|
# policy 2026-05-12: one quarantined release must not break every install).
|
|
|
|
|
# Redirect those lazy installs to a writable dir on the durable data volume.
|
|
|
|
|
# lazy_deps appends this dir to the END of sys.path, so a package installed
|
|
|
|
|
# here can only ADD modules — it can never shadow or downgrade a core module,
|
|
|
|
|
# so the sealed-venv guarantee holds even with installs re-enabled. The dir
|
|
|
|
|
# is seeded + chowned to the hermes user by docker/stage2-hook.sh and lives
|
|
|
|
|
# on the /opt/data volume, so it persists across container recreates / image
|
|
|
|
|
# updates (an ABI stamp invalidates it if a rebuild bumps the interpreter).
|
|
|
|
|
ENV HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages
|
fix(docker): drop `docker exec` to hermes uid before invoking the CLI
When operators ran `docker exec <c> hermes login` (or anything else
that wrote under $HERMES_HOME) they defaulted to root, leaving
/opt/data/auth.json root:root mode 0600. The supervised gateway
(UID 10000) then couldn't read its own credentials and returned
"Provider authentication failed: Hermes is not logged into Nous
Portal" on every Telegram/Discord/etc. message — even though
`docker exec <c> hermes chat -q ping` (also root) succeeded because
root could read its own root-owned file. _load_auth_store swallowed
PermissionError as a parse failure and copied the file aside as
auth.json.corrupt, making the diagnostic more misleading.
Fix: install a privilege-drop shim at /opt/hermes/bin/hermes,
prepended ahead of the venv on PATH. When invoked as root the shim
exec's the real venv binary via `s6-setuidgid hermes` — so any file
the docker-exec session writes is uid-aligned with the supervised
processes. Non-root callers (the supervised processes themselves,
`docker exec --user hermes`, kanban subagents, anything inside the
container that's not coming through docker-exec) hit a single exec
to the absolute venv path with no privilege change.
Recursion is impossible: the shim exec's the venv binary by
absolute path (/opt/hermes/.venv/bin/hermes), so the second hop
cannot re-enter the shim regardless of PATH state. No sentinel env
var needed (unlike #33583's gateway-run redirect which DOES need
HERMES_S6_SUPERVISED_CHILD because there's no absolute-path
equivalent for the s6 dispatch).
Opt-out: `docker exec -e HERMES_DOCKER_EXEC_AS_ROOT=1 …` for
diagnostic sessions where the operator deliberately wants root.
Strict truthiness (1/true/yes case-insensitive); typos like `=0`
do not silently opt out, mirroring HERMES_GATEWAY_NO_SUPERVISE in
#33583.
If `s6-setuidgid` is missing (someone stripped s6-overlay in a
downstream fork), the shim exits 126 with a remediation message
pointing at `--user hermes` and the opt-out — never silently runs
as root.
Test plan:
- tests/docker/test_docker_exec_privilege_drop.py — 11 tests
- shim drops root to hermes uid (file ownership check)
- shim short-circuits for non-root docker exec
- HERMES_DOCKER_EXEC_AS_ROOT=1 keeps root
- strict-truthiness parametrization (5 falsy values reject)
- main CMD path unaffected (recursion guard)
- E2E: every file written by docker-exec is readable by uid 10000
- Full tests/docker/ harness: 32/32 pass against fresh image build
- shellcheck --severity=error: clean
- hadolint: clean
- Manual: reproduced the original symptom (root-owned auth.json)
by bypassing the shim; confirmed default docker-exec produces
hermes-owned files; confirmed opt-out env keeps root semantics.
Known follow-up: this prevents NEW instances of the bug. Volumes
that already have root:root /opt/data/auth.json from a pre-shim
image need a one-time `chown hermes:hermes` before rebooting onto
the new image. A stage2-hook chown sweep can self-heal that, but
is deferred per scope decision.
2026-05-28 13:27:26 +10:00
|
|
|
|
|
|
|
|
# `docker exec` privilege-drop shim. When operators run
|
|
|
|
|
# `docker exec <c> hermes ...` they default to root, and any file the
|
|
|
|
|
# command writes under $HERMES_HOME (auth.json, .env, config.yaml) ends
|
|
|
|
|
# up root-owned and unreadable to the supervised gateway (UID 10000).
|
|
|
|
|
# The shim lives at /opt/hermes/bin/hermes, sits earliest on PATH, and
|
|
|
|
|
# transparently re-exec's the real venv binary via `s6-setuidgid hermes`
|
|
|
|
|
# when invoked as root. Non-root callers (supervised processes,
|
|
|
|
|
# `--user hermes`, etc.) hit the short-circuit path with no overhead.
|
|
|
|
|
# Recursion is impossible because the shim exec's the venv binary by
|
|
|
|
|
# absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for
|
|
|
|
|
# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
|
|
|
|
|
|
feat(docker): per-profile s6 supervision + container-restart reconciliation
Phase 4 of the s6-overlay supervision plan. Activates the Phase 3
S6ServiceManager by hooking it into the profile lifecycle and the
`hermes gateway start/stop/restart` dispatcher, and adds a cont-
init.d-time reconciliation pass that survives `docker restart`.
Task 4.0 — container-boot reconciliation:
/run/service/ is tmpfs, so every `docker restart` wipes every
per-profile gateway slot. /etc/cont-init.d/02-reconcile-profiles
invokes hermes_cli.container_boot.reconcile_profile_gateways() on
every boot, which walks $HERMES_HOME/profiles/<name>/, reads each
gateway_state.json, recreates the s6 service slot, and auto-starts
only those whose last state was 'running'. Other states
(stopped, starting, startup_failed, missing) register the slot
in the down state — avoiding crash-loops across restarts for a
gateway that was broken last boot. Per-profile outcome is recorded
to $HERMES_HOME/logs/container-boot.log.
Implementation: hermes_cli/container_boot.py + 12 unit tests.
Profile-marker is SOUL.md, not config.yaml, because `hermes profile
create` only seeds SOUL.md by default (config.yaml comes from
`hermes setup`).
Task 4.1 / 4.2 — profile create/delete hooks:
hermes_cli/profiles.py::create_profile now calls
_maybe_register_gateway_service(<canon>) at the end, which routes
through ServiceManager.register_profile_gateway when running on s6
and no-ops on host backends. delete_profile mirrors with
_maybe_unregister_gateway_service. _allocate_gateway_port produces
a deterministic SHA-256-derived port in [9200, 9800).
Task 4.3 — gateway dispatch + remove rejection arms:
_dispatch_via_service_manager_if_s6(action) intercepts
start/stop/restart at the top of each subcommand and routes them
through S6ServiceManager.{start,stop,restart}. The pre-Phase-4
`elif is_container():` rejection arms are kept as fallback for
pre-s6 containers / unsupported runtimes, but only ever fire when
detect_service_manager() != 's6'. install/uninstall under s6
print informational guidance pointing users at profile create/delete.
Removed the two xfail(strict=True) markers from
tests/docker/test_profile_gateway.py — both tests now pass strictly.
Task 4.4 — status reporting:
get_gateway_runtime_snapshot() reports
Manager: 's6 (container supervisor)' inside an s6 container instead
of 'docker (foreground)'.
Plan-vs-reality drift fixed in this commit:
- Plan's S6ServiceManager._render_run_script used
`gateway start --foreground --port {port}` — invented args; the
real CLI is `gateway run`. Switched accordingly. port arg
retained for API parity but now documented as 'currently ignored'.
- Plan's reconciler keyed on config.yaml; switched to SOUL.md
(config.yaml is created by hermes setup, not by hermes profile
create, so the original gate caught nothing).
- The plan's _dispatch helper used _profile_arg() which returns
'--profile <name>' (i.e. with the flag prefix). Switched to
_profile_suffix() which returns the bare name.
- Architecture B's docker exec doesn't get /command on PATH or
the venv on PATH; Dockerfile's runtime PATH now includes
/opt/hermes/.venv/bin so 'docker exec <c> hermes ...' works
without sourcing the venv.
- stage2-hook now chowns $HERMES_HOME/profiles to hermes on every
boot, not just on the UID-remap path. Without this, files created
by docker-exec-as-root accumulate and the next reconciler run
fails with PermissionError reading SOUL.md.
Test harness:
19 passed, 0 xfailed (the two pre-Phase-4 xfail targets flip to
passing). 78 unit tests across service_manager + container_boot +
profiles_s6_hooks + gateway_s6_dispatch. Hadolint + shellcheck
pass cleanly.
Refs: docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md
2026-05-21 16:56:51 +10:00
|
|
|
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
|
|
|
|
|
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the
|
|
|
|
|
# same for the container's main process, but `docker exec` and our
|
|
|
|
|
# cont-init.d scripts don't pass through the wrapper. Expose the venv
|
|
|
|
|
# bin globally so `docker exec <container> hermes ...` and any
|
|
|
|
|
# subprocess that doesn't activate the venv first still find hermes.
|
fix(docker): drop `docker exec` to hermes uid before invoking the CLI
When operators ran `docker exec <c> hermes login` (or anything else
that wrote under $HERMES_HOME) they defaulted to root, leaving
/opt/data/auth.json root:root mode 0600. The supervised gateway
(UID 10000) then couldn't read its own credentials and returned
"Provider authentication failed: Hermes is not logged into Nous
Portal" on every Telegram/Discord/etc. message — even though
`docker exec <c> hermes chat -q ping` (also root) succeeded because
root could read its own root-owned file. _load_auth_store swallowed
PermissionError as a parse failure and copied the file aside as
auth.json.corrupt, making the diagnostic more misleading.
Fix: install a privilege-drop shim at /opt/hermes/bin/hermes,
prepended ahead of the venv on PATH. When invoked as root the shim
exec's the real venv binary via `s6-setuidgid hermes` — so any file
the docker-exec session writes is uid-aligned with the supervised
processes. Non-root callers (the supervised processes themselves,
`docker exec --user hermes`, kanban subagents, anything inside the
container that's not coming through docker-exec) hit a single exec
to the absolute venv path with no privilege change.
Recursion is impossible: the shim exec's the venv binary by
absolute path (/opt/hermes/.venv/bin/hermes), so the second hop
cannot re-enter the shim regardless of PATH state. No sentinel env
var needed (unlike #33583's gateway-run redirect which DOES need
HERMES_S6_SUPERVISED_CHILD because there's no absolute-path
equivalent for the s6 dispatch).
Opt-out: `docker exec -e HERMES_DOCKER_EXEC_AS_ROOT=1 …` for
diagnostic sessions where the operator deliberately wants root.
Strict truthiness (1/true/yes case-insensitive); typos like `=0`
do not silently opt out, mirroring HERMES_GATEWAY_NO_SUPERVISE in
#33583.
If `s6-setuidgid` is missing (someone stripped s6-overlay in a
downstream fork), the shim exits 126 with a remediation message
pointing at `--user hermes` and the opt-out — never silently runs
as root.
Test plan:
- tests/docker/test_docker_exec_privilege_drop.py — 11 tests
- shim drops root to hermes uid (file ownership check)
- shim short-circuits for non-root docker exec
- HERMES_DOCKER_EXEC_AS_ROOT=1 keeps root
- strict-truthiness parametrization (5 falsy values reject)
- main CMD path unaffected (recursion guard)
- E2E: every file written by docker-exec is readable by uid 10000
- Full tests/docker/ harness: 32/32 pass against fresh image build
- shellcheck --severity=error: clean
- hadolint: clean
- Manual: reproduced the original symptom (root-owned auth.json)
by bypassing the shim; confirmed default docker-exec produces
hermes-owned files; confirmed opt-out env keeps root semantics.
Known follow-up: this prevents NEW instances of the bug. Volumes
that already have root:root /opt/data/auth.json from a pre-shim
image need a one-time `chown hermes:hermes` before rebooting onto
the new image. A stage2-hook chown sweep can self-heal that, but
is deferred per scope decision.
2026-05-28 13:27:26 +10:00
|
|
|
#
|
|
|
|
|
# /opt/hermes/bin is prepended ahead of the venv so the privilege-drop
|
|
|
|
|
# shim wins PATH resolution. The shim's last act is to exec the venv
|
|
|
|
|
# binary by absolute path, so this PATH ordering is transparent to
|
|
|
|
|
# every other consumer.
|
|
|
|
|
ENV PATH="/opt/hermes/bin:/opt/hermes/.venv/bin:/opt/data/.local/bin:${PATH}"
|
2026-05-18 16:34:10 +05:30
|
|
|
RUN mkdir -p /opt/data
|
feat(docker): add Docker container for the agent (salvage #1841) (#3668)
Adds a complete Docker packaging for Hermes Agent:
- Dockerfile based on debian:13.4 with all deps
- Entrypoint that bootstraps .env, config.yaml, SOUL.md on first run
- CI workflow to build, test, and push to DockerHub
- Documentation for interactive, gateway, and upgrade workflows
Closes #850, #913.
Changes vs original PR:
- Removed pre-created legacy cache/platform dirs from entrypoint
(image_cache, audio_cache, pairing, whatsapp/session) — these are
now created on demand by the application using the consolidated
layout from get_hermes_dir()
- Moved docs from docs/docker.md to website/docs/user-guide/docker.md
and added to Docusaurus sidebar
Co-authored-by: benbarclay <benbarclay@users.noreply.github.com>
2026-03-28 22:21:48 -07:00
|
|
|
VOLUME [ "/opt/data" ]
|
feat(docker)!: replace tini with s6-overlay as PID 1
BREAKING CHANGE: the container ENTRYPOINT is now /init (s6-overlay)
instead of /usr/bin/tini. Main hermes runs as the container CMD with
TTY inherited (preserving --tui), dashboard runs as a supervised s6-rc
service (HERMES_DASHBOARD=1 starts it; crashes auto-restart), and the
ground is laid for per-profile gateway supervision (Phase 3+4).
All five pre-s6 docker run invocation patterns continue to work
identically — verified by the Phase 0 docker harness:
docker run <image> → `hermes` with no args
docker run <image> chat -q "..." → `hermes chat -q ...` passthrough
docker run <image> sleep infinity → `sleep infinity` direct
docker run <image> bash → interactive bash
docker run -it <image> --tui → interactive Ink TUI
Phase 2 harness result: 12 passed, 2 xfailed (Phase 4 target). Hadolint
+ shellcheck pass cleanly.
Architecture pivot from plan v3 (documented in main-hermes/run header):
the plan called for main hermes to be an s6-supervised service, but
two real s6-overlay v3 mechanics blocked that — cont-init.d scripts
receive no arguments (CMD args are not visible to stage2-hook), and
`/run/s6/basedir/bin/halt` after writing the exit code did not
propagate the desired exit code (container exits 143). We use the
s6-overlay-native CMD pattern instead: main-wrapper.sh is the
container's main program (ENTRYPOINT prepends it so leading-dash
args like --version aren't intercepted by /init), exec's the final
program with stdin/stdout/stderr inherited, and the program's exit
code becomes the container exit code. main-hermes is now a no-op
`sleep infinity` slot kept for future supervised-gateway-container
modes. This trades "supervised restart of main hermes" for arg-
parity with the pre-s6 contract — main hermes was already unsupervised
under tini, so we lose nothing functional. Dashboard supervision is
the only new guarantee added by this phase.
Files added:
docker/main-wrapper.sh # arg routing + s6-setuidgid drop
docker/stage2-hook.sh # gosu-equivalent + chown + seed
docker/s6-rc.d/main-hermes/{type,run,dependencies.d/base}
docker/s6-rc.d/dashboard/{type,run,dependencies.d/base}
docker/s6-rc.d/user/contents.d/{main-hermes,dashboard}
Files changed:
Dockerfile: tini → s6-overlay install + ENTRYPOINT flip + service wiring
docker/entrypoint.sh: thin shim to stage2-hook.sh for back-compat
tests/docker/test_dashboard.py: add test_dashboard_restarts_after_crash
Refs: docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md
2026-05-21 15:33:25 +10:00
|
|
|
|
|
|
|
|
# s6-overlay's /init is PID 1. It sets up the supervision tree, runs
|
|
|
|
|
# /etc/cont-init.d/* (our stage2 hook), starts s6-rc services
|
|
|
|
|
# declared in /etc/s6-overlay/s6-rc.d/, then exec's its remaining
|
|
|
|
|
# argv as the container's "main program" with stdin/stdout/stderr
|
|
|
|
|
# inherited (this is what makes interactive --tui work). When the
|
|
|
|
|
# main program exits, /init begins stage 3 shutdown and the container
|
|
|
|
|
# exits with the program's exit code. Replaces tini — see Phase 2 of
|
|
|
|
|
# docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md.
|
|
|
|
|
#
|
|
|
|
|
# We use the ENTRYPOINT+CMD split rather than CMD alone so the
|
|
|
|
|
# wrapper is prepended to user-supplied args automatically:
|
|
|
|
|
#
|
|
|
|
|
# docker run <image> → /init main-wrapper.sh (CMD default)
|
|
|
|
|
# docker run <image> chat -q "hi" → /init main-wrapper.sh chat -q hi
|
|
|
|
|
# docker run <image> sleep infinity → /init main-wrapper.sh sleep infinity
|
|
|
|
|
# docker run <image> --tui → /init main-wrapper.sh --tui
|
|
|
|
|
#
|
|
|
|
|
# main-wrapper.sh handles arg routing (bare-exec vs. hermes
|
|
|
|
|
# subcommand vs. no-args), drops to the hermes user via s6-setuidgid,
|
|
|
|
|
# and exec's the final program so its exit code becomes the container
|
|
|
|
|
# exit code. Without the wrapper-as-ENTRYPOINT, leading-dash args
|
|
|
|
|
# like `--version` would be intercepted by /init's POSIX shell.
|
|
|
|
|
ENTRYPOINT [ "/init", "/opt/hermes/docker/main-wrapper.sh" ]
|
|
|
|
|
CMD [ ]
|