Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 35 additions & 112 deletions docker/stage2-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,9 @@ EOF
fi

# --- Bootstrap HERMES_HOME as root ---
# Create the directory (and any missing parents) while we still have root
# privileges so the chown checks below see real metadata and the later
# `s6-setuidgid hermes mkdir -p` block doesn't EACCES on root-owned
# ancestors. Without this, custom HERMES_HOME paths whose parents only
# root can create (e.g. `HERMES_HOME=/home/hermes/.hermes` in a Compose
# file, or any path under a fresh / not pre-populated by the image)
# fail on first boot with `mkdir: cannot create directory '/...': Permission
# denied` and the cont-init hook exits non-zero. Idempotent — `mkdir -p`
# is a no-op if the dir already exists. (#18482, salvages #18488)
mkdir -p "$HERMES_HOME"
# /opt/data is expected to be pre-created by the host (PVC, bind mount, etc.).
# We skip `mkdir -p` here because in rootless environments the container
# cannot create directories under /opt.

# Numeric UID/GID validation: must be digits only, non-root, 1-65534.
# NAS hosts such as Unraid commonly use low non-root IDs (99:100).
Expand Down Expand Up @@ -211,43 +204,8 @@ refuse_symlinked_path() {
return 1
}

chown_hermes_tree() {
target="$1"
if refuse_symlinked_path "recursive chown" "$target"; then
return 0
fi
chown -R hermes:hermes "$target" 2>/dev/null || \
echo "[stage2] Warning: chown $target failed (rootless container?) — continuing"
}

needs_chown=false
if [ "$(stat -c %u "$HERMES_HOME" 2>/dev/null)" != "$actual_hermes_uid" ]; then
needs_chown=true
fi
if [ "$needs_chown" = true ]; then
echo "[stage2] Fixing ownership of $HERMES_HOME (targeted) to hermes ($actual_hermes_uid)"
# In rootless Podman the container's "root" is mapped to an
# unprivileged host UID — chown will fail. That's fine: the volume
# is already owned by the mapped user on the host side.
#
# Top-level $HERMES_HOME: chown the directory itself (not its contents)
# so hermes can mkdir new subdirs but bind-mounted host files keep
# their existing ownership.
if refuse_symlinked_path "chown" "$HERMES_HOME"; then
:
else
chown hermes:hermes "$HERMES_HOME" 2>/dev/null || \
echo "[stage2] Warning: chown $HERMES_HOME failed (rootless container?) — continuing"
fi
# Hermes-owned subdirs: recursive chown is safe here because these are
# created and managed exclusively by hermes (see the s6-setuidgid mkdir
# -p block below for the canonical list).
for sub in cron sessions logs hooks memories skills skins plans workspace home profiles pairing platforms/pairing lazy-packages; do
if [ -e "$HERMES_HOME/$sub" ]; then
chown_hermes_tree "$HERMES_HOME/$sub"
fi
done
fi
# chown is skipped in rootless environments where the container cannot
# change ownership of host-mounted volumes.

# --- Immutable install tree ---
# Do not chown runtime code or dependency trees under $INSTALL_DIR back to the
Expand All @@ -268,67 +226,8 @@ fi
# unprivileged runtime user, and it persists across container recreates /
# image updates (an ABI stamp wipes it if a rebuild bumps the interpreter).

# Always reset ownership of $HERMES_HOME/profiles to hermes on every
# boot. Profile dirs and files can land owned by root when commands
# are invoked via `docker exec <container> hermes …` (which defaults
# to root unless `-u` is passed), and that breaks the cont-init
# reconciler (02-reconcile-profiles) which runs as hermes and walks
# the profiles dir. Idempotent; skipped on rootless containers where
# chown would fail.
if [ -d "$HERMES_HOME/profiles" ]; then
chown_hermes_tree "$HERMES_HOME/profiles"
fi

# Always reset ownership of $HERMES_HOME/cron on every boot for the same
# docker-exec/root-write reason as profiles/. The cron scheduler state
# (jobs.json) must stay readable by the unprivileged hermes runtime even
# after root-context maintenance commands or scheduler writes.
if [ -d "$HERMES_HOME/cron" ]; then
chown_hermes_tree "$HERMES_HOME/cron"
fi

# Reset ownership of hermes-owned top-level state files on every boot.
# The targeted data-volume chown above only covers hermes-owned
# *subdirectories*; loose state files living directly under $HERMES_HOME
# are missed. When those files are created or rewritten by
# `docker exec <container> hermes …` (root unless `-u` is passed) they
# land root-owned, and the unprivileged hermes runtime then hits
# PermissionError on next startup (e.g. gateway.lock / state.db /
# auth.json), producing a gateway restart loop.
#
# We use an explicit allowlist rather than a blanket `find -user root`
# sweep so host-owned files in a bind-mounted $HERMES_HOME are never
# touched — same targeted-ownership contract as the subdir chown above
# (issue #19788, PR #19795). The list mirrors the top-level *file*
# entries of hermes_cli.profile_distribution.USER_OWNED_EXCLUDE plus the
# runtime lock files; keep them in sync if that set changes.
for f in \
auth.json auth.lock .env \
state.db state.db-shm state.db-wal \
hermes_state.db \
response_store.db response_store.db-shm response_store.db-wal \
gateway.pid gateway.lock gateway_state.json processes.json \
active_profile; do
if [ -e "$HERMES_HOME/$f" ]; then
if refuse_symlinked_path "chown" "$HERMES_HOME/$f"; then
:
else
chown hermes:hermes "$HERMES_HOME/$f" 2>/dev/null || true
fi
fi
done

