Files
executor-config/app/main.py
T

228 lines
10 KiB
Python

"""coreci-executor v0.1.1 — governed write rail (Design v1.0, approved 2026-10-03).
Tool endpoints (X-Executor-Key auth, for Open WebUI Global Tool Server):
POST /submit_request {agent, kind, targets[], content, justification}
GET /request_status?request_id=...
GET /health
Web UI (passcode session): / (queue), /ui/request/{id}, /ui/audit,
/ui/login, POST /ui/request/{id}/approve|deny.
Policy: allowlist.yaml (fail-closed NEEDS_APPROVAL; AUTO_RUN for agent's own
home-repo packs). Packs must be credential-free; auth is injected at run time.
Audit: JSONL to logs/ (+ optional Discord webhook). Secrets stay in env.
"""
import hashlib
import json
import os
import re
import shutil
import subprocess
import tempfile
import threading
import uuid
from datetime import datetime, timezone
from html import escape as esc
from pathlib import Path
import httpx
import yaml
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
APP_DIR = Path(__file__).resolve().parent
ENV = os.environ
API_KEY = ENV["EXECUTOR_API_KEY"]
PASSCODE = ENV["EXECUTOR_PASSCODE"]
GITEA_URL = ENV.get("GITEA_URL", "https://git.coreci.dev")
GITEA_HOST = GITEA_URL.split("://", 1)[-1]
GITEA_USER = ENV.get("GITEA_USER", "coreci-executor")
GITEA_TOKEN = ENV["GITEA_TOKEN"]
DISCORD_URL = ENV.get("DISCORD_WEBHOOK_URL", "")
ALLOWLIST_PATH = ENV.get("ALLOWLIST_PATH", "/config/allowlist.yaml")
WORK_ROOT = Path(ENV.get("WORK_ROOT", "/work"))
AUDIT_DIR = APP_DIR.parent / "logs"
MAX_TAIL = int(ENV.get("MAX_OUTPUT_TAIL", "4000"))
TIMEOUT_S = int(ENV.get("REQUEST_TIMEOUT_S", "900"))
COOKIE_NAME = "exec_session"
CRED_RE = re.compile(r"https?://[^/\s@]+:[^@\s]+@")
app = FastAPI(title="coreci-executor", version="0.1.1")
REQS: dict[str, dict] = {}
LOCK = threading.Lock()
def now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def get_req(rid: str) -> dict:
r = REQS.get(rid)
if r is None:
raise HTTPException(404, "no such request")
return r
def audit(evt: str, r: dict, **extra) -> None:
AUDIT_DIR.mkdir(parents=True, exist_ok=True)
rec = {"ts": now(), "event": evt, "id": r.get("id"),
"agent": r.get("agent"), "kind": r.get("kind"),
"targets": r.get("targets"), "decision": r.get("decision"),
"actor": extra.pop("actor", None), **extra}
line = json.dumps(rec, default=str)
(AUDIT_DIR / f"audit-{datetime.now(timezone.utc):%Y-%m-%d}.jsonl").open("a").write(line + "\n")
if DISCORD_URL:
try:
httpx.post(DISCORD_URL, json={"content": f"[executor] {evt}: {line[:1500]}"}, timeout=5)
except Exception:
pass
def classify(agent: str, kind: str, targets: list) -> str:
cfg = yaml.safe_load(Path(ALLOWLIST_PATH).read_text()) or {}
ag = (cfg.get("agents") or {}).get(agent) or {}
auto = ((ag.get("kinds") or {}).get(kind) or {}).get("auto_run_targets") or []
return "AUTO_RUN" if targets and all(t in auto for t in targets) else "NEEDS_APPROVAL"
def run_pack(rid: str) -> None:
r = REQS[rid]
r["status"] = "running"; audit("started", r)
home = Path(tempfile.mkdtemp(dir=str(WORK_ROOT), prefix="req-"))
(home / ".git-credentials").write_text(
f"https://{GITEA_USER}:{GITEA_TOKEN}@{GITEA_HOST}\n")
script = "git config --global credential.helper store\n" + r["content"]
env = {"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": str(home),
"GIT_TERMINAL_PROMPT": "0", "GITEA_URL": GITEA_URL,
"GITEA_TOKEN": GITEA_TOKEN, "GITEA_USER": GITEA_USER, "TZ": "UTC"}
try:
p = subprocess.run(["bash", "-c", script], capture_output=True,
text=True, timeout=TIMEOUT_S, cwd=home, env=env)
tail = (p.stdout[-MAX_TAIL:] + "\n" + p.stderr[-MAX_TAIL:]).strip()
r.update(status="completed" if p.returncode == 0 else "failed",
exit_code=p.returncode,
output=tail.replace(GITEA_TOKEN, "***"), finished_at=now())
audit("completed" if p.returncode == 0 else "failed", r, exit_code=p.returncode)
except subprocess.TimeoutExpired:
r.update(status="failed", output="TIMEOUT", finished_at=now()); audit("failed", r, error="timeout")
except Exception as e:
r.update(status="failed", output=str(e)[:MAX_TAIL], finished_at=now()); audit("failed", r, error=str(e)[:200])
finally:
try:
(home / ".git-credentials").unlink()
except Exception:
pass
shutil.rmtree(home, ignore_errors=True)
def session_ok(request: Request) -> bool:
return request.cookies.get(COOKIE_NAME) == hashlib.sha256(PASSCODE.encode()).hexdigest()
def page(title: str, body: str) -> str:
return (f"<!doctype html><html><head><meta charset='utf-8'><title>{title}"
f"</title><style>body{{font-family:system-ui;margin:2rem;max-width:60rem}}"
f"pre{{background:#f4f4f4;padding:1rem;overflow:auto}}"
f"button{{padding:.4rem 1rem}}</style></head>"
f"<body>{body}</body></html>")
@app.get("/health")
def health():
return {"status": "ok", "version": "0.1.1", "requests": len(REQS)}
@app.post("/submit_request")
async def submit(request: Request):
if request.headers.get("X-Executor-Key") != API_KEY:
raise HTTPException(401, "bad or missing X-Executor-Key")
d = await request.json()
agent, kind = d.get("agent", ""), d.get("kind", "bash_pack")
targets = d.get("targets") or []
content, just = d.get("content", ""), d.get("justification", "")
if kind != "bash_pack":
raise HTTPException(400, "v0 supports kind=bash_pack only")
if not (agent and content and just):
raise HTTPException(400, "agent, content, justification required")
if not isinstance(targets, list) or not targets or not all(isinstance(t, str) for t in targets):
raise HTTPException(400, "targets must be a non-empty list of repo names")
if CRED_RE.search(content):
raise HTTPException(400, "credential-free packs only (embedded user:pass rejected)")
rid = uuid.uuid4().hex[:12]
r = {"id": rid, "agent": agent, "kind": kind, "targets": targets,
"content": content, "justification": just, "submitted_at": now(),
"status": "pending_approval", "decision": classify(agent, kind, targets)}
with LOCK:
REQS[rid] = r
audit("submitted", r)
if r["decision"] == "AUTO_RUN":
r["status"] = "queued"
threading.Thread(target=run_pack, args=(rid,), daemon=True).start()
return {"request_id": rid, "status": r["status"], "decision": r["decision"]}
@app.get("/request_status")
def status(request_id: str, request: Request):
if request.headers.get("X-Executor-Key") != API_KEY and not session_ok(request):
raise HTTPException(401, "unauthorized")
r = get_req(request_id)
return {k: r.get(k) for k in ("id", "agent", "kind", "targets", "decision",
"status", "exit_code", "output", "submitted_at", "finished_at")}
@app.get("/")
def root(request: Request):
if not session_ok(request):
return RedirectResponse("/ui/login")
rows = "".join(
f"<tr><td><a href='/ui/request/{r['id']}'>{r['id']}</a></td><td>{r['agent']}</td>"
f"<td>{','.join(r['targets'])}</td><td>{r['status']}</td><td>{r['decision']}</td></tr>"
for r in sorted(REQS.values(), key=lambda x: x["submitted_at"], reverse=True)[:50])
return HTMLResponse(page("executor", f"<h1>coreci-executor — queue</h1>"
f"<table border='1' cellpadding='6'><tr><th>id</th><th>agent</th><th>targets</th>"
f"<th>status</th><th>decision</th></tr>{rows}</table>"))
@app.get("/ui/login")
def login():
return HTMLResponse(page("login", "<h1>executor — sign in</h1>"
"<form method='post' action='/ui/login'><input type='password' name='p' autofocus>"
" <button>unlock</button></form>"))
@app.post("/ui/login")
async def login_post(request: Request):
form = await request.form()
if form.get("p") != PASSCODE:
raise HTTPException(401, "wrong passcode")
resp = RedirectResponse("/", status_code=303)
resp.set_cookie(COOKIE_NAME, hashlib.sha256(PASSCODE.encode()).hexdigest(),
httponly=True, samesite="strict")
return resp
@app.get("/ui/request/{rid}")
def detail(rid: str, request: Request):
if not session_ok(request):
return RedirectResponse("/ui/login")
r = get_req(rid)
act = (f"<form method='post' action='/ui/request/{rid}/approve'><button>APPROVE</button></form> "
f"<form method='post' action='/ui/request/{rid}/deny'><button>DENY</button></form>"
if r["status"] == "pending_approval" else "")
return HTMLResponse(page(rid, f"<h1>{rid} — {r['status']}</h1>"
f"<p>agent <b>{r['agent']}</b> | kind <b>{r['kind']}</b> | targets <b>{','.join(r['targets'])}</b> "
f"| decision <b>{r['decision']}</b></p><p><i>{esc(r['justification'])}</i></p>"
f"<h3>pack</h3><pre>{esc(r['content'])}</pre>"
f"<h3>output</h3><pre>{esc(r.get('output', '(pending)'))}</pre>{act}"))
@app.post("/ui/request/{rid}/approve")
def approve(rid: str, request: Request):
if not session_ok(request):
return RedirectResponse("/ui/login")
r = get_req(rid)
if r["status"] == "pending_approval":
r["status"] = "running"; r["decision"] = "APPROVED"; audit("approved", r, actor="PO")
threading.Thread(target=run_pack, args=(rid,), daemon=True).start()
return RedirectResponse(f"/ui/request/{rid}", status_code=303)
@app.post("/ui/request/{rid}/deny")
def deny(rid: str, request: Request):
if not session_ok(request):
return RedirectResponse("/ui/login")
r = get_req(rid)
if r["status"] == "pending_approval":
r["status"] = "denied"; audit("denied", r, actor="PO")
return RedirectResponse(f"/ui/request/{rid}", status_code=303)
@app.get("/ui/audit")
def audit_view(request: Request):
if not session_ok(request):
return RedirectResponse("/ui/login")
f = AUDIT_DIR / f"audit-{datetime.now(timezone.utc):%Y-%m-%d}.jsonl"
txt = f.read_text()[-20000:] if f.exists() else "(empty)"
return HTMLResponse(page("audit", f"<h1>audit — today</h1><pre>{esc(txt)}</pre>"))