Compare commits

..
Author SHA1 Message Date
code-infra-01 cb77c7d629 fix(credentials): gitea arms resolve seat slots — fail loud, no silent service fallback
ci/woodpecker/pr/ci Pipeline was successful
On a fleet host, load_credentials gitea-mosaicstack / gitea-usc now resolve
a git identity's SEAT SLOT (MOSAIC_GIT_IDENTITY with a directory under
MOSAIC_BRAIN_HOME/fleet/agents/) as the token source, mirroring
get_gitea_token in detect-platform.sh (the #1311 lineage). URL still comes
from credentials.json (provider config, not identity). A seat with an empty
or missing slot REFUSES (rc 1, identity and slot path named) rather than
falling back to the shared service store — the same no-fallback rule the
git credential helper and detect-platform enforce; a silent fallback would
act as the wrong identity (#1343 family, usc/uconnect#3084 precedent).
No identity resolved, or a non-seat identity: the service store, unchanged.
Other services untouched (no seat concept to invent).

Hermetic suite test-credentials-gitea-seats.sh (sandbox brain + sandbox
credentials.json; no real credential read), CI-reachable via
test:framework-shell. Six pins: slot-sourced token, seat-miss refusal
without fallback (token compared against the service value), no-identity
service path, non-seat identity, woodpecker indifference to the identity,
pre-set GITEA_TOKEN never overridden. Mutants killed: seat-miss-falls-back
(dies at G2), seat-reads-service-store (dies at G1).

Known pre-existing (out of scope, unchanged by this PR): this host's
credentials.json stores gitea tokens under .gitea.<inst>.default and
per-identity keys, while the loader reads .gitea.<inst>.token — the
no-identity service path fails identically before and after this change
(verified against origin/next's loader). Filed for the wrapper-defect
batch rather than widened here.
2026-08-22 00:24:03 -05:00
fredandgate-merge-01 24294d3b77 fix(git-tools): issue-view shows comment bodies and names the real tea failure (#1357) (#1365)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: fred <[email protected]>
2026-08-22 00:23:22 +00:00
veronicaandgate-merge-01 24caeab057 fix(tmux): locate the REPL input box by shape, not by a Claude-only glyph (#1363)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: veronica <[email protected]>
2026-08-21 23:45:08 +00:00
fredandgate-merge-01 888a6ad29b fix(#1356): tea login resolution fails closed on a declared git identity (#1361)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: fred <[email protected]>
2026-08-21 23:04:31 +00:00
18 changed files with 917 additions and 33 deletions
+9
View File
@@ -91,6 +91,15 @@ 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 regression for issue-view.sh (#1357): mock tea/curl, sandboxed
# repo. Pins that comment BODIES render on both paths and that a tea
# failure is named as what it was (git-config vs credential).
- bash packages/mosaic/framework/tools/git/test-issue-view-comments.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 —
@@ -24,6 +24,24 @@
# $HOME points at a per-profile directory that has no credentials file.
# Operators symlink /etc/mosaic/credentials.json to the host's canonical
# file once, instead of exporting MOSAIC_CREDENTIALS_FILE per invocation.
#
# GITEA SEAT SLOTS (gitea-mosaicstack / gitea-usc arms only):
# On a fleet host, a resolved git identity is a SEAT whose live credential is
# its slot file, not the shared service store. Resolution, mirroring
# get_gitea_token() in tools/git/detect-platform.sh (mosaicstack#1311 lineage):
# - MOSAIC_GIT_IDENTITY names a seat with a directory under
# ${MOSAIC_BRAIN_HOME:-~/.mosaic}/fleet/agents/<identity>/ → its token is
# read from <slot>/secrets/gitea-<instance>-<identity>.token and exported
# as GITEA_TOKEN. The URL still comes from credentials.json (it is
# provider config, not identity).
# - A seat with an EMPTY/missing slot is a REFUSAL (fail loud), not a
# fallback: there is no precedence between the seat and service stores,
# and a silent service fallback would act as the wrong identity (#1343
# family; usc/uconnect#3084 precedent).
# - No identity resolved → the service store in credentials.json, exactly
# as before. Non-fleet hosts are unchanged.
# Other services (woodpecker, authentik, ...) have no seat concept and are
# untouched by this.
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
for _cand in "$HOME/.config/mosaic/credentials.json" "/etc/mosaic/credentials.json"; do
@@ -94,6 +112,35 @@ _mosaic_load_woodpecker_legacy() {
_mosaic_sync_woodpecker_env "$WOODPECKER_INSTANCE" "$WOODPECKER_URL" "$WOODPECKER_TOKEN"
}
_gitea_seat_token() {
# Echo the seat-slot token path for $1=identity $2=instance-prefix, or rc 1
# when the identity is not a seat. Reads nothing; path logic only.
local ident="$1" pfx="$2" brain_home slot
brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
slot="$brain_home/fleet/agents/$ident/secrets/gitea-$pfx-$ident.token"
if [[ -d "$brain_home/fleet/agents/$ident" ]]; then
printf '%s' "$slot"
return 0
fi
return 1
}
_gitea_resolve_seat_or_refuse() {
# $1=identity $2=instance-prefix $3=service-name (for messages).
# Seat with a readable slot → echoes the token (caller exports).
# Seat with an empty/missing slot → rc 1 with a named refusal.
# Not a seat → rc 2 (caller falls to the service store).
local ident="$1" pfx="$2" svc="$3" slot
slot="$(_gitea_seat_token "$ident" "$pfx")" || return 2
if [[ -r "$slot" ]] && [[ -s "$slot" ]]; then
tr -d '\n' <"$slot"
return 0
fi
echo "Error: load_credentials $svc: git identity '$ident' resolves to a SEAT but its slot is empty or unreadable: $slot" >&2
echo " Refusing to fall back to the shared service store — that would act as the wrong identity. Provision the slot or unset MOSAIC_GIT_IDENTITY." >&2
return 1
}
load_credentials() {
local service="$1"
@@ -183,16 +230,30 @@ EOF
;;
gitea-mosaicstack)
export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.mosaicstack.url')}"
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.mosaicstack.token')}"
GITEA_URL="${GITEA_URL%/}"
[[ -n "$GITEA_URL" ]] || { echo "Error: gitea.mosaicstack.url not found" >&2; return 1; }
if [[ -z "${GITEA_TOKEN:-}" && -n "${MOSAIC_GIT_IDENTITY:-}" ]]; then
local _seat_tok
_seat_tok="$(_gitea_resolve_seat_or_refuse "$MOSAIC_GIT_IDENTITY" mosaicstack gitea-mosaicstack)" \
&& export GITEA_TOKEN="$_seat_tok" && return 0
local _src_rc=$?
[[ "$_src_rc" -eq 2 ]] || return 1
fi
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.mosaicstack.token')}"
[[ -n "$GITEA_TOKEN" ]] || { echo "Error: gitea.mosaicstack.token not found" >&2; return 1; }
;;
gitea-usc)
export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.usc.url')}"
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.usc.token')}"
GITEA_URL="${GITEA_URL%/}"
[[ -n "$GITEA_URL" ]] || { echo "Error: gitea.usc.url not found" >&2; return 1; }
if [[ -z "${GITEA_TOKEN:-}" && -n "${MOSAIC_GIT_IDENTITY:-}" ]]; then
local _seat_tok
_seat_tok="$(_gitea_resolve_seat_or_refuse "$MOSAIC_GIT_IDENTITY" usc gitea-usc)" \
&& export GITEA_TOKEN="$_seat_tok" && return 0
local _src_rc=$?
[[ "$_src_rc" -eq 2 ]] || return 1
fi
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.usc.token')}"
[[ -n "$GITEA_TOKEN" ]] || { echo "Error: gitea.usc.token not found" >&2; return 1; }
;;
woodpecker-*)
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Hermetic regression for load_credentials gitea seat-slot resolution.
# Sandbox brain home + sandbox credentials.json; no real credential is read.
#
# Pins:
# G1 MOSAIC_GIT_IDENTITY naming a seat with a populated slot → GITEA_TOKEN
# comes from the SLOT, URL from credentials.json.
# G2 seat with an EMPTY slot → rc 1, refusal names the identity and the
# slot path, and NO fallback to the service store occurred (the token
# must not equal the service-store value).
# G3 no identity → service store, unchanged behavior (token from
# credentials.json).
# G4 identity that is NOT a seat (no directory) → service store (same as
# G3; the identity is irrelevant on a non-fleet path).
# G5 other services are untouched: woodpecker resolution works the same
# with and without MOSAIC_GIT_IDENTITY set.
# G6 pre-existing GITEA_TOKEN env is never overridden by the seat path.
set -uo pipefail
W="${TMPDIR:-/tmp}/creds-seat-test-$$"
BRAIN="$W/brain"; CREDS="$W/credentials.json"
mkdir -p "$BRAIN/fleet/agents/live-seat/secrets" "$BRAIN/fleet/agents/empty-seat"
printf 'seat-token-value-abc123\n' > "$BRAIN/fleet/agents/live-seat/secrets/gitea-mosaicstack-live-seat.token"
cat > "$CREDS" <<'EOF'
{"gitea":{"mosaicstack":{"url":"https://gitea.example.test","token":"service-token-value-xyz789"}},
"woodpecker":{"default":"mosaic","mosaic":{"url":"https://ci.example.test","token":"wp-token-1"}}}
EOF
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=/dev/null
source "$SCRIPT_DIR/credentials.sh"
fail() { echo "FAIL: $*" >&2; exit 1; }
load_env() { # $1=service, $2=env-setup; clean subshell; echoes rc then token
local svc="$1" setup="$2"
(
eval "$setup"
unset GITEA_TOKEN GITEA_URL
export MOSAIC_CREDENTIALS_FILE="$CREDS" MOSAIC_BRAIN_HOME="$BRAIN"
load_credentials "$svc" >/dev/null 2>"$W/err"
rc=$?
printf '%s\n%s\n' "$rc" "${GITEA_TOKEN:-}"
)
}
# G1: seat with populated slot
out=$(load_env gitea-mosaicstack 'export MOSAIC_GIT_IDENTITY=live-seat')
rc=$(printf '%s' "$out" | sed -n 1p); tok=$(printf '%s' "$out" | sed -n 2p)
[ "$rc" = 0 ] || fail "G1: rc=$rc err=$(cat "$W/err")"
[ "$tok" = "seat-token-value-abc123" ] || fail "G1: token not from slot: ${tok:0:20}"
# G2: seat with empty slot refuses, no fallback
out=$(load_env gitea-mosaicstack 'export MOSAIC_GIT_IDENTITY=empty-seat')
rc=$(printf '%s' "$out" | sed -n 1p); tok=$(printf '%s' "$out" | sed -n 2p)
[ "$rc" = 1 ] || fail "G2: expected rc=1 refusal, got rc=$rc tok=${tok:0:20}"
[ "$tok" != "service-token-value-xyz789" ] || fail "G2: FELL BACK to service store on seat-miss"
grep -q "empty-seat" "$W/err" || fail "G2: refusal does not name the identity"
grep -q "fleet/agents/empty-seat" "$W/err" || fail "G2: refusal does not name the slot path"
# G3: no identity → service store
out=$(load_env gitea-mosaicstack 'unset MOSAIC_GIT_IDENTITY')
rc=$(printf '%s' "$out" | sed -n 1p); tok=$(printf '%s' "$out" | sed -n 2p)
[ "$rc" = 0 ] || fail "G3: rc=$rc err=$(cat "$W/err")"
[ "$tok" = "service-token-value-xyz789" ] || fail "G3: service-store token not loaded"
# G4: identity that is not a seat → service store
out=$(load_env gitea-mosaicstack 'export MOSAIC_GIT_IDENTITY=nobody')
rc=$(printf '%s' "$out" | sed -n 1p); tok=$(printf '%s' "$out" | sed -n 2p)
[ "$rc" = 0 ] || fail "G4: rc=$rc err=$(cat "$W/err")"
[ "$tok" = "service-token-value-xyz789" ] || fail "G4: non-seat identity broke the service path"
# G5: woodpecker ignores MOSAIC_GIT_IDENTITY entirely
( export MOSAIC_CREDENTIALS_FILE="$CREDS"
export MOSAIC_GIT_IDENTITY=live-seat
unset WOODPECKER_URL WOODPECKER_TOKEN
load_credentials woodpecker >/dev/null 2>&1 || fail "G5: woodpecker load failed with identity set"
[ "$WOODPECKER_TOKEN" = "wp-token-1" ] || fail "G5: woodpecker token wrong"
[ "$WOODPECKER_URL" = "https://ci.example.test" ] || fail "G5: woodpecker url wrong" )
# G6: pre-set GITEA_TOKEN env is preserved (both arms)
( export MOSAIC_CREDENTIALS_FILE="$CREDS" MOSAIC_BRAIN_HOME="$BRAIN"
export MOSAIC_GIT_IDENTITY=live-seat GITEA_TOKEN=already-set-env
load_credentials gitea-mosaicstack >/dev/null 2>&1 || fail "G6: load failed"
[ "$GITEA_TOKEN" = "already-set-env" ] || fail "G6: seat path overrode existing GITEA_TOKEN" )
rm -rf "$W"
echo "credentials seat-slot regression passed"
+181
View File
@@ -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 "<instance>-<seat>", 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 <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_<INSTANCE>=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,}/<REDACTED>/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-<instance>-<seat>
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 ]
@@ -102,6 +102,36 @@ of their own — `MOSAIC_GIT_IDENTITY=<id>` 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 `<instance>-<identity>`. 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 <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
@@ -180,6 +180,66 @@ raise SystemExit(1)
PY
}
# Map a host to the instance prefix used in canonical tea login names
# ("<instance>-<identity>"). 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 "<identity>\t<source>" 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,49 @@ 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
# Same split as the host path above (#1357 S1): a missing tea binary
# is not a missing login, and the "create it with" advice cannot be
# followed without tea.
if ! command -v tea >/dev/null 2>&1; then
echo "Error: git identity '$ident' (via $ident_src) requested for owner '${owner%%/*}', 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' (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"
@@ -99,8 +99,8 @@ case "$PLATFORM" in
;;
gitea)
if [[ -n "$REPO_OVERRIDE" ]]; then
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override) || {
echo "Error: Could not resolve Gitea login for --repo override. Set GITEA_LOGIN or configure a default tea login." >&2
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override "$REPO_OVERRIDE") || {
echo "Error: could not resolve a Gitea login for the --repo override (the lines above say why). Set GITEA_LOGIN to name one explicitly." >&2
exit 1
}
else
@@ -1,5 +1,5 @@
#!/bin/bash
# issue-view.sh - View issue details on GitHub or Gitea
# issue-view.sh - View issue details, including comments, on GitHub or Gitea
# Usage: issue-view.sh -i <issue_number>
set -e
@@ -28,11 +28,47 @@ gitea_issue_view_api() {
}
url="https://${host}/api/v1/repos/${repo}/issues/${ISSUE_NUMBER}"
if command -v python3 >/dev/null 2>&1; then
curl -fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -m json.tool
else
curl -fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
local -a curl_args=(-fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}")
if ! command -v python3 >/dev/null 2>&1; then
# No renderer: raw JSON is all this path can give. Comments are a
# second resource, so fetch them too rather than only the count.
curl "${curl_args[@]}" "$url"
curl "${curl_args[@]}" "${url}/comments"
return
fi
# Render issue + comments as text (#1357 F2). The old fallback dumped the
# issue JSON, which carries only a comment COUNT, so every comment body was
# invisible on this path and the wrapper could never show what
# `tea issues --comments` shows.
{
curl "${curl_args[@]}" "$url"
echo
echo "__MOSAIC_COMMENTS__"
curl "${curl_args[@]}" "${url}/comments"
} | python3 -c '
import json, sys
raw = sys.stdin.read()
issue_raw, _, comments_raw = raw.partition("__MOSAIC_COMMENTS__")
issue = json.loads(issue_raw)
comments = json.loads(comments_raw) if comments_raw.strip() else []
print("#%s %s" % (issue["number"], issue["title"]))
print("State: %s Author: %s Created: %s" % (issue["state"], issue["user"]["login"], issue["created_at"]))
labels = ", ".join(l["name"] for l in issue.get("labels") or [])
if labels:
print("Labels: " + labels)
if issue.get("milestone"):
print("Milestone: " + issue["milestone"]["title"])
print("URL: " + issue["html_url"])
print()
print(issue.get("body") or "(no body)")
if comments:
print()
print("--- Comments (%d) ---" % len(comments))
for c in comments:
print()
print("[%s at %s]" % (c["user"]["login"], c["created_at"]))
print(c.get("body") or "")
'
}
while [[ $# -gt 0 ]]; do
@@ -46,6 +82,8 @@ while [[ $# -gt 0 ]]; do
echo ""
echo "Options:"
echo " -i, --issue Issue number (required)"
echo ""
echo "Comments are always included (tea --comments / Gitea API /comments)."
echo " -h, --help Show this help"
exit 0
;;
@@ -67,11 +105,30 @@ if [[ "$PLATFORM" == "github" ]]; then
gh issue view "$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
if command -v tea >/dev/null 2>&1; then
if tea issue "$ISSUE_NUMBER" $(get_gitea_repo_args); then
# --comments is what makes tea print the comment bodies (#1357 F3).
# Without it tea prompts for them interactively, which in a
# non-interactive wrapper means they are silently never shown.
tea_err=$(mktemp)
if tea issue "$ISSUE_NUMBER" $(get_gitea_repo_args) --comments 2>"$tea_err"; then
rm -f "$tea_err"
exit 0
fi
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
# Name the cause tea actually reported, not a guessed one (#1357 F1/F4).
# tea reads the cwd's git config before honouring --repo; a repo with
# extensions.worktreeconfig=true makes it exit 1 with a
# repositoryformatversion error. That is a git-config condition, not a
# credential one. The old path printed the REVOKED OR STALE TOKEN note
# here unconditionally, which sent readers to rotate a token that was fine.
if grep -q 'repositoryformatversion' "$tea_err"; then
echo "Warning: tea cannot read this repo's git config (extensions.worktreeconfig); not a credential problem. Using Gitea API fallback." >&2
elif grep -q 'user does not exist' "$tea_err"; then
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
else
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
fi
sed 's/^/ tea: /' "$tea_err" >&2
rm -f "$tea_err"
fi
gitea_issue_view_api
else
@@ -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; }
@@ -94,8 +94,8 @@ case "$PLATFORM" in
;;
gitea)
if [[ -n "$REPO_OVERRIDE" ]]; then
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override) || {
echo "Error: Could not resolve Gitea login for --repo override. Set GITEA_LOGIN or configure a default tea login." >&2
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override "$REPO_OVERRIDE") || {
echo "Error: could not resolve a Gitea login for the --repo override (the lines above say why). Set GITEA_LOGIN to name one explicitly." >&2
exit 1
}
else
@@ -59,8 +59,8 @@ 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) || {
echo "Error: Could not resolve Gitea login for --repo override. Set GITEA_LOGIN or configure a default tea login." >&2
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override "$REPO_OVERRIDE") || {
echo "Error: could not resolve a Gitea login for the --repo override (the lines above say why). Set GITEA_LOGIN to name one explicitly." >&2
exit 1
}
else
@@ -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,151 @@ 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
# Branch 6 (#1357 S1): with tea ABSENT from PATH, the override path must say tea is
# missing, not "no tea login named X exists" (a cause that was never checked) and
# not the seat-logins.sh advice, which cannot be followed without tea.
NOTEA_BIN="$WORK_DIR/notea-bin"; mkdir -p "$NOTEA_BIN"
for t in bash git python3 sed grep cat mktemp dirname basename readlink env sort head tr cut; do
_p="$(command -v "$t" 2>/dev/null || true)"; [[ -n "$_p" ]] && ln -sf "$_p" "$NOTEA_BIN/$t"
done
override_notea_rc=0
override_notea_err=$(cd "$REPO_DIR" && env -u GITEA_LOGIN \
PATH="$NOTEA_BIN" HOME="$HOME_DIR" MOSAIC_GIT_IDENTITY=testseat \
bash -c '
command -v tea >/dev/null 2>&1 && { echo "SETUP: tea still on PATH"; exit 99; }
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_login_for_repo_override mosaicstack/stack
' 2>&1 >/dev/null) || override_notea_rc=$?
if [[ "$override_notea_rc" != 1 ]]; then
echo "Expected --repo override path to fail (rc=1) with tea absent; got rc=$override_notea_rc: $override_notea_err" >&2
exit 1
fi
if ! grep -q 'tea is not installed' <<<"$override_notea_err"; then
echo "Expected --repo override path to name tea as absent; got: $override_notea_err" >&2
exit 1
fi
if grep -q 'has no tea login\|seat-logins.sh' <<<"$override_notea_err"; then
echo "Override path diagnosed a missing LOGIN while tea itself is absent: $override_notea_err" >&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
@@ -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.
@@ -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"
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# Regression: issue-view.sh must show comment BODIES, on both paths, and must name
# the failure tea actually reported instead of guessing a credential cause (#1357).
#
# Four defects, each with its own case below:
# F1 tea exits 1 in any repo with extensions.worktreeconfig=true; the wrapper must
# say so (git-config condition) and fall back to the API.
# F2 the API fallback dumped raw issue JSON, which carries only a comment COUNT.
# F3 the tea path never passed --comments, so tea prompted (non-interactively: nothing).
# F4 on ANY tea failure the wrapper printed the REVOKED OR STALE TOKEN note.
#
# Verification bar (plan §6): assert a real comment BODY appears, not a count and not
# `grep -c comment` (that instrument matched the issue title and read inverted).
#
# Hermetic: mock tea and curl on PATH, sandboxed repo. Resolves no real credentials.
set -euo pipefail
WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}"
SANDBOX="$WORK_ROOT/issue-view-comments-test-$$"
MOCK_BIN="$SANDBOX/bin"; REPO_DIR="$SANDBOX/repo"; CALLS="$SANDBOX/calls.log"
cleanup() { rm -rf "$SANDBOX"; }
trap cleanup EXIT
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET="$SCRIPT_DIR/issue-view.sh"
[ -f "$TARGET" ] || { echo "FAIL: issue-view.sh not found beside this test"; exit 1; }
fail() { echo "FAIL: $*"; exit 1; }
mkdir -p "$MOCK_BIN" "$REPO_DIR" || fail "setup: cannot create sandbox under $WORK_ROOT"
: > "$CALLS" || fail "setup: cannot write calls log at $CALLS"
cd "$REPO_DIR" || fail "setup: cannot cd into $REPO_DIR"
git init -q || fail "setup: git init failed"
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git || fail "setup: git remote add failed"
export PATH="$MOCK_BIN:$PATH" CALLS
export GITEA_URL="https://git.mosaicstack.dev"
export GITEA_TOKEN="redacted-test-token"
# The identity ladder must not reach for this seat's real login; the mock tea below
# defines the only login that exists in this sandbox.
unset MOSAIC_GIT_IDENTITY
# No fleet in the sandbox: on a host that runs one, get_gitea_token fails closed for an
# identity-less caller (by design), which would make this test measure the host, not
# the wrapper. An empty brain home makes the sandbox the same on every host.
export MOSAIC_BRAIN_HOME="$SANDBOX/brain"
mkdir -p "$MOSAIC_BRAIN_HOME" || fail "setup: cannot create sandbox brain home"
# Distinctive strings: a comment body that appears nowhere else, and an issue title
# that contains the word "comment" so a count-of-the-word instrument would misread.
BODY_MARKER="zebra-quill-comment-body-7731"
ISSUE_TITLE="wrapper never shows a comment"
# --- mock curl: serves the issue and its comments; logs every call --------------
cat > "$MOCK_BIN/curl" <<EOF
#!/bin/bash
url=""
while [ \$# -gt 0 ]; do
case "\$1" in
http*) url="\$1"; shift ;;
*) shift ;;
esac
done
printf 'curl %s\n' "\$url" >> "$CALLS"
case "\$url" in
*/issues/77/comments)
if [ "\${MOCK_NO_COMMENTS:-}" = "1" ]; then echo '[]'; else
echo '[{"id":1,"user":{"login":"alice"},"created_at":"2026-08-21T00:00:00Z","body":"$BODY_MARKER"}]'; fi ;;
*/issues/77)
echo '{"number":77,"title":"$ISSUE_TITLE","state":"open","user":{"login":"bob"},"created_at":"2026-08-21T00:00:00Z","labels":[],"milestone":null,"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/issues/77","body":"issue body","comments":1}' ;;
*) echo '{}' ;;
esac
exit 0
EOF
chmod +x "$MOCK_BIN/curl"
# --- mock tea: MOCK_TEA_MODE selects the behaviour under test --------------------
# ok : prints the issue, and the comment body ONLY when --comments is passed (F3)
# wtconfig : exits 1 with the repositoryformatversion error (F1/F4)
# badtoken : exits 1 with tea's credential error (F4 control: credential wording allowed)
cat > "$MOCK_BIN/tea" <<EOF
#!/bin/bash
printf 'tea %s\n' "\$*" >> "$CALLS"
if [[ "\$*" == *"login list"* ]]; then
echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'; exit 0
fi
case "\${MOCK_TEA_MODE:-ok}" in
wtconfig) echo 'Error: core.repositoryformatversion does not support extension: worktreeconfig' >&2; exit 1 ;;
badtoken) echo 'Failed to create Gitea client: invalid username, password or token' >&2; exit 1 ;;
esac
echo "# #77 $ISSUE_TITLE (open)"
echo "issue body"
if [[ "\$*" == *"--comments"* ]]; then echo "$BODY_MARKER"; fi
exit 0
EOF
chmod +x "$MOCK_BIN/tea"
[ "$(command -v tea)" = "$MOCK_BIN/tea" ] || fail "setup: tea does not resolve inside the sandbox"
[ "$(command -v curl)" = "$MOCK_BIN/curl" ] || fail "setup: curl does not resolve inside the sandbox"
run() { bash "$TARGET" -i 77 >"$SANDBOX/out" 2>"$SANDBOX/err"; echo $?; }
# F3: tea path shows the comment body, which the mock emits only under --comments.
: > "$CALLS"
rc=$(MOCK_TEA_MODE=ok run)
[ "$rc" = 0 ] || fail "F3: expected rc=0 on the tea path, got $rc: $(cat "$SANDBOX/err")"
grep -q -- '--comments' "$CALLS" || fail "F3: tea was not invoked with --comments: $(cat "$CALLS")"
grep -q "$BODY_MARKER" "$SANDBOX/out" || fail "F3: comment body missing from tea-path output"
if grep -q '^curl' "$CALLS"; then fail "F3: tea path succeeded but the API fallback ran anyway"; fi
# F1 + F2: worktreeconfig failure is named as a git-config condition, falls back to
# the API, and the API rendering includes the comment BODY.
: > "$CALLS"
rc=$(MOCK_TEA_MODE=wtconfig run)
[ "$rc" = 0 ] || fail "F1: expected rc=0 via API fallback, got $rc: $(cat "$SANDBOX/err")"
grep -q 'worktreeconfig' "$SANDBOX/err" || fail "F1: stderr does not name the worktreeconfig cause: $(cat "$SANDBOX/err")"
grep -q 'not a credential problem' "$SANDBOX/err" || fail "F1: stderr does not rule out the credential cause"
grep -q 'issues/77/comments' "$CALLS" || fail "F2: API fallback never fetched /comments: $(cat "$CALLS")"
grep -q "$BODY_MARKER" "$SANDBOX/out" || fail "F2: comment body missing from API-path output"
grep -q "$ISSUE_TITLE" "$SANDBOX/out" || fail "F2: issue title missing from API-path output"
if grep -q 'REVOKED OR STALE' "$SANDBOX/err"; then fail "F4: stale-token note printed for a git-config failure"; fi
if grep -q '"comments": 1' "$SANDBOX/out"; then fail "F2: output is still raw JSON (comment count instead of bodies)"; fi
# F4 control: a real credential error from tea may still carry the credential note,
# and tea's own line must be relayed so the reader sees the actual cause.
: > "$CALLS"
rc=$(MOCK_TEA_MODE=badtoken run)
[ "$rc" = 0 ] || fail "F4 control: expected rc=0 via API fallback, got $rc"
grep -q 'invalid username, password or token' "$SANDBOX/err" || fail "F4: tea's own error line was not relayed"
if grep -q 'worktreeconfig' "$SANDBOX/err"; then fail "F4: git-config wording printed for a credential failure"; fi
# Negative control: an issue with no comments prints no comment section on the API
# path. Without this, a renderer that always prints a section would pass F2.
: > "$CALLS"
rc=$(MOCK_TEA_MODE=wtconfig MOCK_NO_COMMENTS=1 run)
[ "$rc" = 0 ] || fail "negative control: expected rc=0, got $rc"
if grep -q -- '--- Comments' "$SANDBOX/out"; then fail "negative control: comment section printed for an issue with no comments"; fi
if grep -q "$BODY_MARKER" "$SANDBOX/out"; then fail "negative control: a comment body appeared for an issue with no comments"; fi
echo "issue-view comments regression harness passed"
@@ -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
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh"
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_lib/test-credentials-gitea-seats.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
+2
View File
@@ -61,6 +61,8 @@ 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-issue-view-comments.sh',
'bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh',
'bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh',
],