Compare commits

..

2 Commits

9 changed files with 89 additions and 382 deletions

View File

@@ -1,31 +0,0 @@
# AGENTS.md
Development conventions for the compose repository (Docker Compose stacks).
## Build & Deploy
- The ai stack is managed via `systemctl restart ai_stack.service` on the NixOS host
- Compose files are built from the git repo; apply via `nh os switch` or `systemctl restart ai_stack.service`
- Never `docker compose up -d` directly — it bypasses the systemd env file and breaks secrets
## Hermes Workers
- Paperclip Hermes workers are added via `ai/scripts/provision-hermes-worker.sh`
- The script appends only — never deletes or modifies existing content
- Workers are CPU-only containers on the `ai_backend` network with no GPU passthrough
## Workflow
- New feature → clean branch from `origin/master` → push → PR on Gitea
- Branch naming: `feat/description` (features), `fix/description` (bugs/docs)
- Always branch from `origin/master`, never from another feature branch
- Submodule changes (when this repo is consumed as a submodule): commit the submodule update in the parent repo
- PR title should describe the change; body should explain motivation + summary
- After PR merge, delete the feature branch
## YAML Conventions
- Use `x-*` extension fields for reusable anchors
- Comment out inactive services rather than deleting them
- Environment variables in dictionary format (`KEY: value`) for anchor compatibility
- List format (`- KEY=value`) works for standard services but doesn't merge with anchors

View File

@@ -1,35 +1,4 @@
version: "3.8" version: "3.8"
# ── Hermes Worker Template ──────────────────────────────────
# Used by paperclip-worker-* Hermes containers via YAML anchor.
# Each worker = one isolated Hermes agent for a Paperclip employee.
# Override at service level: container_name, API_SERVER_PORT,
# API_SERVER_KEY, DISCORD_BOT_TOKEN, volumes.
# Workers have NO GPU — they use OpenCode Go or remote providers.
x-hermes-worker: &hermes-worker
build:
context: ./hermes
ssh:
- default
entrypoint: ["/bin/bash", "-c",
"bash /opt/data/hermes-tools/install.sh && exec /usr/bin/tini -g -- /opt/hermes/docker/entrypoint.sh \"$@\"",
"hermes-entrypoint"]
command: gateway run
restart: always
environment:
API_SERVER_ENABLED: "true"
API_SERVER_HOST: "0.0.0.0"
OLLAMA_HOST: "http://ollama:11434"
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
# Each worker needs its own OpenCode Go API key in .env
OPENCODE_GO_API_KEY: ${OPENCODE_GO_API_KEY}
GATEWAY_ALLOW_ALL_USERS: "true"
TZ: "America/Montreal"
networks:
ai_backend:
# NO devices — workers are CPU-only, no GPU passthrough
# ─────────────────────────────────────────────────────────────
services: services:
# webui: # webui:
@@ -57,14 +26,8 @@ services:
# - "traefik.http.routers.webui-https.tls.certresolver=njalla" # - "traefik.http.routers.webui-https.tls.certresolver=njalla"
hermes: hermes:
build: build: ./hermes
context: ./hermes
ssh:
- default
container_name: hermes container_name: hermes
entrypoint: ["/bin/bash", "-c",
"bash /opt/data/hermes-tools/install.sh && exec /usr/bin/tini -g -- /opt/hermes/docker/entrypoint.sh \"$@\"",
"hermes-entrypoint"]
restart: always restart: always
# Gateway run enables the internal API server on port 8642 # Gateway run enables the internal API server on port 8642
command: gateway run command: gateway run
@@ -127,24 +90,6 @@ services:
- "303" - "303"
- "26" - "26"
# ── Paperclip Worker Hermes Agents ──────────────────────────
# Each worker is an isolated Hermes agent for a Paperclip employee.
# Add new workers with: ./scripts/provision-hermes-worker.sh <name> <discord-token>
# The API server key and port are generated automatically.
# Workers are CPU-only — they use OpenCode Go or remote providers.
# ── Worker Template (commented — uncomment + configure to activate) ──
# hermes-worker-1:
# <<: *hermes-worker
# container_name: hermes-worker-1
# environment:
# API_SERVER_PORT: "8651"
# API_SERVER_KEY: "generated-by-provision-script"
# DISCORD_BOT_TOKEN: ${WORKER_1_DISCORD_BOT_TOKEN}
# volumes:
# - /mnt/HoardingCow_docker_data/Hermes/worker-1:/opt/data
# ─────────────────────────────────────────────────────────────
networks: networks:
ai_net: ai_net:
external: true external: true

