Compare commits

...
Author SHA1 Message Date
william 0164ec4c20 Merge pull request 'MCP bridge: let Hermes delegate to the real Claude subscription (v2)' (#18) from feat/mcp-claude-bridge into main
build-agent / build-and-push (push) Successful in 10s
Reviewed-on: #18
2026-08-23 17:42:38 +00:00
william 46192837e6 Re-add MCP bridge so Hermes can delegate to the real Claude Code CLI
Recreates the PR #17 change (closed without merging, then asked for back
after confirming a real ANTHROPIC_API_KEY would mean separate/duplicate
billing rather than actually using the Pro/Max subscription).

New POST /mcp endpoint (Streamable HTTP transport, stateless — fresh
McpServer+transport per request) exposing one tool, ask_claude_code: runs
the real `claude` binary against a prompt, billed against the subscription
rather than API credits. Works specifically because it's the actual CLI
making the request server-side. Read-only — no Edit/Write/git-push/
git-commit tools.

Also removes litellm-config.yaml's "anthropic-claude" model entry: it only
ever worked when the real claude CLI itself was the caller (proven earlier),
so having it listed as a selectable model was actively misleading — Hermes
picking it directly is exactly what produced the '401: Missing Anthropic
API Key' confusion that led back to this bridge. The MCP tool is the actual
working path now; general_settings.forward_client_headers_to_llm_api is
also removed since nothing uses it anymore.