# --- config.yaml permissions ---
# Ensure config.yaml is readable by the hermes runtime user even if it
# was edited on the host after initial ownership setup.
if [ -f "$HERMES_HOME/config.yaml" ]; then
if refuse_symlinked_path "chown/chmod" "$HERMES_HOME/config.yaml"; then
:
else
chown hermes:hermes "$HERMES_HOME/config.yaml" 2>/dev/null || true
chmod 640 "$HERMES_HOME/config.yaml" 2>/dev/null || true
fi
fi
# Profile, cron, and file ownership resets are skipped in rootless
# environments where chown is not available.

# --- Seed directory structure as hermes user ---
# Run as hermes via s6-setuidgid so dirs end up owned correctly (matters
Expand Down Expand Up @@ -394,10 +293,9 @@ seed_one "SOUL.md" "docker/SOUL.md"
# unconditionally (not only on first-seed) so a host-mounted .env that was
# created with a permissive umask gets tightened on every container start.
if [ -f "$HERMES_HOME/.env" ]; then
if refuse_symlinked_path "chown/chmod" "$HERMES_HOME/.env"; then
if refuse_symlinked_path "chmod" "$HERMES_HOME/.env"; then
:
else
chown hermes:hermes "$HERMES_HOME/.env" 2>/dev/null || true
chmod 600 "$HERMES_HOME/.env" 2>/dev/null || true
fi
fi
Expand All @@ -421,7 +319,6 @@ if [ ! -f "$HERMES_HOME/auth.json" ] && [ -n "${HERMES_AUTH_JSON_BOOTSTRAP:-}" ]
:
else
printf '%s' "$HERMES_AUTH_JSON_BOOTSTRAP" > "$HERMES_HOME/auth.json"
chown hermes:hermes "$HERMES_HOME/auth.json" 2>/dev/null || true
chmod 600 "$HERMES_HOME/auth.json"
fi
fi
Expand Down Expand Up @@ -457,7 +354,6 @@ if [ ! -f "$HERMES_HOME/gateway_state.json" ] && \
:
else
printf '{"gateway_state":"running"}\n' > "$HERMES_HOME/gateway_state.json"
chown hermes:hermes "$HERMES_HOME/gateway_state.json" 2>/dev/null || true
chmod 644 "$HERMES_HOME/gateway_state.json"
fi
fi
Expand All @@ -473,6 +369,33 @@ if [ -d "$INSTALL_DIR/skills" ]; then
|| echo "[stage2] Warning: skills_sync.py failed; continuing"
fi

# Mark skills as synced so gateway/run.py start_gateway skips its own sync.
export SPARK_SKILLS_SYNCED=1

# Spark/Nexus hosted:每次 Pod 启动把 template/config_maps 进程 env mirror 到 .env / config.yaml。
if [ -f "$INSTALL_DIR/docker/bootstrap_spark_runtime.py" ]; then
/opt/hermes/.venv/bin/python3 "$INSTALL_DIR/docker/bootstrap_spark_runtime.py" || true
fi

# Nexus hosted sandbox: MCP merge + HERMES_CUSTOM_SOUL → SOUL.md (see sync_nexus_hermes_config.py).
NEXUS_MCP_SNIPPET="$HERMES_HOME/nexus-mcp-servers.yaml"
if [ -f "$INSTALL_DIR/docker/sync_nexus_hermes_config.py" ]; then
_nexus_bootstrap=false
if [ -f "$NEXUS_MCP_SNIPPET" ]; then
_nexus_bootstrap=true
fi
if [ -n "${HERMES_CUSTOM_SOUL+x}" ]; then
_nexus_bootstrap=true
fi
if [ "$_nexus_bootstrap" = true ]; then
set -- "$HERMES_HOME/config.yaml"
if [ -f "$NEXUS_MCP_SNIPPET" ]; then
set -- "$@" "$NEXUS_MCP_SNIPPET"
fi
/opt/hermes/.venv/bin/python3 "$INSTALL_DIR/docker/sync_nexus_hermes_config.py" "$@" || true
fi
fi

# --- Discover agent-browser's Chromium binary ---
# The image's Dockerfile runs `npx playwright install chromium`, which
# populates ``$PLAYWRIGHT_BROWSERS_PATH`` (=/opt/hermes/.playwright) with
Expand Down
Loading