Compare commits

...
Author SHA1 Message Date
code-be-02 2cfcb63cd8 fix(mosaic): honor seat-owned Gitea slots 2026-10-09 12:02:15 -05:00
fred 2101c9b446 fix(git-tools): issue-comment.sh resolves API base without monolith GITEA_URL (#1502)
ci/woodpecker/push/publish Pipeline was successful
2026-09-11 22:50:23 +00:00
9 changed files with 556 additions and 8 deletions
@@ -0,0 +1,12 @@
{
"summary": "No important actionable issues found. Helper failures remain terminal, and the previously reported token-export regression is fixed. Bash syntax, package JSON, and caller-token export checks passed. The full regression suite was not run because the sandbox is read-only.",
"verdict": "approve",
"confidence": 0.88,
"findings": [],
"stats": {
"files_reviewed": 6,
"blockers": 0,
"should_fix": 0,
"suggestions": 0
}
}
@@ -0,0 +1,13 @@
{
"summary": "No confident security findings in the six supplied changed files. Seat lookups use the production helper, helper failures remain terminal, and returned credentials are validated. Bash syntax checks passed. Runtime regression tests were not run because the sandbox is read-only.",
"risk_level": "none",
"confidence": 0.87,
"findings": [],
"stats": {
"files_reviewed": 6,
"critical": 0,
"high": 0,
"medium": 0,
"low": 0
}
}
@@ -0,0 +1,23 @@
# #1311 Credential seat-store alignment
## Scope
Restore the missing `load_credentials` Gitea seat-slot behavior in the Stack framework. A known fleet seat must obtain its token through the production credential helper, while its Gitea URL remains provider configuration. A missing seat slot must fail closed and never fall back to the service store.
## Evidence
- Base: `2101c9b4468b22f57b2f02bb2c9da12067225819` (`origin/next`).
- Historical branch `origin/fix/credentials-gitea-seat-slots` contains the pre-refactor direct-loader fix, but it predates the current Python wrapper and ancestry fence.
- Red reproduction: `test-credentials-gitea-seats.sh` failed on base for populated seats, empty-seat no-fallback, and cross-seat ancestry cases.
- Canonical source: `packages/mosaic/framework/`. The package installer and `mosaic-doctor` describe the shipped framework as the source for deployed framework tools. The brain runtime copy is an estate runtime copy, not this framework change's source.
## Decision
The loader delegates seat token resolution to its sibling `git-credential-mosaic` production entrypoint. It does not invoke `.impl` directly or read a seat slot itself. This preserves the wrapper's environment sanitization and the implementation's process-ancestry fence. Non-seat and no-identity service-store behavior, non-Gitea services, and pre-existing `GITEA_TOKEN` precedence remain unchanged. The loader continues to export a caller-supplied token so child tool processes receive it.
## Validation and review
- The final hermetic matrix passes for Mosaic and USC seat slots, empty and cross-seat refusals, service-store paths, explicit and unexported caller tokens, and Woodpecker isolation. It fails against `origin/next` for the expected missing behavior.
- `bash -n` passes for the loader and new suite. The framework test-enumeration guard passes with the new suite enumerated.
- `pnpm --dir packages/mosaic run test:framework-shell` reaches `invariant_r_unittest.py` and stops on its existing host-runtime check: the test expects Pi `0.84.1` while this host reports `1.0.3`. No file in that invariant or its runtime probe changed in this task. The suite passes its earlier systemd check when the user-bus environment is supplied.
- Initial independent code review found that the new conditional stopped exporting a caller-supplied unexported `GITEA_TOKEN`. The fix exports it in both Gitea arms and adds Mosaic/USC child-process regressions. Re-review approved with no findings. Independent security review reported risk level `none`.
@@ -24,6 +24,14 @@
# $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 has one additional identity-aware path. When the resolved git identity
# names a fleet seat, gitea-mosaicstack and gitea-usc obtain the token through
# tools/git/git-credential-mosaic rather than reading a slot directly. That
# production entrypoint enforces the clean-environment and process-ancestry
# fence before it reads a seat slot. A seat-slot miss is terminal: this loader
# never substitutes the service-store token for it. URLs remain provider
# configuration and continue to come from this loader's service store.
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
for _cand in "$HOME/.config/mosaic/credentials.json" "/etc/mosaic/credentials.json"; do
@@ -94,6 +102,85 @@ _mosaic_load_woodpecker_legacy() {
_mosaic_sync_woodpecker_env "$WOODPECKER_INSTANCE" "$WOODPECKER_URL" "$WOODPECKER_TOKEN"
}
_mosaic_resolve_git_identity() {
local ident="${MOSAIC_GIT_IDENTITY:-}"
if [[ -z "$ident" ]]; then
ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
fi
printf '%s' "$ident"
}
_mosaic_git_identity_is_seat() {
local ident="$1" brain_home
[[ -n "$ident" ]] || return 1
brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
[[ -d "$brain_home/fleet/agents/$ident" ]]
}
_mosaic_gitea_seat_token_from_helper() {
# Use the production wrapper, not its Bash implementation. The wrapper
# removes BASH_ENV/function injection before the implementation evaluates
# MOSAIC_AGENT_NAME ancestry, so a loader consumer cannot bypass that fence.
local host="$1" ident="$2" script_dir helper response key value
local username="" password="" username_seen=0 password_seen=0
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
helper="$script_dir/../git/git-credential-mosaic"
if [[ ! -x "$helper" ]]; then
echo "Error: Gitea seat credential helper is unavailable: $helper" >&2
return 1
fi
if ! response="$(printf 'protocol=https\nhost=%s\n\n' "$host" | "$helper" get)"; then
return 1
fi
while IFS='=' read -r key value; do
[[ -n "$key" ]] || continue
case "$key" in
username)
if (( username_seen )); then
echo 'Error: Gitea seat credential helper returned duplicate username fields' >&2
return 1
fi
username="$value"
username_seen=1
;;
password)
if (( password_seen )); then
echo 'Error: Gitea seat credential helper returned duplicate password fields' >&2
return 1
fi
password="$value"
password_seen=1
;;
*)
echo 'Error: Gitea seat credential helper returned an invalid protocol field' >&2
return 1
;;
esac
done <<< "$response"
if [[ "$username_seen" -ne 1 || "$password_seen" -ne 1 || "$username" != "$ident" || -z "$password" ]]; then
echo "Error: Gitea seat credential helper did not return a valid credential for '$ident'" >&2
return 1
fi
printf '%s' "$password"
}
_mosaic_gitea_token() {
# $1 is the Gitea host and $2 is the legacy service-store jq path.
# Seats are delegated to the fenced helper; all other identities retain the
# existing service-store behavior. A failed seat delegation returns nonzero
# to the caller and deliberately cannot fall through to _mosaic_read_cred.
local host="$1" service_jq_path="$2" ident
ident="$(_mosaic_resolve_git_identity)"
if _mosaic_git_identity_is_seat "$ident"; then
_mosaic_gitea_seat_token_from_helper "$host" "$ident"
return
fi
_mosaic_read_cred "$service_jq_path"
}
load_credentials() {
local service="$1"
@@ -183,16 +270,31 @@ 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; }
# An explicit caller value retains the loader's established precedence.
# Otherwise, a known seat delegates to the ancestry-fenced helper and a
# non-seat identity uses the established service-store lookup.
if [[ -z "${GITEA_TOKEN:-}" ]]; then
local _gitea_token
_gitea_token="$(_mosaic_gitea_token 'git.mosaicstack.dev' '.gitea.mosaicstack.token')" || return 1
GITEA_TOKEN="$_gitea_token"
fi
# Preserve the loader's contract even when the caller supplied an
# unexported shell variable before invoking load_credentials.
export GITEA_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:-}" ]]; then
local _gitea_token
_gitea_token="$(_mosaic_gitea_token 'git.uscllc.com' '.gitea.usc.token')" || return 1
GITEA_TOKEN="$_gitea_token"
fi
export GITEA_TOKEN
[[ -n "$GITEA_TOKEN" ]] || { echo "Error: gitea.usc.token not found" >&2; return 1; }
;;
woodpecker-*)
@@ -0,0 +1,287 @@
#!/usr/bin/env bash
# Hermetic regression for load_credentials Gitea seat-slot resolution.
#
# It runs against copied framework tools under a fake HOME, fixture credentials,
# and fake seat slots only. No real credential path is read.
#
# Contract pinned here:
# G1-G3 an ancestry-owned seat resolves its own host-scoped token through
# the production git-credential-mosaic entrypoint, whether identity
# comes from env or per-worktree git config.
# G4 an owned seat with no slot fails closed and never uses the service
# token.
# G5 a seat cannot request another seat's slot through load_credentials;
# the helper's ancestry fence remains the authorization boundary.
# G6/G7 no seat identity and a non-seat identity retain service-store
# behavior.
# G8-G10 explicitly supplied GITEA_TOKEN values keep their established
# precedence and are exported for child tool processes.
# G11 non-Gitea services remain unaffected.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/credentials-gitea-seats}"
FAKE_HOME="$WORK_DIR/home"
FRAMEWORK_TOOLS="$FAKE_HOME/.config/mosaic/tools"
BRAIN_DIR="$WORK_DIR/brain"
REPO_DIR="$WORK_DIR/repo"
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
SPOOL_DIR="$WORK_DIR/spool"
LOADER="$FRAMEWORK_TOOLS/_lib/credentials.sh"
HELPER="$FRAMEWORK_TOOLS/git/git-credential-mosaic"
rm -rf "$WORK_DIR"
trap 'rm -rf "$WORK_DIR"' EXIT
mkdir -p "$FRAMEWORK_TOOLS/_lib" "$FRAMEWORK_TOOLS/git" "$BRAIN_DIR/fleet/agents" \
"$REPO_DIR" "$SPOOL_DIR"
cp "$SCRIPT_DIR/credentials.sh" "$LOADER"
cp "$SCRIPT_DIR/../git/git-credential-mosaic" "$HELPER"
cp "$SCRIPT_DIR/../git/git-credential-mosaic.impl" "$FRAMEWORK_TOOLS/git/git-credential-mosaic.impl"
chmod +x "$HELPER" "$FRAMEWORK_TOOLS/git/git-credential-mosaic.impl"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" config user.name 'Credential seat test'
git -C "$REPO_DIR" config user.email '[email protected]'
cat > "$CREDENTIALS_FILE" <<'JSON'
{
"gitea": {
"mosaicstack": {
"url": "https://git.mosaicstack.dev",
"token": "fixture-service-token"
},
"usc": {
"url": "https://git.uscllc.com",
"token": "fixture-usc-service-token"
}
},
"woodpecker": {
"default": "mosaic",
"mosaic": {
"url": "https://ci.example.invalid",
"token": "fixture-woodpecker-token"
}
}
}
JSON
for seat in seat-owner seat-config seat-usc seat-victim empty-seat; do
mkdir -p "$BRAIN_DIR/fleet/agents/$seat/secrets"
done
printf '%s' 'fixture-owner-slot-token' > "$BRAIN_DIR/fleet/agents/seat-owner/secrets/gitea-mosaicstack-seat-owner.token"
printf '%s' 'fixture-config-slot-token' > "$BRAIN_DIR/fleet/agents/seat-config/secrets/gitea-mosaicstack-seat-config.token"
printf '%s' 'fixture-usc-slot-token' > "$BRAIN_DIR/fleet/agents/seat-usc/secrets/gitea-usc-seat-usc.token"
printf '%s' 'fixture-victim-slot-token' > "$BRAIN_DIR/fleet/agents/seat-victim/secrets/gitea-mosaicstack-seat-victim.token"
chmod 600 "$BRAIN_DIR"/fleet/agents/*/secrets/*.token
# Establishes a seat identity in an exec-frozen ancestor. The empty-name root
# carries the lineage fence, preventing the helper from seeing this suite's
# real parent process outside its hermetic fixture.
cat > "$WORK_DIR/lineage-root.sh" <<'ROOT'
#!/usr/bin/env bash
set -euo pipefail
caller="$1"
carrier="$2"
shift 2
env MOSAIC_AGENT_NAME="$caller" PATH="$PATH" HOME="$HOME" \
bash "$carrier" "$@"
ROOT
cat > "$WORK_DIR/lineage-carrier.sh" <<'CARRIER'
#!/usr/bin/env bash
set -euo pipefail
loader="$1"
brain="$2"
credentials="$3"
repo="$4"
spool="$5"
identity_source="$6"
target="$7"
preexisting_token="$8"
service="${9:-gitea-mosaicstack}"
cd "$repo"
unset GITEA_URL GITEA_TOKEN MOSAIC_GIT_IDENTITY
git config --unset mosaic.gitIdentity 2>/dev/null || true
case "$identity_source" in
env) export MOSAIC_GIT_IDENTITY="$target" ;;
config) git config mosaic.gitIdentity "$target" ;;
none) ;;
*) echo "unknown identity source: $identity_source" >&2; exit 2 ;;
esac
if [[ "$preexisting_token" != '-' ]]; then
export GITEA_TOKEN="$preexisting_token"
fi
export MOSAIC_BRAIN_HOME="$brain"
export MOSAIC_CREDENTIALS_FILE="$credentials"
export MOSAIC_CREDENTIAL_SPOOL="$spool"
# shellcheck source=/dev/null
source "$loader"
load_credentials "$service"
printf 'url=%s\ntoken=%s\n' "$GITEA_URL" "$GITEA_TOKEN"
CARRIER
chmod +x "$WORK_DIR/lineage-root.sh" "$WORK_DIR/lineage-carrier.sh"
run_lineage() {
local caller="$1" source="$2" target="$3" preset="$4" service="${5:-gitea-mosaicstack}"
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_LINEAGE_FENCE=1 \
bash "$WORK_DIR/lineage-root.sh" "$caller" "$WORK_DIR/lineage-carrier.sh" \
"$LOADER" "$BRAIN_DIR" "$CREDENTIALS_FILE" "$REPO_DIR" "$SPOOL_DIR" \
"$source" "$target" "$preset" "$service"
}
run_plain() {
local source="$1" target="$2" preset="$3" service="${4:-gitea-mosaicstack}"
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_BRAIN_HOME="$BRAIN_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" MOSAIC_CREDENTIAL_SPOOL="$SPOOL_DIR" \
LOADER="$LOADER" REPO_DIR="$REPO_DIR" IDENTITY_SOURCE="$source" TARGET="$target" \
PRESET="$preset" SERVICE="$service" bash -c '
set -euo pipefail
cd "$REPO_DIR"
unset GITEA_URL GITEA_TOKEN MOSAIC_GIT_IDENTITY
git config --unset mosaic.gitIdentity 2>/dev/null || true
case "$IDENTITY_SOURCE" in
env) export MOSAIC_GIT_IDENTITY="$TARGET" ;;
config) git config mosaic.gitIdentity "$TARGET" ;;
none) ;;
*) exit 2 ;;
esac
if [[ "$PRESET" != "-" ]]; then export GITEA_TOKEN="$PRESET"; fi
source "$LOADER"
load_credentials "$SERVICE"
printf "url=%s\\ntoken=%s\\n" "$GITEA_URL" "$GITEA_TOKEN"
'
}
run_unexported_token() {
local service="$1"
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_BRAIN_HOME="$BRAIN_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" LOADER="$LOADER" SERVICE="$service" \
bash -s <<'UNEXPORTED'
set -euo pipefail
unset GITEA_URL GITEA_TOKEN MOSAIC_GIT_IDENTITY
GITEA_TOKEN='fixture-unexported-token'
source "$LOADER"
load_credentials "$SERVICE"
child_token="$(bash -c 'printf "%s" "${GITEA_TOKEN:-}"')"
[[ "$child_token" == "$GITEA_TOKEN" ]] || {
echo 'GITEA_TOKEN was not exported to a child process' >&2
exit 1
}
printf 'url=%s\ntoken=%s\n' "$GITEA_URL" "$GITEA_TOKEN"
UNEXPORTED
}
fail=0
assert_success() {
local desc="$1" expected_token="$2" expected_url="$3"
shift 3
local err="$WORK_DIR/stderr.tmp" out rc token url
: > "$err"
set +e
out=$("$@" 2>"$err")
rc=$?
set -e
if [[ "$rc" -ne 0 ]]; then
echo "FAIL: $desc — expected success, got rc=$rc" >&2
cat "$err" >&2
fail=1
return
fi
token="$(printf '%s\n' "$out" | awk -F= '/^token=/{print substr($0, 7)}')"
url="$(printf '%s\n' "$out" | awk -F= '/^url=/{print substr($0, 5)}')"
if [[ "$token" != "$expected_token" ]]; then
echo "FAIL: $desc — resolved the wrong token source" >&2
fail=1
fi
if [[ "$url" != "$expected_url" ]]; then
echo "FAIL: $desc — URL did not come from provider configuration" >&2
fail=1
fi
}
assert_refused() {
local desc="$1" expected_reason="$2"
shift 2
local err="$WORK_DIR/stderr.tmp" out rc
: > "$err"
set +e
out=$("$@" 2>"$err")
rc=$?
set -e
local diagnostics
diagnostics="$(cat "$err")"
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: $desc — expected refusal, got success" >&2
fail=1
fi
if [[ -n "$out" ]]; then
echo "FAIL: $desc — refusal emitted credential output" >&2
fail=1
fi
if [[ "$diagnostics" != *"$expected_reason"* ]]; then
echo "FAIL: $desc — refusal did not retain helper reason $expected_reason" >&2
fail=1
fi
if [[ "$out$diagnostics" == *'fixture-service-token'* || "$out$diagnostics" == *'fixture-victim-slot-token'* ]]; then
echo "FAIL: $desc — refusal exposed or fell back to another store" >&2
fail=1
fi
}
# G1: env identity, owning seat, populated Mosaic slot.
assert_success 'G1 env-owned seat uses its Mosaic slot through the helper' 'fixture-owner-slot-token' 'https://git.mosaicstack.dev' \
run_lineage seat-owner env seat-owner -
# G2: the same contract when the helper and loader resolve git config identity.
assert_success 'G2 config-owned seat uses its Mosaic slot through the helper' 'fixture-config-slot-token' 'https://git.mosaicstack.dev' \
run_lineage seat-config config seat-config -
# G3: the USC arm remains host-scoped rather than borrowing Mosaic credentials.
assert_success 'G3 USC-owned seat uses its USC slot through the helper' 'fixture-usc-slot-token' 'https://git.uscllc.com' \
run_lineage seat-usc env seat-usc - gitea-usc
# G4: a seat slot miss must be terminal, never service-store fallback.
assert_refused 'G4 empty owned seat refuses without service fallback' 'no-token-for-identity' \
run_lineage empty-seat env empty-seat -
# G5: a child cannot select another seat by rewriting MOSAIC_GIT_IDENTITY.
assert_refused 'G5 cross-seat identity is refused by ancestry fencing' 'cross-seat-identity-refused' \
run_lineage seat-owner env seat-victim -
# G6/G7: non-seat paths retain the existing shared service-store behavior.
assert_success 'G6 no identity keeps the service-store path' 'fixture-service-token' 'https://git.mosaicstack.dev' \
run_plain none '' -
assert_success 'G7 non-seat identity keeps the service-store path' 'fixture-service-token' 'https://git.mosaicstack.dev' \
run_plain env service-automation -
# G8: caller-provided env values retain the established loader precedence.
assert_success 'G8 explicit GITEA_TOKEN remains caller-owned' 'fixture-preexisting-token' 'https://git.mosaicstack.dev' \
run_lineage seat-owner env seat-owner fixture-preexisting-token
# G9/G10: pre-existing shell variables keep the loader's export contract.
assert_success 'G9 unexported Mosaic token reaches child processes' 'fixture-unexported-token' 'https://git.mosaicstack.dev' \
run_unexported_token gitea-mosaicstack
assert_success 'G10 unexported USC token reaches child processes' 'fixture-unexported-token' 'https://git.uscllc.com' \
run_unexported_token gitea-usc
# G11: only Gitea has seat slots; Woodpecker remains service-scoped.
wp_out=$(env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_BRAIN_HOME="$BRAIN_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" MOSAIC_GIT_IDENTITY=seat-owner \
LOADER="$LOADER" bash -c '
set -euo pipefail
unset WOODPECKER_URL WOODPECKER_TOKEN
source "$LOADER"
load_credentials woodpecker
printf "%s|%s" "$WOODPECKER_URL" "$WOODPECKER_TOKEN"
')
if [[ "$wp_out" != 'https://ci.example.invalid|fixture-woodpecker-token' ]]; then
echo 'FAIL: G11 Woodpecker changed under a Gitea seat identity' >&2
fail=1
fi
if [[ "$fail" -eq 0 ]]; then
echo 'credentials Gitea seat-store regression passed'
fi
exit "$fail"
@@ -44,8 +44,8 @@ account/token configured through `tools/_lib/credentials.sh`. That means every a
fleet commits, pushes, and opens PRs under one identity — with no cryptographic
separation between an author and a reviewer.
Both `git-credential-mosaic` and `get_gitea_token()` resolve an optional **per-agent
identity**:
`git-credential-mosaic`, `get_gitea_token()`, and the `gitea-mosaicstack` /
`gitea-usc` arms of `load_credentials` resolve an optional **per-agent identity**:
1. `MOSAIC_GIT_IDENTITY` environment variable, or
2. `git config --get mosaic.gitIdentity` (set per-worktree; persists on disk across
@@ -65,6 +65,12 @@ The store is chosen by what the identity **is**, not by which file happens to ex
`<brain>` is `MOSAIC_BRAIN_HOME` if set, else `~/.mosaic` — the same resolution
`packages/mosaic/src/fleet/brain-home.ts` performs.
The Gitea arms of `load_credentials` obtain a seat token through the production
`git-credential-mosaic` entrypoint, rather than reading the slot directly. That retains
the entrypoint's clean-environment and process-ancestry fence. The Gitea URL remains
provider configuration from the service store. A caller-supplied `GITEA_TOKEN` retains
its established environment precedence.
**There is no precedence between the two stores and no fallback from one to the other.**
A seat whose slot is empty is refused even when a same-named token sits in the framework
store. One credential lives in exactly one location: a second copy is drift rather than
@@ -155,8 +155,15 @@ gitea_resolve_api_for_login() {
}
fi
configured_url=$(get_gitea_url_for_host "$host") || {
echo "Error: Configured Gitea URL not found for comment read-back verification" >&2
return 1
# No monolith-configured Gitea URL for this host (#1450): seat-token-only
# hosts carry no gitea-mosaicstack/gitea-usc credentials.sh entry and no
# bare GITEA_URL, so get_gitea_url_for_host has nothing to match against.
# Synthesize the API base directly from the git remote's own host --
# exactly the trust model issue-create.sh's REST fallback already uses
# successfully on these hosts. This is NOT a cross-host guess: $host came
# from get_remote_host() reading THIS repo's own origin remote, so the
# resolved base always matches the repo actually being acted on.
configured_url="https://${host}"
}
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
@@ -44,6 +44,14 @@
# clobber each other and every scratch file is removed on all exit paths.
# 11. accepts the canonical -b/--body flag exactly like the -c/--comment alias
# (R1, 2026-08-28): a full verified write via -b alone.
# 12. (#1450, 2026-09-11) on a SEAT-TOKEN-ONLY host — identity resolved purely
# via MOSAIC_GIT_IDENTITY's per-slot token file, no tea login involved, and
# no monolith credentials.json entry for this Gitea host (so
# get_gitea_url_for_host has nothing to match) — the wrapper still
# resolves the API base directly from the git remote's own host (no
# cross-host fallback/guessing) instead of failing closed with
# "Configured Gitea URL not found", and the POST + exact-ID read-back both
# run under that same seat identity, never the (absent) host default.
set -euo pipefail
@@ -129,6 +137,12 @@ OVERRIDE_TOKEN="override-token-placeholder"
# repo host: host-bound selection must fail closed on the host mismatch.
CROSS_HOST_LOGIN="foreign-host-reviewer"
CROSS_HOST_TOKEN="cross-host-token-placeholder"
# A seat-token-only identity (#1450): resolved purely via MOSAIC_GIT_IDENTITY's
# per-slot token file under $HOME/.config/mosaic/secrets/gitea-tokens/ -- no tea
# login, no MOSAIC_CREDENTIALS_FILE entry for this host at all.
SEAT_IDENTITY="seat-only-agent"
SEAT_LOGIN="seat-only-actor"
SEAT_TOKEN="seat-token-placeholder"
# tea config: the override login has its own token here (as tea itself stores
# per-login tokens). The default login name ("mosaicstack") is deliberately NOT
@@ -169,6 +183,19 @@ with open(sys.argv[1], "w", encoding="utf-8") as credentials:
}, credentials)
PY
# A monolith credentials file that EXISTS but carries no gitea.mosaicstack (or
# gitea.usc) entry -- the seat-token-only condition (#1450). Distinct from
# $CREDENTIALS_FILE above, which does carry a configured URL for the other
# cases in this suite.
EMPTY_CREDENTIALS_FILE="$WORK_DIR/credentials-empty.json"
printf '{}' > "$EMPTY_CREDENTIALS_FILE"
# The seat identity's per-slot token file, exactly as a provisioned agent seat
# carries one: $HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-<agent>.token.
# get_gitea_token() resolves this BEFORE ever consulting MOSAIC_CREDENTIALS_FILE.
mkdir -p "$HOME_DIR/.config/mosaic/secrets/gitea-tokens"
printf '%s' "$SEAT_TOKEN" > "$HOME_DIR/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-${SEAT_IDENTITY}.token"
# tea stub: only ever answers the login list (used to resolve the default login
# name). It must NEVER be asked to write a comment — the wrapper writes via REST.
cat > "$BIN_DIR/tea" <<'SH'
@@ -250,6 +277,7 @@ case "$auth_token" in
"$ISSUE_COMMENT_DEFAULT_TOKEN") acting_identity="$ISSUE_COMMENT_ACTING_LOGIN" ;;
"$ISSUE_COMMENT_OVERRIDE_TOKEN") acting_identity="$ISSUE_COMMENT_OVERRIDE_LOGIN" ;;
"$ISSUE_COMMENT_CROSS_HOST_TOKEN") acting_identity="$ISSUE_COMMENT_CROSS_HOST_LOGIN" ;;
"$ISSUE_COMMENT_SEAT_TOKEN") acting_identity="$ISSUE_COMMENT_SEAT_LOGIN" ;;
esac
printf '%s %s %s\n' "$method" "$path" "${acting_identity:-<unauthenticated>}" >> "$ISSUE_COMMENT_AUTH_LOG"
@@ -446,6 +474,53 @@ run_comment() {
ISSUE_COMMENT_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
ISSUE_COMMENT_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
ISSUE_COMMENT_CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
ISSUE_COMMENT_SEAT_LOGIN="$SEAT_LOGIN" \
ISSUE_COMMENT_SEAT_TOKEN="$SEAT_TOKEN" \
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
ISSUE_COMMENT_API_BASE="$API_BASE" \
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" "${BODY_FLAG:--c}" "$BODY" "$@"
) > "$OUTPUT_FILE" 2>&1
}
# Seat-token-only variant (#1450): no monolith credentials.json entry for this
# host at all (MOSAIC_CREDENTIALS_FILE points at an empty {}), and identity
# resolves purely via MOSAIC_GIT_IDENTITY's per-slot token file. Everything
# else is identical to run_comment() -- same sandboxing, same always-exported
# constants -- so a diff against run_comment() is exactly these two overrides.
run_comment_seat() {
local mode="$1"
shift
: > "$TEA_LOG"
: > "$CURL_LOG"
: > "$CURL_ARGV_LOG"
: > "$AUTH_LOG"
: > "$OUTPUT_FILE"
seed_state "$mode"
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
TMPDIR="$TMP_SCRATCH" \
HOME="$HOME_DIR" \
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$EMPTY_CREDENTIALS_FILE" \
MOSAIC_GIT_IDENTITY="$SEAT_IDENTITY" \
MOSAIC_BRAIN_HOME="" \
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
ISSUE_COMMENT_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
ISSUE_COMMENT_AUTH_LOG="$AUTH_LOG" \
ISSUE_COMMENT_STATE="$STATE_FILE" \
ISSUE_COMMENT_TEST_MODE="$mode" \
ISSUE_COMMENT_ACTING_LOGIN="$ACTING_LOGIN" \
ISSUE_COMMENT_FOREIGN_LOGIN="$FOREIGN_LOGIN" \
ISSUE_COMMENT_OVERRIDE_LOGIN="$OVERRIDE_LOGIN" \
ISSUE_COMMENT_CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" \
ISSUE_COMMENT_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
ISSUE_COMMENT_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
ISSUE_COMMENT_CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
ISSUE_COMMENT_SEAT_LOGIN="$SEAT_LOGIN" \
ISSUE_COMMENT_SEAT_TOKEN="$SEAT_TOKEN" \
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
ISSUE_COMMENT_API_BASE="$API_BASE" \
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
@@ -473,7 +548,7 @@ assert_no_temp_leak() {
# expected path grep matches nothing, so no token value is ever printed.
assert_token_not_in_argv() {
local context="$1"
if grep -qF -e "$DEFAULT_TOKEN" -e "$OVERRIDE_TOKEN" -e "$CROSS_HOST_TOKEN" "$CURL_ARGV_LOG"; then
if grep -qF -e "$DEFAULT_TOKEN" -e "$OVERRIDE_TOKEN" -e "$CROSS_HOST_TOKEN" -e "$SEAT_TOKEN" "$CURL_ARGV_LOG"; then
echo "FAIL: a Gitea bearer token leaked into curl argv ($context)" >&2
exit 1
fi
@@ -650,4 +725,27 @@ assert_no_temp_leak "fresh-success-body-flag"
assert_token_not_in_argv "fresh-success-body-flag"
unset BODY_FLAG
# Case 12 (#1450, 2026-09-11): a SEAT-TOKEN-ONLY host -- no monolith
# credentials.json entry for this Gitea host at all (get_gitea_url_for_host has
# nothing to match), identity resolved purely via MOSAIC_GIT_IDENTITY's
# per-slot token file. The wrapper must still resolve the API base directly
# from the git remote's own host (no cross-host fallback/guessing -- proven by
# reusing this suite's existing $API_BASE/$API_ROOT constants unmodified) and
# run the POST, the /user lookup, and the exact-id read-back all under the
# seat identity, never a host-default identity that does not even exist here.
run_comment_seat fresh-success
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 51)' "$OUTPUT_FILE"
grep -q "^POST $API_BASE/issues/7/comments$" "$CURL_LOG"
grep -q "^GET $API_BASE/issues/comments/51$" "$CURL_LOG"
grep -q "^POST $API_BASE/issues/7/comments $SEAT_LOGIN$" "$AUTH_LOG"
grep -q "^GET $API_ROOT/user $SEAT_LOGIN$" "$AUTH_LOG"
grep -q "^GET $API_BASE/issues/comments/51 $SEAT_LOGIN$" "$AUTH_LOG"
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: seat-token-only run was attributed to the (nonexistent) host-default identity" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
assert_no_temp_leak "seat-token-no-monolith"
assert_token_not_in_argv "seat-token-no-monolith"
echo "issue-comment.sh REST create + exact-id read-back regression passed"
File diff suppressed because one or more lines are too long