Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53e0fe912e | ||
|
|
836ec3cb1d | ||
|
|
24294d3b77 | ||
|
|
24caeab057 | ||
|
|
888a6ad29b |
@@ -91,6 +91,20 @@ steps:
|
|||||||
# and sandboxes a throwaway git repo, so it resolves no real credentials and
|
# and sandboxes a throwaway git repo, so it resolves no real credentials and
|
||||||
# joins CI directly rather than the exclusions file.
|
# joins CI directly rather than the exclusions file.
|
||||||
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
|
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
|
||||||
|
# Hermetic regression for the git identity ladder (#1356): mock tea on PATH,
|
||||||
|
# sandboxed repo, no real credentials (3/3 green under an empty HOME). Pins
|
||||||
|
# fail-closed: a seat whose login is missing gets a named error, never a
|
||||||
|
# borrowed identity. Joins CI directly; its #1007 exclusion is burned down.
|
||||||
|
- bash packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh
|
||||||
|
# Hermetic regression for issue-view.sh (#1357): mock tea/curl, sandboxed
|
||||||
|
# repo. Pins that comment BODIES render on both paths and that a tea
|
||||||
|
# failure is named as what it was (git-config vs credential).
|
||||||
|
- bash packages/mosaic/framework/tools/git/test-issue-view-comments.sh
|
||||||
|
# Hermetic regression for mint-seat-credential.sh (fleet onboarding moved into
|
||||||
|
# the framework): mock curl, sandboxed brain home, no tea, no network. Pins
|
||||||
|
# that the admin seat is configured rather than hardcoded and that the seat
|
||||||
|
# slot is written from the mint response at mode 600.
|
||||||
|
- bash packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh
|
||||||
# Hermetic behavioural regression for the PreToolUse wrapper guard: proves
|
# Hermetic behavioural regression for the PreToolUse wrapper guard: proves
|
||||||
# it still blocks the three mistakes AND still lets reads, unwrapped
|
# it still blocks the three mistakes AND still lets reads, unwrapped
|
||||||
# endpoints and ordinary commands through. Both directions are asserted —
|
# endpoints and ordinary commands through. Both directions are asserted —
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Fleet tools
|
||||||
|
|
||||||
|
Seat lifecycle tools for a Mosaic fleet. Paths are relative to
|
||||||
|
`packages/mosaic/framework/tools/fleet/` (deployed to `~/.config/mosaic/tools/fleet/`).
|
||||||
|
|
||||||
|
| Script | Purpose |
|
||||||
|
| ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `start-agent-session.sh` | launch, stop, or attach a roster-driven agent session (reads `<seat>.env.generated`, honours `MOSAIC_TMUX_SOCKET`) |
|
||||||
|
| `seat-logins.sh` | project seat tokens into `tea` logins named `<instance>-<seat>` (dry-run by default, `--apply`, `--adopt`) |
|
||||||
|
| `mint-seat-credential.sh` | create the Gitea account for a seat on every configured instance, mint a token, write the seat's credential slot, then project it into `tea` |
|
||||||
|
| `start-interaction-service.sh`, `print-interaction-effective-policy.sh`, `start-tmux-holder.sh` | operator interaction service and tmux holder |
|
||||||
|
|
||||||
|
## Onboarding a seat's credential
|
||||||
|
|
||||||
|
```
|
||||||
|
MOSAIC_ADMIN_SEAT=<admin-seat> MOSAIC_SEAT_EMAIL_DOMAIN=<domain> mint-seat-credential.sh <seat>
|
||||||
|
```
|
||||||
|
|
||||||
|
- The admin token is read from `$MOSAIC_BRAIN_HOME/fleet/agents/<admin-seat>/secrets/gitea-<instance>-<admin-seat>.token`. It is never printed.
|
||||||
|
- `MOSAIC_SEAT_EMAIL_DOMAIN` is required (no default): the framework ships no estate-specific domain.
|
||||||
|
- Instances default to the map shared with `seat-logins.sh`; `MOSAIC_GITEA_INSTANCES="a b"` limits the set and `MOSAIC_GITEA_URL_<INSTANCE>` overrides a server URL (hyphens in the instance name become underscores in the variable, as in `seat-logins.sh`).
|
||||||
|
- The seat slot is written from the mint response: `.token`, `.scopes` (what was granted), `.principal`, each mode 600.
|
||||||
|
- `tea` absent is a warning, not a failure: REST-path wrappers work with the token alone.
|
||||||
|
- Regression suite: `test-mint-seat-credential.sh` (hermetic, mock curl, no network).
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# mint-seat-credential.sh — create the Gitea account and mint a token for one seat,
|
||||||
|
# on every configured instance, writing the result into that seat's credential slot.
|
||||||
|
#
|
||||||
|
# mint-seat-credential.sh [--admin-seat <seat>] [--instances "<a> <b>"] <seat>
|
||||||
|
#
|
||||||
|
# Configuration (environment; flags win over environment):
|
||||||
|
# MOSAIC_ADMIN_SEAT seat whose admin token is used to call the Gitea
|
||||||
|
# admin API. Required. Its token is read from
|
||||||
|
# $MOSAIC_BRAIN_HOME/fleet/agents/<admin>/secrets/
|
||||||
|
# gitea-<instance>-<admin>.token. Never printed.
|
||||||
|
# MOSAIC_GITEA_INSTANCES space-separated instance names to mint on.
|
||||||
|
# Default: every instance in the map below.
|
||||||
|
# MOSAIC_GITEA_URL_<INSTANCE> server URL override per instance (same
|
||||||
|
# convention as seat-logins.sh).
|
||||||
|
# MOSAIC_SEAT_EMAIL_DOMAIN domain for the account email (<seat>@<domain>).
|
||||||
|
# Required, no default: the framework tree
|
||||||
|
# carries no estate-specific domain
|
||||||
|
# (framework-PR firewall; the instance host
|
||||||
|
# map stays per seat-logins.sh precedent).
|
||||||
|
# MOSAIC_BRAIN_HOME brain checkout; default ~/.mosaic.
|
||||||
|
#
|
||||||
|
# Exit codes: 0 minted and projected on every instance; 1 at least one instance
|
||||||
|
# failed (the others are untouched or complete); 3 usage error.
|
||||||
|
#
|
||||||
|
# WHY BASIC AUTH, WHICH LOOKS WRONG AT FIRST
|
||||||
|
# Gitea refuses token auth on POST /users/{user}/tokens by design, and the Sudo
|
||||||
|
# header and sudo query parameter are both rejected there (probed 2026-08-19, probe
|
||||||
|
# token deleted). So minting for another account needs a password: this script
|
||||||
|
# generates a random one, uses it once, and never stores or prints it. Agents
|
||||||
|
# authenticate by token; the password is not a credential anyone keeps.
|
||||||
|
#
|
||||||
|
# The .scopes file is written from the mint RESPONSE rather than from what was
|
||||||
|
# requested, so the record is what was granted rather than what was asked for.
|
||||||
|
#
|
||||||
|
# SECRETS NEVER TOUCH ARGV (#1343 class, rev-security-01 review 259): the admin
|
||||||
|
# token, the generated password, and the minted seat token all pass through
|
||||||
|
# 0600 curl --config / --data files — the landed in-tree standard
|
||||||
|
# (gitea_write_auth_config in detect-platform.sh). argv is world-readable via
|
||||||
|
# /proc/<pid>/cmdline for the life of each request, and a bash -x trace would
|
||||||
|
# print every secret otherwise. The staging files are unlinked after each use.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
# Stage secrets into 0600 files; nothing secret reaches argv or a trace.
|
||||||
|
# write_auth_config <token> -> curl --config carrying the Authorization header
|
||||||
|
# (same shape as gitea_write_auth_config in
|
||||||
|
# detect-platform.sh, local so this script stays
|
||||||
|
# standalone under tools/fleet).
|
||||||
|
# write_user_config <u> <pw> -> curl --config with `user =` (covers -u).
|
||||||
|
# write_body <json> -> 0600 file for --data @file.
|
||||||
|
write_auth_config() {
|
||||||
|
local f; f=$(mktemp "${TMPDIR:-/tmp}/mosaic-mint-auth.XXXXXX") || return 1
|
||||||
|
printf 'header = "Authorization: token %s"\n' "$1" >"$f" || { rm -f "$f"; return 1; }
|
||||||
|
chmod 600 "$f"; printf '%s' "$f"
|
||||||
|
}
|
||||||
|
write_user_config() {
|
||||||
|
local f; f=$(mktemp "${TMPDIR:-/tmp}/mosaic-mint-user.XXXXXX") || return 1
|
||||||
|
printf 'user = "%s:%s"\n' "$1" "$2" >"$f" || { rm -f "$f"; return 1; }
|
||||||
|
chmod 600 "$f"; printf '%s' "$f"
|
||||||
|
}
|
||||||
|
write_body() {
|
||||||
|
local f; f=$(mktemp "${TMPDIR:-/tmp}/mosaic-mint-body.XXXXXX") || return 1
|
||||||
|
printf '%s' "$1" >"$f" || { rm -f "$f"; return 1; }
|
||||||
|
chmod 600 "$f"; printf '%s' "$f"
|
||||||
|
}
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
BRAIN="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
|
||||||
|
ADMIN="${MOSAIC_ADMIN_SEAT:-}"
|
||||||
|
INSTANCES="${MOSAIC_GITEA_INSTANCES:-}"
|
||||||
|
EMAIL_DOMAIN="${MOSAIC_SEAT_EMAIL_DOMAIN:-}"
|
||||||
|
SEAT=""
|
||||||
|
|
||||||
|
usage() { sed -n '2,20p' "${BASH_SOURCE[0]}" >&2; exit 3; }
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--admin-seat) ADMIN="${2:-}"; shift 2 ;;
|
||||||
|
--instances) INSTANCES="${2:-}"; shift 2 ;;
|
||||||
|
-h|--help) usage ;;
|
||||||
|
-*) echo "mint: unknown flag: $1" >&2; exit 3 ;;
|
||||||
|
*) [[ -z "$SEAT" ]] || { echo "mint: one seat only" >&2; exit 3; }; SEAT="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -n "$SEAT" ]] || usage
|
||||||
|
[[ "$SEAT" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "mint: bad seat name: $SEAT" >&2; exit 3; }
|
||||||
|
[[ -n "$ADMIN" ]] || { echo "mint: no admin seat. Set MOSAIC_ADMIN_SEAT or pass --admin-seat." >&2; exit 3; }
|
||||||
|
[[ "$ADMIN" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "mint: bad admin seat name: $ADMIN" >&2; exit 3; }
|
||||||
|
[[ -n "$EMAIL_DOMAIN" ]] || { echo "mint: no email domain. Set MOSAIC_SEAT_EMAIL_DOMAIN (the framework ships no estate default)." >&2; exit 3; }
|
||||||
|
|
||||||
|
# Instance -> server URL. Same map and override convention as seat-logins.sh:
|
||||||
|
# hyphens in instance names map to underscores in the override variable
|
||||||
|
# (MOSAIC_GITEA_URL_MY-INST is not a valid shell name; MY_INST is).
|
||||||
|
url_override_var() { printf 'MOSAIC_GITEA_URL_%s' "$(printf '%s' "$1" | tr '[:lower:]-' '[:upper:]_')"; }
|
||||||
|
declare -A INSTANCE_URL=(
|
||||||
|
[mosaicstack]="https://git.mosaicstack.dev"
|
||||||
|
[usc]="https://git.uscllc.com"
|
||||||
|
)
|
||||||
|
for inst in "${!INSTANCE_URL[@]}"; do
|
||||||
|
ov="$(url_override_var "$inst")"
|
||||||
|
[[ -n "${!ov:-}" ]] && INSTANCE_URL[$inst]="${!ov}"
|
||||||
|
done
|
||||||
|
[[ -n "$INSTANCES" ]] || INSTANCES="$(printf '%s\n' "${!INSTANCE_URL[@]}" | sort | tr '\n' ' ')"
|
||||||
|
|
||||||
|
SCOPES='["read:user","write:repository","write:issue","read:organization"]'
|
||||||
|
D="$BRAIN/fleet/agents/$SEAT/secrets"
|
||||||
|
mkdir -p "$D"; chmod 700 "$D"
|
||||||
|
|
||||||
|
rc=0
|
||||||
|
for KEY in $INSTANCES; do
|
||||||
|
ov="$(url_override_var "$KEY")"
|
||||||
|
BASE="${INSTANCE_URL[$KEY]:-${!ov:-}}"
|
||||||
|
[[ -n "$BASE" ]] || { echo " $KEY: no URL known for this instance (set $ov), skipped" >&2; rc=1; continue; }
|
||||||
|
ADMIN_TOKEN_FILE="$BRAIN/fleet/agents/$ADMIN/secrets/gitea-$KEY-$ADMIN.token"
|
||||||
|
[[ -r "$ADMIN_TOKEN_FILE" ]] || { echo " $KEY: no admin token for seat '$ADMIN' ($ADMIN_TOKEN_FILE), skipped" >&2; rc=1; continue; }
|
||||||
|
T="$(cat "$ADMIN_TOKEN_FILE")"
|
||||||
|
AUTH_CFG="$(write_auth_config "$T")"
|
||||||
|
PW="$(openssl rand -base64 33 | tr -d '\n/+=' | head -c 32)"
|
||||||
|
USER_CFG="$(write_user_config "$SEAT" "$PW")"
|
||||||
|
|
||||||
|
if curl -sf -o /dev/null --config "$AUTH_CFG" "$BASE/api/v1/users/$SEAT"; then
|
||||||
|
BODY="$(write_body "{\"login_name\":\"$SEAT\",\"source_id\":0,\"password\":\"$PW\",\"must_change_password\":false}")"
|
||||||
|
curl -s -o /dev/null -X PATCH -H "Content-Type: application/json" \
|
||||||
|
--config "$AUTH_CFG" --data "@$BODY" \
|
||||||
|
"$BASE/api/v1/admin/users/$SEAT"
|
||||||
|
rm -f "$BODY"; BODY=""
|
||||||
|
act="reset-pw"
|
||||||
|
else
|
||||||
|
BODY="$(write_body "{\"username\":\"$SEAT\",\"email\":\"$SEAT@$EMAIL_DOMAIN\",\"password\":\"$PW\",\"must_change_password\":false,\"full_name\":\"Mosaic fleet seat $SEAT\"}")"
|
||||||
|
curl -s -o /dev/null -X POST -H "Content-Type: application/json" \
|
||||||
|
--config "$AUTH_CFG" --data "@$BODY" \
|
||||||
|
"$BASE/api/v1/admin/users"
|
||||||
|
rm -f "$BODY"; BODY=""
|
||||||
|
act="create"
|
||||||
|
fi
|
||||||
|
|
||||||
|
tmp="$(mktemp)"; chmod 600 "$tmp"
|
||||||
|
MINT_BODY="$(write_body "{\"name\":\"mosaic-seat\",\"scopes\":$SCOPES}")"
|
||||||
|
code="$(curl -s -o "$tmp" -w '%{http_code}' -X POST -H "Content-Type: application/json" \
|
||||||
|
--config "$USER_CFG" --data "@$MINT_BODY" "$BASE/api/v1/users/$SEAT/tokens")"
|
||||||
|
rm -f "$MINT_BODY"; MINT_BODY=""
|
||||||
|
if [[ "$code" != "201" ]]; then
|
||||||
|
echo " $KEY: mint FAILED http=$code ($act)" >&2; rm -f "$tmp"; rc=1; PW=""; rm -f "$AUTH_CFG" "$USER_CFG"; continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "$tmp" "$D" "$KEY" "$SEAT" <<'PY'
|
||||||
|
import json,sys,pathlib
|
||||||
|
tmp,d,key,seat=sys.argv[1:5]
|
||||||
|
t=json.load(open(tmp))
|
||||||
|
p=pathlib.Path(d)
|
||||||
|
(p/f"gitea-{key}-{seat}.token").write_text(t["sha1"]+"\n")
|
||||||
|
(p/f"gitea-{key}-{seat}.scopes").write_text(json.dumps(t.get("scopes",[]))+"\n")
|
||||||
|
(p/f"gitea-{key}-{seat}.principal").write_text(seat+"\n")
|
||||||
|
for suf in ("token","scopes","principal"):
|
||||||
|
(p/f"gitea-{key}-{seat}.{suf}").chmod(0o600)
|
||||||
|
PY
|
||||||
|
rm -f "$tmp"; PW=""; rm -f "$AUTH_CFG" "$USER_CFG"
|
||||||
|
|
||||||
|
VERIFY_CFG="$(write_auth_config "$(cat "$D/gitea-$KEY-$SEAT.token")")"
|
||||||
|
login="$(curl -s --config "$VERIFY_CFG" "$BASE/api/v1/user" \
|
||||||
|
| python3 -c 'import json,sys;print(json.load(sys.stdin).get("login","ERR"))' 2>/dev/null || echo ERR)"
|
||||||
|
rm -f "$VERIFY_CFG"
|
||||||
|
if [[ "$login" == "$SEAT" ]]; then
|
||||||
|
echo " $KEY: $act, minted, GET /user -> $login"
|
||||||
|
else
|
||||||
|
echo " $KEY: minted but identity check returned '$login', expected '$SEAT'" >&2; rc=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Project into tea ─────────────────────────────────────────────────────────
|
||||||
|
# A token in the secrets dir is only half a credential. tea 0.14.0 cannot read
|
||||||
|
# that store, it only uses logins already in its own config, so a seat minted
|
||||||
|
# but not projected holds a working token and no login. Minting and projecting
|
||||||
|
# are therefore ONE operation.
|
||||||
|
#
|
||||||
|
# --adopt is deliberately NOT passed. Adopting deletes an operator-made login,
|
||||||
|
# which is a human decision. A collision reports BLOCK and a nonzero rc instead.
|
||||||
|
#
|
||||||
|
# tea absent is not a minting failure. The REST-path wrappers still work with
|
||||||
|
# the token that was just written, so warn and carry on.
|
||||||
|
SEAT_LOGINS="$SCRIPT_DIR/seat-logins.sh"
|
||||||
|
if [[ "$rc" -eq 0 ]]; then
|
||||||
|
if command -v tea >/dev/null 2>&1; then
|
||||||
|
if "$SEAT_LOGINS" --apply --seat "$SEAT"; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
echo " projection FAILED: token is minted and valid, but no tea login exists for $SEAT." >&2
|
||||||
|
echo " tea-path wrappers will not act as this seat. Re-run:" >&2
|
||||||
|
echo " $SEAT_LOGINS --apply --seat $SEAT" >&2
|
||||||
|
rc=1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " tea not on PATH: token minted, no login projected (REST-path wrappers still work)." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit $rc
|
||||||
+181
@@ -0,0 +1,181 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# seat-logins.sh — project seat credentials into tea's login config.
|
||||||
|
#
|
||||||
|
# Issue: mosaicstack/stack#1356 (tea login resolution fails open).
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS. tea 0.14.0 has no --token on its operations; it can only use a
|
||||||
|
# login already stored in ~/.config/tea/config.yml. So the wrappers cannot read the
|
||||||
|
# seat secrets dir on the tea path. The secrets dir stays authoritative and this
|
||||||
|
# script projects it into tea's config, which is a DERIVED CACHE: regenerate it,
|
||||||
|
# never hand-edit it. Same shape as the config-registry projector, same reason —
|
||||||
|
# a third-party tool that cannot read our store has to be fed.
|
||||||
|
#
|
||||||
|
# Canonical login name is "<instance>-<seat>", which is what the identity ladder in
|
||||||
|
# detect-platform.sh computes from the seat name. A login the ladder cannot compute
|
||||||
|
# is a fail-open surface, so an ad-hoc name is a defect, not a style.
|
||||||
|
#
|
||||||
|
# COLLISIONS. tea refuses to store one token under two names ("token already been
|
||||||
|
# used, delete login 'X' first"). A hand-made alias holding a seat's token there-
|
||||||
|
# fore BLOCKS its canonical name. Detected up front by hashing, so a dry run shows
|
||||||
|
# it; --adopt resolves it by deleting the alias and re-minting canonically. Same
|
||||||
|
# token, same access, only the label changes.
|
||||||
|
#
|
||||||
|
# Tokens are never printed, never logged, and never passed on a visible command
|
||||||
|
# line beyond tea's own --token, which is unavoidable with this client. tea's
|
||||||
|
# stderr is echoed on failure with any token-shaped string redacted.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# seat-logins.sh # dry run, all seats (default: changes nothing)
|
||||||
|
# seat-logins.sh --apply # mint/refresh all seats
|
||||||
|
# seat-logins.sh --seat <seat> # limit to one seat
|
||||||
|
# seat-logins.sh --apply --adopt # also rename ad-hoc aliases to canonical names
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BRAIN_HOME="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
|
||||||
|
TEA_CONFIG="${TEA_CONFIG:-$HOME/.config/tea/config.yml}"
|
||||||
|
APPLY=0
|
||||||
|
ADOPT=0
|
||||||
|
ONLY_SEAT=""
|
||||||
|
|
||||||
|
# Instance -> server URL.
|
||||||
|
#
|
||||||
|
# Instances are named here because there is no registry to read them from yet.
|
||||||
|
# Override per-instance without editing this file, which is how a deployment adds
|
||||||
|
# its own hosts: MOSAIC_GITEA_URL_<INSTANCE>=https://...
|
||||||
|
declare -A INSTANCE_URL=(
|
||||||
|
[mosaicstack]="https://git.mosaicstack.dev"
|
||||||
|
[usc]="https://git.uscllc.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--apply) APPLY=1; shift ;;
|
||||||
|
--adopt) ADOPT=1; shift ;;
|
||||||
|
--seat) ONLY_SEAT="${2:?--seat needs a name}"; shift 2 ;;
|
||||||
|
-h|--help) sed -n '2,33p' "$0"; exit 0 ;;
|
||||||
|
*) echo "seat-logins.sh: unknown argument '$1'" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
command -v tea >/dev/null || { echo "seat-logins.sh: tea not on PATH" >&2; exit 1; }
|
||||||
|
|
||||||
|
url_for() {
|
||||||
|
local inst="$1" ovr
|
||||||
|
ovr="MOSAIC_GITEA_URL_$(printf '%s' "$inst" | tr '[:lower:]-' '[:upper:]_')"
|
||||||
|
if [ -n "${!ovr:-}" ]; then printf '%s' "${!ovr}"; return 0; fi
|
||||||
|
printf '%s' "${INSTANCE_URL[$inst]:-}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Redact anything token-shaped before any tea output reaches a log.
|
||||||
|
redact() { sed -E 's/[A-Za-z0-9]{30,}/<REDACTED>/g'; }
|
||||||
|
|
||||||
|
# token sha256 -> login name, for every login tea already holds. This is what
|
||||||
|
# makes collisions visible in a DRY RUN instead of only as an apply-time error.
|
||||||
|
declare -A TOKEN_OWNER=()
|
||||||
|
if [ -r "$TEA_CONFIG" ]; then
|
||||||
|
while read -r sha lname; do
|
||||||
|
[ -n "${sha:-}" ] && TOKEN_OWNER["$sha"]="$lname"
|
||||||
|
done < <(python3 - "$TEA_CONFIG" <<'PY'
|
||||||
|
import sys, yaml, hashlib
|
||||||
|
try:
|
||||||
|
cfg = yaml.safe_load(open(sys.argv[1])) or {}
|
||||||
|
except Exception:
|
||||||
|
sys.exit(0)
|
||||||
|
for l in (cfg.get('logins') or []):
|
||||||
|
t = l.get('token')
|
||||||
|
if t:
|
||||||
|
print(hashlib.sha256(t.encode()).hexdigest(), l.get('name'))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
|
minted=0; refreshed=0; skipped=0; failed=0; planned=0; adopted=0; blocked=0
|
||||||
|
|
||||||
|
existing="$(tea login list --output simple 2>/dev/null | awk '{print $1}' || true)"
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
for tokfile in "$BRAIN_HOME"/fleet/agents/*/secrets/gitea-*.token; do
|
||||||
|
seat="${tokfile#"$BRAIN_HOME"/fleet/agents/}"; seat="${seat%%/*}"
|
||||||
|
[ -n "$ONLY_SEAT" ] && [ "$seat" != "$ONLY_SEAT" ] && continue
|
||||||
|
|
||||||
|
base="$(basename "$tokfile" .token)" # gitea-<instance>-<seat>
|
||||||
|
inst="${base#gitea-}"; inst="${inst%-"$seat"}"
|
||||||
|
name="${inst}-${seat}"
|
||||||
|
url="$(url_for "$inst")"
|
||||||
|
|
||||||
|
if [ -z "$url" ]; then
|
||||||
|
echo " SKIP $name — no URL known for instance '$inst' (set MOSAIC_GITEA_URL_${inst^^})"
|
||||||
|
skipped=$((skipped+1)); continue
|
||||||
|
fi
|
||||||
|
if [ ! -r "$tokfile" ]; then
|
||||||
|
echo " SKIP $name — token not readable"
|
||||||
|
skipped=$((skipped+1)); continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
action="mint"
|
||||||
|
grep -qx "$name" <<<"$existing" && action="refresh"
|
||||||
|
|
||||||
|
# Is this exact token already stored under some OTHER name?
|
||||||
|
tsha="$(sha256sum < "$tokfile" | awk '{print $1}')"
|
||||||
|
owner="${TOKEN_OWNER[$tsha]:-}"
|
||||||
|
collision=""
|
||||||
|
[ -n "$owner" ] && [ "$owner" != "$name" ] && collision="$owner"
|
||||||
|
|
||||||
|
if [ "$APPLY" -eq 0 ]; then
|
||||||
|
if [ -n "$collision" ]; then
|
||||||
|
if [ "$ADOPT" -eq 1 ]; then
|
||||||
|
echo " PLAN adopt $collision -> $name ($url)"
|
||||||
|
else
|
||||||
|
echo " BLOCK $name — token already stored as '$collision'; re-run with --adopt"
|
||||||
|
blocked=$((blocked+1)); continue
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " PLAN $action $name -> $url"
|
||||||
|
fi
|
||||||
|
planned=$((planned+1)); continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$collision" ]; then
|
||||||
|
if [ "$ADOPT" -eq 0 ]; then
|
||||||
|
echo " BLOCK $name — token already stored as '$collision'; re-run with --adopt"
|
||||||
|
blocked=$((blocked+1)); continue
|
||||||
|
fi
|
||||||
|
tea login delete "$collision" >/dev/null 2>&1 || true
|
||||||
|
action="adopt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# tea has no idempotent add; refresh is delete-then-add so a rotated token lands.
|
||||||
|
[ "$action" = refresh ] && tea login delete "$name" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
if err="$(tea login add --name "$name" --url "$url" \
|
||||||
|
--token "$(cat "$tokfile")" --no-version-check 2>&1 >/dev/null)"; then
|
||||||
|
case "$action" in
|
||||||
|
mint) minted=$((minted+1)) ;;
|
||||||
|
refresh) refreshed=$((refreshed+1)) ;;
|
||||||
|
adopt) adopted=$((adopted+1)) ;;
|
||||||
|
esac
|
||||||
|
if [ "$action" = adopt ]; then
|
||||||
|
echo " OK adopt $collision -> $name ($url)"
|
||||||
|
else
|
||||||
|
echo " OK $action $name -> $url"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# A failure here is real information: the seat's token is dead, or the server
|
||||||
|
# refused it. Do not paper over it; the seat cannot act until it is reminted.
|
||||||
|
# tea's own words, redacted — a summarised FAIL hides whether the cause is the
|
||||||
|
# credential or the client, which cost a diagnosis on 2026-08-21.
|
||||||
|
echo " FAIL $action $name -> $url"
|
||||||
|
echo " tea: $(printf '%s' "$err" | redact | head -1)"
|
||||||
|
failed=$((failed+1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$APPLY" -eq 0 ]; then
|
||||||
|
echo "dry run: $planned login(s) would be written, $skipped skipped, $blocked blocked."
|
||||||
|
[ "$blocked" -gt 0 ] && echo "re-run with --adopt to rename ad-hoc aliases to canonical names."
|
||||||
|
echo "no changes made. re-run with --apply."
|
||||||
|
else
|
||||||
|
echo "minted=$minted adopted=$adopted refreshed=$refreshed skipped=$skipped blocked=$blocked failed=$failed"
|
||||||
|
fi
|
||||||
|
[ "$failed" -eq 0 ] && [ "$blocked" -eq 0 ]
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Hermetic regression for mint-seat-credential.sh: mock curl on PATH, sandboxed
|
||||||
|
# brain home, no tea, no network, no real credentials.
|
||||||
|
#
|
||||||
|
# Pins:
|
||||||
|
# M1 the seat slot is written from the mint RESPONSE (token, granted scopes,
|
||||||
|
# principal), each file mode 600, and the identity check passes.
|
||||||
|
# M2 the admin token is read from MOSAIC_ADMIN_SEAT's slot, never hardcoded;
|
||||||
|
# a missing admin token is reported per instance and exits nonzero.
|
||||||
|
# M3 MOSAIC_GITEA_INSTANCES limits which instances are touched, and the URL
|
||||||
|
# override MOSAIC_GITEA_URL_<INSTANCE> is honoured.
|
||||||
|
# M4 no admin seat configured is a usage error (rc=3), nothing written.
|
||||||
|
# M5 the admin token value never appears on stdout or stderr.
|
||||||
|
# M6 secrets never touch argv: no Authorization header, no -u user:pass, no
|
||||||
|
# inline --data JSON carrying the password, on any curl invocation; auth
|
||||||
|
# travels in --config files and bodies in --data @files (#1343 class,
|
||||||
|
# rev-security-01 review 259 blocker).
|
||||||
|
# M7 the scopes record discriminates: a requested-but-not-granted scope
|
||||||
|
# (write:issue) must be ABSENT from .scopes — the pin is on the RESPONSE,
|
||||||
|
# and a mutant writing the requested set fails here (both reviewers).
|
||||||
|
# M8 hyphenated instance names resolve their override through the underscore
|
||||||
|
# variable, matching seat-logins.sh (SF3).
|
||||||
|
# M9 MOSAIC_SEAT_EMAIL_DOMAIN is required: unset is a usage error (rc=3),
|
||||||
|
# nothing written (framework-PR firewall answer).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}"
|
||||||
|
SANDBOX="$WORK_ROOT/mint-seat-credential-test-$$"
|
||||||
|
MOCK_BIN="$SANDBOX/bin"; BRAIN="$SANDBOX/brain"; CALLS="$SANDBOX/calls.log"
|
||||||
|
cleanup() { rm -rf "$SANDBOX"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
fail() { echo "FAIL: $*"; exit 1; }
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TARGET="$SCRIPT_DIR/mint-seat-credential.sh"
|
||||||
|
[ -f "$TARGET" ] || fail "mint-seat-credential.sh not found beside this test"
|
||||||
|
|
||||||
|
mkdir -p "$MOCK_BIN" "$BRAIN/fleet/agents/admin-seat/secrets" || fail "setup: sandbox"
|
||||||
|
: > "$CALLS"
|
||||||
|
ADMIN_TOKEN_VALUE="admin-token-value-sentinel-4491"
|
||||||
|
printf '%s\n' "$ADMIN_TOKEN_VALUE" > "$BRAIN/fleet/agents/admin-seat/secrets/gitea-alpha-admin-seat.token"
|
||||||
|
chmod 600 "$BRAIN/fleet/agents/admin-seat/secrets/gitea-alpha-admin-seat.token"
|
||||||
|
|
||||||
|
# A PATH with only the mock bin plus the system tools the script needs, and no tea.
|
||||||
|
SYS_BIN="$SANDBOX/sys"; mkdir -p "$SYS_BIN"
|
||||||
|
for t in bash sed cat mktemp openssl tr head python3 sort printf chmod mkdir rm dirname grep stat; do
|
||||||
|
p="$(command -v "$t" 2>/dev/null || true)"; [ -n "$p" ] && ln -s "$p" "$SYS_BIN/$t"
|
||||||
|
done
|
||||||
|
export PATH="$MOCK_BIN:$SYS_BIN" CALLS
|
||||||
|
export MOSAIC_BRAIN_HOME="$BRAIN"
|
||||||
|
export MOSAIC_GITEA_URL_ALPHA="https://alpha.example.test"
|
||||||
|
export MOSAIC_SEAT_EMAIL_DOMAIN="seats.example.test"
|
||||||
|
unset MOSAIC_ADMIN_SEAT MOSAIC_GITEA_INSTANCES
|
||||||
|
|
||||||
|
# --- mock curl: records method + URL + a REDACTED auth marker, answers minting --
|
||||||
|
cat > "$MOCK_BIN/curl" <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
method=GET; url=""; out=""; wcode=0; auth=""; body=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-X) method="$2"; shift 2 ;;
|
||||||
|
-o) out="$2"; shift 2 ;;
|
||||||
|
-w) wcode=1; shift 2 ;;
|
||||||
|
--config)
|
||||||
|
if grep -q 'Authorization: token' "$2" 2>/dev/null; then auth="${auth}token,"; fi
|
||||||
|
if grep -q '^user = ' "$2" 2>/dev/null; then auth="${auth}user,"; fi
|
||||||
|
shift 2 ;;
|
||||||
|
--data)
|
||||||
|
case "$2" in
|
||||||
|
@*) body="@file" ;;
|
||||||
|
*) body="inline" ;;
|
||||||
|
esac
|
||||||
|
shift 2 ;;
|
||||||
|
-H|-u) shift 2 ;;
|
||||||
|
http*) url="$1"; shift ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf '%s %s auth=%s body=%s\n' "$method" "$url" "${auth:-NONE}" "$body" >> "$CALLS"
|
||||||
|
emit() { if [ -n "$out" ]; then printf '%s' "$1" > "$out"; else printf '%s' "$1"; fi; }
|
||||||
|
case "$method $url" in
|
||||||
|
"GET "*/api/v1/users/newseat) exit 22 ;; # 404 under -f: account does not exist yet
|
||||||
|
"POST "*/api/v1/admin/users) emit '{}'; exit 0 ;;
|
||||||
|
"POST "*/api/v1/users/newseat/tokens) emit '{"id":9,"name":"mosaic-seat","sha1":"minted-token-7f3a","scopes":["read:user","write:repository"]}'
|
||||||
|
[ "$wcode" = 1 ] && printf '201'; exit 0 ;;
|
||||||
|
"GET "*/api/v1/user) emit '{"login":"newseat"}'; exit 0 ;;
|
||||||
|
*) emit '{}'; exit 0 ;;
|
||||||
|
esac
|
||||||
|
EOF
|
||||||
|
chmod +x "$MOCK_BIN/curl"
|
||||||
|
[ "$(command -v curl)" = "$MOCK_BIN/curl" ] || fail "setup: curl does not resolve to the mock"
|
||||||
|
command -v tea >/dev/null 2>&1 && fail "setup: tea must be absent from the sandbox PATH"
|
||||||
|
|
||||||
|
run() { bash "$TARGET" "$@" >"$SANDBOX/out" 2>"$SANDBOX/err"; echo $?; }
|
||||||
|
|
||||||
|
# M4: no admin seat configured.
|
||||||
|
rc=$(run newseat)
|
||||||
|
[ "$rc" = 3 ] || fail "M4: expected rc=3 without an admin seat, got $rc: $(cat "$SANDBOX/err")"
|
||||||
|
grep -q 'MOSAIC_ADMIN_SEAT' "$SANDBOX/err" || fail "M4: error does not name MOSAIC_ADMIN_SEAT"
|
||||||
|
[ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M4: a token was written without an admin seat"
|
||||||
|
|
||||||
|
# M9: email domain is required, unset is a usage error, nothing written.
|
||||||
|
rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha MOSAIC_SEAT_EMAIL_DOMAIN= run newseat)
|
||||||
|
[ "$rc" = 3 ] || fail "M9: expected rc=3 with no email domain, got $rc: $(cat "$SANDBOX/err")"
|
||||||
|
grep -q 'MOSAIC_SEAT_EMAIL_DOMAIN' "$SANDBOX/err" || fail "M9: error does not name MOSAIC_SEAT_EMAIL_DOMAIN"
|
||||||
|
[ ! -s "$CALLS" ] || fail "M9: API called without an email domain"
|
||||||
|
[ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M9: token written without an email domain"
|
||||||
|
|
||||||
|
# M1 + M3 + M5: mint on the single configured instance.
|
||||||
|
: > "$CALLS"
|
||||||
|
rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha run newseat)
|
||||||
|
[ "$rc" = 0 ] || fail "M1: expected rc=0, got $rc: $(cat "$SANDBOX/err")"
|
||||||
|
SLOT="$BRAIN/fleet/agents/newseat/secrets"
|
||||||
|
[ "$(cat "$SLOT/gitea-alpha-newseat.token")" = "minted-token-7f3a" ] || fail "M1: token file not written from the mint response"
|
||||||
|
grep -q 'write:repository' "$SLOT/gitea-alpha-newseat.scopes" || fail "M1: scopes file not written from the response"
|
||||||
|
[ "$(cat "$SLOT/gitea-alpha-newseat.principal")" = "newseat" ] || fail "M1: principal file wrong"
|
||||||
|
for suf in token scopes principal; do
|
||||||
|
m=$(stat -c '%a' "$SLOT/gitea-alpha-newseat.$suf"); [ "$m" = 600 ] || fail "M1: $suf is mode $m, expected 600"
|
||||||
|
done
|
||||||
|
grep -q 'alpha: create, minted, GET /user -> newseat' "$SANDBOX/out" || fail "M1: success line missing: $(cat "$SANDBOX/out")"
|
||||||
|
grep -q 'https://alpha.example.test/api/v1/admin/users' "$CALLS" || fail "M3: URL override not honoured: $(cat "$CALLS")"
|
||||||
|
if grep -q 'usc\|mosaicstack' "$CALLS"; then fail "M3: an instance outside MOSAIC_GITEA_INSTANCES was touched: $(cat "$CALLS")"; fi
|
||||||
|
grep -q 'tea not on PATH' "$SANDBOX/err" || fail "tea-absent path should warn, not fail: $(cat "$SANDBOX/err")"
|
||||||
|
if grep -q "$ADMIN_TOKEN_VALUE" "$SANDBOX/out" "$SANDBOX/err" "$CALLS"; then fail "M5: admin token value leaked to output or call log"; fi
|
||||||
|
|
||||||
|
# M7: scopes pin discriminates — requested-but-not-granted scope is ABSENT.
|
||||||
|
if grep -q 'write:issue' "$SLOT/gitea-alpha-newseat.scopes"; then
|
||||||
|
fail "M7: write:issue appears in .scopes — the record is the REQUESTED set, not the response"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# M6: no secret ever travels argv — every call authenticates via --config
|
||||||
|
# (token header or user= basic-auth directive) and bodies go as --data @file.
|
||||||
|
while IFS= read -r line; do
|
||||||
|
case "$line" in
|
||||||
|
*auth=NONE*) fail "M6: unauthenticated call: $line" ;;
|
||||||
|
*body=inline*) fail "M6: inline body (secret in argv risk): $line" ;;
|
||||||
|
esac
|
||||||
|
done < "$CALLS"
|
||||||
|
[ "$(grep -c 'auth=token' "$CALLS")" -eq 3 ] || fail "M6: expected exactly 3 token-auth calls (exists-check, admin write, verify), got: $(cat "$CALLS")"
|
||||||
|
grep -q 'auth=user' "$CALLS" || fail "M6: mint call did not use the user= directive: $(cat "$CALLS")"
|
||||||
|
|
||||||
|
# M8: hyphenated instance name resolves its override via the underscore variable.
|
||||||
|
printf '%s\n' "$ADMIN_TOKEN_VALUE" > "$BRAIN/fleet/agents/admin-seat/secrets/gitea-my-inst-admin-seat.token"
|
||||||
|
chmod 600 "$BRAIN/fleet/agents/admin-seat/secrets/gitea-my-inst-admin-seat.token"
|
||||||
|
export MOSAIC_GITEA_URL_MY_INST="https://myinst.example.test"
|
||||||
|
: > "$CALLS"; rm -rf "$BRAIN/fleet/agents/newseat"
|
||||||
|
rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=my-inst run newseat)
|
||||||
|
[ "$rc" = 0 ] || fail "M8: hyphenated instance mint failed rc=$rc: $(cat "$SANDBOX/err")"
|
||||||
|
grep -q 'https://myinst.example.test/api/v1/admin/users' "$CALLS" || fail "M8: hyphen override (MY_INST) not honoured: $(cat "$CALLS")"
|
||||||
|
unset MOSAIC_GITEA_URL_MY_INST
|
||||||
|
|
||||||
|
# M2: admin token missing for the instance is reported, rc=1, nothing written.
|
||||||
|
rm -rf "$BRAIN/fleet/agents/newseat"
|
||||||
|
: > "$CALLS"
|
||||||
|
rc=$(MOSAIC_ADMIN_SEAT=other-admin MOSAIC_GITEA_INSTANCES=alpha run newseat)
|
||||||
|
[ "$rc" = 1 ] || fail "M2: expected rc=1 with no admin token, got $rc"
|
||||||
|
grep -q "no admin token for seat 'other-admin'" "$SANDBOX/err" || fail "M2: missing-admin-token not reported: $(cat "$SANDBOX/err")"
|
||||||
|
[ ! -s "$CALLS" ] || fail "M2: API was called without an admin token: $(cat "$CALLS")"
|
||||||
|
[ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M2: token written without an admin token"
|
||||||
|
|
||||||
|
echo "mint-seat-credential regression harness passed"
|
||||||
@@ -102,6 +102,36 @@ of their own — `MOSAIC_GIT_IDENTITY=<id>` with a provisioned slot. There is de
|
|||||||
environment variable that restores the fallback; one would reintroduce exactly the
|
environment variable that restores the fallback; one would reintroduce exactly the
|
||||||
substitution this removes.
|
substitution this removes.
|
||||||
|
|
||||||
|
### The tea path: login resolution (#1356)
|
||||||
|
|
||||||
|
The wrappers that go through `tea` (`issue-list.sh`, `pr-list.sh`, `pr-view.sh`,
|
||||||
|
`lane-brief.sh`, and the tea half of `issue-close.sh`) cannot use a token directly: tea
|
||||||
|
0.14 only acts as a **login** already stored in `~/.config/tea/config.yml`. Those wrappers
|
||||||
|
therefore resolve a login name, not a token, and the resolution follows the same identity
|
||||||
|
as above:
|
||||||
|
|
||||||
|
1. Resolve the identity (`MOSAIC_GIT_IDENTITY`, then `git config mosaic.gitIdentity`).
|
||||||
|
2. Derive the Gitea instance from the repo host (`git.mosaicstack.dev` → `mosaicstack`,
|
||||||
|
`git.uscllc.com` → `usc`), or from the owner when `--repo owner/name` is given.
|
||||||
|
3. The canonical login is `<instance>-<identity>`. If tea has it, that login acts.
|
||||||
|
4. If the identity is set but that login is missing, the wrapper **fails closed**: nonzero
|
||||||
|
exit, empty stdout, and a stderr line naming the login it wanted and the source of the
|
||||||
|
identity. When `tea` itself is not installed the message says so instead, since "no such
|
||||||
|
login" would send the reader to create a login they cannot create.
|
||||||
|
5. With **no identity set**, the old host-default behaviour is unchanged (first login
|
||||||
|
configured for that host, else the API fallback).
|
||||||
|
|
||||||
|
Step 4 replaced a fallback that picked any login configured for the host, which meant a
|
||||||
|
seat with no login of its own silently acted as whichever seat had configured one. That
|
||||||
|
satisfied the author≠reviewer gate on paper while one actor held both names.
|
||||||
|
|
||||||
|
**Provisioning the logins.** `tools/fleet/seat-logins.sh` projects each seat's token from
|
||||||
|
its secrets store into tea's config under the canonical name. tea's config is a derived
|
||||||
|
cache of the secrets store: regenerate it with the script, never hand-edit it. Run it with
|
||||||
|
`--seat <seat>` for one seat (all seats when omitted), dry-run by default, `--apply` to write. A hand-made
|
||||||
|
alias holding a seat's token blocks its canonical name (tea refuses one token under two
|
||||||
|
names); `--adopt` renames it.
|
||||||
|
|
||||||
### Enabling it for a clone
|
### Enabling it for a clone
|
||||||
|
|
||||||
The framework installer syncs `git-credential-mosaic` to
|
The framework installer syncs `git-credential-mosaic` to
|
||||||
|
|||||||
@@ -180,6 +180,66 @@ raise SystemExit(1)
|
|||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Map a host to the instance prefix used in canonical tea login names
|
||||||
|
# ("<instance>-<identity>"). This deliberately mirrors the _idpfx case in
|
||||||
|
# get_gitea_token(): the two credential paths must agree on what a host is called,
|
||||||
|
# or an agent authenticates as itself on one path and as somebody else on the other.
|
||||||
|
gitea_instance_for_host() {
|
||||||
|
case "${1:-}" in
|
||||||
|
git.uscllc.com) echo usc ;;
|
||||||
|
git.mosaicstack.dev) echo mosaicstack ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve the acting git identity, same precedence as get_gitea_token() step 0.
|
||||||
|
# Prints "<identity>\t<source>" so the caller can name the source in an error.
|
||||||
|
resolve_git_identity() {
|
||||||
|
local ident src
|
||||||
|
ident="${MOSAIC_GIT_IDENTITY:-}"
|
||||||
|
src="MOSAIC_GIT_IDENTITY"
|
||||||
|
if [[ -z "$ident" ]]; then
|
||||||
|
ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
|
||||||
|
src="git config mosaic.gitIdentity"
|
||||||
|
fi
|
||||||
|
[[ -n "$ident" ]] || return 1
|
||||||
|
printf '%s\t%s\n' "$ident" "$src"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Map a repo owner to an instance. Used only by the --repo override path, which
|
||||||
|
# has an owner and no host. Previously lived inline in lane-brief.sh; one copy so
|
||||||
|
# the two override callers cannot drift apart.
|
||||||
|
gitea_instance_for_owner() {
|
||||||
|
local owner="${1:-}"
|
||||||
|
owner="${owner%%/*}"
|
||||||
|
case "$owner" in
|
||||||
|
usc|USC) echo usc ;;
|
||||||
|
mosaicstack|mosaic) echo mosaicstack ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Does a login of this name exist at all? The --repo override path cannot check
|
||||||
|
# host agreement, because it has no host.
|
||||||
|
tea_login_exists() {
|
||||||
|
local login_name="$1"
|
||||||
|
local logins_json
|
||||||
|
command -v tea >/dev/null 2>&1 || return 1
|
||||||
|
logins_json=$(tea login list --output json 2>/dev/null) || return 1
|
||||||
|
TEA_LOGINS_JSON="$logins_json" python3 - "$login_name" <<'PY_INNER'
|
||||||
|
import json, os, sys
|
||||||
|
want = sys.argv[1]
|
||||||
|
try:
|
||||||
|
logins = json.loads(os.environ.get("TEA_LOGINS_JSON", "[]"))
|
||||||
|
except Exception:
|
||||||
|
raise SystemExit(1)
|
||||||
|
for login in logins if isinstance(logins, list) else []:
|
||||||
|
if str(login.get("name") or login.get("Name") or "") == want:
|
||||||
|
raise SystemExit(0)
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY_INNER
|
||||||
|
}
|
||||||
|
|
||||||
tea_login_matches_host() {
|
tea_login_matches_host() {
|
||||||
local login_name="$1" host="$2"
|
local login_name="$1" host="$2"
|
||||||
local logins_json
|
local logins_json
|
||||||
@@ -276,6 +336,40 @@ get_gitea_login_for_host() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# IDENTITY LADDER (#1356). Below this point the old code took the FIRST login
|
||||||
|
# matching the host, which is not an identity — with 43 logins on a fleet host,
|
||||||
|
# ~22 match one server, so a seat with no login of its own silently acted as
|
||||||
|
# whichever happened to be first in ~/.config/tea/config.yml. Gate 16 depends on
|
||||||
|
# author != reviewer, and borrowing satisfies it mechanically while violating it
|
||||||
|
# in fact. The token path already refuses to borrow; this is the same refusal.
|
||||||
|
#
|
||||||
|
# Enforced ONLY when an identity is resolvable, exactly like get_gitea_token():
|
||||||
|
# no identity means a human at a terminal, and neither path enforces there.
|
||||||
|
local ident ident_src inst canon
|
||||||
|
if IFS=$'\t' read -r ident ident_src < <(resolve_git_identity); then
|
||||||
|
if inst=$(gitea_instance_for_host "$host"); then
|
||||||
|
canon="${inst}-${ident}"
|
||||||
|
if tea_login_matches_host "$canon" "$host"; then
|
||||||
|
echo "$canon"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# Say which of the two it is. "No such login" when tea is simply not
|
||||||
|
# installed is a diagnosis of a cause that was never checked, and it
|
||||||
|
# sends the reader off to create a login they cannot create.
|
||||||
|
if ! command -v tea >/dev/null 2>&1; then
|
||||||
|
echo "Error: git identity '$ident' requested (via $ident_src) for host '$host', but tea is not installed," >&2
|
||||||
|
echo " so no login can be resolved. Refusing to guess an identity." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "Error: git identity '$ident' requested (via $ident_src) for host '$host', but no tea login named '$canon' exists." >&2
|
||||||
|
echo " Refusing to borrow another login. Acting as a different identity would satisfy gate 16 mechanically while violating it." >&2
|
||||||
|
echo " Create it with: ~/.config/mosaic/tools/fleet/seat-logins.sh --apply --seat $ident" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# Identity known but the host is not a Mosaic instance. Fall through: the
|
||||||
|
# canonical name is undefined for it, so there is nothing to enforce.
|
||||||
|
fi
|
||||||
|
|
||||||
login=$(find_tea_login_for_host "$host" || true)
|
login=$(find_tea_login_for_host "$host" || true)
|
||||||
if [[ -n "$login" ]]; then
|
if [[ -n "$login" ]]; then
|
||||||
echo "$login"
|
echo "$login"
|
||||||
@@ -351,14 +445,49 @@ raise SystemExit(1)
|
|||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Resolve a login for an explicit --repo override, which supplies an owner and no
|
||||||
|
# host. Takes "owner" or "owner/repo".
|
||||||
|
#
|
||||||
|
# The old body fell through to get_default_tea_login(), which returns the
|
||||||
|
# default-marked login or, failing that, the first login of ANY host — arbitrary
|
||||||
|
# identity, chosen by config file order. That is the #1356 fail-open in its worst
|
||||||
|
# form, because unlike the host path it does not even constrain the server.
|
||||||
get_gitea_login_for_repo_override() {
|
get_gitea_login_for_repo_override() {
|
||||||
local login
|
local owner="${1:-}"
|
||||||
|
local login ident ident_src inst canon
|
||||||
|
|
||||||
if [[ -n "${GITEA_LOGIN:-}" ]]; then
|
if [[ -n "${GITEA_LOGIN:-}" ]]; then
|
||||||
echo "$GITEA_LOGIN"
|
echo "$GITEA_LOGIN"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if IFS=$'\t' read -r ident ident_src < <(resolve_git_identity); then
|
||||||
|
if inst=$(gitea_instance_for_owner "$owner"); then
|
||||||
|
canon="${inst}-${ident}"
|
||||||
|
if tea_login_exists "$canon"; then
|
||||||
|
echo "$canon"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# Same split as the host path above (#1357 S1): a missing tea binary
|
||||||
|
# is not a missing login, and the "create it with" advice cannot be
|
||||||
|
# followed without tea.
|
||||||
|
if ! command -v tea >/dev/null 2>&1; then
|
||||||
|
echo "Error: git identity '$ident' (via $ident_src) requested for owner '${owner%%/*}', but tea is not installed," >&2
|
||||||
|
echo " so no login can be resolved. Refusing to guess an identity." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "Error: git identity '$ident' (via $ident_src) has no tea login '$canon' for owner '${owner%%/*}'." >&2
|
||||||
|
echo " Create it with: ~/.config/mosaic/tools/fleet/seat-logins.sh --apply --seat $ident" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "Error: git identity '$ident' (via $ident_src) is set, but owner '${owner%%/*}' maps to no known instance," >&2
|
||||||
|
echo " so the login name cannot be derived. Refusing to fall back to an arbitrary login." >&2
|
||||||
|
echo " Set GITEA_LOGIN to name the login explicitly." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# No identity: a human at a terminal. Unchanged, and the same place the token
|
||||||
|
# path stops enforcing.
|
||||||
login=$(get_default_tea_login || true)
|
login=$(get_default_tea_login || true)
|
||||||
if [[ -n "$login" ]]; then
|
if [[ -n "$login" ]]; then
|
||||||
echo "$login"
|
echo "$login"
|
||||||
|
|||||||
@@ -99,8 +99,8 @@ case "$PLATFORM" in
|
|||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||||
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override) || {
|
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override "$REPO_OVERRIDE") || {
|
||||||
echo "Error: Could not resolve Gitea login for --repo override. Set GITEA_LOGIN or configure a default tea login." >&2
|
echo "Error: could not resolve a Gitea login for the --repo override (the lines above say why). Set GITEA_LOGIN to name one explicitly." >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# issue-view.sh - View issue details on GitHub or Gitea
|
# issue-view.sh - View issue details, including comments, on GitHub or Gitea
|
||||||
# Usage: issue-view.sh -i <issue_number>
|
# Usage: issue-view.sh -i <issue_number>
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
@@ -28,11 +28,47 @@ gitea_issue_view_api() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
url="https://${host}/api/v1/repos/${repo}/issues/${ISSUE_NUMBER}"
|
url="https://${host}/api/v1/repos/${repo}/issues/${ISSUE_NUMBER}"
|
||||||
if command -v python3 >/dev/null 2>&1; then
|
local -a curl_args=(-fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}")
|
||||||
curl -fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -m json.tool
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
else
|
# No renderer: raw JSON is all this path can give. Comments are a
|
||||||
curl -fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
|
# second resource, so fetch them too rather than only the count.
|
||||||
|
curl "${curl_args[@]}" "$url"
|
||||||
|
curl "${curl_args[@]}" "${url}/comments"
|
||||||
|
return
|
||||||
fi
|
fi
|
||||||
|
# Render issue + comments as text (#1357 F2). The old fallback dumped the
|
||||||
|
# issue JSON, which carries only a comment COUNT, so every comment body was
|
||||||
|
# invisible on this path and the wrapper could never show what
|
||||||
|
# `tea issues --comments` shows.
|
||||||
|
{
|
||||||
|
curl "${curl_args[@]}" "$url"
|
||||||
|
echo
|
||||||
|
echo "__MOSAIC_COMMENTS__"
|
||||||
|
curl "${curl_args[@]}" "${url}/comments"
|
||||||
|
} | python3 -c '
|
||||||
|
import json, sys
|
||||||
|
raw = sys.stdin.read()
|
||||||
|
issue_raw, _, comments_raw = raw.partition("__MOSAIC_COMMENTS__")
|
||||||
|
issue = json.loads(issue_raw)
|
||||||
|
comments = json.loads(comments_raw) if comments_raw.strip() else []
|
||||||
|
print("#%s %s" % (issue["number"], issue["title"]))
|
||||||
|
print("State: %s Author: %s Created: %s" % (issue["state"], issue["user"]["login"], issue["created_at"]))
|
||||||
|
labels = ", ".join(l["name"] for l in issue.get("labels") or [])
|
||||||
|
if labels:
|
||||||
|
print("Labels: " + labels)
|
||||||
|
if issue.get("milestone"):
|
||||||
|
print("Milestone: " + issue["milestone"]["title"])
|
||||||
|
print("URL: " + issue["html_url"])
|
||||||
|
print()
|
||||||
|
print(issue.get("body") or "(no body)")
|
||||||
|
if comments:
|
||||||
|
print()
|
||||||
|
print("--- Comments (%d) ---" % len(comments))
|
||||||
|
for c in comments:
|
||||||
|
print()
|
||||||
|
print("[%s at %s]" % (c["user"]["login"], c["created_at"]))
|
||||||
|
print(c.get("body") or "")
|
||||||
|
'
|
||||||
}
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
@@ -46,6 +82,8 @@ while [[ $# -gt 0 ]]; do
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " -i, --issue Issue number (required)"
|
echo " -i, --issue Issue number (required)"
|
||||||
|
echo ""
|
||||||
|
echo "Comments are always included (tea --comments / Gitea API /comments)."
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
@@ -67,11 +105,30 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
gh issue view "$ISSUE_NUMBER"
|
gh issue view "$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
if command -v tea >/dev/null 2>&1; then
|
if command -v tea >/dev/null 2>&1; then
|
||||||
if tea issue "$ISSUE_NUMBER" $(get_gitea_repo_args); then
|
# --comments is what makes tea print the comment bodies (#1357 F3).
|
||||||
|
# Without it tea prompts for them interactively, which in a
|
||||||
|
# non-interactive wrapper means they are silently never shown.
|
||||||
|
tea_err=$(mktemp)
|
||||||
|
if tea issue "$ISSUE_NUMBER" $(get_gitea_repo_args) --comments 2>"$tea_err"; then
|
||||||
|
rm -f "$tea_err"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
# Name the cause tea actually reported, not a guessed one (#1357 F1/F4).
|
||||||
|
# tea reads the cwd's git config before honouring --repo; a repo with
|
||||||
|
# extensions.worktreeconfig=true makes it exit 1 with a
|
||||||
|
# repositoryformatversion error. That is a git-config condition, not a
|
||||||
|
# credential one. The old path printed the REVOKED OR STALE TOKEN note
|
||||||
|
# here unconditionally, which sent readers to rotate a token that was fine.
|
||||||
|
if grep -q 'repositoryformatversion' "$tea_err"; then
|
||||||
|
echo "Warning: tea cannot read this repo's git config (extensions.worktreeconfig); not a credential problem. Using Gitea API fallback." >&2
|
||||||
|
elif grep -q 'user does not exist' "$tea_err"; then
|
||||||
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
|
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
|
||||||
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
||||||
|
else
|
||||||
|
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
|
||||||
|
fi
|
||||||
|
sed 's/^/ tea: /' "$tea_err" >&2
|
||||||
|
rm -f "$tea_err"
|
||||||
fi
|
fi
|
||||||
gitea_issue_view_api
|
gitea_issue_view_api
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -49,13 +49,29 @@ if [[ -z "$LOGIN" ]]; then
|
|||||||
if [[ -n "${GITEA_LOGIN:-}" ]]; then
|
if [[ -n "${GITEA_LOGIN:-}" ]]; then
|
||||||
LOGIN="$GITEA_LOGIN"
|
LOGIN="$GITEA_LOGIN"
|
||||||
else
|
else
|
||||||
|
# #1356: the owner-derived map below picks a SHARED login (bare `usc` /
|
||||||
|
# `mosaicstack`). On a seat that is borrowing another identity, which is
|
||||||
|
# exactly what gate 16 forbids. So the identity ladder goes first and the
|
||||||
|
# map is only the no-identity fallback (a human at a terminal), which is
|
||||||
|
# where the token path stops enforcing too.
|
||||||
|
if LOGIN="$(get_gitea_login_for_repo_override "$REPO")"; then
|
||||||
|
:
|
||||||
|
elif resolve_git_identity >/dev/null 2>&1; then
|
||||||
|
# A git identity IS set and the ladder still could not resolve a login.
|
||||||
|
# The named reason is already on stderr. Falling through to the map here
|
||||||
|
# would hand this seat a SHARED login (bare `usc` / `mosaicstack`) — the
|
||||||
|
# identity-borrowing #1356 exists to stop. Fail closed instead.
|
||||||
|
exit 2
|
||||||
|
else
|
||||||
|
# No identity: a human at a terminal. Owner-derived map, unchanged. This
|
||||||
|
# is the same point at which the token path stops enforcing.
|
||||||
case "${REPO%%/*}" in
|
case "${REPO%%/*}" in
|
||||||
usc|USC) LOGIN=usc ;;
|
usc|USC) LOGIN=usc ;;
|
||||||
mosaicstack|mosaic) LOGIN=mosaicstack ;;
|
mosaicstack|mosaic) LOGIN=mosaicstack ;;
|
||||||
*) LOGIN="$(get_gitea_login_for_repo_override 2>/dev/null || true)" ;;
|
|
||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
[[ -n "$LOGIN" ]] || { echo "FATAL: could not resolve a Gitea login for $REPO (pass -L or set GITEA_LOGIN)" >&2; exit 2; }
|
[[ -n "$LOGIN" ]] || { echo "FATAL: could not resolve a Gitea login for $REPO (pass -L or set GITEA_LOGIN)" >&2; exit 2; }
|
||||||
|
|
||||||
command -v tea >/dev/null || { echo "FATAL: tea not found" >&2; exit 1; }
|
command -v tea >/dev/null || { echo "FATAL: tea not found" >&2; exit 1; }
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ case "$PLATFORM" in
|
|||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||||
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override) || {
|
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override "$REPO_OVERRIDE") || {
|
||||||
echo "Error: Could not resolve Gitea login for --repo override. Set GITEA_LOGIN or configure a default tea login." >&2
|
echo "Error: could not resolve a Gitea login for the --repo override (the lines above say why). Set GITEA_LOGIN to name one explicitly." >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
gh pr view "$PR_NUMBER" --repo "$REPO_INFO"
|
gh pr view "$PR_NUMBER" --repo "$REPO_INFO"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||||
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override) || {
|
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override "$REPO_OVERRIDE") || {
|
||||||
echo "Error: Could not resolve Gitea login for --repo override. Set GITEA_LOGIN or configure a default tea login." >&2
|
echo "Error: could not resolve a Gitea login for the --repo override (the lines above say why). Set GITEA_LOGIN to name one explicitly." >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -110,8 +110,20 @@ chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
|
|||||||
|
|
||||||
run_in_repo() {
|
run_in_repo() {
|
||||||
(
|
(
|
||||||
|
# HERMETICITY, second half (#1356). The empty repo-local `mosaic.gitIdentity`
|
||||||
|
# above pins the git-config route into identity resolution. It does NOT pin
|
||||||
|
# the environment route, and MOSAIC_GIT_IDENTITY is checked FIRST — so on any
|
||||||
|
# provisioned seat, where the launcher exports it, this suite failed before
|
||||||
|
# any change: rc=1 as-is, rc=0 under `env -u MOSAIC_GIT_IDENTITY`, one
|
||||||
|
# variable. A suite that cannot run on a seat cannot guard this code for the
|
||||||
|
# agents that actually run it.
|
||||||
|
#
|
||||||
|
# Unset rather than set empty: an empty MOSAIC_GIT_IDENTITY and an absent one
|
||||||
|
# take different branches in resolve_git_identity(), and the case under test
|
||||||
|
# is "no identity at all".
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
PATH="$BIN_DIR:$PATH" \
|
env -u MOSAIC_GIT_IDENTITY \
|
||||||
|
PATH="${_SANDBOX_BIN:-$BIN_DIR}:$PATH" \
|
||||||
HOME="$HOME_DIR" \
|
HOME="$HOME_DIR" \
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||||
@@ -307,14 +319,11 @@ SH
|
|||||||
chmod +x "$BIN_DIR2/tea"
|
chmod +x "$BIN_DIR2/tea"
|
||||||
|
|
||||||
run_in_repo2() {
|
run_in_repo2() {
|
||||||
(
|
# Same sandbox as run_in_repo, different mock tea (BIN_DIR2 defines a
|
||||||
cd "$REPO_DIR"
|
# mosaicstack login). This MUST delegate rather than re-implement: it was a
|
||||||
PATH="$BIN_DIR2:$PATH" \
|
# copy once, and the copy silently missed the MOSAIC_GIT_IDENTITY unset, so
|
||||||
HOME="$HOME_DIR" \
|
# the suite kept failing on a seat after run_in_repo was already fixed.
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
_SANDBOX_BIN="$BIN_DIR2" run_in_repo "$@"
|
||||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
|
||||||
"$@"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
@@ -340,6 +349,151 @@ if [[ "$override_wins" != "mosaicstack" ]]; then
|
|||||||
fi
|
fi
|
||||||
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
|
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# #1356: the git-identity ladder. A seat declares who it is (MOSAIC_GIT_IDENTITY
|
||||||
|
# or `git config mosaic.gitIdentity`); resolution must use THAT seat's login and
|
||||||
|
# must REFUSE to borrow another one when it is absent. Silently borrowing
|
||||||
|
# satisfies gate 16 mechanically (a review exists) while violating it (the
|
||||||
|
# reviewer and the author are the same actor under two names).
|
||||||
|
#
|
||||||
|
# BIN_DIR3 mocks a tea that holds a canonical per-seat login, which is what a
|
||||||
|
# projected seat looks like. BIN_DIR2 (mosaicstack only) is reused as the
|
||||||
|
# "seat has no login" case — no third mock needed for the negative branch.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
BIN_DIR3="$WORK_DIR/bin3"
|
||||||
|
mkdir -p "$BIN_DIR3"
|
||||||
|
cp "$BIN_DIR/curl" "$BIN_DIR3/curl"
|
||||||
|
cat > "$BIN_DIR3/tea" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "$*" == "login list --output json" ]]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
[
|
||||||
|
{"name":"mosaicstack","url":"https://git.mosaicstack.dev","user":"ci-bot"},
|
||||||
|
{"name":"mosaicstack-testseat","url":"https://git.mosaicstack.dev","user":"testseat"},
|
||||||
|
{"name":"usc","url":"https://git.uscllc.com","user":"ci-bot"}
|
||||||
|
]
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
chmod +x "$BIN_DIR3/tea"
|
||||||
|
|
||||||
|
run_in_repo3() { _SANDBOX_BIN="$BIN_DIR3" run_in_repo "$@"; }
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
|
||||||
|
# Branch 1 (host path): identity set, canonical login PRESENT -> that login wins
|
||||||
|
# over the shared `mosaicstack` one, which is what host-matching alone would pick.
|
||||||
|
ladder_hit=$(run_in_repo3 env MOSAIC_GIT_IDENTITY=testseat bash -c '
|
||||||
|
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||||
|
get_gitea_login_for_host git.mosaicstack.dev
|
||||||
|
')
|
||||||
|
if [[ "$ladder_hit" != "mosaicstack-testseat" ]]; then
|
||||||
|
echo "Expected identity ladder to select 'mosaicstack-testseat'; got '$ladder_hit'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# CONTROL for branch 1: the same mock, no identity, must still resolve by host.
|
||||||
|
# Without this, branch 1 passing proves nothing about the ladder specifically --
|
||||||
|
# it would also pass if the code just picked the last matching login.
|
||||||
|
ladder_none=$(run_in_repo3 bash -c '
|
||||||
|
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||||
|
get_gitea_login_for_host git.mosaicstack.dev
|
||||||
|
')
|
||||||
|
if [[ "$ladder_none" != "mosaicstack" ]]; then
|
||||||
|
echo "Expected no-identity host resolution to stay 'mosaicstack'; got '$ladder_none'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Branch 2 (host path): identity set, canonical login ABSENT -> fail closed with a
|
||||||
|
# named error. Two assertions, and they are not the same one twice: rc!=0 proves
|
||||||
|
# it refused, and the ABSENCE of any login on stdout proves it did not borrow the
|
||||||
|
# `mosaicstack` login that is sitting right there matching the host.
|
||||||
|
ladder_err=$(run_in_repo2 env MOSAIC_GIT_IDENTITY=testseat bash -c '
|
||||||
|
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||||
|
get_gitea_login_for_host git.mosaicstack.dev
|
||||||
|
' 2>&1 1>/dev/null || true)
|
||||||
|
ladder_out=$(run_in_repo2 env MOSAIC_GIT_IDENTITY=testseat bash -c '
|
||||||
|
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||||
|
get_gitea_login_for_host git.mosaicstack.dev
|
||||||
|
' 2>/dev/null || true)
|
||||||
|
if [[ -n "$ladder_out" ]]; then
|
||||||
|
echo "Identity ladder BORROWED login '$ladder_out' instead of failing closed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q "mosaicstack-testseat" <<<"$ladder_err"; then
|
||||||
|
echo "Expected the error to name the login it wanted; got: $ladder_err" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Branch 3: `git config mosaic.gitIdentity` is the second rung and must work when
|
||||||
|
# the environment variable is absent -- a seat may be configured either way.
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity testseat
|
||||||
|
ladder_gitcfg=$(run_in_repo3 bash -c '
|
||||||
|
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||||
|
get_gitea_login_for_host git.mosaicstack.dev
|
||||||
|
')
|
||||||
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity || true
|
||||||
|
if [[ "$ladder_gitcfg" != "mosaicstack-testseat" ]]; then
|
||||||
|
echo "Expected git-config identity rung to select 'mosaicstack-testseat'; got '$ladder_gitcfg'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Branch 4 (--repo override path): same rule, owner-derived instead of host-derived.
|
||||||
|
override_ladder=$(run_in_repo3 env MOSAIC_GIT_IDENTITY=testseat bash -c '
|
||||||
|
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||||
|
get_gitea_login_for_repo_override mosaicstack/stack
|
||||||
|
')
|
||||||
|
if [[ "$override_ladder" != "mosaicstack-testseat" ]]; then
|
||||||
|
echo "Expected --repo override ladder to select 'mosaicstack-testseat'; got '$override_ladder'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Branch 5: explicit GITEA_LOGIN outranks the ladder. An operator naming a login
|
||||||
|
# by hand is a deliberate act, not an accident to be second-guessed.
|
||||||
|
override_explicit=$(run_in_repo3 env MOSAIC_GIT_IDENTITY=testseat GITEA_LOGIN=mosaicstack bash -c '
|
||||||
|
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||||
|
get_gitea_login_for_repo_override mosaicstack/stack
|
||||||
|
')
|
||||||
|
if [[ "$override_explicit" != "mosaicstack" ]]; then
|
||||||
|
echo "Expected explicit GITEA_LOGIN to outrank the identity ladder; got '$override_explicit'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Branch 6 (#1357 S1): with tea ABSENT from PATH, the override path must say tea is
|
||||||
|
# missing, not "no tea login named X exists" (a cause that was never checked) and
|
||||||
|
# not the seat-logins.sh advice, which cannot be followed without tea.
|
||||||
|
NOTEA_BIN="$WORK_DIR/notea-bin"; mkdir -p "$NOTEA_BIN"
|
||||||
|
for t in bash git python3 sed grep cat mktemp dirname basename readlink env sort head tr cut; do
|
||||||
|
_p="$(command -v "$t" 2>/dev/null || true)"; [[ -n "$_p" ]] && ln -sf "$_p" "$NOTEA_BIN/$t"
|
||||||
|
done
|
||||||
|
override_notea_rc=0
|
||||||
|
override_notea_err=$(cd "$REPO_DIR" && env -u GITEA_LOGIN \
|
||||||
|
PATH="$NOTEA_BIN" HOME="$HOME_DIR" MOSAIC_GIT_IDENTITY=testseat \
|
||||||
|
bash -c '
|
||||||
|
command -v tea >/dev/null 2>&1 && { echo "SETUP: tea still on PATH"; exit 99; }
|
||||||
|
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||||
|
get_gitea_login_for_repo_override mosaicstack/stack
|
||||||
|
' 2>&1 >/dev/null) || override_notea_rc=$?
|
||||||
|
if [[ "$override_notea_rc" != 1 ]]; then
|
||||||
|
echo "Expected --repo override path to fail (rc=1) with tea absent; got rc=$override_notea_rc: $override_notea_err" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q 'tea is not installed' <<<"$override_notea_err"; then
|
||||||
|
echo "Expected --repo override path to name tea as absent; got: $override_notea_err" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q 'has no tea login\|seat-logins.sh' <<<"$override_notea_err"; then
|
||||||
|
echo "Override path diagnosed a missing LOGIN while tea itself is absent: $override_notea_err" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# #865 Blocker 1 & 2: get_gitea_token_for_login must resolve the SAME token as
|
# #865 Blocker 1 & 2: get_gitea_token_for_login must resolve the SAME token as
|
||||||
# PyYAML would (or fail closed identically) even when PyYAML is ABSENT, and must
|
# PyYAML would (or fail closed identically) even when PyYAML is ABSENT, and must
|
||||||
|
|||||||
@@ -76,7 +76,22 @@ exit 0
|
|||||||
EOF
|
EOF
|
||||||
chmod +x "$MOCK_BIN/tea"
|
chmod +x "$MOCK_BIN/tea"
|
||||||
}
|
}
|
||||||
|
# #1356: login resolution is now identity-aware, so the tea-branch fixture must
|
||||||
|
# offer the login the RUNNER's identity resolves to; otherwise every case below
|
||||||
|
# fails closed before reaching the branch under test.
|
||||||
|
#
|
||||||
|
# This does NOT make the suite hermetic, and it is not trying to. The API-path
|
||||||
|
# cases (5-7) need a usable Gitea token, and with an identity set the token path
|
||||||
|
# reads that seat's credential file rather than the GITEA_TOKEN exported above.
|
||||||
|
# So this suite passes only where the runner owns a real credential for its own
|
||||||
|
# identity, and fails with no identity at all -- on this branch and on its base
|
||||||
|
# alike. That is a pre-existing hole in the fixture, filed separately; pinning a
|
||||||
|
# synthetic identity here would only convert it into a confident-looking green.
|
||||||
|
_LOGIN_IDENT="${MOSAIC_GIT_IDENTITY:-}"
|
||||||
LOGIN_JSON='[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
|
LOGIN_JSON='[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
|
||||||
|
if [[ -n "$_LOGIN_IDENT" ]]; then
|
||||||
|
LOGIN_JSON='[{"name":"mosaicstack-'"$_LOGIN_IDENT"'","url":"https://git.mosaicstack.dev"},{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
|
||||||
|
fi
|
||||||
|
|
||||||
# The mocks must be the ones that run. Without this, a failed setup silently falls through
|
# The mocks must be the ones that run. Without this, a failed setup silently falls through
|
||||||
# to the real tea/curl and the "test" mutates the real provider.
|
# to the real tea/curl and the "test" mutates the real provider.
|
||||||
|
|||||||
@@ -10,6 +10,12 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# HERMETICITY (#1356): this suite's subject is body quoting, not identity. An
|
||||||
|
# ambient MOSAIC_GIT_IDENTITY (every provisioned seat exports one) would make the
|
||||||
|
# identity ladder demand a per-seat login this fixture does not define, and the
|
||||||
|
# suite would fail for a reason it is not testing.
|
||||||
|
unset MOSAIC_GIT_IDENTITY
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-body-safety}"
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-body-safety}"
|
||||||
REPO_DIR="$WORK_DIR/repo"
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression: issue-view.sh must show comment BODIES, on both paths, and must name
|
||||||
|
# the failure tea actually reported instead of guessing a credential cause (#1357).
|
||||||
|
#
|
||||||
|
# Four defects, each with its own case below:
|
||||||
|
# F1 tea exits 1 in any repo with extensions.worktreeconfig=true; the wrapper must
|
||||||
|
# say so (git-config condition) and fall back to the API.
|
||||||
|
# F2 the API fallback dumped raw issue JSON, which carries only a comment COUNT.
|
||||||
|
# F3 the tea path never passed --comments, so tea prompted (non-interactively: nothing).
|
||||||
|
# F4 on ANY tea failure the wrapper printed the REVOKED OR STALE TOKEN note.
|
||||||
|
#
|
||||||
|
# Verification bar (plan §6): assert a real comment BODY appears, not a count and not
|
||||||
|
# `grep -c comment` (that instrument matched the issue title and read inverted).
|
||||||
|
#
|
||||||
|
# Hermetic: mock tea and curl on PATH, sandboxed repo. Resolves no real credentials.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}"
|
||||||
|
SANDBOX="$WORK_ROOT/issue-view-comments-test-$$"
|
||||||
|
MOCK_BIN="$SANDBOX/bin"; REPO_DIR="$SANDBOX/repo"; CALLS="$SANDBOX/calls.log"
|
||||||
|
cleanup() { rm -rf "$SANDBOX"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TARGET="$SCRIPT_DIR/issue-view.sh"
|
||||||
|
[ -f "$TARGET" ] || { echo "FAIL: issue-view.sh not found beside this test"; exit 1; }
|
||||||
|
fail() { echo "FAIL: $*"; exit 1; }
|
||||||
|
|
||||||
|
mkdir -p "$MOCK_BIN" "$REPO_DIR" || fail "setup: cannot create sandbox under $WORK_ROOT"
|
||||||
|
: > "$CALLS" || fail "setup: cannot write calls log at $CALLS"
|
||||||
|
cd "$REPO_DIR" || fail "setup: cannot cd into $REPO_DIR"
|
||||||
|
git init -q || fail "setup: git init failed"
|
||||||
|
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git || fail "setup: git remote add failed"
|
||||||
|
export PATH="$MOCK_BIN:$PATH" CALLS
|
||||||
|
export GITEA_URL="https://git.mosaicstack.dev"
|
||||||
|
export GITEA_TOKEN="redacted-test-token"
|
||||||
|
# The identity ladder must not reach for this seat's real login; the mock tea below
|
||||||
|
# defines the only login that exists in this sandbox.
|
||||||
|
unset MOSAIC_GIT_IDENTITY
|
||||||
|
# No fleet in the sandbox: on a host that runs one, get_gitea_token fails closed for an
|
||||||
|
# identity-less caller (by design), which would make this test measure the host, not
|
||||||
|
# the wrapper. An empty brain home makes the sandbox the same on every host.
|
||||||
|
export MOSAIC_BRAIN_HOME="$SANDBOX/brain"
|
||||||
|
mkdir -p "$MOSAIC_BRAIN_HOME" || fail "setup: cannot create sandbox brain home"
|
||||||
|
|
||||||
|
# Distinctive strings: a comment body that appears nowhere else, and an issue title
|
||||||
|
# that contains the word "comment" so a count-of-the-word instrument would misread.
|
||||||
|
BODY_MARKER="zebra-quill-comment-body-7731"
|
||||||
|
ISSUE_TITLE="wrapper never shows a comment"
|
||||||
|
|
||||||
|
# --- mock curl: serves the issue and its comments; logs every call --------------
|
||||||
|
cat > "$MOCK_BIN/curl" <<EOF
|
||||||
|
#!/bin/bash
|
||||||
|
url=""
|
||||||
|
while [ \$# -gt 0 ]; do
|
||||||
|
case "\$1" in
|
||||||
|
http*) url="\$1"; shift ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf 'curl %s\n' "\$url" >> "$CALLS"
|
||||||
|
case "\$url" in
|
||||||
|
*/issues/77/comments)
|
||||||
|
if [ "\${MOCK_NO_COMMENTS:-}" = "1" ]; then echo '[]'; else
|
||||||
|
echo '[{"id":1,"user":{"login":"alice"},"created_at":"2026-08-21T00:00:00Z","body":"$BODY_MARKER"}]'; fi ;;
|
||||||
|
*/issues/77)
|
||||||
|
echo '{"number":77,"title":"$ISSUE_TITLE","state":"open","user":{"login":"bob"},"created_at":"2026-08-21T00:00:00Z","labels":[],"milestone":null,"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/issues/77","body":"issue body","comments":1}' ;;
|
||||||
|
*) echo '{}' ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod +x "$MOCK_BIN/curl"
|
||||||
|
|
||||||
|
# --- mock tea: MOCK_TEA_MODE selects the behaviour under test --------------------
|
||||||
|
# ok : prints the issue, and the comment body ONLY when --comments is passed (F3)
|
||||||
|
# wtconfig : exits 1 with the repositoryformatversion error (F1/F4)
|
||||||
|
# badtoken : exits 1 with tea's credential error (F4 control: credential wording allowed)
|
||||||
|
cat > "$MOCK_BIN/tea" <<EOF
|
||||||
|
#!/bin/bash
|
||||||
|
printf 'tea %s\n' "\$*" >> "$CALLS"
|
||||||
|
if [[ "\$*" == *"login list"* ]]; then
|
||||||
|
echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'; exit 0
|
||||||
|
fi
|
||||||
|
case "\${MOCK_TEA_MODE:-ok}" in
|
||||||
|
wtconfig) echo 'Error: core.repositoryformatversion does not support extension: worktreeconfig' >&2; exit 1 ;;
|
||||||
|
badtoken) echo 'Failed to create Gitea client: invalid username, password or token' >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
echo "# #77 $ISSUE_TITLE (open)"
|
||||||
|
echo "issue body"
|
||||||
|
if [[ "\$*" == *"--comments"* ]]; then echo "$BODY_MARKER"; fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod +x "$MOCK_BIN/tea"
|
||||||
|
|
||||||
|
[ "$(command -v tea)" = "$MOCK_BIN/tea" ] || fail "setup: tea does not resolve inside the sandbox"
|
||||||
|
[ "$(command -v curl)" = "$MOCK_BIN/curl" ] || fail "setup: curl does not resolve inside the sandbox"
|
||||||
|
|
||||||
|
run() { bash "$TARGET" -i 77 >"$SANDBOX/out" 2>"$SANDBOX/err"; echo $?; }
|
||||||
|
|
||||||
|
# F3: tea path shows the comment body, which the mock emits only under --comments.
|
||||||
|
: > "$CALLS"
|
||||||
|
rc=$(MOCK_TEA_MODE=ok run)
|
||||||
|
[ "$rc" = 0 ] || fail "F3: expected rc=0 on the tea path, got $rc: $(cat "$SANDBOX/err")"
|
||||||
|
grep -q -- '--comments' "$CALLS" || fail "F3: tea was not invoked with --comments: $(cat "$CALLS")"
|
||||||
|
grep -q "$BODY_MARKER" "$SANDBOX/out" || fail "F3: comment body missing from tea-path output"
|
||||||
|
if grep -q '^curl' "$CALLS"; then fail "F3: tea path succeeded but the API fallback ran anyway"; fi
|
||||||
|
|
||||||
|
# F1 + F2: worktreeconfig failure is named as a git-config condition, falls back to
|
||||||
|
# the API, and the API rendering includes the comment BODY.
|
||||||
|
: > "$CALLS"
|
||||||
|
rc=$(MOCK_TEA_MODE=wtconfig run)
|
||||||
|
[ "$rc" = 0 ] || fail "F1: expected rc=0 via API fallback, got $rc: $(cat "$SANDBOX/err")"
|
||||||
|
grep -q 'worktreeconfig' "$SANDBOX/err" || fail "F1: stderr does not name the worktreeconfig cause: $(cat "$SANDBOX/err")"
|
||||||
|
grep -q 'not a credential problem' "$SANDBOX/err" || fail "F1: stderr does not rule out the credential cause"
|
||||||
|
grep -q 'issues/77/comments' "$CALLS" || fail "F2: API fallback never fetched /comments: $(cat "$CALLS")"
|
||||||
|
grep -q "$BODY_MARKER" "$SANDBOX/out" || fail "F2: comment body missing from API-path output"
|
||||||
|
grep -q "$ISSUE_TITLE" "$SANDBOX/out" || fail "F2: issue title missing from API-path output"
|
||||||
|
if grep -q 'REVOKED OR STALE' "$SANDBOX/err"; then fail "F4: stale-token note printed for a git-config failure"; fi
|
||||||
|
if grep -q '"comments": 1' "$SANDBOX/out"; then fail "F2: output is still raw JSON (comment count instead of bodies)"; fi
|
||||||
|
|
||||||
|
# F4 control: a real credential error from tea may still carry the credential note,
|
||||||
|
# and tea's own line must be relayed so the reader sees the actual cause.
|
||||||
|
: > "$CALLS"
|
||||||
|
rc=$(MOCK_TEA_MODE=badtoken run)
|
||||||
|
[ "$rc" = 0 ] || fail "F4 control: expected rc=0 via API fallback, got $rc"
|
||||||
|
grep -q 'invalid username, password or token' "$SANDBOX/err" || fail "F4: tea's own error line was not relayed"
|
||||||
|
if grep -q 'worktreeconfig' "$SANDBOX/err"; then fail "F4: git-config wording printed for a credential failure"; fi
|
||||||
|
|
||||||
|
# Negative control: an issue with no comments prints no comment section on the API
|
||||||
|
# path. Without this, a renderer that always prints a section would pass F2.
|
||||||
|
: > "$CALLS"
|
||||||
|
rc=$(MOCK_TEA_MODE=wtconfig MOCK_NO_COMMENTS=1 run)
|
||||||
|
[ "$rc" = 0 ] || fail "negative control: expected rc=0, got $rc"
|
||||||
|
if grep -q -- '--- Comments' "$SANDBOX/out"; then fail "negative control: comment section printed for an issue with no comments"; fi
|
||||||
|
if grep -q "$BODY_MARKER" "$SANDBOX/out"; then fail "negative control: a comment body appeared for an issue with no comments"; fi
|
||||||
|
|
||||||
|
echo "issue-view comments regression harness passed"
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
# --- tools/git: the #1007 five — non-hermetic, resolve real credentials ---
|
# --- tools/git: the #1007 five — non-hermetic, resolve real credentials ---
|
||||||
packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix (git -C scoping)
|
packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix (git -C scoping)
|
||||||
packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix
|
packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix
|
||||||
packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix
|
|
||||||
packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh | resolves real credentials (#1007 census, fourth entry via family-grep); joins CI after the wrapper-half hermeticity fix
|
packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh | resolves real credentials (#1007 census, fourth entry via family-grep); joins CI after the wrapper-half hermeticity fix
|
||||||
packages/mosaic/framework/tools/git/test-issue-comment-readback.sh | resolves real credentials (#1007 census, fifth entry); joins CI after the wrapper-half hermeticity fix
|
packages/mosaic/framework/tools/git/test-issue-comment-readback.sh | resolves real credentials (#1007 census, fifth entry); joins CI after the wrapper-half hermeticity fix
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,9 @@
|
|||||||
# 0 delivered (submitted) or queued (agent busy; will process when free)
|
# 0 delivered (submitted) or queued (agent busy; will process when free)
|
||||||
# 1 tmux target not found
|
# 1 tmux target not found
|
||||||
# 2 submission NOT confirmed — either still an unsubmitted draft, or the REPL
|
# 2 submission NOT confirmed — either still an unsubmitted draft, or the REPL
|
||||||
# input prompt could not be located to confirm the message actually landed.
|
# input box could not be located to confirm the message actually landed.
|
||||||
|
# Locating the box is runtime-specific; see locate_input_box() below, and
|
||||||
|
# add a shape there before pointing this tool at a new runtime.
|
||||||
# Delivery is NEVER inferred from absence of evidence: if we cannot positively
|
# Delivery is NEVER inferred from absence of evidence: if we cannot positively
|
||||||
# see the input box clear of the message (or the queued banner), we fail loud
|
# see the input box clear of the message (or the queued banner), we fail loud
|
||||||
# so the sender learns immediately instead of a silent worker->lead stall.
|
# so the sender learns immediately instead of a silent worker->lead stall.
|
||||||
@@ -97,10 +99,50 @@ printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
|
|||||||
# would otherwise accumulate forever.
|
# would otherwise accumulate forever.
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
|
|
||||||
|
# Locate the REPL input box in a captured pane. Prints the box's contents on
|
||||||
|
# stdout and returns 0 when the box was FOUND; returns 1 when it could not be
|
||||||
|
# located at all. Found-but-empty is a real, distinct answer (an empty input box
|
||||||
|
# is what a submitted message leaves behind), so the caller must branch on the
|
||||||
|
# return code, never on whether the output is empty.
|
||||||
|
#
|
||||||
|
# Two REPL shapes are recognised:
|
||||||
|
# * a prompt-glyph line — `❯`, a leading `>`, or `│ >`. Claude Code and most
|
||||||
|
# readline REPLs.
|
||||||
|
# * a box drawn as two horizontal `─` rules with the input between them and NO
|
||||||
|
# prompt glyph anywhere. pi renders this. Anchoring on the LAST rule pair is
|
||||||
|
# what makes it safe: agent output can contain its own rules, but nothing is
|
||||||
|
# drawn below the input box except the status line.
|
||||||
|
#
|
||||||
|
# Adding a runtime means adding its shape HERE. A shape that is missing does not
|
||||||
|
# degrade gracefully: it turns every send to that runtime into a false
|
||||||
|
# "may be UNDELIVERED", which is what #1362 measured on pi and #1257 on another
|
||||||
|
# arm of the same probe.
|
||||||
|
locate_input_box() {
|
||||||
|
local pane=$1 glyph_line rule_lines top bottom
|
||||||
|
glyph_line=$(printf '%s\n' "$pane" | grep -E '❯|^>|│ >' | tail -1)
|
||||||
|
if [ -n "$glyph_line" ]; then printf '%s\n' "$glyph_line"; return 0; fi
|
||||||
|
rule_lines=$(printf '%s\n' "$pane" | grep -nE '^[[:space:]]*─{4,}[[:space:]]*$' | cut -d: -f1 | tail -2)
|
||||||
|
[ -n "$rule_lines" ] || return 1
|
||||||
|
# Split the (at most two) captured line numbers with parameter expansion. Not
|
||||||
|
# `head -1`: piping into an early-exiting consumer SIGPIPEs the producer, which
|
||||||
|
# under `set -euo pipefail` aborts the caller with rc=141 and no output. The
|
||||||
|
# scripts/pipefail-early-exit.test.mjs guard reds on that shape, correctly.
|
||||||
|
# With one rule captured both halves resolve to the same value and the
|
||||||
|
# ordering test below rejects it, which is the answer we want anyway.
|
||||||
|
top=${rule_lines%%$'\n'*}
|
||||||
|
bottom=${rule_lines##*$'\n'}
|
||||||
|
[ "$top" != "$bottom" ] || return 1
|
||||||
|
[ "$bottom" -gt "$top" ] || return 1
|
||||||
|
# An empty range (adjacent rules) prints nothing and still returns 0: found,
|
||||||
|
# empty, which is the delivered shape.
|
||||||
|
printf '%s\n' "$pane" | sed -n "$((top + 1)),$((bottom - 1))p"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# 2) Submit, then POSITIVELY confirm submission; flush with another Enter if it is
|
# 2) Submit, then POSITIVELY confirm submission; flush with another Enter if it is
|
||||||
# still a draft. Success requires positive evidence — the queued banner, OR the
|
# still a draft. Success requires positive evidence — the queued banner, OR the
|
||||||
# REPL input box located AND clear of our message tail. The historical bug was
|
# REPL input box located AND clear of our message tail. The historical bug was
|
||||||
# treating ABSENCE of a draft as delivery: if the prompt glyph was never matched
|
# treating ABSENCE of a draft as delivery: if the input box was never located
|
||||||
# (wrong pane / prompt-glyph drift), an unsubmitted message read as "delivered"
|
# (wrong pane / prompt-glyph drift), an unsubmitted message read as "delivered"
|
||||||
# and worker->lead relays stalled silently. We now default to UNCONFIRMED and only
|
# and worker->lead relays stalled silently. We now default to UNCONFIRMED and only
|
||||||
# upgrade to delivered on positive evidence; anything we cannot confirm fails loud.
|
# upgrade to delivered on positive evidence; anything we cannot confirm fails loud.
|
||||||
@@ -113,15 +155,14 @@ for attempt in $(seq 1 $((RETRIES + 1))); do
|
|||||||
if grep -qF "$QUEUED_RE" <<<"$pane"; then
|
if grep -qF "$QUEUED_RE" <<<"$pane"; then
|
||||||
status="queued"; break
|
status="queued"; break
|
||||||
fi
|
fi
|
||||||
# Locate the REPL input box (prompt glyph). If we cannot see it, we have NO
|
# If we cannot see the input box, we have NO evidence of submission state —
|
||||||
# evidence of submission state — stay UNCONFIRMED and retry; never infer delivery.
|
# stay UNCONFIRMED and retry; never infer delivery.
|
||||||
promptline=$(printf '%s' "$pane" | grep -E '❯|^>|│ >' | tail -1)
|
if ! inputbox=$(locate_input_box "$pane"); then
|
||||||
if [ -z "$promptline" ]; then
|
|
||||||
status="unconfirmed"; continue
|
status="unconfirmed"; continue
|
||||||
fi
|
fi
|
||||||
# Input box located AND still carrying our tail => unsubmitted draft. Flush + retry.
|
# Input box located AND still carrying our tail => unsubmitted draft. Flush + retry.
|
||||||
# (Submitted messages scroll up into history; a draft stays on the ❯ line.)
|
# (Submitted messages scroll up into history; a draft stays in the box.)
|
||||||
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$promptline"; then
|
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$inputbox"; then
|
||||||
status="draft"; continue
|
status="draft"; continue
|
||||||
fi
|
fi
|
||||||
# Input box located AND clear of our tail => positively submitted. This is the
|
# Input box located AND clear of our tail => positively submitted. This is the
|
||||||
@@ -135,6 +176,6 @@ case "$status" in
|
|||||||
delivered) echo "✓ delivered to $TARGET"; exit 0 ;;
|
delivered) echo "✓ delivered to $TARGET"; exit 0 ;;
|
||||||
queued) echo "✓ queued to $TARGET (agent busy — will process when it returns to prompt)"; exit 0 ;;
|
queued) echo "✓ queued to $TARGET (agent busy — will process when it returns to prompt)"; exit 0 ;;
|
||||||
draft) echo "✗ still an unsubmitted draft on $TARGET after $RETRIES flush attempts" >&2; exit 2 ;;
|
draft) echo "✗ still an unsubmitted draft on $TARGET after $RETRIES flush attempts" >&2; exit 2 ;;
|
||||||
unconfirmed) echo "✗ could not confirm submission on $TARGET: REPL input prompt not locatable after $((RETRIES + 1)) attempts — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;;
|
unconfirmed) echo "✗ could not confirm submission on $TARGET: REPL input box not locatable after $((RETRIES + 1)) attempts — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;;
|
||||||
*) echo "✗ could not confirm submission on $TARGET (unexpected state '$status')" >&2; exit 2 ;;
|
*) echo "✗ could not confirm submission on $TARGET (unexpected state '$status')" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -10,6 +10,13 @@
|
|||||||
# "could not confirm submission").
|
# "could not confirm submission").
|
||||||
# 3. DRAFT — a `❯ `-prompt pane that never submits (message stays on the
|
# 3. DRAFT — a `❯ `-prompt pane that never submits (message stays on the
|
||||||
# input line) => exit 2, stderr "unsubmitted draft".
|
# input line) => exit 2, stderr "unsubmitted draft".
|
||||||
|
# 4. DELIVERED — a pane whose input box is two `─` rules with NO prompt glyph
|
||||||
|
# (box shape) anywhere (pi's shape) and which submits => exit 0. Pre-#1362
|
||||||
|
# the glyph probe could not see this box at all, so EVERY send
|
||||||
|
# to such a pane reported "may be UNDELIVERED" while landing.
|
||||||
|
# 5. DRAFT — the same glyphless box, holding our tail across every flush
|
||||||
|
# (box shape) Enter => exit 2, stderr "unsubmitted draft". Pre-#1362 this
|
||||||
|
# also reported unconfirmed, so the true state was invisible.
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||||
@@ -69,6 +76,56 @@ else
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- Fixtures 4 and 5: a pi-shaped pane. The input box is two `─` rules with the
|
||||||
|
# text between them and NO prompt glyph anywhere, so the glyph probe alone can
|
||||||
|
# never locate it and every send reports "may be UNDELIVERED" (#1362). The
|
||||||
|
# renderer below is the shape, not the runtime: MODE=clear submits (box empties),
|
||||||
|
# MODE=keep leaves the text sitting in the box.
|
||||||
|
cat > "$TMP/pibox.sh" <<'PIBOX'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
MODE=${1:-clear}
|
||||||
|
RULE=$(printf '─%.0s' $(seq 1 60))
|
||||||
|
buf=""
|
||||||
|
draw() {
|
||||||
|
printf '\033[H\033[2J'
|
||||||
|
printf 'fixture output line\n\n'
|
||||||
|
printf '%s\n' "$RULE"
|
||||||
|
printf '%s\n' "$buf"
|
||||||
|
printf '%s\n' "$RULE"
|
||||||
|
printf '~/fixture (main)\n'
|
||||||
|
printf 'tok 0 model fixture\n'
|
||||||
|
}
|
||||||
|
draw
|
||||||
|
while IFS= read -r line; do
|
||||||
|
# keep: hold the tail across every flush Enter, which is what a stuck draft does.
|
||||||
|
if [ "$MODE" = keep ]; then [ -n "$line" ] && buf=$line; else buf=""; fi
|
||||||
|
draw
|
||||||
|
done
|
||||||
|
PIBOX
|
||||||
|
chmod +x "$TMP/pibox.sh"
|
||||||
|
|
||||||
|
tmux -L "$SOCKET" new-session -d -s pibox -c "$TMP" "exec bash '$TMP/pibox.sh' clear"
|
||||||
|
sleep 0.3
|
||||||
|
out=$("$SEND" -L "$SOCKET" -t "=pibox" -m "pi fixture four delivered ok" 2>"$TMP/e4"); rc=$?
|
||||||
|
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||||
|
ok "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered"
|
||||||
|
else
|
||||||
|
no "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e4")]"
|
||||||
|
fi
|
||||||
|
|
||||||
|
tmux -L "$SOCKET" new-session -d -s piboxdraft -c "$TMP" "exec bash '$TMP/pibox.sh' keep"
|
||||||
|
sleep 0.3
|
||||||
|
if out=$("$SEND" -L "$SOCKET" -t "=piboxdraft" -r 1 -m "pi fixture five stuck in the box" 2>"$TMP/e5"); then
|
||||||
|
no "draft: glyphless box-drawn pane holding our tail must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||||
|
else
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -eq 2 ] && grep -qF "unsubmitted draft" "$TMP/e5"; then
|
||||||
|
ok "draft: message left in a glyphless box => exit 2 + 'unsubmitted draft'"
|
||||||
|
else
|
||||||
|
no "draft: message left in a glyphless box => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e5")]"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "---"
|
echo "---"
|
||||||
echo "PASS=$PASS FAIL=$FAIL"
|
echo "PASS=$PASS FAIL=$FAIL"
|
||||||
[ "$FAIL" -eq 0 ]
|
[ "$FAIL" -eq 0 ]
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ export const STAGES = [
|
|||||||
'bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh --self-test',
|
'bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh --self-test',
|
||||||
'bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh',
|
'bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh',
|
||||||
'bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh',
|
'bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh',
|
||||||
|
'bash packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh',
|
||||||
|
'bash packages/mosaic/framework/tools/git/test-issue-view-comments.sh',
|
||||||
|
'bash packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh',
|
||||||
'bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh',
|
'bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh',
|
||||||
'bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh',
|
'bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh',
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user