#!/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 re 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 # Proven selectors (session 3 DOM dump of setupaccount.html): # #inputUsername (prefilled from invite), #inputDisplayName, # #inputPassword, #inputPasswordRepeat, # submit =
"Set up" (disabled until form valid) page.goto(invite_url, wait_until="networkidle") page.wait_for_timeout(2000) page.wait_for_selector("#inputPassword", timeout=15000) # Pankow/Vue forms need click + keyboard.type, never fill() page.click("#inputDisplayName") page.keyboard.type(display_name) page.click("#inputPassword") page.keyboard.type(password) page.click("#inputPasswordRepeat") page.keyboard.type(password) # Submit button is a div[role=button]; starts disabled, enables on valid input setup_btn = page.locator('[role="button"]:has-text("Set up")') page.wait_for_timeout(1000) if setup_btn.count() > 0 and setup_btn.first.is_visible(): setup_btn.first.click() else: log.warning(f"[{name}] Set up button not found/clickable, pressing Enter") page.keyboard.press("Enter") page.wait_for_timeout(5000) 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: """ Enable TOTP 2FA on a Cloudron account via the profile page. Cloudron's 2FA enrollment flow (discovered via DOM dump): 1. Navigate to #/profile 2. Click "Setup" to start 2FA enrollment 3. Cloudron defaults to Passkey -- click "switchToTotp" to switch 4. TOTP secret appears in page text (base32 encoded) 5. Enter TOTP code in #totpTokenInput, click Enable Returns the TOTP secret (empty string if already enabled or failed). """ name = agent["name"] log.info(f"[{name}] Enabling 2FA on Cloudron account") # Fresh accounts sit on setupaccount.html ("Your account is ready") -- # hash navigation cannot leave that page. Load the panel root first. if "setupaccount" in page.url: page.goto(f"{CLOUDRON_BASE}/", wait_until="networkidle", timeout=20000) page.wait_for_timeout(3000) page.evaluate('() => window.location.hash = "#/profile"') page.wait_for_timeout(3000) # If profile didn't load, try clicking the profile nav link if "#/profile" not in page.url: for nav_text in ["Profile", "Settings", "Account"]: sec_link = page.locator(f'a[href*="#/profile"], 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-profile-{name}") # Check if 2FA is already enabled 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 "" # Step 1: Click "Setup" to start 2FA enrollment # (forced-enrollment screen says "Set up passkey"; profile says "Setup") setup_clicked = False for selector in [ 'text=Setup', 'text=Set up', '[role="button"]:has-text("Setup")', '[role="button"]:has-text("Set up")', 'button:has-text("Setup")', 'button:has-text("Set up")', 'a:has-text("Setup")', ]: loc = page.locator(selector) if loc.count() > 0 and loc.first.is_visible(): loc.first.click() page.wait_for_timeout(3000) setup_clicked = True break if not setup_clicked: log.warning(f"[{name}] Could not find 2FA Setup button") _debug_dump(page, f"cloudron-2fa-no-setup-{name}") return "" # Step 2: Switch from Passkey to TOTP mode page_text = page.evaluate("() => document.body.innerText") if "switchToTotp" in page_text or "Passkey" in page_text: for selector in [ 'text=switchToTotp', 'text=profile.enable2FA.switchToTotp', 'a:has-text("TOTP")', '[role="button"]:has-text("TOTP")', ]: loc = page.locator(selector) if loc.count() > 0 and loc.first.is_visible(): loc.first.click() page.wait_for_timeout(3000) break _debug_dump(page, f"cloudron-2fa-totp-mode-{name}") # Step 3: Extract TOTP secret (base32 string in page text) import re page_text = page.evaluate("() => document.body.innerText") totp_secret = "" # Look in code/pre elements first for el in page.query_selector_all("code, pre"): text = el.text_content().strip().replace(" ", "") if text and re.match(r'^[A-Z2-7=]+$', text): totp_secret = text break # Fall back to regex in page text if not totp_secret: matches = re.findall(r'[A-Z2-7]{16,}=?', page_text.replace(" ", "")) if matches: totp_secret = matches[0] if not totp_secret: log.error(f"[{name}] Could not extract TOTP secret") _debug_dump(page, f"cloudron-2fa-no-secret-{name}") return "" log.info(f"[{name}] Extracted TOTP secret: {totp_secret[:4]}...") # Step 4: Enter TOTP confirmation code import pyotp totp_code = pyotp.TOTP(totp_secret).now() token_input = page.query_selector("#totpTokenInput") if not token_input: for sel in ['input[name="totpToken"]', 'input[type="text"]:visible']: loc = page.locator(sel) if loc.count() > 0 and loc.first.is_visible(): token_input = loc.first.element_handle() break if token_input: token_input.click() page.keyboard.type(totp_code) page.wait_for_timeout(500) # Click Enable/Confirm button for btn_text in ["Enable", "Confirm", "Verify", "OK", "Save"]: loc = page.locator(f'[role="button"]:has-text("{btn_text}"), button:has-text("{btn_text}")') if loc.count() > 0 and loc.first.is_visible(): loc.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