228 lines
10 KiB
Python
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>"))
|