Compare commits

...
2 Commits
Author SHA1 Message Date
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
5 changed files with 175 additions and 59 deletions
+41 -44
View File
@@ -1,55 +1,52 @@
# STATUS.md — Agent Identity Provisioning
**Last updated:** 2026-08-13 (Session 2)
**Phase:** Active development — vp-techops provisioning in progress
**Last updated:** 2026-08-14 (Session 3)
**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
- [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)
BW vault: 15 items across 10 agents (see JOURNAL.md session 3 table).
### 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)
- [ ] Integrate all flows into provision-agent.py main script
- [ ] Provision remaining agents (vp-secops, vp-techcompliance, coo, svp-knel, svp-tctc)
- Provisioner loop proven at scale: 8-agent batch run + cleanup pass.
Invite acceptance + 2FA selectors verified on all 10 identities.
- 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 |
|---|---|---|---|
| vp-techops Cloudron | tsgstaff-coo-vptechops@turnsys.com | 32 chars | Enabled |
| vp-techops Discourse | vptechops | 32-char API key | N/A |
| vp-techops Gitea | vptechops | 40-char token | N/A |
| 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)
- Clear the two blockers above, then re-run
`docker compose run --rm provision --agent vp-secops` (idempotent).
- Add director/IC identities to agents.yaml once H22026.md settles the
org chart (worker/reviewer pairs, audit independence).
- Q4 agents: fill `systems:` blocks as their charters firm up.
+1
View File
@@ -22,3 +22,4 @@ services:
- ./provision-discourse-apikey.py:/app/provision-discourse-apikey.py:ro
- ./provision-redmine.py:/app/provision-redmine.py:ro
- ./merge-invites.py:/app/merge-invites.py:ro
- ./dump-invite-page.py:/app/dump-invite-page.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 |
| Discourse | 32-char user key | `GET /latest.json` with User-Api-Key |
| 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()
+30 -15
View File
@@ -119,28 +119,33 @@ def enroll_cloudron(
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 = <div role="button"> "Set up" (disabled until form valid)
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.wait_for_selector("#inputPassword", timeout=15000)
page.fill('#inputPassword', password)
page.fill('#inputPasswordRepeat', password)
# 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)
# 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()
# 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:
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")
# Enable 2FA
@@ -177,6 +182,12 @@ def enable_cloudron_2fa(page: Page, agent: dict, bw: BitwardenHelper) -> str:
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)
@@ -198,11 +209,15 @@ def enable_cloudron_2fa(page: Page, agent: dict, bw: BitwardenHelper) -> str:
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)