Initial extraction from monorepo
This commit is contained in:
commit
6fa60fdd1a
14 changed files with 637 additions and 0 deletions
6
.dockerignore
Normal file
6
.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.venv/
|
||||
tests/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
requirements-dev.txt
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.hypothesis/
|
||||
*.egg-info/
|
||||
vault
|
||||
41
.woodpecker.yml
Normal file
41
.woodpecker.yml
Normal 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
83
Dockerfile
Normal 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
92
LICENSE.txt
Normal 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.
|
||||
36
agents/beads-task-runner.md
Normal file
36
agents/beads-task-runner.md
Normal 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
0
app/__init__.py
Normal file
136
app/main.py
Normal file
136
app/main.py
Normal 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
10
beads/metadata.json
Normal 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
4
requirements-dev.txt
Normal 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
2
requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fastapi>=0.115.0
|
||||
uvicorn>=0.34.0
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
4
tests/conftest.py
Normal file
4
tests/conftest.py
Normal 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
214
tests/test_main.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue