#!/usr/bin/env python3 """ provision-agent.py — Playwright automation for AI agent identity provisioning. Enrolls AI agent identities in Cloudron, logs into Gitea/Discourse/Redmine via SSO, generates API keys, and stores all credentials in Bitwarden. Usage: python3 provision-agent.py # provision all agents in manifest python3 provision-agent.py --agent vp-techops # provision one agent python3 provision-agent.py --phase1-only # Cloudron enrollment only python3 provision-agent.py --dry-run # validate manifest without browser Manifest: agents.yaml (see agents.yaml.example) See: ~/Q3/agent-identity-bootstrap.md for the full architecture. """ import argparse import json import logging import os import sys import time from pathlib import Path import yaml from playwright.sync_api import BrowserContext, Page, sync_playwright from bw_helper import BitwardenHelper # --------------------------------------------------------------------------- # Logging # --------------------------------------------------------------------------- logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S", ) log = logging.getLogger("provision") # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- CLOUDRON_BASE = os.environ.get("CLOUDRON_BASE", "https://my.knownelement.com") GITEA_URL = os.environ.get("GITEA_URL", "https://git.knownelement.com") DISCOURSE_URL = os.environ.get("DISCOURSE_URL", "https://community.turnsys.com") REDMINE_URL = os.environ.get("REDMINE_URL", "https://projects.knownelement.com") STATE_DIR = Path("/app/state") def _debug_dump(page: Page, label: str) -> None: """Save screenshot + DOM dump for debugging selector issues.""" STATE_DIR.mkdir(parents=True, exist_ok=True) ts = time.strftime("%H%M%S") screenshot_path = STATE_DIR / f"debug-{label}-{ts}.png" dom_path = STATE_DIR / f"debug-{label}-{ts}.txt" try: page.screenshot(path=str(screenshot_path), full_page=True) except Exception: pass try: elements = page.evaluate("""() => { const results = []; document.querySelectorAll('input, button, select, textarea, label, h2, h3, h4, h5, code, .ui.header, .ui.message, [data-tab], .tw-font-semibold').forEach(el => { const tag = el.tagName.toLowerCase(); const text = el.textContent.trim().substring(0, 80); const id = el.id || ''; const name = el.getAttribute('name') || ''; const type = el.getAttribute('type') || ''; const value = el.getAttribute('value') || ''; const cls = (el.className || '').substring(0, 50); if (text || id || name || type || value) results.push(`<${tag}> id=${id} name=${name} type=${type} value=${value} class=${cls} text="${text}"`); }); return results.join('\\n'); }""") dom_path.write_text(f"URL: {page.url}\\n\\n{elements}") log.info(f" Debug dump saved: {screenshot_path.name}, {dom_path.name}") except Exception as e: log.warning(f" Debug dump failed: {e}") # --------------------------------------------------------------------------- # Cloudron enrollment # --------------------------------------------------------------------------- def enroll_cloudron( page: Page, agent: dict, bw: BitwardenHelper, ) -> dict: """ Phase 1: Accept Cloudron invite, set password, enable 2FA. Returns a dict with the agent's Cloudron credentials. """ name = agent["name"] invite_url = agent["cloudron_invite"] display_name = agent.get("display_name", name) log.info(f"[{name}] Phase 1: Cloudron enrollment — {invite_url}") # Check if already provisioned item_name = f"{name} Cloudron" if bw.item_exists(item_name): log.info(f"[{name}] Cloudron credential already exists in Bitwarden — skipping") cloudron_email = agent.get("cloudron_email", f"{name}@turnsys.com") return { "username": cloudron_email, "password": bw.get_item_password(item_name), } # Generate a strong password password = bw.generate_password(length=32) log.info(f"[{name}] Generated password ({len(password)} chars)") # Navigate to invite link page.goto(invite_url, wait_until="networkidle") page.wait_for_timeout(2000) # Fill in the invite acceptance form using real Cloudron selectors page.wait_for_selector('#inputPassword', timeout=15000) page.fill('#inputPassword', password) page.fill('#inputPasswordRepeat', password) # Set display name if field exists display_field = page.query_selector('#inputDisplayName') if display_field: display_field.fill(display_name) # Submit — Cloudron uses a button with class btn-primary or type submit submit = page.query_selector('button[type="submit"], button.btn-primary, button:has-text("Setup"), button:has-text("Create"), button:has-text("Accept")') if submit: submit.click() else: page.keyboard.press('Enter') page.wait_for_load_state("networkidle") log.info(f"[{name}] Invite accepted") # Enable 2FA totp_secret = enable_cloudron_2fa(page, agent, bw) # Store credential in Bitwarden cloudron_email = agent.get("cloudron_email", f"{name}@turnsys.com") bw.create_item( name=item_name, username=cloudron_email, password=password, uris=[CLOUDRON_BASE], collection_name=name, totp_secret=totp_secret, ) log.info(f"[{name}] Cloudron credential stored in Bitwarden (collection: {name})") return {"username": cloudron_email, "password": password, "totp_secret": totp_secret} def enable_cloudron_2fa(page: Page, agent: dict, bw: BitwardenHelper) -> str: """ Navigate to Cloudron security settings and enable TOTP 2FA. Cloudron's security page uses Angular with #inputTotpToggle and #inputTotpSecret/#inputTotpToken selectors. Returns the TOTP secret (empty string if already enabled or failed). """ name = agent["name"] log.info(f"[{name}] Enabling 2FA on Cloudron account") # Navigate to security/2FA settings. Cloudron users access their own # settings at /#/profile (not /#/security which is admin-only). page.evaluate('() => window.location.hash = "#/profile"') page.wait_for_timeout(3000) # If that didn't work, try clicking the profile/settings nav link if "#/profile" not in page.url: for nav_text in ["Profile", "Settings", "Account", "Security"]: sec_link = page.locator(f'a[href*="#/profile"], a[href*="#/security"], a:has-text("{nav_text}")') if sec_link.count() > 0: sec_link.first.click() page.wait_for_timeout(3000) break _debug_dump(page, f"cloudron-security-{name}") # Check if 2FA is already enabled by looking at page text page_text = page.evaluate("() => document.body.innerText") if "totp" in page_text.lower() and "enabled" in page_text.lower(): log.info(f"[{name}] 2FA already enabled on Cloudron") return "" # Click the TOTP enable/setup button on the profile page # Cloudron profile has a TOTP section with an Enable button totp_clicked = False for selector in [ 'button:has-text("Enable")', 'button:has-text("Setup")', 'a:has-text("Enable")', 'input[value="Enable"]', 'button:has-text("TOTP")', ]: btn = page.locator(selector) if btn.count() > 0: btn.first.click() page.wait_for_timeout(3000) totp_clicked = True break if not totp_clicked: log.warning(f"[{name}] Could not find TOTP enable button on profile page") _debug_dump(page, f"cloudron-2fa-no-btn-{name}") return "" _debug_dump(page, f"cloudron-2fa-modal-{name}") # Extract TOTP secret — Cloudron shows it in a modal after clicking Enable secret_el = page.query_selector('input[readonly], code, .totp-secret, #inputTotpSecret') totp_secret = "" if secret_el: totp_secret = secret_el.get_attribute("value") or secret_el.text_content() totp_secret = totp_secret.strip().replace(" ", "") # Try QR code if text secret not found if not totp_secret: qr_img = page.query_selector('img[src*="data:image"]') if qr_img: qr_src = qr_img.get_attribute("src") totp_secret = decode_qr_from_base64(qr_src) if not totp_secret: log.error(f"[{name}] Could not extract TOTP secret from Cloudron 2FA modal") return "" log.info(f"[{name}] Extracted TOTP secret: {totp_secret[:4]}...") # Generate current TOTP code and enter it to confirm import pyotp totp_code = pyotp.TOTP(totp_secret).now() # Cloudron TOTP confirmation input code_input = page.query_selector('#totpTokenInput, #inputTotpToken, input[name="totpToken"]') if not code_input: inputs = page.query_selector_all('input[type="text"]') for inp in inputs: if inp.is_visible(): code_input = inp break if code_input: code_input.fill(totp_code) page.wait_for_timeout(500) # Click confirm button using Playwright locator for btn_text in ["Confirm", "Enable", "Verify", "OK"]: btn = page.locator(f'button:has-text("{btn_text}")') if btn.count() > 0: btn.first.click() break page.wait_for_timeout(3000) log.info(f"[{name}] 2FA confirmation submitted") else: log.warning(f"[{name}] Could not find TOTP confirmation input") _debug_dump(page, f"cloudron-2fa-no-input-{name}") return totp_secret def decode_qr_from_base64(data_uri: str) -> str: """Decode a TOTP secret from a base64 QR code data URI.""" import base64 import io from PIL import Image from pyzbar.pyzbar import decode # Extract base64 data from data URI header, b64data = data_uri.split(",", 1) img_bytes = base64.b64decode(b64data) img = Image.open(io.BytesIO(img_bytes)) decoded = decode(img) if decoded: # TOTP URIs look like: otpauth://totp/Label?secret=XXXX&... uri = decoded[0].data.decode() if "secret=" in uri: return uri.split("secret=")[1].split("&")[0] raise RuntimeError("Could not decode TOTP secret from QR code") # --------------------------------------------------------------------------- # System access (Phase 2) # --------------------------------------------------------------------------- def cloudron_panel_login(page: Page, agent: dict, bw: BitwardenHelper) -> bool: """ Login to the Cloudron panel to establish an SSO session. Once authenticated to the Cloudron panel, subsequent SSO flows to Cloudron-managed apps (Gitea, Redmine, Discourse, etc.) auto-approve without requiring a separate consent click. Returns True if login succeeded or session already active. """ name = agent["name"] cloudron_email = agent.get("cloudron_email", f"{name}@turnsys.com") cloudron_item = f"{name} Cloudron" password = bw.get_item_password(cloudron_item) log.info(f"[{name}] Establishing Cloudron panel session") page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=15000) page.wait_for_timeout(2000) # Already logged in? (must be on the panel, not redirected to OIDC) if "login" not in page.url.lower() and "openid" not in page.url.lower(): log.info(f"[{name}] Cloudron panel session already active") return True # If redirected to OIDC login page, fill that too # (Cloudron login.html and OIDC login look similar, both use #inputUsername/#inputPassword) # Fill Cloudron login form (Pankow/Vue needs keyboard events, not fill()) page.wait_for_selector("#inputPassword", timeout=15000) page.click("#inputUsername") page.keyboard.type(cloudron_email) page.click("#inputPassword") page.keyboard.type(password) # Submit: Cloudron Pankow UI uses
instead of