From c74562c2c300f5779a78cc5d78ee266832371803 Mon Sep 17 00:00:00 2001 From: Hermes Bot Date: Sat, 25 Jul 2026 16:31:26 -0400 Subject: [PATCH] fix: migrate stranded hermes build context into services/ + drop dashboard local key default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two deferred audit fixes plus a reorg correction: 1. Dashboard gateway-key default (services/v1-parity/dashboard/app.py): drop the guessable "local" fallback. MODEL_GATEWAY_API_KEY now falls back to LITELLM_MASTER_KEY then empty (fail-closed) — the key is always injected in practice, so an unset value must not silently authenticate as "local". 2. The services/ reorg stranded the hermes build context at the top-level hermes/ and (wrongly) declared the agent `build: {external: true}` — but a real in-repo Dockerfile exists. git mv'd the whole context (Dockerfile, entrypoint.sh, ops_client.py, spa-patches.mjs, .dockerignore, plugins/, seed/) into services/hermes/ alongside its agent.yaml + README, and removed the external flag + out-of-band comments. Hermes now resolves to its co-located services/hermes/ context (the default) — no explicit build: key. openai-agent stays external (genuinely no in-repo dir). 3. test_build_contexts.py: hermes now asserted to resolve to a real services/hermes/Dockerfile (new test_hermes_resolves_to_its_in_repo_context); dropped from the external exemption, openai-agent kept. test_hermes_ops_client.py loads ops_client.py from the new path. 4. Fixed live refs to the old top-level hermes/ build paths (.gitattributes, docs/hermes-agent.md, docs/runbooks/{bounded-hermes,secrets}.md, architecture tree, ops-router/__init__.py self-ref, README build recipe). Render byte-identical (docker-compose.yml sha 6450f3f0, unchanged — build is metadata-only, never emitted into compose). agent-hermes builds from the new home. pytest 565 passed / ruff clean. Co-Authored-By: Claude Opus 4.8 --- .gitattributes | 2 +- docs/hermes-agent.md | 10 +++++----- .../architecture-and-principles.md | 2 +- docs/runbooks/bounded-hermes.md | 6 +++--- docs/runbooks/secrets.md | 2 +- {hermes => services/hermes}/.dockerignore | 0 {hermes => services/hermes}/Dockerfile | 0 services/hermes/README.md | 8 ++++---- services/hermes/agent.yaml | 12 ++++-------- {hermes => services/hermes}/entrypoint.sh | 2 +- {hermes => services/hermes}/ops_client.py | 0 .../hermes}/plugins/ops-router/__init__.py | 4 ++-- .../hermes}/plugins/ops-router/plugin.yaml | 0 .../hermes}/plugins/push-through/__init__.py | 0 .../hermes}/plugins/push-through/plugin.yaml | 0 {hermes => services/hermes}/seed/SOUL.md | 0 {hermes => services/hermes}/spa-patches.mjs | 0 services/v1-parity/dashboard/app.py | 2 +- tests/substrate/test_build_contexts.py | 9 ++++++++- tests/test_hermes_ops_client.py | 10 +++++----- 20 files changed, 36 insertions(+), 33 deletions(-) rename {hermes => services/hermes}/.dockerignore (100%) rename {hermes => services/hermes}/Dockerfile (100%) rename {hermes => services/hermes}/entrypoint.sh (99%) rename {hermes => services/hermes}/ops_client.py (100%) rename {hermes => services/hermes}/plugins/ops-router/__init__.py (98%) rename {hermes => services/hermes}/plugins/ops-router/plugin.yaml (100%) rename {hermes => services/hermes}/plugins/push-through/__init__.py (100%) rename {hermes => services/hermes}/plugins/push-through/plugin.yaml (100%) rename {hermes => services/hermes}/seed/SOUL.md (100%) rename {hermes => services/hermes}/spa-patches.mjs (100%) diff --git a/.gitattributes b/.gitattributes index 71fbecdd..0e070bc7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,7 +3,7 @@ # (e.g. on Windows checkouts with autocrlf=true). *.sh text eol=lf services/v1-parity/dashboard/entrypoint.sh text eol=lf -hermes/entrypoint.sh text eol=lf +services/hermes/entrypoint.sh text eol=lf scripts/llamacpp/run-llama-server.sh text eol=lf # nginx config is COPYed into the codebase-memory-ui image; keep LF so nginx # doesn't choke on CRLF after a Windows checkout. diff --git a/docs/hermes-agent.md b/docs/hermes-agent.md index 2e418cc4..8d8f21ff 100644 --- a/docs/hermes-agent.md +++ b/docs/hermes-agent.md @@ -1,6 +1,6 @@ # Hermes Agent (Docker-mode) -> ⚠️ **Naming note — v2 is the only stack (V1 top-level tree retired 2026-07-24, commit `62540bf`; see [`LEGACY-CLEANUP.md`](LEGACY-CLEANUP.md)). The repo was later flattened (2026-07-24, commit `2d4bd9c`): the `v2/` directory no longer exists — its contents live at the repo root; there is no v2, there is only Ordo.** Hermes is the stack's assistant-agent layer and **is the default agent**. The stack models an agent as a **data manifest** ([`../services/hermes/agent.yaml`](../services/hermes/agent.yaml)) that the renderer wires into a single `agent` compose service (the hermes web UI ships as the separate `hermes-dashboard` service-plugin, profile `hermes-ui`). The agent contract (chat via model-gateway, tools via mcp-gateway, GPU via the ops-controller `/jobs` scheduler, `.env` read-only) is documented in [`agents.md`](agents.md). Hermes' persistent brain still lives under **`data/hermes/`** (one root, the primary checkout) and the Discord/`SOUL.md`/state notes below remain accurate. The agent image is built from the root **`hermes/Dockerfile`** (that directory is a kept, live Ordo build source — see [`LEGACY-CLEANUP.md`](LEGACY-CLEANUP.md)) per [`history/CUTOVER.md`](history/CUTOVER.md) (`docker build -t ordo/agent-hermes:latest ./hermes`) and selected via `agent: hermes` in `ordo.yaml`. The stack is brought up entirely from the repo root: edit `ordo.yaml` (template `ordo.example.yaml`), render with `ordo render` (`python -m ordo.cli render --out out`), then `docker compose -p ordo … up` from `out/` — see [`operator-guide.md`](operator-guide.md) and [`history/CUTOVER.md`](history/CUTOVER.md). +> ⚠️ **Naming note — v2 is the only stack (V1 top-level tree retired 2026-07-24, commit `62540bf`; see [`LEGACY-CLEANUP.md`](LEGACY-CLEANUP.md)). The repo was later flattened (2026-07-24, commit `2d4bd9c`): the `v2/` directory no longer exists — its contents live at the repo root; there is no v2, there is only Ordo.** Hermes is the stack's assistant-agent layer and **is the default agent**. The stack models an agent as a **data manifest** ([`../services/hermes/agent.yaml`](../services/hermes/agent.yaml)) that the renderer wires into a single `agent` compose service (the hermes web UI ships as the separate `hermes-dashboard` service-plugin, profile `hermes-ui`). The agent contract (chat via model-gateway, tools via mcp-gateway, GPU via the ops-controller `/jobs` scheduler, `.env` read-only) is documented in [`agents.md`](agents.md). Hermes' persistent brain still lives under **`data/hermes/`** (one root, the primary checkout) and the Discord/`SOUL.md`/state notes below remain accurate. The agent image is built from its co-located build context **`services/hermes/`** (`Dockerfile` alongside the `agent.yaml` manifest) via `docker build -t ordo/agent-hermes:latest ./services/hermes` and selected via `agent: hermes` in `ordo.yaml`. The stack is brought up entirely from the repo root: edit `ordo.yaml` (template `ordo.example.yaml`), render with `ordo render` (`python -m ordo.cli render --out out`), then `docker compose -p ordo … up` from `out/` — see [`operator-guide.md`](operator-guide.md) and [`history/CUTOVER.md`](history/CUTOVER.md). [Hermes Agent](https://github.com/NousResearch/hermes-agent) is the stack's assistant-agent layer. It runs as two compose services — `agent` (Discord / Telegram messaging) and `hermes-dashboard` (web UI, container port 9119, published by Caddy at `https://${CADDY_TAILNET_HOSTNAME}:8447/` — served at its origin root behind a plain SSO proxy, no forwarded-prefix base injection) — that come up with the rest of the stack. @@ -126,14 +126,14 @@ Design rationale and known limitations: `docs/superpowers/specs/2026-04-21-herme ## Updating Hermes -The Hermes upstream SHA is pinned in `hermes/Dockerfile` as `ARG HERMES_PINNED_SHA=...`. To upgrade: +The Hermes upstream SHA is pinned in `services/hermes/Dockerfile` as `ARG HERMES_PINNED_SHA=...`. To upgrade: 1. Check recent commits: `git ls-remote https://github.com/NousResearch/hermes-agent.git main` — pick a SHA. -2. Edit `hermes/Dockerfile`, change the `ARG HERMES_PINNED_SHA` default. -3. Rebuild the image from the root `hermes/` build context (per `history/CUTOVER.md`): `docker build -t ordo/agent-hermes:latest ./hermes` (run from the repo root). +2. Edit `services/hermes/Dockerfile`, change the `ARG HERMES_PINNED_SHA` default. +3. Rebuild the image from the `services/hermes/` build context: `docker build -t ordo/agent-hermes:latest ./services/hermes` (run from the repo root). 4. `cd out && docker compose -p ordo up -d agent hermes-dashboard` (recreates with the rebuilt image). -You can also override without editing the file: `docker build --build-arg HERMES_PINNED_SHA= -t ordo/agent-hermes:latest ./hermes`. +You can also override without editing the file: `docker build --build-arg HERMES_PINNED_SHA= -t ordo/agent-hermes:latest ./services/hermes`. ## Troubleshooting diff --git a/docs/product requirements docs/architecture-and-principles.md b/docs/product requirements docs/architecture-and-principles.md index caf4bd43..92beedd6 100644 --- a/docs/product requirements docs/architecture-and-principles.md +++ b/docs/product requirements docs/architecture-and-principles.md @@ -241,8 +241,8 @@ ordo-ai-stack/ │ ├── codebase-memory/ # Headless codebase-memory MCP │ ├── codebase-memory-ui/ # Codebase-memory 3D graph UI service │ ├── worker/ # Background job worker (Dockerfile, worker.py) +│ ├── hermes/ # Hermes agent build context (Dockerfile, entrypoint.sh, plugins/, seed/) + its agent.yaml manifest │ └── … # edge, model-gateway, mcp-gateway, monitoring, voice, native, … -├── hermes/ # Hermes agent build context (Dockerfile, entrypoint.sh, plugins/, seed/); manifest at services/hermes/agent.yaml ├── ordo/ # Render substrate (Python package): `ordo render`, `ordo detect`, etc. ├── catalog/ # Curated model catalog (models.yaml) ├── auth/ # Edge auth: auth/caddy (Caddyfile), auth/oauth2-proxy (SSO allowlist) diff --git a/docs/runbooks/bounded-hermes.md b/docs/runbooks/bounded-hermes.md index 3b577e21..4ea4e0c4 100644 --- a/docs/runbooks/bounded-hermes.md +++ b/docs/runbooks/bounded-hermes.md @@ -17,7 +17,7 @@ and `/health`; it has none of these verbs.) ## What Hermes can still do -Via `hermes/ops_client.py` (the wrapper that talks to `ops-api`): +Via `services/hermes/ops_client.py` (the wrapper that talks to `ops-api`): - `OpsClient().list_containers()` → `GET /containers` - `OpsClient().container_logs(name, tail=N)` → `GET /containers/{name}/logs` @@ -61,7 +61,7 @@ are three ways to bridge that gap: OpsClient().restart_container("open-webui") ``` 2. **Hermes plugin** (future). Register a `pre_tool_call` hook - (similar to `hermes/plugins/push-through/`) that intercepts the + (similar to `services/hermes/plugins/push-through/`) that intercepts the built-in docker / terminal tools and routes them through `OpsClient`. Smaller blast radius than forking upstream. 3. **Fork upstream** (last resort). Maintain a fork of @@ -98,7 +98,7 @@ module) to retain more. (see `docs/operator-guide.md`). Pattern: `_: None = Depends(verify_token)` → do work → `_audit.record(...)` → return. -3. Add a method on `OpsClient` in `hermes/ops_client.py`. +3. Add a method on `OpsClient` in `services/hermes/ops_client.py`. 4. Migrate any caller that needs it. 5. Test, commit, then from `out/`: `docker compose -p ordo restart ops-api agent`. diff --git a/docs/runbooks/secrets.md b/docs/runbooks/secrets.md index 9d939475..faa5a6bd 100644 --- a/docs/runbooks/secrets.md +++ b/docs/runbooks/secrets.md @@ -201,5 +201,5 @@ HuggingFace, Tavily, etc.). Hook into pre-commit if you want. | `scripts/secrets/decrypt.sh` fails with `Failed to get the data key` | `SOPS_AGE_KEY_FILE` env var not set or key file unreadable | Set `SOPS_AGE_KEY_FILE=$HOME/.config/sops/age/keys.txt` and verify `chmod 600` | | `Error unmarshalling input json: invalid character` on .env.sops decrypt | SOPS 3.7.x doesn't auto-detect dotenv format | Use `--input-type=dotenv --output-type=dotenv` flags. The decrypt script already does this. | | Container starts but immediately exits with `cookie_secret must be 16, 24, or 32 bytes` | `OAUTH2_PROXY_COOKIE_SECRET` was generated with `openssl rand -base64 32` (44 chars) | Regenerate with `LC_ALL=C tr -dc 'a-zA-Z0-9' /agent-` convention as `ordo/agent-hermes:latest`, and reused `hermes-dashboard` plugin (V1 runs one Hermes image as both the gateway and the dashboard). This is an **operator-specific** image: it wraps the operator's Hermes `data/` (SOUL.md, skills, -automation) on top of the pinned Hermes base. V1 builds it from `C:\dev\ordo-ai-stack\hermes` -(`Dockerfile`, multi-stage: node builds the SPA, python runtime installs Hermes at a pinned SHA). -Project buildable image, so `ordo preflight` reports a missing one as "build first". +automation) on top of the pinned Hermes base. It builds from this co-located context +`services/hermes/` (`Dockerfile`, multi-stage: node builds the SPA, python runtime installs Hermes +at a pinned SHA). Project buildable image, so `ordo preflight` reports a missing one as "build first". ## Build ``` -docker build -t ordo/agent-hermes:latest C:/dev/ordo-ai-stack/hermes +docker build -t ordo/agent-hermes:latest C:/dev/ordo-ai-stack/services/hermes ``` Referenced (not duplicated) — the Hermes build context + the operator's `data/` are the single diff --git a/services/hermes/agent.yaml b/services/hermes/agent.yaml index af76179b..bc9d2cb4 100644 --- a/services/hermes/agent.yaml +++ b/services/hermes/agent.yaml @@ -4,13 +4,9 @@ description: > The default Ordo agent — a persistent, skill-driven orchestrator. Chats via the model-gateway, runs tools through the mcp-gateway, and requests GPU work through the ops-controller scheduler. default: true -# image omitted -> resolves to /agent-hermes:latest (the operator builds this from their -# Hermes data/); pin an explicit image here to ship a prebuilt one. -# The agent-hermes image is built OUT-OF-BAND (no in-repo Dockerfile — it's built from the operator's -# Hermes tree), so `build.external` declares that: the substrate test exempts it from the -# "must resolve to a Dockerfile" check. METADATA only; never rendered into compose. -build: - external: true +# image omitted -> resolves to /agent-hermes:latest, built from this co-located build +# context (services/hermes/ + its Dockerfile — the default, so no explicit `build:` is needed); +# pin an explicit image here to ship a prebuilt one instead. # The agent-hermes image's default CMD is `hermes --help` (prints usage and exits); the gateway is # started explicitly so the container runs the persistent messaging gateway (mirrors V1 compose). command: [hermes, gateway] @@ -39,7 +35,7 @@ environment: PYTHONIOENCODING: utf-8 # Scheduler (GPU leases, /status, /model-config) — the `ordo serve` control plane. OPS_CONTROLLER_URL: http://ops-controller:9000 - # Container/compose verbs (ops-router tools via hermes/ops_client.py) live on the + # Container/compose verbs (ops-router tools via services/hermes/ops_client.py) live on the # SEPARATE Bearer-gated ops-api service — NOT the scheduler (which 404s them; audit P0-2). OPS_API_URL: http://ops-api:9000 # STT provider base URL — the local faster-whisper service (voice profile). Harmless if voice off. diff --git a/hermes/entrypoint.sh b/services/hermes/entrypoint.sh similarity index 99% rename from hermes/entrypoint.sh rename to services/hermes/entrypoint.sh index 11250e45..f62c1ce1 100644 --- a/hermes/entrypoint.sh +++ b/services/hermes/entrypoint.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# hermes/entrypoint.sh — container startup. +# services/hermes/entrypoint.sh — container startup. # 1. As root: ensure $HERMES_HOME is writable by the unprivileged hermes user # (bind mounts from the host can land with mismatched ownership on Docker # Desktop / virtiofs; without a chmod here, hermes crash-loops on diff --git a/hermes/ops_client.py b/services/hermes/ops_client.py similarity index 100% rename from hermes/ops_client.py rename to services/hermes/ops_client.py diff --git a/hermes/plugins/ops-router/__init__.py b/services/hermes/plugins/ops-router/__init__.py similarity index 98% rename from hermes/plugins/ops-router/__init__.py rename to services/hermes/plugins/ops-router/__init__.py index 29ed92ea..1e015250 100644 --- a/hermes/plugins/ops-router/__init__.py +++ b/services/hermes/plugins/ops-router/__init__.py @@ -20,8 +20,8 @@ the model defaulting to `terminal: docker ...` (which has no socket and always fails with "Cannot connect to the Docker daemon"). -The OpsClient is the canonical hermes/ops_client.py copied into this plugin -directory at Docker build time (see hermes/Dockerfile). +The OpsClient is the canonical services/hermes/ops_client.py copied into this plugin +directory at Docker build time (see services/hermes/Dockerfile). """ from __future__ import annotations diff --git a/hermes/plugins/ops-router/plugin.yaml b/services/hermes/plugins/ops-router/plugin.yaml similarity index 100% rename from hermes/plugins/ops-router/plugin.yaml rename to services/hermes/plugins/ops-router/plugin.yaml diff --git a/hermes/plugins/push-through/__init__.py b/services/hermes/plugins/push-through/__init__.py similarity index 100% rename from hermes/plugins/push-through/__init__.py rename to services/hermes/plugins/push-through/__init__.py diff --git a/hermes/plugins/push-through/plugin.yaml b/services/hermes/plugins/push-through/plugin.yaml similarity index 100% rename from hermes/plugins/push-through/plugin.yaml rename to services/hermes/plugins/push-through/plugin.yaml diff --git a/hermes/seed/SOUL.md b/services/hermes/seed/SOUL.md similarity index 100% rename from hermes/seed/SOUL.md rename to services/hermes/seed/SOUL.md diff --git a/hermes/spa-patches.mjs b/services/hermes/spa-patches.mjs similarity index 100% rename from hermes/spa-patches.mjs rename to services/hermes/spa-patches.mjs diff --git a/services/v1-parity/dashboard/app.py b/services/v1-parity/dashboard/app.py index c2535ac7..2a9b67b6 100644 --- a/services/v1-parity/dashboard/app.py +++ b/services/v1-parity/dashboard/app.py @@ -199,7 +199,7 @@ async def auth_middleware(request: Request, call_next): MODEL_GATEWAY_URL = os.environ.get("MODEL_GATEWAY_URL", "http://model-gateway:11435").rstrip("/") -MODEL_GATEWAY_API_KEY = os.environ.get("MODEL_GATEWAY_API_KEY", os.environ.get("LITELLM_MASTER_KEY", "local")).strip() +MODEL_GATEWAY_API_KEY = (os.environ.get("MODEL_GATEWAY_API_KEY") or os.environ.get("LITELLM_MASTER_KEY", "")).strip() COMFYUI_URL = os.environ.get("COMFYUI_URL", "http://comfyui:8188").rstrip("/") MODELS_DIR = Path(os.environ.get("MODELS_DIR", "/models")) SCRIPTS_DIR = Path(os.environ.get("SCRIPTS_DIR", "/scripts")) diff --git a/tests/substrate/test_build_contexts.py b/tests/substrate/test_build_contexts.py index e172719a..0918b1f5 100644 --- a/tests/substrate/test_build_contexts.py +++ b/tests/substrate/test_build_contexts.py @@ -85,10 +85,17 @@ def test_folder_id_differs_from_image_name_resolves_to_folder(): assert RESOLVE(img) == ctx, f"{img} resolved to {RESOLVE(img)!r}, expected {ctx!r}" +def test_hermes_resolves_to_its_in_repo_context(): + """agent-hermes has a co-located in-repo build context (services/hermes/ + Dockerfile), so it + must resolve to that real folder — NOT external. Guards the reorg: a manifest that re-declares + hermes external, or a move that strands its Dockerfile, fails here.""" + assert RESOLVE("ordo/agent-hermes:latest") == "services/hermes" + assert (ROOT / "services/hermes/Dockerfile").is_file() + + def test_external_agents_declared_out_of_band(): """Pluggable agent images with no in-repo Dockerfile must be declared external (not silently treated as pullable) — so a NEW agent lacking both a Dockerfile and build.external fails CI.""" - assert RESOLVE("ordo/agent-hermes:latest") == buildspec.EXTERNAL assert RESOLVE("ordo/agent-openai-agent:latest") == buildspec.EXTERNAL diff --git a/tests/test_hermes_ops_client.py b/tests/test_hermes_ops_client.py index 0d8533ce..c816dd7c 100644 --- a/tests/test_hermes_ops_client.py +++ b/tests/test_hermes_ops_client.py @@ -1,10 +1,10 @@ -"""Tests for hermes.ops_client.OpsClient — the ops-api HTTP wrapper. +"""Tests for the Hermes OpsClient — the ops-api HTTP wrapper. -Moved here from hermes/test_ops_client.py (audit P1-8): the pytest CI job +Moved here from the sibling copy (audit P1-8): the pytest CI job only collects tests/, so the sibling copy never ran. The module is loaded by file path (importlib spec_from_file_location) rather than -``from hermes.ops_client import ...`` so collection doesn't depend on -``hermes/`` being importable as a package (it has no __init__.py). +``from services.hermes.ops_client import ...`` so collection doesn't depend +on the build context being importable as a package (it has no __init__.py). Also updated for the fixed wiring (audit P0-2): OpsClient now talks to **ops-api** (OPS_API_URL, default http://ops-api:9000), not the @@ -25,7 +25,7 @@ from httpx import Response ROOT = Path(__file__).resolve().parents[1] -OPS_CLIENT_PY = ROOT / "hermes" / "ops_client.py" +OPS_CLIENT_PY = ROOT / "services" / "hermes" / "ops_client.py" _spec = importlib.util.spec_from_file_location("hermes_ops_client_under_test", OPS_CLIENT_PY) ops_client_mod = importlib.util.module_from_spec(_spec)