Tested end-to-end locally again before pushing (built the image, ran it,
full MCP handshake via curl) — real 'pong' from the real claude CLI.
2026-08-23 17:41:49 +00:00
william ec37245b37 Merge pull request 'Add Hermes web dashboard at hermes.apps.williamturner.eu (dual auth)' (#15) from feat/hermes-dashboard into main
Reviewed-on: #15
2026-08-23 17:16:05 +00:00
william 3dc6397862 Merge pull request 'LiteLLM: drop unsupported params (fixes Hermes routing)' (#16) from fix/litellm-drop-params into main
Reviewed-on: #16
2026-08-23 17:15:38 +00:00
william 02a94d1d03 LiteLLM: drop unsupported params instead of erroring (fixes Hermes routing)
Hermes sends provider-agnostic params like reasoning_effort that OpenRouter's
auto model doesn't accept, causing a 400 on every request until this was
set. Also documents (in a follow-up, not this commit) that getting Hermes to
actually use this gateway needed live 'hermes config set' calls on the
running container (providers.litellm.{api,api_key}, model.provider=litellm,
model.default=auto) — that state lives in the hermes-data volume, not git,
so it isn't reproduced automatically by a fresh deploy. See README.
2026-08-23 17:09:39 +00:00
william 143be8200e Drop the Traefik-level basic auth layer — keep only Hermes's own login
By request: one auth layer, not two. Note this isn't really removing a
layer I added on top of nothing — Hermes's own gate is mandatory and
can't be disabled while the dashboard is reachable through a separate
Traefik container (it fails closed at startup on any non-loopback bind
without a configured auth provider). The only thing actually optional
was the Traefik-level middleware, so that's what comes out; Traefik now
just does TLS termination + routing.
2026-08-23 17:02:59 +00:00
william 9739676409 Add Hermes web dashboard at hermes.apps.williamturner.eu
Enabled via HERMES_DASHBOARD=1 (supervised in-container alongside the
gateway, per docs/user-guide/docker.md), bound to 0.0.0.0:9119 so Traefik
can reach it. Two independent auth layers, not one:

1. Traefik basicauth middleware in front of the whole route.
2. Hermes's own basic-auth gate (mandatory once the bind is non-loopback).

Hermes's docs explicitly call basic-auth-alone "not suitable for direct
public-internet exposure" and cite a real June 2026 incident where
internet scanners reached exposed dashboards and drove agents into
planting SSH-key backdoors — hence the extra Traefik-level gate rather
than relying on Hermes's own login page alone.

Also fixes: the htpasswd hash for Traefik's basicauth needs its literal
'$' characters escaped as '2824147' in .env, or docker compose's own variable
interpolation corrupts it (mistook '' for further
 references). Also switched the hash from Python's default SHA-512
crypt ('$...') to apr1 ('$...', via openssl passwd -apr1) —
Traefik's basicauth middleware doesn't accept SHA-512-crypt.

Also re-adds Hermes's OPENAI_BASE_URL/OPENAI_API_KEY routing through the
local litellm gateway (instead of OPENROUTER_API_KEY direct) — this was
part of the now-abandoned PR #12 and never actually landed on main.
2026-08-23 16:21:50 +00:00
william 6d9d081030 Merge pull request 'Remove claude-agent Matrix presence — Hermes only' (#14) from remove/claude-bot-matrix-presence into main
build-agent / build-and-push (push) Successful in 10s
Reviewed-on: #14
2026-08-23 16:16:58 +00:00
william 6bc862e051 Remove claude-agent's Matrix presence entirely — Hermes is the only agent in Matrix
By request: one agent in Matrix, not several. Removes matrixBot.js,
router.js (chat-vs-code-task classifier), litellm.js (claude-agent's own
LiteLLM client), the matrix-bot-sdk dependency, runChatTask() and its
gitea.js branch/PR helpers (createBranch/createPullRequest — only ever
called from the now-removed chat flow), and every Matrix/LiteLLM env var
from claude-agent's compose service.

claude-agent already left the control room manually before this merge.
It keeps its Gitea-webhook-triggered PR review, which never touched Matrix
or LiteLLM to begin with.

Makes PR #12 (the claude-bot/Hermes cross-reply cascade fix) moot — the
bug can't happen once claude-agent has no Matrix client at all. Close #12
without merging once this lands.
2026-08-23 16:16:19 +00:00
william fff021283d Merge pull request 'Re-add claude-subscription LiteLLM route — confirmed working for the real CLI' (#13) from feat/litellm-claude-subscription-route into main
Reviewed-on: #13
2026-08-23 16:09:47 +00:00
william f4f785e0bb Re-add the claude-subscription LiteLLM route — confirmed working for the real CLI
Earlier this session I removed this route after a curl-based test got
rejected by Anthropic and concluded OAuth subscription forwarding doesn't
work through a proxy at all. That conclusion was wrong: the real `claude`
CLI binary, with ANTHROPIC_BASE_URL pointed at litellm, successfully
completed a request billed against the subscription. The earlier curl test
just didn't replicate whatever header/fingerprint Anthropic requires from
genuine Claude Code CLI traffic — LiteLLM relays that fine when the real
CLI is the caller, but a hand-built request from any other client (Hermes
included) still gets rejected the same way curl did.
2026-08-23 16:08:26 +00:00
12 changed files with 204 additions and 290 deletions
+21 -19
View File
@@ -5,6 +5,7 @@
# --- 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
@@ -20,37 +21,38 @@ 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=
# --- matrix bot ---
MATRIX_HOMESERVER_URL=https://matrix.apps.williamturner.eu
MATRIX_BOT_TOKEN=
MATRIX_CONTROL_ROOM_ID=
# The bot's own Matrix ID (@username:server), e.g. @claude-bot:matrix.apps.williamturner.eu
# — set explicitly rather than fetched via the API (that call 404s against Continuwuity).
# Required: without it the bot can't tell its own messages apart from real ones and would
# reply to itself in a loop, so it refuses to start.
MATRIX_BOT_USER_ID=
# Comma-separated "owner/repo" list the chat router is allowed to open code-change PRs
# against. A plain chat message mentioning a repo NOT in this list is treated as chat,
# never as a code task — the router only matches confidently against known repos.
KNOWN_REPOS=william/gitops-automation
# --- litellm (local LLM gateway — see litellm-config.yaml) ---
# --- litellm (local LLM gateway — used by Hermes, see litellm-config.yaml) ---
OPENROUTER_API_KEY=
# Any random string; also used as litellm's general_settings.master_key.
LITELLM_MASTER_KEY=
# --- hermes (autonomous agent with its own native Matrix presence) ---
# --- 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 shared room (e.g. "@hermes <task>" in the control room).
# 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 the same way as claude-bot —
# see README — then log in as it via /_matrix/client/v3/login to get this token).
# 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=
# --- portainer (GitOps redeploy) ---
PORTAINER_STACK_WEBHOOK_URL=
+25 -21
View File
@@ -1,21 +1,25 @@
# gitops-automation
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.
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).
Three things this gives you:
What this gives you today:
- **PR review**: opening/updating a PR in a watched Gitea repo gets a Claude-authored
review comment.
review comment (`claude-agent`, triggered by a Gitea webhook — nothing to do with Matrix).
- **GitOps redeploy**: pushing to `main` on this repo rebuilds the `claude-agent` image
(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.
(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.
Nothing here auto-merges. Every path stops at a comment or an open PR — a human clicks merge.
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).
## Prerequisites (one-time, on the VPS)
@@ -125,17 +129,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: `!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.
- 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.
## Notes
- `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.
- `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.
- `.gitea/workflows/build.yml` assumes the act_runner label `docker` — check
`GITEA_RUNNER_LABELS` in `docker-compose.yml` matches what you actually registered.
<!-- gitops loop smoke test 2026-08-23T11:23:34Z -->
- 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.
+2 -1
View File
@@ -9,6 +9,7 @@
},
"dependencies": {
"express": "^4.19.2",
"matrix-bot-sdk": "^0.7.1"
"@modelcontextprotocol/sdk": "^1.30.0",
"zod": "^3.23.8"
}
}
-21
View File
@@ -24,27 +24,6 @@ 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);
-37
View File
@@ -1,37 +0,0 @@
const LITELLM_BASE_URL = process.env.LITELLM_BASE_URL || "http://litellm:4000";
const LITELLM_MASTER_KEY = process.env.LITELLM_MASTER_KEY;
// OpenAI-compatible chat completion, for OpenRouter-backed models routed through the
// local LiteLLM gateway (e.g. "auto" — OpenRouter's own prompt-aware auto-router).
export async function chatCompletion(model, prompt) {
if (!LITELLM_MASTER_KEY) {
throw new Error("LITELLM_MASTER_KEY is not set");
}
const res = await fetch(`${LITELLM_BASE_URL}/v1/chat/completions`, {
method: "POST",
headers: {
Authorization: `Bearer ${LITELLM_MASTER_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
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(`LiteLLM request failed: ${res.status} ${await res.text()}`);
}
const data = await res.json();
const content = data.choices?.[0]?.message?.content;
if (!content) {
throw new Error(`LiteLLM returned no content: ${JSON.stringify(data)}`);
}
return content;
}
-79
View File
@@ -1,79 +0,0 @@
import { MatrixClient, SimpleFsStorageProvider } from "matrix-bot-sdk";
import { runChatTask } from "./runner.js";
import { routeMessage, chatReply } from "./router.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;
// Set explicitly rather than fetched via client.getUserId() — that call hits /whoami,
// which (like /joined_rooms before it) 404s against Continuwuity for reasons unrelated
// to the endpoint itself. This is also the only reliable way to filter the bot's own
// messages now that there's no command prefix to naturally exclude them by.
const BOT_USER_ID = process.env.MATRIX_BOT_USER_ID;
const KNOWN_REPOS = (process.env.KNOWN_REPOS || "")
.split(",")
.map((r) => r.trim())
.filter(Boolean);
const MAX_REPLY_LENGTH = 4000;
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;
}
if (!BOT_USER_ID) {
console.warn("MATRIX_BOT_USER_ID not set — bot could reply to its own messages, skipping startup");
return;
}
const storage = new SimpleFsStorageProvider("/workspace/matrix-bot-storage.json");
const client = new MatrixClient(HOMESERVER_URL, ACCESS_TOKEN, storage);
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) => {
if (roomId !== CONTROL_ROOM_ID) return;
if (event.sender === BOT_USER_ID) return;
const body = event.content?.body;
if (!body) return;
// Messages explicitly addressed to another agent in this room (currently just
// @hermes) are that agent's to answer — without this, claude-bot's classifier would
// also see and reply to them, since it otherwise treats every message as its own.
if (/^@hermes\b/i.test(body.trim())) return;
try {
const decision = await routeMessage(body, KNOWN_REPOS);
if (decision.type === "code_task") {
const [owner, repo] = decision.repo.split("/");
await client.sendText(roomId, `Working on it: ${decision.repo}${decision.instruction}`);
const cloneUrl = `${GITEA_URL}/${owner}/${repo}.git`;
const pr = await runChatTask({ owner, repo, cloneUrl, instruction: decision.instruction });
await client.sendText(roomId, `Opened PR: ${pr.html_url}`);
return;
}
const reply = await chatReply(body);
await client.sendText(roomId, truncate(reply));
} catch (err) {
console.error("message handling failed", err);
await client.sendText(roomId, `Failed: ${err.message}`);
}
});
await client.start();
console.log("Matrix bot started, room:", CONTROL_ROOM_ID);
}
+71
View File
@@ -0,0 +1,71 @@
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();
}
-45
View File
@@ -1,45 +0,0 @@
import { chatCompletion } from "./litellm.js";
const ROUTER_MODEL = "router-classifier";
const CHAT_MODEL = "auto";
function systemPrompt(knownRepos) {
return [
"You are a routing classifier for a chat bot. Given a user message, decide whether it is:",
'- "chat": a question, discussion, or anything that just needs a text reply.',
'- "code_task": a request to change a specific code repository (add/edit/fix something)',
" where the repository is clearly one of the known repositories below.",
"",
`Known repositories: ${knownRepos.join(", ") || "(none configured)"}`,
"",
"Reply with ONLY a JSON object, nothing else:",
'{"type":"chat"}',
'or',
'{"type":"code_task","repo":"owner/repo","instruction":"clear imperative instruction"}',
"",
"If it sounds like a code change but you can't confidently match it to one of the known",
'repositories, reply {"type":"chat"} instead of guessing.',
].join("\n");
}
function parseDecision(raw) {
try {
const cleaned = raw.trim().replace(/^```(?:json)?\n?/, "").replace(/```$/, "");
const parsed = JSON.parse(cleaned);
if (parsed.type === "code_task" && parsed.repo && parsed.instruction) {
return parsed;
}
} catch {
// fall through to chat — an unparseable classification is not a reason to edit a repo
}
return { type: "chat" };
}
export async function routeMessage(text, knownRepos) {
const raw = await chatCompletion(ROUTER_MODEL, `${systemPrompt(knownRepos)}\n\nMessage: ${text}`);
return parseDecision(raw);
}
export async function chatReply(text) {
return chatCompletion(CHAT_MODEL, text);
}
+8 -42
View File
@@ -2,8 +2,7 @@ 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 crypto from "node:crypto";
import { authenticatedCloneUrl, createBranch, createPullRequest } from "./gitea.js";
import { authenticatedCloneUrl } from "./gitea.js";
const execFileAsync = promisify(execFile);
const WORKSPACE_ROOT = "/workspace";
@@ -29,10 +28,12 @@ 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 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");
// 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",
];
const args = [
"-p", prompt,
@@ -59,41 +60,6 @@ export async function reviewPullRequest({ owner, repo, ref, cloneUrl, prTitle, p
`PR description:\n${prBody}`,
].join("\n");
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}`,
});
return runClaude(dir, prompt);
});
}
+29 -5
View File
@@ -1,8 +1,9 @@
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 { startMatrixBot } from "./matrixBot.js";
import { createMcpServer, mcpAuthMiddleware } from "./mcpBridge.js";
const app = express();
app.use(
@@ -57,10 +58,33 @@ 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);
});
+34 -13
View File
@@ -77,13 +77,28 @@ services:
# rooms (DMs to it would respond unprompted, per Hermes's own default behavior).
MATRIX_ALLOWED_USERS: ${MATRIX_HUMAN_USER_ID}
MATRIX_REQUIRE_MENTION: "true"
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
# 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:
@@ -92,11 +107,24 @@ services:
# 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}
depends_on:
- litellm
container_name: claude-agent
restart: unless-stopped
# Explicit vars, not env_file: .env — Portainer's git-based stack deploy clones the
@@ -109,16 +137,9 @@ 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}
MATRIX_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL}
MATRIX_BOT_TOKEN: ${MATRIX_BOT_TOKEN}
MATRIX_CONTROL_ROOM_ID: ${MATRIX_CONTROL_ROOM_ID}
MATRIX_BOT_USER_ID: ${MATRIX_BOT_USER_ID}
KNOWN_REPOS: ${KNOWN_REPOS}
# All model calls now go through the local litellm service, not OpenRouter directly —
# one gateway for OpenRouter's models (incl. its auto-router) and, for the
# claude-subscription route, Anthropic itself via the forwarded OAuth token above.
LITELLM_BASE_URL: http://litellm:4000
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
# 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}
volumes:
- agent_workspace:/workspace
networks:
+14 -7
View File
@@ -11,13 +11,20 @@ model_list:
model: openrouter/openai/gpt-4o-mini
api_key: os.environ/OPENROUTER_API_KEY
# NOT included: a "claude-subscription" route forwarding the Claude Pro/Max OAuth token
# (from `claude setup-token`) through to Anthropic's raw API. Tested and confirmed
# non-functional — Anthropic returns a generic rate_limit_error for ANY direct API call
# using this token type outside the real Claude Code CLI client (reproduced with plain
# curl straight to api.anthropic.com, bypassing LiteLLM entirely, same result). The
# subscription token only works through the actual Claude Code CLI, which is what
# claude-agent already uses directly for code tasks — it was never routed through here.
# 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