Compare commits

...
4 Commits
Author SHA1 Message Date
vptechops a4a54f553e feat: full app provisioning for all COO agents + race-hardened BW helper
App credentials now flow for every agent: shared handle_oidc_interaction
helper fills the per-app OIDC login form/TOTP/consent (the panel session
is not shared across app clients -- each may demand fresh credentials),
and the Discourse signup clears the prefilled username field before
typing (prefill+typed concatenation exceeded the 20-char cap and failed
validation silently).

BW helper hardened against the sync races observed across concurrent
containers: get_item_id re-syncs stale caches, create/edit retry with
backoff and post-write sync. This class of failure was mine -- the
login-path fix from session 3 left read paths on stale caches.

Final validated matrix (validate-all-logins.py, fresh-context logins
plus live API checks): 9/10 fully green; vp-compliance blocked on a
corrupt stored password (Cloudron admin reset needed). Redmine access
still Cloudron-denied for vp-secops, svp-knel, vp-techcompliance,
vp-facilities ("You do not have access" at the OIDC interaction).
2026-08-14 13:53:01 -05:00
vptechops 1b8bd843d9 fix: get_totp exact-name resolution + all-agent login validator
bw's name search is fuzzy: every agent email contains "coo"
(tsgstaff-coo-*), so `bw get totp "coo Cloudron"` matched 10 items and
errored. get_totp now resolves via get_item_id (exact-name filter)
first, mirroring get_item_password.

Added validate-all-logins.py: per-agent fresh-browser-context login
(shared contexts carry session cookies and hide the login form),
asserts password+TOTP round-trip, then verifies every stored API
credential against its system. First full run: 9/10 PASS.

Known failure: vp-compliance stored password does not match the
account ("Incorrect username or password" pre-TOTP) -- enrollment
typed a different value than stored. Needs Cloudron admin reset,
then update_item and re-validate.
2026-08-14 11:48:06 -05:00
vptechops c461f85efa docs: session 3 results — 10 identities enrolled, matrix + blockers
All 10 agents enrolled in Cloudron with TOTP 2FA (vp-secops and
vp-techcompliance via the --enable-2fa second pass after the setup
page fix). Gitea tokens generated and verified for both Q3 VPs.
Remaining blockers are Charles-side: Redmine app access grants and a
Discourse signup/approval investigation.
2026-08-14 10:44:40 -05:00
vptechops 87298df1ba fix: Cloudron enrollment + 2FA for fresh invite accounts
Invite acceptance (verified live on 8 agents): Pankow forms need
click + keyboard.type (fill() silently no-ops), submit button is
<div role="button"> "Set up" which starts disabled until the form
is valid.

