commit 6fa60fdd1a8af7a2d5cd1cdbc15af7a1b9c8bf45 Author: Viktor Barzin Date: Thu May 7 17:07:12 2026 +0000 Initial extraction from monorepo diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6a1c3f1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.venv/ +tests/ +__pycache__/ +*.pyc +.pytest_cache/ +requirements-dev.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5985741 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +.venv/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.hypothesis/ +*.egg-info/ +vault diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..3e28b5f --- /dev/null +++ b/.woodpecker.yml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f6f2741 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..fbeca00 --- /dev/null +++ b/LICENSE.txt @@ -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. diff --git a/agents/beads-task-runner.md b/agents/beads-task-runner.md new file mode 100644 index 0000000..a4b3bff --- /dev/null +++ b/agents/beads-task-runner.md @@ -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 `` (e.g. `code-abc`) and a `` — read them from the prompt body. + +## Invariant rails — violate any, stop immediately + +- **Scratch directory**: `/workspace/scratch//`. 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 "claimed by agent "`. +2. **Read**: `bd --db /workspace/.beads show ` — 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 --status blocked` and `bd --db /workspace/.beads note "blocked: out of rails — "`. 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 ""` 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 -r "completed by agent "`. + - Blocked: `bd --db /workspace/.beads update --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. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..a77b370 --- /dev/null +++ b/app/main.py @@ -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] diff --git a/beads/metadata.json b/beads/metadata.json new file mode 100644 index 0000000..657b764 --- /dev/null +++ b/beads/metadata.json @@ -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" +} diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..4f3af4c --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest>=8.0.0 +pytest-asyncio>=0.24.0 +httpx>=0.27.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c557303 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +fastapi>=0.115.0 +uvicorn>=0.34.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6df2255 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,4 @@ +import os + +os.environ.setdefault("API_BEARER_TOKEN", "test-token") +os.environ.setdefault("WORKSPACE_DIR", "/tmp/test-workspace") diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..cd13a65 --- /dev/null +++ b/tests/test_main.py @@ -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