Initial extraction from monorepo

This commit is contained in:
Viktor Barzin 2026-05-07 17:07:12 +00:00
commit 6fa60fdd1a
14 changed files with 637 additions and 0 deletions

6
.dockerignore Normal file
View file

@ -0,0 +1,6 @@
.venv/
tests/
__pycache__/
*.pyc
.pytest_cache/
requirements-dev.txt

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
__pycache__/
*.pyc
.venv/
.mypy_cache/
.pytest_cache/
.ruff_cache/
.hypothesis/
*.egg-info/
vault

41
.woodpecker.yml Normal file
View file

@ -0,0 +1,41 @@
when:
event: push
path: "claude-agent-service/**"
clone:
git:
image: woodpeckerci/plugin-git
settings:
attempts: 5
backoff: 10s
steps:
- name: build-and-push
image: woodpeckerci/plugin-docker-buildx
settings:
username: "viktorbarzin"
password:
from_secret: dockerhub-pat
# Dual-push during the Forgejo registry consolidation bake. After
# ≥14 days clean, registry.viktorbarzin.me drops out (Phase 4).
# Once this directory is extracted to the Forgejo viktor/claude-agent-service
# repo, drop the `path:` filter above and the `claude-agent-service/`
# prefix in dockerfile/context.
repo:
- registry.viktorbarzin.me/claude-agent-service
- forgejo.viktorbarzin.me/viktor/claude-agent-service
logins:
- registry: registry.viktorbarzin.me
username: viktorbarzin
password:
from_secret: registry-password
- registry: forgejo.viktorbarzin.me
username:
from_secret: forgejo_user
password:
from_secret: forgejo_push_token
dockerfile: claude-agent-service/Dockerfile
context: claude-agent-service
auto_tag: true
platforms:
- linux/amd64

83
Dockerfile Normal file
View file

@ -0,0 +1,83 @@
FROM alpine:3.20
ARG TERRAFORM_VERSION=1.5.7
ARG TERRAGRUNT_VERSION=0.99.4
ARG SOPS_VERSION=3.9.4
ARG KUBECTL_VERSION=1.34.0
ARG BD_VERSION=1.0.2
# System packages: infra tools + Python + Node.js (for Claude CLI).
# gcompat/libc6-compat provide the glibc shim the bd binary links against.
RUN apk add --no-cache \
bash curl git git-crypt jq openssh-client openssl unzip \
python3 py3-pip \
nodejs npm \
gcompat libc6-compat \
&& rm -rf /var/cache/apk/*
# Terraform
RUN curl -fsSL "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip" \
-o /tmp/terraform.zip \
&& unzip /tmp/terraform.zip -d /usr/local/bin/ \
&& rm /tmp/terraform.zip
# Terragrunt
RUN curl -fsSL "https://github.com/gruntwork-io/terragrunt/releases/download/v${TERRAGRUNT_VERSION}/terragrunt_linux_amd64" \
-o /usr/local/bin/terragrunt \
&& chmod +x /usr/local/bin/terragrunt
# SOPS
RUN curl -fsSL "https://github.com/getsops/sops/releases/download/v${SOPS_VERSION}/sops-v${SOPS_VERSION}.linux.amd64" \
-o /usr/local/bin/sops \
&& chmod +x /usr/local/bin/sops
# kubectl
RUN curl -fsSL "https://dl.k8s.io/release/v${KUBECTL_VERSION}/bin/linux/amd64/kubectl" \
-o /usr/local/bin/kubectl \
&& chmod +x /usr/local/bin/kubectl
# Vault CLI
COPY vault /usr/local/bin/vault
# Claude Code CLI
RUN npm install -g @anthropic-ai/claude-code
# bd (beads CLI). Upstream github.com/steveyegge/beads redirects to gastownhall/beads
# and publishes release tarballs — there is no bare `bd_linux_amd64` asset.
# The binary is glibc-linked; gcompat+libc6-compat installed above provide the shim.
RUN curl -fsSL "https://github.com/gastownhall/beads/releases/download/v${BD_VERSION}/beads_${BD_VERSION}_linux_amd64.tar.gz" \
-o /tmp/beads.tar.gz \
&& tar -xzf /tmp/beads.tar.gz -C /tmp \
&& install -m 0755 /tmp/bd /usr/local/bin/bd \
&& rm -rf /tmp/beads.tar.gz /tmp/bd
# Non-root user (Claude CLI blocks --dangerously-skip-permissions as root)
RUN addgroup -g 1000 agent && adduser -u 1000 -G agent -h /home/agent -s /bin/bash -D agent
# Terraform provider cache
ENV TF_PLUGIN_CACHE_DIR=/tmp/terraform-plugin-cache
ENV TF_PLUGIN_CACHE_MAY_BREAK_DEPENDENCY_LOCK_FILE=1
RUN mkdir -p /tmp/terraform-plugin-cache && chmod 777 /tmp/terraform-plugin-cache
# Python app
COPY requirements.txt /srv/requirements.txt
RUN pip install --no-cache-dir --break-system-packages -r /srv/requirements.txt
COPY app/ /srv/app/
# Set up home directory for agent user
RUN mkdir -p /home/agent/.config/sops/age \
&& chown -R agent:agent /home/agent
# Seed files staged in an image-layer path that is NEVER mounted at runtime.
# /workspace (PVC) and /home/agent/.claude (emptyDir) are both volume-mounted in
# production, so COPYing into them here has no effect. An init container in the
# K8s manifest copies these files into the runtime volumes on each pod start.
COPY beads/metadata.json /usr/share/agent-seed/beads-metadata.json
COPY agents/beads-task-runner.md /usr/share/agent-seed/beads-task-runner.md
USER agent
WORKDIR /workspace/infra
EXPOSE 8080
CMD ["python3", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--app-dir", "/srv"]

92
LICENSE.txt Normal file
View file

@ -0,0 +1,92 @@
License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved.
"Business Source License" is a trademark of MariaDB Corporation Ab.
Parameters
Licensor: HashiCorp, Inc.
Licensed Work: Vault Version 1.15.0 or later. The Licensed Work is (c) 2024
HashiCorp, Inc.
Additional Use Grant: You may make production use of the Licensed Work, provided
Your use does not include offering the Licensed Work to third
parties on a hosted or embedded basis in order to compete with
HashiCorp's paid version(s) of the Licensed Work. For purposes
of this license:
A "competitive offering" is a Product that is offered to third
parties on a paid basis, including through paid support
arrangements, that significantly overlaps with the capabilities
of HashiCorp's paid version(s) of the Licensed Work. If Your
Product is not a competitive offering when You first make it
generally available, it will not become a competitive offering
later due to HashiCorp releasing a new version of the Licensed
Work with additional capabilities. In addition, Products that
are not provided on a paid basis are not competitive.
"Product" means software that is offered to end users to manage
in their own environments or offered as a service on a hosted
basis.
"Embedded" means including the source code or executable code
from the Licensed Work in a competitive offering. "Embedded"
also means packaging the competitive offering in such a way
that the Licensed Work must be accessed or downloaded for the
competitive offering to operate.
Hosting or using the Licensed Work(s) for internal purposes
within an organization is not considered a competitive
offering. HashiCorp considers your organization to include all
of your affiliates under common control.
For binding interpretive guidance on using HashiCorp products
under the Business Source License, please visit our FAQ.
(https://www.hashicorp.com/license-faq)
Change Date: Four years from the date the Licensed Work is published.
Change License: MPL 2.0
For information about alternative licensing arrangements for the Licensed Work,
please contact licensing@hashicorp.com.
Notice
Business Source License 1.1
Terms
The Licensor hereby grants you the right to copy, modify, create derivative
works, redistribute, and make non-production use of the Licensed Work. The
Licensor may make an Additional Use Grant, above, permitting limited production use.
Effective on the Change Date, or the fourth anniversary of the first publicly
available distribution of a specific version of the Licensed Work under this
License, whichever comes first, the Licensor hereby grants you rights under
the terms of the Change License, and the rights granted in the paragraph
above terminate.
If your use of the Licensed Work does not comply with the requirements
currently in effect as described in this License, you must purchase a
commercial license from the Licensor, its affiliated entities, or authorized
resellers, or you must refrain from using the Licensed Work.
All copies of the original and modified Licensed Work, and derivative works
of the Licensed Work, are subject to this License. This License applies
separately for each version of the Licensed Work and the Change Date may vary
for each version of the Licensed Work released by Licensor.
You must conspicuously display this License on each original or modified copy
of the Licensed Work. If you receive the Licensed Work in original or
modified form from a third party, the terms and conditions set forth in this
License apply to your use of that work.
Any use of the Licensed Work in violation of this License will automatically
terminate your rights under this License for the current and all other
versions of the Licensed Work.
This License does not grant you any right in any trademark or logo of
Licensor or its affiliates (provided that you may use a trademark or logo of
Licensor as expressly required by this License).
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
TITLE.

View file

@ -0,0 +1,36 @@
---
name: beads-task-runner
description: Pick up a single beads task and attempt to execute it within strict rails (read-only filesystem outside scratch, bd note/update/close only).
model: sonnet
tools: Read, Grep, Glob, Bash
---
You are the beads-task-runner. The prompt gives you a single `<task_id>` (e.g. `code-abc`) and a `<job_id>` — read them from the prompt body.
## Invariant rails — violate any, stop immediately
- **Scratch directory**: `/workspace/scratch/<job_id>/`. You MAY read/write here. You MAY NOT write anywhere else.
- **Beads DB flag**: every `bd` call MUST include `--db /workspace/.beads`.
- **Allowed `bd` verbs**: `show`, `list`, `note`, `update`, `close`. Nothing else.
- **Allowed shell**: `ls`, `cat`, `head`, `tail`, `grep`, `find`, `git log`, `git status`, `git diff`, `git show`, `jq`. All read-only.
- **Forbidden shell**: `git push`, `git commit`, `git checkout`, `git reset`, any `kubectl … apply|edit|patch|delete|scale|rollout`, any `helm … install|upgrade|uninstall|rollback`, any `terraform|terragrunt … apply|destroy|import|state`, any write to `/workspace/infra/**` or other repo paths, any `sudo`, `curl -X POST|PUT|DELETE`, `ssh`, `scp`.
- **No interactive shells**, no `vim`, no REPLs, no heredoc-authored scripts that edit files outside scratch.
- **No remote writes**: do not push to git remotes, do not create PRs, do not call write-side APIs.
## Required workflow
1. **Claim**: first action, always — `bd --db /workspace/.beads note <task_id> "claimed by agent <job_id>"`.
2. **Read**: `bd --db /workspace/.beads show <task_id>` — read title, description, acceptance criteria.
3. **Triage rails**:
- If description or acceptance requires code edits, infra changes, `apply`, `destroy`, schema migrations, or anything outside the allowed shell above: `bd --db /workspace/.beads update <task_id> --status blocked` and `bd --db /workspace/.beads note <task_id> "blocked: out of rails — <reason>"`. Stop.
- If the task is pure research, investigation, status checking, or documentation writing into scratch only: proceed.
4. **Execute**: do the work using only the allowed verbs. Checkpoint progress with `bd --db /workspace/.beads note <task_id> "<progress>"` as often as useful.
5. **Verify**: cross-check acceptance criteria before closing. If unmet, do NOT close.
6. **Finish** (exactly one of):
- Success: `bd --db /workspace/.beads close <task_id> -r "completed by agent <job_id>"`.
- Blocked: `bd --db /workspace/.beads update <task_id> --status blocked` + explanatory note.
- Giving up without blocking: leave `in_progress`, add a final `bd note` summarising where you stopped and why. The orchestrator decides next.
## Output contract
Your last message to the harness must summarise: task id, final status (closed / blocked / still in_progress), notes added (count), and any rail violations you refused. Do not invent work you didn't do. Do not claim success without running the close command.

0
app/__init__.py Normal file
View file

136
app/main.py Normal file
View file

@ -0,0 +1,136 @@
import asyncio
import hmac
import os
import uuid
from datetime import datetime, timezone
from subprocess import PIPE
from fastapi import FastAPI, HTTPException, Header
from pydantic import BaseModel
app = FastAPI(title="Claude Agent Service")
API_TOKEN = os.environ.get("API_BEARER_TOKEN", "")
WORKSPACE_DIR = os.environ.get("WORKSPACE_DIR", "/workspace/infra")
jobs: dict[str, dict] = {}
execution_lock = asyncio.Lock()
class ExecuteRequest(BaseModel):
prompt: str
agent: str
max_budget_usd: float = 5.0
timeout_seconds: int = 2700
metadata: dict | None = None
def verify_token(authorization: str | None):
# Reject everything when the service is unconfigured. compare_digest("", "")
# returns True, so without this guard an empty API_TOKEN would happily
# accept an empty header.
if not API_TOKEN:
raise HTTPException(status_code=401, detail="Service unauthenticated")
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing bearer token")
token = authorization.removeprefix("Bearer ")
if not hmac.compare_digest(token, API_TOKEN):
raise HTTPException(status_code=401, detail="Invalid token")
async def run_git_sync():
proc = await asyncio.create_subprocess_exec(
"git", "pull", "--rebase",
cwd=WORKSPACE_DIR,
stdout=PIPE, stderr=PIPE,
)
await proc.wait()
async def run_agent(job_id: str, request: ExecuteRequest):
try:
await run_git_sync()
cmd = [
"claude", "-p",
"--agent", request.agent,
"--dangerously-skip-permissions",
"--max-budget-usd", str(request.max_budget_usd),
"--output-format", "json",
request.prompt,
]
proc = await asyncio.create_subprocess_exec(
*cmd,
cwd=WORKSPACE_DIR,
stdout=PIPE,
stderr=PIPE,
)
output_lines = []
async for line in proc.stdout:
output_lines.append(line.decode())
stderr = await proc.stderr.read()
await proc.wait()
jobs[job_id].update({
"status": "completed" if proc.returncode == 0 else "failed",
"exit_code": proc.returncode,
"output": output_lines,
"stderr": stderr.decode(),
"finished_at": datetime.now(timezone.utc).isoformat(),
})
except asyncio.TimeoutError:
jobs[job_id].update({"status": "timeout"})
except Exception as exc:
jobs[job_id].update({"status": "error", "error": str(exc)})
finally:
execution_lock.release()
@app.get("/health")
async def health():
return {"status": "ok", "busy": execution_lock.locked()}
@app.post("/execute", status_code=202)
async def execute(
request: ExecuteRequest,
authorization: str | None = Header(default=None),
):
verify_token(authorization)
if execution_lock.locked():
raise HTTPException(status_code=409, detail="Agent is busy")
await execution_lock.acquire()
job_id = uuid.uuid4().hex[:12]
jobs[job_id] = {
"status": "running",
"prompt": request.prompt,
"agent": request.agent,
"started_at": datetime.now(timezone.utc).isoformat(),
"metadata": request.metadata,
}
asyncio.create_task(
asyncio.wait_for(
run_agent(job_id, request),
timeout=request.timeout_seconds,
)
)
return {"job_id": job_id, "status": "running"}
@app.get("/jobs/{job_id}")
async def get_job(
job_id: str,
authorization: str | None = Header(default=None),
):
verify_token(authorization)
if job_id not in jobs:
raise HTTPException(status_code=404, detail="Job not found")
return jobs[job_id]

10
beads/metadata.json Normal file
View file

@ -0,0 +1,10 @@
{
"database": "dolt",
"backend": "dolt",
"dolt_mode": "server",
"dolt_server_host": "dolt.beads-server.svc.cluster.local",
"dolt_server_port": 3306,
"dolt_server_user": "beads",
"dolt_database": "code",
"project_id": "a8f8bae7-ce65-4145-a5db-a13d11d297da"
}

4
requirements-dev.txt Normal file
View file

@ -0,0 +1,4 @@
-r requirements.txt
pytest>=8.0.0
pytest-asyncio>=0.24.0
httpx>=0.27.0

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
fastapi>=0.115.0
uvicorn>=0.34.0

0
tests/__init__.py Normal file
View file

4
tests/conftest.py Normal file
View file

@ -0,0 +1,4 @@
import os
os.environ.setdefault("API_BEARER_TOKEN", "test-token")
os.environ.setdefault("WORKSPACE_DIR", "/tmp/test-workspace")

214
tests/test_main.py Normal file
View file

@ -0,0 +1,214 @@
import asyncio
from unittest.mock import AsyncMock, patch, MagicMock
import pytest
from httpx import ASGITransport, AsyncClient
from app import main as app_main
from app.main import ExecuteRequest, app
def test_execute_request_default_timeout_is_45_minutes():
# The service-upgrade agent can take 20-45m for CAUTION-class bumps
# (multi-release changelog summarisation, Woodpecker CI polling, DB
# backup waits). 15m cuts off too many real runs — see beads code-cfy.
assert ExecuteRequest(prompt="p", agent="a").timeout_seconds == 2700
@pytest.fixture
def auth_header():
return {"Authorization": "Bearer test-token"}
@pytest.mark.asyncio
async def test_health_returns_ok():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
@pytest.mark.asyncio
async def test_execute_rejects_missing_auth():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post("/execute", json={
"prompt": "test",
"agent": "test-agent",
})
assert response.status_code == 401
@pytest.mark.asyncio
async def test_execute_rejects_wrong_token():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/execute",
json={"prompt": "test", "agent": "test-agent"},
headers={"Authorization": "Bearer wrong-token"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_execute_rejects_missing_prompt(auth_header):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/execute",
json={"agent": "test-agent"},
headers=auth_header,
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_execute_starts_job(auth_header):
mock_process = AsyncMock()
mock_process.stdout = AsyncMock()
mock_process.stdout.__aiter__ = MagicMock(return_value=iter([]))
mock_process.stderr = AsyncMock()
mock_process.stderr.read = AsyncMock(return_value=b"")
mock_process.wait = AsyncMock(return_value=0)
mock_process.returncode = 0
with patch("app.main.asyncio.create_subprocess_exec", return_value=mock_process):
with patch("app.main.run_git_sync", new_callable=AsyncMock):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/execute",
json={"prompt": "test prompt", "agent": "test-agent"},
headers=auth_header,
)
assert response.status_code == 202
body = response.json()
assert "job_id" in body
assert body["status"] == "running"
@pytest.mark.asyncio
async def test_get_job_not_found(auth_header):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/jobs/nonexistent", headers=auth_header)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_execute_stores_metadata_on_job(auth_header):
mock_process = AsyncMock()
mock_process.stdout = AsyncMock()
mock_process.stdout.__aiter__ = MagicMock(return_value=iter([]))
mock_process.stderr = AsyncMock()
mock_process.stderr.read = AsyncMock(return_value=b"")
mock_process.wait = AsyncMock(return_value=0)
mock_process.returncode = 0
metadata = {"task_id": "code-xyz", "source": "beadboard"}
with patch("app.main.asyncio.create_subprocess_exec", return_value=mock_process):
with patch("app.main.run_git_sync", new_callable=AsyncMock):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/execute",
json={
"prompt": "test prompt",
"agent": "beads-task-runner",
"metadata": metadata,
},
headers=auth_header,
)
assert response.status_code == 202
job_id = response.json()["job_id"]
job_response = await client.get(f"/jobs/{job_id}", headers=auth_header)
assert job_response.status_code == 200
assert job_response.json()["metadata"] == metadata
@pytest.mark.asyncio
async def test_execute_respects_sequential_lock(auth_header):
hold_event = asyncio.Event()
release_event = asyncio.Event()
async def slow_subprocess(*args, **kwargs):
mock = AsyncMock()
mock.stdout = AsyncMock()
async def slow_iter():
hold_event.set()
await release_event.wait()
return
yield # noqa: F841 - unreachable yield makes this an async generator
mock.stdout.__aiter__ = MagicMock(side_effect=slow_iter)
mock.stderr = AsyncMock()
mock.stderr.read = AsyncMock(return_value=b"")
mock.wait = AsyncMock(return_value=0)
mock.returncode = 0
return mock
with patch("app.main.asyncio.create_subprocess_exec", side_effect=slow_subprocess):
with patch("app.main.run_git_sync", new_callable=AsyncMock):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
task1 = asyncio.create_task(client.post(
"/execute",
json={"prompt": "first", "agent": "agent1"},
headers=auth_header,
))
await hold_event.wait()
response2 = await client.post(
"/execute",
json={"prompt": "second", "agent": "agent2"},
headers=auth_header,
)
assert response2.status_code == 409
release_event.set()
response1 = await task1
assert response1.status_code == 202
@pytest.mark.asyncio
async def test_execute_rejects_empty_api_token_header():
# When the service is booted without an API_BEARER_TOKEN (misconfiguration),
# every request must be rejected — including requests with an empty Bearer
# header. Without the guard, hmac.compare_digest("", "") would return True.
with patch.object(app_main, "API_TOKEN", ""):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/execute",
json={"prompt": "test", "agent": "test-agent"},
headers={"Authorization": "Bearer "},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_execute_accepts_correct_bearer_token():
mock_process = AsyncMock()
mock_process.stdout = AsyncMock()
mock_process.stdout.__aiter__ = MagicMock(return_value=iter([]))
mock_process.stderr = AsyncMock()
mock_process.stderr.read = AsyncMock(return_value=b"")
mock_process.wait = AsyncMock(return_value=0)
mock_process.returncode = 0
with patch.object(app_main, "API_TOKEN", "secret"):
with patch("app.main.asyncio.create_subprocess_exec", return_value=mock_process):
with patch("app.main.run_git_sync", new_callable=AsyncMock):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/execute",
json={"prompt": "test", "agent": "test-agent"},
headers={"Authorization": "Bearer secret"},
)
assert response.status_code == 202