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).
This commit is contained in:
2026-08-14 13:53:01 -05:00
parent 1b8bd843d9
commit a4a54f553e
6 changed files with 495 additions and 15 deletions
+46 -2
View File
@@ -24,6 +24,7 @@ import os
import subprocess
import sys
import tempfile
import time
from typing import Optional
@@ -38,6 +39,7 @@ class BitwardenHelper:
self.totp_secret = totp_secret
self.server_url = server_url
self.session: Optional[str] = None
self._last_sync: float = 0.0
def _run_bw(self, args: list[str], capture: bool = True) -> str:
"""Run a bw CLI command with the active session."""
@@ -121,6 +123,43 @@ class BitwardenHelper:
not appear in list/search results.
"""
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:
"""Generate a strong password."""
@@ -147,8 +186,11 @@ class BitwardenHelper:
Returns the item ID if exactly one match exists, None if no match,
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:
self._sync_if_stale()
output = self._run_bw(["list", "items", "--search", name])
items = json.loads(output)
# Filter to exact name matches (bw search is fuzzy)
@@ -228,8 +270,9 @@ class BitwardenHelper:
item["collectionIds"] = [collection_id]
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)
self.sync()
return created.get("id", "")
def update_item(
@@ -271,8 +314,9 @@ class BitwardenHelper:
]
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)
self.sync()
return updated.get("id", item_id)
# -------------------------------------------------------------------