executor skeleton v0.1.1: complete app/docs; Dockerfile git+curl; main.py fixes (syntax, credential injection, cleanup); prohibit_login verified (pack 2026-10-03-E2)

This commit is contained in:
2026-10-03 17:17:40 -04:00
parent 618f1c9b31
commit 162b27e631
12 changed files with 326 additions and 7 deletions
+2
View File
@@ -0,0 +1,2 @@
.env
__pycache__/
+13
View File
@@ -0,0 +1,13 @@
# coreci/executor-config — coreci-executor (Write Executor, Design v1.0)
Single governed write rail from Open WebUI agents to Gitea.
- `allowlist.yaml` — policy engine config (AUTO_RUN vs NEEDS_APPROVAL; fail-closed)
- `compose.yaml` + `executor.env.example` — deployment (secrets in .env only)
- `app/` — coreci-executor v0.1.1 (FastAPI)
- `docs/` — approval-UI spec, runbook, Open WebUI registration guide
- `logs/` — git-durable audit JSONL (one line per request event)
Governance: agent authors -> executor gates -> PO approves (NEEDS_APPROVAL) ->
executor runs as `coreci-executor` (non-admin; collaborator-write ONLY on the
repos granted in the deployment pack) -> audit + Discord notify.
Secrets never in chat, repos, or tool calls — executor env vars only.
+1 -1
View File
@@ -12,4 +12,4 @@ agents:
kinds:
bash_pack:
auto_run_targets:
- coreci/executor-config
- coreci/executor-config
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
EXPOSE 8000
CMD ["uvicorn","main:app","--host","0.0.0.0","--port","8000"]
+227
View File
@@ -0,0 +1,227 @@
"""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>"))
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.115.*
uvicorn==0.32.*
pyyaml==6.*
httpx==0.27.*
+5 -5
View File
@@ -4,14 +4,14 @@ services:
container_name: coreci-executor
restart: unless-stopped
ports:
- "127.0.0.1:8765:8000" # HAProxy proxies executor.coreci.dev → here
env_file: .env # secrets live here, never in git
- "127.0.0.1:8765:8000" # HAProxy proxies executor.coreci.dev -> here
env_file: .env
environment:
- ALLOWLIST_PATH=/config/allowlist.yaml
- WORK_ROOT=/work
volumes:
- .:/config:ro # allowlist + docs, read-only in container
- ./logs:/app/logs # audit JSONL (committed back to git)
- .:/config:ro
- ./logs:/app/logs
- executor-work:/work
volumes:
executor-work: {}
executor-work: {}
+19
View File
@@ -0,0 +1,19 @@
# Approval UI — spec (v0 as-built in app/main.py v0.1.1; v1 roadmap)
## v0 (as-built)
- Passcode-gated session cookie (EXECUTOR_PASSCODE; httpOnly; SameSite=Strict).
Intended behind HAProxy with TLS for the PVE trust domain.
- Pages: / (queue: pending + recent), /ui/request/{id} (full pack content,
agent, targets, justification, classification), /ui/audit (recent events).
- Actions: POST-only approve / deny (no GET-side effects; one-click from the
queue page; Discord notification links here).
- Queue states: pending_approval -> queued/running -> completed | failed | denied.
- Every event appended to logs/audit-*.jsonl (id, ts, agent, kind, targets,
decision, actor, outcome tail).
## v1 roadmap (post-P0, not yet built)
- Typed confirmation (click + type request-id fragment) for high-impact ops.
- HAProxy basic-auth layer in front of the passcode session.
- Diff preview for gitea_api_ops requests (planned kind v0.2).
- Session idle timeout + login throttling.
- Approver identity in audit (first approver = the PO by convention).
+15
View File
@@ -0,0 +1,15 @@
# Registering the executor as an Open WebUI Global Tool Server
1. Ensure the executor is running and reachable from the Open WebUI BACKEND
(not your browser): http://<host>:8765 or https://executor.coreci.dev
2. Admin panel -> Settings -> Admin -> Integrations -> Tools -> add Tool Server:
URL: https://executor.coreci.dev API key: EXECUTOR_API_KEY from .env
3. Per-user activation: global tools are hidden by default. In a chat, click
the Integrations button (bottom-left of input) -> Tools -> toggle
`coreci-executor` on. Each user must do this once.
4. R-1 TEST (automation persistence, run BEFORE relying on automations):
- With the tool toggled on, create a test automation that asks the model
to call `submit_request` with a harmless request.
- If the automation-spawned chat has the tool available -> R-1 PASSED.
- If NOT: fall back to interactive-session use first; automations keep the
pack-in-chat convention until Open WebUI behavior is confirmed.
+29
View File
@@ -0,0 +1,29 @@
# coreci-executor runbook
## Deploy (host, one time)
1. git clone this repo onto the PVE host.
2. cp executor.env.example .env && fill: API key, passcode, GITEA_TOKEN
(from pack output), Discord webhook (optional).
3. podman compose up -d --build (or: docker compose up -d --build)
4. Verify: curl http://127.0.0.1:8765/health -> {"status":"ok","version":"0.1.1"}
5. HAProxy: add frontend for executor.coreci.dev -> 127.0.0.1:8765.
## Kill switch
podman compose down (requests in flight abort; audit JSONL persists)
## Token rotation
Admin UI -> coreci-executor -> Applications -> delete executor-v0 ->
recreate (write:repository) -> update .env -> podman compose restart.
(Or delete the token and re-run deployment pack step [2/6]; it recreates and
prints the value once.)
## Audit
logs/audit-YYYY-MM-DD.jsonl inside this repo. Read-only verification from any
chat: GET this repo's logs/ via the Gitea API.
## First live job (P0)
Submit the credential-free re-run of the pending 09-28 PARA pack
(agent coreci-coo, kind bash_pack, target coreci-coo/home).
IMPORTANT: the pack content must be the credential-free variant —
REPO_URL="https://git.coreci.dev/coreci-coo/home.git" (no user:pass@);
the executor injects auth at run time and rejects embedded credentials.
+1 -1
View File
@@ -5,4 +5,4 @@ GITEA_USER=coreci-executor
GITEA_TOKEN=paste-token-from-pack-output-here # write:repository ONLY
DISCORD_WEBHOOK_URL= # optional: approvals + outcomes
MAX_OUTPUT_TAIL=4000
REQUEST_TIMEOUT_S=900
REQUEST_TIMEOUT_S=900
View File