mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
* feat(attribution): conflict-free contributor mappings via contributors/emails/ directory
The AUTHOR_MAP dict in scripts/release.py was a merge-conflict magnet:
every concurrent salvage PR appended entries to the same lines of the
same file, so parallel PRs re-conflicted on every merge to main.
New system: one file per email under contributors/emails/ — filename is
the commit-author email, first non-comment line is the GitHub login.
File additions never conflict, so any number of PRs can add mappings
concurrently.
- scripts/release.py: AUTHOR_MAP is now LEGACY_AUTHOR_MAP (frozen)
merged with the directory at import time (directory wins). All
existing consumers (resolve_author, contributor_audit.py) unchanged.
- scripts/add_contributor.py: idempotent CLI to add a mapping; refuses
conflicting reassignments (incl. against the legacy map), validates
email/login shapes.
- contributor-check.yml: attribution gate now accepts a mapping file OR
a legacy entry; failure message prints the exact add_contributor
command. Also auto-resolves bare <login>@users.noreply.github.com
emails is intentionally NOT added (kept id+login form only, matching
previous behavior).
- contributor_audit.py: guidance now points at add_contributor.py.
- tests/scripts/test_contributor_map.py: 12 tests covering loader,
merge precedence, CLI idempotency/conflict/validation, subprocess E2E.
* feat(ci): one-shot per-file flake retry in the parallel test runner
A failing test FILE is re-run once in a fresh subprocess. Pass-on-retry
counts as green but is loudly reported in a '⚠ FLAKY' summary section
(with both attempts' output preserved) so the flake gets fixed instead
of eating a full-run rerun. Deterministic failures fail both attempts —
regressions cannot be laundered green.
- --file-retries N / HERMES_TEST_FILE_RETRIES (default 1, 0 disables)
- E2E verified: simulated first-run-fail flake goes green with banner;
deterministic failure still exits 1; retries=0 restores old behavior.
This converts the dominant CI failure mode (one timing-sensitive test
flaking a 4600-test shard, requiring a manual 10-minute rerun and an
agent triage loop) into a self-healing retry that costs one file's
runtime.
* test(approval): loosen wall-clock perf bounds 0.15s -> 2.0s
These guard against catastrophic regex backtracking (seconds-to-minutes
class), but 0.15s is within scheduler-stall noise on loaded shared CI
runners — test_max_accepted_separator_free_input_is_fast failed a CI
shard this week on runner load alone. 2.0s still catches the regression
class with zero flake surface.
* fix(ci): job timeouts everywhere + retries on all network installs
Reliability pass over every workflow:
- timeout-minutes on all 21 jobs that lacked one (a hung job previously
burned the 6-hour default runner budget)
- ./.github/actions/retry wrapped around every network-fetching install
that lacked it: pip installs (deploy-site, skills-index), npm ci
(deploy-site website, upload_to_pypi web + ui-tui), uv sync (docker
test deps). Deterministic build steps (npm run build) deliberately
NOT retried — split into separate steps so a real build failure fails
fast instead of retrying 3x.
* docs(agents): document the file-retry flake policy
* fix(ci): curl retries on deploy hook + skills-index probe
* fix(ci): kill the remaining transient-failure classes in workflows + Dockerfile
From the workflow reliability audit:
- tests.yml: duration-cache restore had NO restore-keys while saves use
run_id-suffixed keys — the cache never matched once, so LPT slicing
always ran blind and unbalanced slices pushed heavy files toward the
per-file timeout. One-line restore-keys fixes slice balancing.
- Label gates (lint ci-reviewed, supply-chain mcp-catalog-reviewed):
'gh pr view || true' turned an API blip into 'label absent' → false
BLOCKING failure. Now 3x retry, and API failure is reported as an API
failure instead of a missing label.
- detect-changes action: compare API retried before failing open (was
silently running all lanes on any blip).
- uv-lockfile-check: 'uv lock --check' resolves against PyPI — retried
so registry blips don't read as 'lockfile stale'.
- docker.yml merge job: imagetools create retried (Docker Hub eventual
consistency on just-pushed digests).
- Dockerfile: apt-get Acquire::Retries=3; s6-overlay ADDs converted to
curl --retry 3 (ADD cannot retry; checksums still enforced); npm
--fetch-retries=5; playwright chromium fetch retried 3x.
- Advisory artifact uploads (per-slice durations, ci-timings report)
get continue-on-error so an artifact-service blip can't fail a green
test slice.
* fix(tests): kill the two root-cause flakes — leaking pre-warm timer + env-dependent provider list
- test_tui_gateway_server.py: session.create / non-eager session.resume
arm a 50ms threading.Timer (_schedule_agent_build) that outlives its
test and fires into the NEXT test's _make_agent mock, racily
corrupting captured state (the recurring session_resume shard
failures). Replaced the per-test whack-a-mole stub with a module-wide
autouse fixture; the 3 worker-lifecycle tests that genuinely need the
deferred build opt back in via @pytest.mark.real_agent_prewarm (new
marker in pyproject).
- test_api_key_providers.py: PROVIDER_ENV_VARS is now derived from the
live PROVIDER_REGISTRY instead of a hand-list that had drifted
(missing HF_TOKEN / DEEPINFRA_API_KEY) — resolve_provider('auto')
tests failed on any machine with HF_TOKEN exported. E2E-verified with
HF_TOKEN/DEEPINFRA_API_KEY set: 42/42 pass.
* test: de-flake 30 timing-sensitive test files for loaded CI runners
Root-cause fixes from the flake audit (session-DB mining + repo sweep):
Event-based sync instead of sleep-sync:
- title_generator: mock sets threading.Event, wait(10) replaces
sleep(0.3) hoping the daemon thread got scheduled
- docker zombie_reaping / profile_gateway: poll-for-state helpers
replace fixed 1-3s sleeps (s6 transitions + SIGCHLD reaping are async)
- process_registry tree test: select()-bounded readline replaces an
unbounded blocking read (parent wedge now fails THIS test with a clear
message instead of an opaque rc=124 file kill); SIGTERM grace 1s->2s
(the 1s partition window mid-interpreter-startup is how a child PID
escaped the live-system guard in CI)
Timeout raises (loaded 8-way-sliced runners see ~5s scheduling floors;
all of these complete in ms-to-1s when healthy so the raises cost
nothing on green runs):
- subprocess/thread waits <= 2s raised to 10-15s across mcp_tool,
mcp_circuit_breaker, mcp_reconnect_retry_reset, mcp_parked_self_probe,
mcp_cancelled_error_propagation, registry, clarify_gateway, interrupt,
voice_cli_integration, docker_environment, session_store_lock_io,
planned_stop_watcher, cli_interrupt_subagent, thread_scoped_output
(joins now also assert not is_alive() so stragglers fail loudly)
- wall-clock discrimination ceilings loosened where the guarded hang is
10x larger: local_background_child_hang 4s->10s, interrupt_cleanup
setup 5s->20s + pgid-exit 30s->60s, mcp_stability grandchild spinup
5s->15s, protocol/gil-starvation fast-handler 0.5s->2s,
iso_certify_seam 1.5s->5s, wait_for_mcp_discovery 0.1s->1s
- narrow assertion windows widened: honcho first-turn wait 0.4..0.65 ->
0.25..2.0 (property is bounded-not-hung, not an exact wall-clock);
compression fork-lock TTL 1s->3s (12 refresh chances per lease);
compression-lock expiry margins symmetric (ttl 0.05->0.5, sleep 1.0)
- telegram hung-DNS bound 1.0->1.4 (fake hang is 1.5s — must stay under)
* fix(tests): repair indentation from de-flake batch edit
* fix(tests): harden env isolation and replace remaining sleep-sync races
The full 42k-test run and complete npm check surfaced three more classes:
- Environment isolation: local ~/.honcho defaultHost and SSH_* variables
leaked into Python/TUI tests. Pin the default Honcho host in the
hermetic fixture, isolate the one fallback test from ~/.honcho, and
blank SSH_* around terminalSetup tests. This flipped 20 false failures
back to deterministic behavior on developer machines.
- Background-thread sleep-sync: Honcho async writer tests patched
time.sleep globally, then busy-polled with that same mocked sleep. Under
full-suite load the poller could starve the writer. Each test now waits
on an Event emitted by the exact flush/retry transition; 30/30 passed
under 15-way contention.
- Desktop streaming: the test slept 80ms and assumed a 500ms timer could
not fire before its assertion. A loaded runner descheduled the test for
>500ms and both chunks arrived. Producer controls now gate second-chunk
and completion transitions explicitly.
Also make file-retry observability complete: a self-healed flaky file now
prints BOTH attempts' full output in the FLAKY summary. Two behavioral
runner tests prove pass-on-retry is green+loud+traceback-preserving, while
a deterministic failure remains red.
* refactor(ci): use gh bot pat, better retries
refactor(ci): use retry action for PR label fetch
the retry action now captures stdout as a step output, so it can serve
double duty: retry + output capture for commands like 'gh pr view' whose
result must be consumed by later steps.
Retry action gains:
- 'stdout' output (heredoc-delimited to preserve newlines)
- tee to temp file so stdout still streams to the job log
- step id 'retry' for output reference
Both lint.yml and supply-chain-audit.yml now use the retry action
directly with 'command: gh pr view ...' and read
steps.<id>.outputs.stdout.
ci: use AUTOFIX_BOT_PAT for all gh CLI / GitHub API auth
Replace secrets.GITHUB_TOKEN and github.token with
secrets.AUTOFIX_BOT_PAT across all workflows and composite actions
that use the gh CLI or GitHub API. The PAT has consistent permissions
across fork PRs (where GITHUB_TOKEN is read-only), avoids API rate
limit sharing with the default token, and is already used by
js-autofix.yml for the same reasons.
19 sites swapped across 9 files:
- lint.yml (3): label fetch, comment post/edit, comment update
- supply-chain-audit.yml (5): scan, critical comment, unbounded dep
comment, label fetch, mcp-catalog comment
- lockfile-diff.yml (1): PR comment post/update
- skills-index-freshness.yml (1): issue creation on degraded probe
- skills-index.yml (2): index build, trigger deploy workflow
- upload_to_pypi.yml (2): release view poll, release upload
- ci.yml (1): timings report
- deploy-site.yml (2): skills index crawl
- detect-changes/action.yml (1): compare API call
---------
Co-authored-by: ethernet <arilotter@gmail.com>
361 lines
20 KiB
Docker
361 lines
20 KiB
Docker
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
|
|
# 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
|
|
FROM debian:13.4
|
|
|
|
# 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
|
|
|
|
# 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
|
|
|
|
# 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.
|
|
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 procps git openssh-client docker-cli xz-utils && \
|
|
rm -rf /var/lib/apt/lists/*
|
|
|
|
# ---------- 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: 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
|
|
|
|
# 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/
|
|
|
|
# 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
|
|
|
|
WORKDIR /opt/hermes
|
|
|
|
# ---------- Layer-cached dependency install ----------
|
|
# Copy only package manifests first so npm install + Playwright are cached
|
|
# unless the lockfiles themselves change.
|
|
#
|
|
# 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.
|
|
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/
|
|
# 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/
|
|
|
|
# `npm_config_install_links=false` forces npm to install `file:` deps as
|
|
# 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.
|
|
ENV npm_config_install_links=false
|
|
|
|
RUN npm install --prefer-offline --no-audit --fetch-retries=5 && \
|
|
for i in 1 2 3; do \
|
|
npx playwright install --with-deps chromium --only-shell && break || \
|
|
{ [ "$i" = 3 ] && exit 1; echo "playwright install failed (attempt $i); retrying in 10s"; sleep 10; }; \
|
|
done && \
|
|
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`
|
|
# 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), `[yc-bench]` (another git dep), and `[termux-all]` (Android
|
|
# redundancy), none of which belong 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 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.
|
|
#
|
|
# The editable link is created after the source copy below.
|
|
COPY pyproject.toml uv.lock ./
|
|
RUN touch ./README.md
|
|
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight --extra matrix
|
|
|
|
# ---------- 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/
|
|
COPY apps/shared/ apps/shared/
|
|
RUN cd web && npm run build && \
|
|
cd ../ui-tui && npm run build
|
|
|
|
# ---------- 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 . .
|
|
|
|
# ---------- Permissions ----------
|
|
# 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 "."
|
|
|
|
# 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/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).
|
|
|
|
# ---------- 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
|
|
# (.github/workflows/docker.yml) passes ${{ github.sha }} so
|
|
# every published image has it.
|
|
ARG HERMES_GIT_SHA=
|
|
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
|
|
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha; \
|
|
fi
|
|
|
|
# ---------- 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
|
|
# 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
|
|
|
|
# `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).
|
|
|
|
# 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}"
|
|
RUN mkdir -p /opt/data
|
|
VOLUME [ "/opt/data" ]
|
|
|
|
# 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 [ ]
|