From cb77c7d629b15c3b049f9478af3a0e5a6e8d437f Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Sat, 22 Aug 2026 00:24:03 -0500 Subject: [PATCH] =?UTF-8?q?fix(credentials):=20gitea=20arms=20resolve=20se?= =?UTF-8?q?at=20slots=20=E2=80=94=20fail=20loud,=20no=20silent=20service?= =?UTF-8?q?=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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..default and per-identity keys, while the loader reads .gitea..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. --- .../framework/tools/_lib/credentials.sh | 65 +++++++++++++- .../_lib/test-credentials-gitea-seats.sh | 88 +++++++++++++++++++ packages/mosaic/package.json | 2 +- 3 files changed, 152 insertions(+), 3 deletions(-) create mode 100755 packages/mosaic/framework/tools/_lib/test-credentials-gitea-seats.sh diff --git a/packages/mosaic/framework/tools/_lib/credentials.sh b/packages/mosaic/framework/tools/_lib/credentials.sh index 64d4d63a..69394840 100755 --- a/packages/mosaic/framework/tools/_lib/credentials.sh +++ b/packages/mosaic/framework/tools/_lib/credentials.sh @@ -24,6 +24,24 @@ # $HOME points at a per-profile directory that has no credentials file. # Operators symlink /etc/mosaic/credentials.json to the host's canonical # 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// → its token is +# read from /secrets/gitea--.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 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" } +_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() { local service="$1" @@ -183,16 +230,30 @@ EOF ;; gitea-mosaicstack) 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%/}" [[ -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; } ;; gitea-usc) 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%/}" [[ -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; } ;; woodpecker-*) diff --git a/packages/mosaic/framework/tools/_lib/test-credentials-gitea-seats.sh b/packages/mosaic/framework/tools/_lib/test-credentials-gitea-seats.sh new file mode 100755 index 00000000..5722ac58 --- /dev/null +++ b/packages/mosaic/framework/tools/_lib/test-credentials-gitea-seats.sh @@ -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" diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 647a5999..03434905 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -25,7 +25,7 @@ "lint": "eslint src", "typecheck": "tsc --noEmit", "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": { "@mosaicstack/brain": "workspace:*",