mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 18:56:26 +00:00
4f05d09c2b
Make credentials/hosts, projects, agents, and approvals manageable from the dashboard. The API and control layer are separate containers, so the API can't run control actions directly (no git/tmux/claude, doesn't own the tmux sessions). Instead the API enqueues a command and a worker in the control container executes it and writes the result back. Data model (migration 0003): - `commands` queue/audit table; `forge_hosts` registry; `approvals` gains a nullable approver id + `actor` so operator verdicts are first-class. Control worker: - `control/worker.py` claims commands and dispatches to the existing control functions (spawn/kill/resume/record_approval/write_skills/poller.sweep), plus a periodic CI sweep. New `handler worker` CLI subcommand; it becomes the control image's default command (subsumes `poll-ci --watch`). API: - `require_admin` gate + `ADMIN_TOKEN`; project GET/PATCH/DELETE; agent spawn/kill/delete; resume now enqueues (fixes a cross-container bug where the API tried to send tmux keys to a session in the control container); new approvals/commands/hosts routes; forge-init and poll-ci enqueue endpoints. Credentials/hosts: - host->token-env lookup consults the `forge_hosts` registry first (built-in map is the fallback); `resolve()` refactored to a scheme dispatch reserving `db:` for a future encrypted store. Web input restricts credential_ref to env:/file:/db: (cmd: stays CLI-only — it would run arbitrary commands). Dashboard: - New tabs for projects, agents (spawn/kill with live command-status polling), approvals, hosts, and an activity/audit view; shared context is now writable. Tests: +33 (queue atomicity, worker dispatch, CRUD, hosts, admin gating, cmd: rejection, host-aware credentials, and an API->queue->worker->spawn end-to-end). README gains a Web management section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CrhrBToauu4L2qG6jdnuFP
156 lines
5.9 KiB
Python
156 lines
5.9 KiB
Python
"""Web-management API surface: project/host CRUD, enqueue endpoints, and admin gating.
|
|
|
|
The conftest sets AUTH_TOKEN=test-token and SHARED_CONTEXT_WRITE_TOKEN=shared-token with no
|
|
ADMIN_TOKEN, so the effective admin token is the global test-token. The shared-token is a
|
|
valid-but-not-admin bearer: it passes require_auth (reads) but not require_admin (writes),
|
|
which is exactly what we use to prove the gate."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def lowpriv(env):
|
|
"""A valid bearer that is NOT the admin token (the shared-context write token)."""
|
|
return {"Authorization": f"Bearer {env['shared_token']}"}
|
|
|
|
|
|
def _mk_project(client, auth, pid="proj"):
|
|
return client.post("/projects", json={"id": pid, "root_dir": "/tmp/proj"}, headers=auth)
|
|
|
|
|
|
# --- project CRUD ---------------------------------------------------------------------
|
|
|
|
|
|
def test_get_update_delete_project(client, auth):
|
|
_mk_project(client, auth)
|
|
assert client.get("/projects/proj", headers=auth).json()["id"] == "proj"
|
|
|
|
remote = "https://github.com/me/p.git"
|
|
r = client.patch("/projects/proj", json={"git_remote": remote}, headers=auth)
|
|
assert r.status_code == 200 and r.json()["git_remote"] == remote
|
|
|
|
assert client.delete("/projects/proj", headers=auth).status_code == 200
|
|
assert client.get("/projects/proj", headers=auth).status_code == 404
|
|
|
|
|
|
def test_credential_ref_cmd_scheme_rejected(client, auth):
|
|
r = client.post(
|
|
"/projects",
|
|
json={"id": "x", "root_dir": "/tmp/x", "credential_ref": "cmd:cat /etc/passwd"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 422
|
|
# env:/file:/db: are accepted.
|
|
ok = client.post(
|
|
"/projects",
|
|
json={"id": "y", "root_dir": "/tmp/y", "credential_ref": "env:TOK"},
|
|
headers=auth,
|
|
)
|
|
assert ok.status_code == 201
|
|
|
|
|
|
def test_patch_project_cmd_scheme_rejected(client, auth):
|
|
_mk_project(client, auth)
|
|
r = client.patch("/projects/proj", json={"credential_ref": "cmd:whoami"}, headers=auth)
|
|
assert r.status_code == 422
|
|
|
|
|
|
# --- admin gating ---------------------------------------------------------------------
|
|
|
|
|
|
def test_reads_allowed_but_writes_need_admin(client, auth, lowpriv):
|
|
_mk_project(client, auth)
|
|
# low-priv token can read...
|
|
assert client.get("/projects", headers=lowpriv).status_code == 200
|
|
assert client.get("/hosts", headers=lowpriv).status_code == 200
|
|
# ...but not perform admin actions.
|
|
patch = client.patch("/projects/proj", json={"root_dir": "/x"}, headers=lowpriv)
|
|
assert patch.status_code == 403
|
|
assert client.delete("/projects/proj", headers=lowpriv).status_code == 403
|
|
host = client.post("/hosts", json={"hostname": "h", "forge_type": "gitea"}, headers=lowpriv)
|
|
assert host.status_code == 403
|
|
spawn = client.post("/projects/proj/agents/spawn", json={"name": "j"}, headers=lowpriv)
|
|
assert spawn.status_code == 403
|
|
|
|
|
|
# --- enqueue endpoints ----------------------------------------------------------------
|
|
|
|
|
|
def test_spawn_enqueues_command(client, auth):
|
|
_mk_project(client, auth)
|
|
r = client.post(
|
|
"/projects/proj/agents/spawn",
|
|
json={"name": "junior", "role": "junior", "worktree": "feat/x", "task": "do it"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 202
|
|
body = r.json()
|
|
assert body["type"] == "spawn" and body["status"] == "queued"
|
|
assert body["agent_name"] == "junior"
|
|
assert body["payload"]["role"] == "junior" and body["payload"]["worktree"] == "feat/x"
|
|
# visible on the commands feed
|
|
assert any(c["id"] == body["id"] for c in client.get("/commands", headers=auth).json())
|
|
|
|
|
|
def test_kill_enqueues_command(client, auth):
|
|
_mk_project(client, auth)
|
|
client.post(
|
|
"/projects/proj/agents",
|
|
json={"name": "api", "working_dir": "/tmp/proj/api"},
|
|
headers=auth,
|
|
)
|
|
r = client.post("/projects/proj/agents/api/kill", headers=auth)
|
|
assert r.status_code == 202 and r.json()["type"] == "kill"
|
|
|
|
|
|
def test_approval_enqueues_correct_command_type(client, auth):
|
|
_mk_project(client, auth)
|
|
r = client.post(
|
|
"/projects/proj/approvals",
|
|
json={"branch": "feat/x", "status": "rejected", "note": "nit"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 202
|
|
# verdict 'rejected' maps to command type 'reject'
|
|
assert r.json()["type"] == "reject"
|
|
assert r.json()["payload"]["branch"] == "feat/x"
|
|
|
|
|
|
def test_forge_init_and_poll_ci_enqueue(client, auth):
|
|
_mk_project(client, auth)
|
|
assert client.post("/projects/proj/forge-init", headers=auth).json()["type"] == "forge_init"
|
|
assert client.post("/projects/proj/poll-ci", headers=auth).json()["type"] == "poll_ci"
|
|
assert client.post("/poll-ci", headers=auth).json()["project_id"] is None
|
|
|
|
|
|
def test_command_status_polling(client, auth):
|
|
_mk_project(client, auth)
|
|
cmd = client.post("/projects/proj/poll-ci", headers=auth).json()
|
|
got = client.get(f"/commands/{cmd['id']}", headers=auth)
|
|
assert got.status_code == 200 and got.json()["id"] == cmd["id"]
|
|
assert client.get("/commands/999999", headers=auth).status_code == 404
|
|
|
|
|
|
# --- hosts ----------------------------------------------------------------------------
|
|
|
|
|
|
def test_host_crud(client, auth):
|
|
r = client.post(
|
|
"/hosts",
|
|
json={"hostname": "git.corp", "forge_type": "gitea", "token_env_var": "GITEA_TOKEN"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 201
|
|
assert client.get("/hosts/git.corp", headers=auth).json()["token_env_var"] == "GITEA_TOKEN"
|
|
patch = client.patch("/hosts/git.corp", json={"base_url": "https://git.corp"}, headers=auth)
|
|
assert patch.status_code == 200
|
|
assert client.delete("/hosts/git.corp", headers=auth).status_code == 200
|
|
assert client.get("/hosts/git.corp", headers=auth).status_code == 404
|
|
|
|
|
|
def test_host_bad_forge_type_422(client, auth):
|
|
r = client.post("/hosts", json={"hostname": "h", "forge_type": "svn"}, headers=auth)
|
|
assert r.status_code == 422
|