Compare commits

...
Author SHA1 Message Date
code-infra-01 cb77c7d629 fix(credentials): gitea arms resolve seat slots — fail loud, no silent service fallback
ci/woodpecker/pr/ci Pipeline was successful
On a fleet host, load_credentials gitea-mosaicstack / gitea-usc now resolve
a git identity's SEAT SLOT (MOSAIC_GIT_IDENTITY with a directory under
MOSAIC_BRAIN_HOME/fleet/agents/) as the token source, mirroring
get_gitea_token in detect-platform.sh (the #1311 lineage). URL still comes
from credentials.json (provider config, not identity). A seat with an empty
or missing slot REFUSES (rc 1, identity and slot path named) rather than
falling back to the shared service store — the same no-fallback rule the
git credential helper and detect-platform enforce; a silent fallback would
act as the wrong identity (#1343 family, usc/uconnect#3084 precedent).
No identity resolved, or a non-seat identity: the service store, unchanged.
Other services untouched (no seat concept to invent).

Hermetic suite test-credentials-gitea-seats.sh (sandbox brain + sandbox
credentials.json; no real credential read), CI-reachable via
test:framework-shell. Six pins: slot-sourced token, seat-miss refusal
without fallback (token compared against the service value), no-identity
service path, non-seat identity, woodpecker indifference to the identity,
pre-set GITEA_TOKEN never overridden. Mutants killed: seat-miss-falls-back
(dies at G2), seat-reads-service-store (dies at G1).

Known pre-existing (out of scope, unchanged by this PR): this host's
credentials.json stores gitea tokens under .gitea.<inst>.default and
per-identity keys, while the loader reads .gitea.<inst>.token — the
no-identity service path fails identically before and after this change
(verified against origin/next's loader). Filed for the wrapper-defect
batch rather than widened here.
2026-08-22 00:24:03 -05:00
3 changed files with 152 additions and 3 deletions
@@ -24,6 +24,24 @@
# $HOME points at a per-profile directory that has no credentials file. # $HOME points at a per-profile directory that has no credentials file.
# Operators symlink /etc/mosaic/credentials.json to the host's canonical # Operators symlink /etc/mosaic/credentials.json to the host's canonical
# file once, instead of exporting MOSAIC_CREDENTIALS_FILE per invocation. # file once, instead of exporting MOSAIC_CREDENTIALS_FILE per invocation.
#
# GITEA SEAT SLOTS (gitea-mosaicstack / gitea-usc arms only):
# On a fleet host, a resolved git identity is a SEAT whose live credential is
# its slot file, not the shared service store. Resolution, mirroring
# get_gitea_token() in tools/git/detect-platform.sh (mosaicstack#1311 lineage):
# - MOSAIC_GIT_IDENTITY names a seat with a directory under
# ${MOSAIC_BRAIN_HOME:-~/.mosaic}/fleet/agents/<identity>/ → its token is
# read from <slot>/secrets/gitea-<instance>-<identity>.token and exported
# as GITEA_TOKEN. The URL still comes from credentials.json (it is
# provider config, not identity).
# - A seat with an EMPTY/missing slot is a REFUSAL (fail loud), not a
# fallback: there is no precedence between the seat and service stores,
# and a silent service fallback would act as the wrong identity (#1343
# family; usc/uconnect#3084 precedent).
# - No identity resolved → the service store in credentials.json, exactly
# as before. Non-fleet hosts are unchanged.
# Other services (woodpecker, authentik, ...) have no seat concept and are
# untouched by this.
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
for _cand in "$HOME/.config/mosaic/credentials.json" "/etc/mosaic/credentials.json"; do for _cand in "$HOME/.config/mosaic/credentials.json" "/etc/mosaic/credentials.json"; do
@@ -94,6 +112,35 @@ _mosaic_load_woodpecker_legacy() {
_mosaic_sync_woodpecker_env "$WOODPECKER_INSTANCE" "$WOODPECKER_URL" "$WOODPECKER_TOKEN" _mosaic_sync_woodpecker_env "$WOODPECKER_INSTANCE" "$WOODPECKER_URL" "$WOODPECKER_TOKEN"
} }
_gitea_seat_token() {
# Echo the seat-slot token path for $1=identity $2=instance-prefix, or rc 1
# when the identity is not a seat. Reads nothing; path logic only.
local ident="$1" pfx="$2" brain_home slot
brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
slot="$brain_home/fleet/agents/$ident/secrets/gitea-$pfx-$ident.token"
if [[ -d "$brain_home/fleet/agents/$ident" ]]; then
printf '%s' "$slot"
return 0
fi
return 1
}
_gitea_resolve_seat_or_refuse() {
# $1=identity $2=instance-prefix $3=service-name (for messages).
# Seat with a readable slot → echoes the token (caller exports).
# Seat with an empty/missing slot → rc 1 with a named refusal.
# Not a seat → rc 2 (caller falls to the service store).
local ident="$1" pfx="$2" svc="$3" slot
slot="$(_gitea_seat_token "$ident" "$pfx")" || return 2
if [[ -r "$slot" ]] && [[ -s "$slot" ]]; then
tr -d '\n' <"$slot"
return 0
fi
echo "Error: load_credentials $svc: git identity '$ident' resolves to a SEAT but its slot is empty or unreadable: $slot" >&2
echo " Refusing to fall back to the shared service store — that would act as the wrong identity. Provision the slot or unset MOSAIC_GIT_IDENTITY." >&2
return 1
}
load_credentials() { load_credentials() {
local service="$1" local service="$1"
@@ -183,16 +230,30 @@ EOF
;; ;;
gitea-mosaicstack) gitea-mosaicstack)
export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.mosaicstack.url')}" export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.mosaicstack.url')}"
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.mosaicstack.token')}"
GITEA_URL="${GITEA_URL%/}" GITEA_URL="${GITEA_URL%/}"
[[ -n "$GITEA_URL" ]] || { echo "Error: gitea.mosaicstack.url not found" >&2; return 1; } [[ -n "$GITEA_URL" ]] || { echo "Error: gitea.mosaicstack.url not found" >&2; return 1; }
if [[ -z "${GITEA_TOKEN:-}" && -n "${MOSAIC_GIT_IDENTITY:-}" ]]; then
local _seat_tok
_seat_tok="$(_gitea_resolve_seat_or_refuse "$MOSAIC_GIT_IDENTITY" mosaicstack gitea-mosaicstack)" \
&& export GITEA_TOKEN="$_seat_tok" && return 0
local _src_rc=$?
[[ "$_src_rc" -eq 2 ]] || return 1
fi
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.mosaicstack.token')}"
[[ -n "$GITEA_TOKEN" ]] || { echo "Error: gitea.mosaicstack.token not found" >&2; return 1; } [[ -n "$GITEA_TOKEN" ]] || { echo "Error: gitea.mosaicstack.token not found" >&2; return 1; }
;; ;;
gitea-usc) gitea-usc)
export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.usc.url')}" export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.usc.url')}"
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.usc.token')}"
GITEA_URL="${GITEA_URL%/}" GITEA_URL="${GITEA_URL%/}"
[[ -n "$GITEA_URL" ]] || { echo "Error: gitea.usc.url not found" >&2; return 1; } [[ -n "$GITEA_URL" ]] || { echo "Error: gitea.usc.url not found" >&2; return 1; }
if [[ -z "${GITEA_TOKEN:-}" && -n "${MOSAIC_GIT_IDENTITY:-}" ]]; then
local _seat_tok
_seat_tok="$(_gitea_resolve_seat_or_refuse "$MOSAIC_GIT_IDENTITY" usc gitea-usc)" \
&& export GITEA_TOKEN="$_seat_tok" && return 0
local _src_rc=$?
[[ "$_src_rc" -eq 2 ]] || return 1
fi
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.usc.token')}"
[[ -n "$GITEA_TOKEN" ]] || { echo "Error: gitea.usc.token not found" >&2; return 1; } [[ -n "$GITEA_TOKEN" ]] || { echo "Error: gitea.usc.token not found" >&2; return 1; }
;; ;;
woodpecker-*) woodpecker-*)
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Hermetic regression for load_credentials gitea seat-slot resolution.
# Sandbox brain home + sandbox credentials.json; no real credential is read.
#
# Pins:
# G1 MOSAIC_GIT_IDENTITY naming a seat with a populated slot → GITEA_TOKEN
# comes from the SLOT, URL from credentials.json.
# G2 seat with an EMPTY slot → rc 1, refusal names the identity and the
# slot path, and NO fallback to the service store occurred (the token
# must not equal the service-store value).
# G3 no identity → service store, unchanged behavior (token from
# credentials.json).
# G4 identity that is NOT a seat (no directory) → service store (same as
# G3; the identity is irrelevant on a non-fleet path).
# G5 other services are untouched: woodpecker resolution works the same
# with and without MOSAIC_GIT_IDENTITY set.
# G6 pre-existing GITEA_TOKEN env is never overridden by the seat path.
set -uo pipefail
W="${TMPDIR:-/tmp}/creds-seat-test-$$"
BRAIN="$W/brain"; CREDS="$W/credentials.json"
mkdir -p "$BRAIN/fleet/agents/live-seat/secrets" "$BRAIN/fleet/agents/empty-seat"
printf 'seat-token-value-abc123\n' > "$BRAIN/fleet/agents/live-seat/secrets/gitea-mosaicstack-live-seat.token"
cat > "$CREDS" <<'EOF'
{"gitea":{"mosaicstack":{"url":"https://gitea.example.test","token":"service-token-value-xyz789"}},
"woodpecker":{"default":"mosaic","mosaic":{"url":"https://ci.example.test","token":"wp-token-1"}}}
EOF
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=/dev/null
source "$SCRIPT_DIR/credentials.sh"
fail() { echo "FAIL: $*" >&2; exit 1; }
load_env() { # $1=service, $2=env-setup; clean subshell; echoes rc then token
local svc="$1" setup="$2"
(
eval "$setup"
unset GITEA_TOKEN GITEA_URL
export MOSAIC_CREDENTIALS_FILE="$CREDS" MOSAIC_BRAIN_HOME="$BRAIN"
load_credentials "$svc" >/dev/null 2>"$W/err"
rc=$?
printf '%s\n%s\n' "$rc" "${GITEA_TOKEN:-}"
)
}
# G1: seat with populated slot
out=$(load_env gitea-mosaicstack 'export MOSAIC_GIT_IDENTITY=live-seat')
rc=$(printf '%s' "$out" | sed -n 1p); tok=$(printf '%s' "$out" | sed -n 2p)
[ "$rc" = 0 ] || fail "G1: rc=$rc err=$(cat "$W/err")"
[ "$tok" = "seat-token-value-abc123" ] || fail "G1: token not from slot: ${tok:0:20}"
# G2: seat with empty slot refuses, no fallback
out=$(load_env gitea-mosaicstack 'export MOSAIC_GIT_IDENTITY=empty-seat')
rc=$(printf '%s' "$out" | sed -n 1p); tok=$(printf '%s' "$out" | sed -n 2p)
[ "$rc" = 1 ] || fail "G2: expected rc=1 refusal, got rc=$rc tok=${tok:0:20}"
[ "$tok" != "service-token-value-xyz789" ] || fail "G2: FELL BACK to service store on seat-miss"
grep -q "empty-seat" "$W/err" || fail "G2: refusal does not name the identity"
grep -q "fleet/agents/empty-seat" "$W/err" || fail "G2: refusal does not name the slot path"
# G3: no identity → service store
out=$(load_env gitea-mosaicstack 'unset MOSAIC_GIT_IDENTITY')
rc=$(printf '%s' "$out" | sed -n 1p); tok=$(printf '%s' "$out" | sed -n 2p)
[ "$rc" = 0 ] || fail "G3: rc=$rc err=$(cat "$W/err")"
[ "$tok" = "service-token-value-xyz789" ] || fail "G3: service-store token not loaded"
# G4: identity that is not a seat → service store
out=$(load_env gitea-mosaicstack 'export MOSAIC_GIT_IDENTITY=nobody')
rc=$(printf '%s' "$out" | sed -n 1p); tok=$(printf '%s' "$out" | sed -n 2p)
[ "$rc" = 0 ] || fail "G4: rc=$rc err=$(cat "$W/err")"
[ "$tok" = "service-token-value-xyz789" ] || fail "G4: non-seat identity broke the service path"
# G5: woodpecker ignores MOSAIC_GIT_IDENTITY entirely
( export MOSAIC_CREDENTIALS_FILE="$CREDS"
export MOSAIC_GIT_IDENTITY=live-seat
unset WOODPECKER_URL WOODPECKER_TOKEN
load_credentials woodpecker >/dev/null 2>&1 || fail "G5: woodpecker load failed with identity set"
[ "$WOODPECKER_TOKEN" = "wp-token-1" ] || fail "G5: woodpecker token wrong"
[ "$WOODPECKER_URL" = "https://ci.example.test" ] || fail "G5: woodpecker url wrong" )
# G6: pre-set GITEA_TOKEN env is preserved (both arms)
( export MOSAIC_CREDENTIALS_FILE="$CREDS" MOSAIC_BRAIN_HOME="$BRAIN"
export MOSAIC_GIT_IDENTITY=live-seat GITEA_TOKEN=already-set-env
load_credentials gitea-mosaicstack >/dev/null 2>&1 || fail "G6: load failed"
[ "$GITEA_TOKEN" = "already-set-env" ] || fail "G6: seat path overrode existing GITEA_TOKEN" )
rm -rf "$W"
echo "credentials seat-slot regression passed"
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src", "lint": "eslint src",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh" "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_lib/test-credentials-gitea-seats.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh"
}, },
"dependencies": { "dependencies": {
"@mosaicstack/brain": "workspace:*", "@mosaicstack/brain": "workspace:*",