Some checks failed
plugin-tests / test (push) Failing after 5s
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>
227 lines
9.0 KiB
Python
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
|