Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46192837e6 | ||
|
|
ec37245b37 | ||
|
|
3dc6397862 | ||
|
|
02a94d1d03 | ||
|
|
143be8200e | ||
|
|
9739676409 | ||
|
|
6d9d081030 |
@@ -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,6 +21,12 @@ 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) ---
|
||||
OPENROUTER_API_KEY=
|
||||
@@ -37,6 +44,15 @@ HERMES_MATRIX_ACCESS_TOKEN=
|
||||
# (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=
|
||||
|
||||
|
||||
+3
-1
@@ -8,6 +8,8 @@
|
||||
"start": "node src/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2"
|
||||
"express": "^4.19.2",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,7 +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 { createMcpServer, mcpAuthMiddleware } from "./mcpBridge.js";
|
||||
|
||||
const app = express();
|
||||
app.use(
|
||||
@@ -56,6 +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}`);
|
||||
});
|
||||
|
||||
+34
-4
@@ -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,23 @@ 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:
|
||||
# Gitea PR-review only now — no Matrix presence (see hermes above; only one agent
|
||||
# is meant to be in Matrix). Still triggered by Gitea's pull_request webhook and
|
||||
# posts review comments there, entirely independent of Matrix/LiteLLM.
|
||||
# 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
|
||||
@@ -110,6 +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}
|
||||
# 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
-13
@@ -11,19 +11,20 @@ model_list:
|
||||
model: openrouter/openai/gpt-4o-mini
|
||||
api_key: os.environ/OPENROUTER_API_KEY
|
||||
|
||||
# Routes to Anthropic using the CALLER's forwarded Authorization header (the Claude
|
||||
# Pro/Max subscription OAuth token) instead of a LiteLLM-held API key — billed against
|
||||
# the subscription, not per-token. CONFIRMED WORKING, but only for the real `claude`
|
||||
# CLI binary as caller (tested: `claude -p` with ANTHROPIC_BASE_URL pointed here
|
||||
# returned a real completion). An earlier test with plain curl replicating the same
|
||||
# request shape failed — Anthropic apparently requires header/fingerprint details only
|
||||
# the real CLI sends, which LiteLLM faithfully relays but a hand-built request won't
|
||||
# have. Do NOT expect this to work for other callers (Hermes, generic HTTP clients) —
|
||||
# they aren't the real CLI and can't reproduce that fingerprint.
|
||||
- model_name: anthropic-claude
|
||||
litellm_params:
|
||||
model: anthropic/claude-sonnet-5
|
||||
# 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:
|
||||
forward_client_headers_to_llm_api: true
|
||||
master_key: os.environ/LITELLM_MASTER_KEY
|
||||
|
||||
Reference in New Issue
Block a user