From 888a6ad29b8a3235780d1b3ab6387ec6dfc65c69 Mon Sep 17 00:00:00 2001 From: fred Date: Fri, 21 Aug 2026 23:04:31 +0000 Subject: [PATCH] fix(#1356): tea login resolution fails closed on a declared git identity (#1361) Co-authored-by: fred --- .woodpecker/ci.yml | 5 + .../framework/tools/fleet/seat-logins.sh | 181 ++++++++++++++++++ packages/mosaic/framework/tools/git/README.md | 30 +++ .../framework/tools/git/detect-platform.sh | 123 +++++++++++- .../mosaic/framework/tools/git/issue-list.sh | 2 +- .../mosaic/framework/tools/git/lane-brief.sh | 26 ++- .../mosaic/framework/tools/git/pr-list.sh | 2 +- .../mosaic/framework/tools/git/pr-view.sh | 2 +- .../tools/git/test-gitea-login-resolution.sh | 144 +++++++++++++- .../tools/git/test-issue-close-fail-closed.sh | 15 ++ .../git/test-issue-create-body-safety.sh | 6 + .../quality/test-enumeration-exclusions.txt | 1 - scripts/verify-release.mjs | 1 + 13 files changed, 519 insertions(+), 19 deletions(-) create mode 100755 packages/mosaic/framework/tools/fleet/seat-logins.sh diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index 5427677a..932172a1 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -91,6 +91,11 @@ steps: # and sandboxes a throwaway git repo, so it resolves no real credentials and # joins CI directly rather than the exclusions file. - bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh + # Hermetic regression for the git identity ladder (#1356): mock tea on PATH, + # sandboxed repo, no real credentials (3/3 green under an empty HOME). Pins + # fail-closed: a seat whose login is missing gets a named error, never a + # borrowed identity. Joins CI directly; its #1007 exclusion is burned down. + - bash packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh # Hermetic behavioural regression for the PreToolUse wrapper guard: proves # it still blocks the three mistakes AND still lets reads, unwrapped # endpoints and ordinary commands through. Both directions are asserted — diff --git a/packages/mosaic/framework/tools/fleet/seat-logins.sh b/packages/mosaic/framework/tools/fleet/seat-logins.sh new file mode 100755 index 00000000..01b76c59 --- /dev/null +++ b/packages/mosaic/framework/tools/fleet/seat-logins.sh @@ -0,0 +1,181 @@ +#!/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 ] diff --git a/packages/mosaic/framework/tools/git/README.md b/packages/mosaic/framework/tools/git/README.md index caf14e01..cd6115d2 100644 --- a/packages/mosaic/framework/tools/git/README.md +++ b/packages/mosaic/framework/tools/git/README.md @@ -102,6 +102,36 @@ of their own — `MOSAIC_GIT_IDENTITY=` with a provisioned slot. There is de environment variable that restores the fallback; one would reintroduce exactly the substitution this removes. +### The tea path: login resolution (#1356) + +The wrappers that go through `tea` (`issue-list.sh`, `pr-list.sh`, `pr-view.sh`, +`lane-brief.sh`, and the tea half of `issue-close.sh`) cannot use a token directly: tea +0.14 only acts as a **login** already stored in `~/.config/tea/config.yml`. Those wrappers +therefore resolve a login name, not a token, and the resolution follows the same identity +as above: + +1. Resolve the identity (`MOSAIC_GIT_IDENTITY`, then `git config mosaic.gitIdentity`). +2. Derive the Gitea instance from the repo host (`git.mosaicstack.dev` → `mosaicstack`, + `git.uscllc.com` → `usc`), or from the owner when `--repo owner/name` is given. +3. The canonical login is `-`. If tea has it, that login acts. +4. If the identity is set but that login is missing, the wrapper **fails closed**: nonzero + exit, empty stdout, and a stderr line naming the login it wanted and the source of the + identity. When `tea` itself is not installed the message says so instead, since "no such + login" would send the reader to create a login they cannot create. +5. With **no identity set**, the old host-default behaviour is unchanged (first login + configured for that host, else the API fallback). + +Step 4 replaced a fallback that picked any login configured for the host, which meant a +seat with no login of its own silently acted as whichever seat had configured one. That +satisfied the author≠reviewer gate on paper while one actor held both names. + +**Provisioning the logins.** `tools/fleet/seat-logins.sh` projects each seat's token from +its secrets store into tea's config under the canonical name. tea's config is a derived +cache of the secrets store: regenerate it with the script, never hand-edit it. Run it with +`--seat ` for one seat (all seats when omitted), dry-run by default, `--apply` to write. A hand-made +alias holding a seat's token blocks its canonical name (tea refuses one token under two +names); `--adopt` renames it. + ### Enabling it for a clone The framework installer syncs `git-credential-mosaic` to diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 00b73612..c293f2e7 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -180,6 +180,66 @@ raise SystemExit(1) PY } +# Map a host to the instance prefix used in canonical tea login names +# ("-"). This deliberately mirrors the _idpfx case in +# get_gitea_token(): the two credential paths must agree on what a host is called, +# or an agent authenticates as itself on one path and as somebody else on the other. +gitea_instance_for_host() { + case "${1:-}" in + git.uscllc.com) echo usc ;; + git.mosaicstack.dev) echo mosaicstack ;; + *) return 1 ;; + esac +} + +# Resolve the acting git identity, same precedence as get_gitea_token() step 0. +# Prints "\t" so the caller can name the source in an error. +resolve_git_identity() { + local ident src + ident="${MOSAIC_GIT_IDENTITY:-}" + src="MOSAIC_GIT_IDENTITY" + if [[ -z "$ident" ]]; then + ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)" + src="git config mosaic.gitIdentity" + fi + [[ -n "$ident" ]] || return 1 + printf '%s\t%s\n' "$ident" "$src" +} + +# Map a repo owner to an instance. Used only by the --repo override path, which +# has an owner and no host. Previously lived inline in lane-brief.sh; one copy so +# the two override callers cannot drift apart. +gitea_instance_for_owner() { + local owner="${1:-}" + owner="${owner%%/*}" + case "$owner" in + usc|USC) echo usc ;; + mosaicstack|mosaic) echo mosaicstack ;; + *) return 1 ;; + esac +} + +# Does a login of this name exist at all? The --repo override path cannot check +# host agreement, because it has no host. +tea_login_exists() { + local login_name="$1" + local logins_json + command -v tea >/dev/null 2>&1 || return 1 + logins_json=$(tea login list --output json 2>/dev/null) || return 1 + TEA_LOGINS_JSON="$logins_json" python3 - "$login_name" <<'PY_INNER' +import json, os, sys +want = sys.argv[1] +try: + logins = json.loads(os.environ.get("TEA_LOGINS_JSON", "[]")) +except Exception: + raise SystemExit(1) +for login in logins if isinstance(logins, list) else []: + if str(login.get("name") or login.get("Name") or "") == want: + raise SystemExit(0) +raise SystemExit(1) +PY_INNER +} + tea_login_matches_host() { local login_name="$1" host="$2" local logins_json @@ -276,6 +336,40 @@ get_gitea_login_for_host() { fi fi + # IDENTITY LADDER (#1356). Below this point the old code took the FIRST login + # matching the host, which is not an identity — with 43 logins on a fleet host, + # ~22 match one server, so a seat with no login of its own silently acted as + # whichever happened to be first in ~/.config/tea/config.yml. Gate 16 depends on + # author != reviewer, and borrowing satisfies it mechanically while violating it + # in fact. The token path already refuses to borrow; this is the same refusal. + # + # Enforced ONLY when an identity is resolvable, exactly like get_gitea_token(): + # no identity means a human at a terminal, and neither path enforces there. + local ident ident_src inst canon + if IFS=$'\t' read -r ident ident_src < <(resolve_git_identity); then + if inst=$(gitea_instance_for_host "$host"); then + canon="${inst}-${ident}" + if tea_login_matches_host "$canon" "$host"; then + echo "$canon" + return 0 + fi + # Say which of the two it is. "No such login" when tea is simply not + # installed is a diagnosis of a cause that was never checked, and it + # sends the reader off to create a login they cannot create. + if ! command -v tea >/dev/null 2>&1; then + echo "Error: git identity '$ident' requested (via $ident_src) for host '$host', but tea is not installed," >&2 + echo " so no login can be resolved. Refusing to guess an identity." >&2 + return 1 + fi + echo "Error: git identity '$ident' requested (via $ident_src) for host '$host', but no tea login named '$canon' exists." >&2 + echo " Refusing to borrow another login. Acting as a different identity would satisfy gate 16 mechanically while violating it." >&2 + echo " Create it with: ~/.config/mosaic/tools/fleet/seat-logins.sh --apply --seat $ident" >&2 + return 1 + fi + # Identity known but the host is not a Mosaic instance. Fall through: the + # canonical name is undefined for it, so there is nothing to enforce. + fi + login=$(find_tea_login_for_host "$host" || true) if [[ -n "$login" ]]; then echo "$login" @@ -351,14 +445,41 @@ raise SystemExit(1) PY } +# Resolve a login for an explicit --repo override, which supplies an owner and no +# host. Takes "owner" or "owner/repo". +# +# The old body fell through to get_default_tea_login(), which returns the +# default-marked login or, failing that, the first login of ANY host — arbitrary +# identity, chosen by config file order. That is the #1356 fail-open in its worst +# form, because unlike the host path it does not even constrain the server. get_gitea_login_for_repo_override() { - local login + local owner="${1:-}" + local login ident ident_src inst canon if [[ -n "${GITEA_LOGIN:-}" ]]; then echo "$GITEA_LOGIN" return 0 fi + if IFS=$'\t' read -r ident ident_src < <(resolve_git_identity); then + if inst=$(gitea_instance_for_owner "$owner"); then + canon="${inst}-${ident}" + if tea_login_exists "$canon"; then + echo "$canon" + return 0 + fi + echo "Error: git identity '$ident' (via $ident_src) has no tea login '$canon' for owner '${owner%%/*}'." >&2 + echo " Create it with: ~/.config/mosaic/tools/fleet/seat-logins.sh --apply --seat $ident" >&2 + return 1 + fi + echo "Error: git identity '$ident' (via $ident_src) is set, but owner '${owner%%/*}' maps to no known instance," >&2 + echo " so the login name cannot be derived. Refusing to fall back to an arbitrary login." >&2 + echo " Set GITEA_LOGIN to name the login explicitly." >&2 + return 1 + fi + + # No identity: a human at a terminal. Unchanged, and the same place the token + # path stops enforcing. login=$(get_default_tea_login || true) if [[ -n "$login" ]]; then echo "$login" diff --git a/packages/mosaic/framework/tools/git/issue-list.sh b/packages/mosaic/framework/tools/git/issue-list.sh index b6f9bc19..16ad1520 100755 --- a/packages/mosaic/framework/tools/git/issue-list.sh +++ b/packages/mosaic/framework/tools/git/issue-list.sh @@ -99,7 +99,7 @@ case "$PLATFORM" in ;; gitea) if [[ -n "$REPO_OVERRIDE" ]]; then - GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override) || { + GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override "$REPO_OVERRIDE") || { echo "Error: Could not resolve Gitea login for --repo override. Set GITEA_LOGIN or configure a default tea login." >&2 exit 1 } diff --git a/packages/mosaic/framework/tools/git/lane-brief.sh b/packages/mosaic/framework/tools/git/lane-brief.sh index 336a51a5..5a83fbe9 100755 --- a/packages/mosaic/framework/tools/git/lane-brief.sh +++ b/packages/mosaic/framework/tools/git/lane-brief.sh @@ -49,11 +49,27 @@ if [[ -z "$LOGIN" ]]; then if [[ -n "${GITEA_LOGIN:-}" ]]; then LOGIN="$GITEA_LOGIN" else - case "${REPO%%/*}" in - usc|USC) LOGIN=usc ;; - mosaicstack|mosaic) LOGIN=mosaicstack ;; - *) LOGIN="$(get_gitea_login_for_repo_override 2>/dev/null || true)" ;; - esac + # #1356: the owner-derived map below picks a SHARED login (bare `usc` / + # `mosaicstack`). On a seat that is borrowing another identity, which is + # exactly what gate 16 forbids. So the identity ladder goes first and the + # map is only the no-identity fallback (a human at a terminal), which is + # where the token path stops enforcing too. + if LOGIN="$(get_gitea_login_for_repo_override "$REPO")"; then + : + elif resolve_git_identity >/dev/null 2>&1; then + # A git identity IS set and the ladder still could not resolve a login. + # The named reason is already on stderr. Falling through to the map here + # would hand this seat a SHARED login (bare `usc` / `mosaicstack`) — the + # identity-borrowing #1356 exists to stop. Fail closed instead. + exit 2 + else + # No identity: a human at a terminal. Owner-derived map, unchanged. This + # is the same point at which the token path stops enforcing. + case "${REPO%%/*}" in + usc|USC) LOGIN=usc ;; + mosaicstack|mosaic) LOGIN=mosaicstack ;; + esac + fi fi fi [[ -n "$LOGIN" ]] || { echo "FATAL: could not resolve a Gitea login for $REPO (pass -L or set GITEA_LOGIN)" >&2; exit 2; } diff --git a/packages/mosaic/framework/tools/git/pr-list.sh b/packages/mosaic/framework/tools/git/pr-list.sh index de5c741e..9d4320ff 100755 --- a/packages/mosaic/framework/tools/git/pr-list.sh +++ b/packages/mosaic/framework/tools/git/pr-list.sh @@ -94,7 +94,7 @@ case "$PLATFORM" in ;; gitea) if [[ -n "$REPO_OVERRIDE" ]]; then - GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override) || { + GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override "$REPO_OVERRIDE") || { echo "Error: Could not resolve Gitea login for --repo override. Set GITEA_LOGIN or configure a default tea login." >&2 exit 1 } diff --git a/packages/mosaic/framework/tools/git/pr-view.sh b/packages/mosaic/framework/tools/git/pr-view.sh index 8a1283af..dcc3dc66 100755 --- a/packages/mosaic/framework/tools/git/pr-view.sh +++ b/packages/mosaic/framework/tools/git/pr-view.sh @@ -59,7 +59,7 @@ if [[ "$PLATFORM" == "github" ]]; then gh pr view "$PR_NUMBER" --repo "$REPO_INFO" elif [[ "$PLATFORM" == "gitea" ]]; then if [[ -n "$REPO_OVERRIDE" ]]; then - GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override) || { + GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override "$REPO_OVERRIDE") || { echo "Error: Could not resolve Gitea login for --repo override. Set GITEA_LOGIN or configure a default tea login." >&2 exit 1 } diff --git a/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh b/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh index c619d134..5c1a9b60 100755 --- a/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh +++ b/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh @@ -110,8 +110,20 @@ chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl" run_in_repo() { ( + # HERMETICITY, second half (#1356). The empty repo-local `mosaic.gitIdentity` + # above pins the git-config route into identity resolution. It does NOT pin + # the environment route, and MOSAIC_GIT_IDENTITY is checked FIRST — so on any + # provisioned seat, where the launcher exports it, this suite failed before + # any change: rc=1 as-is, rc=0 under `env -u MOSAIC_GIT_IDENTITY`, one + # variable. A suite that cannot run on a seat cannot guard this code for the + # agents that actually run it. + # + # Unset rather than set empty: an empty MOSAIC_GIT_IDENTITY and an absent one + # take different branches in resolve_git_identity(), and the case under test + # is "no identity at all". cd "$REPO_DIR" - PATH="$BIN_DIR:$PATH" \ + env -u MOSAIC_GIT_IDENTITY \ + PATH="${_SANDBOX_BIN:-$BIN_DIR}:$PATH" \ HOME="$HOME_DIR" \ MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ MOSAIC_TEST_LOG="$LOG_FILE" \ @@ -307,14 +319,11 @@ SH chmod +x "$BIN_DIR2/tea" run_in_repo2() { - ( - cd "$REPO_DIR" - PATH="$BIN_DIR2:$PATH" \ - HOME="$HOME_DIR" \ - MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ - MOSAIC_TEST_LOG="$LOG_FILE" \ - "$@" - ) + # Same sandbox as run_in_repo, different mock tea (BIN_DIR2 defines a + # mosaicstack login). This MUST delegate rather than re-implement: it was a + # copy once, and the copy silently missed the MOSAIC_GIT_IDENTITY unset, so + # the suite kept failing on a seat after run_in_repo was already fixed. + _SANDBOX_BIN="$BIN_DIR2" run_in_repo "$@" } git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git @@ -340,6 +349,123 @@ if [[ "$override_wins" != "mosaicstack" ]]; then fi git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git +# --------------------------------------------------------------------------- +# #1356: the git-identity ladder. A seat declares who it is (MOSAIC_GIT_IDENTITY +# or `git config mosaic.gitIdentity`); resolution must use THAT seat's login and +# must REFUSE to borrow another one when it is absent. Silently borrowing +# satisfies gate 16 mechanically (a review exists) while violating it (the +# reviewer and the author are the same actor under two names). +# +# BIN_DIR3 mocks a tea that holds a canonical per-seat login, which is what a +# projected seat looks like. BIN_DIR2 (mosaicstack only) is reused as the +# "seat has no login" case — no third mock needed for the negative branch. +# --------------------------------------------------------------------------- +BIN_DIR3="$WORK_DIR/bin3" +mkdir -p "$BIN_DIR3" +cp "$BIN_DIR/curl" "$BIN_DIR3/curl" +cat > "$BIN_DIR3/tea" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "login list --output json" ]]; then + cat <<'JSON' +[ + {"name":"mosaicstack","url":"https://git.mosaicstack.dev","user":"ci-bot"}, + {"name":"mosaicstack-testseat","url":"https://git.mosaicstack.dev","user":"testseat"}, + {"name":"usc","url":"https://git.uscllc.com","user":"ci-bot"} +] +JSON + exit 0 +fi +printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG" +exit 0 +SH +chmod +x "$BIN_DIR3/tea" + +run_in_repo3() { _SANDBOX_BIN="$BIN_DIR3" run_in_repo "$@"; } + +git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git + +# Branch 1 (host path): identity set, canonical login PRESENT -> that login wins +# over the shared `mosaicstack` one, which is what host-matching alone would pick. +ladder_hit=$(run_in_repo3 env MOSAIC_GIT_IDENTITY=testseat bash -c ' + source "'"$SCRIPT_DIR"'/detect-platform.sh" + get_gitea_login_for_host git.mosaicstack.dev +') +if [[ "$ladder_hit" != "mosaicstack-testseat" ]]; then + echo "Expected identity ladder to select 'mosaicstack-testseat'; got '$ladder_hit'" >&2 + exit 1 +fi + +# CONTROL for branch 1: the same mock, no identity, must still resolve by host. +# Without this, branch 1 passing proves nothing about the ladder specifically -- +# it would also pass if the code just picked the last matching login. +ladder_none=$(run_in_repo3 bash -c ' + source "'"$SCRIPT_DIR"'/detect-platform.sh" + get_gitea_login_for_host git.mosaicstack.dev +') +if [[ "$ladder_none" != "mosaicstack" ]]; then + echo "Expected no-identity host resolution to stay 'mosaicstack'; got '$ladder_none'" >&2 + exit 1 +fi + +# Branch 2 (host path): identity set, canonical login ABSENT -> fail closed with a +# named error. Two assertions, and they are not the same one twice: rc!=0 proves +# it refused, and the ABSENCE of any login on stdout proves it did not borrow the +# `mosaicstack` login that is sitting right there matching the host. +ladder_err=$(run_in_repo2 env MOSAIC_GIT_IDENTITY=testseat bash -c ' + source "'"$SCRIPT_DIR"'/detect-platform.sh" + get_gitea_login_for_host git.mosaicstack.dev +' 2>&1 1>/dev/null || true) +ladder_out=$(run_in_repo2 env MOSAIC_GIT_IDENTITY=testseat bash -c ' + source "'"$SCRIPT_DIR"'/detect-platform.sh" + get_gitea_login_for_host git.mosaicstack.dev +' 2>/dev/null || true) +if [[ -n "$ladder_out" ]]; then + echo "Identity ladder BORROWED login '$ladder_out' instead of failing closed" >&2 + exit 1 +fi +if ! grep -q "mosaicstack-testseat" <<<"$ladder_err"; then + echo "Expected the error to name the login it wanted; got: $ladder_err" >&2 + exit 1 +fi + +# Branch 3: `git config mosaic.gitIdentity` is the second rung and must work when +# the environment variable is absent -- a seat may be configured either way. +git -C "$REPO_DIR" config mosaic.gitIdentity testseat +ladder_gitcfg=$(run_in_repo3 bash -c ' + source "'"$SCRIPT_DIR"'/detect-platform.sh" + get_gitea_login_for_host git.mosaicstack.dev +') +git -C "$REPO_DIR" config --unset mosaic.gitIdentity || true +if [[ "$ladder_gitcfg" != "mosaicstack-testseat" ]]; then + echo "Expected git-config identity rung to select 'mosaicstack-testseat'; got '$ladder_gitcfg'" >&2 + exit 1 +fi + +# Branch 4 (--repo override path): same rule, owner-derived instead of host-derived. +override_ladder=$(run_in_repo3 env MOSAIC_GIT_IDENTITY=testseat bash -c ' + source "'"$SCRIPT_DIR"'/detect-platform.sh" + get_gitea_login_for_repo_override mosaicstack/stack +') +if [[ "$override_ladder" != "mosaicstack-testseat" ]]; then + echo "Expected --repo override ladder to select 'mosaicstack-testseat'; got '$override_ladder'" >&2 + exit 1 +fi + +# Branch 5: explicit GITEA_LOGIN outranks the ladder. An operator naming a login +# by hand is a deliberate act, not an accident to be second-guessed. +override_explicit=$(run_in_repo3 env MOSAIC_GIT_IDENTITY=testseat GITEA_LOGIN=mosaicstack bash -c ' + source "'"$SCRIPT_DIR"'/detect-platform.sh" + get_gitea_login_for_repo_override mosaicstack/stack +') +if [[ "$override_explicit" != "mosaicstack" ]]; then + echo "Expected explicit GITEA_LOGIN to outrank the identity ladder; got '$override_explicit'" >&2 + exit 1 +fi + +git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git + + # --------------------------------------------------------------------------- # #865 Blocker 1 & 2: get_gitea_token_for_login must resolve the SAME token as # PyYAML would (or fail closed identically) even when PyYAML is ABSENT, and must diff --git a/packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh b/packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh index fdc774dd..e3d02e2c 100755 --- a/packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh +++ b/packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh @@ -76,7 +76,22 @@ exit 0 EOF chmod +x "$MOCK_BIN/tea" } +# #1356: login resolution is now identity-aware, so the tea-branch fixture must +# offer the login the RUNNER's identity resolves to; otherwise every case below +# fails closed before reaching the branch under test. +# +# This does NOT make the suite hermetic, and it is not trying to. The API-path +# cases (5-7) need a usable Gitea token, and with an identity set the token path +# reads that seat's credential file rather than the GITEA_TOKEN exported above. +# So this suite passes only where the runner owns a real credential for its own +# identity, and fails with no identity at all -- on this branch and on its base +# alike. That is a pre-existing hole in the fixture, filed separately; pinning a +# synthetic identity here would only convert it into a confident-looking green. +_LOGIN_IDENT="${MOSAIC_GIT_IDENTITY:-}" LOGIN_JSON='[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]' +if [[ -n "$_LOGIN_IDENT" ]]; then + LOGIN_JSON='[{"name":"mosaicstack-'"$_LOGIN_IDENT"'","url":"https://git.mosaicstack.dev"},{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]' +fi # The mocks must be the ones that run. Without this, a failed setup silently falls through # to the real tea/curl and the "test" mutates the real provider. diff --git a/packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh b/packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh index 1c95c3ca..1759e09b 100755 --- a/packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh +++ b/packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh @@ -10,6 +10,12 @@ set -euo pipefail +# HERMETICITY (#1356): this suite's subject is body quoting, not identity. An +# ambient MOSAIC_GIT_IDENTITY (every provisioned seat exports one) would make the +# identity ladder demand a per-seat login this fixture does not define, and the +# suite would fail for a reason it is not testing. +unset MOSAIC_GIT_IDENTITY + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-body-safety}" REPO_DIR="$WORK_DIR/repo" diff --git a/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt b/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt index bbd1b34d..fa48e012 100644 --- a/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt +++ b/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt @@ -13,7 +13,6 @@ # --- tools/git: the #1007 five — non-hermetic, resolve real credentials --- packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix (git -C scoping) packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix -packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh | resolves real credentials (#1007 census, fourth entry via family-grep); joins CI after the wrapper-half hermeticity fix packages/mosaic/framework/tools/git/test-issue-comment-readback.sh | resolves real credentials (#1007 census, fifth entry); joins CI after the wrapper-half hermeticity fix diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index 0e23adec..1d83c92b 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -61,6 +61,7 @@ export const STAGES = [ 'bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh --self-test', 'bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh', 'bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh', + 'bash packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh', 'bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh', 'bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh', ],