Build TUI, web, desktop UI and runnable agent products from explicit prepared inputs. Keep dependency preparation separate from distribution packaging, with PM and native builds sharing uv environment construction. Docker copies compiled frontend products instead of build dependencies. Nix retains uv2nix environments and consumes shared assembly through store references. Native desktop and Termux use the same launcher and frontend contracts. Preserve the independent PM runtime and source imports from arbitrary working directories. Keep failed frontend builds from replacing the previous product, reject source/output overlap, and bound dependency-process output draining. Include hermes_wisdom in the Nix wheel: real CLI smoke tests exposed its missing package declaration on the base revision too. Verified focused Python and JavaScript suites, Docker build/runtime checks, Nix desktop and CLI/ACP checks, standalone TUI and packaged Electron PTY, and real full-Chromium interaction. Native signed installers, Android device installation and the full repository suite remain CI verification.
508 lines
29 KiB
Docker
508 lines
29 KiB
Docker
# Debian 13 still ships SQLite 3.46.1, which contains the upstream WAL-reset
|
|
# corruption bug. Build a pinned shared library for the runtime image instead
|
|
# of relying on a distro backport that trixie does not currently provide.
|
|
# See #70480 and https://sqlite.org/wal.html#walresetbug.
|
|
FROM debian:13.4 AS sqlite_build
|
|
ARG SQLITE_AUTOCONF_VERSION=3530400
|
|
ARG SQLITE_SHA256=0e9483900e92cd5de8fd48d16bf9200145a61f7fd5be542a5ac81d8a9516eb9c
|
|
RUN apt-get -o Acquire::Retries=3 update && \
|
|
apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
|
|
build-essential ca-certificates curl && \
|
|
rm -rf /var/lib/apt/lists/* && \
|
|
(curl -fsSL --retry 1 --retry-all-errors --connect-timeout 15 --max-time 60 \
|
|
-o /tmp/sqlite.tar.gz \
|
|
"https://sqlite.org/2026/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz" || \
|
|
curl -fsSL --retry 3 --retry-all-errors --connect-timeout 15 --max-time 120 \
|
|
-o /tmp/sqlite.tar.gz \
|
|
"https://sources.buildroot.net/sqlite/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz") && \
|
|
printf '%s %s\n' "${SQLITE_SHA256}" /tmp/sqlite.tar.gz > /tmp/sqlite.sha256 && \
|
|
sha256sum -c /tmp/sqlite.sha256 && \
|
|
tar -xzf /tmp/sqlite.tar.gz -C /tmp && \
|
|
cd "/tmp/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}" && \
|
|
CFLAGS="-O2 \
|
|
-DSQLITE_ENABLE_FTS3 \
|
|
-DSQLITE_ENABLE_FTS3_PARENTHESIS \
|
|
-DSQLITE_ENABLE_FTS4 \
|
|
-DSQLITE_ENABLE_FTS5 \
|
|
-DSQLITE_ENABLE_RTREE \
|
|
-DSQLITE_ENABLE_GEOPOLY \
|
|
-DSQLITE_ENABLE_COLUMN_METADATA \
|
|
-DSQLITE_ENABLE_UNLOCK_NOTIFY \
|
|
-DSQLITE_ENABLE_DBSTAT_VTAB \
|
|
-DSQLITE_ENABLE_DBPAGE_VTAB \
|
|
-DSQLITE_ENABLE_MATH_FUNCTIONS \
|
|
-DSQLITE_ENABLE_PREUPDATE_HOOK \
|
|
-DSQLITE_ENABLE_SESSION \
|
|
-DSQLITE_SECURE_DELETE \
|
|
-DSQLITE_THREADSAFE=1 \
|
|
-DSQLITE_MAX_VARIABLE_NUMBER=250000" \
|
|
./configure --prefix=/opt/sqlite-fixed --disable-static && \
|
|
make -j"$(nproc)" && \
|
|
make install
|
|
|
|
# Node 26 source stage. Debian trixie's bundled nodejs is pinned to 20.x
|
|
# which reached EOL in April 2026 — we copy node + npm from the upstream
|
|
# node:26 image instead (Hermes pins its toolchain to Node 26 everywhere).
|
|
# 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:26-bookworm-slim@sha256:9e6f9357d371591e32ab6f2d8a26d63bdd0d17c29eee3f4f3e7e454d9634bf73 AS node_source
|
|
FROM debian:13.4 AS runtime_base
|
|
|
|
# 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.
|
|
ENV PYTHONUNBUFFERED=1
|
|
ENV PYTHONDONTWRITEBYTECODE=1
|
|
|
|
# The pm-pinned full Chromium lives in the managed tool store at
|
|
# /opt/hermes/tools — outside the /opt/data volume mount, so the
|
|
# build-time install survives the volume overlay at runtime. pm's
|
|
# chromium package fact exports the same value (PLAYWRIGHT_BROWSERS_PATH
|
|
# at the store root); the image ENV names the same directory so
|
|
# Playwright and the browser tool resolve the pinned build even before pm
|
|
# composes tool env.
|
|
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/tools
|
|
|
|
# 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.
|
|
# The second package list is the shared libraries the pinned Chromium links
|
|
# against. `npx playwright install --with-deps` used to apt-install them as
|
|
# a side effect; pm stages the pinned browser instead (below), so the libs
|
|
# must be declared here. The list is the `ldd ... | grep "not found"` set of
|
|
# the pinned chrome binary in this base image, mapped to trixie package
|
|
# names (see .hermes/plans/termux-removal-commit-spec.md).
|
|
RUN apt-get -o Acquire::Retries=3 update && \
|
|
apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
|
|
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev libatomic1 procps git openssh-client docker-cli xz-utils \
|
|
libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 libcairo2 libcups2t64 libdbus-1-3 libgbm1 libglib2.0-0t64 libnspr4 libnss3 libpango-1.0-0 libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 && \
|
|
rm -rf /var/lib/apt/lists/*
|
|
|
|
# Prefer the fixed SQLite over Debian's vulnerable libsqlite3.so.0. Keep the
|
|
# public library name stable so both the system interpreter and the uv-created
|
|
# venv resolve the replacement without changing Python import paths.
|
|
COPY --from=sqlite_build /opt/sqlite-fixed/lib/libsqlite3.so.3.53.4 /usr/local/lib/
|
|
RUN ln -sf libsqlite3.so.3.53.4 /usr/local/lib/libsqlite3.so.0 && \
|
|
ln -sf libsqlite3.so.3.53.4 /usr/local/lib/libsqlite3.so && \
|
|
printf '/usr/local/lib\n' > /etc/ld.so.conf.d/000-sqlite-fixed.conf && \
|
|
ldconfig && \
|
|
python3 -c "import sqlite3, sys; \
|
|
v = sqlite3.sqlite_version_info; \
|
|
sys.exit(f'linked SQLite {sqlite3.sqlite_version} still has the WAL-reset bug') if v < (3, 51, 3) else None; \
|
|
db = sqlite3.connect(':memory:'); \
|
|
db.execute(\"CREATE VIRTUAL TABLE docs USING fts5(content, tokenize='trigram')\"); \
|
|
db.execute(\"INSERT INTO docs VALUES ('hermes')\"); \
|
|
sys.exit('SQLite FTS5 trigram self-test failed') if db.execute(\"SELECT count(*) FROM docs WHERE docs MATCH 'erm'\").fetchone()[0] != 1 else None; \
|
|
db.close()"
|
|
|
|
# ---------- 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.
|
|
#
|
|
# 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 ALL three tarballs: `ADD` evaluates its
|
|
# URL at parse time (no ARG / TARGETARCH substitution) and — critically for
|
|
# CI reliability — cannot retry, so a single GitHub-release CDN blip fails
|
|
# the whole 15-45 min build. curl -fsSL --retry 3 self-heals those blips,
|
|
# and every tarball is still checksum-verified below before extraction.
|
|
ARG TARGETARCH
|
|
ARG S6_OVERLAY_VERSION=3.2.3.0
|
|
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
|
|
RUN set -eu; \
|
|
case "${TARGETARCH:-amd64}" in \
|
|
amd64) s6_arch="x86_64"; s6_arch_sha="${S6_OVERLAY_X86_64_SHA256}" ;; \
|
|
arm64) s6_arch="aarch64"; s6_arch_sha="${S6_OVERLAY_AARCH64_SHA256}" ;; \
|
|
*) echo "Unsupported TARGETARCH=${TARGETARCH} for s6-overlay" >&2; exit 1 ;; \
|
|
esac; \
|
|
base="https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}"; \
|
|
curl -fsSL --retry 3 -o /tmp/s6-overlay-noarch.tar.xz \
|
|
"${base}/s6-overlay-noarch.tar.xz"; \
|
|
curl -fsSL --retry 3 -o /tmp/s6-overlay-symlinks-noarch.tar.xz \
|
|
"${base}/s6-overlay-symlinks-noarch.tar.xz"; \
|
|
curl -fsSL --retry 3 -o /tmp/s6-overlay-arch.tar.xz \
|
|
"${base}/s6-overlay-${s6_arch}.tar.xz"; \
|
|
{ \
|
|
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; \
|
|
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; \
|
|
rm /tmp/s6-overlay-*.tar.xz /tmp/s6-overlay.sha256
|
|
|
|
# #34192 / #66679: backward-compat shim for orchestration templates that
|
|
# still reference the legacy /usr/bin/tini entrypoint (Hostinger's
|
|
# 'Hermes WebUI' catalog, NAS compose projects that preserve an old
|
|
# entrypoint on image update, etc.). A plain symlink to /init made the
|
|
# path exist, but forwarded tini flags like `-g` into s6-overlay's
|
|
# rc.init as the container CMD (`rc.init: 91: -g: not found`) and
|
|
# boot-looped any `restart: unless-stopped` deploy. The shim strips the
|
|
# tini CLI surface, then exec's /init + main-wrapper — see
|
|
# docker/tini-shim.sh. Safe to drop once the affected catalogs are
|
|
# updated.
|
|
COPY --chmod=0755 docker/tini-shim.sh /usr/bin/tini
|
|
|
|
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
|
|
RUN useradd -u 10000 -m -d /opt/data hermes
|
|
|
|
# Node 26: copy the node binary plus the bundled npm JS install 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).
|
|
#
|
|
# No corepack: Node unbundled it upstream, so node:26 ships only npm in
|
|
# /usr/local/lib/node_modules. Nothing here needs it — no package.json
|
|
# declares a `packageManager`, and no build step shells out to yarn or pnpm.
|
|
#
|
|
# 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
|
|
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
|
|
|
|
WORKDIR /opt/hermes
|
|
|
|
# ---------- Pinned toolchain from pm/lock.json (single authority) ----------
|
|
# The image used to assemble uv from a second authority — an astral image
|
|
# tag (ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie) that had already
|
|
# drifted to 0.11.6 while pm/lock.json pinned uv 0.12.3. That is exactly
|
|
# the two-authorities failure the pm design exists to end. The image is now
|
|
# a pin consumer: the stdlib-only pm provisioner reads pm/lock.json and
|
|
# stages the pinned uv + full Chromium (sha256-verified at
|
|
# download — the same code path pm.sh/pm.ps1 and the desktop payload use)
|
|
# into the image's own runtime dir, a self-contained store baked under
|
|
# /opt/hermes, outside the /opt/data volume so it survives the overlay.
|
|
# The pinned uv is linked onto PATH so the `uv sync` / `uv pip install`
|
|
# build steps below run the lockfile's uv, not a second download.
|
|
#
|
|
# Full Chromium supports both headed and headless sessions. It is staged
|
|
# here rather than by `npx playwright install`,
|
|
# which fetched whatever revision the npm-resolved playwright wanted,
|
|
# unverified, and recorded no fact. The resolved browser binary path is
|
|
# baked to /etc/hermes/agent-browser-executable-path for stage2-hook.sh:
|
|
# the layout differs per arch (chrome-linux64/chrome on amd64,
|
|
# chromium-linux-arm64/chromium on arm64), so it is resolved at build time
|
|
# and never hunted at boot.
|
|
ENV HERMES_RUNTIME_DIR=/opt/hermes/tools
|
|
COPY pm/ pm/
|
|
# pm's lazy imports resolve get_default_hermes_root()/project_venv_dir()
|
|
# from hermes_constants (stdlib-only) at install time — a sealed-stage
|
|
# `python3 -m pm.cli install` fails with "No module named
|
|
# 'hermes_constants'" without it on the path. Copy the module next to pm.
|
|
COPY hermes_constants.py hermes_constants.py
|
|
# PM imports the shared stdlib runtime path and locking owners before deps exist.
|
|
COPY hermes_cli/__init__.py hermes_cli/runtime_paths.py hermes_cli/runtime_state.py hermes_cli/
|
|
COPY scripts/bundles/payload.py scripts/bundles/payload.py
|
|
RUN set -eu; \
|
|
python3 -c 'from pm.ensure import ensure; [ensure(name, explicit=True) for name in ("uv", "chromium")]'; \
|
|
ln -sf /opt/hermes/tools/uv-*/uv /usr/local/bin/uv; \
|
|
python3 -c 'from pathlib import Path; from pm.lock import Facts; from pm.registry import get_package; from pm.store import current_target; root = Path("/opt/hermes/tools"); fact = Facts(root / "facts.json").get("python"); binary = get_package("python").binary(root / fact["entry"], current_target()); Path("/usr/local/bin/python3").symlink_to(binary)'; \
|
|
uv --version; \
|
|
browser_bin="$(find /opt/hermes/tools/chromium-* -type f \( -name chrome -o -name chromium \) -print -quit)"; \
|
|
test -n "$browser_bin"; \
|
|
"$browser_bin" --version; \
|
|
mkdir -p /etc/hermes; \
|
|
printf '%s' "$browser_bin" > /etc/hermes/agent-browser-executable-path
|
|
|
|
# PM is resident too: never borrow application libraries or create its worker
|
|
# environment under /root (unreachable to the runtime UID).
|
|
RUN python3 -c 'from pathlib import Path; from pm.runtime_stage import stage_runtime; from scripts.bundles.payload import seal_pm_runtime; root = Path("/opt/hermes"); python = Path("/usr/local/bin/python3").resolve(); stage_runtime(Path("/usr/local/bin/uv"), python, root / "pm-runtime", project=root / "pm"); seal_pm_runtime(root, python)'
|
|
|
|
# Raw uv commands must use PM's staged interpreter, not download another
|
|
# under /root where the unprivileged runtime user cannot traverse it.
|
|
ENV UV_PYTHON=/usr/local/bin/python3
|
|
ENV UV_PYTHON_DOWNLOADS=never
|
|
# The standalone interpreter records its builder's clang toolchain;
|
|
# native extensions must use the compiler installed in this image.
|
|
ENV CC=gcc CXX=g++
|
|
|
|
# Frontend dependencies never enter the runtime layers.
|
|
FROM runtime_base AS frontend_build
|
|
COPY package.json package-lock.json ./
|
|
COPY web/package.json web/
|
|
COPY ui-tui/package.json ui-tui/
|
|
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
|
|
COPY apps/shared/ apps/shared/
|
|
COPY scripts/build/node-deps.mjs scripts/build/node-deps.mjs
|
|
ENV npm_config_install_links=false
|
|
RUN node scripts/build/node-deps.mjs --source /opt/hermes --workspace ui-tui --workspace web
|
|
|
|
COPY pyproject.toml uv.lock ./
|
|
COPY web/ web/
|
|
COPY ui-tui/ ui-tui/
|
|
COPY scripts/build/*.mjs scripts/build/
|
|
COPY scripts/generate-icons.mjs scripts/generate_icons.py scripts/
|
|
COPY assets/ assets/
|
|
RUN node scripts/generate-icons.mjs --source /opt/hermes --out /tmp/hermes-icons && \
|
|
node scripts/build/tui.mjs --source /opt/hermes --out /opt/products/tui && \
|
|
node scripts/build/web.mjs --source /opt/hermes --icons /tmp/hermes-icons --out /opt/products/web
|
|
|
|
FROM runtime_base AS runtime
|
|
# Standalone TypeScript linting is a runtime feature; Vite/esbuild are not.
|
|
COPY --from=frontend_build /opt/hermes/node_modules/typescript /opt/hermes/node_modules/typescript
|
|
RUN mkdir -p /opt/hermes/node_modules/.bin && \
|
|
ln -s ../typescript/bin/tsc /opt/hermes/node_modules/.bin/tsc
|
|
|
|
# ---------- Photon iMessage sidecar deps (baked, NS-606) ----------
|
|
# The photon plugin's Node sidecar needs its own node_modules
|
|
# (spectrum-ts). The install tree is immutable at runtime, so a lazy
|
|
# `npm ci` on first connect would hit EROFS — bake the deps here instead
|
|
# (deterministic installs, NS-559). The patch script is copied alongside
|
|
# the manifests because package.json's postinstall runs it, which also
|
|
# means the spectrum-ts patch is applied at build time. Layer-cached:
|
|
# only re-runs when the sidecar manifests/patch change.
|
|
COPY plugins/platforms/photon/sidecar/package.json \
|
|
plugins/platforms/photon/sidecar/package-lock.json \
|
|
plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs \
|
|
plugins/platforms/photon/sidecar/
|
|
RUN cd plugins/platforms/photon/sidecar && \
|
|
npm ci --no-audit --fetch-retries=5 && \
|
|
npm cache clean --force
|
|
|
|
# ---------- 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.
|
|
#
|
|
# `uv sync --frozen --no-install-project --extra all --extra messaging --extra otlp`
|
|
# installs the deps reachable through the composite `[all]` extra
|
|
# (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`:
|
|
# that would pull in `[rl]` (atroposlib + tinker + torch + wandb from
|
|
# git) and `[yc-bench]` (another git dep), neither of which belongs in
|
|
# the published container.
|
|
#
|
|
# 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).
|
|
#
|
|
# The [otlp] extra contains the SDK/exporter imported by Hermes when Gateway
|
|
# Health export is enabled. Collector and observability-backend dependencies
|
|
# remain external and are not part of the Hermes production image.
|
|
#
|
|
# 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).
|
|
#
|
|
# 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.
|
|
#
|
|
# Source binding is created after the source copy below.
|
|
COPY pyproject.toml uv.lock ./
|
|
RUN touch ./README.md
|
|
COPY scripts/build/python_env.py scripts/build/python_env.py
|
|
RUN python3 -m scripts.build.python_env --source /opt/hermes --python /usr/local/bin/python3 \
|
|
--uv /usr/local/bin/uv --out /opt/hermes/.venv --no-install-project \
|
|
--extra all --extra messaging --extra otlp --extra anthropic --extra bedrock \
|
|
--extra azure-identity --extra hindsight --extra matrix
|
|
|
|
# Shared product outputs are independent of application dependency assembly.
|
|
COPY --from=frontend_build /opt/products/tui /opt/hermes/ui-tui
|
|
COPY --from=frontend_build /opt/products/web /opt/hermes/hermes_cli/web_dist
|
|
|
|
# ---------- Source code ----------
|
|
# .dockerignore excludes node_modules, so the installs above survive.
|
|
# --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 . .
|
|
|
|
# The shared assembler binds the prepared environment and frontend products.
|
|
RUN /opt/hermes/.venv/bin/python -m docker.build_agent
|
|
|
|
# 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).
|
|
|
|
USER root
|
|
RUN mkdir -p /opt/hermes/bin && \
|
|
cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \
|
|
chmod 0755 /opt/hermes /opt/hermes/bin/hermes && \
|
|
printf 'docker\n' > /opt/hermes/.install_method
|
|
# 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.
|
|
# 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).
|
|
|
|
# ---------- Image provenance + install stamp ----------
|
|
# CI (.github/workflows/docker.yml) runs scripts/write_install_stamp.py
|
|
# before `docker build`, so the bulk `COPY . .` above already placed a
|
|
# full-provenance /opt/hermes/install-stamp.json next to the code.
|
|
# .dockerignore excludes .git, so the stamp is the only commit channel the
|
|
# image carries: hermes_cli/version_info.py reads it at runtime (stamp
|
|
# first, live git second, unknown third), and both `hermes dump` and
|
|
# banner.get_git_banner_state() consume it through version_info.
|
|
#
|
|
# A local `docker build` without CI gets the minimal all-zero fallback
|
|
# stamp below; version_info skips the placeholder commit, so dump honestly
|
|
# reports "(unknown)". updateMechanism is `external`: the image is rebuilt
|
|
# and re-pulled, it never updates itself.
|
|
#
|
|
# The versioned, non-secret provenance marker is the authoritative runtime
|
|
# signal that this filesystem came from an immutable image. It deliberately
|
|
# lives outside both /opt/hermes (which operators sometimes bind-mount as a
|
|
# checkout) and /opt/data (the mutable HERMES_HOME volume). Its `revision`
|
|
# is read from the install stamp; the fallback stamp's all-zero commit maps
|
|
# to null.
|
|
RUN set -eu; \
|
|
if [ ! -f /opt/hermes/install-stamp.json ]; then \
|
|
printf '{"schemaVersion":2,"commit":"0000000000000000000000000000000000000000","distribution":"docker","source":"fallback","updateMechanism":"external"}\n' \
|
|
> /opt/hermes/install-stamp.json; \
|
|
fi; \
|
|
python3 -c 'import json; from pathlib import Path; path = Path("/opt/hermes/install-stamp.json"); stamp = json.loads(path.read_text()); stamp["pmRuntime"] = "/opt/hermes/pm-runtime"; path.write_text(json.dumps(stamp) + "\n")'; \
|
|
mkdir -p /etc/hermes; \
|
|
python3 -c 'import json, pathlib, tomllib; project = tomllib.loads(pathlib.Path("/opt/hermes/pyproject.toml").read_text(encoding="utf-8"))["project"]; stamp = json.loads(pathlib.Path("/opt/hermes/install-stamp.json").read_text(encoding="utf-8")); commit = stamp.get("commit"); revision = commit if commit and set(commit) != {"0"} else None; marker = pathlib.Path("/etc/hermes/image-provenance.json"); marker.write_text(json.dumps({"schema": 1, "deployment_kind": "image", "manager": "docker", "image": "nousresearch/hermes-agent", "version": project["version"], "revision": revision}, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8"); marker.chmod(0o444)'
|
|
|
|
# ---------- s6-overlay service wiring ----------
|
|
# Static services declared at build time: main-hermes + dashboard.
|
|
# Per-profile gateway services are registered dynamically at runtime by
|
|
# the profile create/delete hooks (Phase 4); they live under
|
|
# /run/service/ (tmpfs) and are reconciled on container restart by
|
|
# /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,
|
|
# skills sync — all the work the old entrypoint.sh did before
|
|
# `exec hermes`. Wired in as cont-init.d/01- so it
|
|
# 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.
|
|
RUN mkdir -p /etc/cont-init.d && \
|
|
printf '#!/command/with-contenv sh\nexec /opt/hermes/docker/stage2-hook.sh\n' \
|
|
> /etc/cont-init.d/01-hermes-setup && \
|
|
chmod +x /etc/cont-init.d/01-hermes-setup
|
|
COPY --chmod=0755 docker/cont-init.d/015-supervise-perms /etc/cont-init.d/015-supervise-perms
|
|
COPY --chmod=0755 docker/cont-init.d/02-reconcile-profiles /etc/cont-init.d/02-reconcile-profiles
|
|
|
|
# ---------- Runtime ----------
|
|
ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
|
|
# 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
|
|
ENV HERMES_HOME=/opt/data
|
|
ENV HERMES_WRITE_SAFE_ROOT=/opt/data
|
|
ENV HERMES_DISABLE_LAZY_INSTALLS=1
|
|
# Lazy installs are fully disabled in the published image (see
|
|
# HERMES_DISABLE_LAZY_INSTALLS above): the venv is sealed and opt-in backend
|
|
# SDKs are not installed at runtime.
|
|
|
|
# `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).
|
|
COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
|
|
COPY --chmod=0755 docker/entrypoint-dispatch.sh /opt/hermes/docker/entrypoint-dispatch.sh
|
|
|
|
# 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.
|
|
#
|
|
# /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}"
|
|
# PM's atomic writer creates private facts for source installs. In the
|
|
# image these are shared, non-secret package metadata, read by UID 10000.
|
|
RUN mkdir -p /opt/data && chmod 0644 /opt/hermes/tools/facts.json
|
|
VOLUME [ "/opt/data" ]
|
|
|
|
# The image ENTRYPOINT is a tiny dispatcher rather than `/init` directly.
|
|
# When the image really owns PID 1 (normal Docker / Podman), the dispatcher
|
|
# execs `/init` and preserves the full s6 supervision tree. When a platform
|
|
# wraps the image entrypoint under its own PID-1 init (Fly Machines,
|
|
# `docker run --init`, some schedulers), `/init` would abort with
|
|
# `can only run as pid 1`; in that case the dispatcher falls back to
|
|
# `stage2-hook.sh` + `main-wrapper.sh` directly so foreground commands still
|
|
# work. See #38349.
|
|
#
|
|
# On the PID-1 path, s6-overlay's /init 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> → entrypoint-dispatch.sh (CMD default)
|
|
# docker run <image> chat -q "hi" → entrypoint-dispatch.sh chat -q hi
|
|
# docker run <image> sleep infinity → entrypoint-dispatch.sh sleep infinity
|
|
# docker run <image> --tui → entrypoint-dispatch.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. The dispatcher preserves that contract across both the
|
|
# supervised PID-1 path and the non-PID-1 fallback path. Without the
|
|
# wrapper-as-ENTRYPOINT, leading-dash args like `--version` would be
|
|
# intercepted by /init's POSIX shell.
|
|
ENTRYPOINT [ "/opt/hermes/docker/entrypoint-dispatch.sh" ]
|
|
CMD [ ]
|