View File

@@ -1,59 +1,50 @@
# syntax=docker/dockerfile:1 # 1. On récupère la version la plus récente d'UV
# Hermes Agent -- custom fork build FROM ghcr.io/astral-sh/uv:latest AS uv_source
# Builds on top of official image + overlays our forked source from Gitea.
# Requires Docker BuildKit. Pass SSH agent for git clone:
# docker compose build hermes
# Or manually:
# DOCKER_BUILDKIT=1 docker build --ssh default -t hermes-agent:custom .
# ---------- Base: official Hermes image (system deps, npm, uv, Playwright) ---------- # 2. Image officielle Hermes Agent de NousResearch
# Contient déjà: Python, Node.js, npm, Playwright/Chromium, venv, tts_tool.py, etc.
FROM nousresearch/hermes-agent:latest FROM nousresearch/hermes-agent:latest
# ---------- Overlay our forked source ---------- # ---------- System dependencies ----------
# Uses SSH agent forwarding from the build host (no key baked into image). # The official hermes-agent image already has: git, curl, ffmpeg, python3,
# --exclude node_modules/.venv keeps the base image's pre-built layers intact. # gcc, build-essential, openssh-client, procps, tini, ripgrep, docker-cli,
# Only the Python source, web UI source, and config change. # libportaudio2, ca-certificates, etc.
RUN --mount=type=ssh \ #
mkdir -p /root/.ssh && \ # These extras we need to add back:
ssh-keyscan -p 2222 code.lazyworkhorse.net >> /root/.ssh/known_hosts 2>/dev/null && \ # - poppler-utils, imagemagick (PDF/image processing)
cd /tmp && \ # - texlive-* (LaTeX typesetting for reports)
GIT_SSH_COMMAND='ssh -p 2222 -o StrictHostKeyChecking=no' \ # - qemu-user-static, binfmt-support (QEMU cross-compilation)
git clone --depth 1 --branch main \ # - emacs-nox (text editing in container)
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 ----------
# Source files changed; node_modules (from base image) reused.
RUN cd /opt/hermes && npm run build
# ---------- Reinstall Python package (editable) ----------
# Picks up source changes from our fork.
RUN . /opt/hermes/.venv/bin/activate && \
uv pip install --no-cache-dir --no-deps -e /opt/hermes
# ---------- Extra system deps ----------
USER root USER root
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
libportaudio2 ca-certificates poppler-utils imagemagick \ libportaudio2 \
texlive-latex-base texlive-latex-extra texlive-fonts-recommended \ ca-certificates \
texlive-xetex texlive-science \ poppler-utils \
qemu-user-static binfmt-support emacs-nox && \ 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/* rm -rf /var/lib/apt/lists/*
# ---------- UV ---------- # ---------- UV (hyperfast pip alternative) ----------
COPY --chmod=0755 --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/ COPY --chmod=0755 --from=uv_source /uv /usr/local/bin/
# ---------- Piper TTS ---------- WORKDIR /opt/hermes
# ---------- Piper TTS dans le venv existant ----------
# Le venv de l'image de base est root-owned, on doit installer en root aussi
RUN . /opt/hermes/.venv/bin/activate && \ RUN . /opt/hermes/.venv/bin/activate && \
uv pip install --no-cache-dir piper-tts sounddevice numpy && \ 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' # ---------- Télécharger la voix Piper Ryan (high quality) ----------
RUN mkdir -p /opt/hermes/.venv/share/piper/voices && \
/opt/hermes/.venv/bin/python3 /dev/stdin << 'PYEOF'
import urllib.request import urllib.request
base = '/opt/hermes/.venv/share/piper/voices' 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' url = 'https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/ryan/high/en_US-ryan-high.onnx'
@@ -61,33 +52,22 @@ urllib.request.urlretrieve(url, base + '/en_US-ryan-high.onnx')
urllib.request.urlretrieve(url + '.json', base + '/en_US-ryan-high.onnx.json') urllib.request.urlretrieve(url + '.json', base + '/en_US-ryan-high.onnx.json')
PYEOF PYEOF
# ---------- Install Himalaya email CLI ---------- # ---------- Patch tts_tool.py: remplacer Edge TTS par Piper ----------
RUN /opt/hermes/.venv/bin/python3 /dev/stdin << 'PYEOF' # Edge TTS appelle les serveurs Microsoft — on ne veut jamais ça.
import urllib.request, tarfile, os, shutil # Piper roule localement sur CPU, aucun cloud, aucune donnée qui sort.
url = 'https://github.com/pimalaya/himalaya/releases/download/v1.2.0/himalaya.x86_64-linux.tgz' COPY patch_tts_tool.py /tmp/patch_tts_tool.py
tgz = '/tmp/himalaya.tgz' RUN /opt/hermes/.venv/bin/python3 /tmp/patch_tts_tool.py && rm /tmp/patch_tts_tool.py
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
# ---------- Runtime ---------- # ---------- Runtime ----------
# Retour à l'utilisateur non-root pour la sécurité
USER hermes USER hermes
ENV HERMES_HOME=/opt/data ENV HERMES_HOME=/opt/data
ENV PATH="/opt/data/.local/bin:${PATH}" ENV PATH="/opt/data/.local/bin:${PATH}"
# Point browser tool to Playwright's Chromium (already in base image)
ENV CHROME_EXECUTABLE=/opt/hermes/.playwright/chromium/chrome-linux/chrome
# Ensure tools directory and toolsets.py are writable by the hermes runtime user
# so custom tools can be injected from the persistent volume at startup.
RUN chown -R hermes:hermes /opt/hermes/tools /opt/hermes/toolsets.py
VOLUME [ "/opt/data" ] VOLUME [ "/opt/data" ]
# Script de réparation des permissions + patch TTS au démarrage
COPY --chmod=0755 fix-permissions.sh /opt/hermes/fix-permissions.sh
ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/opt/hermes/fix-permissions.sh" ]

View File

@@ -0,0 +1,38 @@
#!/bin/bash
# Startup permission fix + TTS patch.
# Runs as root before the entrypoint drops to the hermes user.
set -e
HERMES_HOME="${HERMES_HOME:-/opt/data}"
# Fix ownership on critical writable directories
chown -R hermes:hermes \
"$HERMES_HOME/sessions" \
"$HERMES_HOME/checkpoints" \
"$HERMES_HOME/skills" \
"$HERMES_HOME/memories" \
"$HERMES_HOME/workspace" \
"$HERMES_HOME/pastes" \
"$HERMES_HOME/logs" \
"$HERMES_HOME/cron" \
"$HERMES_HOME/plans" \
"$HERMES_HOME/hooks" \
"$HERMES_HOME/cache" \
2>/dev/null || true
# Fix data volume root ownership
if [ "$(stat -c %u "$HERMES_HOME" 2>/dev/null)" != "$(id -u hermes)" ]; then
chown hermes:hermes "$HERMES_HOME" 2>/dev/null || true
fi
# ---------- Patch tts_tool.py: replace Edge TTS with Piper ----------
# Fallback runtime patch in case the volume's site-packages differ from the image.
# Idempotent: if already patched, the script does nothing.
PATCH_SCRIPT="/opt/hermes/patch_tts_tool.py"
if [ -f "$PATCH_SCRIPT" ]; then
echo "Applying TTS patch (Piper only, no Edge fallback)..."
/opt/hermes/.venv/bin/python3 "$PATCH_SCRIPT" 2>&1 || true
fi
# Chain to the official Hermes entrypoint
exec /opt/hermes/docker/entrypoint.sh "$@"

View File

@@ -1,73 +0,0 @@
#!/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] <command> [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" "$@"

View File

@@ -1,135 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# ── Hermes Worker Provisioner ──────────────────────────────
# Adds a new Paperclip Hermes worker to the ai compose stack.
#
# Usage:
# ./provision-hermes-worker.sh <name> <discord_bot_token_var>
#
# Example:
# ./provision-hermes-worker.sh worker-1 WORKER_1_DISCORD_BOT_TOKEN
#
# The script APPENDS only — never modifies or removes existing
# content, even commented lines.
#
# Post-provision steps (manual):
# 1. Add secrets to agenix .env file
# 2. systemctl restart ai_stack.service
# 3. Configure Paperclip agent
# ─────────────────────────────────────────────────────────────
NAME="${1:?Usage: $0 <name> <discord_bot_token_var>}"
TOKEN_VAR="${2:?Usage: $0 <name> <discord_bot_token_var>}"
# ── Paths ───────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
COMPOSE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
COMPOSE_FILE="${COMPOSE_DIR}/compose.yml"
# Each Hermes worker gets its own volume on the NFS HoardingCow
VOLUME_BASE="/mnt/HoardingCow_docker_data/Hermes"
VOLUME_DIR="${VOLUME_BASE}/${NAME}"
# The Hermes container runs as UID 10000 (hermes user from Dockerfile)
HERMES_UID=10000
# ── Validation ──────────────────────────────────────────────
if ! [ -f "$COMPOSE_FILE" ]; then
echo "❌ compose.yml not found at $COMPOSE_FILE"
exit 1
fi
if grep -q "^ ${NAME}:" "$COMPOSE_FILE"; then
echo "❌ Service '${NAME}' already exists in ${COMPOSE_FILE}"
exit 1
fi
# ── Generate unique API key ─────────────────────────────────
# Used by Paperclip to authenticate against this worker's
# Hermes API server (/v1/chat/completions)
API_KEY="pc_worker_$(openssl rand -hex 16)"
# ── Find next available API port ────────────────────────────
# Workers get sequential ports starting at 8650.
# Scans compose.yml for existing API_SERVER_PORT values and
# picks the next one.
BASE_PORT=8650
MAX_PORT=0
while IFS= read -r line; do
port="${line#*API_SERVER_PORT: \"}"
port="${port%%\"*}"
if [ -n "$port" ] && [ "$port" -gt "$MAX_PORT" ]; then
MAX_PORT="$port"
fi
done < <(grep -oP 'API_SERVER_PORT:\s*"\d+"' "$COMPOSE_FILE" 2>/dev/null)
NEW_PORT=$((MAX_PORT + 1))
if [ "$NEW_PORT" -lt "$BASE_PORT" ]; then
NEW_PORT=$BASE_PORT
fi
# ── Create volume directory (on NFS) ────────────────────────
echo "📁 Creating volume directory: ${VOLUME_DIR}"
mkdir -p "$VOLUME_DIR"
# Hermes container runs as UID 10000 — set ownership so the
# container can write its config, sessions, skills
if command -v chown &>/dev/null; then
chown -R "${HERMES_UID}:${HERMES_UID}" "$VOLUME_DIR" 2>/dev/null || \
echo "⚠ Could not chown ${VOLUME_DIR} — run with sudo if needed"
fi
# Make it group-readable for debugging
chmod 755 "$VOLUME_DIR" 2>/dev/null || true
# ── Append service to compose.yml ───────────────────────────
echo "📝 Appending service '${NAME}' to compose.yml ..."
TMPFILE=$(mktemp)
awk -v name="$NAME" \
-v port="$NEW_PORT" \
-v api_key="$API_KEY" \
-v token_var="$TOKEN_VAR" \
'
# Insert new worker service block just before the networks: section
/^networks:/ {
print ""
print " " name ":"
print " <<: *hermes-worker"
print " container_name: " name
print " environment:"
print " API_SERVER_PORT: \"" port "\""
print " API_SERVER_KEY: \"" api_key "\""
print " DISCORD_BOT_TOKEN: ${" token_var "}"
print " volumes:"
print " - /mnt/HoardingCow_docker_data/Hermes/" name ":/opt/data"
print ""
}
{ print }
' "$COMPOSE_FILE" > "$TMPFILE" && mv "$TMPFILE" "$COMPOSE_FILE"
# ── Done ────────────────────────────────────────────────────
echo ""
echo "✅ Worker '${NAME}' provisioned successfully"
echo ""
echo "────────────────────────────────────────────"
echo " NEXT STEPS"
echo "────────────────────────────────────────────"
echo ""
echo "1. Add secrets to the agenix .env stack file:"
echo ""
echo " # ${NAME}"
echo " ${TOKEN_VAR}=<paste-discord-bot-token-here>"
echo ""
echo "2. Restart the AI stack:"
echo ""
echo " systemctl restart ai_stack.service"
echo ""
echo "3. In Paperclip, create an agent with HTTP adapter:"
echo ""
echo " Endpoint: http://${NAME}:${NEW_PORT}/v1/chat/completions"
echo " API Key: ${API_KEY}"
echo ""
echo "────────────────────────────────────────────"

View File

@@ -8,13 +8,8 @@ services:
- USER_GID=1000 - USER_GID=1000
- GITEA__server__ROOT_URL=https://code.lazyworkhorse.net - GITEA__server__ROOT_URL=https://code.lazyworkhorse.net
- GITEA__actions__ENABLED=true - GITEA__actions__ENABLED=true
- GITEA__actions__DEFAULT_ACTIONS_URL=off
- SSH_PORT=2222 - SSH_PORT=2222
- SSH_LISTEN_PORT=2222 - SSH_LISTEN_PORT=2222
# Enable Gitea Actions (act_runner required on host)
- GITEA__actions__ENABLED=true
# Don't fetch actions from GitHub (offline mode + local only)
- GITEA__actions__DEFAULT_ACTIONS_URL=off
volumes: volumes:
- /mnt/HoardingCow_docker_data/Gitea:/data - /mnt/HoardingCow_docker_data/Gitea:/data
networks: networks:
@@ -50,7 +45,7 @@ services:
image: gitea/act_runner:latest image: gitea/act_runner:latest
container_name: act_runner container_name: act_runner
environment: environment:
- GITEA_INSTANCE_URL=https://code.lazyworkhorse.net - GITEA_INSTANCE_URL=http://gitea:3000
- GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_TOKEN} - GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_TOKEN}
- GITEA_RUNNER_NAME=ai-host-runner - GITEA_RUNNER_NAME=ai-host-runner
- GITEA_RUNNER_LABELS=ubuntu-latest:docker://catthehacker/ubuntu:full-22.04,nixos-builder:docker://nixos/nix - GITEA_RUNNER_LABELS=ubuntu-latest:docker://catthehacker/ubuntu:full-22.04,nixos-builder:docker://nixos/nix

View File

@@ -1,9 +0,0 @@
# Custom wg-easy with iptables-nft (nftables-backed iptables)
# Fixes crash-loop when host kernel lacks legacy iptable_nat module.
FROM ghcr.io/wg-easy/wg-easy:latest
# The upstream image registers only iptables-legacy with update-alternatives.
# iptables-nft binary exists but isn't registered as an alternative key.
# Override the alternatives-managed symlinks directly.
RUN ln -sf /usr/sbin/iptables-nft /usr/sbin/iptables && \
ln -sf /usr/sbin/ip6tables-nft /usr/sbin/ip6tables

View File

@@ -2,10 +2,7 @@ version: "3.8"
services: services:
wireguard: wireguard:
build: image: weejewel/wg-easy:latest
context: .
dockerfile: Dockerfile
image: wg-easy-iptables-nft:latest
container_name: wireguard container_name: wireguard
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN