Compare commits
22
Commits
d7a881c768
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0164ec4c20 | ||
|
|
46192837e6 | ||
|
|
ec37245b37 | ||
|
|
3dc6397862 | ||
|
|
02a94d1d03 | ||
|
|
143be8200e | ||
|
|
9739676409 | ||
|
|
6d9d081030 | ||
|
|
6bc862e051 | ||
|
|
fff021283d | ||
|
|
f4f785e0bb | ||
|
|
c88fdcc2ea | ||
|
|
98762e764a | ||
|
|
5507192ee6 | ||
|
|
9fa025f7d5 | ||
|
|
5461754f23 | ||
|
|
f93bfb25a2 | ||
|
|
4fc4433833 | ||
|
|
8db8347df0 | ||
|
|
34c2f7d634 | ||
|
|
fc53251285 | ||
|
|
6d0cb3bba3 |
+30
-7
@@ -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,15 +21,37 @@ 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=
|
||||
|
||||
# --- openrouter (the "!ai" chat command — other models, not the coding agent) ---
|
||||
# --- litellm (local LLM gateway — used by Hermes, see litellm-config.yaml) ---
|
||||
OPENROUTER_API_KEY=
|
||||
OPENROUTER_DEFAULT_MODEL=openai/gpt-4o-mini
|
||||
# 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=
|
||||
|
||||
# --- portainer (GitOps redeploy) ---
|
||||
PORTAINER_STACK_WEBHOOK_URL=
|
||||
|
||||
@@ -31,3 +31,10 @@ 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 }}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -96,20 +100,46 @@ docker network create web
|
||||
13. **Firewall**: `sudo ufw allow 80/tcp 443/tcp`; once the HTTPS routes above are all
|
||||
confirmed working, `sudo ufw delete allow 3000/tcp` and `sudo ufw delete allow 9443/tcp`.
|
||||
|
||||
14. **Branch protection on `main`**, on every repo you want the bot working on (so it's
|
||||
structurally limited to opening PRs, never merging or pushing directly) — via the repo's
|
||||
Settings → Branches → Add Rule, or the API:
|
||||
```bash
|
||||
curl -X POST -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \
|
||||
"$GITEA_URL/api/v1/repos/<owner>/<repo>/branch_protections" \
|
||||
-d '{
|
||||
"rule_name": "main",
|
||||
"enable_push": false,
|
||||
"enable_merge_whitelist": true,
|
||||
"merge_whitelist_usernames": ["<your-human-username>"]
|
||||
}'
|
||||
```
|
||||
This blocks *all* direct pushes to `main` (everyone goes through a PR) and restricts
|
||||
merging to the human usernames listed — `claude-bot` (see below) is never in that list,
|
||||
so it structurally cannot merge, only open PRs, regardless of what its token can do.
|
||||
|
||||
15. **Dedicated bot account** (recommended over using your own account/token for the
|
||||
agent): create a separate Gitea user (e.g. `claude-bot`) via Site Administration → User
|
||||
Accounts, generate its own token (scopes: repository, issue, package — no `admin`,
|
||||
`organization`, or `user` needed), add it as a repo Collaborator with **Write**
|
||||
permission on each automated repo, then use its token as `GITEA_TOKEN` (agent) and
|
||||
`REGISTRY_TOKEN` (CI secret) instead of your own. This makes every PR comment, branch,
|
||||
and PR clearly attributed to the bot instead of you, and its access is easy to revoke
|
||||
independently.
|
||||
|
||||
## 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
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"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");
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { MatrixClient, SimpleFsStorageProvider, AutojoinRoomsMixin } 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);
|
||||
AutojoinRoomsMixin.setupOnClient(client);
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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,37 +0,0 @@
|
||||
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 };
|
||||
+8
-42
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
+90
-5
@@ -36,7 +36,94 @@ 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
|
||||
@@ -50,11 +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}
|
||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
|
||||
OPENROUTER_DEFAULT_MODEL: ${OPENROUTER_DEFAULT_MODEL:-openai/gpt-4o-mini}
|
||||
# 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:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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
|
||||
Reference in New Issue
Block a user