Files
agent-identity-provisioning/provision-agent.py
T
TSYS Group COO f638105614 fix: credential lifecycle safety -- update_item, duplicate prevention, tests
CRITICAL FIX: The --enable-2fa flow was creating duplicate BW items
instead of updating in place, which led to ambiguous item resolution
and data integrity issues. This was a severe failure in core
credential lifecycle operations.

Changes:
- bw_helper.py: Complete rewrite with safety guarantees
  - update_item(): modifies existing item in place by ID, preserves
    all fields not being updated
  - create_item(): refuses to create duplicates (raises if item exists)
  - get_item_id(): resolves name to ID, raises on ambiguous matches
  - get_item(): returns full item JSON
  - NO delete_item method exists by design -- credential deletion
    is a manual operation only
- provision-agent.py: --enable-2fa now uses update_item() instead
  of create_item() to add TOTP to existing credentials
- Dockerfile: non-root user with correct BW state directory ownership
- docker-compose.yml: bind mount for BW state (proper permissions)
- test_bw_helper.py: 10 tests covering full lifecycle
  (create, read, duplicate rejection, update password, update TOTP,
  field preservation, no-delete verification)
  Tests 1-4 verified passing against live Vaultwarden instance.
- requirements.txt: added pytest

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-13 14:47:57 -05:00

905 lines
32 KiB
Python

