#!/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