Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b09589f02c |
@@ -30,7 +30,7 @@ The Gitea API token is **never passed on a curl command line.** An `Authorizatio
|
|||||||
|
|
||||||
### `--login` override
|
### `--login` override
|
||||||
|
|
||||||
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` 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 <reviewer-login>`.
|
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` 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 <reviewer-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.
|
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,36 +58,6 @@ 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
|
unchanged, so this feature is a no-op on any host that hasn't provisioned per-slot
|
||||||
tokens.
|
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 <name>` (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
|
### Enabling it for a clone
|
||||||
|
|
||||||
The framework installer syncs `git-credential-mosaic` to
|
The framework installer syncs `git-credential-mosaic` to
|
||||||
|
|||||||
@@ -497,32 +497,6 @@ get_gitea_url_for_host() {
|
|||||||
return 1
|
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.
|
# Resolve a Gitea API token for the given host.
|
||||||
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
|
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
|
||||||
get_gitea_token() {
|
get_gitea_token() {
|
||||||
@@ -543,8 +517,13 @@ get_gitea_token() {
|
|||||||
_ident_src="git config mosaic.gitIdentity"
|
_ident_src="git config mosaic.gitIdentity"
|
||||||
fi
|
fi
|
||||||
if [[ -n "$_ident" ]]; then
|
if [[ -n "$_ident" ]]; then
|
||||||
local _idtok=""
|
local _idpfx=""
|
||||||
if _idtok="$(gitea_identity_token_slot "$_ident" "$host" 2>/dev/null)"; then
|
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"
|
||||||
if [[ -r "$_idtok" ]]; then
|
if [[ -r "$_idtok" ]]; then
|
||||||
cat "$_idtok"
|
cat "$_idtok"
|
||||||
return 0
|
return 0
|
||||||
@@ -1486,81 +1465,6 @@ raise SystemExit(1)
|
|||||||
PY
|
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.
|
# 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.
|
# Prints "username:password" for direct curl -u consumption. Callers must not log it.
|
||||||
get_gitea_basic_auth() {
|
get_gitea_basic_auth() {
|
||||||
|
|||||||
@@ -76,36 +76,27 @@ fi
|
|||||||
detect_platform >/dev/null
|
detect_platform >/dev/null
|
||||||
|
|
||||||
# Resolve and cache the Gitea REST endpoint + token for the current remote,
|
# Resolve and cache the Gitea REST endpoint + token for the current remote,
|
||||||
# bound to a SPECIFIC acting principal ($1) selected identity-first (#1280):
|
# bound to a SPECIFIC login identity ($1). Populates GITEA_API_ROOT (…/api/v1),
|
||||||
# an explicit --login wins, else MOSAIC_GIT_IDENTITY / git config
|
# GITEA_API_BASE (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
|
||||||
# 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/<slug>), and GITEA_API_TOKEN.
|
|
||||||
#
|
#
|
||||||
# The token is resolved for the EFFECTIVE principal so that the single
|
# The token is resolved for the EFFECTIVE login (the --login override when
|
||||||
# credential used for the write ALSO drives the /user identity read and the
|
# given, otherwise the detected default) so that the single credential used for
|
||||||
# read-back — write token and read-back token are the same identity by
|
# the write ALSO drives the /user identity read and the read-back — write token
|
||||||
# construction (this is the credential-ordering fix: a --login override is no
|
# and read-back token are the same identity by construction (this is the
|
||||||
# longer written under one credential and verified under a different default
|
# credential-ordering fix: a --login override is no longer written under one
|
||||||
# one). When $2 is "identity" the principal ($1) is a requested git identity:
|
# credential and verified under a different default one). Falls back to the
|
||||||
# the token MUST resolve from that identity's per-slot token (get_gitea_token's
|
# host-scoped credential ONLY when NO --login override was supplied (the
|
||||||
# identity arm), failing closed rather than borrowing the tea default login —
|
# best-effort default path). When $2 is "explicit" the login came from a
|
||||||
# the tea login list must never shadow a requested identity (#1280). When $2
|
# caller-supplied --login: that exact login's token MUST resolve, and we FAIL
|
||||||
# is "explicit" the principal came from a caller-supplied --login: that exact
|
# CLOSED rather than silently downgrading the write to the host default
|
||||||
# login's token MUST resolve, and we FAIL CLOSED rather than silently
|
# identity — otherwise a caller relying on a dedicated per-role credential would
|
||||||
# downgrading the write to the host default identity. Otherwise the best-effort
|
# be told the write succeeded as requested while it was attributed to the shared
|
||||||
# default path applies (per-login token, else the host-scoped credential).
|
# default. Returns non-zero (clear stderr) on any resolution failure.
|
||||||
# Returns non-zero (clear stderr) on any resolution failure.
|
|
||||||
gitea_resolve_api_for_login() {
|
gitea_resolve_api_for_login() {
|
||||||
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
|
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
|
||||||
|
|
||||||
host=$(get_remote_host)
|
host=$(get_remote_host)
|
||||||
if [[ "$override_explicit" == "identity" ]]; then
|
if [[ -n "$override_explicit" ]]; 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") || {
|
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
|
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
|
return 1
|
||||||
@@ -327,31 +318,23 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||||
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
# Resolve the acting principal identity-first (#1280): an explicit --login
|
# Resolve the login this comment should be attributed to: the --login
|
||||||
# wins; otherwise MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity
|
# override when given, otherwise the detected default for this repo's host.
|
||||||
# selects the principal when a per-slot token exists (fail-loud when it
|
# A --login override always wins. Otherwise name this repo host's login only
|
||||||
# does not); the tea login list is the LAST resort — it knows nothing about
|
# as a best effort: the login name merely selects a per-login token, and
|
||||||
# which seat is calling, so resolving from it first wrote under whichever
|
# gitea_resolve_api_for_login falls back to the host credential
|
||||||
# account tea had configured (the #1280 family).
|
# (get_gitea_token) when no tea login is named, so the default credential
|
||||||
principal_host=$(get_remote_host)
|
# still resolves even when the host tea has no matching login entry.
|
||||||
if ! principal_resolved="$(resolve_gitea_principal "$LOGIN_OVERRIDE" "$principal_host")"; then
|
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
||||||
# resolve_gitea_principal already printed the fail-loud diagnostic.
|
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login 2>/dev/null || true)
|
||||||
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 resolved principal, then derive the
|
# Bind the REST endpoint + token to the effective login, then derive the
|
||||||
# acting identity from that SAME credential (GET /user). The write below and
|
# 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
|
# its read-back both use this credential, so the write is verified against
|
||||||
# the identity that actually performed it.
|
# the identity that actually performed it. Passing "explicit" when --login
|
||||||
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
|
# was supplied forbids the host-default fallback: an unresolvable explicit
|
||||||
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
|
# override fails closed instead of writing under the default identity.
|
||||||
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
||||||
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
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||||
|
|
||||||
comment_id=$(gitea_create_comment_verified "$ISSUE_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
|
comment_id=$(gitea_create_comment_verified "$ISSUE_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# issue-create.sh - Create issues on Gitea or GitHub
|
# issue-create.sh - Create issues on Gitea or GitHub
|
||||||
# Usage: issue-create.sh -t "Title" [-b "Body"] [-l "label1,label2"] [-m "milestone"] [--login <name>]
|
# Usage: issue-create.sh -t "Title" [-b "Body"] [-l "label1,label2"] [-m "milestone"]
|
||||||
#
|
|
||||||
# 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
|
set -e
|
||||||
|
|
||||||
@@ -25,14 +16,6 @@ INTERACTIVE=false
|
|||||||
|
|
||||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
# 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() {
|
gitea_issue_create_api() {
|
||||||
local host repo token url payload
|
local host repo token url payload
|
||||||
host=$(get_remote_host) || {
|
host=$(get_remote_host) || {
|
||||||
@@ -43,19 +26,10 @@ gitea_issue_create_api() {
|
|||||||
echo "Error: could not determine repo owner/name for API fallback" >&2
|
echo "Error: could not determine repo owner/name for API fallback" >&2
|
||||||
return 1
|
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") || {
|
token=$(get_gitea_token "$host") || {
|
||||||
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
|
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$LABELS" || -n "$MILESTONE" ]]; then
|
if [[ -n "$LABELS" || -n "$MILESTONE" ]]; then
|
||||||
echo "Warning: API fallback currently applies title/body only; labels/milestone require authenticated tea setup." >&2
|
echo "Warning: API fallback currently applies title/body only; labels/milestone require authenticated tea setup." >&2
|
||||||
@@ -93,7 +67,6 @@ Options:
|
|||||||
-b, --body BODY Issue body/description
|
-b, --body BODY Issue body/description
|
||||||
-l, --labels LABELS Comma-separated labels (e.g., "bug,feature")
|
-l, --labels LABELS Comma-separated labels (e.g., "bug,feature")
|
||||||
-m, --milestone NAME Milestone name to assign
|
-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
|
-i, --interactive Prompt for missing issue fields
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
@@ -124,10 +97,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
MILESTONE="$2"
|
MILESTONE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--login)
|
|
||||||
LOGIN_OVERRIDE="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-i|--interactive)
|
-i|--interactive)
|
||||||
INTERACTIVE=true
|
INTERACTIVE=true
|
||||||
shift
|
shift
|
||||||
@@ -165,37 +134,13 @@ case "$PLATFORM" in
|
|||||||
"${CMD[@]}"
|
"${CMD[@]}"
|
||||||
;;
|
;;
|
||||||
gitea)
|
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
|
if command -v tea >/dev/null 2>&1; then
|
||||||
REPO_SLUG=$(get_repo_slug)
|
REPO_SLUG=$(get_repo_slug)
|
||||||
if [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
|
||||||
GITEA_LOGIN_NAME="$PRINCIPAL_NAME"
|
|
||||||
else
|
|
||||||
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
||||||
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
|
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
|
||||||
gitea_issue_create_api
|
gitea_issue_create_api
|
||||||
exit $?
|
exit $?
|
||||||
}
|
}
|
||||||
fi
|
|
||||||
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
|
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
|
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
|
||||||
gitea_issue_create_api
|
gitea_issue_create_api
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pr-create.sh - Create pull requests on Gitea or GitHub
|
# 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"] [--login <name>]
|
# Usage: pr-create.sh -t "Title" [-b "Body"] [-B base] [-H head] [-l "labels"] [-m "milestone"]
|
||||||
#
|
|
||||||
# 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
|
set -e
|
||||||
|
|
||||||
@@ -28,15 +19,6 @@ ISSUE=""
|
|||||||
|
|
||||||
# get_remote_host, get_gitea_token, get_repo_info, and get_gitea_repo_args are provided by detect-platform.sh
|
# 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() {
|
gitea_pr_create_api() {
|
||||||
local host repo token url payload
|
local host repo token url payload
|
||||||
host=$(get_remote_host) || {
|
host=$(get_remote_host) || {
|
||||||
@@ -47,19 +29,10 @@ gitea_pr_create_api() {
|
|||||||
echo "Error: could not determine repo owner/name for API fallback" >&2
|
echo "Error: could not determine repo owner/name for API fallback" >&2
|
||||||
return 1
|
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") || {
|
token=$(get_gitea_token "$host") || {
|
||||||
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
|
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$LABELS" || -n "$MILESTONE" || "$DRAFT" == true ]]; then
|
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
|
echo "Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup." >&2
|
||||||
@@ -103,7 +76,6 @@ Options:
|
|||||||
-H, --head BRANCH Head branch with changes (default: current branch)
|
-H, --head BRANCH Head branch with changes (default: current branch)
|
||||||
-l, --labels LABELS Comma-separated labels
|
-l, --labels LABELS Comma-separated labels
|
||||||
-m, --milestone NAME Milestone name
|
-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)
|
-i, --issue NUMBER Link to issue (auto-generates title if not provided)
|
||||||
-d, --draft Create as draft PR
|
-d, --draft Create as draft PR
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
@@ -144,10 +116,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
MILESTONE="$2"
|
MILESTONE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--login)
|
|
||||||
LOGIN_OVERRIDE="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-i|--issue)
|
-i|--issue)
|
||||||
ISSUE="$2"
|
ISSUE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -206,41 +174,15 @@ case "$PLATFORM" in
|
|||||||
"${CMD[@]}"
|
"${CMD[@]}"
|
||||||
;;
|
;;
|
||||||
gitea)
|
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
|
# tea pull create syntax. Always pass --repo because tea repo inference
|
||||||
# is unreliable in Mosaic worktrees/profile shells. Use arrays instead
|
# is unreliable in Mosaic worktrees/profile shells. Use arrays instead
|
||||||
# of eval so markdown backticks/body content are not shell-executed.
|
# of eval so markdown backticks/body content are not shell-executed.
|
||||||
REPO_SLUG=$(get_repo_slug)
|
REPO_SLUG=$(get_repo_slug)
|
||||||
if [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
|
||||||
GITEA_LOGIN_NAME="$PRINCIPAL_NAME"
|
|
||||||
else
|
|
||||||
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
||||||
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
|
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
|
||||||
gitea_pr_create_api
|
gitea_pr_create_api
|
||||||
exit $?
|
exit $?
|
||||||
}
|
}
|
||||||
fi
|
|
||||||
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
|
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
|
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
|
||||||
gitea_pr_create_api
|
gitea_pr_create_api
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
# 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] [--login <name>]
|
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
|
||||||
#
|
|
||||||
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -23,7 +16,6 @@ DRY_RUN=false
|
|||||||
EXPECT_HEAD=""
|
EXPECT_HEAD=""
|
||||||
CO_AUTHOR_TRAILERS=false
|
CO_AUTHOR_TRAILERS=false
|
||||||
ESCALATE_TO=""
|
ESCALATE_TO=""
|
||||||
LOGIN_OVERRIDE=""
|
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -39,7 +31,6 @@ Options:
|
|||||||
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
||||||
--co-author-trailers Build verified trailers from linked PR commit authors
|
--co-author-trailers Build verified trailers from linked PR commit authors
|
||||||
--escalate-to NAME Named principal for an unresolved-author BLOCK
|
--escalate-to NAME Named principal for an unresolved-author BLOCK
|
||||||
--login NAME Act as this Gitea tea login (wins over MOSAIC_GIT_IDENTITY)
|
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -48,7 +39,6 @@ Examples:
|
|||||||
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
||||||
$(basename "$0") -n 42 --expect-head 0123456789abcdef0123456789abcdef01234567
|
$(basename "$0") -n 42 --expect-head 0123456789abcdef0123456789abcdef01234567
|
||||||
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
|
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
|
||||||
$(basename "$0") -n 42 --login fred-ms # Merge under the fred-ms tea login
|
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
@@ -92,14 +82,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
ESCALATE_TO="$2"
|
ESCALATE_TO="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--login|-l)
|
|
||||||
if [[ $# -lt 2 ]]; then
|
|
||||||
echo "Error: --login requires one tea login name." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
LOGIN_OVERRIDE="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage 0
|
usage 0
|
||||||
;;
|
;;
|
||||||
@@ -590,23 +572,10 @@ PY
|
|||||||
merge_gitea_with_api() {
|
merge_gitea_with_api() {
|
||||||
local host="$1" token attempt_rc
|
local host="$1" token attempt_rc
|
||||||
|
|
||||||
# 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
|
if ! token=$(get_gitea_token "$host"); then
|
||||||
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
|
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
fi
|
|
||||||
if [[ -z "$token" ]]; then
|
if [[ -z "$token" ]]; then
|
||||||
echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
|
echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
|
||||||
return 1
|
return 1
|
||||||
@@ -633,25 +602,10 @@ if [[ "$DRY_RUN" == true ]]; then
|
|||||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||||
exit 1
|
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
|
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||||
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)."
|
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)."
|
||||||
else
|
else
|
||||||
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)."
|
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)."
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
|
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
echo " -n, --number PR number (required)"
|
echo " -n, --number PR number (required)"
|
||||||
echo " -a, --action Review action: approve, request-changes, comment (required)"
|
echo " -a, --action Review action: approve, request-changes, comment (required)"
|
||||||
echo " -c, --comment Review comment (required for request-changes)"
|
echo " -c, --comment Review comment (required for request-changes)"
|
||||||
echo " -l, --login Override the detected Gitea tea login (all actions; wins over MOSAIC_GIT_IDENTITY)"
|
echo " -l, --login Override the detected Gitea tea login (approve/request-changes only)"
|
||||||
echo " -r, --repo Explicit owner/repo slug (skips git-remote slug inference)"
|
echo " -r, --repo Explicit owner/repo slug (skips git-remote slug inference)"
|
||||||
echo " -H, --host Explicit Gitea host (skips remote-host inference)"
|
echo " -H, --host Explicit Gitea host (skips remote-host inference)"
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
@@ -346,14 +346,7 @@ gitea_resolve_api_for_login() {
|
|||||||
else
|
else
|
||||||
host=$(get_remote_host)
|
host=$(get_remote_host)
|
||||||
fi
|
fi
|
||||||
if [[ "$override_explicit" == "identity" ]]; then
|
if [[ -n "$override_explicit" ]]; 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") || {
|
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
|
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
|
return 1
|
||||||
@@ -683,32 +676,29 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
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
|
case $ACTION in
|
||||||
approve)
|
approve)
|
||||||
# Identity-first principal resolution (#1280): PRINCIPAL_MODE /
|
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
|
||||||
# PRINCIPAL_NAME were resolved once above from --login >
|
# below re-derives the real host from HOST_OVERRIDE/remote independently and
|
||||||
# MOSAIC_GIT_IDENTITY / git config > tea login list (last resort).
|
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
|
||||||
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
|
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
|
||||||
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
|
# under `set -e`, with no origin and no -H, previously killed the script
|
||||||
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
|
||||||
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
|
# that support running with no usable origin at all).
|
||||||
else
|
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
|
||||||
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
|
# A --login override always wins. Otherwise name this host's login
|
||||||
fi
|
# 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
|
||||||
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||||
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || 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
|
# The review body (if any) travels with the review itself in the REST
|
||||||
@@ -725,16 +715,24 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
echo "Error: Comment required for request-changes"
|
echo "Error: Comment required for request-changes"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# Identity-first principal resolution (#1280): PRINCIPAL_MODE /
|
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
|
||||||
# PRINCIPAL_NAME were resolved once above from --login >
|
# below re-derives the real host from HOST_OVERRIDE/remote independently and
|
||||||
# MOSAIC_GIT_IDENTITY / git config > tea login list (last resort).
|
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
|
||||||
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
|
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
|
||||||
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
|
# under `set -e`, with no origin and no -H, previously killed the script
|
||||||
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
|
||||||
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
|
# that support running with no usable origin at all).
|
||||||
else
|
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
|
||||||
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
|
# A --login override always wins. Otherwise name this host's login
|
||||||
fi
|
# 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
|
||||||
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||||
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || 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") || {
|
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "REQUEST_CHANGES" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
|
||||||
@@ -748,16 +746,24 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
echo "Error: Comment required"
|
echo "Error: Comment required"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# Identity-first principal resolution (#1280): PRINCIPAL_MODE /
|
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
|
||||||
# PRINCIPAL_NAME were resolved once above from --login >
|
# below re-derives the real host from HOST_OVERRIDE/remote independently and
|
||||||
# MOSAIC_GIT_IDENTITY / git config > tea login list (last resort).
|
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
|
||||||
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
|
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
|
||||||
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
|
# under `set -e`, with no origin and no -H, previously killed the script
|
||||||
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
|
||||||
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
|
# that support running with no usable origin at all).
|
||||||
else
|
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
|
||||||
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
|
# A --login override always wins. Otherwise name this host's login
|
||||||
fi
|
# 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
|
||||||
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||||
comment_id=$(gitea_create_comment_verified "$PR_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
|
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
|
echo "Error: could not create and verify a comment on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
|
||||||
|
|||||||
@@ -1,255 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@@ -77,30 +77,12 @@ exit 0
|
|||||||
SH
|
SH
|
||||||
chmod +x "$BIN_DIR/tea"
|
chmod +x "$BIN_DIR/tea"
|
||||||
|
|
||||||
# TRIPWIRE provider stub: this harness tests argv construction, so ANY curl
|
|
||||||
# call is a failure of that contract (and, before this stub existed, a LIVE
|
|
||||||
# write — the #1282–#1287 incident: the seat's real HOME leaked a global
|
|
||||||
# mosaic.gitIdentity, flipping the wrapper into identity mode whose real
|
|
||||||
# per-slot token created real issues on the forge). Fail loudly instead.
|
|
||||||
cat > "$BIN_DIR/curl" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
echo "FAIL: body-safety harness reached a provider request — this test must never curl" >&2
|
|
||||||
exit 99
|
|
||||||
SH
|
|
||||||
chmod +x "$BIN_DIR/curl"
|
|
||||||
|
|
||||||
# Hermetic invocation: fake HOME (no credentials, no tea config, no token
|
|
||||||
# slots) and GIT_CONFIG_GLOBAL severed — `git config --get mosaic.gitIdentity`
|
|
||||||
# otherwise resolves the WORKSTATION's global identity (mos-dt-0 on the seat
|
|
||||||
# that wrote this) and reroutes the wrapper into identity mode (#1280 family).
|
|
||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
env -i HOME="$WORK_DIR/home" PATH="$BIN_DIR:$PATH" \
|
PATH="$BIN_DIR:$PATH" \
|
||||||
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
|
|
||||||
MOSAIC_TEST_RECEIVED="$RECEIVED_FILE" \
|
MOSAIC_TEST_RECEIVED="$RECEIVED_FILE" \
|
||||||
"$SCRIPT_DIR/issue-create.sh" -t "Body safety test" -b "$BODY"
|
"$SCRIPT_DIR/issue-create.sh" -t "Body safety test" -b "$BODY"
|
||||||
) >/dev/null
|
) >/dev/null
|
||||||
mkdir -p "$WORK_DIR/home"
|
|
||||||
|
|
||||||
# 1. No command substitution executed anywhere in the pipeline.
|
# 1. No command substitution executed anywhere in the pipeline.
|
||||||
if [[ -e "$SENTINEL" ]]; then
|
if [[ -e "$SENTINEL" ]]; then
|
||||||
|
|||||||
@@ -47,31 +47,14 @@ SH
|
|||||||
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
|
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
|
||||||
|
|
||||||
run_wrapper() {
|
run_wrapper() {
|
||||||
# Hermetic: fake HOME (fixture credentials only, no token slots, no tea
|
|
||||||
# config) and GIT_CONFIG_GLOBAL severed — `git config --get
|
|
||||||
# mosaic.gitIdentity` otherwise resolves the WORKSTATION's global identity
|
|
||||||
# and reroutes the wrapper into identity mode before the tea paths this
|
|
||||||
# harness exercises (#1280 family; see test-issue-create-body-safety.sh).
|
|
||||||
# An `env …` prefix (used for MOSAIC_TEA_STALE_USER) is re-wrapped, not
|
|
||||||
# doubled: arguments beginning with "env" are shifted past.
|
|
||||||
local env_pairs=()
|
|
||||||
if [[ "${1:-}" == "env" ]]; then
|
|
||||||
shift
|
|
||||||
while [[ "$#" -gt 0 && "$1" == *=* ]]; do
|
|
||||||
env_pairs+=("$1")
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
env -i HOME="$WORK_DIR/home" PATH="$BIN_DIR:$PATH" \
|
PATH="$BIN_DIR:$PATH" \
|
||||||
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
|
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
MOSAIC_TEST_LOG="$LOG_FILE" "${env_pairs[@]}" \
|
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||||
"$@"
|
"$@"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
mkdir -p "$WORK_DIR/home"
|
|
||||||
|
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
printf 'Interactive title\nInteractive body\nlabel-a,label-b\nM1\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i >/dev/null
|
printf 'Interactive title\nInteractive body\nlabel-a,label-b\nM1\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i >/dev/null
|
||||||
|
|||||||
@@ -1,244 +0,0 @@
|
|||||||
#!/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 <n> /user` feeds get_gitea_authenticated_user; `pr create` marks
|
|
||||||
# the marker file (its presence fails the identity-mode assertions).
|
|
||||||
cat > "$BIN_DIR/tea" <<SH
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
printf 'TEA: %s\n' "\$*" >> "$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" <<SH
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
url=""
|
|
||||||
auth=""
|
|
||||||
while [[ \$# -gt 0 ]]; do
|
|
||||||
case "\$1" in
|
|
||||||
-H)
|
|
||||||
case "\$2" in
|
|
||||||
Authorization*) auth="\$2" ;;
|
|
||||||
esac
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
*) [[ -n "\$1" && "\$1" != -* ]] && url="\$1"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
printf 'CURL-URL: %s\nCURL-AUTH: %s\n' "\$url" "\$auth" >> "$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"
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
#!/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" <<SH
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
printf '%s\n' '{"baseRefName":"next","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"mosaicstack/stack","title":"Test PR","author":{"login":"contributor"}}'
|
|
||||||
SH
|
|
||||||
cat > "$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" <<SH
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
url=""
|
|
||||||
out_file=""
|
|
||||||
stdin_config=""
|
|
||||||
if [[ ! -t 0 ]]; then
|
|
||||||
stdin_config="\$(cat || true)"
|
|
||||||
fi
|
|
||||||
while [[ \$# -gt 0 ]]; do
|
|
||||||
case "\$1" in
|
|
||||||
-o) out_file="\$2"; shift 2 ;;
|
|
||||||
-K|-w|--max-filesize|--max-time|--connect-timeout|-sS) shift 2 ;;
|
|
||||||
*) [[ -n "\$1" && "\$1" != -* && -z "\$url" ]] && url="\$1"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
auth="\$(printf '%s' "\$stdin_config" | grep -o 'Authorization: token [^"]*' || true)"
|
|
||||||
printf 'CURL-URL: %s\nCURL-AUTH: %s\n' "\$url" "\$auth" >> "$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"
|
|
||||||
@@ -39,20 +39,3 @@ packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires rea
|
|||||||
# recorded judgement. These lines ARE that judgement, signed.)
|
# recorded judgement. These lines ARE that judgement, signed.)
|
||||||
packages/mosaic/framework/tools/orchestrator/smoke-test.sh | behavior smoke checks for coord continue/run workflows, run manually by orchestrator seats; unmeasured in CI; #1017 burndown
|
packages/mosaic/framework/tools/orchestrator/smoke-test.sh | behavior smoke checks for coord continue/run workflows, run manually by orchestrator seats; unmeasured in CI; #1017 burndown
|
||||||
packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh | #973 instrument self-test, run as a precondition of the validate-973 evidence procedure rather than as a standing CI suite; #1017 burndown candidate
|
packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh | #973 instrument self-test, run as a precondition of the validate-973 evidence procedure rather than as a standing CI suite; #1017 burndown candidate
|
||||||
|
|
||||||
# --- tools/fleet: precondition is unsatisfiable in the CI image (#1271) ---
|
|
||||||
# Signed by fred (sb-it-1-dt, 2026-08-16) at origin/next 476db12.
|
|
||||||
# This suite asserts the launcher's behaviour when `mosaic` and `pi` are MISSING.
|
|
||||||
# It shims fakes into $FAKE_BIN, but the constructed PANE_PATH always ends in the
|
|
||||||
# real system path, so on a host that installs those binaries the missing-binary
|
|
||||||
# cases cannot be measured at all. The suite's own guard (line 103) says so and
|
|
||||||
# fails rather than reporting a pass it cannot back. That guard is correct.
|
|
||||||
# The error was wiring the suite into CI: #1017 (c56483eb) enumerated it and
|
|
||||||
# dropped this exclusion, and the CI image provides `pi` in the system path, so
|
|
||||||
# it has failed on every pipeline since. Measured 2026-08-16 across pipelines
|
|
||||||
# 2444 (#1256), 2438 (#1240) and 2441 (#1017-quality): exactly one FAIL line in
|
|
||||||
# each full log, identical, this assertion; control `zzz-not-present-zzz` -> 0.
|
|
||||||
# Burn-down and the full measurement are tracked in #1271; unwired by PR #1270.
|
|
||||||
# Because test:framework-shell is one && chain and this sat at position 44 of 48,
|
|
||||||
# the four suites after it had not run at all since the merge.
|
|
||||||
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | precondition unsatisfiable in the CI image: asserts missing-binary behaviour, but PANE_PATH always ends in the system path and the image provides `pi` there; guard at line 103 fails by design rather than passing unmeasured. Burn down by controlling the tail of PANE_PATH inside the test. NOT by removing `pi` from the image: the CI image installs @earendil-works/[email protected] deliberately (measured in pipeline 2444's test-step log), and other suites depend on that pin. Burn-down tracked in #1271
|
|
||||||
|
|||||||
@@ -97,13 +97,34 @@ printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
|
|||||||
# would otherwise accumulate forever.
|
# would otherwise accumulate forever.
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
|
|
||||||
# 2) Submit, then POSITIVELY confirm submission; flush with another Enter if it is
|
# 2) Submit, then POSITIVELY confirm submission by DRAFT TRANSITION, not by prompt
|
||||||
# still a draft. Success requires positive evidence — the queued banner, OR the
|
# glyph. The historical bug was treating ABSENCE of a draft as delivery; the
|
||||||
# REPL input box located AND clear of our message tail. The historical bug was
|
# 2026-08 fix over-corrected to glyph inference (grep '❯|^>|│ >'), which locates
|
||||||
# treating ABSENCE of a draft as delivery: if the prompt glyph was never matched
|
# only Claude Code's box and false-NEGATIVES every glyphless REPL (pi renders a
|
||||||
# (wrong pane / prompt-glyph drift), an unsubmitted message read as "delivered"
|
# U+2500 rule, no glyph) — a delivered message reported "UNDELIVERED", driving a
|
||||||
# and worker->lead relays stalled silently. We now default to UNCONFIRMED and only
|
# retry that duplicates it. Runtime-agnostic evidence: our message tail sits on
|
||||||
# upgrade to delivered on positive evidence; anything we cannot confirm fails loud.
|
# the INPUT line (located by the cursor row, not a glyph) BEFORE Enter, and has
|
||||||
|
# LEFT it AFTER — that transition is positive proof of submission and needs no
|
||||||
|
# glyph. Absence alone still never means delivered: if we never saw our draft on
|
||||||
|
# the input line we stay UNCONFIRMED (wrong/dead pane), and a draft that never
|
||||||
|
# leaves the input line stays a DRAFT (exit 2), preserving both historical guards.
|
||||||
|
_cursor_line() { # echo the pane's current input (cursor) line, glyph-free
|
||||||
|
local cy line
|
||||||
|
cy=$("${tmux_cmd[@]}" display-message -p -t "$EFFECTIVE_TARGET" -F '#{cursor_y}' 2>/dev/null) || return 1
|
||||||
|
[ -n "$cy" ] || return 1
|
||||||
|
"${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null | sed -n "$((cy + 1))p"
|
||||||
|
}
|
||||||
|
_draft_on_input() { # true iff our message tail is sitting on the input line now
|
||||||
|
[ -n "$snippet" ] || return 1
|
||||||
|
printf '%s' "$(_cursor_line)" | grep -qF "$snippet"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Baseline: after the paste, our draft must be on the input line. This is positive
|
||||||
|
# proof we are on the right pane and the paste landed — the anchor the transition
|
||||||
|
# check measures against.
|
||||||
|
saw_draft=0
|
||||||
|
_draft_on_input && saw_draft=1
|
||||||
|
|
||||||
status="unconfirmed"
|
status="unconfirmed"
|
||||||
for attempt in $(seq 1 $((RETRIES + 1))); do
|
for attempt in $(seq 1 $((RETRIES + 1))); do
|
||||||
"${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter
|
"${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter
|
||||||
@@ -113,20 +134,26 @@ for attempt in $(seq 1 $((RETRIES + 1))); do
|
|||||||
if printf '%s' "$pane" | grep -qF "$QUEUED_RE"; then
|
if printf '%s' "$pane" | grep -qF "$QUEUED_RE"; then
|
||||||
status="queued"; break
|
status="queued"; break
|
||||||
fi
|
fi
|
||||||
# Locate the REPL input box (prompt glyph). If we cannot see it, we have NO
|
# POSITIVE draft evidence from a located prompt box, when one exists. This is the
|
||||||
# evidence of submission state — stay UNCONFIRMED and retry; never infer delivery.
|
# cursor-row check's blind spot: a pane in COOKED mode (a plain shell whose
|
||||||
|
# foreground process never reads stdin) echoes our paste via the kernel line
|
||||||
|
# discipline and moves the cursor off it on Enter, which is indistinguishable from
|
||||||
|
# a real submit by cursor row alone. If a prompt box IS locatable and still carries
|
||||||
|
# our tail, that is affirmative proof the message was not consumed. Absence of a
|
||||||
|
# glyph is still never used for anything — that inference is the original E7 bug.
|
||||||
promptline=$(printf '%s' "$pane" | grep -E '❯|^>|│ >' | tail -1)
|
promptline=$(printf '%s' "$pane" | grep -E '❯|^>|│ >' | tail -1)
|
||||||
if [ -z "$promptline" ]; then
|
if [ -n "$promptline" ] && [ -n "$snippet" ] && printf '%s' "$promptline" | grep -qF "$snippet"; then
|
||||||
status="unconfirmed"; continue
|
|
||||||
fi
|
|
||||||
# Input box located AND still carrying our tail => unsubmitted draft. Flush + retry.
|
|
||||||
# (Submitted messages scroll up into history; a draft stays on the ❯ line.)
|
|
||||||
if [ -n "$snippet" ] && printf '%s' "$promptline" | grep -qF "$snippet"; then
|
|
||||||
status="draft"; continue
|
status="draft"; continue
|
||||||
fi
|
fi
|
||||||
# Input box located AND clear of our tail => positively submitted. This is the
|
if [ "$saw_draft" = 1 ]; then
|
||||||
# only path to success besides the queued banner.
|
if _draft_on_input; then
|
||||||
status="delivered"; break
|
status="draft"; continue # still on the input line => not submitted; flush + retry
|
||||||
|
fi
|
||||||
|
status="delivered"; break # left the input line => positively submitted
|
||||||
|
fi
|
||||||
|
# No confirmed baseline yet: try to (re)acquire it; never infer delivery from absence.
|
||||||
|
if _draft_on_input; then saw_draft=1; status="draft"; continue; fi
|
||||||
|
status="unconfirmed"; continue
|
||||||
done
|
done
|
||||||
|
|
||||||
[ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; }
|
[ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; }
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Red-first regression test for E7 (#1017 task 2): the confirm-check must bind
|
||||||
|
# "delivered" to WHETHER THE MESSAGE WAS SUBMITTED, not to which runtime's prompt
|
||||||
|
# glyph is present. A pi seat renders a U+2500 rule input box with no ❯/^>/│ >
|
||||||
|
# glyph; send-message.sh:118 locates the box only by glyph, so a genuinely
|
||||||
|
# delivered message on a glyphless REPL falsely reports exit 2 "may be UNDELIVERED",
|
||||||
|
# and the operator's rc=2-driven retry duplicates it.
|
||||||
|
#
|
||||||
|
# Parameterized on $SEND: RED against the shipping blob (B and D fail), GREEN
|
||||||
|
# against a candidate patch. No pi; no fake HOME; hermetic throwaway socket.
|
||||||
|
#
|
||||||
|
# Submission counting is EXACT and terminal-echo-independent: the fixture message
|
||||||
|
# is `echo <tok> >>SINK`; each real submission appends one line. wc -l SINK ==
|
||||||
|
# number of times the REPL actually executed the send. This does not depend on how
|
||||||
|
# many times the marker string is painted on screen.
|
||||||
|
set -u
|
||||||
|
SEND="${SEND:?set SEND=/path/to/send-message.sh}"
|
||||||
|
SOCKET="glyphagnostic-$$"
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
tmux() { command tmux -L "$SOCKET" "$@"; }
|
||||||
|
cleanup() { command tmux -L "$SOCKET" kill-server 2>/dev/null; rm -rf "$TMP"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
pass=0; fail=0
|
||||||
|
ok() { printf 'ok %s\n' "$1"; pass=$((pass+1)); }
|
||||||
|
no() { printf 'FAIL %s -- %s\n' "$1" "$2"; fail=$((fail+1)); }
|
||||||
|
|
||||||
|
mk() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" "PS1='$2' exec bash --noprofile --norc -i"; sleep 0.5; }
|
||||||
|
subs() { [ -f "$1" ] && wc -l <"$1" | tr -d ' ' || echo 0; } # exact submission count
|
||||||
|
|
||||||
|
echo "SEND=$SEND tmux $(command tmux -V | awk '{print $2}')"
|
||||||
|
|
||||||
|
# --- A (control): glyph box (❯) that submits => exit 0, exactly one submission.
|
||||||
|
mk ctl '❯ '
|
||||||
|
SINK="$TMP/sink.ctl"
|
||||||
|
out=$("$SEND" -L "$SOCKET" -t ctl -m "echo x >>'$SINK'" 2>"$TMP/e.ctl"); rc=$?; sleep 0.4
|
||||||
|
if [ "$rc" = 0 ] && [ "$(subs "$SINK")" = 1 ]; then
|
||||||
|
ok "control: ❯-box submits => exit 0, exactly one submission"
|
||||||
|
else no "control: ❯-box submits => exit 0, one submission" "rc=$rc subs=$(subs "$SINK") err=[$(cat "$TMP/e.ctl")]"; fi
|
||||||
|
|
||||||
|
# --- B (THE false-rc regression): glyphless U+2500 box that SUBMITS. Message lands
|
||||||
|
# (subs==1) yet shipping reports exit 2. Must be exit 0.
|
||||||
|
mk sub $'──────── \n'
|
||||||
|
SINK="$TMP/sink.sub"
|
||||||
|
out=$("$SEND" -L "$SOCKET" -t sub -m "echo x >>'$SINK'" 2>"$TMP/e.sub"); rc=$?; sleep 0.4
|
||||||
|
if [ "$rc" = 0 ] && [ "$(subs "$SINK")" = 1 ]; then
|
||||||
|
ok "glyphless: U+2500 box that submits => exit 0 (delivered, not 'UNDELIVERED')"
|
||||||
|
else no "glyphless: U+2500 box that submits => exit 0" \
|
||||||
|
"rc=$rc subs=$(subs "$SINK")(delivered=$([ "$(subs "$SINK")" -ge 1 ] && echo yes||echo no)) err=[$(cat "$TMP/e.sub")]"; fi
|
||||||
|
|
||||||
|
# --- D (duplicate arm): operator follows the rc=2 stderr and retries once. On the
|
||||||
|
# glyphless box, shipping => two submissions (the reported duplicate). The
|
||||||
|
# property: one logical send => exactly one submission. Same fix closes it.
|
||||||
|
mk dup $'──────── \n'
|
||||||
|
SINK="$TMP/sink.dup"
|
||||||
|
tries=0
|
||||||
|
for attempt in 1 2; do
|
||||||
|
tries=$((tries+1))
|
||||||
|
out=$("$SEND" -L "$SOCKET" -t dup -m "echo x >>'$SINK'" 2>/dev/null); rc=$?
|
||||||
|
sleep 0.4
|
||||||
|
[ "$rc" = 0 ] && break # operator stops retrying only when told delivered
|
||||||
|
done
|
||||||
|
if [ "$(subs "$SINK")" = 1 ]; then
|
||||||
|
ok "duplicate: one logical send (rc-driven retry) => exactly one submission (tries=$tries)"
|
||||||
|
else no "duplicate: one logical send => exactly one submission" "submissions=$(subs "$SINK") tries=$tries"; fi
|
||||||
|
|
||||||
|
# --- E (faithful hung managed TUI, NOT a cooked shell): raw/no-echo, paints nothing.
|
||||||
|
# A cooked `sleep infinity` echoes the paste via the kernel line discipline and
|
||||||
|
# false-passes a cursor-row fix that is correct on real seats (measured). So: raw.
|
||||||
|
mk_rawstuck() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" \
|
||||||
|
"bash --noprofile --norc -c 'stty -echo -icanon min 1 time 0 2>/dev/null; exec sleep infinity'"; sleep 0.5; }
|
||||||
|
mk_rawstuck estuck
|
||||||
|
SINK="$TMP/sink.estuck"
|
||||||
|
out=$("$SEND" -L "$SOCKET" -t estuck -r 1 -m "this stuck draft was never submitted" 2>/dev/null); rc=$?
|
||||||
|
sleep 0.3
|
||||||
|
if [ "$rc" != 0 ] && [ "$(subs "$SINK")" = 0 ]; then
|
||||||
|
ok "raw/no-echo stuck TUI (not submitted) => non-zero (no false delivered)"
|
||||||
|
else no "raw stuck TUI must NOT report delivered" "rc=$rc subs=$(subs "$SINK")"; fi
|
||||||
|
|
||||||
|
# --- F (busy/queued branch, your BUSY-not-runtime finding): glyphless pane rendering the
|
||||||
|
# queued banner, never consuming. QUEUED_RE :113 fires before the glyph grep => rc=0.
|
||||||
|
mk_busy() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" \
|
||||||
|
"bash --noprofile --norc -c 'printf \"Press up to edit queued messages\n\"; exec sleep infinity'"; sleep 0.5; }
|
||||||
|
mk_busy ebusy
|
||||||
|
SINK="$TMP/sink.ebusy"
|
||||||
|
out=$("$SEND" -L "$SOCKET" -t ebusy -m "echo x >>'$SINK'" 2>/dev/null); rc=$?; sleep 0.3
|
||||||
|
if [ "$rc" = 0 ]; then
|
||||||
|
ok "busy/queued-banner glyphless => exit 0 (queued is delivery; runtime owns custody)"
|
||||||
|
else no "busy/queued-banner must report delivered" "rc=$rc"; fi
|
||||||
|
|
||||||
|
# --- C (historical-bug guard): unresolvable target. No pane ever carried our draft
|
||||||
|
# => must fail, never infer delivered from absence of a glyph/snippet.
|
||||||
|
if out=$("$SEND" -L "$SOCKET" -t "nonexistent-$$" -m "echo x >>'$TMP/sink.wrong'" 2>/dev/null); then
|
||||||
|
no "wrong-pane: unresolvable target must NOT report success" "expected non-zero, got 0"
|
||||||
|
else ok "wrong-pane: unresolvable target => non-zero (no false delivered)"; fi
|
||||||
|
|
||||||
|
echo "---"; echo "pass=$pass fail=$fail"
|
||||||
|
[ "$fail" = 0 ]
|
||||||
@@ -4,10 +4,13 @@
|
|||||||
#
|
#
|
||||||
# 1. DELIVERED — a REPL that renders a `❯ ` input box and submits on Enter
|
# 1. DELIVERED — a REPL that renders a `❯ ` input box and submits on Enter
|
||||||
# (text scrolls to history, box clears) => exit 0 "✓ delivered".
|
# (text scrolls to history, box clears) => exit 0 "✓ delivered".
|
||||||
# 2. UNCONFIRMED — a pane with NO locatable prompt glyph. This is the exact
|
# 2. DELIVERED — a pane with NO prompt glyph that DOES submit => exit 0. A pi
|
||||||
# historical FALSE POSITIVE: pre-patch it printed "✓ delivered"
|
# seat is this fixture (U+2500 rule, no glyph). Reshaped for
|
||||||
# exit 0; post-patch it MUST fail loud (exit 2, stderr
|
# #1257; see the note at the fixture for why the old exit-2
|
||||||
# "could not confirm submission").
|
# assertion was wrong.
|
||||||
|
# 2b. UNCONFIRMED— a glyphless pane that never submits (raw/no-echo hung TUI)
|
||||||
|
# => must fail loud. This carries the historical
|
||||||
|
# false-positive guard that fixture 2 used to be credited with.
|
||||||
# 3. DRAFT — a `❯ `-prompt pane that never submits (message stays on the
|
# 3. DRAFT — a `❯ `-prompt pane that never submits (message stays on the
|
||||||
# input line) => exit 2, stderr "unsubmitted draft".
|
# input line) => exit 2, stderr "unsubmitted draft".
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
@@ -37,19 +40,44 @@ else
|
|||||||
no "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]"
|
no "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Fixture 2: NO prompt glyph (default bash PS1). THE regression: pre-patch this
|
# --- Fixture 2: NO prompt glyph, and the pane DOES submit (interactive bash).
|
||||||
# was a silent false-positive "delivered"; post-patch it must be unconfirmed→exit 2.
|
# RESHAPED 2026-08-16 (#1257), deliberately. This fixture previously asserted
|
||||||
|
# exit 2 here and was labelled "false-positive FIXED". That assertion was wrong,
|
||||||
|
# and locking it in is what kept E7 alive: the pane submits, so "delivered" is
|
||||||
|
# the truth, and a pi seat — whose input box is a bare U+2500 rule with no glyph
|
||||||
|
# — IS this fixture. Reporting exit 2 for it told operators a delivered message
|
||||||
|
# may be undelivered, and the retry that advice invites is the duplicate.
|
||||||
|
#
|
||||||
|
# The guard this fixture was reaching for is real and is NOT dropped: "never
|
||||||
|
# infer delivered from absence" is now enforced positively by fixture 2b below
|
||||||
|
# (glyphless AND not submitting => must fail) and by fixture 3 (locatable box
|
||||||
|
# still carrying our tail => draft). Absence alone decides nothing either way.
|
||||||
tmux -L "$SOCKET" new-session -d -s noglyph -c "$TMP" \
|
tmux -L "$SOCKET" new-session -d -s noglyph -c "$TMP" \
|
||||||
'PS1="sh-noglyph$ " exec bash --noprofile --norc -i'
|
'PS1="sh-noglyph$ " exec bash --noprofile --norc -i'
|
||||||
sleep 0.3
|
sleep 0.3
|
||||||
if out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); then
|
out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); rc=$?
|
||||||
no "unconfirmed: glyphless pane must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -qF "✓ delivered"; then
|
||||||
|
ok "delivered: glyphless pane that submits => exit 0 (runtime-agnostic, E7 FIXED)"
|
||||||
|
else
|
||||||
|
no "delivered: glyphless pane that submits => exit 0" "rc=$rc out=[$out] err=[$(cat "$TMP/e2")]"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Fixture 2b: NO prompt glyph AND never submits — a hung managed TUI holding the
|
||||||
|
# terminal in raw/no-echo, which is what a stuck agent seat actually is (measured
|
||||||
|
# on live pi: stty -echo -icanon). Nothing is echoed, nothing is consumed, so
|
||||||
|
# there is no positive evidence of submission and the tool MUST fail loud. This
|
||||||
|
# is the historical false-positive guard, kept as a positive test.
|
||||||
|
tmux -L "$SOCKET" new-session -d -s rawstuck -c "$TMP" \
|
||||||
|
'bash --noprofile --norc -c "stty -echo -icanon min 1 time 0 2>/dev/null; exec sleep infinity"'
|
||||||
|
sleep 0.3
|
||||||
|
if out=$("$SEND" -L "$SOCKET" -t "=rawstuck" -r 1 -m "verdict fixture two-b never submitted" 2>"$TMP/e2b"); then
|
||||||
|
no "unconfirmed: glyphless hung TUI must NOT report success" "expected non-zero, got 0 (out=[$out])"
|
||||||
else
|
else
|
||||||
rc=$?
|
rc=$?
|
||||||
if [ "$rc" -eq 2 ] && grep -qF "could not confirm submission" "$TMP/e2"; then
|
if [ "$rc" -ne 0 ] && grep -qF "could not confirm submission" "$TMP/e2b"; then
|
||||||
ok "unconfirmed: glyphless pane => exit 2 + 'could not confirm submission' (false-positive FIXED)"
|
ok "unconfirmed: glyphless hung TUI (raw/no-echo) => non-zero + 'could not confirm submission'"
|
||||||
else
|
else
|
||||||
no "unconfirmed: glyphless pane => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e2")]"
|
no "unconfirmed: glyphless hung TUI => non-zero + stderr" "rc=$rc err=[$(cat "$TMP/e2b")]"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"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/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/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/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": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
Reference in New Issue
Block a user