fix(git): identity-first principal resolution across write wrappers (#1280)
MOSAIC_GIT_IDENTITY=fargo produced objects attributed to mos-dt-0: every write wrapper resolved its acting principal from tea's login list, which enumerates whatever logins the host happens to hold and knows nothing about which seat is calling. The identity-aware code was present and correct but unreachable on the happy path — it sat on arms that only ran when tea failed. One shared resolver, not twenty patches: resolve_gitea_principal() in detect-platform.sh implements the precedence (explicit --login beats MOSAIC_GIT_IDENTITY / worktree git config mosaic.gitIdentity; the tea login list is the LAST resort), fails loud (nonzero, naming the identity or login and the expected slot path) when the requested principal has no credential, and never prints a token value. gitea_identity_token_slot() is the single source of truth for the slot layout, shared with get_gitea_token, so resolver and token resolution cannot disagree. Call-site conversions (the proving five): pr-review.sh (principal resolved once for every action; the comment action now honors --login), issue-comment.sh, pr-create.sh (identity mode reaches the REST API on the HAPPY path — tea is never consulted, so the login list cannot shadow the identity; --login wins even on the tea-failure fallback arm), issue-create.sh (same), pr-merge.sh (gains --login; --dry-run reports the principal the merge WOULD act as, resolved exactly as the merge resolves it; no cross-principal fallback — an identity-bound 401 is a hard stop). Remaining wrappers are call-site conversions onto the same resolver, measured: write-path issue-assign, issue-close, issue-edit, issue-reopen, milestone-close, milestone-create, pr-close; read-path issue-list, milestone-list, pr-list, pr-view (issue-view mixed). pr-diff, pr-metadata, pr-ci-wait and ci-queue-wait already inherit identity-first resolution via get_gitea_token. Known interaction: on a host with a workstation-GLOBAL mosaic.gitIdentity, this fix activates identity mode for every seat that has not set a local one — correct behavior driven by a wrong configuration (measured: #1282-#1287, six accidental live issues, closed with provenance by fred). Set mosaic.gitIdentity per-worktree, never --global. Tests: test-gitea-principal-resolution.sh (resolver matrix — identity present/absent, --login precedence, env vs git-config, unrecognized-host containment, slot-path-by-path-never-by-value); test-pr-create-identity- first.sh (the load-bearing ordering test: identity arm REACHED on the happy path with tea never invoked, fail-loud BEFORE any write on a missing slot, --login wins, default preserved); test-pr-merge-principal- resolution.sh (dry-run truthfulness, merge credential binding, unknown --login never reaches the provider). All wired into test:framework-shell. Sabotage control: precedence inverted to tea-list-first inside the resolver -> exactly the three new suites redden with the #1280 signatures (identity resolves to the tea-list account; missing slot returns rc=0 with silent fallthrough) while all 11 pre-existing git suites stay green; restored byte-identical (sha256 verified); all 14 green again.
This commit is contained in:
@@ -497,6 +497,32 @@ get_gitea_url_for_host() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Map a Gitea host to the per-agent identity-token slot PREFIX ("gitea-usc" /
|
||||
# "gitea-mosaicstack") used by identity-first principal resolution
|
||||
# (MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity; #1280). Returns 1 for
|
||||
# hosts with no per-slot scheme — callers treat that as "identity does not
|
||||
# bind here" and fall through to existing behavior, never as an error. This is
|
||||
# the single source of truth for the slot layout: get_gitea_token and
|
||||
# resolve_gitea_principal both derive their slot paths from here, so the two
|
||||
# resolutions can never disagree about where an identity's credential lives.
|
||||
gitea_identity_slot_prefix() {
|
||||
case "$1" in
|
||||
git.uscllc.com) echo "gitea-usc" ;;
|
||||
git.mosaicstack.dev) echo "gitea-mosaicstack" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Resolve the per-slot token FILE PATH for an identity on a host. Prints the
|
||||
# absolute path on success; returns 1 (no output) when the host has no per-slot
|
||||
# scheme. Prints a PATH only — never a token value.
|
||||
gitea_identity_token_slot() {
|
||||
local identity="$1" host="$2" prefix
|
||||
[[ -n "$identity" ]] || return 1
|
||||
prefix=$(gitea_identity_slot_prefix "$host") || return 1
|
||||
printf '%s\n' "$HOME/.config/mosaic/secrets/gitea-tokens/${prefix}-${identity}.token"
|
||||
}
|
||||
|
||||
# Resolve a Gitea API token for the given host.
|
||||
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
|
||||
get_gitea_token() {
|
||||
@@ -517,13 +543,8 @@ get_gitea_token() {
|
||||
_ident_src="git config mosaic.gitIdentity"
|
||||
fi
|
||||
if [[ -n "$_ident" ]]; then
|
||||
local _idpfx=""
|
||||
case "$host" in
|
||||
git.uscllc.com) _idpfx=gitea-usc ;;
|
||||
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
|
||||
esac
|
||||
if [[ -n "$_idpfx" ]]; then
|
||||
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
||||
local _idtok=""
|
||||
if _idtok="$(gitea_identity_token_slot "$_ident" "$host" 2>/dev/null)"; then
|
||||
if [[ -r "$_idtok" ]]; then
|
||||
cat "$_idtok"
|
||||
return 0
|
||||
@@ -1465,6 +1486,81 @@ raise SystemExit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
# resolve_gitea_principal — identity-first acting-principal resolution shared by
|
||||
# the git wrappers (#1280). The defect this fixes: wrappers resolved their
|
||||
# acting principal from tea's login list FIRST, and that list enumerates
|
||||
# whatever logins happen to be configured on the host — it knows nothing about
|
||||
# which seat is calling — so a wrapper invoked with MOSAIC_GIT_IDENTITY=fargo
|
||||
# still wrote under whichever account tea held (mos-dt-0), and the correct
|
||||
# identity-aware code sat behind arms that only ran when the tea path failed.
|
||||
# Precedence here is the contract:
|
||||
# 1. an explicit login override ($1, the wrapper's --login) — operator intent
|
||||
# beats environment;
|
||||
# 2. MOSAIC_GIT_IDENTITY env, else per-worktree `git config mosaic.gitIdentity`
|
||||
# (mirroring get_gitea_token exactly, so resolver and token resolution can
|
||||
# never disagree) — binds only on hosts with a per-slot token scheme;
|
||||
# 3. the tea login list — LAST resort, never the first.
|
||||
#
|
||||
# Prints exactly one line, three tab-separated fields (machine-readable for
|
||||
# wrapper dispatch and tests):
|
||||
# mode "login" | "identity" | "default"
|
||||
# principal login name (login) | identity name (identity) | tea login or "" (default)
|
||||
# source "tea-login:<name>" | "identity-slot:<path>" | "tea-default" | "host-credential"
|
||||
#
|
||||
# Fails LOUD (nonzero, empty stdout, stderr diagnostic) when an explicit
|
||||
# override cannot be honored — a refusal is a good day; silently falling
|
||||
# through to whoever tea has configured is the exact defect this resolves:
|
||||
# - login mode: no host-bound token for that tea login. The existence check
|
||||
# runs the same tea-config lookup tea itself uses; the token VALUE is
|
||||
# discarded (never printed, never used).
|
||||
# - identity mode: no per-slot token file for that identity on a recognized
|
||||
# host — the diagnostic names the identity, its source, and the expected
|
||||
# slot path. An identity requested on a host with NO per-slot scheme does
|
||||
# not bind (matching get_gitea_token's containment) and falls to default.
|
||||
#
|
||||
# NEVER prints a token value — principal names and slot paths only.
|
||||
# $1 = explicit login override ("" when absent), $2 = host (default: the
|
||||
# origin remote's host).
|
||||
resolve_gitea_principal() {
|
||||
local login_override="${1:-}" host="${2:-}" ident ident_src slot login
|
||||
[[ -n "$host" ]] || { host=$(get_remote_host) || return 1; }
|
||||
|
||||
if [[ -n "$login_override" ]]; then
|
||||
get_gitea_token_for_login "$login_override" "$host" >/dev/null || {
|
||||
echo "Error: --login '$login_override' has no host-matched token on host '$host' (tea config lookup); refusing to fall back to any other principal (#1280 identity-first resolution)." >&2
|
||||
return 1
|
||||
}
|
||||
printf 'login\t%s\ttea-login:%s\n' "$login_override" "$login_override"
|
||||
return 0
|
||||
fi
|
||||
|
||||
ident="${MOSAIC_GIT_IDENTITY:-}"
|
||||
ident_src="MOSAIC_GIT_IDENTITY"
|
||||
if [[ -z "$ident" ]]; then
|
||||
ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
|
||||
ident_src="git config mosaic.gitIdentity"
|
||||
fi
|
||||
if [[ -n "$ident" ]] && slot="$(gitea_identity_token_slot "$ident" "$host" 2>/dev/null)"; then
|
||||
if [[ -r "$slot" ]]; then
|
||||
printf 'identity\t%s\tidentity-slot:%s\n' "$ident" "$slot"
|
||||
return 0
|
||||
fi
|
||||
echo "Error: git identity '$ident' requested (via $ident_src) for host '$host', but no per-slot token at $slot (#1280 identity-first resolution)." >&2
|
||||
echo " Refusing to fall back to the tea login list or shared credentials. Provision the per-slot token, or unset the identity." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# No override requested: tea's login list is the LAST resort. Absence is
|
||||
# not an error here — callers fall back to the host credential, exactly as
|
||||
# they did before this resolver existed (preserved behavior).
|
||||
if login=$(get_gitea_login_for_host "$host" 2>/dev/null); then
|
||||
printf 'default\t%s\ttea-default\n' "$login"
|
||||
else
|
||||
printf 'default\t\thost-credential\n'
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Resolve HTTPS basic auth credentials for a Gitea host from ~/.git-credentials.
|
||||
# Prints "username:password" for direct curl -u consumption. Callers must not log it.
|
||||
get_gitea_basic_auth() {
|
||||
|
||||
Reference in New Issue
Block a user