Production-like install: the container runs the same GitHub install script as a real user (curl …/install-hermes-plugin.sh | bash -s -- --headless --no-control-plane). Only entrypoint.sh is mounted — it seeds Docker-only config, starts the IntentFrame Control Plane on 0.0.0.0:9720, then brings up the enforcement stack and Hermes dashboard.
The installer normally starts the control plane at the end of install. Docker uses --no-control-plane so the entrypoint can:
- Seed
INTENTFRAME_CONTROL_PLANE_HOST=0.0.0.0andALLOW_REMOTE=1(required for port publishing) - Start the control plane after config is written
The installer may print Control Plane: not started — that is expected; the entrypoint starts it on the next step.
Same as a local install: one uvicorn process on :9720 serves the pre-built React static files (static/index.html, /assets/*) and the JSON API (/api/*). There is no separate nginx or Vite container. App code and static assets come from the GitHub integration pack tarball; only entrypoint.sh is bind-mounted from this repo.
Host browser → localhost:9720 → container uvicorn → static/ + /api/*
Host browser → localhost:9119 → hermes dashboard (separate process)
User-facing install and chat flow: README.md.
Hermes is installed with --headless (skip setup wizard + browser engine). OpenAI model/provider are seeded in entrypoint like tests/hermes_gateway/isolation.py. Dashboard basic auth is seeded so Hermes can bind 0.0.0.0 for Docker port publishing (required since Hermes 0.17+).
export OPENAI_API_KEY=sk-...
docker compose -f tests/docker/docker-compose.test.yml up| Step | URL | Notes |
|---|---|---|
| 1. Control Plane | http://localhost:9720 | Governance, policy, stack status |
| 2. Hermes chat | http://localhost:9119/chat | Sign in with hermes / docker-test |
The entrypoint auto-starts the enforcement stack (intentframe-integrations up hermes) so chat is ready immediately; you can also start/stop it from the Control Plane Overview page.
If you already have a Hermes session cookie, use Log out first to see the login screen.
The entrypoint clears Hermes’s default OpenRouter base_url so OPENAI_API_KEY hits OpenAI directly.
Pin a GitHub ref (branch, tag, or commit) for the install script and integration pack — use the same ref for both:
export REF=my-branch # or REF=v0.2.1, REF=<commit-sha>
# VERSION= is a deprecated alias for REF=Optional model override:
export INTENTFRAME_HERMES_E2E_MODEL=gpt-4o-miniAll paths below are inside the container (hermes-intentframe-test). Run from the repo root; service name is hermes-intentframe.
| Path | What it shows |
|---|---|
/root/.intentframe/logs/control-plane.log |
IntentFrame Control Plane (uvicorn) |
/root/.intentframe/logs/intentframe-server.log |
Primary — pretty INTENT boxes (FILE SHIELD, deterministic Guardian, AE, Guardian ALLOW/BLOCK) |
/root/.intentframe/logs/bundle-sdk.log |
JSON per bundle hook (enforce_constraints, structural_gates, …) with full intent + evidence |
/root/.intentframe/logs/analysis_outputs.log |
Analysis Engine JSON (scope mismatch, risk, hidden behaviors) |
/root/.intentframe/logs/guardian_outputs.log |
Guardian JSON + full IntentFrame object on semantic blocks |
/root/.intentframe/logs/analysis_prompts.log |
Full AE prompts (large) |
/root/.intentframe/logs/guardian_prompts.log |
Full Guardian prompts (large) |
/root/.intentframe/logs/executor_actions.log |
Executor result JSON per intent (validated_only, etc.) |
/root/.intentframe/backend/bridge.log |
Validate bridge HTTP (POST /validate) |
/root/.intentframe/backend/supervisor.log |
if-integration-backend wrapper starting IntentFrame core |
/root/.intentframe/integrations/hermes/adapter.log |
Adapter uvicorn access (mostly POST /validate-tool 200) |
/root/.intentframe/integrations/hermes/gateway.log |
Hermes gateway (from up hermes) |
/root/.hermes/logs/agent.log |
Hermes tool calls, durations, blocked tool JSON |
/root/.hermes/state.db |
Exact tool-call arguments (messages.tool_calls) |
Per-service stdout also lands under /root/.intentframe/logs/ (policy-registry.log, executor.log, resource-registry.log).
docker compose -f tests/docker/docker-compose.test.yml exec hermes-intentframe tail -f /root/.intentframe/logs/intentframe-server.logIn another terminal, use chat at http://localhost:9119/chat. Each governed tool call should produce at least one INTENT #N block.
Optional second tail for semantic blocks (AE + Guardian take several seconds):
docker compose -f tests/docker/docker-compose.test.yml exec hermes-intentframe tail -f /root/.intentframe/logs/guardian_outputs.logList every log file:
docker compose -f tests/docker/docker-compose.test.yml exec hermes-intentframe find /root/.intentframe /root/.hermes/logs -name '*.log' | sort| Outcome | Signature |
|---|---|
| Deterministic ALLOW | ⚡ Deterministic ALLOW — AE + AIGuardian skipped (e.g. safe printf via terminal) |
| Deterministic BLOCK (path) | DETERMINISTIC GUARDIAN → Gate: constraint — no AE/Guardian section |
| Deterministic BLOCK (command) | COMMAND SHIELD: CATASTROPHIC or Gate: command_shield |
| Semantic BLOCK | ANALYSIS ENGINE + GUARDIAN → ⛔ DECISION: BLOCK (e.g. .bashrc overwrite) |
| Semantic ALLOW | GUARDIAN → ✅ DECISION: ALLOW then validated_only: true in executor line |
Hermes dashboard chat may also append a File-mutation verifier footer when write_file / patch failed — that text comes from Hermes (run_agent.py), not the LLM; it echoes the tool error JSON from the gate.
agent.log shows tool results and timing, not always full arguments. For the literal function.arguments JSON:
docker compose -f tests/docker/docker-compose.test.yml exec hermes-intentframe python3 <<'PY'
import sqlite3, json
conn = sqlite3.connect("/root/.hermes/state.db")
cur = conn.cursor()
# Latest session with tool calls
cur.execute("""
SELECT m.id, m.tool_name, m.tool_calls, m.content
FROM messages m
JOIN sessions s ON s.id = m.session_id
WHERE m.tool_calls IS NOT NULL
ORDER BY m.id DESC LIMIT 5
""")
for mid, tool_name, tool_calls, content in cur.fetchall():
print(f"--- msg {mid} tool_name={tool_name} ---")
print(json.dumps(json.loads(tool_calls), indent=2))
if content and len(content) < 600:
print("result:", content)
PYFilter by path or probe name:
docker compose -f tests/docker/docker-compose.test.yml exec hermes-intentframe python3 -c "
import sqlite3, json
c = sqlite3.connect('/root/.hermes/state.db')
for mid, tc in c.execute(
\"SELECT id, tool_calls FROM messages WHERE tool_calls IS NOT NULL\"
\" AND (tool_calls LIKE '%bashrc%' OR tool_calls LIKE '%e2e-block%')\"
):
print(json.dumps(json.loads(tc), indent=2))
"The adapter maps Hermes tools to bridge /validate bodies. Examples:
write_file→WRITE_HOST_FILEwithpath,content,reasonpatch(replace mode) →WRITE_HOST_FILEwith syntheticcontentlike--- /path\n-old\n+...\n(replace '...')
Deterministic path block — bundle-sdk.log (terminal hook on enforce_constraints):
docker compose -f tests/docker/docker-compose.test.yml exec hermes-intentframe grep 'e2e-block-probe' /root/.intentframe/logs/bundle-sdk.log | tail -1 | python3 -m json.toolLook for "phase": "enforce_constraints", "terminal": true, "matched_gate": "constraint".
Semantic block — guardian_outputs.log includes the full intent under converted_output.intent:
docker compose -f tests/docker/docker-compose.test.yml exec hermes-intentframe grep 'bashrc' /root/.intentframe/logs/guardian_outputs.log | tail -1 | python3 -m json.toolAE detail (scope mismatch, hidden behaviors):
docker compose -f tests/docker/docker-compose.test.yml exec hermes-intentframe grep 'bashrc' /root/.intentframe/logs/analysis_outputs.log | tail -1 | python3 -m json.toolUse explicit one-shot prompts in dashboard chat so the model picks the right tool.
| Probe | Example prompt | Expected gate |
|---|---|---|
| Deterministic ALLOW | Use terminal once: printf 'intentframe-allow-test' |
Deterministic ALLOW (skip AE) |
| Deterministic BLOCK (path) | Use write_file once: path /etc/intentframe-e2e-block-probe, content blocked |
Gate: constraint |
| Deterministic BLOCK (sudo) | Use terminal once: sudo echo intentframe-e2e-block-probe |
command_shield / privilege |
| Semantic (home file) | Add comment # testing to ~/.bashrc |
May pass constraint, then AE + Guardian |
After a path-policy block, chat should show tool status: blocked and a file-mutation verifier line; the file must not exist on disk (read_file or ls /etc/...).
| Symptom | Check |
|---|---|
No INTENT #N lines after chat |
LLM never called a governed tool — agent.log, model/API errors |
| Chat 401 to OpenRouter | Stale volume — down -v and restart; entrypoint clears base_url for OpenAI |
adapter.log only shows 200 OK |
Normal; use intentframe-server.log and state.db for detail |
| Config change ignored | Named volumes persist — docker compose … down -v |
| Control Plane 503 / empty UI | Git ref may lack pre-built static/ assets; use a ref that includes the built frontend, or rebuild locally before pinning REF= |
| Overview shows UI server unhealthy but page loads | Fixed in current branch — was uvicorn self-deadlock on /api/status; update ref or rebuild |
Overview URL shows http://0.0.0.0:9720 |
Display only (bind address from .env); use http://localhost:9720 in the browser |
More on IntentFrame log layers: tests/hermes_gateway/README.md (sandbox paths; same filenames under /root/.intentframe in Docker).
Captured manual session write-ups (chat + audit trail): logs/ — e.g. 2026-06-26 gating session (deterministic + semantic ALLOW/BLOCK probes).
After a greenfield install (down -v then up), the installer runs as root and symlinks into /usr/local/bin. These checks do not need an interactive shell or sourcing:
# 1. Greenfield (picks up install script from GitHub main, or REF=your-branch)
docker compose -f tests/docker/docker-compose.test.yml down -v
export OPENAI_API_KEY=sk-...
docker compose -f tests/docker/docker-compose.test.yml up -d
# 2. Direct exec — the case that must work (no bash, no rc)
docker compose -f tests/docker/docker-compose.test.yml exec hermes-intentframe intentframe-integrations --help
# 3. Strict default PATH only (no ~/.local/bin in env)
docker compose -f tests/docker/docker-compose.test.yml exec hermes-intentframe env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
intentframe-integrations --help
# 4. Symlinks point at the venv binary
docker compose -f tests/docker/docker-compose.test.yml exec hermes-intentframe ls -la /usr/local/bin/intentframe-integrations /root/.local/bin/intentframe-integrationsTest your local install script (before merge) by mounting it into a one-off container:
docker run --rm -it \
-v "$(pwd)/scripts/install-hermes-plugin.sh:/install.sh:ro" \
ghcr.io/astral-sh/uv:python3.14-bookworm-slim \
bash -lc 'apt-get update -qq && apt-get install -y -qq curl tar ca-certificates git && bash /install.sh'
# then inside that container (still running):
command -v intentframe-integrations
env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin intentframe-integrations --helpOr pin your ref in compose: export REF=my-branch (or REF=v0.2.1) before up (script is fetched from GitHub).
Reset (fresh install):
docker compose -f tests/docker/docker-compose.test.yml down -vThrowaway container using the local repo (not GitHub tarball). Exercises lifecycle on 0.0.0.0:9720, external health probe, and SPA index:
bash tests/docker/test_control_plane_smoke.shSee control_plane_smoke_inner.sh.
docker compose -f tests/docker/docker-compose.test.yml exec hermes-intentframe intentframe-integrations uninstall hermes --remove-hermesVerify from the host (CLI should be gone):
docker compose -f tests/docker/docker-compose.test.yml exec hermes-intentframe command -v intentframe-integrations || echo "gone"Full uninstall scope and verify steps: docs/hermes-cli.md#uninstall.