Compare commits

..
13 changed files with 248 additions and 292 deletions
+7 -30
View File
@@ -5,7 +5,6 @@
# --- domain / TLS ---
MATRIX_SERVER_NAME=matrix.apps.williamturner.eu
AGENT_HOSTNAME=agent.apps.williamturner.eu
HERMES_DASHBOARD_HOSTNAME=hermes.apps.williamturner.eu
# Set to true ONLY for the first-boot window while creating the bot account,
# then back to false (or unset) and redeploy. See README.
MATRIX_ALLOW_REGISTRATION=false
@@ -21,37 +20,15 @@ GITEA_REGISTRY_IMAGE=gitea.apps.williamturner.eu/<your-gitea-username>/<repo-nam
# Run `claude setup-token` interactively (needs a browser + Claude Pro/Max subscription)
# to generate this — it's a long-lived OAuth token, not an API key.
CLAUDE_CODE_OAUTH_TOKEN=
# Any random string — shared secret for claude-agent's /mcp bridge endpoint (see
# agent/src/mcpBridge.js), which lets Hermes delegate a question to the real `claude`
# CLI (billed against the subscription above) via MCP. Register it in Hermes with:
# docker exec hermes hermes config set mcp_servers.claude-code.url http://claude-agent:3001/mcp
# docker exec hermes hermes config set 'mcp_servers.claude-code.headers.Authorization' 'Bearer <this value>'
MCP_BRIDGE_KEY=
# --- litellm (local LLM gateway — used by Hermes, see litellm-config.yaml) ---
# --- matrix bot ---
MATRIX_HOMESERVER_URL=https://matrix.apps.williamturner.eu
MATRIX_BOT_TOKEN=
MATRIX_CONTROL_ROOM_ID=
# --- openrouter (the "!ai" chat command — other models, not the coding agent) ---
OPENROUTER_API_KEY=
# Any random string; also used as litellm's general_settings.master_key.
LITELLM_MASTER_KEY=
# --- hermes (the only agent with a Matrix presence — see README) ---
# Your own Matrix ID — Hermes only responds to this user, and only when @mentioned
# in a room (free-response in DMs).
MATRIX_HUMAN_USER_ID=@william:matrix.apps.williamturner.eu
# Access token for the @hermes bot account — register it on the homeserver, then log
# in as it via /_matrix/client/v3/login to get this token (see README).
HERMES_MATRIX_ACCESS_TOKEN=
# Any random string — bearer key for Hermes's own OpenAI-compatible API server
# (internal network only, not published anywhere).
HERMES_API_SERVER_KEY=
# --- hermes web dashboard (hermes.apps.williamturner.eu) ---
# Hermes's own login gate — mandatory once its dashboard is bound non-loopback (needed
# for Traefik, a separate container, to reach it at all), so this can't be turned off
# while the dashboard is reachable through Traefik.
HERMES_DASHBOARD_USERNAME=william
HERMES_DASHBOARD_PASSWORD=
# 32+ random bytes — `openssl rand -base64 32`
HERMES_DASHBOARD_SECRET=
OPENROUTER_DEFAULT_MODEL=openai/gpt-4o-mini
# --- portainer (GitOps redeploy) ---
PORTAINER_STACK_WEBHOOK_URL=
-7
View File
@@ -31,10 +31,3 @@ jobs:
IMAGE="${{ vars.REGISTRY_HOST }}/${{ gitea.repository }}/claude-agent:latest"
docker build -t "$IMAGE" ./agent
docker push "$IMAGE"
- name: Trigger Portainer redeploy
# Deliberately NOT a separate Gitea repo webhook firing in parallel on the same
# push — that raced with this build and could redeploy before the new image was
# actually pushed, silently keeping the old code running. Chaining it here as the
# last step guarantees the image exists before Portainer goes to pull it.
run: curl -f -X POST "${{ secrets.PORTAINER_WEBHOOK_URL }}"
+8
View File
@@ -0,0 +1,8 @@
# Contributing
- Changes land via pull request — no direct pushes to `main`. Branch protection enforces
this (see README.md, "Branch protection on `main`").
- This applies to the Claude bot too: it only ever opens PRs, never pushes or merges
directly. `claude-bot`'s account is deliberately excluded from the merge whitelist, so a
human always reviews and merges.
- Open a PR against `main` and wait for review before merging.
+21 -25
View File
@@ -1,25 +1,21 @@
# gitops-automation
Claude Code + Hermes automation wired into Gitea + Portainer + Matrix on this VPS. See
`~/.claude/plans/cozy-honking-lantern.md` on the host for the original design rationale
(some of it — the Matrix chat bot on claude-agent — has since been superseded, see below).
Claude Code automation wired into Gitea + Portainer + Matrix on this VPS. See
`~/.claude/plans/cozy-honking-lantern.md` on the host for the full design rationale.
What this gives you today:
Three things this gives you:
- **PR review**: opening/updating a PR in a watched Gitea repo gets a Claude-authored
review comment (`claude-agent`, triggered by a Gitea webhook — nothing to do with Matrix).
review comment.
- **GitOps redeploy**: pushing to `main` on this repo rebuilds the `claude-agent` image
(Gitea Actions) and redeploys the stack, chained as the last step of that same workflow.
- **Matrix**: **Hermes is the only agent present in Matrix.** `claude-agent` used to also
run a Matrix bot (`!claude`/`!ai` commands, then no-prefix auto-routing) — that's been
removed entirely (by request: one agent in Matrix, not several). Hermes has its own
native Matrix connection, responds to `@hermes <message>` in shared rooms (no mention
needed in DMs), and has its own tools (terminal, code execution, web search, etc.) — see
its docs at https://hermes-agent.nousresearch.com for what it can do. It does not (yet)
have the old branch/PR-opening workflow the Matrix bot used to have; that logic still
exists in git history if it's worth reviving as a Hermes tool/skill later.
(Gitea Actions) and redeploys the stack (Portainer webhook).
- **Chat-driven coding agent**: `!claude owner/repo <instruction>` in the Matrix control
room clones the repo, runs Claude Code, and opens a PR with the result.
- **Ask other models**: `!ai <prompt>` (default model) or `!ai provider/model <prompt>`
(e.g. `!ai google/gemini-2.0-flash-001 explain this error`) queries any model on
OpenRouter and replies in the room. No repo/file access — just a chat reply, unlike
`!claude` which is the only command that can edit files and open PRs.
Nothing here auto-merges PRs. Every path stops at a comment or an open PR — a human clicks
merge (enforced by branch protection on `main`, not just by convention — see below).
Nothing here auto-merges. Every path stops at a comment or an open PR — a human clicks merge.
## Prerequisites (one-time, on the VPS)
@@ -129,17 +125,17 @@ docker network create web
## Smoke test
- Open a throwaway PR on a repo with the PR webhook set → expect a Claude review comment.
- In the Matrix control room: `@hermes hello` → expect a reply from Hermes.
- `git push` to `main` on this repo (touching `agent/**`) → expect a Gitea Actions run,
then a chained Portainer redeploy at the end of that same workflow.
- In the Matrix control room: `!claude owner/repo add a comment to the README` → expect a
"working on it" reply, then a PR link.
- `git push` to `main` on this repo → expect a Gitea Actions run, then a Portainer redeploy.
## Notes
- `agent/src/runner.js` only ever runs read-only `git clone`/`fetch`/`checkout` for the PR
diff it reviews — Claude Code itself is denied `Edit`/`Write`/commit/push tools
(`--disallowedTools`), so this path can never modify a repo, only comment on it.
- `agent/src/runner.js` is the only thing that ever runs `git commit`/`git push`/`git
checkout` — Claude Code itself is explicitly denied those tools (`--disallowedTools`),
so even a misbehaving prompt can't push directly or touch `main`.
- The Matrix bot only reacts inside `MATRIX_CONTROL_ROOM_ID`; keep that room invite-only.
- `.gitea/workflows/build.yml` assumes the act_runner label `docker` — check
`GITEA_RUNNER_LABELS` in `docker-compose.yml` matches what you actually registered.
- Hermes's own config/memory/skills live in `/home/william/hermes-data` on the host
(bind-mounted, not in this repo) — back that up separately if it accumulates anything
worth keeping.
<!-- gitops loop smoke test 2026-08-23T11:23:34Z -->
+1 -2
View File
@@ -9,7 +9,6 @@
},
"dependencies": {
"express": "^4.19.2",
"@modelcontextprotocol/sdk": "^1.30.0",
"zod": "^3.23.8"
"matrix-bot-sdk": "^0.7.1"
}
}
+21
View File
@@ -24,6 +24,27 @@ export async function postPRComment(owner, repo, index, body) {
await assertOk(res, "post PR comment");
}
export async function createBranch(owner, repo, newBranch, oldBranch = "main") {
const url = `${GITEA_URL}/api/v1/repos/${owner}/${repo}/branches`;
const res = await fetch(url, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ new_branch_name: newBranch, old_branch_name: oldBranch }),
});
await assertOk(res, "create branch");
}
export async function createPullRequest(owner, repo, { head, base = "main", title, body }) {
const url = `${GITEA_URL}/api/v1/repos/${owner}/${repo}/pulls`;
const res = await fetch(url, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ head, base, title, body }),
});
await assertOk(res, "create PR");
return res.json();
}
// Injects the agent's token into a Gitea clone URL so git operations don't need SSH keys.
export function authenticatedCloneUrl(cloneUrl) {
const u = new URL(cloneUrl);
+101
View File
@@ -0,0 +1,101 @@
import { MatrixClient, SimpleFsStorageProvider } from "matrix-bot-sdk";
import { runChatTask } from "./runner.js";
import { askOpenRouter, DEFAULT_MODEL } from "./openrouter.js";
const HOMESERVER_URL = process.env.MATRIX_HOMESERVER_URL;
const ACCESS_TOKEN = process.env.MATRIX_BOT_TOKEN;
const CONTROL_ROOM_ID = process.env.MATRIX_CONTROL_ROOM_ID;
const GITEA_URL = process.env.GITEA_URL;
const MAX_REPLY_LENGTH = 4000;
// "!claude owner/repo do the thing" — everything after the repo slug is the instruction.
function parseClaudeCommand(text) {
const match = text.match(/^!claude\s+([^\s/]+\/[^\s/]+)\s+(.+)$/s);
if (!match) return null;
const [, repoFullName, instruction] = match;
return { repoFullName, instruction: instruction.trim() };
}
// "!ai <prompt>" uses the default model. "!ai provider/model <prompt>" (first token
// contains a "/") picks a specific OpenRouter model, e.g. "!ai google/gemini-2.0-flash-001
// summarize this repo's README".
function parseAiCommand(text) {
const match = text.match(/^!ai\s+(.+)$/s);
if (!match) return null;
const rest = match[1].trim();
const firstSpace = rest.search(/\s/);
const firstToken = firstSpace === -1 ? rest : rest.slice(0, firstSpace);
if (firstToken.includes("/") && firstSpace !== -1) {
return { model: firstToken, prompt: rest.slice(firstSpace + 1).trim() };
}
return { model: DEFAULT_MODEL, prompt: rest };
}
function truncate(text) {
if (text.length <= MAX_REPLY_LENGTH) return text;
return `${text.slice(0, MAX_REPLY_LENGTH)}\n\n[truncated]`;
}
export async function startMatrixBot() {
if (!HOMESERVER_URL || !ACCESS_TOKEN || !CONTROL_ROOM_ID) {
console.warn("Matrix env vars not set — skipping bot startup");
return;
}
const storage = new SimpleFsStorageProvider("/workspace/matrix-bot-storage.json");
const client = new MatrixClient(HOMESERVER_URL, ACCESS_TOKEN, storage);
// Not using AutojoinRoomsMixin: it calls /joined_rooms at startup to build its initial
// state, which — like the /whoami call removed earlier — 404s against Continuwuity for
// reasons unrelated to the endpoint itself (curling it directly works fine). This
// simpler handler does the one thing we actually need — auto-join on invite — without
// that startup scan.
client.on("room.invite", async (roomId) => {
try {
await client.joinRoom(roomId);
} catch (err) {
console.error("failed to join invited room", roomId, err.message);
}
});
client.on("room.message", async (roomId, event) => {
// Invite-only control room enforces who can reach the bot at all; this just scopes
// command handling to that one room. No need to fetch/compare the bot's own user ID
// to filter out its own messages — its replies never match either command pattern
// below, so they're ignored the same as any other non-command message.
if (roomId !== CONTROL_ROOM_ID) return;
const body = event.content?.body;
if (!body) return;
const aiCmd = parseAiCommand(body);
if (aiCmd) {
try {
const reply = await askOpenRouter(aiCmd.model, aiCmd.prompt);
await client.sendText(roomId, `[${aiCmd.model}] ${truncate(reply)}`);
} catch (err) {
console.error("openrouter query failed", err);
await client.sendText(roomId, `Failed: ${err.message}`);
}
return;
}
const cmd = parseClaudeCommand(body);
if (!cmd) return;
const [owner, repo] = cmd.repoFullName.split("/");
await client.sendText(roomId, `Working on it: ${cmd.repoFullName}${cmd.instruction}`);
try {
const cloneUrl = `${GITEA_URL}/${owner}/${repo}.git`;
const pr = await runChatTask({ owner, repo, cloneUrl, instruction: cmd.instruction });
await client.sendText(roomId, `Opened PR: ${pr.html_url}`);
} catch (err) {
console.error("chat task failed", err);
await client.sendText(roomId, `Failed: ${err.message}`);
}
});
await client.start();
console.log("Matrix bot started, room:", CONTROL_ROOM_ID);
}
-71
View File
@@ -1,71 +0,0 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mkdtemp, rm, mkdir } from "node:fs/promises";
import path from "node:path";
const execFileAsync = promisify(execFile);
const WORKSPACE_ROOT = "/workspace";
// Runs the real Claude Code CLI — billed against the Claude Pro/Max subscription
// (CLAUDE_CODE_OAUTH_TOKEN), not per-token API billing. This only works because it's
// the actual `claude` binary making the request: Anthropic rejects the same OAuth token
// used by any other HTTP client (proven earlier — direct curl replicating the same
// request shape gets rejected). Read-only: no git/file-write tools, since this is a
// quick-answer bridge, not a repo-editing agent (claude-agent's own webhook flow already
// owns that for PRs).
async function askClaudeSubscription(prompt) {
await mkdir(WORKSPACE_ROOT, { recursive: true });
const dir = await mkdtemp(path.join(WORKSPACE_ROOT, "mcp-"));
try {
const { stdout } = await execFileAsync(
"claude",
[
"-p", prompt,
"--output-format", "text",
"--permission-mode", "bypassPermissions",
"--disallowedTools", "Bash(git push:*),Bash(git commit:*),Edit,Write,NotebookEdit",
],
{ cwd: dir, maxBuffer: 1024 * 1024 * 32 }
);
return stdout;
} finally {
await rm(dir, { recursive: true, force: true });
}
}
// A fresh McpServer per request (stateless transport) — cheap, and avoids any
// cross-request state for what's a single-tool, single-shot bridge.
export function createMcpServer() {
const server = new McpServer({ name: "claude-code-bridge", version: "1.0.0" });
server.registerTool(
"ask_claude_code",
{
description:
"Ask the real Claude Code CLI a question or reasoning task, billed against the " +
"Claude Pro/Max subscription rather than per-token API credits. Use this when " +
"you specifically want Claude's own model rather than whatever the default " +
"routed model provides. Read-only — cannot edit files, push, or commit.",
inputSchema: { prompt: z.string().describe("The question or task to ask Claude") },
},
async ({ prompt }) => {
try {
const text = await askClaudeSubscription(prompt);
return { content: [{ type: "text", text }] };
} catch (err) {
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
}
}
);
return server;
}
export function mcpAuthMiddleware(req, res, next) {
const key = process.env.MCP_BRIDGE_KEY;
if (!key) return res.status(500).send("MCP_BRIDGE_KEY not configured");
if (req.get("Authorization") !== `Bearer ${key}`) return res.status(401).send("unauthorized");
next();
}
+37
View File
@@ -0,0 +1,37 @@
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
const DEFAULT_MODEL = process.env.OPENROUTER_DEFAULT_MODEL || "openai/gpt-4o-mini";
export async function askOpenRouter(model, prompt) {
if (!OPENROUTER_API_KEY) {
throw new Error("OPENROUTER_API_KEY is not set");
}
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: model || DEFAULT_MODEL,
messages: [{ role: "user", content: prompt }],
// Some models default max_tokens to their full context window (e.g. 65536),
// which can exceed available credit balance before a single token is generated.
// This is a quick chat reply, not a long-form task — cap it.
max_tokens: 1024,
}),
});
if (!res.ok) {
throw new Error(`OpenRouter request failed: ${res.status} ${await res.text()}`);
}
const data = await res.json();
const content = data.choices?.[0]?.message?.content;
if (!content) {
throw new Error(`OpenRouter returned no content: ${JSON.stringify(data)}`);
}
return content;
}
export { DEFAULT_MODEL };
+42 -8
View File
@@ -2,7 +2,8 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mkdtemp, rm, mkdir } from "node:fs/promises";
import path from "node:path";
import { authenticatedCloneUrl } from "./gitea.js";
import crypto from "node:crypto";
import { authenticatedCloneUrl, createBranch, createPullRequest } from "./gitea.js";
const execFileAsync = promisify(execFile);
const WORKSPACE_ROOT = "/workspace";
@@ -28,12 +29,10 @@ async function withWorkspace(fn) {
// Runs Claude Code headless. Unattended containers have no TTY to answer permission
// prompts, so this trusts the sandboxing of the throwaway clone dir instead:
// bypassPermissions to avoid hanging, plus --disallowedTools as defense in depth so
// Claude can never push/commit/checkout, or edit files — this is read-only review.
async function runClaude(cwd, prompt) {
const disallowed = [
"Bash(git push:*)", "Bash(git commit:*)", "Bash(git checkout:*)",
"Edit", "Write", "NotebookEdit",
];
// Claude can never push/commit/checkout itself — this script owns those steps.
async function runClaude(cwd, prompt, { allowEdits }) {
const disallowed = ["Bash(git push:*)", "Bash(git commit:*)", "Bash(git checkout:*)"];
if (!allowEdits) disallowed.push("Edit", "Write", "NotebookEdit");
const args = [
"-p", prompt,
@@ -60,6 +59,41 @@ export async function reviewPullRequest({ owner, repo, ref, cloneUrl, prTitle, p
`PR description:\n${prBody}`,
].join("\n");
return runClaude(dir, prompt);
return runClaude(dir, prompt, { allowEdits: false });
});
}
export async function runChatTask({ owner, repo, cloneUrl, instruction }) {
return withWorkspace(async (dir) => {
const authedUrl = authenticatedCloneUrl(cloneUrl);
await run("git", ["clone", "--quiet", authedUrl, dir]);
const branch = `claude/${crypto.randomBytes(4).toString("hex")}`;
await createBranch(owner, repo, branch);
await run("git", ["fetch", "--quiet", "origin", branch], { cwd: dir });
await run("git", ["checkout", "--quiet", branch], { cwd: dir });
const prompt = [
"Implement the change described below in this repository. Make the smallest",
"correct change that satisfies it. Do not run git commit, git push, or git",
"checkout yourself — just edit files; committing and pushing happens separately.",
`Instruction: ${instruction}`,
].join("\n");
await runClaude(dir, prompt, { allowEdits: true });
await run("git", ["add", "-A"], { cwd: dir });
const status = await run("git", ["status", "--porcelain"], { cwd: dir });
if (!status.trim()) {
throw new Error("Claude made no changes for this instruction");
}
await run("git", ["commit", "-m", `claude: ${instruction}`.slice(0, 200)], { cwd: dir });
await run("git", ["push", "--quiet", "origin", branch], { cwd: dir });
return createPullRequest(owner, repo, {
head: branch,
title: `claude: ${instruction}`.slice(0, 200),
body: `Requested via Matrix:\n\n> ${instruction}`,
});
});
}
+5 -29
View File
@@ -1,9 +1,8 @@
import express from "express";
import crypto from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { postPRComment } from "./gitea.js";
import { reviewPullRequest } from "./runner.js";
import { createMcpServer, mcpAuthMiddleware } from "./mcpBridge.js";
import { startMatrixBot } from "./matrixBot.js";
const app = express();
app.use(
@@ -58,33 +57,10 @@ app.post("/webhooks/gitea", async (req, res) => {
}
});
// MCP bridge — lets Hermes (or anything else speaking MCP) delegate a question to the
// real Claude Code CLI, billed against the subscription. Stateless: a fresh server +
// transport per request, no session tracking needed for a single-tool bridge like this.
app.post("/mcp", mcpAuthMiddleware, async (req, res) => {
const mcpServer = createMcpServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => {
transport.close();
mcpServer.close();
});
try {
await mcpServer.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (err) {
console.error("MCP request handling failed:", err);
if (!res.headersSent) res.status(500).send("internal error");
}
});
app.get("/mcp", mcpAuthMiddleware, (_req, res) => {
res.status(405).set("Allow", "POST").send("Method Not Allowed");
});
app.delete("/mcp", mcpAuthMiddleware, (_req, res) => {
res.status(405).set("Allow", "POST").send("Method Not Allowed");
});
app.listen(PORT, () => {
console.log(`claude-agent listening on :${PORT}`);
});
startMatrixBot().catch((err) => {
console.error("matrix bot failed to start:", err);
});
+5 -90
View File
@@ -36,94 +36,7 @@ services:
- "traefik.http.routers.matrix.tls.certresolver=letsencrypt"
- "traefik.http.services.matrix.loadbalancer.server.port=8008"
litellm:
# Pinned deliberately, not :latest or :main-latest — litellm==1.82.7/1.82.8 on PyPI
# were compromised with credential-stealing malware in March 2026 (fixed within the
# hour, but a floating tag could still land on a bad release in the future). v1.98.0
# verified clean as of this writing.
image: ghcr.io/berriai/litellm:v1.98.0
container_name: litellm
restart: unless-stopped
environment:
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
volumes:
# Absolute host path, NOT a repo-relative one — Portainer's git-stack deploy clones
# into its own directory (/data/compose/N/) whose checkout doesn't reliably persist
# for the container's runtime (see the act_runner config comment below for the same
# failure mode). An absolute path on the actual host filesystem always resolves the
# same way regardless of which tool ran `docker compose up`. Keep this local clone
# (/home/william/gitops-automation) pulled to latest when the config changes.
- /home/william/gitops-automation/litellm-config.yaml:/app/config.yaml:ro
command: ["--config", "/app/config.yaml", "--port", "4000"]
networks:
- web
# Internal only — no Traefik labels. No reason to expose an LLM gateway holding a
# master key and OAuth-forwarding config to the public internet.
hermes:
# Pinned to a specific dated release, not :latest — same rationale as litellm above.
image: nousresearch/hermes-agent:v2026.8.19
container_name: hermes
restart: unless-stopped
environment:
HERMES_UID: "1000"
HERMES_GID: "1000"
# Internal container address, not the public HTTPS one — same docker network as
# matrix-homeserver, no reason to round-trip through Traefik/TLS for this.
MATRIX_HOMESERVER: http://matrix-homeserver:8008
MATRIX_ACCESS_TOKEN: ${HERMES_MATRIX_ACCESS_TOKEN}
# Only you can trigger it; and only with an explicit @hermes mention in shared
# rooms (DMs to it would respond unprompted, per Hermes's own default behavior).
MATRIX_ALLOWED_USERS: ${MATRIX_HUMAN_USER_ID}
MATRIX_REQUIRE_MENTION: "true"
# Routed through the local litellm gateway, not OpenRouter directly — one place to
# hold the OpenRouter credential and swap models. Does NOT grant Hermes access to
# the Claude subscription (Anthropic-side restriction, proven earlier — the
# subscription only works through the real `claude` CLI binary, which Hermes isn't).
OPENAI_BASE_URL: http://litellm:4000/v1
OPENAI_API_KEY: ${LITELLM_MASTER_KEY}
# Left disabled: Hermes itself warns that a network-reachable API server combined
# with the default unsandboxed ('local') terminal backend gives any caller full
# terminal/file access within the container. Matrix is the actual interface in use;
# re-enable (API_SERVER_HOST: 0.0.0.0) only alongside terminal.backend: docker if
# claude-agent ever needs to call Hermes programmatically.
API_SERVER_ENABLED: "false"
# Web dashboard, supervised in-container alongside the gateway (same process group,
# same s6 tree) — see docs/user-guide/docker.md "Running the dashboard". Binds
# 0.0.0.0 so Traefik (a separate container) can reach it; that makes Hermes's own
# auth gate mandatory, which it enforces automatically once the bind isn't loopback.
HERMES_DASHBOARD: "1"
HERMES_DASHBOARD_HOST: 0.0.0.0
HERMES_DASHBOARD_PORT: "9119"
HERMES_DASHBOARD_BASIC_AUTH_USERNAME: ${HERMES_DASHBOARD_USERNAME}
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD: ${HERMES_DASHBOARD_PASSWORD}
HERMES_DASHBOARD_BASIC_AUTH_SECRET: ${HERMES_DASHBOARD_SECRET}
volumes:
- /home/william/hermes-data:/opt/data
networks:
- web
# Without this the image's default command launches the interactive CLI, which
# immediately exits ("Input is not a terminal") since a detached container has no
# stdin — the container then just sits there having done nothing, every restart.
command: ["gateway", "run"]
labels:
- "traefik.enable=true"
- "traefik.http.routers.hermes-dashboard.rule=Host(`${HERMES_DASHBOARD_HOSTNAME}`)"
- "traefik.http.routers.hermes-dashboard.entrypoints=websecure"
- "traefik.http.routers.hermes-dashboard.tls.certresolver=letsencrypt"
# Just TLS termination + routing — no Traefik-level auth middleware. Hermes's own
# login gate is not optional here anyway: it fails closed at startup once its bind
# isn't loopback-only (required for Traefik, a separate container, to reach it at
# all), so a second gate in front of it would only add friction, not remove Hermes's
# own one. One password, at Hermes's own login page.
- "traefik.http.services.hermes-dashboard.loadbalancer.server.port=9119"
claude-agent:
# No Matrix presence (see hermes above; only one agent is meant to be in Matrix).
# Two things call this now: Gitea's pull_request webhook (PR review), and Hermes,
# over MCP (POST /mcp), to delegate a question to the real `claude` CLI when it
# specifically wants the Claude subscription instead of whatever LiteLLM routed it to.
image: ${GITEA_REGISTRY_IMAGE}
container_name: claude-agent
restart: unless-stopped
@@ -137,9 +50,11 @@ services:
# Claude subscription (Pro/Max) auth via `claude setup-token`, not API billing —
# Claude Code reads this in preference to ANTHROPIC_API_KEY when both could apply.
CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN}
# Shared secret for the /mcp bridge endpoint (internal network only either way, but
# this keeps it from being callable by anything that merely reaches the container).
MCP_BRIDGE_KEY: ${MCP_BRIDGE_KEY}
MATRIX_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL}
MATRIX_BOT_TOKEN: ${MATRIX_BOT_TOKEN}
MATRIX_CONTROL_ROOM_ID: ${MATRIX_CONTROL_ROOM_ID}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
OPENROUTER_DEFAULT_MODEL: ${OPENROUTER_DEFAULT_MODEL:-openai/gpt-4o-mini}
volumes:
- agent_workspace:/workspace
networks:
-30
View File
@@ -1,30 +0,0 @@
model_list:
# General chat — OpenRouter's own auto-router picks the best underlying model per prompt.
- model_name: auto
litellm_params:
model: openrouter/openrouter/auto
api_key: os.environ/OPENROUTER_API_KEY
# Cheap/fast model used by the agent's own chat-vs-code-task classifier, not by users directly.
- model_name: router-classifier
litellm_params:
model: openrouter/openai/gpt-4o-mini
api_key: os.environ/OPENROUTER_API_KEY
# NOT included: an "anthropic-claude" model routing to Anthropic via the caller's
# forwarded OAuth header (general_settings.forward_client_headers_to_llm_api). It
# genuinely works — but ONLY when the real `claude` CLI binary is the caller (its
# request carries a header/fingerprint only that binary sends; a hand-built request,
# including Hermes selecting this model directly, gets a hard auth error from
# Anthropic). Having it selectable here caused exactly that confusion once already.
# The actual working path for "Hermes uses the Claude subscription" is the MCP bridge
# at claude-agent's /mcp (agent/src/mcpBridge.js) — it shells out to the real `claude`
# binary server-side instead of trying to make an arbitrary caller impersonate it.
litellm_settings:
# Callers (Hermes included) send provider-specific params like reasoning_effort that
# not every routed model/provider accepts — drop unsupported ones instead of erroring.
drop_params: true
general_settings:
master_key: os.environ/LITELLM_MASTER_KEY