diff --git a/.gitea/workflows/images.yml b/.gitea/workflows/images.yml index 4f2be97..b8599f7 100644 --- a/.gitea/workflows/images.yml +++ b/.gitea/workflows/images.yml @@ -59,15 +59,24 @@ jobs: IMAGE=git.lair.cafe/lair/hermes # Self-healing: the source of truth is "is this version in the registry?" # — not a committed pin that can desync if a prior build failed. + # NB: when the *build definition* changes (e.g. the writable-tree + # layer), republish the same version with the `force` dispatch input. if [ "$FORCE" != "true" ] && skopeo inspect "docker://${IMAGE}:${VERSION}" >/dev/null 2>&1; then echo "${IMAGE}:${VERSION} already published — nothing to build" exit 0 fi - echo "building ${IMAGE}:${VERSION} from NousResearch/hermes-agent#${TAG}" - podman build --pull=newer \ + # Two-stage: (1) build upstream from the git context into a local tag, + # (2) derive our published image from it via images/hermes/Containerfile + # (makes /opt/hermes writable by uid 10000 — see that file). + BASE="localhost/hermes-upstream:${VERSION}" + echo "[1/2] building upstream ${BASE} from NousResearch/hermes-agent#${TAG}" + podman build --pull=newer -t "${BASE}" \ + "https://github.com/NousResearch/hermes-agent.git#${TAG}" + echo "[2/2] building derived (writable /opt/hermes) -> ${IMAGE}:${VERSION}" + podman build --build-arg BASE="${BASE}" \ -t "${IMAGE}:${VERSION}" \ -t "${IMAGE}:latest" \ - "https://github.com/NousResearch/hermes-agent.git#${TAG}" + images/hermes podman push "${IMAGE}:${VERSION}" podman push "${IMAGE}:latest" echo "published ${IMAGE}:${VERSION} (and :latest)" diff --git a/images/hermes/Containerfile b/images/hermes/Containerfile new file mode 100644 index 0000000..7de934b --- /dev/null +++ b/images/hermes/Containerfile @@ -0,0 +1,20 @@ +# Derived layer over the upstream NousResearch/hermes-agent image. +# +# Upstream ships /opt/hermes (the app: Python source + .venv + scripts) as a +# read-only tree owned by root (0555/0444). That stops the runtime `hermes` +# user (uid 10000) from self-modifying and, concretely, stops the gateway from +# auto-installing the WhatsApp Node bridge's node_modules in place. +# +# We make the whole app tree writable by uid 10000 so the agent has "untied +# hands". This is done in the IMAGE (not a volume) on purpose: a volume over +# /opt/hermes would copy-up once and then freeze the app, silently defeating +# AutoUpdate=registry. As a baked layer it ships hands-free on every pull AND +# refreshes cleanly on every update. Anything that must PERSIST across updates +# belongs on the /opt/data volume (skills, memory, sessions, the WhatsApp +# bridge via the gateway's `bridge_script` config), not in /opt/hermes. +# +# BASE is the upstream image the `images` workflow builds from the git context. +ARG BASE +FROM ${BASE} +USER root +RUN chown -R 10000:10000 /opt/hermes && chmod -R u+w /opt/hermes diff --git a/images/hermes/build.sh b/images/hermes/build.sh index 133837f..b0e8520 100755 --- a/images/hermes/build.sh +++ b/images/hermes/build.sh @@ -17,11 +17,18 @@ fi [ -n "${HERMES_REF}" ] || { echo "could not resolve an upstream hermes ref"; exit 1; } VERSION="${HERMES_REF#v}" -echo "building ${IMAGE_NAME}:${VERSION} from NousResearch/hermes-agent#${HERMES_REF}" -podman build --pull=newer \ +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BASE="localhost/hermes-upstream:${VERSION}" + +echo "[1/2] building upstream ${BASE} from NousResearch/hermes-agent#${HERMES_REF}" +podman build --pull=newer -t "${BASE}" \ + "https://github.com/NousResearch/hermes-agent.git#${HERMES_REF}" + +echo "[2/2] building derived (writable /opt/hermes) -> ${IMAGE_NAME}:${VERSION}" +podman build --build-arg BASE="${BASE}" \ -t "${IMAGE_NAME}:${VERSION}" \ -t "${IMAGE_NAME}:latest" \ - "https://github.com/NousResearch/hermes-agent.git#${HERMES_REF}" + "${SCRIPT_DIR}" echo "built ${IMAGE_NAME}:${VERSION} and :latest" echo "push with: podman push ${IMAGE_NAME}:${VERSION} && podman push ${IMAGE_NAME}:latest" diff --git a/images/hermes/readme.md b/images/hermes/readme.md index 443fd93..c090e55 100644 --- a/images/hermes/readme.md +++ b/images/hermes/readme.md @@ -6,22 +6,31 @@ self-improving AI agent — packaged for lair infra and published to ## How it's built -Upstream ships its own `Dockerfile` (debian 13 + s6-overlay), so there is **no -vendored Containerfile here**. The `images` workflow (and `build.sh`) build -straight from the upstream git context at the latest release tag: +Upstream ships its own `Dockerfile` (debian 13 + s6-overlay), so we build in two +stages — `build.sh` and the `images` workflow both do this: -``` -podman build github.com/NousResearch/hermes-agent.git# \ - -t git.lair.cafe/lair/hermes: -t git.lair.cafe/lair/hermes:latest -``` +1. **Upstream** — build straight from the upstream git context at the release tag + into a local tag: `podman build github.com/NousResearch/hermes-agent.git#`. +2. **Derived** — [`Containerfile`](Containerfile) does `FROM` that and makes the + `/opt/hermes` app tree writable by the runtime `hermes` user (uid 10000), then + publishes `git.lair.cafe/lair/hermes:{,latest}`. + +The writable-tree layer gives the agent "untied hands" (self-modify; the gateway +can auto-install the WhatsApp bridge's `node_modules` in place) **without** the +volume trap: a volume over `/opt/hermes` would copy-up once and freeze the app, +silently defeating `AutoUpdate=registry`. Baked into the image, it ships +hands-free on every pull and refreshes cleanly on update. **Persistence belongs +on `/opt/data`** (skills, memory, sessions, the WhatsApp bridge via `bridge_script`), +never in `/opt/hermes`. Builds are **release-triggered** (daily poll of the GitHub releases API; a build runs only when that version isn't already in our registry) and **self-healing** -(a failed build leaves the version absent, so the next poll retries). Force a -rebuild via the workflow's `force` dispatch input, or locally: +(a failed build leaves the version absent, so the next poll retries). When the +*build definition* changes (e.g. this writable layer) for an already-published +version, republish with the workflow's `force` dispatch input. Locally: ``` -HERMES_REF=v0.2.0 ./build.sh +HERMES_REF=v0.17.0 ./build.sh ``` ## How it runs (single container, gateway + dashboard)