Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46192837e6 | ||
|
|
ec37245b37 | ||
|
|
3dc6397862 | ||
|
|
02a94d1d03 | ||
|
|
143be8200e | ||
|
|
9739676409 | ||
|
|
6d9d081030 | ||
|
|
6bc862e051 | ||
|
|
fff021283d | ||
|
|
f4f785e0bb |
+21
-23
@@ -5,6 +5,7 @@
|
|||||||
# --- domain / TLS ---
|
# --- domain / TLS ---
|
||||||
MATRIX_SERVER_NAME=matrix.apps.williamturner.eu
|
MATRIX_SERVER_NAME=matrix.apps.williamturner.eu
|
||||||
AGENT_HOSTNAME=agent.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,
|
# Set to true ONLY for the first-boot window while creating the bot account,
|
||||||
# then back to false (or unset) and redeploy. See README.
|
# then back to false (or unset) and redeploy. See README.
|
||||||
MATRIX_ALLOW_REGISTRATION=false
|
MATRIX_ALLOW_REGISTRATION=false
|
||||||
@@ -20,41 +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)
|
# 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.
|
# to generate this — it's a long-lived OAuth token, not an API key.
|
||||||
CLAUDE_CODE_OAUTH_TOKEN=
|
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 ---
|
# --- litellm (local LLM gateway — used by Hermes, see litellm-config.yaml) ---
|
||||||
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 Matrix IDs of OTHER agents sharing the control room (currently just
|
|
||||||
# @hermes:...) — without this, claude-bot treats every message another bot posts as
|
|
||||||
# fresh chat input and replies to it, which that bot may then react to in turn.
|
|
||||||
OTHER_AGENT_USER_IDS=@hermes:matrix.apps.williamturner.eu
|
|
||||||
# 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) ---
|
|
||||||
OPENROUTER_API_KEY=
|
OPENROUTER_API_KEY=
|
||||||
# Any random string; also used as litellm's general_settings.master_key.
|
# Any random string; also used as litellm's general_settings.master_key.
|
||||||
LITELLM_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
|
# 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
|
MATRIX_HUMAN_USER_ID=@william:matrix.apps.williamturner.eu
|
||||||
# Access token for the @hermes bot account (register it the same way as claude-bot —
|
# Access token for the @hermes bot account — register it on the homeserver, then log
|
||||||
# see README — then log in as it via /_matrix/client/v3/login to get this token).
|
# in as it via /_matrix/client/v3/login to get this token (see README).
|
||||||
HERMES_MATRIX_ACCESS_TOKEN=
|
HERMES_MATRIX_ACCESS_TOKEN=
|
||||||
# Any random string — bearer key for Hermes's own OpenAI-compatible API server
|
# Any random string — bearer key for Hermes's own OpenAI-compatible API server
|
||||||
# (internal network only, not published anywhere).
|
# (internal network only, not published anywhere).
|
||||||
HERMES_API_SERVER_KEY=
|
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 (GitOps redeploy) ---
|
||||||
PORTAINER_STACK_WEBHOOK_URL=
|
PORTAINER_STACK_WEBHOOK_URL=
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
# gitops-automation
|
# gitops-automation
|
||||||
|
|
||||||
Claude Code automation wired into Gitea + Portainer + Matrix on this VPS. See
|
Claude Code + Hermes 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/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
|
- **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
|
- **GitOps redeploy**: pushing to `main` on this repo rebuilds the `claude-agent` image
|
||||||
(Gitea Actions) and redeploys the stack (Portainer webhook).
|
(Gitea Actions) and redeploys the stack, chained as the last step of that same workflow.
|
||||||
- **Chat-driven coding agent**: `!claude owner/repo <instruction>` in the Matrix control
|
- **Matrix**: **Hermes is the only agent present in Matrix.** `claude-agent` used to also
|
||||||
room clones the repo, runs Claude Code, and opens a PR with the result.
|
run a Matrix bot (`!claude`/`!ai` commands, then no-prefix auto-routing) — that's been
|
||||||
- **Ask other models**: `!ai <prompt>` (default model) or `!ai provider/model <prompt>`
|
removed entirely (by request: one agent in Matrix, not several). Hermes has its own
|
||||||
(e.g. `!ai google/gemini-2.0-flash-001 explain this error`) queries any model on
|
native Matrix connection, responds to `@hermes <message>` in shared rooms (no mention
|
||||||
OpenRouter and replies in the room. No repo/file access — just a chat reply, unlike
|
needed in DMs), and has its own tools (terminal, code execution, web search, etc.) — see
|
||||||
`!claude` which is the only command that can edit files and open PRs.
|
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)
|
## Prerequisites (one-time, on the VPS)
|
||||||
|
|
||||||
@@ -125,17 +129,17 @@ docker network create web
|
|||||||
## Smoke test
|
## Smoke test
|
||||||
|
|
||||||
- Open a throwaway PR on a repo with the PR webhook set → expect a Claude review comment.
|
- 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
|
- In the Matrix control room: `@hermes hello` → expect a reply from Hermes.
|
||||||
"working on it" reply, then a PR link.
|
- `git push` to `main` on this repo (touching `agent/**`) → expect a Gitea Actions run,
|
||||||
- `git push` to `main` on this repo → expect a Gitea Actions run, then a Portainer redeploy.
|
then a chained Portainer redeploy at the end of that same workflow.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `agent/src/runner.js` is the only thing that ever runs `git commit`/`git push`/`git
|
- `agent/src/runner.js` only ever runs read-only `git clone`/`fetch`/`checkout` for the PR
|
||||||
checkout` — Claude Code itself is explicitly denied those tools (`--disallowedTools`),
|
diff it reviews — Claude Code itself is denied `Edit`/`Write`/commit/push tools
|
||||||
so even a misbehaving prompt can't push directly or touch `main`.
|
(`--disallowedTools`), so this path can never modify a repo, only comment on it.
|
||||||
- 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/workflows/build.yml` assumes the act_runner label `docker` — check
|
||||||
`GITEA_RUNNER_LABELS` in `docker-compose.yml` matches what you actually registered.
|
`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
|
||||||
<!-- gitops loop smoke test 2026-08-23T11:23:34Z -->
|
(bind-mounted, not in this repo) — back that up separately if it accumulates anything
|
||||||
|
worth keeping.
|
||||||
|
|||||||
+2
-1
@@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"matrix-bot-sdk": "^0.7.1"
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,27 +24,6 @@ export async function postPRComment(owner, repo, index, body) {
|
|||||||
await assertOk(res, "post PR comment");
|
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.
|
// Injects the agent's token into a Gitea clone URL so git operations don't need SSH keys.
|
||||||
export function authenticatedCloneUrl(cloneUrl) {
|
export function authenticatedCloneUrl(cloneUrl) {
|
||||||
const u = new URL(cloneUrl);
|
const u = new URL(cloneUrl);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,90 +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;
|
|
||||||
// Other agents sharing this room (currently just Hermes) — their own messages must be
|
|
||||||
// ignored the same way claude-bot ignores its own, or claude-bot's classifier treats
|
|
||||||
// every message another bot posts as fresh chat input and replies to it, which that
|
|
||||||
// bot may then react to in turn. Found the hard way: a single @hermes mention cascaded
|
|
||||||
// into claude-bot replying to Hermes's own thread messages ("Hermes says: ...").
|
|
||||||
const OTHER_AGENT_USER_IDS = (process.env.OTHER_AGENT_USER_IDS || "")
|
|
||||||
.split(",")
|
|
||||||
.map((id) => id.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
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;
|
|
||||||
if (OTHER_AGENT_USER_IDS.includes(event.sender)) 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);
|
|
||||||
console.log("chat reply sent, length:", reply.length);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -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,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
@@ -2,8 +2,7 @@ import { execFile } from "node:child_process";
|
|||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { mkdtemp, rm, mkdir } from "node:fs/promises";
|
import { mkdtemp, rm, mkdir } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import crypto from "node:crypto";
|
import { authenticatedCloneUrl } from "./gitea.js";
|
||||||
import { authenticatedCloneUrl, createBranch, createPullRequest } from "./gitea.js";
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const WORKSPACE_ROOT = "/workspace";
|
const WORKSPACE_ROOT = "/workspace";
|
||||||
@@ -29,10 +28,12 @@ async function withWorkspace(fn) {
|
|||||||
// Runs Claude Code headless. Unattended containers have no TTY to answer permission
|
// Runs Claude Code headless. Unattended containers have no TTY to answer permission
|
||||||
// prompts, so this trusts the sandboxing of the throwaway clone dir instead:
|
// prompts, so this trusts the sandboxing of the throwaway clone dir instead:
|
||||||
// bypassPermissions to avoid hanging, plus --disallowedTools as defense in depth so
|
// bypassPermissions to avoid hanging, plus --disallowedTools as defense in depth so
|
||||||
// Claude can never push/commit/checkout itself — this script owns those steps.
|
// Claude can never push/commit/checkout, or edit files — this is read-only review.
|
||||||
async function runClaude(cwd, prompt, { allowEdits }) {
|
async function runClaude(cwd, prompt) {
|
||||||
const disallowed = ["Bash(git push:*)", "Bash(git commit:*)", "Bash(git checkout:*)"];
|
const disallowed = [
|
||||||
if (!allowEdits) disallowed.push("Edit", "Write", "NotebookEdit");
|
"Bash(git push:*)", "Bash(git commit:*)", "Bash(git checkout:*)",
|
||||||
|
"Edit", "Write", "NotebookEdit",
|
||||||
|
];
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
"-p", prompt,
|
"-p", prompt,
|
||||||
@@ -59,41 +60,6 @@ export async function reviewPullRequest({ owner, repo, ref, cloneUrl, prTitle, p
|
|||||||
`PR description:\n${prBody}`,
|
`PR description:\n${prBody}`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
return runClaude(dir, prompt, { allowEdits: false });
|
return runClaude(dir, prompt);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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}`,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-5
@@ -1,8 +1,9 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||||
import { postPRComment } from "./gitea.js";
|
import { postPRComment } from "./gitea.js";
|
||||||
import { reviewPullRequest } from "./runner.js";
|
import { reviewPullRequest } from "./runner.js";
|
||||||
import { startMatrixBot } from "./matrixBot.js";
|
import { createMcpServer, mcpAuthMiddleware } from "./mcpBridge.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(
|
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, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`claude-agent listening on :${PORT}`);
|
console.log(`claude-agent listening on :${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
startMatrixBot().catch((err) => {
|
|
||||||
console.error("matrix bot failed to start:", err);
|
|
||||||
});
|
|
||||||
|
|||||||
+32
-19
@@ -77,13 +77,10 @@ services:
|
|||||||
# rooms (DMs to it would respond unprompted, per Hermes's own default behavior).
|
# rooms (DMs to it would respond unprompted, per Hermes's own default behavior).
|
||||||
MATRIX_ALLOWED_USERS: ${MATRIX_HUMAN_USER_ID}
|
MATRIX_ALLOWED_USERS: ${MATRIX_HUMAN_USER_ID}
|
||||||
MATRIX_REQUIRE_MENTION: "true"
|
MATRIX_REQUIRE_MENTION: "true"
|
||||||
# Routed through the local litellm gateway, not OpenRouter directly — same pattern
|
# Routed through the local litellm gateway, not OpenRouter directly — one place to
|
||||||
# as claude-agent's chat path, one place to hold the OpenRouter credential and swap
|
# hold the OpenRouter credential and swap models. Does NOT grant Hermes access to
|
||||||
# models. Hermes's "main"/custom-endpoint provider is any OpenAI-compatible API
|
# the Claude subscription (Anthropic-side restriction, proven earlier — the
|
||||||
# reachable via OPENAI_BASE_URL + OPENAI_API_KEY. Note: this does NOT give Hermes
|
# subscription only works through the real `claude` CLI binary, which Hermes isn't).
|
||||||
# access to the Claude Pro/Max subscription — that's blocked by Anthropic itself for
|
|
||||||
# any caller other than the real Claude Code CLI, proven earlier in this session
|
|
||||||
# (reproduced with plain curl straight to api.anthropic.com, LiteLLM or not).
|
|
||||||
OPENAI_BASE_URL: http://litellm:4000/v1
|
OPENAI_BASE_URL: http://litellm:4000/v1
|
||||||
OPENAI_API_KEY: ${LITELLM_MASTER_KEY}
|
OPENAI_API_KEY: ${LITELLM_MASTER_KEY}
|
||||||
# Left disabled: Hermes itself warns that a network-reachable API server combined
|
# Left disabled: Hermes itself warns that a network-reachable API server combined
|
||||||
@@ -92,6 +89,16 @@ services:
|
|||||||
# re-enable (API_SERVER_HOST: 0.0.0.0) only alongside terminal.backend: docker if
|
# re-enable (API_SERVER_HOST: 0.0.0.0) only alongside terminal.backend: docker if
|
||||||
# claude-agent ever needs to call Hermes programmatically.
|
# claude-agent ever needs to call Hermes programmatically.
|
||||||
API_SERVER_ENABLED: "false"
|
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:
|
volumes:
|
||||||
- /home/william/hermes-data:/opt/data
|
- /home/william/hermes-data:/opt/data
|
||||||
networks:
|
networks:
|
||||||
@@ -100,11 +107,24 @@ services:
|
|||||||
# immediately exits ("Input is not a terminal") since a detached container has no
|
# 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.
|
# stdin — the container then just sits there having done nothing, every restart.
|
||||||
command: ["gateway", "run"]
|
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:
|
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}
|
image: ${GITEA_REGISTRY_IMAGE}
|
||||||
depends_on:
|
|
||||||
- litellm
|
|
||||||
container_name: claude-agent
|
container_name: claude-agent
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Explicit vars, not env_file: .env — Portainer's git-based stack deploy clones the
|
# Explicit vars, not env_file: .env — Portainer's git-based stack deploy clones the
|
||||||
@@ -117,16 +137,9 @@ services:
|
|||||||
# Claude subscription (Pro/Max) auth via `claude setup-token`, not API billing —
|
# 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 reads this in preference to ANTHROPIC_API_KEY when both could apply.
|
||||||
CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN}
|
CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN}
|
||||||
MATRIX_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL}
|
# Shared secret for the /mcp bridge endpoint (internal network only either way, but
|
||||||
MATRIX_BOT_TOKEN: ${MATRIX_BOT_TOKEN}
|
# this keeps it from being callable by anything that merely reaches the container).
|
||||||
MATRIX_CONTROL_ROOM_ID: ${MATRIX_CONTROL_ROOM_ID}
|
MCP_BRIDGE_KEY: ${MCP_BRIDGE_KEY}
|
||||||
MATRIX_BOT_USER_ID: ${MATRIX_BOT_USER_ID}
|
|
||||||
OTHER_AGENT_USER_IDS: ${OTHER_AGENT_USER_IDS}
|
|
||||||
KNOWN_REPOS: ${KNOWN_REPOS}
|
|
||||||
# Chat replies go through the local litellm service (OpenRouter's models, incl. its
|
|
||||||
# auto-router), not OpenRouter directly.
|
|
||||||
LITELLM_BASE_URL: http://litellm:4000
|
|
||||||
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
|
|
||||||
volumes:
|
volumes:
|
||||||
- agent_workspace:/workspace
|
- agent_workspace:/workspace
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
+14
-7
@@ -11,13 +11,20 @@ model_list:
|
|||||||
model: openrouter/openai/gpt-4o-mini
|
model: openrouter/openai/gpt-4o-mini
|
||||||
api_key: os.environ/OPENROUTER_API_KEY
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
# NOT included: a "claude-subscription" route forwarding the Claude Pro/Max OAuth token
|
# NOT included: an "anthropic-claude" model routing to Anthropic via the caller's
|
||||||
# (from `claude setup-token`) through to Anthropic's raw API. Tested and confirmed
|
# forwarded OAuth header (general_settings.forward_client_headers_to_llm_api). It
|
||||||
# non-functional — Anthropic returns a generic rate_limit_error for ANY direct API call
|
# genuinely works — but ONLY when the real `claude` CLI binary is the caller (its
|
||||||
# using this token type outside the real Claude Code CLI client (reproduced with plain
|
# request carries a header/fingerprint only that binary sends; a hand-built request,
|
||||||
# curl straight to api.anthropic.com, bypassing LiteLLM entirely, same result). The
|
# including Hermes selecting this model directly, gets a hard auth error from
|
||||||
# subscription token only works through the actual Claude Code CLI, which is what
|
# Anthropic). Having it selectable here caused exactly that confusion once already.
|
||||||
# claude-agent already uses directly for code tasks — it was never routed through here.
|
# 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:
|
general_settings:
|
||||||
master_key: os.environ/LITELLM_MASTER_KEY
|
master_key: os.environ/LITELLM_MASTER_KEY
|
||||||
|
|||||||
Reference in New Issue
Block a user