diff --git a/packages/mosaic/framework/tools/git/README.md b/packages/mosaic/framework/tools/git/README.md index 729c5e32..a796d866 100644 --- a/packages/mosaic/framework/tools/git/README.md +++ b/packages/mosaic/framework/tools/git/README.md @@ -30,7 +30,7 @@ The Gitea API token is **never passed on a curl command line.** An `Authorizatio ### `--login` override -Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login ` flag that overrides the automatically detected Gitea login for that single invocation. The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login `. +Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login ` flag that overrides the automatically detected Gitea login for that single invocation (as of #1280, `pr-create.sh`, `pr-merge.sh` and `issue-create.sh` accept it too, and it wins over `MOSAIC_GIT_IDENTITY` everywhere). The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login `. As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag. @@ -58,6 +58,36 @@ token file present, both tools fall through to the existing shared-account path unchanged, so this feature is a no-op on any host that hasn't provisioned per-slot tokens. +### Identity-first principal resolution in the wrappers (#1280) + +`resolve_gitea_principal()` (detect-platform.sh) gives the write wrappers — +`pr-create.sh`, `pr-merge.sh`, `pr-review.sh`, `issue-create.sh`, `issue-comment.sh` — +ONE precedence for choosing the acting principal: + +1. an explicit `--login ` (now accepted by all five; operator intent beats + environment), then +2. the per-agent identity above (`MOSAIC_GIT_IDENTITY` env / worktree + `mosaic.gitIdentity`) when a per-slot token exists — the wrapper then writes via the + REST API with that identity's token and never consults `tea`, so the tea login list + cannot shadow the requested principal, then +3. the tea login list — the LAST resort, never the first, because it enumerates + whatever logins the host happens to hold and knows nothing about which seat is + calling. + +A requested identity whose per-slot token is absent, or a `--login` whose token cannot +resolve host-bound, **fails loud** (nonzero, naming the identity/login and the expected +slot) instead of silently writing under whatever account `tea` has configured — that +silent fallthrough is defect #1280 (reviews, comments, merges, PRs and issues filed +under the wrong account). `pr-merge.sh --dry-run` reports the principal the merge would +act as, resolved exactly as the real merge resolves it. ⚠ A **workstation-global** +`mosaic.gitIdentity` shadows every seat on that host (a fresh clone with no local value +resolves the global one) — set it per-worktree, not with `--global`. + +The resolver is covered by `test-gitea-principal-resolution.sh`; the happy-path +ordering (identity arm REACHED, not sitting behind a tea failure) by +`test-pr-create-identity-first.sh`; merge credential binding by +`test-pr-merge-principal-resolution.sh`. + ### 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 97c6e5b3..0794054e 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -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:" | "identity-slot:" | "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() { diff --git a/packages/mosaic/framework/tools/git/issue-comment.sh b/packages/mosaic/framework/tools/git/issue-comment.sh index 7c17794d..e41c34e6 100755 --- a/packages/mosaic/framework/tools/git/issue-comment.sh +++ b/packages/mosaic/framework/tools/git/issue-comment.sh @@ -76,27 +76,36 @@ fi detect_platform >/dev/null # Resolve and cache the Gitea REST endpoint + token for the current remote, -# bound to a SPECIFIC login identity ($1). Populates GITEA_API_ROOT (…/api/v1), -# GITEA_API_BASE (…/api/v1/repos/), and GITEA_API_TOKEN. +# bound to a SPECIFIC acting principal ($1) selected identity-first (#1280): +# an explicit --login wins, else MOSAIC_GIT_IDENTITY / git config +# mosaic.gitIdentity binds the per-slot credential, else the tea login list +# (last resort). Populates GITEA_API_ROOT (…/api/v1), GITEA_API_BASE +# (…/api/v1/repos/), and GITEA_API_TOKEN. # -# The token is resolved for the EFFECTIVE login (the --login override when -# given, otherwise the detected default) so that the single credential used for -# the write ALSO drives the /user identity read and the read-back — write token -# and read-back token are the same identity by construction (this is the -# credential-ordering fix: a --login override is no longer written under one -# credential and verified under a different default one). Falls back to the -# host-scoped credential ONLY when NO --login override was supplied (the -# best-effort default path). When $2 is "explicit" the login came from a -# caller-supplied --login: that exact login's token MUST resolve, and we FAIL -# CLOSED rather than silently downgrading the write to the host default -# identity — otherwise a caller relying on a dedicated per-role credential would -# be told the write succeeded as requested while it was attributed to the shared -# default. Returns non-zero (clear stderr) on any resolution failure. +# The token is resolved for the EFFECTIVE principal so that the single +# credential used for the write ALSO drives the /user identity read and the +# read-back — write token and read-back token are the same identity by +# construction (this is the credential-ordering fix: a --login override is no +# longer written under one credential and verified under a different default +# one). When $2 is "identity" the principal ($1) is a requested git identity: +# the token MUST resolve from that identity's per-slot token (get_gitea_token's +# identity arm), failing closed rather than borrowing the tea default login — +# the tea login list must never shadow a requested identity (#1280). When $2 +# is "explicit" the principal came from a caller-supplied --login: that exact +# login's token MUST resolve, and we FAIL CLOSED rather than silently +# downgrading the write to the host default identity. Otherwise the best-effort +# default path applies (per-login token, else the host-scoped credential). +# Returns non-zero (clear stderr) on any resolution failure. gitea_resolve_api_for_login() { local effective_login="$1" override_explicit="${2:-}" host configured_url repo host=$(get_remote_host) - if [[ -n "$override_explicit" ]]; then + if [[ "$override_explicit" == "identity" ]]; then + GITEA_API_TOKEN=$(get_gitea_token "$host") || { + echo "Error: could not resolve the per-slot token for requested git identity '$effective_login' on host '$host'; refusing to fall back to the tea login list or shared credentials (comment write/read-back, #1280)." >&2 + return 1 + } + elif [[ -n "$override_explicit" ]]; then GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || { echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (comment write/read-back)" >&2 return 1 @@ -318,23 +327,31 @@ if [[ "$PLATFORM" == "github" ]]; then gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" echo "Added comment to GitHub issue #$ISSUE_NUMBER" elif [[ "$PLATFORM" == "gitea" ]]; then - # Resolve the login this comment should be attributed to: the --login - # override when given, otherwise the detected default for this repo's host. - # A --login override always wins. Otherwise name this repo host's login only - # as a best effort: the login name merely selects a per-login token, and - # gitea_resolve_api_for_login falls back to the host credential - # (get_gitea_token) when no tea login is named, so the default credential - # still resolves even when the host tea has no matching login entry. - EFFECTIVE_LOGIN="$LOGIN_OVERRIDE" - [[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login 2>/dev/null || true) + # Resolve the acting principal identity-first (#1280): an explicit --login + # wins; otherwise MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity + # selects the principal when a per-slot token exists (fail-loud when it + # does not); the tea login list is the LAST resort — it knows nothing about + # which seat is calling, so resolving from it first wrote under whichever + # account tea had configured (the #1280 family). + principal_host=$(get_remote_host) + if ! principal_resolved="$(resolve_gitea_principal "$LOGIN_OVERRIDE" "$principal_host")"; then + # resolve_gitea_principal already printed the fail-loud diagnostic. + exit 1 + fi + PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)" + PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)" - # Bind the REST endpoint + token to the effective login, then derive the + # Bind the REST endpoint + token to the resolved principal, then derive the # acting identity from that SAME credential (GET /user). The write below and # its read-back both use this credential, so the write is verified against - # the identity that actually performed it. Passing "explicit" when --login - # was supplied forbids the host-default fallback: an unresolvable explicit - # override fails closed instead of writing under the default identity. - gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1 + # the identity that actually performed it. + if [[ "$PRINCIPAL_MODE" == "identity" ]]; then + gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1 + elif [[ "$PRINCIPAL_MODE" == "login" ]]; then + gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1 + else + gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1 + fi ACTING_LOGIN=$(gitea_authenticated_login) || exit 1 comment_id=$(gitea_create_comment_verified "$ISSUE_NUMBER" "$COMMENT" "$ACTING_LOGIN") || { diff --git a/packages/mosaic/framework/tools/git/issue-create.sh b/packages/mosaic/framework/tools/git/issue-create.sh index 96f890cf..fbe77671 100755 --- a/packages/mosaic/framework/tools/git/issue-create.sh +++ b/packages/mosaic/framework/tools/git/issue-create.sh @@ -1,6 +1,15 @@ #!/bin/bash # issue-create.sh - Create issues on Gitea or GitHub -# Usage: issue-create.sh -t "Title" [-b "Body"] [-l "label1,label2"] [-m "milestone"] +# Usage: issue-create.sh -t "Title" [-b "Body"] [-l "label1,label2"] [-m "milestone"] [--login ] +# +# Acting principal is resolved identity-first (#1280): an explicit --login +# wins; otherwise MOSAIC_GIT_IDENTITY / per-worktree git config +# mosaic.gitIdentity selects the principal when a per-slot token exists (and +# the wrapper then creates the issue through the REST API with that identity's +# token — tea is never invoked, so the tea login list cannot shadow the +# requested principal); the tea login list is the LAST resort. A requested +# identity with no per-slot token fails LOUD rather than writing under +# whichever account tea happens to hold. set -e @@ -16,6 +25,14 @@ INTERACTIVE=false # get_remote_host and get_gitea_token are provided by detect-platform.sh +# Acting-principal mode set in the Gitea branch below (from +# resolve_gitea_principal): "login" when --login was given, "identity" when a +# git identity bound, "default" otherwise. PRINCIPAL_MODE=login makes the API +# arm resolve the --login principal's token too, so an explicit --login keeps +# winning even on the tea-FAILURE fallback arm. +PRINCIPAL_MODE="" +PRINCIPAL_NAME="" + gitea_issue_create_api() { local host repo token url payload host=$(get_remote_host) || { @@ -26,10 +43,19 @@ gitea_issue_create_api() { echo "Error: could not determine repo owner/name for API fallback" >&2 return 1 } - token=$(get_gitea_token "$host") || { - echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2 - return 1 - } + if [[ "$PRINCIPAL_MODE" == "login" ]]; then + token=$(get_gitea_token_for_login "$PRINCIPAL_NAME" "$host") || { + echo "Error: could not resolve a host-matched Gitea token for --login '$PRINCIPAL_NAME' on host '$host' (API path)" >&2 + return 1 + } + else + # Identity-first when MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity + # is set (per-slot token, fail-loud on absence); shared default otherwise. + token=$(get_gitea_token "$host") || { + echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2 + return 1 + } + fi if [[ -n "$LABELS" || -n "$MILESTONE" ]]; then echo "Warning: API fallback currently applies title/body only; labels/milestone require authenticated tea setup." >&2 @@ -67,6 +93,7 @@ Options: -b, --body BODY Issue body/description -l, --labels LABELS Comma-separated labels (e.g., "bug,feature") -m, --milestone NAME Milestone name to assign + --login NAME Act as this Gitea tea login (wins over MOSAIC_GIT_IDENTITY) -i, --interactive Prompt for missing issue fields -h, --help Show this help message @@ -97,6 +124,10 @@ while [[ $# -gt 0 ]]; do MILESTONE="$2" shift 2 ;; + --login) + LOGIN_OVERRIDE="$2" + shift 2 + ;; -i|--interactive) INTERACTIVE=true shift @@ -134,13 +165,37 @@ case "$PLATFORM" in "${CMD[@]}" ;; gitea) + # Resolve the acting principal identity-first (#1280). The tea login + # list is the LAST resort: it knows nothing about which seat is calling, + # and a login resolved from it first is what attributed issues to the + # wrong account even when MOSAIC_GIT_IDENTITY was set. + principal_host=$(get_remote_host 2>/dev/null || true) + if ! principal_resolved="$(resolve_gitea_principal "${LOGIN_OVERRIDE:-}" "$principal_host")"; then + # resolve_gitea_principal already printed the fail-loud diagnostic. + exit 1 + fi + PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)" + PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)" + + if [[ "$PRINCIPAL_MODE" == "identity" ]]; then + # HAPPY PATH for a requested identity: create through the REST API + # with the per-slot token and never invoke tea — the identity arm + # must be REACHED, not sit behind a tea failure (#1280). + gitea_issue_create_api + exit $? + fi + if command -v tea >/dev/null 2>&1; then REPO_SLUG=$(get_repo_slug) - GITEA_LOGIN_NAME=$(get_gitea_login) || { - echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2 - gitea_issue_create_api - exit $? - } + if [[ "$PRINCIPAL_MODE" == "login" ]]; then + GITEA_LOGIN_NAME="$PRINCIPAL_NAME" + else + GITEA_LOGIN_NAME=$(get_gitea_login) || { + echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2 + gitea_issue_create_api + exit $? + } + fi if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2 gitea_issue_create_api diff --git a/packages/mosaic/framework/tools/git/pr-create.sh b/packages/mosaic/framework/tools/git/pr-create.sh index 46b82a64..44ee5dde 100755 --- a/packages/mosaic/framework/tools/git/pr-create.sh +++ b/packages/mosaic/framework/tools/git/pr-create.sh @@ -1,6 +1,15 @@ #!/bin/bash # pr-create.sh - Create pull requests on Gitea or GitHub -# Usage: pr-create.sh -t "Title" [-b "Body"] [-B base] [-H head] [-l "labels"] [-m "milestone"] +# Usage: pr-create.sh -t "Title" [-b "Body"] [-B base] [-H head] [-l "labels"] [-m "milestone"] [--login ] +# +# Acting principal is resolved identity-first (#1280): an explicit --login +# wins; otherwise MOSAIC_GIT_IDENTITY / per-worktree git config +# mosaic.gitIdentity selects the principal when a per-slot token exists (and +# the wrapper then creates the PR through the REST API with that identity's +# token — tea is never invoked, so the tea login list cannot shadow the +# requested principal); the tea login list is the LAST resort. A requested +# identity with no per-slot token fails LOUD rather than writing under +# whichever account tea happens to hold. set -e @@ -19,6 +28,15 @@ ISSUE="" # get_remote_host, get_gitea_token, get_repo_info, and get_gitea_repo_args are provided by detect-platform.sh +# Acting-principal mode set in the Gitea branch below (from +# resolve_gitea_principal): "login" when --login was given, "identity" when a +# git identity bound, "default" otherwise. PRINCIPAL_MODE=login makes the API +# arm resolve the --login principal's token too, so an explicit --login keeps +# winning even on the tea-FAILURE fallback arm (otherwise the fallback would +# silently re-resolve to the environment identity or shared credential). +PRINCIPAL_MODE="" +PRINCIPAL_NAME="" + gitea_pr_create_api() { local host repo token url payload host=$(get_remote_host) || { @@ -29,10 +47,19 @@ gitea_pr_create_api() { echo "Error: could not determine repo owner/name for API fallback" >&2 return 1 } - token=$(get_gitea_token "$host") || { - echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2 - return 1 - } + if [[ "$PRINCIPAL_MODE" == "login" ]]; then + token=$(get_gitea_token_for_login "$PRINCIPAL_NAME" "$host") || { + echo "Error: could not resolve a host-matched Gitea token for --login '$PRINCIPAL_NAME' on host '$host' (API path)" >&2 + return 1 + } + else + # Identity-first when MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity + # is set (per-slot token, fail-loud on absence); shared default otherwise. + token=$(get_gitea_token "$host") || { + echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2 + return 1 + } + fi if [[ -n "$LABELS" || -n "$MILESTONE" || "$DRAFT" == true ]]; then echo "Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup." >&2 @@ -76,6 +103,7 @@ Options: -H, --head BRANCH Head branch with changes (default: current branch) -l, --labels LABELS Comma-separated labels -m, --milestone NAME Milestone name + --login NAME Act as this Gitea tea login (wins over MOSAIC_GIT_IDENTITY) -i, --issue NUMBER Link to issue (auto-generates title if not provided) -d, --draft Create as draft PR -h, --help Show this help message @@ -116,6 +144,10 @@ while [[ $# -gt 0 ]]; do MILESTONE="$2" shift 2 ;; + --login) + LOGIN_OVERRIDE="$2" + shift 2 + ;; -i|--issue) ISSUE="$2" shift 2 @@ -174,15 +206,41 @@ case "$PLATFORM" in "${CMD[@]}" ;; gitea) + # Resolve the acting principal identity-first (#1280). The tea login + # list is the LAST resort: it knows nothing about which seat is calling, + # and a login resolved from it first is what attributed PRs to the wrong + # account even when MOSAIC_GIT_IDENTITY was set. + principal_host=$(get_remote_host 2>/dev/null || true) + if ! principal_resolved="$(resolve_gitea_principal "${LOGIN_OVERRIDE:-}" "$principal_host")"; then + # resolve_gitea_principal already printed the fail-loud diagnostic. + exit 1 + fi + PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)" + PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)" + + if [[ "$PRINCIPAL_MODE" == "identity" ]]; then + # HAPPY PATH for a requested identity: the per-slot token IS the + # credential, so create through the REST API directly and never + # invoke tea — the identity arm must be REACHED, not sit behind a + # tea failure (#1280). Fail-loud on a missing slot already happened + # in resolve_gitea_principal. + gitea_pr_create_api + exit $? + fi + # tea pull create syntax. Always pass --repo because tea repo inference # is unreliable in Mosaic worktrees/profile shells. Use arrays instead # of eval so markdown backticks/body content are not shell-executed. REPO_SLUG=$(get_repo_slug) - GITEA_LOGIN_NAME=$(get_gitea_login) || { - echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2 - gitea_pr_create_api - exit $? - } + if [[ "$PRINCIPAL_MODE" == "login" ]]; then + GITEA_LOGIN_NAME="$PRINCIPAL_NAME" + else + GITEA_LOGIN_NAME=$(get_gitea_login) || { + echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2 + gitea_pr_create_api + exit $? + } + fi if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2 gitea_pr_create_api diff --git a/packages/mosaic/framework/tools/git/pr-merge.sh b/packages/mosaic/framework/tools/git/pr-merge.sh index 150ac91e..3d5229ca 100755 --- a/packages/mosaic/framework/tools/git/pr-merge.sh +++ b/packages/mosaic/framework/tools/git/pr-merge.sh @@ -1,6 +1,13 @@ #!/bin/bash # pr-merge.sh - Merge pull requests on Gitea or GitHub -# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL] +# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL] [--login ] +# +# Acting principal is resolved identity-first (#1280): an explicit --login +# wins; otherwise MOSAIC_GIT_IDENTITY / per-worktree git config +# mosaic.gitIdentity selects the credential (per-slot token, fail-loud when +# absent); the shared host credential is the last resort. The merge is +# performed with the resolved credential only — never a cross-principal +# fallback (an HTTP 401 from the identity-bound token is a hard stop). set -euo pipefail @@ -16,6 +23,7 @@ DRY_RUN=false EXPECT_HEAD="" CO_AUTHOR_TRAILERS=false ESCALATE_TO="" +LOGIN_OVERRIDE="" usage() { cat <&2 + exit 1 + fi + LOGIN_OVERRIDE="$2" + shift 2 + ;; -h|--help) usage 0 ;; @@ -572,9 +590,22 @@ PY merge_gitea_with_api() { local host="$1" token attempt_rc - if ! token=$(get_gitea_token "$host"); then - echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2 - return 1 + # Identity-first principal resolution (#1280): an explicit --login wins + # over MOSAIC_GIT_IDENTITY (operator intent beats environment); otherwise + # get_gitea_token resolves the identity's per-slot token when an identity + # is requested (fail-loud when absent) and the shared host credential only + # when no identity is set. No cross-principal fallback: whatever resolves + # here is the ONLY credential the merge is attempted with. + if [[ -n "$LOGIN_OVERRIDE" ]]; then + if ! token=$(get_gitea_token_for_login "$LOGIN_OVERRIDE" "$host"); then + echo "Error: --login '$LOGIN_OVERRIDE' has no host-matched token on host '$host'; refusing to merge under any other principal (#1280 identity-first resolution)." >&2 + return 1 + fi + else + if ! token=$(get_gitea_token "$host"); then + echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2 + return 1 + fi fi if [[ -z "$token" ]]; then echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2 @@ -602,10 +633,25 @@ if [[ "$DRY_RUN" == true ]]; then echo "Error: Cannot determine host from origin remote URL" >&2 exit 1 } + # Report the acting principal the merge WOULD use, resolved the same + # way the real merge resolves it (#1280) — a dry run that names a + # different principal than the merge would act as is a lie. + if ! principal_resolved="$(resolve_gitea_principal "$LOGIN_OVERRIDE" "$HOST")"; then + # Fail-loud diagnostic already printed (unresolvable --login or a + # requested identity with no per-slot token). + exit 1 + fi + DRY_PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)" + DRY_PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)" + case "$DRY_PRINCIPAL_MODE" in + login) DRY_PRINCIPAL_DESC="tea login '$DRY_PRINCIPAL_NAME'" ;; + identity) DRY_PRINCIPAL_DESC="git identity '$DRY_PRINCIPAL_NAME' (per-slot credential)" ;; + *) DRY_PRINCIPAL_DESC="default host credential" ;; + esac if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then - echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)." + echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST as $DRY_PRINCIPAL_DESC with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)." else - echo "Dry run: would merge PR #$PR_NUMBER on $HOST with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)." + echo "Dry run: would merge PR #$PR_NUMBER on $HOST as $DRY_PRINCIPAL_DESC with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)." fi else echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)." diff --git a/packages/mosaic/framework/tools/git/pr-review.sh b/packages/mosaic/framework/tools/git/pr-review.sh index ce10fe98..75ac398f 100755 --- a/packages/mosaic/framework/tools/git/pr-review.sh +++ b/packages/mosaic/framework/tools/git/pr-review.sh @@ -76,7 +76,7 @@ while [[ $# -gt 0 ]]; do echo " -n, --number PR number (required)" echo " -a, --action Review action: approve, request-changes, comment (required)" echo " -c, --comment Review comment (required for request-changes)" - echo " -l, --login Override the detected Gitea tea login (approve/request-changes only)" + echo " -l, --login Override the detected Gitea tea login (all actions; wins over MOSAIC_GIT_IDENTITY)" echo " -r, --repo Explicit owner/repo slug (skips git-remote slug inference)" echo " -H, --host Explicit Gitea host (skips remote-host inference)" echo " -h, --help Show this help" @@ -346,7 +346,14 @@ gitea_resolve_api_for_login() { else host=$(get_remote_host) fi - if [[ -n "$override_explicit" ]]; then + if [[ "$override_explicit" == "identity" ]]; then + # Requested git identity (#1280): the per-slot token MUST resolve via + # get_gitea_token's identity arm; never borrow the tea default login. + GITEA_API_TOKEN=$(get_gitea_token "$host") || { + echo "Error: could not resolve the per-slot token for requested git identity '$effective_login' on host '$host'; refusing to fall back to the tea login list or shared credentials (review write/read-back, #1280)." >&2 + return 1 + } + elif [[ -n "$override_explicit" ]]; then GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || { echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (review write/read-back)" >&2 return 1 @@ -676,29 +683,32 @@ if [[ "$PLATFORM" == "github" ]]; then ;; esac elif [[ "$PLATFORM" == "gitea" ]]; then + # Resolve the acting principal ONCE for every action, identity-first + # (#1280): an explicit --login wins; otherwise MOSAIC_GIT_IDENTITY / + # per-worktree git config mosaic.gitIdentity selects the principal when a + # per-slot token exists (fail-loud when it does not); the tea login list is + # the LAST resort — it enumerates whatever logins this host happens to hold + # and knows nothing about which seat is calling, so resolving from it first + # wrote under whichever account tea had configured (the #1280 family). + principal_host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}" + if ! principal_resolved="$(resolve_gitea_principal "$LOGIN_OVERRIDE" "$principal_host")"; then + # resolve_gitea_principal already printed the fail-loud diagnostic. + exit 1 + fi + PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)" + PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)" case $ACTION in approve) - # Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login - # below re-derives the real host from HOST_OVERRIDE/remote independently and - # is authoritative). Prefer an explicit -H/--host; otherwise best-effort - # git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here - # under `set -e`, with no origin and no -H, previously killed the script - # SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags - # that support running with no usable origin at all). - host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}" - # A --login override always wins. Otherwise name this host's login - # only as a best effort: the login name merely selects a per-login - # token, and gitea_resolve_api_for_login falls back to the host - # credential (get_gitea_token) when no tea login is named — so a host - # tea's login list need not enumerate exotic (e.g. ported) hosts for - # the default credential to resolve. The single resolved token is - # then used for the write, the /user identity, and the read-back. - EFFECTIVE_LOGIN="$LOGIN_OVERRIDE" - [[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true) - # Bind the REST endpoint + token to the effective login, then derive - # the acting identity from that SAME credential so the review submit - # and its read-back verify against the identity that performed them. - gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1 + # Identity-first principal resolution (#1280): PRINCIPAL_MODE / + # PRINCIPAL_NAME were resolved once above from --login > + # MOSAIC_GIT_IDENTITY / git config > tea login list (last resort). + if [[ "$PRINCIPAL_MODE" == "identity" ]]; then + gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1 + elif [[ "$PRINCIPAL_MODE" == "login" ]]; then + gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1 + else + gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1 + fi ACTING_LOGIN=$(gitea_authenticated_login) || exit 1 head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1 # The review body (if any) travels with the review itself in the REST @@ -715,24 +725,16 @@ elif [[ "$PLATFORM" == "gitea" ]]; then echo "Error: Comment required for request-changes" exit 1 fi - # Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login - # below re-derives the real host from HOST_OVERRIDE/remote independently and - # is authoritative). Prefer an explicit -H/--host; otherwise best-effort - # git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here - # under `set -e`, with no origin and no -H, previously killed the script - # SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags - # that support running with no usable origin at all). - host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}" - # A --login override always wins. Otherwise name this host's login - # only as a best effort: the login name merely selects a per-login - # token, and gitea_resolve_api_for_login falls back to the host - # credential (get_gitea_token) when no tea login is named — so a host - # tea's login list need not enumerate exotic (e.g. ported) hosts for - # the default credential to resolve. The single resolved token is - # then used for the write, the /user identity, and the read-back. - EFFECTIVE_LOGIN="$LOGIN_OVERRIDE" - [[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true) - gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1 + # Identity-first principal resolution (#1280): PRINCIPAL_MODE / + # PRINCIPAL_NAME were resolved once above from --login > + # MOSAIC_GIT_IDENTITY / git config > tea login list (last resort). + if [[ "$PRINCIPAL_MODE" == "identity" ]]; then + gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1 + elif [[ "$PRINCIPAL_MODE" == "login" ]]; then + gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1 + else + gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1 + fi ACTING_LOGIN=$(gitea_authenticated_login) || exit 1 head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1 review_id=$(gitea_submit_review_verified "$PR_NUMBER" "REQUEST_CHANGES" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || { @@ -746,24 +748,16 @@ elif [[ "$PLATFORM" == "gitea" ]]; then echo "Error: Comment required" exit 1 fi - # Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login - # below re-derives the real host from HOST_OVERRIDE/remote independently and - # is authoritative). Prefer an explicit -H/--host; otherwise best-effort - # git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here - # under `set -e`, with no origin and no -H, previously killed the script - # SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags - # that support running with no usable origin at all). - host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}" - # A --login override always wins. Otherwise name this host's login - # only as a best effort: the login name merely selects a per-login - # token, and gitea_resolve_api_for_login falls back to the host - # credential (get_gitea_token) when no tea login is named — so a host - # tea's login list need not enumerate exotic (e.g. ported) hosts for - # the default credential to resolve. The single resolved token is - # then used for the write, the /user identity, and the read-back. - EFFECTIVE_LOGIN="$LOGIN_OVERRIDE" - [[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true) - gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1 + # Identity-first principal resolution (#1280): PRINCIPAL_MODE / + # PRINCIPAL_NAME were resolved once above from --login > + # MOSAIC_GIT_IDENTITY / git config > tea login list (last resort). + if [[ "$PRINCIPAL_MODE" == "identity" ]]; then + gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1 + elif [[ "$PRINCIPAL_MODE" == "login" ]]; then + gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1 + else + gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1 + fi ACTING_LOGIN=$(gitea_authenticated_login) || exit 1 comment_id=$(gitea_create_comment_verified "$PR_NUMBER" "$COMMENT" "$ACTING_LOGIN") || { echo "Error: could not create and verify a comment on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2 diff --git a/packages/mosaic/framework/tools/git/test-gitea-principal-resolution.sh b/packages/mosaic/framework/tools/git/test-gitea-principal-resolution.sh new file mode 100755 index 00000000..a0aaba3d --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-gitea-principal-resolution.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +# Regression harness for detect-platform.sh's resolve_gitea_principal() — the +# identity-first acting-principal resolution shared by the git wrappers +# (mosaicstack/stack #1280). +# +# The contract under test (precedence: --login > MOSAIC_GIT_IDENTITY / +# git config mosaic.gitIdentity > tea login list, which is the LAST resort): +# 1. identity env + per-slot token present -> mode=identity, principal= +# identity name, source names the identity's slot PATH (never a token +# value). +# 2. identity env + per-slot token ABSENT -> FAIL LOUD: nonzero, empty +# stdout, stderr naming the identity and the expected slot path. +# 3. identity env + --login -> --login wins (login mode resolves even when +# the identity has no slot — operator intent beats environment). +# 4. identity unset + no --login -> default mode: the tea login list +# resolves the principal exactly as before (preserved behavior). +# 5. no identity + no host-matching tea login -> default/host-credential +# (preserved behavior; absence is not an error on the default path). +# 6. identity on an UNRECOGNIZED host (no per-slot scheme) -> does not bind; +# default mode (containment, mirroring get_gitea_token). +# 7. --login with no host-bound token for that login -> FAIL LOUD, stderr +# naming the login and the host. +# 8. git config mosaic.gitIdentity is honored when the env var is unset. +# 9. The resolver NEVER emits a token value — stdout/stderr of every +# successful resolution must not contain the slot file's contents. +# +# Uses a stubbed tea binary, stubbed tea config.yml, stubbed credentials.json +# and stubbed per-slot token files under a fake HOME. NEVER reads real secrets. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-principal-resolution}" +FAKE_HOME="$WORK_DIR/home" +REPO_DIR="$WORK_DIR/repo" +BIN_DIR="$WORK_DIR/bin" +CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json" + +rm -rf "$WORK_DIR" +mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$FAKE_HOME/.config/tea" "$REPO_DIR" "$BIN_DIR" + +git -C "$REPO_DIR" init -q +git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git + +cat > "$CREDENTIALS_FILE" <<'JSON' +{ + "gitea": { + "mosaicstack": { + "url": "https://git.mosaicstack.dev", + "token": "shared-mosaicstack-token" + }, + "usc": { + "url": "https://git.uscllc.com", + "token": "shared-usc-token" + } + } +} +JSON + +# tea's own config store: the source get_gitea_token_for_login reads. Logins +# "alice" (mosaicstack) and "bob-usc" (usc) carry sentinel token values that +# the assertions prove are NEVER emitted by the resolver. +cat > "$FAKE_HOME/.config/tea/config.yml" <<'YAML' +logins: +- name: alice + url: https://git.mosaicstack.dev + token: SECRET-alice-tea-token +- name: bob-usc + url: https://git.uscllc.com + token: SECRET-bob-usc-tea-token +YAML + +# Stubbed tea: only what login resolution needs (`login list --output json`). +cat > "$BIN_DIR/tea" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "login list --output json" ]]; then + cat <<'JSON' +[ + {"name":"alice","url":"https://git.mosaicstack.dev","default":true}, + {"name":"bob-usc","url":"https://git.uscllc.com"} +] +JSON + exit 0 +fi +exit 0 +SH +chmod +x "$BIN_DIR/tea" + +# Per-slot identity token with a sentinel value the assertions prove is never +# emitted (proving "token came from the identity's slot BY PATH, not by value"). +echo -n "SECRET-agentX-slot-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentX.token" + +fail=0 +assert_eq() { + local desc="$1" expected="$2" actual="$3" + if [[ "$expected" != "$actual" ]]; then + echo "FAIL: $desc — expected '$expected', got '$actual'" >&2 + fail=1 + fi +} +assert_contains() { + local desc="$1" haystack="$2" needle="$3" + if [[ "$haystack" != *"$needle"* ]]; then + echo "FAIL: $desc — missing '$needle' in: $haystack" >&2 + fail=1 + fi +} +assert_not_contains() { + local desc="$1" haystack="$2" needle="$3" + if [[ "$haystack" == *"$needle"* ]]; then + echo "FAIL: $desc — must not contain '$needle', got: $haystack" >&2 + fail=1 + fi +} + +# Runs resolve_gitea_principal for $1=login_override $2=host inside REPO_DIR +# (per-worktree git config resolves there) under a fake HOME, stubbed tea, and +# stubbed credentials. Extra env (e.g. MOSAIC_GIT_IDENTITY) via $@. +call_resolver() { + local login="$1" host="$2"; shift 2 + ( + cd "$REPO_DIR" + env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:$PATH" \ + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \ + MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ + DETECT_PLATFORM_SH="$SCRIPT_DIR/detect-platform.sh" "$@" \ + bash -c 'source "$DETECT_PLATFORM_SH"; resolve_gitea_principal "$1" "$2"' _ "$login" "$host" + ) +} + +field() { printf '%s' "$1" | cut -f"$2"; } + +# --------------------------------------------------------------------------- +# 1. Identity env + slot present -> identity mode, slot named BY PATH, and no +# token value ever emitted. +# --------------------------------------------------------------------------- +git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true +out=$(call_resolver "" "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentX) +assert_eq "identity mode" "identity" "$(field "$out" 1)" +assert_eq "identity principal" "agentX" "$(field "$out" 2)" +assert_eq "identity slot source" \ + "identity-slot:$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentX.token" \ + "$(field "$out" 3)" +assert_not_contains "identity stdout leaks token" "$out" "SECRET" + +# --------------------------------------------------------------------------- +# 2. Identity env + slot ABSENT -> fail loud: nonzero, empty stdout, stderr +# naming the identity and the expected slot path. +# --------------------------------------------------------------------------- +stderr_file="$WORK_DIR/stderr.tmp" +set +e +out=$(call_resolver "" "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentNoSlot 2>"$stderr_file") +rc=$? +set -e +if [[ "$rc" -eq 0 ]]; then + echo "FAIL: missing slot — expected nonzero return, got 0 (stdout='$out')" >&2 + fail=1 +fi +if [[ -n "$out" ]]; then + echo "FAIL: missing slot — expected empty stdout, got '$out'" >&2 + fail=1 +fi +err=$(cat "$stderr_file") +assert_contains "missing slot names identity" "$err" "agentNoSlot" +assert_contains "missing slot names slot path" "$err" \ + "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentNoSlot.token" +assert_not_contains "missing-slot stderr leaks token" "$err" "SECRET" + +# --------------------------------------------------------------------------- +# 3. Identity + --login -> --login wins. Also wins when the identity has NO +# slot (no identity check may veto an explicit login). +# --------------------------------------------------------------------------- +out=$(call_resolver "alice" "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentX) +assert_eq "login beats identity (mode)" "login" "$(field "$out" 1)" +assert_eq "login beats identity (principal)" "alice" "$(field "$out" 2)" +assert_eq "login source" "tea-login:alice" "$(field "$out" 3)" +out=$(call_resolver "alice" "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentNoSlot) +assert_eq "login beats slot-less identity" "login" "$(field "$out" 1)" + +# --------------------------------------------------------------------------- +# 4. No identity, no --login -> default mode via the tea login list +# (preserved behavior). +# --------------------------------------------------------------------------- +out=$(call_resolver "" "git.mosaicstack.dev") +assert_eq "default mode" "default" "$(field "$out" 1)" +assert_eq "default principal" "alice" "$(field "$out" 2)" +assert_eq "default source" "tea-default" "$(field "$out" 3)" + +# --------------------------------------------------------------------------- +# 5. No identity, no --login, no host-matching tea login -> default with the +# host credential (absence is not an error on the default path). +# --------------------------------------------------------------------------- +out=$(call_resolver "" "git.unknown.test") +assert_eq "no-match default mode" "default" "$(field "$out" 1)" +assert_eq "no-match default principal" "" "$(field "$out" 2)" +assert_eq "no-match default source" "host-credential" "$(field "$out" 3)" + +# --------------------------------------------------------------------------- +# 6. Identity on an UNRECOGNIZED host -> does not bind; default mode +# (containment, mirroring get_gitea_token's scope). +# --------------------------------------------------------------------------- +out=$(call_resolver "" "github.com" MOSAIC_GIT_IDENTITY=agentX) +assert_eq "unrecognized host falls to default" "default" "$(field "$out" 1)" + +# --------------------------------------------------------------------------- +# 7. --login with no host-bound token for that login -> fail loud, stderr +# naming the login and the host. +# --------------------------------------------------------------------------- +: > "$stderr_file" +set +e +out=$(call_resolver "ghost-login" "git.mosaicstack.dev" 2>"$stderr_file") +rc=$? +set -e +if [[ "$rc" -eq 0 ]]; then + echo "FAIL: unknown --login — expected nonzero return, got 0 (stdout='$out')" >&2 + fail=1 +fi +err=$(cat "$stderr_file") +assert_contains "unknown login names login" "$err" "ghost-login" +assert_contains "unknown login names host" "$err" "git.mosaicstack.dev" +# A cross-host login (exists, but for usc) must ALSO fail loud for mosaicstack. +set +e +out=$(call_resolver "bob-usc" "git.mosaicstack.dev" 2>"$stderr_file") +rc=$? +set -e +if [[ "$rc" -eq 0 ]]; then + echo "FAIL: cross-host --login — expected nonzero return, got 0" >&2 + fail=1 +fi + +# --------------------------------------------------------------------------- +# 8. git config mosaic.gitIdentity honored when env is unset. +# --------------------------------------------------------------------------- +git -C "$REPO_DIR" config mosaic.gitIdentity agentX +out=$(call_resolver "" "git.mosaicstack.dev") +assert_eq "git-config identity mode" "identity" "$(field "$out" 1)" +assert_eq "git-config identity principal" "agentX" "$(field "$out" 2)" +git -C "$REPO_DIR" config --unset mosaic.gitIdentity + +# --------------------------------------------------------------------------- +# 9. Cross-host slot layout: the usc slot path is chosen for the usc host. +# --------------------------------------------------------------------------- +echo -n "SECRET-agentX-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentX.token" +out=$(call_resolver "" "git.uscllc.com" MOSAIC_GIT_IDENTITY=agentX) +assert_eq "usc identity mode" "identity" "$(field "$out" 1)" +assert_eq "usc slot source" \ + "identity-slot:$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentX.token" \ + "$(field "$out" 3)" + +if [[ "$fail" -eq 0 ]]; then + echo "resolve_gitea_principal identity-first resolution regression passed" +fi + +exit "$fail" diff --git a/packages/mosaic/framework/tools/git/test-pr-create-identity-first.sh b/packages/mosaic/framework/tools/git/test-pr-create-identity-first.sh new file mode 100755 index 00000000..a45ecec1 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-pr-create-identity-first.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# Load-bearing regression harness for pr-create.sh identity-first principal +# resolution (mosaicstack/stack #1280). +# +# The failure this harness is written down to catch: `MOSAIC_GIT_IDENTITY=fargo +# pr-create.sh …` produces a PR attributed to `mos-dt-0` (whichever account the +# tea login list happens to hold). Before #1280 the identity-aware code existed +# but sat on the API arm that only ran when the tea path FAILED — tea succeeded, +# so the identity arm never executed, and every test that did not check ORDERING +# passed. This harness checks ordering directly: +# +# 1. identity set + slot present -> the PR is created via the REST API with +# the identity's per-slot token (asserted by sentinel value AT the fake +# provider), and tea's `pr create` is NEVER invoked. +# 2. identity set + slot ABSENT -> nonzero, stderr naming the identity and +# the expected slot path; neither tea `pr create` nor any API request +# fires. No silent fallback to the tea login list. +# 3. identity set + --login -> --login wins: tea runs WITH the explicit +# --login, no API request. +# 4. nothing set -> preserved behavior: tea path with the tea-list login. +# +# Uses a stubbed tea, a stubbed curl provider, stubbed credentials.json and +# per-slot token under a fake HOME. NEVER reads real secrets or hits a live +# forge — all assertions are against the stubs' logs. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-create-identity-first}" +FAKE_HOME="$WORK_DIR/home" +REPO_DIR="$WORK_DIR/repo" +TOOLS_DIR="$WORK_DIR/tools" +BIN_DIR="$WORK_DIR/bin" +LOG_FILE="$WORK_DIR/calls.log" +CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json" + +rm -rf "$WORK_DIR" +mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$FAKE_HOME/.config/tea" \ + "$REPO_DIR" "$TOOLS_DIR/git" "$TOOLS_DIR/_lib" "$BIN_DIR" + +# Fixture: the real scripts under test, copied so sibling stubs (and the +# ../_lib credential loader) resolve inside the fixture tree. +cp "$SCRIPT_DIR/pr-create.sh" "$TOOLS_DIR/git/pr-create.sh" +cp "$SCRIPT_DIR/detect-platform.sh" "$TOOLS_DIR/git/detect-platform.sh" +cp "$SCRIPT_DIR/../_lib/credentials.sh" "$TOOLS_DIR/_lib/credentials.sh" +chmod +x "$TOOLS_DIR/git/pr-create.sh" + +git -C "$REPO_DIR" init -q +git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git + +cat > "$CREDENTIALS_FILE" <<'JSON' +{ + "gitea": { + "mosaicstack": { + "url": "https://git.mosaicstack.dev", + "token": "shared-mosaicstack-token" + } + } +} +JSON + +cat > "$FAKE_HOME/.config/tea/config.yml" <<'YAML' +logins: +- name: alice + url: https://git.mosaicstack.dev + token: SECRET-alice-tea-token +YAML + +echo -n "SECRET-agentX-slot-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentX.token" + +: > "$LOG_FILE" + +# Stubbed tea: records every invocation; `login list` feeds login resolution; +# `api --login /user` feeds get_gitea_authenticated_user; `pr create` marks +# the marker file (its presence fails the identity-mode assertions). +cat > "$BIN_DIR/tea" <> "$LOG_FILE" +if [[ "\$*" == "login list --output json" ]]; then + cat <<'JSON' +[ + {"name":"alice","url":"https://git.mosaicstack.dev","default":true} +] +JSON + exit 0 +fi +if [[ "\${1:-}" == "api" ]]; then + printf '%s\n' '{"login":"alice"}' + exit 0 +fi +if [[ "\$*" == pr\ create* ]]; then + echo "TEA-PR-CREATE-INVOKED" >> "$LOG_FILE" + exit 0 +fi +exit 0 +SH +chmod +x "$BIN_DIR/tea" + +# Stubbed provider: records the URL and the Authorization header VALUE it +# received, answers 201 with a created-PR object. The sentinel token values are +# synthetic fixtures — asserting them at the provider proves WHICH slot's +# credential carried the write. +cat > "$BIN_DIR/curl" <> "$LOG_FILE" +cat <<'JSON' +{"number": 1299, "html_url": "https://git.mosaicstack.dev/mosaicstack/stack/pulls/1299"} +JSON +exit 0 +SH +chmod +x "$BIN_DIR/curl" + +fail=0 +assert_contains() { + local desc="$1" needle="$2" + if ! grep -qF -- "$needle" "$LOG_FILE"; then + echo "FAIL: $desc — log does not contain '$needle':" >&2 + cat "$LOG_FILE" >&2 + fail=1 + fi +} +assert_not_contains() { + local desc="$1" needle="$2" + if grep -qF -- "$needle" "$LOG_FILE"; then + echo "FAIL: $desc — log must not contain '$needle':" >&2 + cat "$LOG_FILE" >&2 + fail=1 + fi +} + +EXTRA_ARGS="" +run_pr_create() { + # "$@" carries ONLY environment assignments (VAR=value); EXTRA_ARGS (if + # set) carries wrapper arguments, so `env` never mistakes a wrapper flag + # like --login for one of its own. + ( + cd "$REPO_DIR" + # shellcheck disable=SC2086 # EXTRA_ARGS is deliberately word-split wrapper args + env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:$PATH" \ + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \ + MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" "$@" \ + bash "$TOOLS_DIR/git/pr-create.sh" -t "Test PR" -B next -H fix/test $EXTRA_ARGS + ) +} + +# --------------------------------------------------------------------------- +# 1. HAPPY PATH (the load-bearing ordering test): identity set + slot present +# -> REST API with the per-slot token; tea `pr create` NEVER invoked. +# --------------------------------------------------------------------------- +set +e +out=$(run_pr_create MOSAIC_GIT_IDENTITY=agentX 2>"$WORK_DIR/stderr-1.tmp") +rc=$? +set -e +if [[ "$rc" -ne 0 ]]; then + echo "FAIL: identity happy path — expected rc=0, got $rc" >&2 + cat "$WORK_DIR/stderr-1.tmp" >&2 + fail=1 +fi +assert_contains "identity happy path reaches the API" "CURL-URL: https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls" +assert_contains "identity happy path carries the slot token" "CURL-AUTH: Authorization: token SECRET-agentX-slot-token" +assert_not_contains "identity happy path must NOT invoke tea pr create" "TEA-PR-CREATE-INVOKED" + +# --------------------------------------------------------------------------- +# 2. Identity set + slot ABSENT -> fail loud BEFORE any write: nonzero, stderr +# naming identity + slot path, no tea pr create, no API request. +# --------------------------------------------------------------------------- +: > "$LOG_FILE" +set +e +out=$(run_pr_create MOSAIC_GIT_IDENTITY=agentNoSlot 2>"$WORK_DIR/stderr-2.tmp") +rc=$? +set -e +if [[ "$rc" -eq 0 ]]; then + echo "FAIL: missing slot — expected nonzero return, got 0 (stdout='$out')" >&2 + fail=1 +fi +err=$(cat "$WORK_DIR/stderr-2.tmp") +if [[ "$err" != *"agentNoSlot"* ]]; then + echo "FAIL: missing slot — stderr does not name the identity:" >&2 + echo "$err" >&2 + fail=1 +fi +if [[ "$err" != *"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentNoSlot.token"* ]]; then + echo "FAIL: missing slot — stderr does not name the expected slot path:" >&2 + echo "$err" >&2 + fail=1 +fi +assert_not_contains "missing slot must not reach tea pr create" "TEA-PR-CREATE-INVOKED" +assert_not_contains "missing slot must not reach the API" "CURL-URL" + +# --------------------------------------------------------------------------- +# 3. Identity set + --login -> --login wins: tea runs WITH the explicit login. +# --------------------------------------------------------------------------- +: > "$LOG_FILE" +EXTRA_ARGS="--login alice" +set +e +out=$(run_pr_create MOSAIC_GIT_IDENTITY=agentX 2>"$WORK_DIR/stderr-3.tmp") +rc=$? +set -e +EXTRA_ARGS="" +if [[ "$rc" -ne 0 ]]; then + echo "FAIL: login override — expected rc=0, got $rc" >&2 + cat "$WORK_DIR/stderr-3.tmp" >&2 + fail=1 +fi +assert_contains "login override drives tea with the explicit login" "TEA: pr create --repo mosaicstack/stack --login alice" +assert_not_contains "login override must not hit the API" "CURL-URL" + +# --------------------------------------------------------------------------- +# 4. Nothing set -> preserved behavior: tea path with the tea-list login. +# --------------------------------------------------------------------------- +: > "$LOG_FILE" +set +e +out=$(run_pr_create 2>"$WORK_DIR/stderr-4.tmp") +rc=$? +set -e +if [[ "$rc" -ne 0 ]]; then + echo "FAIL: default path — expected rc=0, got $rc" >&2 + cat "$WORK_DIR/stderr-4.tmp" >&2 + fail=1 +fi +assert_contains "default path still uses the tea-list login" "TEA: pr create --repo mosaicstack/stack --login alice" + +if [[ "$fail" -eq 0 ]]; then + echo "pr-create identity-first happy-path regression passed" +fi + +exit "$fail" diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-principal-resolution.sh b/packages/mosaic/framework/tools/git/test-pr-merge-principal-resolution.sh new file mode 100755 index 00000000..4076f0d4 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-pr-merge-principal-resolution.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# Regression harness for pr-merge.sh identity-first principal resolution +# (mosaicstack/stack #1280). +# +# Covers: +# 1. --dry-run reports the acting principal the merge WOULD use, resolved the +# same way the real merge resolves it: --login > MOSAIC_GIT_IDENTITY / +# git config mosaic.gitIdentity > shared host credential. (The pre-#1280 +# deployed copy reported a tea login that the merge would not act as.) +# 2. --dry-run fails closed when the requested principal has no credential: +# unknown --login, or an identity with no per-slot token (stderr names +# the login / the identity and its slot path). +# 3. The real merge POST carries the resolved principal's credential and no +# other: --login merges with that login's tea-config token; an identity +# merges with the per-slot token; an unresolvable --login never reaches +# the provider. +# +# Fixture pattern from test-pr-merge-head-pin.sh: the scripts under test are +# copied into a fixture tree with stubbed pr-metadata.sh / ci-queue-wait.sh +# siblings; the provider is a stubbed curl that records the credential it +# received. NEVER reads real secrets or hits a live forge. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-principal-resolution}" +FAKE_HOME="$WORK_DIR/home" +REPO_DIR="$WORK_DIR/repo" +TOOLS_DIR="$WORK_DIR/tools" +BIN_DIR="$WORK_DIR/bin" +LOG_FILE="$WORK_DIR/calls.log" +CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json" +SHA=0123456789abcdef0123456789abcdef01234567 + +rm -rf "$WORK_DIR" +mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$FAKE_HOME/.config/tea" \ + "$REPO_DIR" "$TOOLS_DIR/git" "$TOOLS_DIR/_lib" "$BIN_DIR" + +cp "$SCRIPT_DIR/pr-merge.sh" "$TOOLS_DIR/git/pr-merge.sh" +cp "$SCRIPT_DIR/detect-platform.sh" "$TOOLS_DIR/git/detect-platform.sh" +cp "$SCRIPT_DIR/../_lib/credentials.sh" "$TOOLS_DIR/_lib/credentials.sh" +chmod +x "$TOOLS_DIR/git/pr-merge.sh" + +git -C "$REPO_DIR" init -q +git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git + +# Stubbed siblings pr-merge.sh resolves relative to its own SCRIPT_DIR. +cat > "$TOOLS_DIR/git/pr-metadata.sh" < "$TOOLS_DIR/git/ci-queue-wait.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH +chmod +x "$TOOLS_DIR/git/pr-metadata.sh" "$TOOLS_DIR/git/ci-queue-wait.sh" + +cat > "$CREDENTIALS_FILE" <<'JSON' +{ + "gitea": { + "mosaicstack": { + "url": "https://git.mosaicstack.dev", + "token": "shared-mosaicstack-token" + } + } +} +JSON + +cat > "$FAKE_HOME/.config/tea/config.yml" <<'YAML' +logins: +- name: fred-ms + url: https://git.mosaicstack.dev + token: SECRET-fred-ms-tea-token +YAML + +echo -n "SECRET-agentX-slot-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentX.token" + +: > "$LOG_FILE" + +# Stubbed tea for login-list resolution only. +cat > "$BIN_DIR/tea" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "login list --output json" ]]; then + cat <<'JSON' +[ + {"name":"fred-ms","url":"https://git.mosaicstack.dev","default":true} +] +JSON + exit 0 +fi +exit 0 +SH +chmod +x "$BIN_DIR/tea" + +# Stubbed provider. pr-merge passes curl config on STDIN with -K -; the stub +# reads stdin, records the Authorization header it received, answers 200. +cat > "$BIN_DIR/curl" <> "$LOG_FILE" +[[ -n "\$out_file" ]] && printf '{}' > "\$out_file" +printf '200\n' +exit 0 +SH +chmod +x "$BIN_DIR/curl" + +fail=0 +assert_contains_log() { + local desc="$1" needle="$2" + if ! grep -qF -- "$needle" "$LOG_FILE"; then + echo "FAIL: $desc — log does not contain '$needle':" >&2 + cat "$LOG_FILE" >&2 + fail=1 + fi +} +assert_not_contains_log() { + local desc="$1" needle="$2" + if grep -qF -- "$needle" "$LOG_FILE"; then + echo "FAIL: $desc — log must not contain '$needle':" >&2 + cat "$LOG_FILE" >&2 + fail=1 + fi +} + +run_pr_merge() { + local extra_args="$1"; shift + ( + cd "$REPO_DIR" + # shellcheck disable=SC2086 # extra_args is deliberately word-split wrapper args + env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:$PATH" \ + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \ + MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" "$@" \ + bash "$TOOLS_DIR/git/pr-merge.sh" -n 42 $extra_args + ) +} + +# --------------------------------------------------------------------------- +# 1. --dry-run reports the resolved acting principal truthfully. +# --------------------------------------------------------------------------- +out=$(run_pr_merge "--dry-run" MOSAIC_GIT_IDENTITY=agentX) +if [[ "$out" != *"as git identity 'agentX' (per-slot credential)"* ]]; then + echo "FAIL: dry-run identity — principal not reported: $out" >&2 + fail=1 +fi + +out=$(run_pr_merge "--dry-run --login fred-ms" MOSAIC_GIT_IDENTITY=agentX) +if [[ "$out" != *"as tea login 'fred-ms'"* ]]; then + echo "FAIL: dry-run login override — login not reported (must beat env identity): $out" >&2 + fail=1 +fi + +out=$(run_pr_merge "--dry-run") +if [[ "$out" != *"as default host credential"* ]]; then + echo "FAIL: dry-run default — not reported: $out" >&2 + fail=1 +fi + +# --------------------------------------------------------------------------- +# 2. --dry-run fails closed when the requested principal has no credential. +# --------------------------------------------------------------------------- +stderr_file="$WORK_DIR/stderr.tmp" +set +e +out=$(run_pr_merge "--dry-run --login ghost" 2>"$stderr_file") +rc=$? +set -e +if [[ "$rc" -eq 0 ]] || [[ "$(cat "$stderr_file")" != *"ghost"* ]]; then + echo "FAIL: dry-run unknown --login — expected fail-loud naming 'ghost', rc=$rc" >&2 + cat "$stderr_file" >&2 + fail=1 +fi + +: > "$stderr_file" +set +e +out=$(run_pr_merge "--dry-run" MOSAIC_GIT_IDENTITY=agentNoSlot 2>"$stderr_file") +rc=$? +set -e +err=$(cat "$stderr_file") +if [[ "$rc" -eq 0 ]] || [[ "$err" != *"agentNoSlot"* ]] \ + || [[ "$err" != *"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentNoSlot.token"* ]]; then + echo "FAIL: dry-run identity without slot — expected fail-loud naming identity + slot path, rc=$rc" >&2 + echo "$err" >&2 + fail=1 +fi + +# --------------------------------------------------------------------------- +# 3. The real merge POST carries the resolved principal's credential ONLY. +# --------------------------------------------------------------------------- +: > "$LOG_FILE" +set +e +out=$(run_pr_merge "--login fred-ms" MOSAIC_GIT_IDENTITY=agentX 2>"$stderr_file") +rc=$? +set -e +if [[ "$rc" -ne 0 ]]; then + echo "FAIL: merge with --login — expected rc=0, got $rc" >&2 + cat "$stderr_file" >&2 + fail=1 +fi +assert_contains_log "merge --login uses the login token" "CURL-AUTH: Authorization: token SECRET-fred-ms-tea-token" +assert_not_contains_log "merge --login must not use the identity slot token" "SECRET-agentX-slot-token" +assert_not_contains_log "merge --login must not use the shared token" "shared-mosaicstack-token" + +: > "$LOG_FILE" +set +e +out=$(run_pr_merge "" MOSAIC_GIT_IDENTITY=agentX 2>"$stderr_file") +rc=$? +set -e +if [[ "$rc" -ne 0 ]]; then + echo "FAIL: merge with identity — expected rc=0, got $rc" >&2 + cat "$stderr_file" >&2 + fail=1 +fi +assert_contains_log "merge identity uses the per-slot token" "CURL-AUTH: Authorization: token SECRET-agentX-slot-token" +assert_not_contains_log "merge identity must not use the shared token" "shared-mosaicstack-token" + +: > "$LOG_FILE" +set +e +out=$(run_pr_merge "--login ghost" 2>"$stderr_file") +rc=$? +set -e +if [[ "$rc" -eq 0 ]]; then + echo "FAIL: merge with unknown --login — expected nonzero, got 0" >&2 + fail=1 +fi +assert_not_contains_log "merge with unknown --login must not reach the provider" "CURL-URL" + +if [[ "$fail" -eq 0 ]]; then + echo "pr-merge identity-first principal resolution regression passed" +fi + +exit "$fail" diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 98cf494f..f7a988a2 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -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 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/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-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.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-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/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/fleet/test-start-agent-session.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" + "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.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/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-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.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-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-gitea-principal-resolution.sh && bash framework/tools/git/test-pr-create-identity-first.sh && bash framework/tools/git/test-pr-merge-principal-resolution.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/fleet/test-start-agent-session.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" }, "dependencies": { "@mosaicstack/brain": "workspace:*",