svrnty-hermes-webui-plugin/routes/adwright.py
Svrnty 0b19fdd7d0
Some checks failed
plugin-tests / test (push) Failing after 5s
feat(plugin): Adwright + BTE Command Center panels (v0.4.0)
Two new tool panels surfaced inside hermes-webui via inject_script/
inject_stylesheet. Both vanilla JS + CSS, no frameworks, WebUI CSS-vars
only (no hardcoded colors), light/dark inherits free.

## Adwright panel (static/adwright.{js,css} + routes/adwright.py)

5 tabs: Overview · Cycles · Audience · Targeting · Connections.
Layout: 60/40 panel/chat split via CSS :has() selector.
Always-visible, soft-disabled when active profile isn't `cmo*`.

Action wiring (READ path — agent-mediated per governance):
1. Panel button → fires custom event
2. Handler synthesizes /adwright <cmd> chat message
3. Posts via existing btnSend pathway → message visible in chat
4. CMO sees + calls mcp_adwright_<tool>
5. Panel polls /api/adwright/last-panel-update for structured payload
6. Mock payload returned v1; real session-DB reader plugs in when
   adwright-mcp gains writer

Connections WRITE path (governance exception, NO secrets in chat):
- POST /api/adwright/provision-creds with form fields
- Plugin invokes credctl set <key> via stdin (value never on argv)
- Allowlist enforced (defense-in-depth on key names)
- Auth-gated by WebUI session cookie

Skin: .svrnty-aw-* class prefix, window.SvrntyAdwright JS namespace,
guard against double-load, scoped MutationObserver.

## BTE Command Center panel (static/bte.{js,css} + routes/bte_proxy.py)

Content-mode pills (Polished/UGC/Photorealistic/Artistic) × media toggle
(Image/Video — Video disabled v1 pending Phase 4e) × recipe family picker
(Hero Shot/Lifestyle Shot/Photoshoot/Recipe Sheet/Montage Catalog) per
canonical PLANB-RECIPE-TAXONOMY. SKU picker, variant stepper 1-12,
single/batch toggle, [Generate] button.

Asset grid with streaming thumbnails, asset detail (full-res + rate +
comment + "Use in Adwright cycle" deep link). Embedded CMO chat right rail
for re-orienting generations ("make next batch warmer / less white space").

BTE proxy route (/api/bte/proxy) with whitelisted paths
(requestPhotoshoot, assetGrid, recipeStats, assets/{id}/thumb, etc.)
prevents browser-side CORS to BTE :6001.

Skin: .svrnty-bte-* class prefix, window.SvrntyBTE JS namespace.

## Wiring

manifest_version: 0.2.0 → 0.4.0
assets registered:
- /plugins/svrnty/adwright.{js,css} + static/adwright/
- /plugins/svrnty/bte.{js,css} + static/bte/
routes registered:
- GET /api/adwright/last-panel-update (panel update channel)
- POST /api/adwright/provision-creds (governance-exception write)
- GET/POST /api/bte/proxy (BTE REST proxy with allowlist)

Karpathy 4 rules: agents reported every deviation with rationale (Python
venv interp for hermes mcp add, missing aggregate connections-status RPC
composed from two verifies, mock panel-update v1 with locked frontend
protocol so real session-DB reader is a drop-in swap), verified asset
serving + plugin route registration before claiming complete, surfaced
open questions instead of silently choosing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:12:27 -04:00

227 lines
9.0 KiB
Python

