Resolve against #1148's purpose-sensitive rewrite: keep next's queue-clear message format and terminal-failure split, re-apply the null-statuses tolerance and zero-contexts-first classification (the half #1148 did not carry). All four ci-queue-wait suites green on the merged tree.
This commit is contained in:
@@ -43,7 +43,7 @@ fleet commits, pushes, and opens PRs under one identity — with no cryptographi
|
||||
separation between an author and a reviewer.
|
||||
|
||||
Both `git-credential-mosaic` and `get_gitea_token()` resolve an optional **per-agent
|
||||
identity** before falling back to the shared account:
|
||||
identity**:
|
||||
|
||||
1. `MOSAIC_GIT_IDENTITY` environment variable, or
|
||||
2. `git config --get mosaic.gitIdentity` (set per-worktree; persists on disk across
|
||||
@@ -51,12 +51,54 @@ identity** before falling back to the shared account:
|
||||
3. (git-credential-mosaic only) the username git itself supplies for the credential
|
||||
request.
|
||||
|
||||
If the resolved identity has a token file at
|
||||
`~/.config/mosaic/secrets/gitea-tokens/gitea-{usc,mosaicstack}-<agent-id>.token`, that
|
||||
identity + token is used. **Nothing configured → nothing changes**: with no per-slot
|
||||
token file present, both tools fall through to the existing shared-account path
|
||||
unchanged, so this feature is a no-op on any host that hasn't provisioned per-slot
|
||||
tokens.
|
||||
### Which store a credential is read from
|
||||
|
||||
The store is chosen by what the identity **is**, not by which file happens to exist first:
|
||||
|
||||
| The identity | Its credential is read from |
|
||||
| ------------------------------------------------------------------ | -------------------------------------------------------------------------- |
|
||||
| has a directory at `<brain>/fleet/agents/<id>/` — it is a **seat** | `<brain>/fleet/agents/<id>/secrets/gitea-{usc,mosaicstack}-<id>.token` |
|
||||
| does not — it is a **service identity** | `~/.config/mosaic/secrets/gitea-tokens/gitea-{usc,mosaicstack}-<id>.token` |
|
||||
|
||||
`<brain>` is `MOSAIC_BRAIN_HOME` if set, else `~/.mosaic` — the same resolution
|
||||
`packages/mosaic/src/fleet/brain-home.ts` performs.
|
||||
|
||||
**There is no precedence between the two stores and no fallback from one to the other.**
|
||||
A seat whose slot is empty is refused even when a same-named token sits in the framework
|
||||
store. One credential lives in exactly one location: a second copy is drift rather than
|
||||
redundancy, and the way drift surfaces is a stale copy returning 401, which reads as a
|
||||
revoked token and sends whoever debugs it to the wrong place.
|
||||
|
||||
### What happens when nothing resolves
|
||||
|
||||
| identity resolves | token in its store | host runs a fleet | result |
|
||||
| ----------------- | ------------------ | ----------------- | ------------------------- |
|
||||
| yes | yes | — | that identity + token |
|
||||
| yes | no | — | **fail closed** |
|
||||
| no | — | yes | **fail closed** |
|
||||
| no | — | no | shared account, unchanged |
|
||||
|
||||
A host "runs a fleet" when `<brain>/fleet/agents` exists — the same signal `brain-home.ts`
|
||||
uses to decide a brain is active.
|
||||
|
||||
Failing closed means: nothing is emitted, the exit status is nonzero, a stderr diagnostic
|
||||
names the identity, its source, the store it resolved to and the path that was expected,
|
||||
and `git-credential-mosaic` additionally appends a record (identity, host, reason, cwd —
|
||||
never a token value) to `${MOSAIC_CREDENTIAL_SPOOL:-~/.local/state/mosaic-credential-escalations}`.
|
||||
The git operation fails; nothing is attributed to anyone.
|
||||
|
||||
The shared-account fallback that used to cover these two cases is why a PR could be
|
||||
authored, commented and merged under an account whose owner did not open it — every seat
|
||||
shared one identity, so the record could not be traced back afterwards. An
|
||||
under-provisioned agent is refused rather than handed the most privileged account
|
||||
available.
|
||||
|
||||
**On a host with no fleet, nothing changes**: no `fleet/agents` directory means the shared
|
||||
account still answers, so this is a no-op for an operator who has not provisioned per-slot
|
||||
tokens. On a host that does run a fleet, a human doing manual git work needs an identity
|
||||
of their own — `MOSAIC_GIT_IDENTITY=<id>` with a provisioned slot. There is deliberately no
|
||||
environment variable that restores the fallback; one would reintroduce exactly the
|
||||
substitution this removes.
|
||||
|
||||
### Enabling it for a clone
|
||||
|
||||
|
||||
@@ -481,13 +481,21 @@ while true; do
|
||||
# (push=degraded-pass, merge=hold). Merge stays fail-closed:
|
||||
# no-status there may just mean CI has not reported yet.
|
||||
if [[ "$PURPOSE" == "push" ]]; then
|
||||
echo "[ci-queue-wait] no status contexts on ${BRANCH} head (state=no-status) — no CI queue to wait on; queue clear."
|
||||
echo "[ci-queue-wait] queue-clear state=no-status purpose=push branch=${BRANCH}; no queued or running CI."
|
||||
exit 0
|
||||
fi
|
||||
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
exit 3
|
||||
;;
|
||||
terminal-failure|malformed|unknown)
|
||||
terminal-failure)
|
||||
if [[ "$PURPOSE" == "push" ]]; then
|
||||
echo "[ci-queue-wait] queue-clear state=terminal-failure purpose=push branch=${BRANCH}; no queued or running CI."
|
||||
exit 0
|
||||
fi
|
||||
echo "Error: ASSERTED_NOT_READY state=terminal-failure purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
exit 3
|
||||
;;
|
||||
malformed|unknown)
|
||||
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
exit 3
|
||||
;;
|
||||
|
||||
@@ -507,37 +507,69 @@ get_gitea_token() {
|
||||
|
||||
# 0. Per-agent identity (Gate-16 author≠reviewer). If MOSAIC_GIT_IDENTITY, or the
|
||||
# per-worktree `git config mosaic.gitIdentity`, resolves to an agent that has a
|
||||
# stored per-slot token for this host, act AS that agent so API tooling
|
||||
# stored credential for this host, act AS that agent so API tooling
|
||||
# (pr-create, issue-create, …) authors under the right identity — matching the
|
||||
# git credential helper. Backward-compatible: nothing resolvable → shared logic below.
|
||||
# git credential helper, which this block deliberately mirrors.
|
||||
local _ident="${MOSAIC_GIT_IDENTITY:-}"
|
||||
local _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" ]]; then
|
||||
local _idpfx=""
|
||||
case "$host" in
|
||||
git.uscllc.com) _idpfx=gitea-usc ;;
|
||||
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
|
||||
esac
|
||||
if [[ -n "$_idpfx" ]]; then
|
||||
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
||||
if [[ -r "$_idtok" ]]; then
|
||||
cat "$_idtok"
|
||||
return 0
|
||||
fi
|
||||
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
|
||||
# but no per-slot token exists for THAT identity. Refuse to fall through to the
|
||||
# shared/default credential loader below — silently borrowing another slot's token
|
||||
# would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review attributed
|
||||
# to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop instead so the
|
||||
# caller aborts loudly rather than acting as the wrong identity.
|
||||
echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2
|
||||
echo " Refusing to borrow another slot's token. Provision the per-slot token, or unset the identity to use shared credentials." >&2
|
||||
return 1
|
||||
|
||||
# Recognized Gitea hosts are the ones carrying the per-identity token scheme.
|
||||
local _idpfx=""
|
||||
case "$host" in
|
||||
git.uscllc.com) _idpfx=gitea-usc ;;
|
||||
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
|
||||
esac
|
||||
|
||||
# Brain-home resolution mirrors packages/mosaic/src/fleet/brain-home.ts and
|
||||
# tools/fleet/start-agent-session.sh: MOSAIC_BRAIN_HOME wins, else ~/.mosaic.
|
||||
local _brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
|
||||
|
||||
if [[ -n "$_ident" && -n "$_idpfx" ]]; then
|
||||
# Credential store selection: an identity is a SEAT or a SERVICE, and which
|
||||
# one it is decides where its credential lives. No precedence between the
|
||||
# two stores and no fallback from one to the other — a seat with an empty
|
||||
# slot fails loud rather than reading a service credential of the same name.
|
||||
# One credential, one location: two copies diverge, and the stale copy fails
|
||||
# in a way that reads as a revoked token rather than as drift.
|
||||
local _idtok _ident_kind
|
||||
if [[ -d "$_brain_home/fleet/agents/$_ident" ]]; then
|
||||
_ident_kind="seat"
|
||||
_idtok="$_brain_home/fleet/agents/$_ident/secrets/${_idpfx}-${_ident}.token"
|
||||
else
|
||||
_ident_kind="service identity"
|
||||
_idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
||||
fi
|
||||
if [[ -r "$_idtok" ]]; then
|
||||
cat "$_idtok"
|
||||
return 0
|
||||
fi
|
||||
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
|
||||
# but no credential exists for THAT identity. Refuse to fall through to the
|
||||
# shared/default credential loader below — silently borrowing another identity's
|
||||
# token would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review
|
||||
# attributed to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop
|
||||
# instead so the caller aborts loudly rather than acting as the wrong identity.
|
||||
echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2
|
||||
echo " Resolved as a ${_ident_kind}; there is no fallback between the seat and service stores." >&2
|
||||
echo " Refusing to borrow another slot's token. Provision the credential at that path, or unset the identity to use shared credentials." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# FAIL LOUD: no identity resolved, on a host that HAS a fleet. Where seats exist,
|
||||
# every record must name the agent that made it, so an unattributable request is
|
||||
# refused rather than handed the shared account. `fleet/agents` existing is the
|
||||
# same signal brain-home.ts uses to decide a brain is active. A host with no fleet
|
||||
# keeps the shared path below unchanged: there the shared account is the operator's
|
||||
# own and there is no attribution to lose.
|
||||
if [[ -z "$_ident" && -n "$_idpfx" && -d "$_brain_home/fleet/agents" ]]; then
|
||||
echo "Error: no git identity resolved for host '$host', but this host runs a fleet ($_brain_home/fleet/agents)." >&2
|
||||
echo " Refusing to fall back to the shared account: records it creates cannot be attributed to the agent that made them." >&2
|
||||
echo " Set MOSAIC_GIT_IDENTITY=<agent-id> or 'git config mosaic.gitIdentity <agent-id>'." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
|
||||
|
||||
@@ -1,21 +1,48 @@
|
||||
#!/bin/bash
|
||||
# git-credential-mosaic — git credential helper — resolves Gitea tokens from
|
||||
# the Mosaic credential store at runtime so remote URLs never embed secrets.
|
||||
# git-credential-mosaic — git credential helper. Resolves a Gitea token from the
|
||||
# Mosaic credential store at runtime so remote URLs never embed secrets.
|
||||
#
|
||||
# Install (one-time, per clone or globally):
|
||||
# git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||
# # or, fleet-wide: git config --global credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||
#
|
||||
# Per-agent Gate-16 identity (author != reviewer separation):
|
||||
# Per-agent identity (Gate-16 author != reviewer separation):
|
||||
# git config mosaic.gitIdentity <agent-id> # per-worktree, persists on disk
|
||||
# # or: export MOSAIC_GIT_IDENTITY=<agent-id>
|
||||
#
|
||||
# Resolution priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity
|
||||
# (per-worktree, survives across non-persistent shells) > git-supplied username
|
||||
# (credential.username / URL). When the resolved identity has a matching
|
||||
# per-agent token file, use it instead of the shared account. Backward
|
||||
# compatible: nothing resolvable -> shared token (unchanged behavior).
|
||||
# ── WHY THIS FAILS CLOSED ──────────────────────────────────────────────────────
|
||||
# This helper used to end by emitting the shared account's token for any request
|
||||
# it could not resolve to an identity. A seat with no identity, or with an
|
||||
# identity whose token was never provisioned, therefore received the most
|
||||
# privileged credential configured on the host — silently, and indistinguishably
|
||||
# from correct operation. Every record it then created (commit, push, PR, review)
|
||||
# was attributed to that shared account, so author != reviewer separation was
|
||||
# unenforceable and the true actor was unrecoverable after the fact.
|
||||
#
|
||||
# Under-provisioning must fail loudly, not impersonate. A refused git operation
|
||||
# is recoverable in one command; a merged pull request attributed to the wrong
|
||||
# principal is not.
|
||||
#
|
||||
# ── CONTRACT ───────────────────────────────────────────────────────────────────
|
||||
# identity : MOSAIC_GIT_IDENTITY > git config mosaic.gitIdentity > the
|
||||
# username git supplies on stdin
|
||||
# store : chosen by what the identity IS, with no precedence and no
|
||||
# cross-store fallback (see "Credential store selection" below)
|
||||
# hit : emit username + password, exit 0
|
||||
# miss : emit NOTHING, spool a durable escalation record, explain on
|
||||
# stderr, exit 1 — git surfaces the failure and nothing is attributed
|
||||
# unknown host : exit 0 with no output, no record (passthrough for non-Mosaic
|
||||
# remotes handled by another helper)
|
||||
#
|
||||
# Backward compatibility is preserved for exactly one case: a host with no fleet
|
||||
# and no identity requested still gets the shared account, because on such a host
|
||||
# the shared account is the operator's own and there is no attribution to lose.
|
||||
# A host that HAS a fleet has agents whose records must be distinguishable, so
|
||||
# the shared fallback is refused there.
|
||||
#
|
||||
# A token is never written to stderr, to the escalation record, or to any log.
|
||||
|
||||
[ "$1" = "get" ] || exit 0
|
||||
|
||||
host=""; username_in=""
|
||||
while IFS= read -r line; do
|
||||
[ -z "$line" ] && break
|
||||
@@ -24,46 +51,170 @@ while IFS= read -r line; do
|
||||
username=*) username_in=${line#username=};;
|
||||
esac
|
||||
done
|
||||
# Per-agent identity resolution (Gate-16 author≠reviewer separation).
|
||||
# Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree,
|
||||
# survives across non-persistent shells) > git-supplied username (credential.username
|
||||
# / URL). When the resolved identity has a matching per-agent token, use it instead of
|
||||
# the shared account. Backward-compatible: nothing resolvable → shared token.
|
||||
ident="$MOSAIC_GIT_IDENTITY"
|
||||
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
||||
[ -z "$ident" ] && ident="$username_in"
|
||||
if [ -n "$ident" ]; then
|
||||
case "$host" in
|
||||
git.uscllc.com) idpfx=gitea-usc;;
|
||||
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
|
||||
*) idpfx="";;
|
||||
esac
|
||||
if [ -n "$idpfx" ]; then
|
||||
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
|
||||
if [ -r "$idtok" ]; then
|
||||
echo "username=${ident}"
|
||||
echo "password=$(cat "$idtok")"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Recognized Gitea hosts carry the per-identity token scheme. Anything else is
|
||||
# declined quietly — another helper owns it, and refusing would break it.
|
||||
case "$host" in
|
||||
git.uscllc.com) svc=gitea-usc;;
|
||||
git.mosaicstack.dev) svc=gitea-mosaicstack;;
|
||||
git.uscllc.com) idpfx=gitea-usc;;
|
||||
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
|
||||
*) exit 0;;
|
||||
esac
|
||||
# Script-relative (not $HOME-absolute) so this resolves correctly regardless
|
||||
# of where the framework installer places tools/ under $HOME — mirrors
|
||||
# detect-platform.sh's own cred_loader resolution in this same directory.
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=../_lib/credentials.sh
|
||||
source "$script_dir/../_lib/credentials.sh"
|
||||
load_credentials "$svc" >/dev/null 2>&1 || exit 0
|
||||
# GITEA_USER is not populated by load_credentials (it only exports
|
||||
# GITEA_URL/GITEA_TOKEN for gitea-*), so this fallback is normally taken. Gitea's
|
||||
# git-over-HTTP auth authenticates from the token itself (the password field),
|
||||
# not from the username string, so any non-empty placeholder works here — this
|
||||
# is deliberately NOT a real account name (framework files must stay
|
||||
# operator-agnostic; see tools/quality/scripts/verify-sanitized.sh).
|
||||
echo "username=${GITEA_USER:-git}"
|
||||
echo "password=$GITEA_TOKEN"
|
||||
|
||||
ident="$MOSAIC_GIT_IDENTITY"; ident_src="MOSAIC_GIT_IDENTITY"
|
||||
if [ -z "$ident" ]; then
|
||||
ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
||||
ident_src="git config mosaic.gitIdentity"
|
||||
fi
|
||||
if [ -z "$ident" ]; then
|
||||
ident="$username_in"
|
||||
ident_src="the username git supplied"
|
||||
fi
|
||||
|
||||
# ── Credential store selection ────────────────────────────────────────────────
|
||||
# An identity is a SEAT or it is a SERVICE, and which one it is determines where
|
||||
# its credential lives. There is no precedence rule between the two stores and no
|
||||
# fallback from one to the other: a seat whose slot is empty fails closed rather
|
||||
# than reading a service credential that happens to share its name.
|
||||
#
|
||||
# seat — <brain>/fleet/agents/<ident>/ exists
|
||||
# credential at <brain>/fleet/agents/<ident>/secrets/<idpfx>-<ident>.token
|
||||
# service — it does not
|
||||
# credential at ~/.config/mosaic/secrets/gitea-tokens/<idpfx>-<ident>.token
|
||||
#
|
||||
# One credential, one location. Two copies of one credential diverge, and the
|
||||
# stale copy fails in a way that reads as a revoked token rather than as drift.
|
||||
#
|
||||
# Brain-home resolution mirrors packages/mosaic/src/fleet/brain-home.ts and
|
||||
# tools/fleet/start-agent-session.sh: MOSAIC_BRAIN_HOME wins, else ~/.mosaic.
|
||||
brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
|
||||
svc_store="$HOME/.config/mosaic/secrets/gitea-tokens"
|
||||
|
||||
idtok=""; ident_kind=""
|
||||
if [ -n "$ident" ]; then
|
||||
if [ -d "$brain_home/fleet/agents/$ident" ]; then
|
||||
ident_kind="seat"
|
||||
idtok="$brain_home/fleet/agents/$ident/secrets/${idpfx}-${ident}.token"
|
||||
else
|
||||
ident_kind="service identity"
|
||||
idtok="$svc_store/${idpfx}-${ident}.token"
|
||||
fi
|
||||
if [ -r "$idtok" ]; then
|
||||
echo "username=${ident}"
|
||||
echo "password=$(cat "$idtok")"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Shared-account fallback: ONLY on a host with no fleet and no identity ──────
|
||||
# `fleet/agents` existing is the same signal brain-home.ts uses to decide a brain
|
||||
# is active. Where there are seats, records must be attributable, so an
|
||||
# unresolvable request is refused instead of borrowing the shared account.
|
||||
fleet_present=0
|
||||
[ -d "$brain_home/fleet/agents" ] && fleet_present=1
|
||||
|
||||
if [ -z "$ident" ] && [ "$fleet_present" -eq 0 ]; then
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=../_lib/credentials.sh
|
||||
source "$script_dir/../_lib/credentials.sh"
|
||||
load_credentials "$idpfx" >/dev/null 2>&1 || exit 0
|
||||
# GITEA_USER is not populated by load_credentials (it exports GITEA_URL and
|
||||
# GITEA_TOKEN only). Gitea's git-over-HTTP auth authenticates from the token in
|
||||
# the password field, not from the username string, so any non-empty
|
||||
# placeholder works — deliberately NOT a real account name, since framework
|
||||
# files stay operator-agnostic (tools/quality/scripts/verify-sanitized.sh).
|
||||
echo "username=${GITEA_USER:-git}"
|
||||
echo "password=$GITEA_TOKEN"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── FAIL CLOSED ───────────────────────────────────────────────────────────────
|
||||
if [ -z "$ident" ]; then
|
||||
reason="no-identity"
|
||||
else
|
||||
reason="no-token-for-identity"
|
||||
fi
|
||||
|
||||
seat="${MOSAIC_AGENT_NAME:-unknown}"
|
||||
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
# The escalation RECORD is durable and unconditional; any notification built on
|
||||
# top of it is best-effort. Record and alert are deduplicated separately — a cap
|
||||
# on the alert alone lets the spool grow without bound exactly while the operator
|
||||
# is being told nothing, so the louder the failure the quieter it gets.
|
||||
#
|
||||
# A record field is arbitrary operator-supplied text: an identity comes from git
|
||||
# config or the environment, and cwd is whatever directory git ran in. Either can
|
||||
# contain a quote or a backslash, which would make the line unparseable JSON --
|
||||
# and a spool that silently stops parsing is worse than no spool, because the
|
||||
# operator only discovers it while reading the record that explains an outage.
|
||||
json_escape() {
|
||||
local s=$1
|
||||
s=${s//\\/\\\\}
|
||||
s=${s//\"/\\\"}
|
||||
s=${s//$'\t'/\\t}
|
||||
s=${s//$'\r'/\\r}
|
||||
s=${s//$'\n'/\\n}
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
spool="${MOSAIC_CREDENTIAL_SPOOL:-$HOME/.local/state/mosaic-credential-escalations}"
|
||||
spool_record=""
|
||||
if mkdir -p "$spool" 2>/dev/null; then
|
||||
chmod 700 "$spool" 2>/dev/null
|
||||
spoolfile="$spool/$(date -u +%Y%m%d).jsonl"
|
||||
dedupe="$spool/.spooled-${seat}-${ident:-none}-${reason}-$(date -u +%Y%m%d%H%M)"
|
||||
if [ ! -e "$dedupe" ]; then
|
||||
: > "$dedupe" 2>/dev/null
|
||||
printf '{"ts":"%s","reason":"%s","identity":"%s","identity_source":"%s","kind":"%s","seat":"%s","host":"%s","cwd":"%s"}\n' \
|
||||
"$(json_escape "$ts")" "$(json_escape "$reason")" \
|
||||
"$(json_escape "${ident:-<unset>}")" "$(json_escape "$ident_src")" \
|
||||
"$(json_escape "${ident_kind:-none}")" "$(json_escape "$seat")" \
|
||||
"$(json_escape "$host")" "$(json_escape "$PWD")" \
|
||||
>> "$spoolfile" 2>/dev/null
|
||||
chmod 600 "$spoolfile" 2>/dev/null
|
||||
fi
|
||||
# Name the record only if one is actually on disk. Printing the path
|
||||
# unconditionally sends the operator to a file that does not exist on exactly
|
||||
# the hosts where the spool could not be created.
|
||||
[ -s "$spoolfile" ] && spool_record="$spoolfile"
|
||||
find "$spool" -maxdepth 1 -name '.spooled-*' -mmin +120 -delete 2>/dev/null
|
||||
fi
|
||||
|
||||
cat >&2 <<EOF
|
||||
git-credential-mosaic: REFUSED (fail-closed).
|
||||
host : ${host}
|
||||
identity : ${ident:-<unset>}${ident:+ (from ${ident_src}; resolved as a ${ident_kind})}
|
||||
reason : ${reason}
|
||||
EOF
|
||||
|
||||
if [ -n "$ident" ]; then
|
||||
cat >&2 <<EOF
|
||||
expected : ${idtok}
|
||||
EOF
|
||||
fi
|
||||
|
||||
cat >&2 <<EOF
|
||||
|
||||
No per-identity credential resolved. This helper does NOT fall back to the shared
|
||||
account: that fallback makes every record it creates attributable to one
|
||||
principal, which is unrecoverable once a pull request has merged under it.
|
||||
|
||||
Fix (pick one):
|
||||
export MOSAIC_GIT_IDENTITY=<agent-id> # process-scoped
|
||||
git config mosaic.gitIdentity <agent-id> # per-repo/worktree, persists
|
||||
Then provision that identity's credential at the path named above. An identity
|
||||
with a directory under \${MOSAIC_BRAIN_HOME:-\$HOME/.mosaic}/fleet/agents/ is a
|
||||
seat and is read ONLY from its own secrets/ slot; any other identity is read from
|
||||
~/.config/mosaic/secrets/gitea-tokens/. There is no fallback between the two.
|
||||
|
||||
If this identity legitimately needs git access and has none, ask the orchestrator
|
||||
to provision one.
|
||||
|
||||
EOF
|
||||
|
||||
if [ -n "$spool_record" ]; then
|
||||
echo " record: ${spool_record}" >&2
|
||||
else
|
||||
echo " record: NOT WRITTEN — spool unavailable at ${spool}" >&2
|
||||
fi
|
||||
exit 1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d]
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -14,6 +14,8 @@ MERGE_METHOD="squash"
|
||||
DELETE_BRANCH=false
|
||||
DRY_RUN=false
|
||||
EXPECT_HEAD=""
|
||||
CO_AUTHOR_TRAILERS=false
|
||||
ESCALATE_TO=""
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
@@ -27,12 +29,16 @@ Options:
|
||||
-d, --delete-branch Delete the head branch after merge
|
||||
--dry-run Run metadata/login preflight without merging
|
||||
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
||||
--co-author-trailers Build verified trailers from linked PR commit authors
|
||||
--escalate-to NAME Named principal for an unresolved-author BLOCK
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
$(basename "$0") -n 42 # Merge PR #42
|
||||
$(basename "$0") -n 42 -m squash # Squash merge
|
||||
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
||||
$(basename "$0") -n 42 --expect-head 0123456789abcdef0123456789abcdef01234567
|
||||
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
}
|
||||
@@ -57,9 +63,25 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
;;
|
||||
--expect-head)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Error: --expect-head requires one full commit SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
EXPECT_HEAD="$2"
|
||||
shift 2
|
||||
;;
|
||||
--co-author-trailers)
|
||||
CO_AUTHOR_TRAILERS=true
|
||||
shift
|
||||
;;
|
||||
--escalate-to)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Error: --escalate-to requires one principal name." >&2
|
||||
exit 1
|
||||
fi
|
||||
ESCALATE_TO="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage 0
|
||||
;;
|
||||
@@ -88,17 +110,30 @@ if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$CO_AUTHOR_TRAILERS" == true && -z "$ESCALATE_TO" ]]; then
|
||||
echo "Error: --co-author-trailers requires --escalate-to with a named principal." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$ESCALATE_TO" && ! "$ESCALATE_TO" =~ ^[A-Za-z0-9_.-]+$ ]]; then
|
||||
echo "Error: --escalate-to must be one exact principal name." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$CO_AUTHOR_TRAILERS" != true && -n "$ESCALATE_TO" ]]; then
|
||||
echo "Error: --escalate-to is valid only with --co-author-trailers." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
|
||||
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
|
||||
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
|
||||
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
|
||||
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
|
||||
if [[ "$BASE_BRANCH" != "main" ]]; then
|
||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
||||
PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')"
|
||||
PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')"
|
||||
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
|
||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (found '$BASE_BRANCH')." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
|
||||
exit 1
|
||||
@@ -122,70 +157,442 @@ PLATFORM=$(detect_platform)
|
||||
OWNER=$(get_repo_owner)
|
||||
REPO=$(get_repo_name)
|
||||
|
||||
merge_gitea_with_api() {
|
||||
local host="$1" api_url token basic_auth body_file raw_code payload
|
||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
|
||||
payload=$(python3 - "$HEAD_SHA" "$DELETE_BRANCH" <<'PY'
|
||||
write_curl_auth_config() {
|
||||
local mode="$1" credential="$2"
|
||||
printf '%s' "$credential" | python3 -c '
|
||||
import sys
|
||||
mode = sys.argv[1]
|
||||
credential = sys.stdin.read()
|
||||
if not credential or any(char in credential for char in "\r\n"):
|
||||
raise SystemExit(1)
|
||||
escaped = credential.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
if mode == "token":
|
||||
print(f"header = \"Authorization: token {escaped}\"")
|
||||
elif mode == "basic":
|
||||
print(f"user = \"{escaped}\"")
|
||||
else:
|
||||
raise SystemExit(1)
|
||||
' "$mode"
|
||||
}
|
||||
|
||||
LAST_GITEA_HTTP_CODE="000"
|
||||
LAST_GITEA_ERROR=""
|
||||
MERGE_TEMP_DIRS=()
|
||||
GITEA_CURL_MAX_BYTES="${MOSAIC_GITEA_CURL_MAX_BYTES:-1048576}"
|
||||
GITEA_CURL_MAX_TIME="${MOSAIC_GITEA_CURL_MAX_TIME_SEC:-30}"
|
||||
GITEA_CURL_CONNECT_TIMEOUT="${MOSAIC_GITEA_CURL_CONNECT_TIMEOUT_SEC:-10}"
|
||||
for bound in "$GITEA_CURL_MAX_BYTES" "$GITEA_CURL_MAX_TIME" "$GITEA_CURL_CONNECT_TIMEOUT"; do
|
||||
if [[ ! "$bound" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "Error: Gitea curl bounds must be positive integers; refusing request." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
GITEA_CURL_BOUNDS=(
|
||||
--max-filesize "$GITEA_CURL_MAX_BYTES"
|
||||
--max-time "$GITEA_CURL_MAX_TIME"
|
||||
--connect-timeout "$GITEA_CURL_CONNECT_TIMEOUT"
|
||||
)
|
||||
|
||||
format_gitea_error_response() {
|
||||
local response_file="$1"
|
||||
python3 - "$response_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
head_sha, delete_branch = sys.argv[1:]
|
||||
with open(sys.argv[1], "rb") as handle:
|
||||
raw = handle.read(65536)
|
||||
try:
|
||||
response = json.loads(raw.decode("utf-8", errors="replace"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
message = "non-JSON response omitted"
|
||||
else:
|
||||
if isinstance(response, dict):
|
||||
message = response.get("message") or response.get("error")
|
||||
if not message and response.get("errors") is not None:
|
||||
message = json.dumps(response["errors"], separators=(",", ":"))
|
||||
else:
|
||||
message = None
|
||||
if not message:
|
||||
message = "JSON response contained no error message"
|
||||
message = str(message)
|
||||
if len(message) > 500:
|
||||
message = message[:500] + "..."
|
||||
print(ascii(message))
|
||||
PY
|
||||
}
|
||||
|
||||
cleanup_merge_temp_dirs() {
|
||||
local path
|
||||
for path in "${MERGE_TEMP_DIRS[@]}"; do
|
||||
[[ -n "$path" ]] && rm -rf -- "$path"
|
||||
done
|
||||
}
|
||||
trap cleanup_merge_temp_dirs EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
fetch_gitea_pr_head() {
|
||||
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
|
||||
local response_file raw_code api_url auth_config curl_rc
|
||||
response_file=$(mktemp "$work_root/pr-merge-pr.XXXXXX")
|
||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}"
|
||||
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
|
||||
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
|
||||
rm -f "$response_file"
|
||||
return 1
|
||||
fi
|
||||
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$response_file" \
|
||||
-H "User-Agent: curl/8" "$api_url" <<<"$auth_config")
|
||||
curl_rc=$?
|
||||
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
||||
if [[ "$curl_rc" -ne 0 ]]; then
|
||||
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
|
||||
rm -f "$response_file"
|
||||
return 1
|
||||
fi
|
||||
if [[ ! "$raw_code" =~ ^2 ]]; then
|
||||
LAST_GITEA_ERROR=$(format_gitea_error_response "$response_file")
|
||||
rm -f "$response_file"
|
||||
return 1
|
||||
fi
|
||||
if ! python3 - "$response_file" <<'PY'
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
pull = json.load(handle)
|
||||
head = pull.get("head") if isinstance(pull, dict) else None
|
||||
sha = str(head.get("sha") or "") if isinstance(head, dict) else ""
|
||||
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
|
||||
raise SystemExit(1)
|
||||
print(sha)
|
||||
PY
|
||||
then
|
||||
echo "Error: Gitea PR response has no valid head SHA; refusing merge." >&2
|
||||
rm -f "$response_file"
|
||||
return 1
|
||||
fi
|
||||
rm -f "$response_file"
|
||||
}
|
||||
|
||||
fetch_gitea_pr_commits() {
|
||||
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
|
||||
local page page_file combined_file merged_file raw_code page_count api_url auth_config curl_rc
|
||||
mkdir -p "$work_root"
|
||||
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
|
||||
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
|
||||
return 1
|
||||
fi
|
||||
combined_file=$(mktemp "$work_root/pr-merge-commits.XXXXXX")
|
||||
printf '[]' > "$combined_file"
|
||||
|
||||
page=1
|
||||
while true; do
|
||||
page_file=$(mktemp "$work_root/pr-merge-commits-page.XXXXXX")
|
||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/commits?limit=50&page=${page}"
|
||||
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$page_file" \
|
||||
-H "User-Agent: curl/8" "$api_url" <<<"$auth_config")
|
||||
curl_rc=$?
|
||||
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
||||
if [[ "$curl_rc" -ne 0 ]]; then
|
||||
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
|
||||
rm -f "$page_file" "$combined_file"
|
||||
return 1
|
||||
fi
|
||||
if [[ ! "$raw_code" =~ ^2 ]]; then
|
||||
LAST_GITEA_ERROR=$(format_gitea_error_response "$page_file")
|
||||
rm -f "$page_file" "$combined_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! page_count=$(python3 - "$page_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
page = json.load(handle)
|
||||
if not isinstance(page, list):
|
||||
raise SystemExit(1)
|
||||
print(len(page))
|
||||
PY
|
||||
); then
|
||||
echo "Error: Gitea PR commits response is not a JSON array; refusing merge." >&2
|
||||
rm -f "$page_file" "$combined_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
merged_file=$(mktemp "$work_root/pr-merge-commits-merged.XXXXXX")
|
||||
if ! python3 - "$combined_file" "$page_file" > "$merged_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
combined = json.load(handle)
|
||||
with open(sys.argv[2], encoding="utf-8") as handle:
|
||||
page = json.load(handle)
|
||||
json.dump(combined + page, sys.stdout, separators=(",", ":"))
|
||||
PY
|
||||
then
|
||||
echo "Error: Could not combine paginated PR commit metadata; refusing merge." >&2
|
||||
rm -f "$page_file" "$combined_file" "$merged_file"
|
||||
return 1
|
||||
fi
|
||||
mv "$merged_file" "$combined_file"
|
||||
rm -f "$page_file"
|
||||
|
||||
if [[ "$page_count" -lt 50 ]]; then
|
||||
break
|
||||
fi
|
||||
page=$((page + 1))
|
||||
if [[ "$page" -gt 1000 ]]; then
|
||||
echo "Error: PR commit pagination exceeded 1000 pages; refusing merge." >&2
|
||||
rm -f "$combined_file"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
cat "$combined_file"
|
||||
rm -f "$combined_file"
|
||||
}
|
||||
|
||||
# LIMITATION: author.login resolution proves the commit address maps to a registered account.
|
||||
# It does NOT prove the named principal authored the commit — git author metadata is self-asserted.
|
||||
# This gate checks ATTRIBUTION LINKAGE, not AUTHORSHIP. Commit signing is out of scope and unadopted.
|
||||
build_coauthor_message_fields() {
|
||||
local commits_file="$1" context_file="$2" head_file="$3"
|
||||
python3 - "$commits_file" "$context_file" "$head_file" <<'PY'
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
commits_path, context_path, head_path = sys.argv[1:]
|
||||
with open(commits_path, encoding="utf-8") as handle:
|
||||
commits = json.load(handle)
|
||||
head_sha = open(head_path, encoding="utf-8").read().strip()
|
||||
context_parts = open(context_path, "rb").read().split(b"\0")
|
||||
if len(context_parts) != 4 or context_parts[-1] != b"":
|
||||
raise SystemExit(1)
|
||||
poster, title, principal = (part.decode("utf-8") for part in context_parts[:3])
|
||||
|
||||
if not isinstance(commits, list) or not commits:
|
||||
print(
|
||||
f"BLOCK: provider returned no PR commits; author identity is unmeasurable. "
|
||||
f"Refusing merge; escalate to named principal '{principal}'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(75)
|
||||
if not poster:
|
||||
print(
|
||||
f"BLOCK: PR poster login is empty; refusing merge; "
|
||||
f"escalate to named principal '{principal}'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(75)
|
||||
|
||||
if not re.fullmatch(r"[0-9a-fA-F]{40}", head_sha):
|
||||
print(
|
||||
f"BLOCK: inspected PR head SHA is invalid; refusing merge; "
|
||||
f"escalate to named principal '{principal}'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(75)
|
||||
|
||||
seen = set()
|
||||
trailers = []
|
||||
head_seen = False
|
||||
for item in commits:
|
||||
if not isinstance(item, dict):
|
||||
print(f"BLOCK: malformed PR commit metadata; escalate to named principal '{principal}'.", file=sys.stderr)
|
||||
raise SystemExit(75)
|
||||
sha = str(item.get("sha") or "<unknown>")
|
||||
if sha == head_sha:
|
||||
head_seen = True
|
||||
commit = item.get("commit") if isinstance(item.get("commit"), dict) else {}
|
||||
commit_author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
|
||||
email = str(commit_author.get("email") or "").strip()
|
||||
provider_author = item.get("author") if isinstance(item.get("author"), dict) else {}
|
||||
login = str(provider_author.get("login") or "").strip()
|
||||
|
||||
if not login:
|
||||
diagnostic_email = email or "<missing>"
|
||||
print(
|
||||
f"BLOCK: commit {sha!r} has author.login=NULL while "
|
||||
f"commit.author.email={diagnostic_email!r}; refusing merge; "
|
||||
f"escalate to named principal '{principal}'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(75)
|
||||
if (
|
||||
not email.isascii()
|
||||
or not email.isprintable()
|
||||
or not re.fullmatch(r"[A-Za-z0-9_.-]+", login)
|
||||
or not re.fullmatch(r"[^<>\s]+@[^<>\s]+", email)
|
||||
):
|
||||
print(
|
||||
f"BLOCK: commit {sha!r} has unusable linked identity "
|
||||
f"author.login={login!r}, commit.author.email={email!r}; refusing merge; "
|
||||
f"escalate to named principal '{principal}'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(75)
|
||||
if login == poster or login in seen:
|
||||
continue
|
||||
seen.add(login)
|
||||
trailers.append(f"Co-authored-by: {login} <{email}>")
|
||||
|
||||
if not head_seen:
|
||||
print(
|
||||
f"BLOCK: inspected PR head is absent from commit enumeration; refusing merge; "
|
||||
f"escalate to named principal '{principal}'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(75)
|
||||
if not trailers:
|
||||
print("{}")
|
||||
raise SystemExit(0)
|
||||
if not title:
|
||||
print(
|
||||
f"BLOCK: PR title is empty; refusing merge; escalate to named principal '{principal}'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(75)
|
||||
if not title.isprintable() or re.match(r"^[A-Za-z-]+-[Bb]y:", title):
|
||||
print(
|
||||
f"BLOCK: PR title is not one printable, non-trailer line; refusing merge; "
|
||||
f"escalate to named principal '{principal}'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(75)
|
||||
|
||||
print(json.dumps({
|
||||
"MergeTitleField": title,
|
||||
"MergeMessageField": "\n".join(trailers),
|
||||
}, separators=(",", ":")))
|
||||
PY
|
||||
}
|
||||
|
||||
merge_gitea_api_attempt() {
|
||||
local host="$1" auth_mode="$2" credential="$3"
|
||||
local api_url attempt_dir body_file raw_code commits_file fields_file context_file head_file payload_file work_root attempt_rc auth_config curl_rc
|
||||
LAST_GITEA_HTTP_CODE="000"
|
||||
LAST_GITEA_ERROR=""
|
||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
||||
work_root="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||
mkdir -p "$work_root"
|
||||
attempt_dir=$(mktemp -d "$work_root/pr-merge-attempt.XXXXXX")
|
||||
chmod 0700 "$attempt_dir"
|
||||
MERGE_TEMP_DIRS+=("$attempt_dir")
|
||||
body_file=$(mktemp "$attempt_dir/api-response.XXXXXX")
|
||||
fields_file=$(mktemp "$attempt_dir/message-fields.XXXXXX")
|
||||
payload_file=$(mktemp "$attempt_dir/payload.XXXXXX")
|
||||
printf '{}' > "$fields_file"
|
||||
|
||||
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||
commits_file=$(mktemp "$attempt_dir/pr-merge-commits-input.XXXXXX")
|
||||
context_file=$(mktemp "$attempt_dir/pr-merge-message-context.XXXXXX")
|
||||
head_file=$(mktemp "$attempt_dir/pr-merge-head-input.XXXXXX")
|
||||
printf '%s\0%s\0%s\0' "$PR_AUTHOR" "$PR_TITLE" "$ESCALATE_TO" > "$context_file"
|
||||
if fetch_gitea_pr_head "$host" "$auth_mode" "$credential" "$attempt_dir" > "$head_file"; then
|
||||
:
|
||||
else
|
||||
attempt_rc=$?
|
||||
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
|
||||
return "$attempt_rc"
|
||||
fi
|
||||
if [[ "$(<"$head_file")" != "$HEAD_SHA" ]]; then
|
||||
echo "BLOCK: authenticated PR head moved from reviewed $HEAD_SHA to $(<"$head_file"); refusing merge; escalate to named principal '$ESCALATE_TO'." >&2
|
||||
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
|
||||
return 75
|
||||
fi
|
||||
if fetch_gitea_pr_commits "$host" "$auth_mode" "$credential" "$attempt_dir" > "$commits_file"; then
|
||||
:
|
||||
else
|
||||
attempt_rc=$?
|
||||
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
|
||||
return "$attempt_rc"
|
||||
fi
|
||||
if build_coauthor_message_fields "$commits_file" "$context_file" "$head_file" > "$fields_file"; then
|
||||
:
|
||||
else
|
||||
attempt_rc=$?
|
||||
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
|
||||
return "$attempt_rc"
|
||||
fi
|
||||
rm -f "$commits_file" "$context_file" "$head_file"
|
||||
fi
|
||||
|
||||
if ! python3 - "$fields_file" "$HEAD_SHA" "$DELETE_BRANCH" > "$payload_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
fields = json.load(handle)
|
||||
head_sha, delete_branch = sys.argv[2:]
|
||||
payload = {"Do": "squash", "head_commit_id": head_sha}
|
||||
if delete_branch == "true":
|
||||
payload["delete_branch_after_merge"] = True
|
||||
payload.update(fields)
|
||||
allowed = {"Do", "head_commit_id", "delete_branch_after_merge", "MergeTitleField", "MergeMessageField"}
|
||||
if payload.get("Do") != "squash" or set(payload) - allowed:
|
||||
raise SystemExit(1)
|
||||
print(json.dumps(payload, separators=(",", ":")))
|
||||
PY
|
||||
)
|
||||
|
||||
token=$(get_gitea_token "$host" || true)
|
||||
if [[ -n "$token" ]]; then
|
||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
||||
-X POST \
|
||||
-H "User-Agent: curl/8" \
|
||||
-H "Authorization: token $token" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$payload" \
|
||||
"$api_url" || true)
|
||||
if [[ "$raw_code" =~ ^2 ]]; then
|
||||
rm -f "$body_file"
|
||||
return 0
|
||||
fi
|
||||
then
|
||||
rm -f "$body_file" "$fields_file" "$payload_file"
|
||||
return 1
|
||||
fi
|
||||
rm -f "$fields_file"
|
||||
|
||||
basic_auth=$(get_gitea_basic_auth "$host" || true)
|
||||
if [[ -n "$basic_auth" ]]; then
|
||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
||||
-X POST \
|
||||
-u "$basic_auth" \
|
||||
-H "User-Agent: curl/8" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$payload" \
|
||||
"$api_url" || true)
|
||||
if [[ "$raw_code" =~ ^2 ]]; then
|
||||
rm -f "$body_file"
|
||||
return 0
|
||||
fi
|
||||
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
|
||||
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
|
||||
rm -f "$body_file" "$payload_file"
|
||||
return 1
|
||||
fi
|
||||
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$body_file" \
|
||||
-X POST -H "User-Agent: curl/8" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary "@$payload_file" "$api_url" <<<"$auth_config")
|
||||
curl_rc=$?
|
||||
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
||||
if [[ "$curl_rc" -ne 0 ]]; then
|
||||
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
|
||||
rm -f "$body_file" "$payload_file"
|
||||
rm -rf -- "$attempt_dir"
|
||||
return 1
|
||||
fi
|
||||
if [[ ! "$raw_code" =~ ^2 ]]; then
|
||||
LAST_GITEA_ERROR=$(format_gitea_error_response "$body_file")
|
||||
fi
|
||||
rm -f "$body_file" "$payload_file"
|
||||
rm -rf -- "$attempt_dir"
|
||||
[[ "$raw_code" =~ ^2 ]]
|
||||
}
|
||||
|
||||
python3 - "${raw_code:-000}" "$body_file" <<'PY' >&2
|
||||
import json
|
||||
import sys
|
||||
code, path = sys.argv[1], sys.argv[2]
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as handle:
|
||||
raw = handle.read(500)
|
||||
data = json.loads(raw) if raw else {}
|
||||
message = data.get("message") or data.get("error") or raw or "empty response"
|
||||
except Exception:
|
||||
try:
|
||||
message = open(path, encoding="utf-8", errors="replace").read(500) or "empty response"
|
||||
except Exception:
|
||||
message = "unreadable response"
|
||||
print(f"Error: Gitea API merge failed with HTTP {code}: {message}")
|
||||
PY
|
||||
rm -f "$body_file"
|
||||
merge_gitea_with_api() {
|
||||
local host="$1" token attempt_rc
|
||||
|
||||
if ! token=$(get_gitea_token "$host"); then
|
||||
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$token" ]]; then
|
||||
echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
|
||||
return 1
|
||||
fi
|
||||
if merge_gitea_api_attempt "$host" token "$token"; then
|
||||
return 0
|
||||
else
|
||||
attempt_rc=$?
|
||||
fi
|
||||
if [[ "$attempt_rc" -eq 75 ]]; then
|
||||
return 75
|
||||
fi
|
||||
if [[ "$LAST_GITEA_HTTP_CODE" != "401" ]]; then
|
||||
echo "Error: Gitea API merge failed with the identity-bound token (HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR}" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Error: Gitea API rejected the identity-bound token with HTTP 401; refusing cross-principal credential fallback." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -195,11 +602,10 @@ if [[ "$DRY_RUN" == true ]]; then
|
||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||
exit 1
|
||||
}
|
||||
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
||||
if [[ -n "$TEA_LOGIN" ]]; then
|
||||
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with tea login '$TEA_LOGIN' (base=$BASE_BRANCH, method=squash)."
|
||||
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||
echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)."
|
||||
else
|
||||
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with authenticated Gitea API fallback (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
|
||||
else
|
||||
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
|
||||
@@ -209,6 +615,10 @@ fi
|
||||
|
||||
case "$PLATFORM" in
|
||||
github)
|
||||
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||
echo "Error: --co-author-trailers currently requires the Gitea REST message-field contract." >&2
|
||||
exit 1
|
||||
fi
|
||||
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
|
||||
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
||||
"${cmd[@]}"
|
||||
@@ -219,7 +629,7 @@ case "$PLATFORM" in
|
||||
exit 1
|
||||
}
|
||||
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
|
||||
# tea cannot express it, so exact-head merges use the authenticated API path.
|
||||
# tea cannot express it, so every Gitea merge uses the authenticated API path.
|
||||
merge_gitea_with_api "$HOST"
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -43,22 +43,22 @@ SH
|
||||
chmod +x "$STUB_DIR/gh"
|
||||
|
||||
run_guard() {
|
||||
local mode="$1"
|
||||
local mode="$1" purpose="${2:-push}"
|
||||
(
|
||||
cd "$REPO_DIR" || exit
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
export MOSAIC_GH_CHECK_MODE="$mode"
|
||||
export MOSAIC_GH_CALL_LOG="$WORK_DIR/gh-calls.log"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose push -t 0 -i 0
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "$purpose" -t 0 -i 0
|
||||
)
|
||||
}
|
||||
|
||||
failures=0
|
||||
assert_case() {
|
||||
local mode="$1" expected_rc="$2" expected_state="$3" output rc
|
||||
local mode="$1" expected_rc="$2" expected_state="$3" purpose="${4:-push}" output rc
|
||||
set +e
|
||||
output=$(run_guard "$mode" 2>&1)
|
||||
output=$(run_guard "$mode" "$purpose" 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
if [[ "$expected_rc" == zero && "$rc" -ne 0 ]]; then
|
||||
@@ -79,10 +79,12 @@ set -e
|
||||
: > "$WORK_DIR/gh-calls.log"
|
||||
assert_case success zero terminal-success
|
||||
assert_case pending nonzero pending
|
||||
assert_case failure nonzero terminal-failure
|
||||
assert_case late-failure nonzero terminal-failure
|
||||
assert_case failure zero terminal-failure
|
||||
assert_case late-failure zero terminal-failure
|
||||
assert_case failure nonzero terminal-failure merge
|
||||
assert_case late-failure nonzero terminal-failure merge
|
||||
|
||||
if [[ $(grep -c 'check-runs?per_page=100&filter=latest' "$WORK_DIR/gh-calls.log") -lt 4 ]]; then
|
||||
if [[ $(grep -c 'check-runs?per_page=100&filter=latest' "$WORK_DIR/gh-calls.log") -lt 6 ]]; then
|
||||
echo "FAIL: expected every case to query all Checks API pages" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
@@ -92,4 +94,4 @@ if [[ "$failures" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "GitHub check-runs regression passed (4/4 cases, including later-page failure)"
|
||||
echo "GitHub check-runs regression passed (6/6 purpose-aware cases, including later-page failure)"
|
||||
|
||||
@@ -9,10 +9,51 @@ WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
STUB_DIR="$WORK_DIR/stubs"
|
||||
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
||||
STATUS_OBSERVED="$WORK_DIR/status-observed"
|
||||
CLOCK_LOG="$WORK_DIR/clock.log"
|
||||
WATCHDOG_PYTHON="/usr/bin/python3"
|
||||
WATCHDOG_SCRIPT="$WORK_DIR/real-clock-watchdog.py"
|
||||
WATCHDOG_TIMEOUT_SEC=5
|
||||
WATCHDOG_EXIT=90
|
||||
FEATURE_BRANCH="fix/rm-03-fixture"
|
||||
|
||||
if [[ ! -x "$WATCHDOG_PYTHON" ]]; then
|
||||
echo "FAIL setup: required real-clock watchdog runtime is unavailable at $WATCHDOG_PYTHON" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
||||
cat > "$WATCHDOG_SCRIPT" <<'PY'
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
raise SystemExit(2)
|
||||
|
||||
timeout_seconds = float(sys.argv[1])
|
||||
process = subprocess.Popen(sys.argv[2:], start_new_session=True)
|
||||
try:
|
||||
return_code = process.wait(timeout=timeout_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
process.wait()
|
||||
print(
|
||||
f"FAIL HANG watchdog: subject exceeded {timeout_seconds:g}s "
|
||||
"before completing its intended path",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(90)
|
||||
|
||||
if return_code < 0:
|
||||
raise SystemExit(128 - return_code)
|
||||
raise SystemExit(return_code)
|
||||
PY
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
|
||||
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
||||
@@ -33,6 +74,9 @@ printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
|
||||
|
||||
case "$url" in
|
||||
*/branches/*)
|
||||
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "hang-before-provider" ]]; then
|
||||
while :; do :; done
|
||||
fi
|
||||
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
|
||||
exit 7
|
||||
fi
|
||||
@@ -44,6 +88,7 @@ case "$url" in
|
||||
fi
|
||||
;;
|
||||
*/status)
|
||||
: > "${MOSAIC_STUB_STATUS_OBSERVED:?}"
|
||||
case "${MOSAIC_STUB_STATUS_MODE:?}" in
|
||||
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
|
||||
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
|
||||
@@ -53,6 +98,7 @@ case "$url" in
|
||||
malformed) printf '%s' 'not-json' ;;
|
||||
malformed-statuses-type) printf '%s' '{"state":"success","statuses":"corrupt"}' ;;
|
||||
malformed-status-entry) printf '%s' '{"state":"success","statuses":[null]}' ;;
|
||||
unknown) printf '%s' '{"state":"success","statuses":[{"status":"cancelled"}]}' ;;
|
||||
large-success)
|
||||
python3 -c 'import json; print(json.dumps({"state":"success", "statuses":[{"status":"success"}], "padding":"x" * (160 * 1024)}), end="")'
|
||||
;;
|
||||
@@ -63,7 +109,31 @@ case "$url" in
|
||||
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$STUB_DIR/curl"
|
||||
|
||||
cat > "$STUB_DIR/date" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$#" -ne 1 || "$1" != "+%s" ]]; then
|
||||
echo "unexpected date invocation: $*" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -e "${MOSAIC_STUB_STATUS_OBSERVED:?}" ]]; then
|
||||
printf 'date-phase=after-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||
printf '1002\n'
|
||||
else
|
||||
printf 'date-phase=before-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||
printf '1000\n'
|
||||
fi
|
||||
SH
|
||||
|
||||
cat > "$STUB_DIR/sleep" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'sleep-after-status=%s\n' "$*" >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||
SH
|
||||
chmod +x "$STUB_DIR/curl" "$STUB_DIR/date" "$STUB_DIR/sleep"
|
||||
|
||||
run_guard() {
|
||||
local status_mode="$1"
|
||||
@@ -83,13 +153,46 @@ run_guard() {
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_STUB_STATUS_MODE="$status_mode"
|
||||
fi
|
||||
rm -f "$STATUS_OBSERVED" "$CLOCK_LOG"
|
||||
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
|
||||
export MOSAIC_STUB_STATUS_OBSERVED="$STATUS_OBSERVED"
|
||||
export MOSAIC_STUB_CLOCK_LOG="$CLOCK_LOG"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 0 -i 0 "$@"
|
||||
# Provider observation is the synchronization event. The one-second
|
||||
# timeout is subject semantics under virtual time, never a wall wait.
|
||||
# The absolute Python runtime uses an internal monotonic wait and kills
|
||||
# the subject's isolated process group. Neither operation can resolve
|
||||
# to the virtual date/sleep stubs at the front of PATH.
|
||||
local subject_rc
|
||||
if "$WATCHDOG_PYTHON" "$WATCHDOG_SCRIPT" "$WATCHDOG_TIMEOUT_SEC" \
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 1 -i 1 "$@"; then
|
||||
subject_rc=0
|
||||
else
|
||||
subject_rc=$?
|
||||
fi
|
||||
return "$subject_rc"
|
||||
)
|
||||
}
|
||||
|
||||
failures=0
|
||||
assert_provider_observed() {
|
||||
local name="$1" require_expiration="${2:-0}"
|
||||
if [[ ! -e "$STATUS_OBSERVED" ]]; then
|
||||
echo "FAIL $name: status provider was not observed" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ ! -s "$CLOCK_LOG" ]] || ! grep -q '^date-phase=before-status$' "$CLOCK_LOG"; then
|
||||
echo "FAIL $name: virtual clock interception did not run before provider observation" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$require_expiration" -eq 1 ]]; then
|
||||
if ! grep -q '^sleep-after-status=' "$CLOCK_LOG" || ! grep -q '^date-phase=after-status$' "$CLOCK_LOG"; then
|
||||
echo "FAIL $name: pending path did not expire after provider observation" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
run_assertion() {
|
||||
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
|
||||
local output rc
|
||||
@@ -124,22 +227,59 @@ run_assertion() {
|
||||
printf '%s\n' "$output" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$status_mode" != "credential-unresolvable" ]]; then
|
||||
if [[ "$status_mode" == "pending" ]]; then
|
||||
assert_provider_observed "$name" 1
|
||||
else
|
||||
assert_provider_observed "$name"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
set -e
|
||||
: > "$WORK_DIR/urls.log"
|
||||
run_assertion success zero success 'state=terminal-success'
|
||||
run_assertion pending nonzero pending 'ASSERTED_NOT_READY'
|
||||
run_assertion failure nonzero failure 'ASSERTED_NOT_READY'
|
||||
run_assertion no-status nonzero no-status 'ASSERTED_NOT_READY'
|
||||
run_assertion aggregate-success-no-status nonzero aggregate-success-no-status 'ASSERTED_NOT_READY'
|
||||
run_assertion malformed nonzero malformed 'ASSERTED_NOT_READY'
|
||||
run_assertion malformed-statuses-type nonzero malformed-statuses-type 'ASSERTED_NOT_READY'
|
||||
run_assertion malformed-status-entry nonzero malformed-status-entry 'ASSERTED_NOT_READY'
|
||||
run_assertion large-payload not126 large-success 'state=terminal-success'
|
||||
# Push readiness is queue clearance, not proof that prior CI succeeded.
|
||||
run_assertion push-success zero success 'state=terminal-success'
|
||||
run_assertion push-pending nonzero pending 'ASSERTED_NOT_READY'
|
||||
run_assertion push-failure zero failure 'queue-clear state=terminal-failure purpose=push'
|
||||
run_assertion push-no-status zero no-status 'queue-clear state=no-status purpose=push'
|
||||
run_assertion push-aggregate-success-no-status zero aggregate-success-no-status 'queue-clear state=no-status purpose=push'
|
||||
run_assertion push-require-status nonzero no-status 'ASSERTED_NOT_READY state=no-status' --require-status
|
||||
run_assertion push-malformed nonzero malformed 'ASSERTED_NOT_READY'
|
||||
run_assertion push-malformed-statuses-type nonzero malformed-statuses-type 'ASSERTED_NOT_READY'
|
||||
run_assertion push-malformed-status-entry nonzero malformed-status-entry 'ASSERTED_NOT_READY'
|
||||
run_assertion push-unknown nonzero unknown 'ASSERTED_NOT_READY'
|
||||
run_assertion push-large-payload not126 large-success 'state=terminal-success'
|
||||
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
|
||||
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
|
||||
|
||||
# Merge readiness remains fail-closed and requires exact-head terminal success.
|
||||
MOSAIC_TEST_PURPOSE=merge run_assertion merge-success zero success 'state=terminal-success'
|
||||
MOSAIC_TEST_PURPOSE=merge run_assertion merge-failure nonzero failure 'ASSERTED_NOT_READY state=terminal-failure'
|
||||
MOSAIC_TEST_PURPOSE=merge run_assertion merge-no-status nonzero no-status 'ASSERTED_NOT_READY state=no-status'
|
||||
MOSAIC_TEST_PURPOSE=merge run_assertion merge-unknown nonzero unknown 'ASSERTED_NOT_READY state=unknown'
|
||||
|
||||
# Positive liveness control: a subject mutant hangs before the branch lookup
|
||||
# can reach the status provider. Only the independent real-clock watchdog may
|
||||
# terminate it, and its failure must be distinct from subject timeout rc=124.
|
||||
set +e
|
||||
watchdog_output=$(MOSAIC_STUB_BRANCH_MODE=hang-before-provider run_guard success "$AUDIT_LOG" 2>&1)
|
||||
watchdog_rc=$?
|
||||
set -e
|
||||
if [[ "$watchdog_rc" -ne "$WATCHDOG_EXIT" ]]; then
|
||||
echo "FAIL watchdog-control: expected hang-specific rc=$WATCHDOG_EXIT, got rc=$watchdog_rc" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$watchdog_output" != *"FAIL HANG watchdog:"* ]]; then
|
||||
echo "FAIL watchdog-control: expected distinct hang-specific diagnostic" >&2
|
||||
printf '%s\n' "$watchdog_output" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ -e "$STATUS_OBSERVED" ]]; then
|
||||
echo "FAIL watchdog-control: hanging mutant unexpectedly reached the status provider" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
|
||||
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
|
||||
failures=$((failures + 1))
|
||||
@@ -160,6 +300,7 @@ if [[ "$merge_unreachable_output" != *"CANNOT_ASSERT"* ]]; then
|
||||
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
assert_provider_observed merge-provider-unreachable
|
||||
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
||||
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
|
||||
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
|
||||
@@ -223,6 +364,7 @@ if [[ "$audit_failure_output" != *"audit"* ]]; then
|
||||
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
assert_provider_observed audit-unavailable
|
||||
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for `git-credential-mosaic` — per-agent Gitea identity
|
||||
# resolution (Gate-16 author≠reviewer separation).
|
||||
# resolution (Gate-16 author≠reviewer separation) and fail-closed refusal.
|
||||
#
|
||||
# Covers:
|
||||
# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config
|
||||
# mosaic.gitIdentity (per-worktree) > git-supplied username.
|
||||
# 2. Correct per-slot token file path chosen per host
|
||||
# 2. Correct token file path chosen per host
|
||||
# (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
|
||||
# 3. Per-slot token present -> emits that identity + token.
|
||||
# 4. Per-slot token absent -> falls back to the shared account
|
||||
# (backward-compat / no-op for hosts without per-slot tokens).
|
||||
# 5. Unknown/unrelated host -> exits 0 with no output (passthrough).
|
||||
# 3. Credential store selection: an identity with a directory under
|
||||
# <brain>/fleet/agents/ is a SEAT and is read ONLY from its own secrets/
|
||||
# slot; any other identity is a SERVICE and is read from the framework
|
||||
# store. No precedence between them and NO fallback from one to the other.
|
||||
# 4. Fail-closed: an identity that resolves but has no credential is REFUSED —
|
||||
# no output, nonzero exit, a stderr diagnostic, and a durable spool record.
|
||||
# The shared account is never emitted in its place.
|
||||
# 5. Fail-closed: no identity resolvable on a host that runs a fleet is also
|
||||
# REFUSED, because records made there must name the agent that made them.
|
||||
# 6. Backward compatibility, the one surviving fallback: no identity AND no
|
||||
# fleet -> shared account, unchanged. On such a host the shared account is
|
||||
# the operator's own and there is no attribution to lose.
|
||||
# 7. Unknown/unrelated host -> exits 0 with no output (passthrough).
|
||||
# 8. Non-"get" verb -> exits 0 with no output.
|
||||
#
|
||||
# Uses stubbed token files under a fake HOME + a real (throwaway) git repo.
|
||||
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
||||
@@ -21,6 +31,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}"
|
||||
FAKE_HOME="$WORK_DIR/home"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
BRAIN_DIR="$WORK_DIR/brain"
|
||||
SPOOL_DIR="$WORK_DIR/spool"
|
||||
SVC_STORE="$FAKE_HOME/.config/mosaic/secrets/gitea-tokens"
|
||||
# Mirror the real deployed layout (~/.config/mosaic/tools/{git,_lib}/) under the
|
||||
# fake HOME: git-credential-mosaic resolves its credentials.sh sibling via a
|
||||
# script-relative path (BASH_SOURCE), so the copy must live next to a stubbed
|
||||
@@ -28,10 +41,10 @@ REPO_DIR="$WORK_DIR/repo"
|
||||
HELPER="$FAKE_HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" \
|
||||
mkdir -p "$SVC_STORE" \
|
||||
"$FAKE_HOME/.config/mosaic/tools/git" \
|
||||
"$FAKE_HOME/.config/mosaic/tools/_lib" \
|
||||
"$REPO_DIR"
|
||||
"$REPO_DIR" "$BRAIN_DIR"
|
||||
|
||||
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
|
||||
chmod +x "$HELPER"
|
||||
@@ -68,7 +81,8 @@ run_helper() {
|
||||
local host="$1" username_in="$2"; shift 2
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" "$@" bash "$HELPER" get <<EOF
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$SPOOL_DIR" "$@" \
|
||||
bash "$HELPER" get <<EOF
|
||||
host=$host
|
||||
username=$username_in
|
||||
|
||||
@@ -76,20 +90,61 @@ EOF
|
||||
)
|
||||
}
|
||||
|
||||
# A refusal must be observable in four independent ways: nonzero exit, EMPTY
|
||||
# stdout, a stderr diagnostic naming the identity and host, and — the assertion
|
||||
# that actually catches a regression to the old behavior — NO shared token value
|
||||
# anywhere in the output. Checking only the exit code would pass against a helper
|
||||
# that emitted the shared credential and then exited 1.
|
||||
assert_fail_closed() {
|
||||
local desc="$1" host="$2" username_in="$3" want_in_stderr="$4"; shift 4
|
||||
local stderr_file="$WORK_DIR/stderr.tmp"
|
||||
: > "$stderr_file"
|
||||
set +e
|
||||
local stdout
|
||||
stdout=$(run_helper "$host" "$username_in" "$@" 2>"$stderr_file")
|
||||
local rc=$?
|
||||
set -e
|
||||
local stderr
|
||||
stderr=$(cat "$stderr_file")
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
echo "FAIL: $desc — expected nonzero exit, got 0 (stdout='$stdout')" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ -n "$stdout" ]]; then
|
||||
echo "FAIL: $desc — expected empty stdout (nothing emitted), got '$stdout'" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$stdout$stderr" == *"shared-mosaicstack-token"* || "$stdout$stderr" == *"shared-usc-token"* ]]; then
|
||||
echo "FAIL: $desc — a SHARED token value appeared in the output. The shared-account fallback must be gone:" >&2
|
||||
echo "$stdout$stderr" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ -n "$want_in_stderr" && "$stderr" != *"$want_in_stderr"* ]]; then
|
||||
echo "FAIL: $desc — stderr does not contain '$want_in_stderr':" >&2
|
||||
echo "$stderr" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$stderr" != *"$host"* ]]; then
|
||||
echo "FAIL: $desc — stderr does not name the host '$host':" >&2
|
||||
echo "$stderr" >&2
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. No identity resolvable anywhere, no per-slot token -> shared fallback
|
||||
# (backward-compat: unchanged behavior when nothing is configured).
|
||||
# 1. Backward compatibility: nothing resolvable, and NO fleet on this host ->
|
||||
# shared account, unchanged. This is the only surviving fallback.
|
||||
# ---------------------------------------------------------------------------
|
||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||
out=$(run_helper "git.mosaicstack.dev" "")
|
||||
assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||
assert_eq "shared fallback: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||
assert_eq "no identity + no fleet: username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||
assert_eq "no identity + no fleet: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. git-supplied username resolves to an identity WITH a per-slot token ->
|
||||
# that identity + token wins over the shared account.
|
||||
# 2. git-supplied username resolves to a SERVICE identity WITH a token in the
|
||||
# framework store -> that identity + token wins over the shared account.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
|
||||
echo -n "agentA-mosaicstack-token" > "$SVC_STORE/gitea-mosaicstack-agentA.token"
|
||||
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
||||
assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')"
|
||||
assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||
@@ -97,7 +152,7 @@ assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-to
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo -n "agentB-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentB.token"
|
||||
echo -n "agentB-mosaicstack-token" > "$SVC_STORE/gitea-mosaicstack-agentB.token"
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity agentB
|
||||
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
||||
assert_eq "git-config beats username: username" "username=agentB" "$(echo "$out" | grep '^username=')"
|
||||
@@ -106,54 +161,210 @@ assert_eq "git-config beats username: password" "password=agentB-mosaicstack-tok
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo -n "agentC-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentC.token"
|
||||
echo -n "agentC-mosaicstack-token" > "$SVC_STORE/gitea-mosaicstack-agentC.token"
|
||||
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_GIT_IDENTITY=agentC)
|
||||
assert_eq "env beats git-config: username" "username=agentC" "$(echo "$out" | grep '^username=')"
|
||||
assert_eq "env beats git-config: password" "password=agentC-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Identity resolves, but no matching per-slot token file -> falls back to
|
||||
# the shared account (per-agent identity is opt-in, not a hard requirement).
|
||||
# 5. Correct token PATH is chosen per host: same agent id, different host
|
||||
# prefix (gitea-usc- vs gitea-mosaicstack-).
|
||||
# ---------------------------------------------------------------------------
|
||||
out=$(run_helper "git.mosaicstack.dev" "no-such-agent")
|
||||
assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||
assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Correct per-slot token PATH is chosen per host: same agent id, different
|
||||
# host prefix (gitea-usc- vs gitea-mosaicstack-).
|
||||
# ---------------------------------------------------------------------------
|
||||
echo -n "agentD-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentD.token"
|
||||
echo -n "agentD-usc-token" > "$SVC_STORE/gitea-usc-agentD.token"
|
||||
out=$(run_helper "git.uscllc.com" "agentD")
|
||||
assert_eq "host-scoped token path (usc): username" "username=agentD" "$(echo "$out" | grep '^username=')"
|
||||
assert_eq "host-scoped token path (usc): password" "password=agentD-usc-token" "$(echo "$out" | grep '^password=')"
|
||||
# agentD has NO mosaicstack token -> must fall back to shared mosaicstack, not
|
||||
# leak the usc token across hosts.
|
||||
out=$(run_helper "git.mosaicstack.dev" "agentD")
|
||||
assert_eq "host-scoped token path (cross-host must not leak): username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||
assert_eq "host-scoped token path (cross-host must not leak): password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
|
||||
# remotes, e.g. github.com via a different credential helper).
|
||||
# 6. FAIL CLOSED — identity resolves, no credential for it on this host. Must
|
||||
# NOT borrow the shared account, and must NOT leak the same agent's token
|
||||
# for a DIFFERENT host (agentD holds a usc token and no mosaicstack one).
|
||||
# ---------------------------------------------------------------------------
|
||||
assert_fail_closed "cross-host absence refuses (no shared fallback, no cross-host leak)" \
|
||||
"git.mosaicstack.dev" "agentD" "gitea-mosaicstack-agentD.token"
|
||||
# The agent's own usc token must not appear either.
|
||||
: > "$WORK_DIR/stderr.tmp"
|
||||
set +e
|
||||
leak_out=$(run_helper "git.mosaicstack.dev" "agentD" 2>"$WORK_DIR/stderr.tmp")
|
||||
set -e
|
||||
if [[ "$leak_out$(cat "$WORK_DIR/stderr.tmp")" == *"agentD-usc-token"* ]]; then
|
||||
echo "FAIL: cross-host leak — the usc token value appeared on a mosaicstack request" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
assert_fail_closed "unknown identity refuses (shared account never substituted)" \
|
||||
"git.mosaicstack.dev" "no-such-agent" "no-token-for-identity"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. A refusal leaves a durable spool record, and that record contains no token.
|
||||
# The stderr diagnostic is transient; the record is what an operator reads
|
||||
# afterwards, so it must exist independently of anyone watching the terminal.
|
||||
# ---------------------------------------------------------------------------
|
||||
spool_file=$(find "$SPOOL_DIR" -maxdepth 1 -name '*.jsonl' | head -n 1)
|
||||
if [[ -z "$spool_file" ]]; then
|
||||
echo "FAIL: fail-closed left no spool record under $SPOOL_DIR" >&2
|
||||
fail=1
|
||||
else
|
||||
spool_body=$(cat "$spool_file")
|
||||
assert_eq "spool record names the refused identity" "1" \
|
||||
"$(grep -c '"identity":"no-such-agent"' "$spool_file" | head -n 1)"
|
||||
if [[ "$spool_body" == *"shared-"*"-token"* || "$spool_body" == *"agentD-usc-token"* ]]; then
|
||||
echo "FAIL: spool record contains a token value:" >&2
|
||||
echo "$spool_body" >&2
|
||||
fail=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. SEAT identity: an id with a directory under <brain>/fleet/agents/ is read
|
||||
# from its OWN secrets/ slot, not from the framework store.
|
||||
# ---------------------------------------------------------------------------
|
||||
mkdir -p "$BRAIN_DIR/fleet/agents/seatE/secrets"
|
||||
echo -n "seatE-slot-token" > "$BRAIN_DIR/fleet/agents/seatE/secrets/gitea-mosaicstack-seatE.token"
|
||||
out=$(run_helper "git.mosaicstack.dev" "seatE" MOSAIC_BRAIN_HOME="$BRAIN_DIR")
|
||||
assert_eq "seat reads its own slot: username" "username=seatE" "$(echo "$out" | grep '^username=')"
|
||||
assert_eq "seat reads its own slot: password" "password=seatE-slot-token" "$(echo "$out" | grep '^password=')"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. NO CROSS-STORE FALLBACK — the assertion this whole store-selection design
|
||||
# exists for. seatF is a seat (it has a directory) with an EMPTY slot, while
|
||||
# a framework-store token of the identical name is present and readable.
|
||||
# The helper must refuse rather than read it: one credential, one location,
|
||||
# and a seat that reads a same-named service credential is exactly the
|
||||
# silent-substitution failure the fail-closed rule removes.
|
||||
# ---------------------------------------------------------------------------
|
||||
mkdir -p "$BRAIN_DIR/fleet/agents/seatF/secrets"
|
||||
echo -n "seatF-SERVICE-STORE-token" > "$SVC_STORE/gitea-mosaicstack-seatF.token"
|
||||
assert_fail_closed "seat with empty slot does NOT fall back to the framework store" \
|
||||
"git.mosaicstack.dev" "seatF" "fleet/agents/seatF/secrets" MOSAIC_BRAIN_HOME="$BRAIN_DIR"
|
||||
: > "$WORK_DIR/stderr.tmp"
|
||||
set +e
|
||||
xstore_out=$(run_helper "git.mosaicstack.dev" "seatF" MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr.tmp")
|
||||
set -e
|
||||
if [[ "$xstore_out$(cat "$WORK_DIR/stderr.tmp")" == *"seatF-SERVICE-STORE-token"* ]]; then
|
||||
echo "FAIL: cross-store fallback — a seat read the framework store's same-named token" >&2
|
||||
fail=1
|
||||
fi
|
||||
# Control: that framework-store token IS readable, so the refusal above is the
|
||||
# store rule firing and not an unreadable file. A non-seat identity pointed at
|
||||
# the same file gets it.
|
||||
out=$(run_helper "git.mosaicstack.dev" "seatF" MOSAIC_BRAIN_HOME="$WORK_DIR/no-such-brain")
|
||||
assert_eq "control — same file IS readable for a non-seat identity" \
|
||||
"password=seatF-SERVICE-STORE-token" "$(echo "$out" | grep '^password=')"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. FAIL CLOSED — no identity resolvable, but this host runs a fleet. Where
|
||||
# seats exist, an unattributable request is refused instead of receiving
|
||||
# the shared account. Contrast with case 1, which is the same request on a
|
||||
# host with no fleet and still returns the shared account.
|
||||
# ---------------------------------------------------------------------------
|
||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||
assert_fail_closed "no identity on a fleet host refuses" \
|
||||
"git.mosaicstack.dev" "" "no-identity" MOSAIC_BRAIN_HOME="$BRAIN_DIR"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. The brain home defaults to ~/.mosaic when MOSAIC_BRAIN_HOME is unset —
|
||||
# the fleet gate must fire on the default path too, not only on an
|
||||
# explicitly injected one. Case 1 ran before this directory existed; the
|
||||
# same call now refuses, which also proves case 1 was measuring the
|
||||
# no-fleet branch rather than passing for an unrelated reason.
|
||||
# ---------------------------------------------------------------------------
|
||||
mkdir -p "$FAKE_HOME/.mosaic/fleet/agents"
|
||||
assert_fail_closed "fleet gate fires on the default ~/.mosaic brain home" \
|
||||
"git.mosaicstack.dev" "" "no-identity"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 12. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
|
||||
# remotes, e.g. github.com via a different credential helper). A fleet host
|
||||
# must not refuse a host this helper does not own.
|
||||
# ---------------------------------------------------------------------------
|
||||
out=$(run_helper "github.com" "agentA")
|
||||
assert_eq "unknown host: no output" "" "$out"
|
||||
out=$(run_helper "github.com" "" MOSAIC_BRAIN_HOME="$BRAIN_DIR")
|
||||
assert_eq "unknown host on a fleet host: still passthrough, not a refusal" "" "$out"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
|
||||
# protocol: this helper only implements get).
|
||||
# 13. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
|
||||
# protocol: this helper only implements get).
|
||||
# ---------------------------------------------------------------------------
|
||||
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF
|
||||
host=git.mosaicstack.dev
|
||||
username=agentA
|
||||
username=no-such-agent
|
||||
password=whatever
|
||||
|
||||
EOF
|
||||
)
|
||||
assert_eq "store verb: no output" "" "$store_out"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 14. The escalation record is machine-readable even when a field carries a
|
||||
# quote or a backslash. A cwd is arbitrary operator text; an unescaped one
|
||||
# silently turns the spool into unparseable JSONL, and the operator only
|
||||
# finds out while reading the record that explains an outage.
|
||||
# ---------------------------------------------------------------------------
|
||||
hostile_dir="$WORK_DIR/we\"ird\\dir"
|
||||
mkdir -p "$hostile_dir"
|
||||
hostile_spool="$WORK_DIR/spool-hostile"
|
||||
(
|
||||
cd "$hostile_dir"
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$hostile_spool" \
|
||||
MOSAIC_GIT_IDENTITY=no-such-agent \
|
||||
bash "$HELPER" get <<EOF >/dev/null 2>&1
|
||||
host=git.mosaicstack.dev
|
||||
username=no-such-agent
|
||||
|
||||
EOF
|
||||
) || true
|
||||
# Deliberately not `ls ... | head -1`: under `set -o pipefail` a missed glob
|
||||
# makes ls exit 2, the pipeline inherits it, and `set -e` kills this suite with
|
||||
# zero output — the same silent-nonzero failure this file exists to catch.
|
||||
record_file=""
|
||||
for candidate in "$hostile_spool"/*.jsonl; do
|
||||
if [[ -e "$candidate" ]]; then
|
||||
record_file="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -z "$record_file" ]]; then
|
||||
echo "FAIL: hostile cwd — no escalation record was written at all" >&2
|
||||
fail=1
|
||||
elif ! python3 -c 'import json,sys
|
||||
for line in open(sys.argv[1]):
|
||||
line = line.strip()
|
||||
if line:
|
||||
json.loads(line)' "$record_file" 2>/dev/null; then
|
||||
echo "FAIL: hostile cwd — escalation record is not parseable JSONL:" >&2
|
||||
cat "$record_file" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 15. When the spool cannot be created, the diagnostic must NOT name a record
|
||||
# path. Naming a file that was never written sends the operator to an
|
||||
# empty path on exactly the hosts where the escalation was lost.
|
||||
# ---------------------------------------------------------------------------
|
||||
unwritable_spool="/proc/mosaic-credential-spool-cannot-exist"
|
||||
nospool_err=$(
|
||||
cd "$REPO_DIR"
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$unwritable_spool" \
|
||||
MOSAIC_GIT_IDENTITY=no-such-agent \
|
||||
bash "$HELPER" get <<EOF 2>&1 >/dev/null
|
||||
host=git.mosaicstack.dev
|
||||
username=no-such-agent
|
||||
|
||||
EOF
|
||||
) || true
|
||||
if [[ "$nospool_err" == *"record: $unwritable_spool/"* ]]; then
|
||||
echo "FAIL: unwritable spool — diagnostic names a record file that was never written" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$nospool_err" != *"NOT WRITTEN"* ]]; then
|
||||
echo "FAIL: unwritable spool — diagnostic does not say the record was not written" >&2
|
||||
echo "$nospool_err" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "git-credential-mosaic identity resolution regression passed"
|
||||
fi
|
||||
|
||||
@@ -23,6 +23,17 @@
|
||||
# 6. Scope containment: identity requested + an UNRECOGNIZED Gitea host (no
|
||||
# per-slot token scheme) -> Patch 2b does not apply; existing
|
||||
# fall-through behavior is unchanged.
|
||||
# 7. Credential store selection: an identity with a directory under
|
||||
# <brain>/fleet/agents/ is a SEAT and is read ONLY from its own secrets/
|
||||
# slot; any other identity is a SERVICE and is read from the framework
|
||||
# store. No precedence between them and NO fallback from one to the
|
||||
# other — a seat with an empty slot is REFUSED even when a same-named
|
||||
# token sits in the framework store.
|
||||
# 8. Fail loud when NO identity resolves on a host that runs a fleet: where
|
||||
# seats exist, an unattributable API call is refused rather than made
|
||||
# under the shared account. On a host with no fleet the same call still
|
||||
# returns the shared token (case 1), which is what keeps this change a
|
||||
# no-op for non-fleet operators of the framework.
|
||||
#
|
||||
# Uses a stubbed credentials.json + stubbed per-slot token files under a fake
|
||||
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
||||
@@ -210,6 +221,94 @@ if [[ "$err" == *"no per-slot token at"* ]]; then
|
||||
fi
|
||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. SEAT identity: an id with a directory under <brain>/fleet/agents/ is read
|
||||
# from its OWN secrets/ slot, not from the framework store. The brain home
|
||||
# is resolved exactly as packages/mosaic/src/fleet/brain-home.ts does it:
|
||||
# MOSAIC_BRAIN_HOME, else ~/.mosaic.
|
||||
# ---------------------------------------------------------------------------
|
||||
BRAIN_DIR="$WORK_DIR/brain"
|
||||
mkdir -p "$BRAIN_DIR/fleet/agents/seatE/secrets"
|
||||
echo -n "seatE-slot-token" > "$BRAIN_DIR/fleet/agents/seatE/secrets/gitea-mosaicstack-seatE.token"
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatE MOSAIC_BRAIN_HOME="$BRAIN_DIR")
|
||||
assert_eq "seat reads its own slot" "seatE-slot-token" "$out"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. NO CROSS-STORE FALLBACK. seatF is a seat (it has a directory) with an
|
||||
# EMPTY slot, while a framework-store token of the identical name is
|
||||
# present and readable. It must be REFUSED rather than served that token:
|
||||
# one credential, one location. A seat that silently reads a same-named
|
||||
# service credential is the same substitution failure as the shared-account
|
||||
# fallback, one store further down.
|
||||
# ---------------------------------------------------------------------------
|
||||
mkdir -p "$BRAIN_DIR/fleet/agents/seatF/secrets"
|
||||
echo -n "seatF-SERVICE-STORE-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-seatF.token"
|
||||
assert_failloud "seat with empty slot does NOT fall back to the framework store" \
|
||||
"git.mosaicstack.dev" "seatF" \
|
||||
"$BRAIN_DIR/fleet/agents/seatF/secrets/gitea-mosaicstack-seatF.token" \
|
||||
MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$BRAIN_DIR"
|
||||
# assert_failloud only screens stderr for the word "shared"; this store's token
|
||||
# is not named that, so check for its value explicitly.
|
||||
set +e
|
||||
xstore_out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr-xstore.tmp")
|
||||
set -e
|
||||
if [[ "$xstore_out$(cat "$WORK_DIR/stderr-xstore.tmp")" == *"seatF-SERVICE-STORE-token"* ]]; then
|
||||
echo "FAIL: cross-store fallback — a seat was served the framework store's same-named token" >&2
|
||||
fail=1
|
||||
fi
|
||||
# Control: that framework-store token IS readable, so the refusal above is the
|
||||
# store rule firing and not an unreadable file. The same id, resolved against a
|
||||
# brain home where it is not a seat, gets it.
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$WORK_DIR/no-such-brain")
|
||||
assert_eq "control — same file IS readable for a non-seat identity" "seatF-SERVICE-STORE-token" "$out"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. FAIL LOUD — no identity resolvable, but this host runs a fleet. Contrast
|
||||
# with case 1: the identical call on a host with no fleet still returns the
|
||||
# shared token.
|
||||
# ---------------------------------------------------------------------------
|
||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||
set +e
|
||||
noid_out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr-noid.tmp")
|
||||
noid_rc=$?
|
||||
set -e
|
||||
noid_err=$(cat "$WORK_DIR/stderr-noid.tmp")
|
||||
if [[ "$noid_rc" -eq 0 ]]; then
|
||||
echo "FAIL: no identity on a fleet host — expected nonzero return, got 0 (stdout='$noid_out')" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ -n "$noid_out" ]]; then
|
||||
echo "FAIL: no identity on a fleet host — expected empty stdout, got '$noid_out'" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$noid_out" == *"shared-mosaicstack-token"* || "$noid_err" == *"shared-mosaicstack-token"* ]]; then
|
||||
echo "FAIL: no identity on a fleet host — the shared token was served anyway:" >&2
|
||||
echo "$noid_out$noid_err" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$noid_err" != *"MOSAIC_GIT_IDENTITY"* ]]; then
|
||||
echo "FAIL: no identity on a fleet host — stderr does not say how to set an identity:" >&2
|
||||
echo "$noid_err" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. The brain home defaults to ~/.mosaic when MOSAIC_BRAIN_HOME is unset —
|
||||
# the fleet gate must fire on the default path too, not only on an
|
||||
# explicitly injected one. Case 1 ran before this directory existed; the
|
||||
# same call now refuses, which also proves case 1 was measuring the
|
||||
# no-fleet branch rather than passing for an unrelated reason.
|
||||
# ---------------------------------------------------------------------------
|
||||
mkdir -p "$FAKE_HOME/.mosaic/fleet/agents"
|
||||
set +e
|
||||
dflt_out=$(call_get_gitea_token "git.mosaicstack.dev" 2>"$WORK_DIR/stderr-dflt.tmp")
|
||||
dflt_rc=$?
|
||||
set -e
|
||||
if [[ "$dflt_rc" -eq 0 || -n "$dflt_out" ]]; then
|
||||
echo "FAIL: fleet gate did not fire on the default ~/.mosaic brain home (rc=$dflt_rc stdout='$dflt_out')" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "get_gitea_token identity resolution regression passed"
|
||||
fi
|
||||
|
||||
@@ -51,22 +51,23 @@ for arg in "$@"; do
|
||||
prev=""
|
||||
continue
|
||||
fi
|
||||
if [[ "$prev" == "-d" ]]; then
|
||||
if [[ "$prev" == "data" ]]; then
|
||||
post_data="$arg"
|
||||
[[ "$post_data" == @* ]] && post_data=$(<"${post_data#@}")
|
||||
prev=""
|
||||
continue
|
||||
fi
|
||||
if [[ "$arg" == "-o" ]]; then
|
||||
prev="-o"
|
||||
if [[ "$prev" == "config" ]]; then
|
||||
[[ "$arg" == "-" ]] && cat >/dev/null
|
||||
prev=""
|
||||
continue
|
||||
fi
|
||||
if [[ "$arg" == "-d" ]]; then
|
||||
prev="-d"
|
||||
continue
|
||||
fi
|
||||
if [[ "$arg" == "-w" ]]; then
|
||||
write_code=true
|
||||
fi
|
||||
case "$arg" in
|
||||
-o) prev="-o" ;;
|
||||
-d|--data|--data-binary) prev="data" ;;
|
||||
-K|--config) prev="config" ;;
|
||||
-w) write_code=true ;;
|
||||
esac
|
||||
done
|
||||
emit_response() {
|
||||
local body="$1"
|
||||
|
||||
@@ -17,9 +17,10 @@ make_fixture() {
|
||||
cp "$SCRIPT_DIR/detect-platform.sh" "$tools/detect-platform.sh"
|
||||
git -C "$root/repo" init -q
|
||||
git -C "$root/repo" remote add origin "$remote"
|
||||
local base_branch="${3:-main}"
|
||||
cat > "$tools/pr-metadata.sh" <<SH
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
|
||||
printf '%s\n' '{"baseRefName":"$base_branch","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
|
||||
SH
|
||||
cat > "$tools/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
@@ -29,20 +30,37 @@ SH
|
||||
}
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
make_fixture gitea https://git.example.test/acme/widgets.git
|
||||
make_fixture github https://github.com/acme/widgets.git
|
||||
make_fixture gitea https://git.example.test/acme/widgets.git next
|
||||
make_fixture github https://github.com/acme/widgets.git main
|
||||
|
||||
cat > "$WORK_DIR/gitea/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
payload=""
|
||||
for ((i=1; i<=$#; i++)); do
|
||||
if [[ "${!i}" == "-d" ]]; then
|
||||
j=$((i + 1))
|
||||
payload="${!j}"
|
||||
fi
|
||||
out_file=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-d|--data|--data-binary)
|
||||
payload="$2"
|
||||
[[ "$payload" == @* ]] && payload=$(<"${payload#@}")
|
||||
shift 2
|
||||
;;
|
||||
-o)
|
||||
out_file="$2"
|
||||
shift 2
|
||||
;;
|
||||
-K|--config)
|
||||
[[ "$2" == "-" ]] && cat >/dev/null
|
||||
shift 2
|
||||
;;
|
||||
-w|-X|-H)
|
||||
shift 2
|
||||
;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
|
||||
[[ -n "$out_file" ]] && printf '{}' > "$out_file"
|
||||
printf '200'
|
||||
SH
|
||||
chmod +x "$WORK_DIR/gitea/curl"
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for the optional, identity-checked Gitea squash message.
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SUBJECT="${MOSAIC_TEST_SUBJECT:-$SCRIPT_DIR/pr-merge.sh}"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-message-field}"
|
||||
ORIG_PATH="$PATH"
|
||||
failures=0
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$WORK_DIR"
|
||||
|
||||
fail() {
|
||||
echo "FAIL $1" >&2
|
||||
failures=$((failures + 1))
|
||||
}
|
||||
|
||||
make_case() {
|
||||
local name="$1" case_dir
|
||||
case_dir="$WORK_DIR/$name"
|
||||
mkdir -p "$case_dir/bin" "$case_dir/agent"
|
||||
cp "$SUBJECT" "$case_dir/pr-merge.sh"
|
||||
chmod +x "$case_dir/pr-merge.sh"
|
||||
|
||||
cat > "$case_dir/detect-platform.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
detect_platform() { PLATFORM=gitea; printf 'gitea\n'; }
|
||||
get_repo_owner() { printf 'acme\n'; }
|
||||
get_repo_name() { printf 'widgets\n'; }
|
||||
get_remote_host() { printf 'git.example.test\n'; }
|
||||
get_gitea_token() {
|
||||
printf 'resolved\n' >> "${MOSAIC_TEST_TOKEN_RESOLUTION_LOG:?}"
|
||||
if [[ "${MOSAIC_TEST_TOKEN_AVAILABLE:-true}" != "true" ]]; then
|
||||
return 1
|
||||
fi
|
||||
printf 'fixture-token\n'
|
||||
}
|
||||
get_gitea_basic_auth() {
|
||||
printf 'resolved\n' >> "${MOSAIC_TEST_BASIC_RESOLUTION_LOG:?}"
|
||||
if [[ "${MOSAIC_TEST_BASIC_AVAILABLE:-false}" == "true" ]]; then
|
||||
printf 'fixture-user:fixture-password\n'
|
||||
return "${MOSAIC_TEST_BASIC_RC:-0}"
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
get_gitea_login_for_host() { return 1; }
|
||||
SH
|
||||
|
||||
cat > "$case_dir/pr-metadata.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
if [[ "${MOSAIC_TEST_TITLE_MODE:-safe}" == "injection" ]]; then
|
||||
title='Preserve authors\n\nCo-authored-by: victim <[email protected]>'
|
||||
else
|
||||
title='Preserve both branch authors'
|
||||
fi
|
||||
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
|
||||
verified) head_sha=2222222222222222222222222222222222222222 ;;
|
||||
null-login|unsafe-identity) head_sha=3333333333333333333333333333333333333333 ;;
|
||||
single) head_sha=1111111111111111111111111111111111111111 ;;
|
||||
*) echo "unknown commits mode" >&2; exit 2 ;;
|
||||
esac
|
||||
printf '{"number":42,"title":"%s","author":"poster","baseRefName":"main","headRefName":"feature/fixture","headRefOid":"%s","headRepository":"acme/widgets"}\n' "$title" "$head_sha"
|
||||
SH
|
||||
|
||||
cat > "$case_dir/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
exit 0
|
||||
SH
|
||||
|
||||
cat > "$case_dir/bin/python3" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
*"Preserve both branch authors"*|*"[email protected]"*)
|
||||
: > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
exec "${MOSAIC_TEST_REAL_PYTHON:?}" "$@"
|
||||
SH
|
||||
|
||||
cat > "$case_dir/bin/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -eu
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
*"Preserve both branch authors"*|*"[email protected]"*)
|
||||
: > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
url=""
|
||||
method="GET"
|
||||
out_file=""
|
||||
data=""
|
||||
config=""
|
||||
auth_mode="none"
|
||||
has_max_filesize=0
|
||||
has_max_time=0
|
||||
has_connect_timeout=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-o)
|
||||
out_file="$2"
|
||||
shift 2
|
||||
;;
|
||||
-w)
|
||||
shift 2
|
||||
;;
|
||||
-X)
|
||||
method="$2"
|
||||
shift 2
|
||||
;;
|
||||
-d|--data|--data-binary)
|
||||
data="$2"
|
||||
if [[ "$data" == @* ]]; then
|
||||
data=$(<"${data#@}")
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
-K|--config)
|
||||
if [[ "$2" == "-" ]]; then
|
||||
config=$(cat)
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
--max-filesize)
|
||||
has_max_filesize=1
|
||||
shift 2
|
||||
;;
|
||||
--max-time)
|
||||
has_max_time=1
|
||||
shift 2
|
||||
;;
|
||||
--connect-timeout)
|
||||
has_connect_timeout=1
|
||||
shift 2
|
||||
;;
|
||||
-H|--header|-u|--user)
|
||||
if [[ "$2" == *"fixture-token"* ]]; then
|
||||
: > "${MOSAIC_TEST_TOKEN_ARGV_MARKER:?}"
|
||||
fi
|
||||
if [[ "$2" == *"fixture-password"* ]]; then
|
||||
: > "${MOSAIC_TEST_BASIC_ARGV_MARKER:?}"
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
http://*|https://*)
|
||||
url="$1"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$config" == *"Authorization: token fixture-token"* ]]; then
|
||||
auth_mode="token"
|
||||
: > "${MOSAIC_TEST_AUTH_CONFIG_MARKER:?}"
|
||||
elif [[ "$config" == *"user = \"fixture-user:fixture-password\""* ]]; then
|
||||
auth_mode="basic"
|
||||
: > "${MOSAIC_TEST_BASIC_CONFIG_MARKER:?}"
|
||||
fi
|
||||
printf '%s %s %s\n' "$method" "$auth_mode" "$url" >> "${MOSAIC_TEST_CURL_LOG:?}"
|
||||
printf '%s:%s:%s\n' "$has_max_filesize" "$has_max_time" "$has_connect_timeout" >> "${MOSAIC_TEST_CURL_BOUNDS_LOG:?}"
|
||||
|
||||
case "$url" in
|
||||
*/pulls/42)
|
||||
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
|
||||
verified) head_sha=2222222222222222222222222222222222222222 ;;
|
||||
null-login|unsafe-identity) head_sha=3333333333333333333333333333333333333333 ;;
|
||||
single) head_sha=1111111111111111111111111111111111111111 ;;
|
||||
*) echo "unknown commits mode" >&2; exit 2 ;;
|
||||
esac
|
||||
if [[ "${MOSAIC_TEST_HEAD_MODE:-stable}" == "moved" ]]; then
|
||||
head_sha=4444444444444444444444444444444444444444
|
||||
fi
|
||||
body="{\"head\":{\"sha\":\"$head_sha\"}}"
|
||||
code=200
|
||||
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then
|
||||
body='{"message":"token rejected"}'
|
||||
code=401
|
||||
fi
|
||||
;;
|
||||
*/pulls/42/commits*)
|
||||
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
|
||||
verified)
|
||||
if [[ "${MOSAIC_TEST_EMAIL_MODE:-safe}" == "escape" ]]; then
|
||||
body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"alice+\u001b[[email protected]"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||
else
|
||||
body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"[email protected]"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||
fi
|
||||
;;
|
||||
null-login)
|
||||
body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Unresolved Author","email":"[email protected]\n\u001b[31m"}},"author":null}]'
|
||||
;;
|
||||
unsafe-identity)
|
||||
body='[{"sha":"unsafe\n\u001b[31m","commit":{"author":{"name":"Unsafe","email":"not-an-email"}},"author":{"login":"unsafe"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||
;;
|
||||
single)
|
||||
body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||
;;
|
||||
*)
|
||||
echo "unknown commits mode" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
code=200
|
||||
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then
|
||||
body='{"message":"token rejected"}'
|
||||
code=401
|
||||
fi
|
||||
;;
|
||||
*/pulls/42/merge)
|
||||
body='{}'
|
||||
code=200
|
||||
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "merge" && "$auth_mode" == "token" ]]; then
|
||||
body='{"message":"token rejected"}'
|
||||
code=401
|
||||
elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "provider-error" ]]; then
|
||||
body='{"message":"branch policy rejected\n\u001b[31m"}'
|
||||
code=409
|
||||
elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "forbidden" ]]; then
|
||||
body='{"message":"permission denied"}'
|
||||
code=403
|
||||
else
|
||||
printf '%s' "$data" > "${MOSAIC_TEST_MERGE_PAYLOAD:?}"
|
||||
fi
|
||||
;;
|
||||
*/users/*)
|
||||
body='{"message":"not found"}'
|
||||
code=404
|
||||
;;
|
||||
*)
|
||||
body='{"message":"unexpected URL"}'
|
||||
code=500
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -n "$out_file" ]]; then
|
||||
printf '%s' "$body" > "$out_file"
|
||||
else
|
||||
printf '%s' "$body"
|
||||
fi
|
||||
printf '%s' "$code"
|
||||
case "${MOSAIC_TEST_CURL_FAILURE:-none}" in
|
||||
oversize) exit 63 ;;
|
||||
stalled) exit 28 ;;
|
||||
esac
|
||||
SH
|
||||
|
||||
chmod +x "$case_dir/detect-platform.sh" "$case_dir/pr-metadata.sh" \
|
||||
"$case_dir/ci-queue-wait.sh" "$case_dir/bin/curl" "$case_dir/bin/python3"
|
||||
printf '%s\n' "$case_dir"
|
||||
}
|
||||
|
||||
run_case() {
|
||||
local case_dir="$1" mode="$2"
|
||||
shift 2
|
||||
MOSAIC_TEST_COMMITS_MODE="$mode" \
|
||||
MOSAIC_TEST_CURL_LOG="$case_dir/curl.log" \
|
||||
MOSAIC_TEST_CURL_BOUNDS_LOG="$case_dir/curl-bounds.log" \
|
||||
MOSAIC_TEST_MERGE_PAYLOAD="$case_dir/merge-payload.json" \
|
||||
MOSAIC_TEST_TOKEN_ARGV_MARKER="$case_dir/token-in-argv" \
|
||||
MOSAIC_TEST_BASIC_ARGV_MARKER="$case_dir/basic-in-argv" \
|
||||
MOSAIC_TEST_AUTH_CONFIG_MARKER="$case_dir/auth-via-config" \
|
||||
MOSAIC_TEST_BASIC_CONFIG_MARKER="$case_dir/basic-via-config" \
|
||||
MOSAIC_TEST_TOKEN_RESOLUTION_LOG="$case_dir/token-resolution.log" \
|
||||
MOSAIC_TEST_BASIC_RESOLUTION_LOG="$case_dir/basic-resolution.log" \
|
||||
MOSAIC_TEST_METADATA_ARGV_MARKER="$case_dir/metadata-in-argv" \
|
||||
MOSAIC_TEST_REAL_PYTHON="$(command -v python3)" \
|
||||
AGENT_WORK_ROOT="$case_dir/agent" \
|
||||
PATH="$case_dir/bin:$ORIG_PATH" \
|
||||
"$case_dir/pr-merge.sh" -n 42 "$@"
|
||||
}
|
||||
|
||||
# Verified multi-author path: the non-poster trailer is built from one commit's
|
||||
# linked author.login and that same commit's author email. No /users lookup.
|
||||
verified_dir=$(make_case verified)
|
||||
set +e
|
||||
verified_output=$(run_case "$verified_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
verified_rc=$?
|
||||
set -e
|
||||
if [[ "$verified_rc" -ne 0 ]]; then
|
||||
fail "verified multi-author merge expected rc=0, got rc=$verified_rc: $verified_output"
|
||||
elif [[ ! -s "$verified_dir/merge-payload.json" ]]; then
|
||||
fail "verified multi-author merge did not reach the API payload"
|
||||
else
|
||||
python3 - "$verified_dir/merge-payload.json" <<'PY' || fail "verified payload did not preserve squash and exact message fields"
|
||||
import json
|
||||
import sys
|
||||
payload = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
assert payload == {
|
||||
"Do": "squash",
|
||||
"head_commit_id": "2222222222222222222222222222222222222222",
|
||||
"MergeTitleField": "Preserve both branch authors",
|
||||
"MergeMessageField": "Co-authored-by: alice <[email protected]>",
|
||||
}, payload
|
||||
PY
|
||||
fi
|
||||
[[ -e "$verified_dir/auth-via-config" ]] || fail "verified path did not authenticate curl through stdin config"
|
||||
[[ ! -e "$verified_dir/token-in-argv" ]] || fail "verified path placed the Gitea token in curl argv"
|
||||
[[ ! -e "$verified_dir/metadata-in-argv" ]] || fail "verified path placed PR title or contributor email in child argv"
|
||||
[[ "$(wc -l < "$verified_dir/token-resolution.log")" -eq 1 ]] || fail "verified path did not bind inspection and merge to one credential resolution"
|
||||
if grep -q '/users/' "$verified_dir/curl.log" 2>/dev/null; then
|
||||
fail "verified path performed a forbidden second /users lookup"
|
||||
fi
|
||||
if grep -qv '^1:1:1$' "$verified_dir/curl-bounds.log"; then
|
||||
fail "verified path did not apply size/max-time/connect-time bounds to every provider download"
|
||||
fi
|
||||
|
||||
# A linked email containing a terminal escape must block before mutation.
|
||||
escape_email_dir=$(make_case escape-email)
|
||||
set +e
|
||||
escape_email_output=$(MOSAIC_TEST_EMAIL_MODE=escape run_case "$escape_email_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
escape_email_rc=$?
|
||||
set -e
|
||||
[[ "$escape_email_rc" -ne 0 ]] || fail "control-byte email unexpectedly passed"
|
||||
[[ "$escape_email_output" == *"unusable linked identity"* ]] || fail "control-byte email refusal lost its diagnostic"
|
||||
[[ ! -e "$escape_email_dir/merge-payload.json" ]] || fail "control-byte email reached the merge API"
|
||||
|
||||
# Curl transfer and duration failures must remain failures even with HTTP 200.
|
||||
for failure_mode in oversize stalled; do
|
||||
failure_dir=$(make_case "curl-$failure_mode")
|
||||
set +e
|
||||
failure_output=$(MOSAIC_TEST_CURL_FAILURE="$failure_mode" run_case "$failure_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
failure_rc=$?
|
||||
set -e
|
||||
[[ "$failure_rc" -ne 0 ]] || fail "curl $failure_mode failure was discarded: $failure_output"
|
||||
[[ ! -e "$failure_dir/merge-payload.json" ]] || fail "curl $failure_mode failure reached the merge API"
|
||||
done
|
||||
|
||||
# The authenticated head is re-read under the mutation credential but cannot
|
||||
# replace the canonical preflight/review head. A move blocks before enumeration
|
||||
# or mutation even though the provider returned a valid new SHA.
|
||||
moved_dir=$(make_case moved-head)
|
||||
set +e
|
||||
moved_output=$(MOSAIC_TEST_HEAD_MODE=moved \
|
||||
run_case "$moved_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
moved_rc=$?
|
||||
set -e
|
||||
[[ "$moved_rc" -ne 0 ]] || fail "moved authenticated head unexpectedly passed"
|
||||
[[ "$moved_output" == *"authenticated PR head moved from reviewed"* ]] || fail "moved head refusal lost its diagnostic"
|
||||
[[ "$moved_output" == *"tl-mosaic"* ]] || fail "moved head refusal omitted the named escalation principal"
|
||||
[[ ! -e "$moved_dir/merge-payload.json" ]] || fail "moved head refusal reached the merge API"
|
||||
moved_sequence=$(awk '{print $1 ":" $2}' "$moved_dir/curl.log" | paste -sd, -)
|
||||
[[ "$moved_sequence" == "GET:token" ]] || fail "moved head refusal performed post-move inspection/mutation (calls=$moved_sequence)"
|
||||
|
||||
# Token resolution failure is not an authentication response. It must fail
|
||||
# closed instead of borrowing a Basic credential under a different principal.
|
||||
token_missing_dir=$(make_case token-missing)
|
||||
set +e
|
||||
token_missing_output=$(MOSAIC_TEST_TOKEN_AVAILABLE=false MOSAIC_TEST_BASIC_AVAILABLE=true \
|
||||
run_case "$token_missing_dir" single 2>&1)
|
||||
token_missing_rc=$?
|
||||
set -e
|
||||
[[ "$token_missing_rc" -ne 0 ]] || fail "missing token unexpectedly borrowed Basic Auth"
|
||||
[[ "$token_missing_output" == *"required Gitea token"* ]] || fail "missing token refusal lost its diagnostic"
|
||||
[[ ! -e "$token_missing_dir/basic-resolution.log" ]] || fail "missing token resolved Basic Auth after identity failure"
|
||||
[[ ! -e "$token_missing_dir/curl.log" ]] || fail "missing token reached a provider request"
|
||||
|
||||
# A failed Basic resolver must never use its nonempty output or reach mutation.
|
||||
basic_rc_dir=$(make_case basic-resolver-rc)
|
||||
set +e
|
||||
basic_rc_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_BASIC_RC=91 MOSAIC_TEST_FALLBACK_MODE=inspection \
|
||||
run_case "$basic_rc_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
basic_rc_rc=$?
|
||||
set -e
|
||||
[[ "$basic_rc_rc" -ne 0 ]] || fail "failed Basic resolver output unexpectedly authorized a merge: $basic_rc_output"
|
||||
[[ ! -e "$basic_rc_dir/merge-payload.json" ]] || fail "failed Basic resolver reached the merge API"
|
||||
|
||||
# HTTP 401 never changes principals: inspection rejection fails closed without
|
||||
# resolving or attempting Basic Auth.
|
||||
fallback_inspect_dir=$(make_case fallback-inspection)
|
||||
set +e
|
||||
fallback_inspect_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=inspection \
|
||||
run_case "$fallback_inspect_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
fallback_inspect_rc=$?
|
||||
set -e
|
||||
[[ "$fallback_inspect_rc" -ne 0 ]] || fail "inspection token rejection unexpectedly changed principals"
|
||||
[[ "$fallback_inspect_output" == *"refusing cross-principal credential fallback"* ]] || fail "inspection token rejection lost its refusal diagnostic"
|
||||
[[ ! -e "$fallback_inspect_dir/basic-resolution.log" ]] || fail "inspection token rejection resolved Basic Auth"
|
||||
[[ ! -e "$fallback_inspect_dir/merge-payload.json" ]] || fail "inspection token rejection reached merge mutation"
|
||||
inspect_sequence=$(awk '{print $1 ":" $2}' "$fallback_inspect_dir/curl.log" | paste -sd, -)
|
||||
[[ "$inspect_sequence" == "GET:token" ]] || fail "inspection rejection made unexpected provider calls (calls=$inspect_sequence)"
|
||||
|
||||
# Token rejection at merge likewise fails closed without cross-principal retry.
|
||||
fallback_merge_dir=$(make_case fallback-merge)
|
||||
set +e
|
||||
fallback_merge_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=merge \
|
||||
run_case "$fallback_merge_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
fallback_merge_rc=$?
|
||||
set -e
|
||||
[[ "$fallback_merge_rc" -ne 0 ]] || fail "merge token rejection unexpectedly changed principals"
|
||||
[[ "$fallback_merge_output" == *"refusing cross-principal credential fallback"* ]] || fail "merge token rejection lost its refusal diagnostic"
|
||||
[[ ! -e "$fallback_merge_dir/basic-resolution.log" ]] || fail "merge token rejection resolved Basic Auth"
|
||||
[[ ! -e "$fallback_merge_dir/merge-payload.json" ]] || fail "merge token rejection recorded a successful payload"
|
||||
merge_sequence=$(awk '{print $1 ":" $2}' "$fallback_merge_dir/curl.log" | paste -sd, -)
|
||||
[[ "$merge_sequence" == "GET:token,GET:token,POST:token" ]] || fail "merge rejection made unexpected provider calls (calls=$merge_sequence)"
|
||||
|
||||
# BLOCK path: a commit email exists but author.login is null. It must name both
|
||||
# facts, name the escalation principal, and never reach the merge endpoint.
|
||||
null_dir=$(make_case null-login)
|
||||
set +e
|
||||
null_output=$(run_case "$null_dir" null-login --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
null_rc=$?
|
||||
set -e
|
||||
[[ "$null_rc" -ne 0 ]] || fail "null-login author expected a non-zero BLOCK"
|
||||
[[ "$null_output" == *"BLOCK"* ]] || fail "null-login author omitted BLOCK diagnostic"
|
||||
[[ "$null_output" == *"author.login=NULL"* ]] || fail "null-login author omitted the null provider fact"
|
||||
[[ "$null_output" == *"[email protected]"* ]] || fail "null-login author omitted the commit email fact"
|
||||
[[ "$null_output" == *'\n\x1b[31m'* ]] || fail "null-login author diagnostic did not escape control characters"
|
||||
[[ "$null_output" != *$'\033'* ]] || fail "null-login author diagnostic emitted a raw terminal escape"
|
||||
[[ "$(printf '%s\n' "$null_output" | wc -l)" -eq 1 ]] || fail "null-login author diagnostic permitted newline injection"
|
||||
[[ "$null_output" == *"tl-mosaic"* ]] || fail "null-login author omitted the named escalation principal"
|
||||
[[ ! -e "$null_dir/merge-payload.json" ]] || fail "null-login BLOCK still reached the merge API"
|
||||
|
||||
# Every provider-derived field in alternate BLOCK diagnostics is log-safe too,
|
||||
# including an invalid non-head SHA that contains control characters.
|
||||
unsafe_dir=$(make_case unsafe-identity)
|
||||
set +e
|
||||
unsafe_output=$(run_case "$unsafe_dir" unsafe-identity --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
unsafe_rc=$?
|
||||
set -e
|
||||
[[ "$unsafe_rc" -ne 0 ]] || fail "unsafe identity expected a non-zero BLOCK"
|
||||
[[ "$unsafe_output" == *"unusable linked identity"* ]] || fail "unsafe identity omitted its BLOCK reason"
|
||||
[[ "$unsafe_output" == *'\n\x1b[31m'* ]] || fail "unsafe identity SHA did not escape control characters"
|
||||
[[ "$unsafe_output" != *$'\033'* ]] || fail "unsafe identity diagnostic emitted a raw terminal escape"
|
||||
[[ "$(printf '%s\n' "$unsafe_output" | wc -l)" -eq 1 ]] || fail "unsafe identity diagnostic permitted newline injection"
|
||||
[[ ! -e "$unsafe_dir/merge-payload.json" ]] || fail "unsafe identity BLOCK still reached the merge API"
|
||||
|
||||
# The provider PR title cannot add an unchecked trailer outside the constructed
|
||||
# message field: multi-line and trailer-shaped titles block before mutation.
|
||||
title_dir=$(make_case title-injection)
|
||||
set +e
|
||||
title_output=$(MOSAIC_TEST_TITLE_MODE=injection \
|
||||
run_case "$title_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
title_rc=$?
|
||||
set -e
|
||||
[[ "$title_rc" -ne 0 ]] || fail "title trailer injection unexpectedly passed"
|
||||
[[ "$title_output" == *"not one printable, non-trailer line"* ]] || fail "title injection refusal lost its diagnostic"
|
||||
[[ ! -e "$title_dir/merge-payload.json" ]] || fail "title injection reached the merge API"
|
||||
|
||||
# Provider failures remain diagnosable after their temporary response file is
|
||||
# removed, but provider-controlled control characters stay log-safe.
|
||||
error_dir=$(make_case provider-error)
|
||||
set +e
|
||||
error_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=provider-error \
|
||||
run_case "$error_dir" single 2>&1)
|
||||
error_rc=$?
|
||||
set -e
|
||||
[[ "$error_rc" -ne 0 ]] || fail "provider error unexpectedly passed"
|
||||
[[ "$error_output" == *"HTTP 409"* ]] || fail "provider error omitted the HTTP status"
|
||||
[[ "$error_output" == *"branch policy rejected"* ]] || fail "provider error response was discarded"
|
||||
[[ "$error_output" == *'\n\x1b[31m'* ]] || fail "provider error response did not escape control characters"
|
||||
[[ "$error_output" != *$'\033'* ]] || fail "provider error response emitted a raw terminal escape"
|
||||
[[ "$error_output" != *"Basic Auth fallback"* ]] || fail "provider error advertised removed Basic Auth fallback"
|
||||
[[ ! -e "$error_dir/basic-resolution.log" ]] || fail "HTTP 409 policy denial incorrectly triggered Basic Auth fallback"
|
||||
|
||||
# Authorization denials likewise fail closed instead of changing principals.
|
||||
forbidden_dir=$(make_case forbidden)
|
||||
set +e
|
||||
forbidden_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=forbidden \
|
||||
run_case "$forbidden_dir" single 2>&1)
|
||||
forbidden_rc=$?
|
||||
set -e
|
||||
[[ "$forbidden_rc" -ne 0 ]] || fail "HTTP 403 authorization denial unexpectedly passed"
|
||||
[[ "$forbidden_output" == *"HTTP 403"* ]] || fail "authorization denial omitted the HTTP status"
|
||||
[[ "$forbidden_output" != *"Basic Auth fallback"* ]] || fail "authorization denial advertised removed Basic Auth fallback"
|
||||
[[ ! -e "$forbidden_dir/basic-resolution.log" ]] || fail "HTTP 403 authorization denial incorrectly triggered Basic Auth fallback"
|
||||
|
||||
# The BLOCK destination cannot be generic or inferred after failure: opting in
|
||||
# without a named principal is refused before any provider operation.
|
||||
principal_dir=$(make_case missing-principal)
|
||||
set +e
|
||||
principal_output=$(run_case "$principal_dir" verified --co-author-trailers 2>&1)
|
||||
principal_rc=$?
|
||||
set -e
|
||||
[[ "$principal_rc" -ne 0 ]] || fail "co-author mode without a named principal unexpectedly passed"
|
||||
[[ "$principal_output" == *"requires --escalate-to with a named principal"* ]] || fail "missing-principal refusal lost its diagnostic"
|
||||
[[ ! -e "$principal_dir/merge-payload.json" ]] || fail "missing-principal refusal reached the merge API"
|
||||
|
||||
# A trailing value-taking option receives a stable CLI diagnostic instead of a
|
||||
# set -u unbound-variable crash.
|
||||
value_dir=$(make_case missing-principal-value)
|
||||
set +e
|
||||
value_output=$(run_case "$value_dir" verified --co-author-trailers --escalate-to 2>&1)
|
||||
value_rc=$?
|
||||
set -e
|
||||
[[ "$value_rc" -ne 0 ]] || fail "missing --escalate-to value unexpectedly passed"
|
||||
[[ "$value_output" == *"--escalate-to requires one principal name"* ]] || fail "missing --escalate-to value lost its diagnostic"
|
||||
[[ "$value_output" != *"unbound variable"* ]] || fail "missing --escalate-to value crashed under set -u"
|
||||
[[ ! -e "$value_dir/merge-payload.json" ]] || fail "missing --escalate-to value reached the merge API"
|
||||
|
||||
# Negative control: ordinary single-author merge remains byte-for-byte payload
|
||||
# compatible and hardcoded to squash, with no optional message fields.
|
||||
single_dir=$(make_case single)
|
||||
set +e
|
||||
single_output=$(run_case "$single_dir" single 2>&1)
|
||||
single_rc=$?
|
||||
set -e
|
||||
if [[ "$single_rc" -ne 0 ]]; then
|
||||
fail "ordinary single-author merge expected rc=0, got rc=$single_rc: $single_output"
|
||||
elif [[ ! -s "$single_dir/merge-payload.json" ]]; then
|
||||
fail "ordinary single-author merge did not reach the API payload"
|
||||
else
|
||||
python3 - "$single_dir/merge-payload.json" <<'PY' || fail "ordinary single-author payload changed"
|
||||
import json
|
||||
import sys
|
||||
payload = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
assert payload == {
|
||||
"Do": "squash",
|
||||
"head_commit_id": "1111111111111111111111111111111111111111",
|
||||
}, payload
|
||||
PY
|
||||
fi
|
||||
[[ -e "$single_dir/auth-via-config" ]] || fail "ordinary path did not authenticate curl through stdin config"
|
||||
[[ ! -e "$single_dir/token-in-argv" ]] || fail "ordinary path placed the Gitea token in curl argv"
|
||||
[[ "$(wc -l < "$single_dir/token-resolution.log")" -eq 1 ]] || fail "ordinary path did not use exactly one credential resolution"
|
||||
|
||||
# Squash is not defaultable: an explicit non-squash method must remain refused.
|
||||
method_dir=$(make_case method-refusal)
|
||||
set +e
|
||||
method_output=$(run_case "$method_dir" single -m merge 2>&1)
|
||||
method_rc=$?
|
||||
set -e
|
||||
[[ "$method_rc" -ne 0 ]] || fail "non-squash method unexpectedly passed"
|
||||
[[ "$method_output" == *"enforces squash merge only"* ]] || fail "non-squash refusal lost its policy diagnostic"
|
||||
[[ ! -e "$method_dir/merge-payload.json" ]] || fail "non-squash refusal reached the merge API"
|
||||
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
echo "pr-merge message-field regression failed ($failures assertions)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr-merge message-field regression passed (verified, BLOCK, and unchanged squash control)"
|
||||
Reference in New Issue
Block a user