From 6b506163e980842fc24947d51928a212eb2f008a Mon Sep 17 00:00:00 2001 From: Hermes Date: Tue, 19 May 2026 20:50:08 -0400 Subject: [PATCH] feat: add combined Hermes Workspace image with Swarm worker support New directory ai/hermes-workspace/ with: - Dockerfile (multi-stage): builds workspace web UI from source, overlays our Hermes fork, installs tmux for Swarm workers - entrypoint-combined.sh: starts workspace UI, waits for gateway - himalaya-ro.sh: read-only Himalaya wrapper (shared from hermes/) Existing ai/hermes/ Dockerfile preserved unchanged as fallback. compose.yml changes: - Add HERMES_DASHBOARD=1 + healthcheck to hermes service - Add hermes-workspace service using combined image (build context: ./hermes-workspace, SSH build) - Connects to hermes:8642 (gateway) + :9119 (dashboard) - Shares Hermes data volume for config/sessions/skills - Traefik on workspace.lazyworkhorse.net (port 3000) - Networks: ai_backend + ai_net --- ai/compose.yml | 50 +++++++++ ai/hermes-workspace/Dockerfile | 125 +++++++++++++++++++++ ai/hermes-workspace/entrypoint-combined.sh | 33 ++++++ ai/hermes-workspace/himalaya-ro.sh | 73 ++++++++++++ 4 files changed, 281 insertions(+) create mode 100644 ai/hermes-workspace/Dockerfile create mode 100644 ai/hermes-workspace/entrypoint-combined.sh create mode 100644 ai/hermes-workspace/himalaya-ro.sh diff --git a/ai/compose.yml b/ai/compose.yml index 1db7831..446d2f5 100644 --- a/ai/compose.yml +++ b/ai/compose.yml @@ -52,6 +52,10 @@ services: - ROCR_VISIBLE_DEVICES=0,1 - HSA_ENABLE_SDMA=0 - TZ=America/Montreal + # Hermes Workspace dashboard (port 9119) — enables multi-agent web UI + - HERMES_DASHBOARD=1 + - HERMES_DASHBOARD_HOST=0.0.0.0 + - HERMES_DASHBOARD_PORT=9119 volumes: - /mnt/HoardingCow_docker_data/Hermes/data:/opt/data # Syncthing-shared org files — read-only view of user's agenda @@ -66,6 +70,12 @@ services: - "26" networks: - ai_backend + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:8642/health && curl -fsS http://localhost:9119/api/status || exit 1"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 60s syncthing: image: syncthing/syncthing:latest @@ -129,6 +139,46 @@ services: - "303" - "26" + # ── Hermes Workspace (combined image) ──────────────────────── + # Web UI + Swarm worker support. Uses custom combined image with + # our Hermes fork + workspace web UI + tmux for Swarm workers. + hermes-workspace: + build: + context: ./hermes-workspace + ssh: + - default + container_name: hermes-workspace + restart: unless-stopped + depends_on: + hermes: + condition: service_healthy + environment: + HERMES_API_URL: http://hermes:8642 + HERMES_DASHBOARD_URL: http://hermes:9119 + HERMES_API_TOKEN: hermes_local_key + HERMES_PASSWORD: ${HERMES_WORKSPACE_PASSWORD:?must be set} + COOKIE_SECURE: "1" + volumes: + - /mnt/HoardingCow_docker_data/Hermes/data:/opt/data + networks: + - ai_backend + - ai_net + labels: + - "traefik.enable=true" + - "traefik.docker.network=ai_net" + + - "traefik.http.routers.workspace-http.rule=Host(`workspace.lazyworkhorse.net`)" + - "traefik.http.routers.workspace-http.entrypoints=web" + - "traefik.http.routers.workspace-http.middlewares=redirect-to-https" + + - "traefik.http.routers.workspace-https.rule=Host(`workspace.lazyworkhorse.net`)" + - "traefik.http.routers.workspace-https.entrypoints=websecure" + - "traefik.http.routers.workspace-https.tls=true" + - "traefik.http.routers.workspace-https.tls.certresolver=njalla" + + - "traefik.http.services.workspace.loadbalancer.server.port=3000" +# ───────────────────────────────────────────────────────────── + networks: ai_net: external: true diff --git a/ai/hermes-workspace/Dockerfile b/ai/hermes-workspace/Dockerfile new file mode 100644 index 0000000..2f40c9a --- /dev/null +++ b/ai/hermes-workspace/Dockerfile @@ -0,0 +1,125 @@ +# syntax=docker/dockerfile:1 +# Hermes Agent + Hermes Workspace — combined image +# Builds on top of official image + our forked source + workspace UI. +# Supports Swarm Mode (tmux workers) in a single container. +# Requires Docker BuildKit. Pass SSH agent for git clone: +# docker compose build hermes-workspace + +# ---------- Stage 1: Build Hermes Workspace (web UI) ---------- +FROM node:22-slim AS workspace-build + +WORKDIR /app + +# Install pnpm and git +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates git curl \ + && rm -rf /var/lib/apt/lists/* \ + && corepack enable + +# Clone workspace source (pinned to a known-good commit on main) +RUN git clone --depth 1 --branch main \ + https://github.com/outsourc-e/hermes-workspace.git /app/workspace-src \ + && rm -rf /app/workspace-src/.git + +WORKDIR /app/workspace-src + +# Install deps and build +RUN pnpm install --frozen-lockfile && pnpm build + +# ---------- Stage 2: Hermes Agent + Workspace runtime ---------- +FROM nousresearch/hermes-agent:latest + +# ---------- Install Node.js + tmux for Workspace + Swarm ---------- +USER root +RUN apt-get update && apt-get install -y --no-install-recommends \ + nodejs tmux ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +# ---------- Overlay our forked Hermes source ---------- +RUN --mount=type=ssh \ + mkdir -p /root/.ssh && \ + ssh-keyscan -p 2222 code.lazyworkhorse.net >> /root/.ssh/known_hosts 2>/dev/null && \ + cd /tmp && \ + GIT_SSH_COMMAND='ssh -p 2222 -o StrictHostKeyChecking=no' \ + git clone --depth 1 --branch main \ + git@code.lazyworkhorse.net:gortium/hermes-agent.git fork && \ + rsync -a --delete fork/ /opt/hermes/ \ + --exclude node_modules \ + --exclude .venv \ + --exclude .git && \ + rm -rf /tmp/fork /root/.ssh/ + +# ---------- Rebuild web UI ---------- +RUN cd /opt/hermes && npm run build + +# ---------- Reinstall Python package ---------- +RUN . /opt/hermes/.venv/bin/activate && \ + uv pip install --no-cache-dir --no-deps -e /opt/hermes + +# ---------- Extra system deps ---------- +RUN apt-get update && apt-get install -y --no-install-recommends \ + libportaudio2 ca-certificates poppler-utils imagemagick \ + texlive-latex-base texlive-latex-extra texlive-fonts-recommended \ + texlive-xetex texlive-science \ + qemu-user-static binfmt-support emacs-nox \ + && rm -rf /var/lib/apt/lists/* + +# ---------- UV ---------- +COPY --chmod=0755 --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/ + +# ---------- Piper TTS ---------- +RUN . /opt/hermes/.venv/bin/activate && \ + uv pip install --no-cache-dir piper-tts sounddevice numpy && \ + mkdir -p /opt/hermes/.venv/share/piper/voices + +RUN /opt/hermes/.venv/bin/python3 /dev/stdin << 'PYEOF' +import urllib.request +base = '/opt/hermes/.venv/share/piper/voices' +url = 'https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/ryan/high/en_US-ryan-high.onnx' +urllib.request.urlretrieve(url, base + '/en_US-ryan-high.onnx') +urllib.request.urlretrieve(url + '.json', base + '/en_US-ryan-high.onnx.json') +PYEOF + +# ---------- Install Himalaya email CLI ---------- +RUN /opt/hermes/.venv/bin/python3 /dev/stdin << 'PYEOF' +import urllib.request, tarfile, os, shutil +url = 'https://github.com/pimalaya/himalaya/releases/download/v1.2.0/himalaya.x86_64-linux.tgz' +tgz = '/tmp/himalaya.tgz' +urllib.request.urlretrieve(url, tgz) +with tarfile.open(tgz) as t: + t.extractall('/tmp') +shutil.move('/tmp/himalaya', '/usr/local/bin/himalaya') +os.chmod('/usr/local/bin/himalaya', 0o755) +os.remove(tgz) +print('himalaya v1.2.0 installed') +PYEOF + +# ---------- Install himalaya-ro wrapper ---------- +COPY --chmod=0755 himalaya-ro.sh /usr/local/bin/himalaya-ro + +# ---------- Copy Hermes Workspace build artifacts ---------- +COPY --from=workspace-build --chown=hermes:hermes \ + /app/workspace-src/dist /workspace/dist +COPY --from=workspace-build --chown=hermes:hermes \ + /app/workspace-src/node_modules /workspace/node_modules +COPY --from=workspace-build --chown=hermes:hermes \ + /app/workspace-src/package.json /workspace/ +COPY --from=workspace-build --chown=hermes:hermes \ + /app/workspace-src/server-entry.js /workspace/ +COPY --from=workspace-build --chown=hermes:hermes \ + /app/workspace-src/skills /workspace/skills +COPY --chmod=0755 entrypoint-combined.sh /usr/local/bin/entrypoint-combined.sh + +# ---------- Runtime ---------- +USER hermes +ENV HERMES_HOME=/opt/data +ENV PATH="/opt/data/.local/bin:${PATH}" +ENV CHROME_EXECUTABLE=/opt/hermes/.playwright/chromium/chrome-linux/chrome + +RUN chown -R hermes:hermes /opt/hermes/tools /opt/hermes/toolsets.py + +VOLUME [ "/opt/data" ] + +EXPOSE 8642 9119 3000 + +ENTRYPOINT ["/usr/bin/tini", "-g", "--", "/usr/local/bin/entrypoint-combined.sh"] diff --git a/ai/hermes-workspace/entrypoint-combined.sh b/ai/hermes-workspace/entrypoint-combined.sh new file mode 100644 index 0000000..614b535 --- /dev/null +++ b/ai/hermes-workspace/entrypoint-combined.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -e + +# ── Hermes Workspace + Swarm Worker Entrypoint ── +# Starts Hermes Workspace web UI (port 3000) and makes +# hermes CLI + tmux available for Swarm workers. +# The Hermes gateway runs in a separate container (hermes:8642). +# Swarm workers spawned here connect to the gateway via HTTP. +# ────────────────────────────────────────────────────────── + +# Install custom tools from persistent volume +if [ -f /opt/data/hermes-tools/install.sh ]; then + bash /opt/data/hermes-tools/install.sh || true +fi + +# Wait for Hermes gateway to be healthy before starting workspace +if [ -n "${HERMES_API_URL:-}" ]; then + echo "Waiting for Hermes gateway..." + for i in $(seq 1 30); do + if curl -fsS "${HERMES_API_URL}/health" >/dev/null 2>&1; then + echo "Gateway healthy after ${i}s" + break + fi + if [ "$i" -eq 30 ]; then + echo "WARNING: Gateway not healthy after 30s, starting workspace anyway" + fi + sleep 1 + done +fi + +# Start Hermes Workspace in foreground +cd /workspace +exec node --max-old-space-size=2048 server-entry.js diff --git a/ai/hermes-workspace/himalaya-ro.sh b/ai/hermes-workspace/himalaya-ro.sh new file mode 100644 index 0000000..212f1ae --- /dev/null +++ b/ai/hermes-workspace/himalaya-ro.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────── +# himalaya-ro — Read-only wrapper for himalaya +# +# Blocks destructive commands and logs audit trail. +# Pass-through for read-only commands (list, read, search). +# +# Usage: himalaya-ro [options] [args...] +# +# Install: place in PATH before the real himalaya, or use +# `ln -sf himalaya-ro /usr/local/bin/himalaya` +# ───────────────────────────────────────────────────────────── +set -o pipefail + +# ── Configuration ─────────────────────────────────────────── +HIMALAYA_BIN="${HIMALAYA_BIN:-/usr/local/bin/himalaya}" +AUDIT_LOG="${HIMALAYA_AUDIT_LOG:-/var/log/himalaya-audit.log}" + +# ── Destructive commands we block ────────────────────────── +BLOCKED_CMDS=( + "message move" + "message delete" + "message copy" + "flag add" + "flag remove" + "folder create" + "folder delete" + "folder rename" + "template send" + "account configure" + "account delete" +) + +# ── Determine the subcommand being invoked ───────────────── +# Strip leading options (--account, --output, etc.) to find the verb +ARGS=() +SKIP_NEXT=false +for arg in "$@"; do + if $SKIP_NEXT; then + SKIP_NEXT=false + continue + fi + if [[ "$arg" == --* ]]; then + case "$arg" in + --account|--output|--page|--page-size|--folder|--color|--format) + SKIP_NEXT=true ;; + esac + continue + fi + ARGS+=("$arg") +done + +# Build subcommand string and check against blocklist +CMD_STR="" +for ((i=0; i<${#ARGS[@]}; i++)); do + if [ -z "$CMD_STR" ]; then + CMD_STR="${ARGS[$i]}" + else + CMD_STR="$CMD_STR ${ARGS[$i]}" + fi + for blocked in "${BLOCKED_CMDS[@]}"; do + if [[ "$CMD_STR" == "$blocked" ]]; then + TS=$(date '+%Y-%m-%d %H:%M:%S') + echo "[AUDIT] $TS BLOCKED: himalaya $*" >> "$AUDIT_LOG" + echo "ERROR: Command 'himalaya $CMD_STR ...' is blocked by read-only policy." >&2 + echo " Audit log: $AUDIT_LOG" >&2 + exit 100 + fi + done +done + +# ── Allow pass-through ───────────────────────────────────── +exec "$HIMALAYA_BIN" "$@"