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:
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
__pycache__/
|
||||
@@ -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
@@ -12,4 +12,4 @@ agents:
|
||||
kinds:
|
||||
bash_pack:
|
||||
auto_run_targets:
|
||||
- coreci/executor-config
|
||||
- coreci/executor-config
|
||||
|
||||
@@ -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
@@ -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>"))
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi==0.115.*
|
||||
uvicorn==0.32.*
|
||||
pyyaml==6.*
|
||||
httpx==0.27.*
|
||||
+5
-5
@@ -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: {}
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user