#!/usr/bin/env bash # seat-logins.sh — project seat credentials into tea's login config. # # Issue: mosaicstack/stack#1356 (tea login resolution fails open). # # WHY THIS EXISTS. tea 0.14.0 has no --token on its operations; it can only use a # login already stored in ~/.config/tea/config.yml. So the wrappers cannot read the # seat secrets dir on the tea path. The secrets dir stays authoritative and this # script projects it into tea's config, which is a DERIVED CACHE: regenerate it, # never hand-edit it. Same shape as the config-registry projector, same reason — # a third-party tool that cannot read our store has to be fed. # # Canonical login name is "-", which is what the identity ladder in # detect-platform.sh computes from the seat name. A login the ladder cannot compute # is a fail-open surface, so an ad-hoc name is a defect, not a style. # # COLLISIONS. tea refuses to store one token under two names ("token already been # used, delete login 'X' first"). A hand-made alias holding a seat's token there- # fore BLOCKS its canonical name. Detected up front by hashing, so a dry run shows # it; --adopt resolves it by deleting the alias and re-minting canonically. Same # token, same access, only the label changes. # # Tokens are never printed, never logged, and never passed on a visible command # line beyond tea's own --token, which is unavoidable with this client. tea's # stderr is echoed on failure with any token-shaped string redacted. # # Usage: # seat-logins.sh # dry run, all seats (default: changes nothing) # seat-logins.sh --apply # mint/refresh all seats # seat-logins.sh --seat # limit to one seat # seat-logins.sh --apply --adopt # also rename ad-hoc aliases to canonical names set -euo pipefail BRAIN_HOME="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}" TEA_CONFIG="${TEA_CONFIG:-$HOME/.config/tea/config.yml}" APPLY=0 ADOPT=0 ONLY_SEAT="" # Instance -> server URL. # # Instances are named here because there is no registry to read them from yet. # Override per-instance without editing this file, which is how a deployment adds # its own hosts: MOSAIC_GITEA_URL_=https://... declare -A INSTANCE_URL=( [mosaicstack]="https://git.mosaicstack.dev" [usc]="https://git.uscllc.com" ) while [ $# -gt 0 ]; do case "$1" in --apply) APPLY=1; shift ;; --adopt) ADOPT=1; shift ;; --seat) ONLY_SEAT="${2:?--seat needs a name}"; shift 2 ;; -h|--help) sed -n '2,33p' "$0"; exit 0 ;; *) echo "seat-logins.sh: unknown argument '$1'" >&2; exit 2 ;; esac done command -v tea >/dev/null || { echo "seat-logins.sh: tea not on PATH" >&2; exit 1; } url_for() { local inst="$1" ovr ovr="MOSAIC_GITEA_URL_$(printf '%s' "$inst" | tr '[:lower:]-' '[:upper:]_')" if [ -n "${!ovr:-}" ]; then printf '%s' "${!ovr}"; return 0; fi printf '%s' "${INSTANCE_URL[$inst]:-}" } # Redact anything token-shaped before any tea output reaches a log. redact() { sed -E 's/[A-Za-z0-9]{30,}//g'; } # token sha256 -> login name, for every login tea already holds. This is what # makes collisions visible in a DRY RUN instead of only as an apply-time error. declare -A TOKEN_OWNER=() if [ -r "$TEA_CONFIG" ]; then while read -r sha lname; do [ -n "${sha:-}" ] && TOKEN_OWNER["$sha"]="$lname" done < <(python3 - "$TEA_CONFIG" <<'PY' import sys, yaml, hashlib try: cfg = yaml.safe_load(open(sys.argv[1])) or {} except Exception: sys.exit(0) for l in (cfg.get('logins') or []): t = l.get('token') if t: print(hashlib.sha256(t.encode()).hexdigest(), l.get('name')) PY ) fi minted=0; refreshed=0; skipped=0; failed=0; planned=0; adopted=0; blocked=0 existing="$(tea login list --output simple 2>/dev/null | awk '{print $1}' || true)" shopt -s nullglob for tokfile in "$BRAIN_HOME"/fleet/agents/*/secrets/gitea-*.token; do seat="${tokfile#"$BRAIN_HOME"/fleet/agents/}"; seat="${seat%%/*}" [ -n "$ONLY_SEAT" ] && [ "$seat" != "$ONLY_SEAT" ] && continue base="$(basename "$tokfile" .token)" # gitea-- inst="${base#gitea-}"; inst="${inst%-"$seat"}" name="${inst}-${seat}" url="$(url_for "$inst")" if [ -z "$url" ]; then echo " SKIP $name — no URL known for instance '$inst' (set MOSAIC_GITEA_URL_${inst^^})" skipped=$((skipped+1)); continue fi if [ ! -r "$tokfile" ]; then echo " SKIP $name — token not readable" skipped=$((skipped+1)); continue fi action="mint" grep -qx "$name" <<<"$existing" && action="refresh" # Is this exact token already stored under some OTHER name? tsha="$(sha256sum < "$tokfile" | awk '{print $1}')" owner="${TOKEN_OWNER[$tsha]:-}" collision="" [ -n "$owner" ] && [ "$owner" != "$name" ] && collision="$owner" if [ "$APPLY" -eq 0 ]; then if [ -n "$collision" ]; then if [ "$ADOPT" -eq 1 ]; then echo " PLAN adopt $collision -> $name ($url)" else echo " BLOCK $name — token already stored as '$collision'; re-run with --adopt" blocked=$((blocked+1)); continue fi else echo " PLAN $action $name -> $url" fi planned=$((planned+1)); continue fi if [ -n "$collision" ]; then if [ "$ADOPT" -eq 0 ]; then echo " BLOCK $name — token already stored as '$collision'; re-run with --adopt" blocked=$((blocked+1)); continue fi tea login delete "$collision" >/dev/null 2>&1 || true action="adopt" fi # tea has no idempotent add; refresh is delete-then-add so a rotated token lands. [ "$action" = refresh ] && tea login delete "$name" >/dev/null 2>&1 || true if err="$(tea login add --name "$name" --url "$url" \ --token "$(cat "$tokfile")" --no-version-check 2>&1 >/dev/null)"; then case "$action" in mint) minted=$((minted+1)) ;; refresh) refreshed=$((refreshed+1)) ;; adopt) adopted=$((adopted+1)) ;; esac if [ "$action" = adopt ]; then echo " OK adopt $collision -> $name ($url)" else echo " OK $action $name -> $url" fi else # A failure here is real information: the seat's token is dead, or the server # refused it. Do not paper over it; the seat cannot act until it is reminted. # tea's own words, redacted — a summarised FAIL hides whether the cause is the # credential or the client, which cost a diagnosis on 2026-08-21. echo " FAIL $action $name -> $url" echo " tea: $(printf '%s' "$err" | redact | head -1)" failed=$((failed+1)) fi done echo if [ "$APPLY" -eq 0 ]; then echo "dry run: $planned login(s) would be written, $skipped skipped, $blocked blocked." [ "$blocked" -gt 0 ] && echo "re-run with --adopt to rename ad-hoc aliases to canonical names." echo "no changes made. re-run with --apply." else echo "minted=$minted adopted=$adopted refreshed=$refreshed skipped=$skipped blocked=$blocked failed=$failed" fi [ "$failed" -eq 0 ] && [ "$blocked" -eq 0 ]