feat(fleet-tools): mint-seat-credential.sh joins the framework toolkit
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Moves seat credential minting out of a brain-local fleet/bin into packages/mosaic/framework/tools/fleet/, parameterized for any deployment: - MOSAIC_ADMIN_SEAT (or --admin-seat) names the seat whose admin token calls the Gitea admin API; no seat name is hardcoded. - MOSAIC_GITEA_INSTANCES / MOSAIC_GITEA_URL_<INSTANCE> select and override instances, the same convention seat-logins.sh already uses. - MOSAIC_SEAT_EMAIL_DOMAIN sets the account email domain. - tea projection calls the sibling seat-logins.sh, not a brain-local copy. Hermetic suite test-mint-seat-credential.sh (mock curl, sandboxed brain home, no tea, no network) pins: slot written from the mint response at mode 600; admin seat must be configured (rc=3) and its token present (rc=1, no API call); instance selection and URL override; admin token value never echoed. Joins ci.yml and the verify-release canonical list. Adds tools/fleet/README.md. Plan: docs/plans/2026-08-21_git-operations-toolkit.md step 6 (first of three scripts; new-seat.sh and launch-seat.sh need a design ruling, see the plan).
This commit is contained in:
@@ -100,6 +100,11 @@ 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 —
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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> 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.
|
||||
- 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.
|
||||
- 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,150 @@
|
||||
#!/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>).
|
||||
# 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.
|
||||
set -Eeuo pipefail
|
||||
|
||||
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:-mosaicstack.dev}"
|
||||
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; }
|
||||
|
||||
# Instance -> server URL. Same map and override convention as seat-logins.sh.
|
||||
declare -A INSTANCE_URL=(
|
||||
[mosaicstack]="https://git.mosaicstack.dev"
|
||||
[usc]="https://git.uscllc.com"
|
||||
)
|
||||
for inst in "${!INSTANCE_URL[@]}"; do
|
||||
ov="MOSAIC_GITEA_URL_${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="MOSAIC_GITEA_URL_${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")"
|
||||
PW="$(openssl rand -base64 33 | tr -d '\n/+=' | head -c 32)"
|
||||
|
||||
if curl -sf -o /dev/null -H "Authorization: token $T" "$BASE/api/v1/users/$SEAT"; then
|
||||
curl -s -o /dev/null -X PATCH -H "Authorization: token $T" -H "Content-Type: application/json" \
|
||||
-d "{\"login_name\":\"$SEAT\",\"source_id\":0,\"password\":\"$PW\",\"must_change_password\":false}" \
|
||||
"$BASE/api/v1/admin/users/$SEAT"
|
||||
act="reset-pw"
|
||||
else
|
||||
curl -s -o /dev/null -X POST -H "Authorization: token $T" -H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"$SEAT\",\"email\":\"$SEAT@$EMAIL_DOMAIN\",\"password\":\"$PW\",\"must_change_password\":false,\"full_name\":\"Mosaic fleet seat $SEAT\"}" \
|
||||
"$BASE/api/v1/admin/users"
|
||||
act="create"
|
||||
fi
|
||||
|
||||
tmp="$(mktemp)"
|
||||
code="$(curl -s -o "$tmp" -w '%{http_code}' -X POST -u "$SEAT:$PW" -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"mosaic-seat\",\"scopes\":$SCOPES}" "$BASE/api/v1/users/$SEAT/tokens")"
|
||||
if [[ "$code" != "201" ]]; then
|
||||
echo " $KEY: mint FAILED http=$code ($act)" >&2; rm -f "$tmp"; rc=1; PW=""; 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=""
|
||||
|
||||
login="$(curl -s -H "Authorization: token $(cat "$D/gitea-$KEY-$SEAT.token")" "$BASE/api/v1/user" \
|
||||
| python3 -c 'import json,sys;print(json.load(sys.stdin).get("login","ERR"))' 2>/dev/null || echo ERR)"
|
||||
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
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/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.
|
||||
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"
|
||||
unset MOSAIC_ADMIN_SEAT MOSAIC_GITEA_INSTANCES
|
||||
|
||||
# --- mock curl: records method + URL, answers the minting sequence -----------
|
||||
cat > "$MOCK_BIN/curl" <<'EOF'
|
||||
#!/bin/bash
|
||||
method=GET; url=""; out=""; wcode=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-X) method="$2"; shift 2 ;;
|
||||
-o) out="$2"; shift 2 ;;
|
||||
-w) wcode=1; shift 2 ;;
|
||||
-H|-d|-u) shift 2 ;;
|
||||
http*) url="$1"; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
printf '%s %s\n' "$method" "$url" >> "$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"
|
||||
|
||||
# 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"; then fail "M5: admin token value leaked to output"; fi
|
||||
|
||||
# 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"
|
||||
@@ -63,6 +63,7 @@ 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',
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user