ci/woodpecker/pr/ci Pipeline failed
Two changes to one rule: a credential is resolved from exactly one place,
and an identity that cannot be resolved is refused rather than substituted.
FAIL CLOSED. Both readers ended in an unconditional fall-through to the
shared Gitea account whenever an identity did not resolve. Every seat in a
fleet therefore pushed, opened PRs and filed reviews under one account, and
a record made that way cannot be traced to the agent that made it
afterwards. The fallback now applies only where there is no attribution to
lose: a host with no fleet. Where seats exist, an unresolvable request emits
nothing, exits nonzero, explains itself on stderr, and — in the git helper —
appends a record naming the identity, host, reason and cwd, and no token
value, to ${MOSAIC_CREDENTIAL_SPOOL:-~/.local/state/mosaic-credential-escalations}.
A host runs a fleet when <brain>/fleet/agents exists, which is the signal
packages/mosaic/src/fleet/brain-home.ts already uses to decide a brain is
active, resolved the same way (MOSAIC_BRAIN_HOME, else ~/.mosaic). This is
what keeps the change a no-op for an operator who has not provisioned
per-slot tokens: no fleet directory, shared account, unchanged. It is also
why there is no environment variable to restore the old behavior — one would
reintroduce the substitution being removed.
STORE SELECTION. Both readers hardcoded ~/.config/mosaic/secrets/gitea-tokens,
so a seat's own secrets/ slot was invisible to the framework: a seat could
hold a valid credential and still be served the shared account. The store is
now chosen by what the identity is. An identity with a directory under
<brain>/fleet/agents/ is a seat and is read only from
<brain>/fleet/agents/<id>/secrets/; any other identity is a service identity
and is read from the framework store. There is no precedence between them
and no fallback from one to the other, so a seat with an empty slot is
refused even when a same-named token sits in the framework store. Two copies
of one credential are drift rather than redundancy, and drift surfaces as
the stale copy returning 401, which reads as a revoked token and sends
whoever debugs it somewhere else.
detect-platform.sh is in scope alongside git-credential-mosaic because they
are the two readers of these tokens. Patching only the git helper would make
"one credential, one location" true for push and fetch and false for
pr-create.sh, issue-create.sh and pr-review.sh, which is the harder failure
to notice.
TESTS. The three assertions that pinned the shared-account fall-through are
now fail-closed assertions, and a refusal is checked four independent ways:
nonzero exit, empty stdout, a stderr diagnostic naming identity and host,
and no shared token value anywhere in the output. The exit code alone would
pass against a helper that emitted the credential and then failed. Added:
seat-slot resolution, the no-cross-store-fallback case with a control
proving the framework-store file it declines to read is readable, no-identity
on a fleet host, the fleet gate firing on the default ~/.mosaic and not only
on an injected MOSAIC_BRAIN_HOME, and a cross-host leak check. Both suites
were run against the pre-change code as a control and fail there on exactly
the shared-token emission.
shellcheck is not installed on the authoring host, so the rewritten helper
is unlinted locally and CI is the first lint of it.
317 lines
15 KiB
Bash
Executable File
317 lines
15 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Regression harness for detect-platform.sh's get_gitea_token() per-agent
|
|
# identity resolution (Gate-16 author≠reviewer separation) — the API-tooling
|
|
# counterpart to git-credential-mosaic, so pr-create.sh/issue-create.sh/etc.
|
|
# open records under the resolved agent identity, not the shared account.
|
|
#
|
|
# Covers:
|
|
# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config
|
|
# mosaic.gitIdentity (per-worktree).
|
|
# 2. Correct per-slot token file path chosen per host
|
|
# (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
|
|
# 3. Per-slot token present -> that token is returned (agent-authored calls).
|
|
# 4. No identity requested -> shared credential-loader token (backward
|
|
# compat, unchanged).
|
|
# 5. Patch 2b — explicit identity + recognized Gitea host + ABSENT per-slot
|
|
# token for that identity -> FAIL LOUD (nonzero return, empty stdout, a
|
|
# stderr diagnostic naming identity/source/host/expected path). Must NOT
|
|
# fall through to the shared/default token (Gate-16 author≠reviewer
|
|
# integrity — never silently borrow another slot's credentials). Covered
|
|
# for both identity sources (git config, MOSAIC_GIT_IDENTITY env) and
|
|
# for a same-identity cross-host case (token exists for one host, not
|
|
# the other).
|
|
# 6. Scope containment: identity requested + an UNRECOGNIZED Gitea host (no
|
|
# per-slot token scheme) -> Patch 2b does not apply; existing
|
|
# fall-through behavior is unchanged.
|
|
# 7. Credential store selection: an identity with a directory under
|
|
# <brain>/fleet/agents/ is a SEAT and is read ONLY from its own secrets/
|
|
# slot; any other identity is a SERVICE and is read from the framework
|
|
# store. No precedence between them and NO fallback from one to the
|
|
# other — a seat with an empty slot is REFUSED even when a same-named
|
|
# token sits in the framework store.
|
|
# 8. Fail loud when NO identity resolves on a host that runs a fleet: where
|
|
# seats exist, an unattributable API call is refused rather than made
|
|
# under the shared account. On a host with no fleet the same call still
|
|
# returns the shared token (case 1), which is what keeps this change a
|
|
# no-op for non-fleet operators of the framework.
|
|
#
|
|
# Uses a stubbed credentials.json + stubbed per-slot token files under a fake
|
|
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}"
|
|
FAKE_HOME="$WORK_DIR/home"
|
|
REPO_DIR="$WORK_DIR/repo"
|
|
CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json"
|
|
|
|
rm -rf "$WORK_DIR"
|
|
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$REPO_DIR"
|
|
|
|
git -C "$REPO_DIR" init -q
|
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
|
|
|
cat > "$CREDENTIALS_FILE" <<'JSON'
|
|
{
|
|
"gitea": {
|
|
"mosaicstack": {
|
|
"url": "https://git.mosaicstack.dev",
|
|
"token": "shared-mosaicstack-token"
|
|
},
|
|
"usc": {
|
|
"url": "https://git.uscllc.com",
|
|
"token": "shared-usc-token"
|
|
}
|
|
}
|
|
}
|
|
JSON
|
|
|
|
fail=0
|
|
assert_eq() {
|
|
local desc="$1" expected="$2" actual="$3"
|
|
if [[ "$expected" != "$actual" ]]; then
|
|
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
|
|
fail=1
|
|
fi
|
|
}
|
|
|
|
# Runs get_gitea_token for $1=host inside REPO_DIR (per-worktree git config
|
|
# resolves there) with a fake HOME + the stub credentials.json, plus any
|
|
# extra env passed in $@.
|
|
call_get_gitea_token() {
|
|
local host="$1"; shift
|
|
(
|
|
cd "$REPO_DIR"
|
|
# shellcheck disable=SC2016 # deliberately deferred: $DETECT_PLATFORM_SH is
|
|
# expanded by the INNER bash -c (via the exported env var below), not here.
|
|
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
|
DETECT_PLATFORM_SH="$SCRIPT_DIR/detect-platform.sh" "$@" \
|
|
bash -c 'source "$DETECT_PLATFORM_SH"; get_gitea_token "$1"' _ "$host"
|
|
)
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. No identity resolvable -> shared credential-loader token (unchanged).
|
|
# ---------------------------------------------------------------------------
|
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
|
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
|
assert_eq "shared fallback (no identity)" "shared-mosaicstack-token" "$out"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. git config mosaic.gitIdentity resolves to an agent WITH a per-slot
|
|
# token -> that token wins over the shared account.
|
|
# ---------------------------------------------------------------------------
|
|
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
|
|
git -C "$REPO_DIR" config mosaic.gitIdentity agentA
|
|
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
|
assert_eq "git-config identity token" "agentA-mosaicstack-token" "$out"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
|
|
# ---------------------------------------------------------------------------
|
|
echo -n "agentB-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentB.token"
|
|
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentB)
|
|
assert_eq "env beats git-config identity token" "agentB-mosaicstack-token" "$out"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. FAIL LOUD (Patch 2b): an identity is explicitly requested (via git config
|
|
# mosaic.gitIdentity, and separately via MOSAIC_GIT_IDENTITY env) for a
|
|
# RECOGNIZED Gitea host, but no per-slot token exists for THAT identity.
|
|
# Must NOT fall through to the shared/default token — silently borrowing
|
|
# another slot's credentials would post PRs/issues/reviews as the WRONG
|
|
# agent (Gate-16 author≠reviewer integrity break). Expect: nonzero return,
|
|
# EMPTY stdout (no token — shared or otherwise — leaked), and a stderr
|
|
# diagnostic naming the identity, its source, the host, and the expected
|
|
# per-slot token path.
|
|
# ---------------------------------------------------------------------------
|
|
assert_failloud() {
|
|
local desc="$1" host="$2" ident="$3" expected_tok_path="$4"; shift 4
|
|
local stderr_file="$WORK_DIR/stderr.tmp"
|
|
: > "$stderr_file"
|
|
set +e
|
|
local stdout
|
|
stdout=$(call_get_gitea_token "$host" "$@" 2>"$stderr_file")
|
|
local rc=$?
|
|
set -e
|
|
local stderr
|
|
stderr=$(cat "$stderr_file")
|
|
if [[ "$rc" -eq 0 ]]; then
|
|
echo "FAIL: $desc — expected nonzero return, got 0 (stdout='$stdout')" >&2
|
|
fail=1
|
|
fi
|
|
if [[ -n "$stdout" ]]; then
|
|
echo "FAIL: $desc — expected empty stdout (no token leaked), got '$stdout'" >&2
|
|
fail=1
|
|
fi
|
|
if [[ "$stderr" != *"$ident"* ]]; then
|
|
echo "FAIL: $desc — stderr does not name the requested identity '$ident':" >&2
|
|
echo "$stderr" >&2
|
|
fail=1
|
|
fi
|
|
if [[ "$stderr" != *"$host"* ]]; then
|
|
echo "FAIL: $desc — stderr does not name the host '$host':" >&2
|
|
echo "$stderr" >&2
|
|
fail=1
|
|
fi
|
|
if [[ "$stderr" != *"$expected_tok_path"* ]]; then
|
|
echo "FAIL: $desc — stderr does not name the expected per-slot token path '$expected_tok_path':" >&2
|
|
echo "$stderr" >&2
|
|
fail=1
|
|
fi
|
|
if [[ "$stderr" == *"shared"*"token"* ]]; then
|
|
echo "FAIL: $desc — stderr unexpectedly mentions a shared token value:" >&2
|
|
echo "$stderr" >&2
|
|
fail=1
|
|
fi
|
|
}
|
|
|
|
# 4a. git config mosaic.gitIdentity source, recognized host (mosaicstack),
|
|
# shared token IS present but must not be borrowed.
|
|
git -C "$REPO_DIR" config mosaic.gitIdentity no-such-agent
|
|
assert_failloud "fail-loud via git-config identity (recognized host)" \
|
|
"git.mosaicstack.dev" "no-such-agent" \
|
|
"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-no-such-agent.token"
|
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
|
|
|
# 4b. MOSAIC_GIT_IDENTITY env source (takes priority over git config), same
|
|
# recognized-host / absent-token scenario -> also fails loud.
|
|
assert_failloud "fail-loud via MOSAIC_GIT_IDENTITY env (recognized host)" \
|
|
"git.mosaicstack.dev" "no-such-agent-env" \
|
|
"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-no-such-agent-env.token" \
|
|
MOSAIC_GIT_IDENTITY=no-such-agent-env
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Correct per-slot token PATH per host: same agent id, only a usc token
|
|
# exists. usc host returns it (happy path, unchanged). mosaicstack host
|
|
# has NO per-slot token for this identity -> Patch 2b fail-loud applies
|
|
# there too (must NOT fall back to the shared mosaicstack token, and must
|
|
# NOT leak the agent's usc token either).
|
|
# ---------------------------------------------------------------------------
|
|
echo -n "agentD-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentD.token"
|
|
git -C "$REPO_DIR" config mosaic.gitIdentity agentD
|
|
out=$(call_get_gitea_token "git.uscllc.com")
|
|
assert_eq "host-scoped token path (usc)" "agentD-usc-token" "$out"
|
|
assert_failloud "fail-loud on cross-host absence (no fallback, no cross-host leak)" \
|
|
"git.mosaicstack.dev" "agentD" \
|
|
"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentD.token"
|
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Scope containment: identity explicitly requested, but the host is NOT a
|
|
# recognized Gitea host (no per-slot token scheme at all) -> Patch 2b does
|
|
# NOT apply; existing fall-through behavior is unchanged (ends in the
|
|
# pre-existing generic failure since no shared credentials match either,
|
|
# NOT the fail-loud diagnostic path).
|
|
# ---------------------------------------------------------------------------
|
|
git -C "$REPO_DIR" config mosaic.gitIdentity no-such-agent
|
|
set +e
|
|
out=$(call_get_gitea_token "github.com" 2>"$WORK_DIR/stderr-scope.tmp")
|
|
rc=$?
|
|
set -e
|
|
err=$(cat "$WORK_DIR/stderr-scope.tmp")
|
|
if [[ "$rc" -eq 0 ]]; then
|
|
echo "FAIL: unrecognized host + identity — expected nonzero (no credentials configured), got 0" >&2
|
|
fail=1
|
|
fi
|
|
if [[ "$err" == *"no per-slot token at"* ]]; then
|
|
echo "FAIL: unrecognized host + identity — fail-loud diagnostic must not fire for a host with no per-slot scheme:" >&2
|
|
echo "$err" >&2
|
|
fail=1
|
|
fi
|
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. SEAT identity: an id with a directory under <brain>/fleet/agents/ is read
|
|
# from its OWN secrets/ slot, not from the framework store. The brain home
|
|
# is resolved exactly as packages/mosaic/src/fleet/brain-home.ts does it:
|
|
# MOSAIC_BRAIN_HOME, else ~/.mosaic.
|
|
# ---------------------------------------------------------------------------
|
|
BRAIN_DIR="$WORK_DIR/brain"
|
|
mkdir -p "$BRAIN_DIR/fleet/agents/seatE/secrets"
|
|
echo -n "seatE-slot-token" > "$BRAIN_DIR/fleet/agents/seatE/secrets/gitea-mosaicstack-seatE.token"
|
|
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatE MOSAIC_BRAIN_HOME="$BRAIN_DIR")
|
|
assert_eq "seat reads its own slot" "seatE-slot-token" "$out"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8. NO CROSS-STORE FALLBACK. seatF is a seat (it has a directory) with an
|
|
# EMPTY slot, while a framework-store token of the identical name is
|
|
# present and readable. It must be REFUSED rather than served that token:
|
|
# one credential, one location. A seat that silently reads a same-named
|
|
# service credential is the same substitution failure as the shared-account
|
|
# fallback, one store further down.
|
|
# ---------------------------------------------------------------------------
|
|
mkdir -p "$BRAIN_DIR/fleet/agents/seatF/secrets"
|
|
echo -n "seatF-SERVICE-STORE-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-seatF.token"
|
|
assert_failloud "seat with empty slot does NOT fall back to the framework store" \
|
|
"git.mosaicstack.dev" "seatF" \
|
|
"$BRAIN_DIR/fleet/agents/seatF/secrets/gitea-mosaicstack-seatF.token" \
|
|
MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$BRAIN_DIR"
|
|
# assert_failloud only screens stderr for the word "shared"; this store's token
|
|
# is not named that, so check for its value explicitly.
|
|
set +e
|
|
xstore_out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr-xstore.tmp")
|
|
set -e
|
|
if [[ "$xstore_out$(cat "$WORK_DIR/stderr-xstore.tmp")" == *"seatF-SERVICE-STORE-token"* ]]; then
|
|
echo "FAIL: cross-store fallback — a seat was served the framework store's same-named token" >&2
|
|
fail=1
|
|
fi
|
|
# Control: that framework-store token IS readable, so the refusal above is the
|
|
# store rule firing and not an unreadable file. The same id, resolved against a
|
|
# brain home where it is not a seat, gets it.
|
|
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$WORK_DIR/no-such-brain")
|
|
assert_eq "control — same file IS readable for a non-seat identity" "seatF-SERVICE-STORE-token" "$out"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 9. FAIL LOUD — no identity resolvable, but this host runs a fleet. Contrast
|
|
# with case 1: the identical call on a host with no fleet still returns the
|
|
# shared token.
|
|
# ---------------------------------------------------------------------------
|
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
|
set +e
|
|
noid_out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr-noid.tmp")
|
|
noid_rc=$?
|
|
set -e
|
|
noid_err=$(cat "$WORK_DIR/stderr-noid.tmp")
|
|
if [[ "$noid_rc" -eq 0 ]]; then
|
|
echo "FAIL: no identity on a fleet host — expected nonzero return, got 0 (stdout='$noid_out')" >&2
|
|
fail=1
|
|
fi
|
|
if [[ -n "$noid_out" ]]; then
|
|
echo "FAIL: no identity on a fleet host — expected empty stdout, got '$noid_out'" >&2
|
|
fail=1
|
|
fi
|
|
if [[ "$noid_out" == *"shared-mosaicstack-token"* || "$noid_err" == *"shared-mosaicstack-token"* ]]; then
|
|
echo "FAIL: no identity on a fleet host — the shared token was served anyway:" >&2
|
|
echo "$noid_out$noid_err" >&2
|
|
fail=1
|
|
fi
|
|
if [[ "$noid_err" != *"MOSAIC_GIT_IDENTITY"* ]]; then
|
|
echo "FAIL: no identity on a fleet host — stderr does not say how to set an identity:" >&2
|
|
echo "$noid_err" >&2
|
|
fail=1
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 10. The brain home defaults to ~/.mosaic when MOSAIC_BRAIN_HOME is unset —
|
|
# the fleet gate must fire on the default path too, not only on an
|
|
# explicitly injected one. Case 1 ran before this directory existed; the
|
|
# same call now refuses, which also proves case 1 was measuring the
|
|
# no-fleet branch rather than passing for an unrelated reason.
|
|
# ---------------------------------------------------------------------------
|
|
mkdir -p "$FAKE_HOME/.mosaic/fleet/agents"
|
|
set +e
|
|
dflt_out=$(call_get_gitea_token "git.mosaicstack.dev" 2>"$WORK_DIR/stderr-dflt.tmp")
|
|
dflt_rc=$?
|
|
set -e
|
|
if [[ "$dflt_rc" -eq 0 || -n "$dflt_out" ]]; then
|
|
echo "FAIL: fleet gate did not fire on the default ~/.mosaic brain home (rc=$dflt_rc stdout='$dflt_out')" >&2
|
|
fail=1
|
|
fi
|
|
|
|
if [[ "$fail" -eq 0 ]]; then
|
|
echo "get_gitea_token identity resolution regression passed"
|
|
fi
|
|
|
|
exit "$fail"
|