"""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). v2: imports adwright_core from the adwright-mcp subdir and calls Adwright gRPC directly. Falls back to mock if import fails. 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. adwright_core import is a workspace-internal dependency on adwright-mcp/ (same hermes/ workspace; same Python venv at runtime). Documented in CONNECTION-MAP.md. """ import json import os import subprocess import sys import time import urllib.parse # Lazy-import adwright_core from the adwright-mcp subdir. Only loaded when # the panel first asks for real data. If the import fails (proto version # mismatch, adwright-mcp not present), routes fall back to mock data. _ADWRIGHT_MCP_PATH = "/home/svrnty/workspaces/hermes/adwright-mcp" _adwright_core = None _adwright_core_err: str | None = None def _load_adwright_core(): global _adwright_core, _adwright_core_err if _adwright_core is not None or _adwright_core_err is not None: return _adwright_core try: if _ADWRIGHT_MCP_PATH not in sys.path: sys.path.insert(0, _ADWRIGHT_MCP_PATH) import adwright_core # type: ignore _adwright_core = adwright_core return _adwright_core except Exception as e: _adwright_core_err = f"{type(e).__name__}: {e}" return None # 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 {} # Real-data path — calls adwright_core directly. adwright_core tool functions # return JSON strings; we parse + return the dict for the panel. # Returns None when the tool isn't recognized or adwright_core unavailable. def _real_payload_for(tool: str, qs: dict) -> dict | None: core = _load_adwright_core() if core is None: return None try: if tool == "adwright_list_cycles": limit = int((qs.get("limit", ["20"])[0] or "20")) raw = core.list_cycles_tool(limit=limit) elif tool == "adwright_refresh_cycles": raw = core.refresh_cycles_tool() # Also load fresh cycle list for the panel to re-render. cycles_raw = core.list_cycles_tool(limit=20) refresh_dict = json.loads(raw) cycles_dict = json.loads(cycles_raw) return {**refresh_dict, **cycles_dict} elif tool == "adwright_get_cycle": cycle_id = int((qs.get("cycle_id", ["0"])[0] or "0")) raw = core.get_cycle_tool(cycle_id=cycle_id) elif tool == "adwright_list_segments": raw = core.list_segments_tool() elif tool == "adwright_list_recipes": limit = int((qs.get("limit", ["10"])[0] or "10")) raw = core.list_recipes_tool(limit=limit) elif tool == "adwright_get_connections_status": raw = core.get_connections_status_tool() else: return None return json.loads(raw) if isinstance(raw, str) else raw except Exception: return None 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}, 200) # Real-data first; mock fallback if adwright_core unavailable. payload = _real_payload_for(tool, qs) is_mock = False if payload is None: payload = _mock_payload_for(tool) is_mock = True if not payload: return _send_json(handler, {"update": None, "mock": is_mock}, 200) return _send_json(handler, { "update": { "ts": now_ms, "tool": tool, "payload": payload, }, "mock": is_mock, }, 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 {: , ...}. Only keys in _ALLOWED_CRED_KEYS are written; others are silently dropped. Values are piped to `credctl set ` 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 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