From 162b27e63142ae962d8c2679910a636ecc266e3b Mon Sep 17 00:00:00 2001 From: coreci-coo Date: Sat, 3 Oct 2026 17:17:40 -0400 Subject: [PATCH] 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) --- .gitignore | 2 + README.md | 13 ++ allowlist.yaml | 2 +- app/Dockerfile | 10 ++ app/main.py | 227 +++++++++++++++++++++++++++++++++ app/requirements.txt | 4 + compose.yaml | 10 +- docs/approval-ui-spec.md | 19 +++ docs/openwebui-registration.md | 15 +++ docs/runbook.md | 29 +++++ executor.env.example | 2 +- logs/.gitkeep | 0 12 files changed, 326 insertions(+), 7 deletions(-) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/Dockerfile create mode 100644 app/main.py create mode 100644 app/requirements.txt create mode 100644 docs/approval-ui-spec.md create mode 100644 docs/openwebui-registration.md create mode 100644 docs/runbook.md create mode 100644 logs/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d50a09f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..50cfc1b --- /dev/null +++ b/README.md @@ -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. diff --git a/allowlist.yaml b/allowlist.yaml index 9cfb2c4..ee78d4d 100644 --- a/allowlist.yaml +++ b/allowlist.yaml @@ -12,4 +12,4 @@ agents: kinds: bash_pack: auto_run_targets: - - coreci/executor-config \ No newline at end of file + - coreci/executor-config diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..df2db17 --- /dev/null +++ b/app/Dockerfile @@ -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"] diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..dc25011 --- /dev/null +++ b/app/main.py @@ -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"{title}" + f"" + f"{body}") + +@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"{r['id']}{r['agent']}" + f"{','.join(r['targets'])}{r['status']}{r['decision']}" + for r in sorted(REQS.values(), key=lambda x: x["submitted_at"], reverse=True)[:50]) + return HTMLResponse(page("executor", f"

coreci-executor — queue

" + f"" + f"{rows}
idagenttargetsstatusdecision
")) + +@app.get("/ui/login") +def login(): + return HTMLResponse(page("login", "

executor — sign in

" + "
" + "
")) + +@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"
" + f"
" + if r["status"] == "pending_approval" else "") + return HTMLResponse(page(rid, f"

{rid} — {r['status']}

" + f"

agent {r['agent']} | kind {r['kind']} | targets {','.join(r['targets'])} " + f"| decision {r['decision']}

{esc(r['justification'])}

" + f"

pack

{esc(r['content'])}
" + f"

output

{esc(r.get('output', '(pending)'))}
{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"

audit — today

{esc(txt)}
")) diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..fe1501f --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.* +uvicorn==0.32.* +pyyaml==6.* +httpx==0.27.* diff --git a/compose.yaml b/compose.yaml index 4a110d3..c70f740 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: {} \ No newline at end of file + executor-work: {} diff --git a/docs/approval-ui-spec.md b/docs/approval-ui-spec.md new file mode 100644 index 0000000..6e23154 --- /dev/null +++ b/docs/approval-ui-spec.md @@ -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). diff --git a/docs/openwebui-registration.md b/docs/openwebui-registration.md new file mode 100644 index 0000000..2c3bd63 --- /dev/null +++ b/docs/openwebui-registration.md @@ -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://: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. diff --git a/docs/runbook.md b/docs/runbook.md new file mode 100644 index 0000000..066b020 --- /dev/null +++ b/docs/runbook.md @@ -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. diff --git a/executor.env.example b/executor.env.example index fcdc350..40d64fa 100644 --- a/executor.env.example +++ b/executor.env.example @@ -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 \ No newline at end of file +REQUEST_TIMEOUT_S=900 diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29