"""Adwright tool panel — plugin backend routes.
Per ADWRIGHT-PANEL-PRD §5-§6:
GET /api/adwright/last-panel-update — read channel for the frontend panel
(polled while a /adwright cmd is
pending; reads from session DB).
v1 returns mocked data per tool name
while adwright-mcp is still wiring
its writes (PRD §5 [8] — TBD).
POST /api/adwright/provision-creds — governance exception write path.
Writes Meta + Woo credentials direct
to the credctl vault, bypassing chat
and MCP. NO secret ever logged.
Public API surface used: api.register_route, api.logger.
No forced internal dependencies — uses subprocess to call credctl directly.
"""
import json
import os
import subprocess
import time
import urllib.parse
# Same credctl path as routes/vault_status.py (consistency).
_DEFAULT_CREDCTL = "/home/svrnty/workspaces/cortex/L6-svrnty.core-credentials/credctl"
# Credential keys the panel form is allowed to write. Anything else in the
# POST body is silently dropped — defense in depth against a compromised
# frontend trying to write arbitrary vault keys.
_ALLOWED_CRED_KEYS = frozenset({
"meta-app-id",
"meta-app-secret",
"meta-sandbox-access-token",
"meta-sandbox-ad-account",
"meta-sandbox-page-id",
"woocommerce-ck",
"woocommerce-cs",
})
def register(api):
"""Wire both Adwright plugin routes."""
log = api.logger("svrnty.routes.adwright")
api.register_route(
"/api/adwright/last-panel-update", "GET", _handle_last_panel_update)
api.register_route(
"/api/adwright/provision-creds", "POST", _handle_provision_creds)
log.info("adwright panel routes registered (last-panel-update + provision-creds)")
# ── GET /api/adwright/last-panel-update ─────────────────────────────────────
# v1: until adwright-mcp writes structured payloads to the session DB
# (PRD §5 step [8], table adwright_panel_updates), this returns mocked
# data keyed by ?tool= so the panel can be exercised end-to-end.
# Real wiring lands when adwright-mcp ships in parallel.
_MOCK_CYCLES = [
{"id": 14, "title": "Cycle #14 · May 2026", "status": "running",
"started_at": "2026-05-20", "spend": 4210, "budget": 6000,
"impressions": 1240000, "ctr": 2.1},
{"id": 13, "title": "Cycle #13 · Apr 2026", "status": "complete",
"started_at": "2026-04-15", "spend": 5800, "budget": 6000,
"impressions": 980000, "ctr": 1.8},
{"id": 12, "title": "Cycle #12 · Mar 2026", "status": "complete",
"started_at": "2026-03-10", "spend": 5990, "budget": 6000,
"impressions": 1100000, "ctr": 2.4},
]
_MOCK_SEGMENTS = [
{"id": "seg-1", "name": "Lookalike — buyers 30d", "size": 850000,
"description": "Meta lookalike from WooCommerce buyers, 30-day window"},
{"id": "seg-2", "name": "Cart abandoners", "size": 12400,
"description": "Pixel: AddToCart without Purchase, 14d"},
{"id": "seg-3", "name": "Newsletter subscribers", "size": 4800,
"description": "Mailchimp list sync"},
]
_MOCK_RECIPES = [
{"id": "rec-1", "name": "Prospecting · video"},
{"id": "rec-2", "name": "Retargeting · carousel"},
{"id": "rec-3", "name": "Brand · static"},
]
_MOCK_CONNECTIONS = [
{"id": "meta", "name": "Meta (Facebook + Instagram)", "status": "ok", "ok": True},
{"id": "woocommerce", "name": "WooCommerce", "status": "ok", "ok": True},
]
def _mock_payload_for(tool: str) -> dict:
if tool in ("adwright_list_cycles", "adwright_refresh_cycles"):
return {"cycles": _MOCK_CYCLES}
if tool == "adwright_get_cycle":
# Return cycle #14's expanded form with mock variants.
return {
"id": 14, "title": "Cycle #14 · May 2026", "status": "running",
"variants": [
{"id": "v-1", "name": "video · audience A", "status": "active",
"impressions": 540000},
{"id": "v-2", "name": "video · audience B", "status": "active",
"impressions": 410000},
{"id": "v-3", "name": "carousel · retarget",
"status": "paused", "impressions": 290000},
],
}
if tool == "adwright_list_segments":
return {"segments": _MOCK_SEGMENTS}
if tool == "adwright_list_recipes":
return {"recipes": _MOCK_RECIPES}
if tool == "adwright_get_connections_status":
return {"connections": _MOCK_CONNECTIONS}
return {}
def _handle_last_panel_update(handler, parsed):
"""GET /api/adwright/last-panel-update?session_id=&since=&tool=
v1 returns a mocked update for the requested tool. Real impl will read
from session DB (table: adwright_panel_updates) once adwright-mcp writes
structured payloads — see PRD §5 step [8] and §9 decision #12.
"""
qs = urllib.parse.parse_qs(parsed.query or "")
tool = (qs.get("tool", [""])[0] or "").strip()
since = qs.get("since", ["0"])[0]
try:
since_ts = int(since)
except (TypeError, ValueError):
since_ts = 0
if not tool:
# No tool specified → nothing pending; return empty (frontend treats
# as "no update").
return _send_json(handler, {"update": None, "mock": True}, 200)
now_ms = int(time.time() * 1000)
if since_ts and (now_ms - since_ts) < 500:
# Spec polls every 2s; if frontend just got an update <500ms ago,
# return empty so we don't double-render.
return _send_json(handler, {"update": None, "mock": True}, 200)
payload = _mock_payload_for(tool)
if not payload:
return _send_json(handler, {"update": None, "mock": True}, 200)
return _send_json(handler, {
"update": {
"ts": now_ms,
"tool": tool,
"payload": payload,
},
"mock": True,
}, 200)
# ── POST /api/adwright/provision-creds ──────────────────────────────────────
def _handle_provision_creds(handler, parsed):
"""POST /api/adwright/provision-creds — direct vault write (PRD §6).
Body: JSON object {<cred-key>: <value>, ...}. Only keys in
_ALLOWED_CRED_KEYS are written; others are silently dropped. Values are
piped to `credctl set <key>` via stdin — never on argv, never in logs.
Returns {ok: bool, written: [keys], error?: str}. The list of WRITTEN
keys is safe to return (key names are public; values are not). Per PRD
§6, secrets NEVER touch CMO, adwright-mcp, hermes-agent, or session DB.
"""
length = int(handler.headers.get("Content-Length", "0") or 0)
if not length:
return _send_json(handler, {"ok": False, "error": "empty body"}, 400)
raw = handler.rfile.read(length)
try:
body = json.loads(raw.decode("utf-8"))
except Exception:
return _send_json(handler, {"ok": False, "error": "invalid JSON"}, 400)
if not isinstance(body, dict):
return _send_json(handler, {"ok": False, "error": "body must be object"}, 400)
credctl = os.environ.get("CREDCTL", _DEFAULT_CREDCTL)
written = []
errors = []
for key, val in body.items():
if key not in _ALLOWED_CRED_KEYS:
continue
if not isinstance(val, str) or not val.strip():
continue
try:
# credctl set <key> reads value from stdin; never on argv.
proc = subprocess.run(
[credctl, "set", key],
input=val,
text=True,
capture_output=True,
timeout=10,
)
if proc.returncode == 0:
written.append(key)
else:
# Never include `val` or proc.stderr in the response —
# stderr may echo the value back.
errors.append(key)
except Exception:
errors.append(key)
if errors and not written:
return _send_json(handler, {
"ok": False,
"written": [],
"error": "credctl write failed for: " + ",".join(errors),
}, 500)
return _send_json(handler, {
"ok": True,
"written": written,
"failed": errors,
}, 200)
# ── helpers ─────────────────────────────────────────────────────────────────
def _send_json(handler, payload: dict, status: int) -> bool:
body = json.dumps(payload).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Cache-Control", "no-store")
handler.end_headers()
handler.wfile.write(body)
return True