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