Compare commits

..
Author SHA1 Message Date
fred 62321700a3 fix(git): review follow-ups for #1357 (S1, S2, indent)
ci/woodpecker/pr/ci Pipeline was successful
S1: get_gitea_login_for_repo_override() now distinguishes "tea is not
installed" from "no tea login named X exists", mirroring the host path.
The old message diagnosed a cause that was never checked and pointed at
seat-logins.sh, which cannot run without tea. Branch 6 in
test-gitea-login-resolution.sh pins it (tea removed from PATH); reverting
the fix fails that branch.

S2: issue-list/pr-list/pr-view override-path error now points at the
lines above for the cause instead of suggesting a default tea login.

verify-release.mjs: indent of the #1356 test line fixed (cosmetic).
2026-08-21 18:12:12 -05:00
fred 341be60723 fix(git-tools): issue-view shows comment bodies and names the real tea failure (#1357)
Four defects in issue-view.sh, each pinned by the new hermetic suite
test-issue-view-comments.sh (mock tea + curl, sandboxed repo):

F1  tea exits 1 in any repo with extensions.worktreeconfig=true. The wrapper
    now names that as a git-config condition and falls back to the API.
F2  The API fallback dumped raw issue JSON, which carries only a comment
    COUNT. It now fetches /comments and renders issue + comment bodies.
F3  The tea path never passed --comments, so comment bodies were never shown
    non-interactively. It now does.
F4  Every tea failure printed the REVOKED OR STALE TOKEN note. The wrapper now
    relays tea's own error line and only hints at credentials when tea did.

The suite joins ci.yml and the verify-release canonical list (mirror test).

Closes #1357
2026-08-21 18:06:06 -05:00
7 changed files with 9 additions and 611 deletions
-5
View File
@@ -100,11 +100,6 @@ steps:
# 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
# it still blocks the three mistakes AND still lets reads, unwrapped
# endpoints and ordinary commands through. Both directions are asserted —
@@ -1,24 +0,0 @@
# 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).
@@ -1,227 +0,0 @@
#!/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), and the
# staging area is a single trap-swept directory (review 263 blocker 2): the
# admin token, the generated password, and the minted seat token all pass
# through 0600 files under a per-run staging dir removed by an EXIT/INT/TERM
# trap, so a transport failure mid-run cannot leave secrets at rest in /tmp.
#
# TRACE CHANNEL, stated plainly (review 263 blocker 1): this script is held to
# a higher bar than ordinary wrappers because it mints admin-grade
# credentials and a durable password. Secrets here are assembled FILE-TO-FILE
# — source token file, password generated straight into its staging file,
# bodies composed with jq from those files — so no secret is ever expanded
# into a shell word a trace would print. A plain `bash -x` of this script
# shows staging PATHS only. (The landed gitea_write_auth_config in
# detect-platform.sh still expands tokens into shell words and DOES leak
# under -x; that fleet-wide parity gap is tracked in its own issue — see the
# framework-hardening issue referenced from this PR.)
set -Eeuo pipefail
STAGE_DIR=""
cleanup_stage() {
[ -n "$STAGE_DIR" ] && rm -rf -- "$STAGE_DIR"
STAGE_DIR=""
}
trap cleanup_stage EXIT INT TERM
# All staging lives in one per-run dir, swept by the trap above. Files are
# created 0600 and secrets are moved between them only by tool reads
# (jq/cat), never through shell-word expansion.
new_stage() { STAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-mint.XXXXXX")"; chmod 700 "$STAGE_DIR"; }
# stage_auth <token-file> — curl --config carrying the Authorization header,
# reading the token from the file with jq so it never
# becomes a shell word.
# stage_user <seat> <pw-file> — curl --config with `user =`; the password is
# read from its file by jq. <seat> is not a secret.
# stage_body <template-json> <pw-file> — body file; jq injects the password
# file's value into the template. The mint body has
# no secret and is written directly.
stage_auth() {
local f="$STAGE_DIR/auth.cfg"
jq -rn --rawfile t "$1" '"header = \"Authorization: token " + $t + "\""' >"$f" || return 1
chmod 600 "$f"; printf '%s' "$f"
}
stage_user() {
local f="$STAGE_DIR/user.cfg"
jq -rn --rawfile p "$2" --arg u "$1" '"user = \"" + $u + ":" + $p + "\""' >"$f" || return 1
chmod 600 "$f"; printf '%s' "$f"
}
stage_body() {
# $1 is a JSON template string (no secrets); $2 is the password file. jq
# parses the template and injects the password read straight from the file.
local f="$STAGE_DIR/body.json"
jq -c --rawfile p "$2" '.password = $p' <<<"$1" >"$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; }
# Per-instance staging dir: everything under it dies with the trap, so a
# transport failure (review 263 blocker 2) cannot leave secrets at rest.
new_stage
AUTH_CFG="$(stage_auth "$ADMIN_TOKEN_FILE")"
# The password is generated STRAIGHT INTO its staging file; the variable
# below is its path, never the value (review 263 blocker 1).
openssl rand -base64 33 | tr -d '\n/+=' | head -c 32 >"$STAGE_DIR/pw"
chmod 600 "$STAGE_DIR/pw"
USER_CFG="$(stage_user "$SEAT" "$STAGE_DIR/pw")"
if curl -sf -o /dev/null --config "$AUTH_CFG" "$BASE/api/v1/users/$SEAT"; then
BODY="$(stage_body '{"login_name":"'"$SEAT"'","source_id":0,"password":"","must_change_password":false}' "$STAGE_DIR/pw")"
curl -s -o /dev/null -X PATCH -H "Content-Type: application/json" \
--config "$AUTH_CFG" --data "@$BODY" \
"$BASE/api/v1/admin/users/$SEAT"
act="reset-pw"
else
BODY="$(stage_body '{"username":"'"$SEAT"'","email":"'"$SEAT@$EMAIL_DOMAIN"'","password":"","must_change_password":false,"full_name":"Mosaic fleet seat '"$SEAT"'"}' "$STAGE_DIR/pw")"
curl -s -o /dev/null -X POST -H "Content-Type: application/json" \
--config "$AUTH_CFG" --data "@$BODY" \
"$BASE/api/v1/admin/users"
act="create"
fi
tmp="$STAGE_DIR/mint-response.json"
printf '{"name":"mosaic-seat","scopes":%s}' "$SCOPES" >"$STAGE_DIR/mint-body.json"; chmod 600 "$STAGE_DIR/mint-body.json"
MINT_BODY="$STAGE_DIR/mint-body.json"
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")"
if [[ "$code" != "201" ]]; then
echo " $KEY: mint FAILED http=$code ($act)" >&2; rm -f "$tmp"; rc=1; cleanup_stage; 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"; cleanup_stage
new_stage # fresh staging for the verify read
VERIFY_CFG="$(stage_auth "$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)"
cleanup_stage
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
@@ -1,247 +0,0 @@
#!/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).
# M10 no secret at rest after ANY exit, including transport failure mid-run
# (review 263 blocker 2): the staging dir is swept by the trap, so a curl
# that dies rc=7 on the admin POST leaves nothing behind.
# M11 a real `bash -x` trace of the whole run contains no secret VALUE —
# staging appears only as paths (review 263 blocker 1).
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 jq find wc; 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"
# M10: transport failure mid-run leaves NO secret at rest (review 263 blocker 2).
# A second mock that dies rc=7 on the admin POST; the trap must sweep the staging dir.
rm -rf "$BRAIN/fleet/agents/newseat"
M10_TMP="$SANDBOX/m10-tmp"; mkdir -p "$M10_TMP"
cat > "$MOCK_BIN/curl" <<'EOF'
#!/bin/bash
while [ $# -gt 0 ]; do
case "$1" in
http*) echo "$1" >> "${FAIL_URLS:?}"; exit 7 ;;
*) shift ;;
esac
done
exit 7
EOF
chmod +x "$MOCK_BIN/curl"
export FAIL_URLS="$SANDBOX/failed-urls.txt"; : > "$FAIL_URLS"
BEFORE=$(find "$M10_TMP" -maxdepth 1 -name 'mosaic-mint.*' 2>/dev/null | wc -l)
rc=$(TMPDIR="$M10_TMP" MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha run newseat)
[ "$rc" != 0 ] || fail "M10: transport failure reported rc=0"
AFTER=$(find "$M10_TMP" -maxdepth 1 -name 'mosaic-mint.*' 2>/dev/null | wc -l)
[ "$AFTER" -le "$BEFORE" ] || fail "M10: staging left at rest after failure: $AFTER dir(s) under $M10_TMP"
[ -s "$FAIL_URLS" ] || fail "M10: mock never called"
# Restore the well-behaved mock for M11.
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 ;;
"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"
unset FAIL_URLS
# M11: a real bash -x trace of a full mint contains no secret VALUE (review 263
# blocker 1). Secrets are generated straight into staging files and moved only
# by jq reads, so only staging PATHS may appear. The mock above has no secrets,
# so this measures the SCRIPT's word handling: the admin token sentinel and the
# mint response token must not appear in the xtrace of a successful run.
rm -rf "$BRAIN/fleet/agents/newseat"
: > "$CALLS"
TRACE="$SANDBOX/trace.log"
MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha bash -x "$TARGET" newseat >"$SANDBOX/out11" 2>"$TRACE" || fail "M11: traced run failed"
if grep -qF "$ADMIN_TOKEN_VALUE" "$TRACE"; then fail "M11: admin token value appears in xtrace"; fi
if grep -qF 'minted-token-7f3a' "$TRACE"; then fail "M11: minted token value appears in xtrace"; fi
# Any password-shaped expansion: the password is 32 base64ish chars. A trace
# that expands it into a word (assignment or argument) prints exactly that
# shape; the clean script's trace contains no 32-char base64ish run at all
# (paths and URLs are the only long strings).
if grep -qE "[[:space:]=\"'][A-Za-z0-9]{32}([[:space:]\"']|$)" "$TRACE"; then
LEAK=$(grep -oE "[[:space:]=\"'][A-Za-z0-9]{32}" "$TRACE" | head -1)
fail "M11: password-like value expansion in xtrace: $LEAK"
fi
echo "mint-seat-credential regression harness passed"
@@ -32,9 +32,7 @@
# 0 delivered (submitted) or queued (agent busy; will process when free)
# 1 tmux target not found
# 2 submission NOT confirmed — either still an unsubmitted draft, or the REPL
# 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.
# input prompt could not be located to confirm the message actually landed.
# 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
# so the sender learns immediately instead of a silent worker->lead stall.
@@ -99,50 +97,10 @@ printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
# would otherwise accumulate forever.
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
# 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
# treating ABSENCE of a draft as delivery: if the input box was never located
# treating ABSENCE of a draft as delivery: if the prompt glyph was never matched
# (wrong pane / prompt-glyph drift), an unsubmitted message read as "delivered"
# 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.
@@ -155,14 +113,15 @@ for attempt in $(seq 1 $((RETRIES + 1))); do
if grep -qF "$QUEUED_RE" <<<"$pane"; then
status="queued"; break
fi
# If we cannot see the input box, we have NO evidence of submission state —
# stay UNCONFIRMED and retry; never infer delivery.
if ! inputbox=$(locate_input_box "$pane"); then
# Locate the REPL input box (prompt glyph). If we cannot see it, we have NO
# evidence of submission state — stay UNCONFIRMED and retry; never infer delivery.
promptline=$(printf '%s' "$pane" | grep -E '|^>|│ >' | tail -1)
if [ -z "$promptline" ]; then
status="unconfirmed"; continue
fi
# Input box located AND still carrying our tail => unsubmitted draft. Flush + retry.
# (Submitted messages scroll up into history; a draft stays in the box.)
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$inputbox"; then
# (Submitted messages scroll up into history; a draft stays on the line.)
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$promptline"; then
status="draft"; continue
fi
# Input box located AND clear of our tail => positively submitted. This is the
@@ -176,6 +135,6 @@ case "$status" in
delivered) echo "✓ delivered to $TARGET"; 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 ;;
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 ;;
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 ;;
*) echo "✗ could not confirm submission on $TARGET (unexpected state '$status')" >&2; exit 2 ;;
esac
@@ -10,13 +10,6 @@
# "could not confirm submission").
# 3. DRAFT — a ` `-prompt pane that never submits (message stays on the
# 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
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
@@ -76,56 +69,6 @@ else
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 "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ]
-1
View File
@@ -63,7 +63,6 @@ export const STAGES = [
'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-mosaic-worktree-large-repo.sh',
],