ci/woodpecker/pr/ci Pipeline failed
Upstream of brain commits 08a00149 + 971586ef + the arm-6 sandbox fix (brain 5th commit of 2026-08-28 series): - R1: -b/--body is the canonical comment flag (matches issue-create, issue-edit, pr-create, pr-edit); -c/--comment stays a compatible alias. - R4: usage errors print to stderr and exit 2, distinct from provider, credential, and verification failures (exit 1). Value-less flags fail loudly (previously -c with no value died silently at rc=1 via set -e on the failed shift 2). gh failures on the GitHub path normalize to exit 1 (gh's own usage errors exit 2 and would collide with the reserved code). - Tests: new usage-contract suite (help rc, unknown/missing/value-less rc=2 on stderr, alias parse acceptance under a sandboxed runner, GitHub-path exit normalization with a stubbed gh, zero provider contact on parser failure); readback suite gains case 11 (full verified write via -b) and neutralizes seat-exported MOSAIC_GIT_IDENTITY / MOSAIC_BRAIN_HOME that escape the sandboxed HOME (documented HTTP 401 / fail-loud shapes). Driver: a fleet seat full-stopped on an issue-comment usage error because usage failures were indistinguishable from provider failures and the stop gate treated every wrapper failure as blocking.
654 lines
28 KiB
Bash
Executable File
654 lines
28 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Regression harness for issue-comment.sh's Gitea comment write + verification
|
|
# (#865).
|
|
#
|
|
# The #865 defect class: tea 0.11.1's `tea issue comment ...` (a nonexistent
|
|
# subcommand) silently no-ops yet exits 0, and tea cannot emit the id of a
|
|
# record it created — so an exit code is worthless as proof of a durable write.
|
|
# The wrapper therefore does NOT write via tea at all. It POSTs the comment to
|
|
# the Gitea REST API (which returns the created comment object, including its
|
|
# id), then GETs THAT EXACT id back and requires it to match on id, author
|
|
# (acting identity), body, and issue. Because verification is keyed to the id
|
|
# the create returned, no concurrent comment can masquerade as this write, and a
|
|
# suppressed/no-op create yields no id and fails closed.
|
|
#
|
|
# This harness models a REAL server: the curl stub keeps persistent comment
|
|
# state on disk, the POST actually CREATES and PERSISTS a record and returns its
|
|
# id, and the read-back GET reads that same state. There is no independently
|
|
# fabricated record for the wrapper to "find" — the only way verification
|
|
# passes is if the POST genuinely created the record the read-back retrieves.
|
|
# It proves the wrapper:
|
|
# 1. never shells out to tea to write (no `tea comment` / `tea issue comment`);
|
|
# 2. creates the comment via REST POST and learns the provider-returned id;
|
|
# 3. verifies THAT EXACT id by direct GET, attributed to the acting identity;
|
|
# 4. fails closed when the write is a no-op even though a concurrent
|
|
# SAME-IDENTITY comment with the same body already exists (the closed
|
|
# concurrency window — no fallback list scan can rescue a no-op);
|
|
# 5. fails closed when the created record is not authored by the acting
|
|
# identity;
|
|
# 6. treats the exact-id GET as the SOLE authority — it performs NO follow-up
|
|
# list enumeration (the stub exposes no comment-list endpoint, so any
|
|
# residual enumeration attempt would fail the run);
|
|
# 7. with a RESOLVABLE --login override, performs the write, the /user identity
|
|
# lookup, and the read-back ALL under THAT login's token/identity — never
|
|
# the host default;
|
|
# 8. with an UNRESOLVABLE --login override, FAILS CLOSED (nonzero, no write, no
|
|
# success line) instead of silently downgrading to the host default
|
|
# identity — the token seam maps each bearer token to the identity it
|
|
# authenticates as, so a misattributed write is caught;
|
|
# 9. with a --login override whose tea config URL is a DIFFERENT host than the
|
|
# repo remote, FAILS CLOSED (host-bound token selection) rather than sending
|
|
# that other host's credential cross-host;
|
|
# 10. leaves NO temp files behind (POST/GET bodies + metadata) on either the
|
|
# success or the failure path — nested function-scoped RETURN traps do not
|
|
# 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.
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-comment-readback}"
|
|
REPO_DIR="$WORK_DIR/repo"
|
|
BIN_DIR="$WORK_DIR/bin"
|
|
XDG_DIR="$WORK_DIR/xdg"
|
|
TEA_LOG="$WORK_DIR/tea.log"
|
|
CURL_LOG="$WORK_DIR/curl.log"
|
|
# Full curl argv per invocation — proves the bearer token never rides in argv.
|
|
CURL_ARGV_LOG="$WORK_DIR/curl-argv.log"
|
|
AUTH_LOG="$WORK_DIR/auth.log"
|
|
OUTPUT_FILE="$WORK_DIR/output.log"
|
|
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
|
STATE_FILE="$WORK_DIR/comments.json"
|
|
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
|
|
# check can assert every POST/GET body + metadata temp file is cleaned up.
|
|
TMP_SCRATCH="$WORK_DIR/scratch"
|
|
HOME_DIR="$WORK_DIR/home"
|
|
|
|
cleanup() {
|
|
rm -rf "$WORK_DIR"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH" "$HOME_DIR"
|
|
git -C "$REPO_DIR" init -q
|
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
|
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
|
#
|
|
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
|
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
|
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
|
# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever
|
|
# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credential below is silently
|
|
# ignored. The stub curl then rejects the unrecognised bearer, and this suite
|
|
# fails at its FIRST case with `Gitea authenticated-identity read failed with
|
|
# HTTP 401`. An empty repo-local value shadows the global one and reads back
|
|
# empty at rc=0. Measured: without this pin the suite is RED on every seat.
|
|
#
|
|
# CONTAINMENT: the sandboxed HOME in run_comment(). It only has to bound a
|
|
# failure that the pin should already have prevented.
|
|
#
|
|
# THIS SUITE WAS THE HARDEST OF THE FIVE TO SEE, and the reason is worth stating
|
|
# because it generalises: run_comment() sends the wrapper's stdout AND stderr to
|
|
# $OUTPUT_FILE, and the EXIT trap above deletes $WORK_DIR. So the 401 — the only
|
|
# thing that says what went wrong — exists only inside a directory that is gone
|
|
# by the time anyone looks. The suite exits 1 with ZERO bytes on stdout and
|
|
# stderr. A suite that discards or deletes its own evidence turns any post-hoc
|
|
# assay into a non-measurement: "nothing found" there means "no surviving
|
|
# trace", never "clean". It was found by intercepting the identity read at its
|
|
# SOURCE (a PATH shim over `git` logging every `mosaic.gitIdentity` read to a
|
|
# file outside $WORK_DIR), which is deletion-proof by construction, rather than
|
|
# by grepping for the symptom.
|
|
#
|
|
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
|
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
|
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
|
# by construction and every suite reads clean however vulnerable it is. To
|
|
# measure, REPLICATE a seat (a decoy HOME whose .gitconfig sets
|
|
# mosaic.gitIdentity, with no per-slot token) so step 0 reaches its fail-loud
|
|
# branch — or intercept the read as described above.
|
|
#
|
|
# Note the env-var route does NOT work: detect-platform.sh reads
|
|
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
|
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
|
|
|
ISSUE_NUMBER=7
|
|
REPO_SLUG="mosaicstack/stack"
|
|
API_BASE="https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack"
|
|
API_ROOT="https://git.mosaicstack.dev/api/v1"
|
|
BODY='durable "note" -- marker'
|
|
ACTING_LOGIN="primary-reviewer"
|
|
FOREIGN_LOGIN="other-writer"
|
|
# A dedicated per-role --login override identity, with its own token stored in
|
|
# tea's config (exactly the author-not-equal-reviewer hardening path).
|
|
OVERRIDE_LOGIN="delegated-reviewer"
|
|
DEFAULT_TOKEN="test-only-placeholder"
|
|
OVERRIDE_TOKEN="override-token-placeholder"
|
|
# A --login override whose tea config URL points at a DIFFERENT Gitea host than
|
|
# the repo remote (git.mosaicstack.dev). Its token must NEVER be sent to the
|
|
# 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"
|
|
|
|
# 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
|
|
# present, so the no-override default path resolves via the host credential
|
|
# fallback while an explicit --login must resolve from this file or fail closed.
|
|
# A second login is configured for a DIFFERENT host to exercise host-bound
|
|
# rejection.
|
|
mkdir -p "$XDG_DIR/tea"
|
|
OVERRIDE_LOGIN="$OVERRIDE_LOGIN" OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
|
|
CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
|
|
python3 - "$XDG_DIR/tea/config.yml" <<'PY'
|
|
import os
|
|
import sys
|
|
|
|
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
|
handle.write("logins:\n")
|
|
handle.write(f" - name: {os.environ['OVERRIDE_LOGIN']}\n")
|
|
handle.write(" url: https://git.mosaicstack.dev\n")
|
|
handle.write(f" token: {os.environ['OVERRIDE_TOKEN']}\n")
|
|
handle.write(f" - name: {os.environ['CROSS_HOST_LOGIN']}\n")
|
|
handle.write(" url: https://git.uscllc.com\n")
|
|
handle.write(f" token: {os.environ['CROSS_HOST_TOKEN']}\n")
|
|
PY
|
|
|
|
CONFIGURED_GITEA_URL="https://git.mosaicstack.dev" python3 - "$CREDENTIALS_FILE" <<'PY'
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
with open(sys.argv[1], "w", encoding="utf-8") as credentials:
|
|
json.dump({
|
|
"gitea": {
|
|
"mosaicstack": {
|
|
"url": os.environ["CONFIGURED_GITEA_URL"],
|
|
"token": "test-only-placeholder",
|
|
}
|
|
}
|
|
}, credentials)
|
|
PY
|
|
|
|
# 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'
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
printf '%s\n' "$*" >> "$ISSUE_COMMENT_TEA_LOG"
|
|
|
|
if [[ "$*" == "login list --output json" ]]; then
|
|
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
|
|
exit 0
|
|
fi
|
|
|
|
echo "Unexpected tea command (wrapper must not write via tea): $*" >&2
|
|
exit 92
|
|
SH
|
|
chmod +x "$BIN_DIR/tea"
|
|
|
|
# curl stub: a small REST server backed by persistent on-disk comment state.
|
|
# GET /user -> acting identity
|
|
# POST /issues/7/comments -> CREATE + PERSIST, return created object
|
|
# GET /issues/comments/{id} -> read the persisted record by exact id
|
|
# There is deliberately NO comment-LIST endpoint: exact-id read-back is the sole
|
|
# authority, so any residual list enumeration attempt hits the unexpected-request
|
|
# guard and fails the test.
|
|
cat > "$BIN_DIR/curl" <<'SH'
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# Record the FULL argv exactly as spawned, before consumption. The bearer token
|
|
# must NOT appear here — it is delivered via a curl --config file (#865 ITEM 3a),
|
|
# so only the config file PATH may show up.
|
|
printf '%s\n' "$*" >> "$ISSUE_COMMENT_CURL_ARGV_LOG"
|
|
|
|
output_file=""
|
|
method="GET"
|
|
url=""
|
|
data=""
|
|
auth_token=""
|
|
config_file=""
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-o) output_file="$2"; shift 2 ;;
|
|
-H)
|
|
[[ "$2" == Authorization:* ]] && auth_token="${2##* }"
|
|
shift 2 ;;
|
|
-K|--config) config_file="$2"; shift 2 ;;
|
|
-w) shift 2 ;;
|
|
-X) method="$2"; shift 2 ;;
|
|
-d|--data) data="$2"; shift 2 ;;
|
|
-s|-S|-sS) shift ;;
|
|
http://*|https://*) url="$1"; shift ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
|
|
# Resolve the bearer token from the curl --config file (its real, secure source);
|
|
# fall back to an -H header only for defense in depth. The config line is
|
|
# `header = "Authorization: token <value>"`.
|
|
if [[ -z "$auth_token" && -n "$config_file" && -f "$config_file" ]]; then
|
|
config_hdr="$(grep -i 'Authorization' "$config_file" 2>/dev/null || true)"
|
|
if [[ "$config_hdr" == *"token "* ]]; then
|
|
auth_token="${config_hdr##*token }"
|
|
auth_token="${auth_token%\"}"
|
|
fi
|
|
fi
|
|
|
|
path="${url%%\?*}"
|
|
query="${url#*\?}"
|
|
[[ "$query" == "$url" ]] && query=""
|
|
printf '%s %s\n' "$method" "$url" >> "$ISSUE_COMMENT_CURL_LOG"
|
|
|
|
# Map the presented bearer token to the identity it authenticates as — the same
|
|
# derivation Gitea's own /user does. The wrapper's write, /user lookup, and
|
|
# read-back must all carry the SAME token, so the acting identity recorded here
|
|
# reveals which credential actually performed the request.
|
|
acting_identity=""
|
|
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" ;;
|
|
esac
|
|
printf '%s %s %s\n' "$method" "$path" "${acting_identity:-<unauthenticated>}" >> "$ISSUE_COMMENT_AUTH_LOG"
|
|
|
|
write_response() {
|
|
local status="$1" body="$2"
|
|
[[ -n "$output_file" ]] || exit 96
|
|
printf '%s' "$body" > "$output_file"
|
|
printf '%s' "$status"
|
|
}
|
|
|
|
if [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_ROOT/user" ]]; then
|
|
[[ -n "$acting_identity" ]] || { write_response 401 '{"message":"unauthenticated"}'; exit 0; }
|
|
write_response 200 "$(ISSUE_COMMENT_LOGIN="$acting_identity" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
print(json.dumps({"login": os.environ["ISSUE_COMMENT_LOGIN"]}))
|
|
PY
|
|
)"
|
|
elif [[ "$method" == "POST" && "$path" == "$ISSUE_COMMENT_API_BASE/issues/7/comments" ]]; then
|
|
result=$(ISSUE_COMMENT_ACTING_LOGIN="${acting_identity:-$ISSUE_COMMENT_ACTING_LOGIN}" ISSUE_COMMENT_DATA="$data" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
|
|
state_path = os.environ["ISSUE_COMMENT_STATE"]
|
|
mode = os.environ["ISSUE_COMMENT_TEST_MODE"]
|
|
acting = os.environ["ISSUE_COMMENT_ACTING_LOGIN"]
|
|
foreign = os.environ["ISSUE_COMMENT_FOREIGN_LOGIN"]
|
|
repo = os.environ["ISSUE_COMMENT_REPO_SLUG"]
|
|
body = json.loads(os.environ["ISSUE_COMMENT_DATA"]).get("body")
|
|
|
|
with open(state_path, encoding="utf-8") as handle:
|
|
comments = json.load(handle)
|
|
|
|
# no-op-concurrent: the wrapper's own write is SUPPRESSED (returns 200 with no
|
|
# created object) even though a concurrent same-identity comment already exists
|
|
# in state. Nothing is persisted; there is no created id to verify.
|
|
if mode == "no-op-concurrent":
|
|
print("200")
|
|
print(json.dumps({}))
|
|
raise SystemExit(0)
|
|
|
|
author = foreign if mode == "author-mismatch" else acting
|
|
new_id = (max((c["id"] for c in comments), default=0)) + 1
|
|
# REAL Gitea comment shape: issue_url is the WEB (html) path, not an API path,
|
|
# and a plain issue comment leaves pull_request_url empty. The URL-injection
|
|
# modes persist a record whose id/author/body are all correct but whose
|
|
# issue_url is forged, so ONLY the origin+path verification can catch them.
|
|
issue_url = f"https://git.mosaicstack.dev/{repo}/issues/7"
|
|
if mode == "url-wrong-host":
|
|
issue_url = f"https://evil.example/{repo}/issues/7"
|
|
elif mode == "url-wrong-owner":
|
|
issue_url = "https://git.mosaicstack.dev/attacker/stack/issues/7"
|
|
elif mode == "url-wrong-repo":
|
|
issue_url = "https://git.mosaicstack.dev/mosaicstack/other/issues/7"
|
|
elif mode == "url-suffix-injection":
|
|
# Prefix-injected: a bare endswith("/<slug>/issues/7") test would ACCEPT this.
|
|
issue_url = f"https://git.mosaicstack.dev/deceptive/{repo}/issues/7"
|
|
record = {
|
|
"id": new_id,
|
|
"body": body,
|
|
"user": {"login": author},
|
|
"issue_url": issue_url,
|
|
"pull_request_url": "",
|
|
}
|
|
comments.append(record)
|
|
with open(state_path, "w", encoding="utf-8") as handle:
|
|
json.dump(comments, handle)
|
|
print("201")
|
|
print(json.dumps(record))
|
|
PY
|
|
)
|
|
response_status="${result%%$'\n'*}"
|
|
response_body=""
|
|
[[ "$result" == *$'\n'* ]] && response_body="${result#*$'\n'}"
|
|
write_response "$response_status" "$response_body"
|
|
elif [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_BASE"/issues/comments/* ]]; then
|
|
result=$(ISSUE_COMMENT_GET_ID="${path##*/}" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
|
|
state_path = os.environ["ISSUE_COMMENT_STATE"]
|
|
wanted = int(os.environ["ISSUE_COMMENT_GET_ID"])
|
|
with open(state_path, encoding="utf-8") as handle:
|
|
comments = json.load(handle)
|
|
match = next((c for c in comments if c["id"] == wanted), None)
|
|
if match is None:
|
|
print("404")
|
|
print(json.dumps({"message": "not found"}))
|
|
else:
|
|
print("200")
|
|
print(json.dumps(match))
|
|
PY
|
|
)
|
|
response_status="${result%%$'\n'*}"
|
|
response_body=""
|
|
[[ "$result" == *$'\n'* ]] && response_body="${result#*$'\n'}"
|
|
write_response "$response_status" "$response_body"
|
|
else
|
|
echo "Unexpected curl request: $method $url" >&2
|
|
exit 97
|
|
fi
|
|
SH
|
|
chmod +x "$BIN_DIR/curl"
|
|
|
|
# Seed persistent server state for a mode, then run the wrapper against it.
|
|
seed_state() {
|
|
local mode="$1"
|
|
ISSUE_COMMENT_SEED_MODE="$mode" ISSUE_COMMENT_SEED_BODY="$BODY" \
|
|
ISSUE_COMMENT_SEED_ACTING="$ACTING_LOGIN" ISSUE_COMMENT_SEED_REPO="$REPO_SLUG" \
|
|
python3 - "$STATE_FILE" <<'PY'
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
mode = os.environ["ISSUE_COMMENT_SEED_MODE"]
|
|
body = os.environ["ISSUE_COMMENT_SEED_BODY"]
|
|
acting = os.environ["ISSUE_COMMENT_SEED_ACTING"]
|
|
repo = os.environ["ISSUE_COMMENT_SEED_REPO"]
|
|
# REAL Gitea comment shape: issue_url is the WEB path, pull_request_url empty.
|
|
issue_url = f"https://git.mosaicstack.dev/{repo}/issues/7"
|
|
|
|
|
|
def comment(cid, text, author):
|
|
return {
|
|
"id": cid,
|
|
"body": text,
|
|
"user": {"login": author},
|
|
"issue_url": issue_url,
|
|
"pull_request_url": "",
|
|
}
|
|
|
|
|
|
if mode == "fresh-success":
|
|
# 50 pre-existing comments already exist; the comment this run creates
|
|
# becomes id 51, proving exact-id read-back works regardless of how many
|
|
# comments precede it (no list enumeration is involved).
|
|
comments = [comment(i, f"prior {i}", acting) for i in range(1, 51)]
|
|
elif mode == "no-op-concurrent":
|
|
# A concurrent SAME-IDENTITY comment with the IDENTICAL body already exists.
|
|
# The wrapper's own write will be a no-op; it must still fail closed because
|
|
# no created id is returned — it must not scan and accept this record.
|
|
comments = [comment(55, body, acting)]
|
|
else: # author-mismatch
|
|
comments = []
|
|
|
|
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
|
json.dump(comments, handle)
|
|
PY
|
|
}
|
|
|
|
run_comment() {
|
|
local mode="$1"
|
|
shift
|
|
: > "$TEA_LOG"
|
|
: > "$CURL_LOG"
|
|
: > "$CURL_ARGV_LOG"
|
|
: > "$AUTH_LOG"
|
|
: > "$OUTPUT_FILE"
|
|
seed_state "$mode"
|
|
(
|
|
cd "$REPO_DIR"
|
|
# Provisioned seats export MOSAIC_GIT_IDENTITY and MOSAIC_BRAIN_HOME
|
|
# seat-wide (launcher), and both escape this harness's sandboxed HOME:
|
|
# detect-platform.sh consults MOSAIC_GIT_IDENTITY BEFORE the repo-local
|
|
# mosaic.gitIdentity pin, and resolves the brain home (whose
|
|
# fleet/agents presence arms the no-identity fail-loud branch) from
|
|
# MOSAIC_BRAIN_HOME before $HOME. Without these explicit empties the
|
|
# wrapper either resolves the REAL seat-slot token (stub curl rejects
|
|
# it: the documented HTTP 401) or fails loud before any request.
|
|
# Set-but-empty reads as unset to detect-platform's "${VAR:-}" forms.
|
|
# NOTE: keep this comment block ABOVE the assignment chain — a comment
|
|
# inside a backslash-continued prefix chain terminates the command and
|
|
# silently demotes every earlier assignment to an unexported subshell
|
|
# assignment (measured 2026-08-28: the wrapper then ran without
|
|
# MOSAIC_CREDENTIALS_FILE and the suite died at credential resolution
|
|
# with zero diagnostic output).
|
|
PATH="$BIN_DIR:$PATH" \
|
|
TMPDIR="$TMP_SCRATCH" \
|
|
HOME="$HOME_DIR" \
|
|
XDG_CONFIG_HOME="$XDG_DIR" \
|
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
|
MOSAIC_GIT_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_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
|
|
}
|
|
|
|
# Assert the wrapper left no scratch temp files behind in TMPDIR (POST/GET
|
|
# request bodies + metadata). Called after both success and failure paths so a
|
|
# clobbered/leaked RETURN trap is caught on every exit route.
|
|
assert_no_temp_leak() {
|
|
local context="$1" leaked
|
|
# Includes the curl auth-config files (mosaic-gitea-auth-*), which carry the
|
|
# bearer token and must be unlinked on every exit path.
|
|
leaked=$(find "$TMP_SCRATCH" -type f \( -name 'mosaic-issue-comment-*' -o -name 'mosaic-gitea-auth-*' \) 2>/dev/null || true)
|
|
if [[ -n "$leaked" ]]; then
|
|
echo "FAIL: issue-comment temp files leaked ($context):" >&2
|
|
printf '%s\n' "$leaked" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# Assert the presented bearer token NEVER appeared in curl's argv (it must travel
|
|
# via a curl --config file), and that --config auth was actually used. On the
|
|
# 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
|
|
echo "FAIL: a Gitea bearer token leaked into curl argv ($context)" >&2
|
|
exit 1
|
|
fi
|
|
if ! grep -q -- '--config' "$CURL_ARGV_LOG"; then
|
|
echo "FAIL: curl was not invoked with --config file auth ($context)" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# Case 1: a genuine REST create (id 51) is verified end to end via its exact
|
|
# provider-returned id — no list enumeration is involved.
|
|
run_comment fresh-success
|
|
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 51)' "$OUTPUT_FILE"
|
|
# The write is a REST POST, never a tea comment.
|
|
grep -q "^POST $API_BASE/issues/7/comments$" "$CURL_LOG"
|
|
if grep -Eq '^comment |^issue comment ' "$TEA_LOG"; then
|
|
echo "FAIL: wrapper wrote a comment via tea instead of REST" >&2
|
|
exit 1
|
|
fi
|
|
# Read-back is a DIRECT GET of the exact created id.
|
|
grep -q "^GET $API_BASE/issues/comments/51$" "$CURL_LOG"
|
|
# Acting identity resolved via GET /user.
|
|
grep -q "^GET $API_ROOT/user$" "$CURL_LOG"
|
|
# No comment-list enumeration is performed — the exact-id GET is authoritative.
|
|
if grep -Eq "^GET $API_BASE/issues/7/comments(\?|$)" "$CURL_LOG"; then
|
|
echo "FAIL: wrapper performed a redundant comment-list enumeration" >&2
|
|
exit 1
|
|
fi
|
|
# Default path (no --login): the host credential fallback resolves, and the
|
|
# write is performed AND self-verified under the host-default acting identity.
|
|
grep -q "^POST $API_BASE/issues/7/comments $ACTING_LOGIN$" "$AUTH_LOG"
|
|
grep -q "^GET $API_BASE/issues/comments/51 $ACTING_LOGIN$" "$AUTH_LOG"
|
|
# Success path leaves no scratch temp files behind.
|
|
assert_no_temp_leak "fresh-success"
|
|
# ITEM 3a: the token drove the write/read-back chain but never appeared in curl
|
|
# argv — it was passed via a curl --config file.
|
|
assert_token_not_in_argv "fresh-success default-token"
|
|
|
|
# Case 2: a no-op write with a concurrent SAME-IDENTITY, same-body comment
|
|
# already present must FAIL CLOSED — the closed concurrency window.
|
|
if run_comment no-op-concurrent; then
|
|
echo "FAIL: wrapper reported success when its write no-opped but a concurrent same-identity comment existed" >&2
|
|
cat "$OUTPUT_FILE" >&2
|
|
exit 1
|
|
fi
|
|
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
|
echo "FAIL: wrapper accepted a concurrent record for a no-op write (window not closed)" >&2
|
|
exit 1
|
|
fi
|
|
# It must NOT have fallen back to a list scan that could find the concurrent id.
|
|
if grep -q "^GET $API_BASE/issues/comments/55$" "$CURL_LOG"; then
|
|
echo "FAIL: wrapper read back the concurrent comment id 55 (illegitimate fallback)" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Case 3: a created record NOT authored by the acting identity must FAIL CLOSED.
|
|
if run_comment author-mismatch; then
|
|
echo "FAIL: wrapper accepted a created comment authored by a different identity" >&2
|
|
cat "$OUTPUT_FILE" >&2
|
|
exit 1
|
|
fi
|
|
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
|
echo "FAIL: read-back did not enforce acting-identity authorship" >&2
|
|
exit 1
|
|
fi
|
|
# Failure-after-read-back path must ALSO leave no scratch temp files behind
|
|
# (proves the RETURN traps clean up on the error-return route, not just success).
|
|
assert_no_temp_leak "author-mismatch"
|
|
|
|
# Case 4: a RESOLVABLE --login override — the write, the /user identity lookup,
|
|
# and the read-back must ALL be performed under THAT login's token/identity, not
|
|
# the host default. The override login has id 1 (empty seed).
|
|
run_comment override-success --login "$OVERRIDE_LOGIN"
|
|
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 1)' "$OUTPUT_FILE"
|
|
grep -q "^GET $API_ROOT/user $OVERRIDE_LOGIN$" "$AUTH_LOG"
|
|
grep -q "^POST $API_BASE/issues/7/comments $OVERRIDE_LOGIN$" "$AUTH_LOG"
|
|
grep -q "^GET $API_BASE/issues/comments/1 $OVERRIDE_LOGIN$" "$AUTH_LOG"
|
|
# The host-default identity must NOT have performed ANY request in this run.
|
|
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
|
|
echo "FAIL: an explicit --login override request was performed under the host default identity" >&2
|
|
cat "$AUTH_LOG" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Case 5: an UNRESOLVABLE --login override (name absent from tea config) must
|
|
# FAIL CLOSED — no silent downgrade to the host default identity: nonzero exit,
|
|
# no success line, and NO write performed.
|
|
if run_comment override-unresolvable --login "nonexistent-typo-login"; then
|
|
echo "FAIL: unresolvable --login override did not fail closed" >&2
|
|
cat "$OUTPUT_FILE" >&2
|
|
exit 1
|
|
fi
|
|
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
|
echo "FAIL: unresolvable --login override reported success" >&2
|
|
exit 1
|
|
fi
|
|
if grep -q "^POST $API_BASE/issues/7/comments" "$CURL_LOG"; then
|
|
echo "FAIL: unresolvable --login override still performed a write" >&2
|
|
exit 1
|
|
fi
|
|
# And it must not have silently fallen back to the host default identity.
|
|
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
|
|
echo "FAIL: unresolvable --login override fell back to the host default identity" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Case 6: a --login override that IS present in tea config but whose URL is a
|
|
# DIFFERENT host than the repo remote must FAIL CLOSED (host-bound selection).
|
|
# The cross-host token must NEVER be sent to the repo host, and no write occurs.
|
|
if run_comment cross-host --login "$CROSS_HOST_LOGIN"; then
|
|
echo "FAIL: cross-host --login override did not fail closed" >&2
|
|
cat "$OUTPUT_FILE" >&2
|
|
exit 1
|
|
fi
|
|
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
|
echo "FAIL: cross-host --login override reported success" >&2
|
|
exit 1
|
|
fi
|
|
# The cross-host credential must not have performed ANY request against the repo
|
|
# host — no request may be attributed to the cross-host identity.
|
|
if grep -q " $CROSS_HOST_LOGIN\$" "$AUTH_LOG"; then
|
|
echo "FAIL: cross-host credential was sent to the repo host (cross-host leak)" >&2
|
|
cat "$AUTH_LOG" >&2
|
|
exit 1
|
|
fi
|
|
if grep -q "^POST $API_BASE/issues/7/comments" "$CURL_LOG"; then
|
|
echo "FAIL: cross-host --login override still performed a write" >&2
|
|
exit 1
|
|
fi
|
|
# It must not have silently downgraded to the host default identity either.
|
|
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
|
|
echo "FAIL: cross-host --login override fell back to the host default identity" >&2
|
|
exit 1
|
|
fi
|
|
assert_no_temp_leak "cross-host"
|
|
|
|
# Cases 7-10 (#865 Blocker 3): the created record's id/author/body are all
|
|
# correct, but its provider-returned issue_url is forged. Verification pins the
|
|
# URL's ORIGIN (scheme+host+effective-port) and its FULL path (deployment prefix
|
|
# + exact owner/repo + kind + number), so each forgery must FAIL CLOSED. A bare
|
|
# endswith/suffix test would wrongly accept the look-alike-host and
|
|
# prefix-injection variants.
|
|
for bad_mode in url-wrong-host url-wrong-owner url-wrong-repo url-suffix-injection; do
|
|
if run_comment "$bad_mode"; then
|
|
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
|
|
cat "$OUTPUT_FILE" >&2
|
|
exit 1
|
|
fi
|
|
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
|
echo "FAIL: forged comment URL ($bad_mode) passed verification" >&2
|
|
exit 1
|
|
fi
|
|
assert_no_temp_leak "$bad_mode"
|
|
done
|
|
|
|
# Sanity: the exact same verification path still ACCEPTS a legitimate web-shaped
|
|
# issue_url (already exercised by Case 1's fresh-success), so the tightened check
|
|
# is not rejecting genuine writes.
|
|
|
|
# Case 11 (R1, 2026-08-28): -b/--body is the canonical comment flag and must
|
|
# drive a full verified write exactly like the -c/--comment alias. BODY_FLAG
|
|
# swaps only the flag spelling; every assertion below is case 1's contract.
|
|
BODY_FLAG="-b"
|
|
run_comment 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"
|
|
if grep -Eq '^comment |^issue comment ' "$TEA_LOG"; then
|
|
echo "FAIL: --body write went through tea instead of REST" >&2
|
|
exit 1
|
|
fi
|
|
grep -q "^GET $API_BASE/issues/comments/51$" "$CURL_LOG"
|
|
grep -q "^POST $API_BASE/issues/7/comments $ACTING_LOGIN$" "$AUTH_LOG"
|
|
assert_no_temp_leak "fresh-success-body-flag"
|
|
assert_token_not_in_argv "fresh-success-body-flag"
|
|
unset BODY_FLAG
|
|
|
|
echo "issue-comment.sh REST create + exact-id read-back regression passed"
|