#!/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 (same selectors as invite page)
page.wait_for_selector("#inputPassword", timeout=15000)
page.fill("#inputUsername", cloudron_email)
page.fill("#inputPassword", password)
# Submit: Cloudron uses different button types on panel vs OIDC pages
page.evaluate("""() => {
const selectors = [
"button.btn-primary",
'button[type="submit"]',
'input[type="submit"]',
"#loginSubmitButton",
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) { el.click(); return; }
}
}""")
page.wait_for_timeout(5000)
# Handle TOTP if prompted
totp_input = page.query_selector("#inputTotp")
if totp_input and totp_input.is_visible():
totp_code = bw.get_totp(cloudron_item)
totp_input.fill(totp_code)
page.evaluate('''() => {
for (const sel of ["button.btn-primary", 'button[type="submit"]', 'input[type="submit"]']) {
const el = document.querySelector(sel);
if (el) { el.click(); return; }
}
}''')
page.wait_for_timeout(5000)
if "login" not in page.url.lower() and "openid" not in page.url.lower():
log.info(f"[{name}] Cloudron panel login successful")
return True
log.error(f"[{name}] Cloudron panel login failed (still on login page: {page.url})")
return False
def sso_login(page: Page, system_url: str, agent: dict, bw: BitwardenHelper,
sso_button_selector: str = "") -> bool:
"""
Log into a Cloudron-managed app via SSO.
Requires an active Cloudron panel session (call cloudron_panel_login first).
Clicks the SSO/OAuth button on the app's login page, which redirects
through Cloudron's OIDC provider and back to the app authenticated.
Args:
sso_button_selector: CSS selector for the SSO button. If empty,
tries common patterns (oauth2 link, "cloudron" text, etc.).
Returns True if login succeeded.
"""
name = agent["name"]
log.info(f"[{name}] SSO login: {system_url}")
page.goto(system_url, wait_until="networkidle")
page.wait_for_timeout(2000)
# Already authenticated?
if not page.query_selector('input[type="password"]'):
log.info(f"[{name}] Already authenticated at {system_url}")
return True
# Click the SSO/OAuth button
sso_clicked = False
if sso_button_selector:
btn = page.locator(sso_button_selector)
if btn.count() > 0:
btn.first.click()
sso_clicked = True
if not sso_clicked:
for selector in [
'a[href*="oauth2/cloudron"]',
'a[href*="oauth"]',
'button[id*="login-oauth"]',
'a:has-text("cloudron")',
'a:has-text("Cloud")',
'button:has-text("cloudron")',
'button:has-text("Cloud")',
]:
btn = page.locator(selector)
if btn.count() > 0 and btn.first.is_visible():
btn.first.click()
sso_clicked = True
break
if not sso_clicked:
log.error(f"[{name}] Could not find SSO button at {system_url}")
_debug_dump(page, f"sso-no-button-{name}")
return False
page.wait_for_timeout(5000)
log.info(f"[{name}] SSO redirect: {page.url}")
# Handle Cloudron OIDC login page if redirected
if "openid" in page.url or "interaction" in page.url:
if not page.query_selector('input[type="password"]'):
log.info(f"[{name}] OIDC auto-consent (already authenticated)")
else:
log.info(f"[{name}] Handling Cloudron OIDC login")
cloudron_email = agent.get("cloudron_email", f"{name}@turnsys.com")
cloudron_item = f"{name} Cloudron"
password = bw.get_item_password(cloudron_item)
page.wait_for_selector("#inputPassword", timeout=15000)
page.fill("#inputUsername", cloudron_email)
page.fill("#inputPassword", password)
# Submit: Cloudron OIDC uses different button patterns than the panel
page.evaluate('''() => {
const selectors = [
"button.btn-primary",
'button[type="submit"]',
'input[type="submit"]',
'button:has-text("Log in")',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) { el.click(); return; }
}
}''')
page.wait_for_timeout(5000)
# After OIDC login, may need consent — or may redirect back to app
page.wait_for_timeout(3000)
page.wait_for_timeout(3000)
log.info(f"[{name}] SSO result: {page.url}")
# Verify we're back on the target app (not stuck on OIDC/login)
if "openid" in page.url or "interaction" in page.url:
log.error(f"[{name}] SSO login failed (stuck on OIDC page)")
_debug_dump(page, f"sso-stuck-{name}")
return False
if "login" in page.url.lower():
log.error(f"[{name}] SSO login failed (still on login page)")
_debug_dump(page, f"sso-failed-{name}")
return False
log.info(f"[{name}] SSO login successful")
return True
def provision_gitea(page: Page, agent: dict, bw: BitwardenHelper) -> str:
"""Generate a Gitea API token via SSO login. Returns the token."""
name = agent["name"]
systems = agent.get("systems", {})
gitea_cfg = systems.get("gitea", {})
if not gitea_cfg:
log.info(f"[{name}] No Gitea config — skipping")
return ""
item_name = f"{name} Gitea"
if bw.item_exists(item_name):
log.info(f"[{name}] Gitea token already exists — skipping")
return bw.get_item_password(item_name)
url = gitea_cfg.get("url", GITEA_URL)
token_name = gitea_cfg.get("token_name", f"{name}-api")
sso_login(page, f"{url}/user/login", agent, bw,
sso_button_selector='a[href*="oauth2/cloudron"]')
# Navigate to API token settings
page.goto(f"{url}/user/settings/applications", wait_until="networkidle")
page.wait_for_timeout(2000)
_debug_dump(page, f"gitea-settings-{name}")
# Generate new token
name_input = page.query_selector('input[name="name"]')
if name_input:
name_input.fill(token_name)
# Select scopes if checkboxes exist
for scope in gitea_cfg.get("scopes", ["api", "repo", "read:org"]):
scope_cb = page.query_selector(f'input[value="{scope}"]')
if scope_cb and not scope_cb.is_checked():
scope_cb.check()
gen_btn = page.query_selector('button:has-text("Generate Token")')
if gen_btn:
gen_btn.click()
page.wait_for_timeout(2000)
# Extract the generated token
token_el = page.query_selector('.ui.info.message code, .ui.message code, input[readonly]')
if not token_el:
token_el = page.query_selector('.token-value, .access-token')
token = token_el.text_content().strip() if token_el else ""
if not token:
log.error(f"[{name}] Could not extract Gitea API token")
_debug_dump(page, f"gitea-no-token-{name}")
return ""
log.info(f"[{name}] Gitea token generated: {token[:8]}...")
# Store in Bitwarden
bw.create_item(
name=item_name,
username=name,
password=token,
uris=[url],
collection_name=name,
custom_fields={"token_name": token_name},
)
log.info(f"[{name}] Gitea token stored in Bitwarden")
return token
def provision_discourse(page: Page, agent: dict, bw: BitwardenHelper) -> str:
"""
Generate a Discourse API key via SSO login.
Note: Discourse API keys typically require admin to create.
If the agent can't self-generate, this logs a warning.
Returns the API key (empty string if not possible).
"""
name = agent["name"]
systems = agent.get("systems", {})
discourse_cfg = systems.get("discourse", {})
if not discourse_cfg:
log.info(f"[{name}] No Discourse config — skipping")
return ""
item_name = f"{name} Discourse"
if bw.item_exists(item_name):
log.info(f"[{name}] Discourse key already exists — skipping")
return bw.get_item_password(item_name)
url = discourse_cfg.get("url", DISCOURSE_URL)
sso_login(page, f"{url}/", agent, bw,
sso_button_selector='button[id*="login-oauth"], a[href*="auth"]')
# Try to generate an API key from user preferences
# Note: In Discourse, only admin can create API keys via UI
# Non-admin users may not have this option
page.goto(f"{url}/u/{name}/preferences/account", wait_until="networkidle")
api_key_section = page.query_selector('.api-keys, [data-section="api-keys"]')
if not api_key_section:
log.warning(
f"[{name}] Discourse API key self-generation not available. "
"An admin must create the key. The agent will need a manually-created key."
)
return ""
# If the section exists, try to create a key
revoke_btn = page.query_selector('.api-keys button:has-text("Revoke")')
if not revoke_btn:
# No existing keys — create one
gen_btn = page.query_selector('button:has-text("New API Key"), button:has-text("Create")')
if gen_btn:
gen_btn.click()
page.wait_for_timeout(2000)
# Read the key
key_el = page.query_selector('.api-key-value, code')
api_key = key_el.text_content().strip() if key_el else ""
if api_key:
bw.create_item(
name=item_name,
username=name,
password=api_key,
uris=[url],
collection_name=name,
)
log.info(f"[{name}] Discourse API key stored in Bitwarden")
return api_key
log.warning(f"[{name}] Could not generate Discourse API key")
return ""
def provision_redmine(page: Page, agent: dict, bw: BitwardenHelper) -> str:
"""Get the Redmine API access key via SSO login. Returns the key."""
name = agent["name"]
systems = agent.get("systems", {})
redmine_cfg = systems.get("redmine", {})
if not redmine_cfg:
log.info(f"[{name}] No Redmine config — skipping")
return ""
item_name = f"{name} Redmine"
if bw.item_exists(item_name):
log.info(f"[{name}] Redmine key already exists — skipping")
return bw.get_item_password(item_name)
url = redmine_cfg.get("url", REDMINE_URL)
sso_login(page, f"{url}/login", agent, bw,
sso_button_selector='button[id*="login-oauth"]')
# Navigate to account page where API key lives
page.goto(f"{url}/my/account", wait_until="networkidle")
page.wait_for_timeout(2000)
_debug_dump(page, f"redmine-account-{name}")
# The API key is in the right sidebar under "API access key"
show_link = page.query_selector('a:has-text("Show"), #api_access_key + a, a[href*="access_key"]')
if show_link:
show_link.click()
page.wait_for_timeout(1000)
key_el = page.query_selector('#api_access_key, .api-key code, .api-access-key')
api_key = key_el.text_content().strip() if key_el else ""
if not api_key:
reset_link = page.query_selector('a:has-text("Reset"), a:has-text("Generate")')
if reset_link:
reset_link.click()
page.wait_for_timeout(2000)
page.click('button:has-text("OK"), button:has-text("Confirm")')
page.wait_for_timeout(1000)
key_el = page.query_selector('#api_access_key, .api-key code')
api_key = key_el.text_content().strip() if key_el else ""
if not api_key:
log.error(f"[{name}] Could not get Redmine API key")
_debug_dump(page, f"redmine-no-key-{name}")
return ""
log.info(f"[{name}] Redmine API key obtained: {api_key[:8]}...")
# Store in Bitwarden
bw.create_item(
name=item_name,
username=name,
password=api_key,
uris=[url],
collection_name=name,
)
log.info(f"[{name}] Redmine API key stored in Bitwarden")
return api_key
# ---------------------------------------------------------------------------
# Verification
# ---------------------------------------------------------------------------
def verify_gitea(token: str, agent: dict) -> bool:
"""Verify the Gitea API token works."""
import urllib.request
url = agent.get("systems", {}).get("gitea", {}).get("url", GITEA_URL)
req = urllib.request.Request(f"{url}/api/v1/user", headers={"Authorization": f"token {token}"})
try:
resp = urllib.request.urlopen(req, timeout=10)
data = json.loads(resp.read())
log.info(f" Gitea verify: user={data.get('login', '?')}")
return resp.status == 200
except Exception as e:
log.error(f" Gitea verify FAILED: {e}")
return False
def verify_redmine(key: str, agent: dict) -> bool:
"""Verify the Redmine API key works."""
import urllib.request
url = agent.get("systems", {}).get("redmine", {}).get("url", REDMINE_URL)
req = urllib.request.Request(f"{url}/users/current.json", headers={"X-Redmine-API-Key": key})
try:
resp = urllib.request.urlopen(req, timeout=10)
data = json.loads(resp.read())
log.info(f" Redmine verify: user={data.get('user', {}).get('login', '?')}")
return resp.status == 200
except Exception as e:
log.error(f" Redmine verify FAILED: {e}")
return False
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def load_manifest(path: str) -> list[dict]:
"""Load and validate the agent manifest."""
with open(path) as f:
data = yaml.safe_load(f)
agents = data.get("agents", [])
if not agents:
log.error("No agents found in manifest")
sys.exit(1)
for agent in agents:
if "cloudron_invite" not in agent or "REPLACE" in agent["cloudron_invite"]:
log.warning(f"Agent {agent.get('name', '?')} has no valid Cloudron invite")
return agents
def provision_agent(
context: BrowserContext,
agent: dict,
bw: BitwardenHelper,
phase1_only: bool = False,
) -> dict:
"""Provision a single agent identity."""
name = agent["name"]
results = {"name": name, "cloudron": False, "gitea": False, "discourse": False, "redmine": False}
page = context.new_page()
try:
# Phase 1: Cloudron enrollment
enroll_cloudron(page, agent, bw)
results["cloudron"] = True
log.info(f"[{name}] Phase 1 complete: Cloudron identity enrolled")
if phase1_only:
log.info(f"[{name}] Phase 1 only — skipping system access")
return results
systems = agent.get("systems", {})
if not systems:
log.info(f"[{name}] No systems configured — Phase 1 only")
return results
# Establish Cloudron panel session for SSO (shared across all apps)
sso_page = context.new_page()
cloudron_panel_login(sso_page, agent, bw)
sso_page.close()
# Phase 2: System access (fresh page for each system)
for system_name in ["gitea", "discourse", "redmine"]:
system_page = context.new_page()
try:
if system_name == "gitea":
token = provision_gitea(system_page, agent, bw)
results["gitea"] = bool(token) and verify_gitea(token, agent)
elif system_name == "discourse":
key = provision_discourse(system_page, agent, bw)
results["discourse"] = bool(key)
elif system_name == "redmine":
key = provision_redmine(system_page, agent, bw)
results["redmine"] = bool(key) and verify_redmine(key, agent)
except Exception as e:
log.error(f"[{name}] {system_name} provisioning failed: {e}")
finally:
system_page.close()
log.info(f"[{name}] All phases complete: {results}")
except Exception as e:
log.error(f"[{name}] Provisioning failed: {e}")
results["error"] = str(e)
finally:
page.close()
# Save state (in finally so partial results survive failures)
state_file = STATE_DIR / f"{name}.json"
try:
with open(state_file, "w") as f:
json.dump(results, f, indent=2)
except OSError:
log.warning(f"[{name}] Could not write state file: {state_file}")
return results
def main():
parser = argparse.ArgumentParser(description="Provision AI agent identities")
parser.add_argument("--manifest", default="agents.yaml", help="Path to manifest file")
parser.add_argument("--agent", help="Provision only this agent")
parser.add_argument("--phase1-only", action="store_true", help="Cloudron enrollment only")
parser.add_argument("--enable-2fa", action="store_true", help="Enable 2FA on existing Cloudron accounts")
parser.add_argument("--dry-run", action="store_true", help="Validate manifest without browser")
parser.add_argument("--headed", action="store_true", help="Show browser (debugging)")
args = parser.parse_args()
# Ensure state directory exists (deferred from module level so --dry-run works)
STATE_DIR.mkdir(parents=True, exist_ok=True)
# Load manifest
agents = load_manifest(args.manifest)
if args.agent:
agents = [a for a in agents if a["name"] == args.agent]
if not agents:
log.error(f"Agent '{args.agent}' not found in manifest")
sys.exit(1)
log.info(f"Manifest: {len(agents)} agent(s) to provision")
for a in agents:
log.info(f" - {a['name']} ({a.get('display_name', '?')}) [{a.get('priority', '?')}]")
if args.dry_run:
log.info("Dry run — manifest validated successfully")
return
# Initialize Bitwarden
bw = BitwardenHelper(
client_id=os.environ["BW_CLIENTID"],
client_secret=os.environ["BW_CLIENTSECRET"],
password=os.environ["BW_PASSWORD"],
totp_secret=os.environ.get("BW_TOTP_SECRET", ""),
server_url=os.environ.get("BW_SERVER", ""),
)
log.info("Connecting to Bitwarden...")
bw.login()
log.info("Bitwarden session established")
# Launch Playwright
headless = not args.headed
if os.environ.get("HEADFUL", "false").lower() == "true":
headless = False
all_results = []
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=headless)
for agent in agents:
# Fresh context per agent (no cookie/session bleed)
context = browser.new_context(
accept_downloads=False,
java_script_enabled=True,
)
log.info(f"=== Provisioning: {agent['name']} ===")
try:
if args.enable_2fa:
page = context.new_page()
cloudron_panel_login(page, agent, bw)
totp_secret = enable_cloudron_2fa(page, agent, bw)
if totp_secret:
item_name = f"{agent['name']} Cloudron"
bw.update_item(item_name, totp_secret=totp_secret)
log.info(f"[{agent['name']}] Updated BW item with TOTP secret (in place)")
page.close()
all_results.append({"name": agent["name"], "2fa": bool(totp_secret)})
else:
result = provision_agent(context, agent, bw, args.phase1_only)
all_results.append(result)
except Exception as e:
log.error(f"FAILED: {agent['name']}: {e}")
all_results.append({"name": agent["name"], "error": str(e)})
finally:
context.close()
browser.close()
# Summary
log.info("\n=== PROVISIONING SUMMARY ===")
for r in all_results:
status = "OK" if "error" not in r else "FAILED"
systems = []
for s in ["cloudron", "gitea", "discourse", "redmine"]:
if r.get(s):
systems.append(s)
log.info(f" {r['name']}: {status}{', '.join(systems) if systems else '(none)'}")
# Exit non-zero if any failed
if any("error" in r for r in all_results):
sys.exit(1)
if __name__ == "__main__":
main()