2FA enablement: fresh accounts land on setupaccount.html ("Your
account is ready") and hash navigation cannot leave that page --
load the panel root first. Also match "Set up" (forced-enrollment
screen says "Set up passkey", profile page says "Setup").

Verified in the 8-agent run: coo/svp-knel/svp-tctc/vp-investing/
vp-trading/vp-compliance enrolled with TOTP. vp-secops and
vp-techcompliance ran pre-fix and need the --enable-2fa second pass.
2026-08-14 10:26:05 -05:00
12 changed files with 1023 additions and 76 deletions
+41 -44
View File
@@ -1,55 +1,52 @@
# STATUS.md — Agent Identity Provisioning # STATUS.md — Agent Identity Provisioning
**Last updated:** 2026-08-13 (Session 2) **Last updated:** 2026-08-14 (Session 3)
**Phase:** Active development — vp-techops provisioning in progress **Phase:** 10/10 agent identities enrolled in Cloudron with TOTP 2FA. App credentials partial.
## Current State (Session 2) ## Identity Matrix (session 3 end)
### Completed | Agent | Cloudron | 2FA | Gitea | Discourse | Redmine |
|---|---|---|---|---|---|
| vp-techops | ✅ | ✅ | ✅ token+verified | ✅ user key | ✅ key verified |
| vp-secops | ✅ | ✅ | ✅ token+verified | ❌ see blocker | ❌ see blocker |
| vp-techcompliance | ✅ | ✅ | ✅ token+verified | ❌ see blocker | ❌ see blocker |
| coo | ✅ | ✅ | Q4 phase1 | Q4 phase1 | Q4 phase1 |
| svp-knel | ✅ | ✅ | Q4 phase1 | Q4 phase1 | Q4 phase1 |
| svp-tctc | ✅ | ✅ | Q4 phase1 | Q4 phase1 | Q4 phase1 |
| vp-investing | ✅ | ✅ | Q4 phase1 | Q4 phase1 | Q4 phase1 |
| vp-trading | ✅ | ✅ | Q4 phase1 | Q4 phase1 | Q4 phase1 |
| vp-compliance | ✅ | ✅ | Q4 phase1 | Q4 phase1 | Q4 phase1 |
| vp-facilities | ✅ | ✅ | Q4 phase1 | Q4 phase1 | Q4 phase1 |
- [x] **BW state sync fixed** — added `sync()` to `login()` lifecycle; 5-phase cross-container persistence test passes BW vault: 15 items across 10 agents (see JOURNAL.md session 3 table).
- [x] **Container UID/GID fixed** — provision user now matches host TSGCOO (1002:1002)
- [x] **Cloudron 2FA enabled** — TOTP on vp-techops account, secret stored in BW, full round-trip verified
- [x] **Discourse SSO + signup** — account created (username: vptechops), SSO via OpenID Connect working
- [x] **Discourse API key** — User API key generated via RSA flow, stored in BW, verified working
- [x] **Redmine SSO + API key** — SSO working after Charles granted Cloudron app access. API key extracted via "Show" button, verified via `X-Redmine-API-Key` header.
- [x] **Gitea token** — stored in BW, verified working (user=vptechops, active=true)
- [x] **BW vault** — 3 items: Cloudron (TOTP), Discourse (API key), Gitea (token)
### Blocked (needs Charles) ## Blockers (Charles actions)
(none currently) - **Redmine for vp-secops/vp-techcompliance**: Cloudron app access not
granted (OIDC bounce-back to /login, same class as session-2 Redmine fix).
- **Discourse for vp-secops/vp-techcompliance**: OIDC email auth works and
the signup form appears each run (account creation not persisting).
Suspect the "must approve users" site setting or a signup validation
error -- needs a look at Discourse admin. API cannot diagnose (user
keys get 404 on /u/*.json).
- **vp-techops stale Gitea tokens** from session 1 (revoke all but stored).
- **vptechops org perms**: currently broad (admin on repos/orgs used
during bootstrap). Dial back once per-agent ownership is settled.
### Remaining ## Tooling State
- [ ] Gitea token cleanup (multiple stale tokens may exist from session 1 iterations) - Provisioner loop proven at scale: 8-agent batch run + cleanup pass.
- [ ] Integrate all flows into provision-agent.py main script Invite acceptance + 2FA selectors verified on all 10 identities.
- [ ] Provision remaining agents (vp-secops, vp-techcompliance, coo, svp-knel, svp-tctc) - tea configured (login `git.knownelement.com`, vptechops) + git credential
helper installed -- NOTE: pushes from this host authenticate as
vptechops globally.
- Repo moved: KNEL/agent-identity-provisioning (transferred from
TSYSGroupCorporate; owned by VP TechOps). All work pushed.
## BW Vault State ## Next
| Item | Username | Password | TOTP | - Clear the two blockers above, then re-run
|---|---|---|---| `docker compose run --rm provision --agent vp-secops` (idempotent).
| vp-techops Cloudron | tsgstaff-coo-vptechops@turnsys.com | 32 chars | Enabled | - Add director/IC identities to agents.yaml once H22026.md settles the
| vp-techops Discourse | vptechops | 32-char API key | N/A | org chart (worker/reviewer pairs, audit independence).
| vp-techops Gitea | vptechops | 40-char token | N/A | - Q4 agents: fill `systems:` blocks as their charters firm up.
| vp-techops Redmine | vptechops | 40-char API key | N/A |
## Key Technical Discoveries (Session 2)
1. **Cloudron 2FA flow:** Profile -> Setup -> switchToTotp (Cloudron defaults to Passkey) -> extract base32 secret -> #totpTokenInput -> Enable
2. **Cloudron OIDC TOTP field:** `#inputTotpToken` (not `#inputTotp` as previously assumed)
3. **Discourse SSO:** Click `.login-button` -> click `button:has-text("OpenID")` in modal
4. **Discourse User API Key:** RSA-based flow with PKCS1v15 padding (not OAEP). Response payload is JSON: `{"key":"...","nonce":"..."}`
5. **Discourse API auth:** Use `User-Api-Key` header (not `Api-Key`)
## Provisioner Container
- Image: `agent-identity-provisioning-provision:latest`
- UID/GID: 1002:1002 (matches host TSGCOO)
- Source code mounted as read-only volumes for fast iteration
- BW state persists via `./bw-state` bind mount + `sync()` after every login
## Ticket
[#442](https://projects.knownelement.com/issues/442)
+56 -4
View File
@@ -24,6 +24,7 @@ import os
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time
from typing import Optional from typing import Optional
@@ -38,6 +39,7 @@ class BitwardenHelper:
self.totp_secret = totp_secret self.totp_secret = totp_secret
self.server_url = server_url self.server_url = server_url
self.session: Optional[str] = None self.session: Optional[str] = None
self._last_sync: float = 0.0
def _run_bw(self, args: list[str], capture: bool = True) -> str: def _run_bw(self, args: list[str], capture: bool = True) -> str:
"""Run a bw CLI command with the active session.""" """Run a bw CLI command with the active session."""
@@ -121,14 +123,59 @@ class BitwardenHelper:
not appear in list/search results. not appear in list/search results.
""" """
self._run_bw(["sync"]) self._run_bw(["sync"])
self._last_sync = time.monotonic()
def _sync_if_stale(self, max_age_s: float = 30.0) -> None:
"""Re-sync if the cache is older than max_age_s seconds.
Multiple containers share this vault (host wrapper, provisioner
runs). A read performed on a stale cache sees ghosts: items that
exist server-side appear missing (or vice versa). Cheap enough
to run before every read.
"""
if time.monotonic() - self._last_sync > max_age_s:
self.sync()
def _run_bw_with_retry(self, args: list[str], retries: int = 3) -> str:
"""Run a bw command, retrying on transient failures.
The bw CLI occasionally returns empty output or non-JSON errors
under load (observed: empty create response, 'Expecting value'
JSON decode upstream). Retry with backoff before failing.
"""
last_err = None
for attempt in range(1, retries + 1):
try:
return self._run_bw(args)
except (RuntimeError, json.JSONDecodeError) as e:
last_err = e
msg = str(e)
# Non-retryable failures: re-raise immediately
if "not found" in msg.lower() or "already exists" in msg.lower():
raise
if attempt < retries:
delay = 2 * attempt
time.sleep(delay)
self.sync()
raise RuntimeError(
f"bw {' '.join(args)} failed after {retries} retries: {last_err}"
)
def generate_password(self, length: int = 32) -> str: def generate_password(self, length: int = 32) -> str:
"""Generate a strong password.""" """Generate a strong password."""
return self._run_bw(["generate", "-ulns", "--length", str(length)]) return self._run_bw(["generate", "-ulns", "--length", str(length)])
def get_totp(self, item_name: str) -> str: def get_totp(self, item_name: str) -> str:
"""Get the current TOTP code for a Bitwarden item.""" """Get the current TOTP code for a Bitwarden item.
return self._run_bw(["get", "totp", item_name])
Resolves via exact-name match first -- bw's search is fuzzy and
shared substrings (e.g. "coo" in every tsgstaff-coo-* username)
make name-based gets ambiguous.
"""
item_id = self.get_item_id(item_name)
if not item_id:
raise RuntimeError(f"Item '{item_name}' not found")
return self._run_bw(["get", "totp", item_id])
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# Item ID resolution -- the safe way to reference items # Item ID resolution -- the safe way to reference items
@@ -139,8 +186,11 @@ class BitwardenHelper:
Returns the item ID if exactly one match exists, None if no match, Returns the item ID if exactly one match exists, None if no match,
and raises RuntimeError if multiple items share the name (ambiguous). and raises RuntimeError if multiple items share the name (ambiguous).
Always syncs if the cache is stale -- containers share this vault
and a stale cache sees ghosts.
""" """
try: try:
self._sync_if_stale()
output = self._run_bw(["list", "items", "--search", name]) output = self._run_bw(["list", "items", "--search", name])
items = json.loads(output) items = json.loads(output)
# Filter to exact name matches (bw search is fuzzy) # Filter to exact name matches (bw search is fuzzy)
@@ -220,8 +270,9 @@ class BitwardenHelper:
item["collectionIds"] = [collection_id] item["collectionIds"] = [collection_id]
encoded_item = self._encode(item) encoded_item = self._encode(item)
output = self._run_bw(["create", "item", encoded_item]) output = self._run_bw_with_retry(["create", "item", encoded_item])
created = json.loads(output) created = json.loads(output)
self.sync()
return created.get("id", "") return created.get("id", "")
def update_item( def update_item(
@@ -263,8 +314,9 @@ class BitwardenHelper:
] ]
encoded_item = self._encode(current) encoded_item = self._encode(current)
output = self._run_bw(["edit", "item", item_id, encoded_item]) output = self._run_bw_with_retry(["edit", "item", item_id, encoded_item])
updated = json.loads(output) updated = json.loads(output)
self.sync()
return updated.get("id", item_id) return updated.get("id", item_id)
# ------------------------------------------------------------------- # -------------------------------------------------------------------
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""
diagnose-sso-chain.py -- Trace the full SSO navigation chain for one agent.
Captures every navigation, page title, and visible messages during:
1. Cloudron panel login (establishes OIDC session)
2. Redmine SSO attempt (click -> ... -> final URL)
3. Discourse SSO attempt (modal -> OIDC -> signup/login -> final state)
Usage:
docker compose run --rm --entrypoint python3 \
-e AGENT=vp-secops provision diagnose-sso-chain.py
"""
import os, sys, time
from pathlib import Path
from playwright.sync_api import sync_playwright
sys.path.insert(0, os.path.dirname(__file__))
from bw_helper import BitwardenHelper
CLOUDRON_BASE = "https://my.knownelement.com"
REDMINE_URL = "https://projects.knownelement.com"
DISCOURSE_URL = "https://community.turnsys.com"
STATE_DIR = Path("/app/state")
AGENT = os.environ.get("AGENT", "vp-secops")
def snapshot(page, label, trail):
body = ""
try:
body = page.evaluate("() => document.body.innerText.substring(0, 250)")
except Exception:
pass
entry = f"[{label}] {page.url}\n body: {' | '.join(body.splitlines()[:6])}"
trail.append(entry)
print(entry)
def cloudron_login(page, bw):
item = f"{AGENT} Cloudron"
email_map = {
"vp-secops": "tsgstaff-coo-vpsecops@turnsys.com",
"vp-techcompliance": "tsgstaff-coo-vptechcompliance@turnsys.com",
}
email = email_map.get(AGENT, f"{AGENT}@turnsys.com")
password = bw.get_item_password(item)
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(2000)
page.wait_for_selector("#inputPassword", timeout=15000)
page.click("#inputUsername")
page.keyboard.type(email)
page.click("#inputPassword")
page.keyboard.type(password)
page.locator('[role="button"]:has-text("Log in")').first.click()
page.wait_for_timeout(4000)
totp = page.query_selector("#inputTotpToken")
if totp and totp.is_visible():
totp.click()
page.keyboard.type(bw.get_totp(item))
page.locator("#totpTokenSubmitButton").click()
page.wait_for_timeout(5000)
def main():
bw = BitwardenHelper(
client_id=os.environ["BW_CLIENTID"],
client_secret=os.environ["BW_CLIENTSECRET"],
password=os.environ["BW_PASSWORD"],
server_url=os.environ.get("BW_SERVER", ""),
)
bw.login()
trail = []
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 1024})
page = context.new_page()
page.on("framenavigated", lambda frame: None) # no-op; we poll manually
print(f"=== {AGENT}: Cloudron login ===")
cloudron_login(page, bw)
snapshot(page, "cloudron", trail)
print(f"\n=== {AGENT}: Redmine SSO chain ===")
page.goto(f"{REDMINE_URL}/login", wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(2000)
snapshot(page, "redmine-login", trail)
page.locator("#login-oauth-submit-1").first.click()
for i in range(6):
page.wait_for_timeout(1500)
snapshot(page, f"redmine+{(i+1)*1.5}s", trail)
if "projects.knownelement.com" in page.url and "/login" not in page.url:
break
# capture any flash error
err = page.evaluate("""() => {
const f = document.querySelector('#flash_notice, #flash_error, .flash, .error, .message');
return f ? f.textContent.trim() : '';
}""")
print(f" flash/error element: {err!r}")
print(f"\n=== {AGENT}: Discourse SSO chain ===")
page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(3000)
snapshot(page, "discourse-home", trail)
login_btn = page.locator(".login-button")
if login_btn.count() > 0:
login_btn.first.click()
page.wait_for_timeout(2000)
snapshot(page, "discourse-modal", trail)
sso = page.locator('button:has-text("OpenID")')
if sso.count() > 0:
sso.first.click()
for i in range(6):
page.wait_for_timeout(2000)
snapshot(page, f"discourse+{(i+1)*2}s", trail)
if "/signup" not in page.url and "/login" not in page.url:
break
if "/signup" in page.url:
body = page.evaluate("() => document.body.innerText")
print(f" SIGNUP PAGE body: {body[:300]!r}")
page.screenshot(path=str(STATE_DIR / f"sso-chain-{AGENT}-{time.strftime('%H%M%S')}.png"),
full_page=True)
browser.close()
(STATE_DIR / f"sso-chain-{AGENT}-{time.strftime('%H%M%S')}.txt").write_text(
"\n".join(trail))
if __name__ == "__main__":
main()
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
diagnose-vp-compliance.py -- Reproduce the vp-compliance login with full
DOM dumps at every step to find where it diverges from the other agents.
Usage:
docker compose run --rm --entrypoint python3 provision diagnose-vp-compliance.py
"""
import os, sys, time
from pathlib import Path
from playwright.sync_api import sync_playwright
sys.path.insert(0, os.path.dirname(__file__))
from bw_helper import BitwardenHelper
CLOUDRON_BASE = "https://my.knownelement.com"
STATE_DIR = Path("/app/state")
ITEM = "vp-compliance Cloudron"
EMAIL = "tsgstaff-coo-vpcompliance@turnsys.com"
def dump(page, label):
ts = time.strftime("%H%M%S")
page.screenshot(path=str(STATE_DIR / f"diag-vpc-{label}-{ts}.png"), full_page=True)
body = page.evaluate("() => document.body.innerText.substring(0, 300)")
els = page.evaluate("""() => Array.from(document.querySelectorAll(
'input, [role="button"], button, .ui.message')).map(el => ({
tag: el.tagName, id: el.id, type: el.type||'',
vis: el.offsetParent !== null, text: (el.textContent||'').trim().substring(0,50)}))""")
print(f"--- {label} ---")
print(f"URL: {page.url}")
print(f"BODY: {body[:200]}")
for e in els:
print(f" <{e['tag']}> id={e['id']} type={e['type']} vis={e['vis']} text={e['text']!r}")
def main():
bw = BitwardenHelper(
client_id=os.environ["BW_CLIENTID"],
client_secret=os.environ["BW_CLIENTSECRET"],
password=os.environ["BW_PASSWORD"],
server_url=os.environ.get("BW_SERVER", ""),
)
bw.login()
# Resolve by exact name to dodge the fuzzy-search ambiguity
item_id = bw.get_item_id(ITEM)
item = json.loads(bw._run_bw(["get", "item", item_id]))
password = item["login"]["password"]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 1024})
page = context.new_page()
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=20000)
page.wait_for_timeout(2000)
dump(page, "01-login-page")
page.wait_for_selector("#inputPassword", timeout=15000)
page.click("#inputUsername")
page.keyboard.type(EMAIL)
page.click("#inputPassword")
page.keyboard.type(password)
dump(page, "02-filled")
btn = page.locator('[role="button"]:has-text("Log in")')
print(f"login button count={btn.count()} visible={btn.first.is_visible() if btn.count() else '?'}")
print(f"login button classes: {btn.first.get_attribute('class') if btn.count() else '?'}")
btn.first.click()
page.wait_for_timeout(5000)
dump(page, "03-after-submit")
totp = page.query_selector("#inputTotpToken")
print(f"TOTP field present={totp is not None} visible={totp.is_visible() if totp else False}")
if totp and totp.is_visible():
code = bw._run_bw(["get", "totp", item_id])
totp.click()
page.keyboard.type(code)
page.locator("#totpTokenSubmitButton").click()
page.wait_for_timeout(5000)
dump(page, "04-after-totp")
browser.close()
import json # noqa: E402
if __name__ == "__main__":
main()
+7
View File
@@ -22,3 +22,10 @@ services:
- ./provision-discourse-apikey.py:/app/provision-discourse-apikey.py:ro - ./provision-discourse-apikey.py:/app/provision-discourse-apikey.py:ro
- ./provision-redmine.py:/app/provision-redmine.py:ro - ./provision-redmine.py:/app/provision-redmine.py:ro
- ./merge-invites.py:/app/merge-invites.py:ro - ./merge-invites.py:/app/merge-invites.py:ro
- ./dump-invite-page.py:/app/dump-invite-page.py:ro
- ./validate-all-logins.py:/app/validate-all-logins.py:ro
- ./diagnose-vp-compliance.py:/app/diagnose-vp-compliance.py:ro
- ./validate-coo.py:/app/validate-coo.py:ro
- ./diagnose-sso-chain.py:/app/diagnose-sso-chain.py:ro
- ./probe-redmine-access.py:/app/probe-redmine-access.py:ro
- ./probe-discourse-signup.py:/app/probe-discourse-signup.py:ro
+53
View File
@@ -82,3 +82,56 @@ Override with an explicit `username:` field in agents.yaml.
| Gitea | 40-char token | `GET /api/v1/user` -> vptechops | | Gitea | 40-char token | `GET /api/v1/user` -> vptechops |
| Discourse | 32-char user key | `GET /latest.json` with User-Api-Key | | Discourse | 32-char user key | `GET /latest.json` with User-Api-Key |
| Redmine | 40-char API key | `GET /users/current.json` -> id 11 | | Redmine | 40-char API key | `GET /users/current.json` -> id 11 |
---
## 2026-08-14 — Session 3: 8 agents enrolled, repo transfer, KNEL org layout
**Commits:** a96990d (org-buildout), 657939d/e3e5451 (KNELSecretsManager),
c564531 (TSYSGroupAIOS), 87298df (aip)
### Decisions
1. **Repo ownership split**: org-buildout is docs-only (BW CLI source
moved out); BW CLI lives in KNEL/KNELSecretsManager with ADR-002;
agent-identity-provisioning transferred TSYSGroupCorporate -> KNEL,
owned by VP TechOps. All 31 KNEL repos cloned to ~/projects/KNEL/.
2. **Gitea links commits to accounts by EMAIL.** Registries must use
Cloudron-issued addresses (tsgstaff-coo-*@turnsys.com), not synthetic
ones. Fixed in clone-as.sh/agent-profile.sh.
3. **tea credential helper** installed globally (vptechops token) --
clones over HTTPS work with clean URLs. NOTE: all pushes from this
host to git.knownelement.com now authenticate as vptechops; watch
attribution when pushing from TSGCOO-owned repos.
### Patterns proven this session
**Invite acceptance (setupaccount.html):** fields #inputDisplayName,
#inputPassword, #inputPasswordRepeat (username prefilled). Pankow forms
require click + keyboard.type. Submit is <div role="button"> "Set up",
disabled until form valid.
**2FA on fresh accounts:** after acceptance the SPA sits on
setupaccount.html; hash navigation cannot leave it -- goto panel root
first. Forced-2FA screen says "Set up passkey" (profile says "Setup")
-- match both. TOTP flow identical to session 2.
### Results (10 identities)
| Agent | Cloudron | 2FA | Gitea | Discourse | Redmine |
|---|---|---|---|---|---|
| vp-techops | yes | yes | token | key | key |
| vp-secops | yes | 2nd pass | BLOCKED (no app access) | acct created, retry | BLOCKED |
| vp-techcompliance | yes | 2nd pass | BLOCKED (no app access) | acct created, retry | BLOCKED |
| coo/svp-knel/svp-tctc | yes | yes | - (Q4 phase1) | - | - |
| vp-investing/vp-trading | yes | yes | - | - | - |
| vp-compliance | yes | yes | - | - | - |
| vp-facilities | cleanup pass | cleanup pass | - | - | - |
### Open items for Charles
- Grant Gitea/Redmine (and Discourse approval if required) app access
for vp-secops and vp-techcompliance -- Gitea OIDC returns
"The authorization request was denied." until granted (same class as
the session-2 Redmine blocker).
- vp-techops stale Gitea tokens cleanup still pending.
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""
dump-invite-page.py -- DOM dump of a Cloudron invite/setup page (pre-acceptance).
Usage:
docker compose run --rm --entrypoint python3 \
-e AGENT=vp-secops provision dump-invite-page.py
"""
import os, sys, time
from pathlib import Path
from playwright.sync_api import sync_playwright
STATE_DIR = Path("/app/state")
AGENT = os.environ.get("AGENT", "vp-secops")
import yaml
with open("/app/agents.yaml") as f:
manifest = yaml.safe_load(f)
agent = next(a for a in manifest["agents"] if a["name"] == AGENT)
invite_url = agent["cloudron_invite"]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_context(viewport={"width": 1280, "height": 1024}).new_page()
page.goto(invite_url, wait_until="networkidle", timeout=20000)
page.wait_for_timeout(3000)
ts = time.strftime("%H%M%S")
page.screenshot(path=str(STATE_DIR / f"invite-{AGENT}-{ts}.png"), full_page=True)
elements = page.evaluate("""() => {
const out = [];
document.querySelectorAll('input, button, [role="button"], label, a, h1, h2, h3, form').forEach(el => {
const tag = el.tagName.toLowerCase();
const text = (el.textContent || '').trim().substring(0, 60);
const id = el.id || '';
const type = el.getAttribute('type') || '';
const cls = (el.getAttribute('class') || '').substring(0, 50);
const role = el.getAttribute('role') || '';
const vis = el.offsetParent !== null;
out.push(`<${tag}> id=${id} type=${type} role=${role} class=${cls} vis=${vis} text="${text}"`);
});
return out;
}""")
body = page.evaluate("() => document.body.innerText.substring(0, 400)")
out = f"URL: {page.url}\n\nBODY:\n{body}\n\nELEMENTS:\n" + "\n".join(elements)
(STATE_DIR / f"invite-{AGENT}-{ts}.txt").write_text(out)
print(out)
browser.close()
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""
probe-discourse-signup.py -- Dump Discourse signup validation state.
Gets an agent to the OIDC-authenticated /signup page, fills a candidate
username, clicks Create Account, and dumps every field hint/error plus
the page state 8s later. Reveals why "account created" never sticks.
Usage:
docker compose run --rm --entrypoint python3 \
-e AGENT=vp-secops provision probe-discourse-signup.py
"""
import os, sys, time
from pathlib import Path
from playwright.sync_api import sync_playwright
sys.path.insert(0, os.path.dirname(__file__))
from bw_helper import BitwardenHelper
CLOUDRON_BASE = "https://my.knownelement.com"
DISCOURSE_URL = "https://community.turnsys.com"
STATE_DIR = Path("/app/state")
AGENT = os.environ.get("AGENT", "vp-secops")
EMAILS = {
"vp-secops": "tsgstaff-coo-vpsecops@turnsys.com",
"svp-knel": "tsgstaff-coo-svpknel@turnsys.com",
}
USERNAME = AGENT.replace("-", "")
def field_state(page):
return page.evaluate("""() => {
const out = [];
document.querySelectorAll('input, .tip, .invalid, .good, .bad, .warning, [class*="hint"]').forEach(el => {
const id = el.id || '';
const val = (el.value || '').substring(0, 40);
const text = (el.textContent || '').trim().substring(0, 100);
const cls = (el.getAttribute('class') || '').substring(0, 60);
const vis = el.offsetParent !== null;
if (text || val || id) out.push(`<${el.tagName.toLowerCase()}> id=${id} class=${cls} value="${val}" vis=${vis} text="${text}"`);
});
return out.join('\\n');
}""")
def main():
bw = BitwardenHelper(
client_id=os.environ["BW_CLIENTID"],
client_secret=os.environ["BW_CLIENTSECRET"],
password=os.environ["BW_PASSWORD"],
server_url=os.environ.get("BW_SERVER", ""),
)
bw.login()
item = f"{AGENT} Cloudron"
email = EMAILS.get(AGENT, f"{AGENT}@turnsys.com")
password = bw.get_item_password(item)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 1024})
page = context.new_page()
# Panel login
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=30000)
page.wait_for_timeout(2000)
page.wait_for_selector("#inputPassword", timeout=15000)
page.click("#inputUsername")
page.keyboard.type(email)
page.click("#inputPassword")
page.keyboard.type(password)
page.locator('[role="button"]:has-text("Log in")').first.click()
page.wait_for_timeout(4000)
totp = page.query_selector("#inputTotpToken")
if totp and totp.is_visible():
totp.click()
page.keyboard.type(bw.get_totp(item))
page.locator("#totpTokenSubmitButton").click()
page.wait_for_timeout(5000)
print(f"PANEL: {page.url}")
# Discourse: login modal -> OpenID
page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(3000)
page.locator(".login-button").first.click()
page.wait_for_timeout(3000)
page.locator('button:has-text("OpenID")').first.click()
page.wait_for_timeout(5000)
# Handle any OIDC interaction
for _ in range(4):
pw = page.query_selector("#inputPassword")
if pw and pw.is_visible():
page.click("#inputUsername"); page.keyboard.type(email)
page.click("#inputPassword"); page.keyboard.type(password)
page.locator('[role="button"]:has-text("Log in")').first.click()
page.wait_for_timeout(4000)
continue
t = page.query_selector("#inputTotpToken")
if t and t.is_visible():
t.click(); page.keyboard.type(bw.get_totp(item))
page.locator("#totpTokenSubmitButton").click()
page.wait_for_timeout(5000)
continue
if "community.turnsys.com" in page.url:
break
page.wait_for_timeout(2500)
print(f"POST-OIDC: {page.url}")
if "/signup" not in page.url:
print("NOT ON SIGNUP -- dumping body:")
print(page.evaluate("() => document.body.innerText.substring(0, 400)"))
browser.close()
return
# Fill username
page.wait_for_timeout(2000)
inp = page.locator("#new-account-username, input[name='username']").first
inp.click()
page.keyboard.type(USERNAME)
page.wait_for_timeout(2000)
print(f"\n=== FIELD STATE after typing {USERNAME} ===")
print(field_state(page))
# Click create
for btn in ["Create Account", "Sign Up"]:
loc = page.locator(f'button:has-text("{btn}")')
if loc.count() > 0 and loc.first.is_visible():
loc.first.click()
print(f"\nClicked: {btn}")
break
page.wait_for_timeout(8000)
print(f"\n=== 8s LATER: URL={page.url} ===")
print(field_state(page))
body = page.evaluate("() => document.body.innerText.substring(0, 400)")
print(f"BODY: {body!r}")
page.screenshot(path=str(STATE_DIR / f"discourse-signup-{AGENT}-{time.strftime('%H%M%S')}.png"), full_page=True)
browser.close()
if __name__ == "__main__":
main()
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""
probe-redmine-access.py -- What does the Redmine OIDC page actually say?
Logs in via panel, clicks Redmine SSO, dumps the interaction page text
verbatim. Distinguishes: login form vs consent vs "You do not have access".
Usage:
docker compose run --rm --entrypoint python3 \
-e AGENT=svp-knel provision probe-redmine-access.py
"""
import os, sys, time
from pathlib import Path
from playwright.sync_api import sync_playwright
sys.path.insert(0, os.path.dirname(__file__))
from bw_helper import BitwardenHelper
CLOUDRON_BASE = "https://my.knownelement.com"
REDMINE_URL = "https://projects.knownelement.com"
STATE_DIR = Path("/app/state")
AGENT = os.environ.get("AGENT", "svp-knel")
EMAILS = {
"svp-knel": "tsgstaff-coo-svpknel@turnsys.com",
"vp-secops": "tsgstaff-coo-vpsecops@turnsys.com",
"coo": "tsgstaff-coo@turnsys.com",
}
def main():
bw = BitwardenHelper(
client_id=os.environ["BW_CLIENTID"],
client_secret=os.environ["BW_CLIENTSECRET"],
password=os.environ["BW_PASSWORD"],
server_url=os.environ.get("BW_SERVER", ""),
)
bw.login()
item = f"{AGENT} Cloudron"
email = EMAILS[AGENT]
password = bw.get_item_password(item)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 1024})
page = context.new_page()
# Panel login (networkidle like the working loop code)
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=30000)
page.wait_for_timeout(2000)
page.wait_for_selector("#inputPassword", timeout=15000)
page.click("#inputUsername")
page.keyboard.type(email)
page.click("#inputPassword")
page.keyboard.type(password)
page.locator('[role="button"]:has-text("Log in")').first.click()
page.wait_for_timeout(4000)
totp = page.query_selector("#inputTotpToken")
if totp and totp.is_visible():
totp.click()
page.keyboard.type(bw.get_totp(item))
page.locator("#totpTokenSubmitButton").click()
page.wait_for_timeout(5000)
print(f"PANEL: {page.url}")
# Redmine SSO
page.goto(f"{REDMINE_URL}/login", wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(2000)
page.locator("#login-oauth-submit-1").first.click()
page.wait_for_timeout(6000)
print(f"\nOIDC URL: {page.url}")
body = page.evaluate("() => document.body.innerText")
print("OIDC BODY (verbatim):")
print(body)
page.screenshot(path=str(STATE_DIR / f"redmine-access-{AGENT}-{time.strftime('%H%M%S')}.png"))
browser.close()
if __name__ == "__main__":
main()
+114 -28
View File
@@ -119,28 +119,33 @@ def enroll_cloudron(
log.info(f"[{name}] Generated password ({len(password)} chars)") log.info(f"[{name}] Generated password ({len(password)} chars)")
# Navigate to invite link # Navigate to invite link
# Proven selectors (session 3 DOM dump of setupaccount.html):
# #inputUsername (prefilled from invite), #inputDisplayName,
# #inputPassword, #inputPasswordRepeat,
# submit = <div role="button"> "Set up" (disabled until form valid)
page.goto(invite_url, wait_until="networkidle") page.goto(invite_url, wait_until="networkidle")
page.wait_for_timeout(2000) page.wait_for_timeout(2000)
# Fill in the invite acceptance form using real Cloudron selectors page.wait_for_selector("#inputPassword", timeout=15000)
page.wait_for_selector('#inputPassword', timeout=15000)
page.fill('#inputPassword', password) # Pankow/Vue forms need click + keyboard.type, never fill()
page.fill('#inputPasswordRepeat', password) page.click("#inputDisplayName")
page.keyboard.type(display_name)
page.click("#inputPassword")
page.keyboard.type(password)
page.click("#inputPasswordRepeat")
page.keyboard.type(password)
# Set display name if field exists # Submit button is a div[role=button]; starts disabled, enables on valid input
display_field = page.query_selector('#inputDisplayName') setup_btn = page.locator('[role="button"]:has-text("Set up")')
if display_field: page.wait_for_timeout(1000)
display_field.fill(display_name) if setup_btn.count() > 0 and setup_btn.first.is_visible():
setup_btn.first.click()
# 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: else:
page.keyboard.press('Enter') log.warning(f"[{name}] Set up button not found/clickable, pressing Enter")
page.keyboard.press("Enter")
page.wait_for_load_state("networkidle") page.wait_for_timeout(5000)
log.info(f"[{name}] Invite accepted") log.info(f"[{name}] Invite accepted")
# Enable 2FA # Enable 2FA
@@ -177,6 +182,12 @@ def enable_cloudron_2fa(page: Page, agent: dict, bw: BitwardenHelper) -> str:
name = agent["name"] name = agent["name"]
log.info(f"[{name}] Enabling 2FA on Cloudron account") 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.evaluate('() => window.location.hash = "#/profile"')
page.wait_for_timeout(3000) page.wait_for_timeout(3000)
@@ -198,10 +209,14 @@ def enable_cloudron_2fa(page: Page, agent: dict, bw: BitwardenHelper) -> str:
return "" return ""
# Step 1: Click "Setup" to start 2FA enrollment # Step 1: Click "Setup" to start 2FA enrollment
# (forced-enrollment screen says "Set up passkey"; profile says "Setup")
setup_clicked = False setup_clicked = False
for selector in [ for selector in [
'text=Setup',
'text=Set up', 'text=Set up',
'[role="button"]:has-text("Setup")', '[role="button"]:has-text("Setup")',
'[role="button"]:has-text("Set up")',
'button:has-text("Setup")',
'button:has-text("Set up")', 'button:has-text("Set up")',
'a:has-text("Setup")', 'a:has-text("Setup")',
]: ]:
@@ -546,6 +561,69 @@ def sso_login(page: Page, system_url: str, agent: dict, bw: BitwardenHelper,
return True return True
def handle_oidc_interaction(page: Page, agent: dict, bw: BitwardenHelper) -> None:
"""Handle a Cloudron OIDC interaction page: login form, TOTP, consent.
Each Cloudron app has its own OIDC client and the panel session is
NOT always shared -- the interaction page may present a fresh Pankow
login form (#inputPassword), a TOTP prompt (#inputTotpToken), a
consent page, or any combination. Safe to call speculatively; every
step checks visibility first.
Cloudron's Pankow forms use #input* IDs (Gitea/Redmine/Discourse
login pages use different IDs, so there is no cross-app collision).
"""
name = agent["name"]
cloudron_item = f"{name} Cloudron"
# Poll: the redirect to the OIDC interaction may still be in flight
for _ in range(6):
on_interaction = ("openid" in page.url or "interaction" in page.url
or "my.knownelement.com" in page.url)
# 1. Login form (Cloudron Pankow IDs -- no cross-app collision)
pw = page.query_selector("#inputPassword")
if pw and pw.is_visible():
email = agent.get("cloudron_email", f"{name}@turnsys.com")
password = bw.get_item_password(cloudron_item)
page.click("#inputUsername")
page.keyboard.type(email)
page.click("#inputPassword")
page.keyboard.type(password)
page.locator('[role="button"]:has-text("Log in")').first.click()
page.wait_for_timeout(4000)
log.info(f"[{name}] OIDC interaction: login form submitted")
continue
# 2. TOTP
totp = page.query_selector("#inputTotpToken")
if totp and totp.is_visible():
code = bw.get_totp(cloudron_item)
totp.click()
page.keyboard.type(code)
page.locator("#totpTokenSubmitButton").click()
page.wait_for_timeout(5000)
log.info(f"[{name}] OIDC interaction: TOTP submitted")
continue
# 3. Consent (only on the interaction page itself)
if on_interaction:
for text in ["Continue", "Authorize", "Allow", "Accept"]:
loc = page.locator(
f'[role="button"]:has-text("{text}"), button:has-text("{text}")'
)
if loc.count() > 0 and loc.first.is_visible():
loc.first.click()
page.wait_for_timeout(5000)
log.info(f"[{name}] OIDC interaction: consent clicked ({text})")
break
# Done if we've left the interaction pages
if not on_interaction:
return
page.wait_for_timeout(2500)
def provision_gitea(page: Page, agent: dict, bw: BitwardenHelper) -> str: def provision_gitea(page: Page, agent: dict, bw: BitwardenHelper) -> str:
"""Generate a Gitea API token via SSO login. Returns the token.""" """Generate a Gitea API token via SSO login. Returns the token."""
name = agent["name"] name = agent["name"]
@@ -680,7 +758,12 @@ def provision_discourse(page: Page, agent: dict, bw: BitwardenHelper) -> str:
return bw.get_item_password(item_name) return bw.get_item_password(item_name)
url = discourse_cfg.get("url", DISCOURSE_URL) url = discourse_cfg.get("url", DISCOURSE_URL)
username = agent.get("username", name.replace("-", "")) # Discourse caps usernames at 20 chars. Invite-issued usernames are
# often longer (tsgstaff-coo-svptctc-vpinvesting), which fails signup
# validation silently. Default to the short form unless overridden.
username = agent.get("discourse_username", name.replace("-", ""))
if len(username) > 20:
username = name.replace("-", "")[:20]
# --- SSO login --- # --- SSO login ---
page.goto(f"{url}/", wait_until="domcontentloaded", timeout=30000) page.goto(f"{url}/", wait_until="domcontentloaded", timeout=30000)
@@ -695,14 +778,23 @@ def provision_discourse(page: Page, agent: dict, bw: BitwardenHelper) -> str:
sso_btn = page.locator('button:has-text("OpenID")') sso_btn = page.locator('button:has-text("OpenID")')
if sso_btn.count() > 0: if sso_btn.count() > 0:
sso_btn.first.click() sso_btn.first.click()
page.wait_for_timeout(5000) page.wait_for_timeout(3000)
# First login: /signup with email already authenticated by OIDC # The OIDC interaction may need a fresh login/TOTP/consent
handle_oidc_interaction(page, agent, bw)
page.wait_for_timeout(3000)
# First login: /signup with email already authenticated by OIDC.
# Discourse PREFILLS the username field (truncated OIDC username)
# -- appending without clearing produces a too-long concatenation.
# Select-all + delete before typing.
if "/signup" in page.url: if "/signup" in page.url:
page.wait_for_timeout(2000) page.wait_for_timeout(2000)
username_input = page.locator('#new-account-username, input[name="username"]') username_input = page.locator('#new-account-username, input[name="username"]')
if username_input.count() > 0 and username_input.first.is_visible(): if username_input.count() > 0 and username_input.first.is_visible():
username_input.first.click() username_input.first.click()
page.keyboard.press("Control+a")
page.keyboard.press("Delete")
page.keyboard.type(username) page.keyboard.type(username)
page.wait_for_timeout(1000) page.wait_for_timeout(1000)
for btn_text in ["Create Account", "Sign Up", "Register"]: for btn_text in ["Create Account", "Sign Up", "Register"]:
@@ -867,17 +959,11 @@ def provision_redmine(page: Page, agent: dict, bw: BitwardenHelper) -> str:
) )
if sso_btn.count() > 0 and sso_btn.first.is_visible(): if sso_btn.count() > 0 and sso_btn.first.is_visible():
sso_btn.first.click() sso_btn.first.click()
page.wait_for_timeout(5000) page.wait_for_timeout(3000)
if "openid" in page.url.lower(): # The OIDC interaction may need a fresh login/TOTP/consent
for consent in ["Continue", "Authorize", "Allow", "Accept"]: handle_oidc_interaction(page, agent, bw)
cbtn = page.locator( page.wait_for_timeout(3000)
f'[role="button"]:has-text("{consent}"), button:has-text("{consent}")'
)
if cbtn.count() > 0 and cbtn.first.is_visible():
cbtn.first.click()
page.wait_for_timeout(5000)
break
if "/login" in page.url: if "/login" in page.url:
log.error(f"[{name}] Redmine SSO failed (still on login page)") log.error(f"[{name}] Redmine SSO failed (still on login page)")
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""
validate-all-logins.py -- End-to-end phase-1 validation for all COO agents.
For each agent in the manifest (excluding non-COO identities):
1. Fresh Cloudron login: username + password + TOTP from BW
2. Assert we land on an authenticated page
3. If the agent has Gitea/Redmine/Discourse credentials in BW, verify
each API credential against its API
Prints a PASS/FAIL matrix. Exits 1 if any login fails.
Usage:
docker compose run --rm --entrypoint python3 provision validate-all-logins.py
"""
import json
import os
import sys
import time
import urllib.request
from pathlib import Path
import yaml
from playwright.sync_api import sync_playwright
sys.path.insert(0, os.path.dirname(__file__))
from bw_helper import BitwardenHelper
CLOUDRON_BASE = os.environ.get("CLOUDRON_BASE", "https://my.knownelement.com")
GITEA_URL = "https://git.knownelement.com"
REDMINE_URL = "https://projects.knownelement.com"
DISCOURSE_URL = "https://community.turnsys.com"
STATE_DIR = Path("/app/state")
# Identities that are NOT owned by the COO BW account
EXCLUDE = {"cco", "ctpo", "board-secretariat", "director-audit"}
def verify_gitea(token):
req = urllib.request.Request(
f"{GITEA_URL}/api/v1/user",
headers={"Authorization": f"token {token}"},
)
with urllib.request.urlopen(req, timeout=15) as r:
return json.load(r).get("login")
def verify_redmine(key):
req = urllib.request.Request(
f"{REDMINE_URL}/users/current.json",
headers={"X-Redmine-API-Key": key},
)
with urllib.request.urlopen(req, timeout=15) as r:
return json.load(r).get("user", {}).get("login")
def verify_discourse(key):
req = urllib.request.Request(
f"{DISCOURSE_URL}/notifications.json",
headers={"User-Api-Key": key},
)
with urllib.request.urlopen(req, timeout=15) as r:
r.read()
return "ok"
def _validate_agent(bw, context, agent, results):
"""Validate one agent: fresh login + API credential checks."""
name = agent["name"]
email = agent.get("cloudron_email", f"{name}@turnsys.com")
item = f"{name} Cloudron"
row = {"name": name, "cloudron": False, "totp": False,
"gitea": None, "redmine": None, "discourse": None, "err": ""}
try:
if not bw.item_exists(item):
row["err"] = "no BW item"
results.append(row)
print(f"[{name:20s}] FAIL: no Cloudron item in BW")
return
password = bw.get_item_password(item)
page = context.new_page()
page.goto(f"{CLOUDRON_BASE}/login.html",
wait_until="networkidle", timeout=20000)
page.wait_for_timeout(1500)
page.wait_for_selector("#inputPassword", timeout=15000)
page.click("#inputUsername")
page.keyboard.type(email)
page.click("#inputPassword")
page.keyboard.type(password)
page.locator('[role="button"]:has-text("Log in")').first.click()
page.wait_for_timeout(4000)
totp_el = page.query_selector("#inputTotpToken")
if totp_el and totp_el.is_visible():
row["totp"] = True # 2FA prompt appeared = 2FA is ON
code = bw.get_totp(item)
totp_el.click()
page.keyboard.type(code)
page.locator("#totpTokenSubmitButton").click()
page.wait_for_timeout(5000)
authed = ("login" not in page.url.lower()
and "openid" not in page.url.lower()
and "setupaccount" not in page.url.lower())
row["cloudron"] = authed
if not authed:
page.screenshot(path=str(
STATE_DIR / f"validate-fail-{name}-{time.strftime('%H%M%S')}.png"))
row["err"] = f"login stuck at {page.url[:60]}"
page.close()
except Exception as e:
row["err"] = str(e)[:80]
# API credential checks (failures recorded, not fatal)
for sysname, verifier, bwitem in [
("gitea", verify_gitea, f"{name} Gitea"),
("redmine", verify_redmine, f"{name} Redmine"),
("discourse", verify_discourse, f"{name} Discourse"),
]:
try:
if bw.item_exists(bwitem):
row[sysname] = verifier(bw.get_item_password(bwitem))
else:
row[sysname] = "-"
except Exception as e:
row[sysname] = f"ERR {str(e)[:40]}"
results.append(row)
g = row["gitea"] if row["gitea"] is not None else "?"
r = row["redmine"] if row["redmine"] is not None else "?"
d = row["discourse"] if row["discourse"] is not None else "?"
status = "PASS" if row["cloudron"] and row["totp"] else "FAIL"
print(f"[{name:20s}] {status} cloudron={row['cloudron']} "
f"2fa={row['totp']} gitea={g} redmine={r} discourse={d} "
f"{row['err']}")
def main():
bw = BitwardenHelper(
client_id=os.environ["BW_CLIENTID"],
client_secret=os.environ["BW_CLIENTSECRET"],
password=os.environ["BW_PASSWORD"],
server_url=os.environ.get("BW_SERVER", ""),
)
bw.login()
manifest = yaml.safe_load(open("/app/agents.yaml"))
agents = [a for a in manifest["agents"] if a["name"] not in EXCLUDE]
results = []
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 1024})
for agent in agents:
# Fresh context per agent: a shared context carries the previous
# agent's session cookie and login.html never shows the form.
context = browser.new_context(viewport={"width": 1280, "height": 1024})
try:
_validate_agent(bw, context, agent, results)
finally:
context.close()
browser.close()
print("\n=== VALIDATION MATRIX ===")
fails = 0
for row in results:
ok = row["cloudron"] and row["totp"]
if not ok:
fails += 1
print(f" {row['name']:20s} {'OK ' if ok else 'FAIL'} {row['err']}")
sys.exit(1 if fails else 0)
if __name__ == "__main__":
main()
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""
validate-coo.py -- Targeted re-validation of the coo identity.
Verifies the get_totp exact-name fix: fresh login with password + TOTP.
Usage:
docker compose run --rm --entrypoint python3 provision validate-coo.py
"""
import os
import sys
from playwright.sync_api import sync_playwright
sys.path.insert(0, os.path.dirname(__file__))
from bw_helper import BitwardenHelper
CLOUDRON_BASE = "https://my.knownelement.com"
ITEM = "coo Cloudron"
EMAIL = "tsgstaff-coo@turnsys.com"
def main():
bw = BitwardenHelper(
client_id=os.environ["BW_CLIENTID"],
client_secret=os.environ["BW_CLIENTSECRET"],
password=os.environ["BW_PASSWORD"],
server_url=os.environ.get("BW_SERVER", ""),
)
bw.login()
password = bw.get_item_password(ITEM)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 1024})
page = context.new_page()
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(1500)
page.wait_for_selector("#inputPassword", timeout=15000)
page.click("#inputUsername")
page.keyboard.type(EMAIL)
page.click("#inputPassword")
page.keyboard.type(password)
page.locator('[role="button"]:has-text("Log in")').first.click()
page.wait_for_timeout(4000)
totp_el = page.query_selector("#inputTotpToken")
if totp_el and totp_el.is_visible():
print("2FA prompt: YES")
code = bw.get_totp(ITEM)
print(f"TOTP resolved by exact name: {code}")
totp_el.click()
page.keyboard.type(code)
page.locator("#totpTokenSubmitButton").click()
page.wait_for_timeout(5000)
else:
print("2FA prompt: NO (problem)")
authed = ("login" not in page.url.lower()
and "openid" not in page.url.lower()
and "setupaccount" not in page.url.lower())
print(f"coo login: {'PASS' if authed else 'FAIL'} ({page.url})")
sys.exit(0 if authed else 1)
if __name__ == "__main__":
main()