diff --git a/Dockerfile b/Dockerfile index 6d6e9ac2d91c7..0bb092ae85e39 100644 --- a/Dockerfile +++ b/Dockerfile @@ -400,6 +400,8 @@ ENV HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages # 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 @@ -416,27 +418,37 @@ 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. +# 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 → /init main-wrapper.sh (CMD default) -# docker run chat -q "hi" → /init main-wrapper.sh chat -q hi -# docker run sleep infinity → /init main-wrapper.sh sleep infinity -# docker run --tui → /init main-wrapper.sh --tui +# docker run → entrypoint-dispatch.sh (CMD default) +# docker run chat -q "hi" → entrypoint-dispatch.sh chat -q hi +# docker run sleep infinity → entrypoint-dispatch.sh sleep infinity +# docker run --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. 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" ] +# 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 [ ] diff --git a/docker/entrypoint-dispatch.sh b/docker/entrypoint-dispatch.sh new file mode 100644 index 0000000000000..927ed032f6d82 --- /dev/null +++ b/docker/entrypoint-dispatch.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# shellcheck shell=sh +# Entry-point dispatcher for runtimes that may or may not give the image +# ownership of PID 1. +# +# Normal Docker / Podman path: this script is PID 1, so we delegate to +# s6-overlay's /init exactly as before and keep the full supervision tree. +# +# Wrapped-runtime path (Fly Machines, `docker run --init`, some Nomad/K8s +# setups): the platform's own init is already PID 1 and execs the image +# entrypoint as a child. s6-overlay aborts there with "can only run as pid 1", +# so we run the stage2 bootstrap directly and then exec the main wrapper +# without /init. + +set -e + +if [ "$$" -eq 1 ]; then + exec /init /opt/hermes/docker/main-wrapper.sh "$@" +fi + +echo "[hermes] WARNING: container entrypoint is not PID 1; skipping s6-overlay /init and falling back to direct bootstrap. Supervised services are unavailable in this runtime, but the requested command will still run." >&2 +# /init normally seeds PATH with s6's helpers; the non-PID-1 fallback skips it. +export PATH="/command:/package/admin/s6/command:${PATH}" +/opt/hermes/docker/stage2-hook.sh +exec /opt/hermes/docker/main-wrapper.sh "$@" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 9e735fe561bd7..30cff58f4a7b5 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -19,9 +19,10 @@ # Surface a warning to stderr so anyone still invoking this path # sees the migration notice in their logs. echo "[hermes] WARNING: docker/entrypoint.sh is a deprecated shim under " \ - "s6-overlay. The container's real ENTRYPOINT is /init + " \ - "main-wrapper.sh; this script only runs the stage2 cont-init hook " \ + "s6-overlay. The container's real ENTRYPOINT is " \ + "entrypoint-dispatch.sh (which delegates to /init + main-wrapper.sh " \ + "when PID 1); this script only runs the stage2 cont-init hook " \ "and does NOT exec the CMD. If you hard-coded docker/entrypoint.sh " \ "as your ENTRYPOINT, drop the override — docker will use the image's " \ - "default ENTRYPOINT (/init), which handles bootstrap AND CMD." >&2 + "default ENTRYPOINT dispatcher, which handles bootstrap AND CMD." >&2 exec /opt/hermes/docker/stage2-hook.sh "$@" diff --git a/docker/main-wrapper.sh b/docker/main-wrapper.sh index 20d8c709ad49f..efabad9839343 100755 --- a/docker/main-wrapper.sh +++ b/docker/main-wrapper.sh @@ -1,15 +1,16 @@ -#!/command/with-contenv sh +#!/bin/sh # shellcheck shell=sh # /opt/hermes/docker/main-wrapper.sh — wraps the container's CMD with # the same argument-routing logic the pre-s6 entrypoint.sh used. Runs # as /init's "main program" (Docker CMD) so it inherits stdin/stdout/ -# stderr from the container. +# stderr from the container. The non-PID-1 entrypoint fallback also +# execs this script directly after running the stage2 bootstrap. # -# Shebang note: /init scrubs env before invoking CMD, so a plain -# `#!/bin/sh` wrapper sees an empty environ and `ENV HERMES_HOME=/opt/data` -# from the Dockerfile never reaches `hermes`. with-contenv repopulates -# the env from /run/s6/container_environment before exec'ing, which is -# what s6-supervised services use too (see main-hermes/run). +# Env note: /init scrubs env before invoking CMD, so when this wrapper +# is launched through the supervised path it must rehydrate via +# with-contenv before touching HERMES_HOME / PATH. On the non-PID-1 +# fallback path the Dockerfile env is still intact, so we skip the +# re-exec and continue directly. # # Routing: # no args → exec `hermes` (the default) @@ -19,6 +20,14 @@ # Drop to hermes via s6-setuidgid, but skip it when already non-root. set -e +if [ -z "${HERMES_MAIN_WRAPPER_ENV_READY:-}" ] && \ + [ -z "${HERMES_HOME:-}" ] && \ + [ -x /command/with-contenv ]; then + export HERMES_MAIN_WRAPPER_ENV_READY=1 + exec /command/with-contenv sh "$0" "$@" +fi +unset HERMES_MAIN_WRAPPER_ENV_READY + drop() { [ "$(id -u)" = 0 ] && set -- s6-setuidgid hermes "$@"; exec "$@"; } # --- Reject the unsupported `docker run --user :` start --- diff --git a/hermes_cli/container_boot.py b/hermes_cli/container_boot.py index 3a84962f1569c..b0e9821b7b8dd 100644 --- a/hermes_cli/container_boot.py +++ b/hermes_cli/container_boot.py @@ -332,6 +332,10 @@ def _strip_container_argv_prefix(argv: Sequence[str]) -> list[str]: # Defensive: an `init` prefix with no wrapper token in argv. args = args[1:] + # Non-PID-1 entrypoints go through the dispatch shim instead of /init. + if args and args[0].endswith("entrypoint-dispatch.sh"): + args = args[1:] + # The wrapper re-execs `hermes `; peel an explicit hermes. if args and Path(args[0]).name == "hermes": args = args[1:] diff --git a/tests/tools/test_dockerfile_pid1_reaping.py b/tests/tools/test_dockerfile_pid1_reaping.py index ad4333a2f3d96..565a7d8701a27 100644 --- a/tests/tools/test_dockerfile_pid1_reaping.py +++ b/tests/tools/test_dockerfile_pid1_reaping.py @@ -28,6 +28,7 @@ import pytest REPO_ROOT = Path(__file__).resolve().parents[2] DOCKERFILE = REPO_ROOT / "Dockerfile" DOCKERIGNORE = REPO_ROOT / ".dockerignore" +ENTRYPOINT_DISPATCH = REPO_ROOT / "docker" / "entrypoint-dispatch.sh" # Init-process families this repo accepts as PID 1. ``tini`` / @@ -112,6 +113,52 @@ def test_dockerfile_installs_an_init_for_zombie_reaping(dockerfile_text): ) +def test_dockerfile_entrypoint_routes_through_the_init(dockerfile_text): + """The ENTRYPOINT must preserve a PID-1 init path, even with a dispatcher. + + Installing the init is only half the fix — the container must actually + run with it as PID 1. A shell dispatcher is fine only if it execs the + real init when the image owns PID 1; otherwise the shell would become + PID 1 and hermes would run without zombie reaping. + """ + # Find the last uncommented ENTRYPOINT line — Docker honours the final one. + entrypoint_line = None + for raw_line in dockerfile_text.splitlines(): + line = raw_line.strip() + if line.startswith("#"): + continue + if line.startswith("ENTRYPOINT"): + entrypoint_line = line + + assert entrypoint_line is not None, "Dockerfile is missing an ENTRYPOINT directive" + + if any(name in entrypoint_line for name in _KNOWN_INIT_TOKENS): + return + + assert "/opt/hermes/docker/entrypoint-dispatch.sh" in entrypoint_line, ( + f"Unexpected Dockerfile ENTRYPOINT: {entrypoint_line!r}" + ) + assert ENTRYPOINT_DISPATCH.exists(), ( + "Dockerfile points at entrypoint-dispatch.sh but the script is missing." + ) + dispatcher = ENTRYPOINT_DISPATCH.read_text(encoding="utf-8") + assert 'if [ "$$" -eq 1 ]; then' in dispatcher + assert "exec /init /opt/hermes/docker/main-wrapper.sh" in dispatcher, ( + "The entrypoint dispatcher must hand PID-1 execution off to /init; " + "otherwise the shell becomes PID 1 and zombies will accumulate." + ) + + +def test_dispatcher_non_pid1_fallback_restores_s6_helpers_on_path() -> None: + """Skipping /init must still expose s6-setuidgid to stage2/main-wrapper.""" + dispatcher = ENTRYPOINT_DISPATCH.read_text(encoding="utf-8") + assert 'export PATH="/command:/package/admin/s6/command:${PATH}"' in dispatcher, ( + "The non-PID-1 entrypoint fallback skips /init, so it must restore the " + "s6 helper directories on PATH before invoking stage2-hook.sh and " + "main-wrapper.sh." + ) + + def test_dockerignore_excludes_nested_dependency_dirs(): if not DOCKERIGNORE.exists(): pytest.skip(".dockerignore not present in this checkout")