Compare commits

..

1 Commits

Author SHA1 Message Date
wjarvis mos-comms
cbacd10a66 test(mosaic): wait for broker socket readiness
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
2026-07-20 01:01:49 -05:00
19 changed files with 27 additions and 4087 deletions

View File

@@ -64,7 +64,7 @@ Active workstream is **W1 — Federation v1**. Workers should:
| FCM-M3-002 | in-progress | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Canonical v2 named-socket + legacy-v1 default-server boundaries; fake adapters/temp fixtures only |
| FCM-M4-001 | done | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | PR #788; final head `d63bb0206a1d312ab8352ec1d3ca3631146b0baa`; tree `4da210da9a71b035130d4160a4a2e691bdfde2da`; squash `9745bc3f29c26b021a478b7ad03cfb494f6c9de3`; descendant-main pipeline 1855 terminal success |
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | HOLD: never starts a previously stopped agent or kills an unproven unmanaged session; not authorized by FCM-M5-001 |
| FCM-M5-001 | done | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | #789 content squash 627cf2bb; de-flake repair PR#851/#849 squash 77c9a826; completion proof wp1937 @aa999daf push/ci step 49632 recovery_runtime_unittest.py 3/3 OK (closes wp1932 step 49576 Errno111) |
| FCM-M5-001 | in-progress | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Sole owner: this FCM-M5-001 delivery on the recorded branch; must close every checklist item or record an approved deferral |
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | HOLD: final #758 gate; quality, independent code/security review, validator certificate, merge-gate approval, and green CI remain out of M5-001 |
## Thin-core prompt diet (#528) — feat/contract-thin-core

View File

@@ -1,58 +0,0 @@
# Issue #812 — durable Gitea PR review comments
- **Lane:** ms-812
- **Branch:** `fix/812-pr-review-comment`
- **Issue:** mosaicstack/stack#812
- **Budget:** 15K working estimate; single focused shell-wrapper/test/docs change.
## Objective
Make the Gitea `comment` action in `packages/mosaic/framework/tools/git/pr-review.sh` use the supported Gitea comments REST API and report success only after provider read-back verifies the created comment against the intended repository, PR, and exact body.
## Plan
1. Add and commit a failing shell regression harness before production changes.
2. Verify RED against the nonexistent `tea pr comment` fallback false-positive.
3. Implement the minimal supported write plus ID-based provider read-back.
4. Document that wrapper write output is not durable provenance until read-back succeeds.
5. Run focused regression tests, touched-package tests, and repository quality gates.
6. Remediate review findings, queue-guard, and push for coordinator-owned independent review. Do not open or merge a PR.
## Progress checkpoints
- [x] RED regression committed and reported to mosaic-100 (rebased commit `770e3f57`)
- [x] Initial minimal fix implemented (rebased commit `ea7f8c57`)
- [x] Rebased cleanly onto main `627cf2bb387f7c84a532d88819903a7679ce0d72`
- [x] Codex blocker remediated by replacing unsupported `tea api` with authenticated REST write/read-back
- [x] Focused, package, and repository gates green
- [ ] Coordinator-owned independent review pending after push
- [x] No PR opened; no self-review or self-merge
## Tests run
- RED after rebase: the regression harness failed against `origin/main` with status 1 after reproducing the old `tea pr comment` zero-exit fallback and false success echo.
- GREEN at resumed head: the same harness passed with REST POST 201 plus GET 200 read-back.
- All `packages/mosaic/framework/tools/git/test-*.sh` harnesses passed.
- `shellcheck -x` passed for the changed scripts; `bash -n` passed.
- Manifest resolver returned `framework` for `tools/git/test-pr-review-gitea-comment.sh`.
- `pnpm test` passed (43/43 Turbo tasks; Mosaic 75 files/1434 tests; Gateway 56 files/628 tests plus documented skips).
- `pnpm typecheck` passed (42/42 tasks), `pnpm lint` passed (23/23), and `pnpm format:check` passed.
- Firewall checks found no user-home paths or operator identities in changed shipped files; no token value is logged or echoed.
## Risks / blockers
- No active implementation blocker. #789 reached terminal merged state and the coordination hold was lifted.
- Review round 1 found one portability blocker: the API base reconstructed `https://$host` and discarded configured schemes/path prefixes.
- Review round 2 found a second subpath portability blocker: clone-derived `get_repo_slug` retained the deployment prefix, duplicating it under `/api/v1/repos/`.
- Round 3 resolves owner/repo relative to the configured Gitea base path for HTTP(S) clones while preserving root-mounted and SSH clone forms. Host matching now compares non-default ports consistently.
- REST transport failures, non-201 writes, malformed/missing created IDs, non-200 read-backs, and read-back mismatches all fail closed.
- Existing approve/request-changes behavior remains covered.
- Independent exact-head re-review remains coordinator-owned.
## Final verification evidence
- URL-portability regression was RED before remediation at the new `http://git.mosaicstack.dev` case and GREEN afterward.
- Round-3 genuine subpath regression was RED against round-2 head `1b190201` and GREEN after the fix: `https://git.example/gitea/owner/repo.git` maps to API repository `owner/repo` under configured base `/gitea`.
- Regression coverage verifies POST and read-back GET for root-mounted HTTP(S), path-prefixed HTTP(S), non-default HTTP port, scp-style SSH, and `ssh://` clone forms.
- Focused shell checks, all git-wrapper harnesses, and full repository test/typecheck/lint/format gates passed after remediation.
- Branch will be force-pushed with lease for coordinator re-verification; no PR opened.

View File

@@ -1,35 +0,0 @@
# Git provider wrappers
These scripts provide host-aware GitHub and Gitea issue, pull-request, milestone, and CI operations.
## Durable review provenance
A successful provider write command—or a wrapper message based only on that command's exit code—is **not** durable review provenance. Review comments, approvals, and change requests count as durable provenance only after the wrapper reads the created provider record back and verifies that it was created by _this_ write.
**The write is a direct Gitea REST `POST` that returns the created record's id.** Neither wrapper writes through `tea` — tea 0.11.1 can silently no-op while exiting 0 and cannot emit the id of a record it creates, so its exit code is worthless as proof of a durable write (#865). Instead:
- Comments (`issue-comment.sh`, and the `comment` action of `pr-review.sh`) `POST /api/v1/repos/{owner}/{repo}/issues/{index}/comments`, requiring a `201` and parsing the created comment's `id` from the response body.
- Reviews (`approve` / `request-changes`) `POST /api/v1/repos/{owner}/{repo}/pulls/{index}/reviews` with the `event` (`APPROVED` / `REQUEST_CHANGES`), the review `body`, and `commit_id` pinned to the PR's current head, then parse the created review's `id`. The review body travels _in the review submit itself_ — there is no separate detached comment to reconcile (a Gitea `REQUEST_CHANGES` review requires a non-empty body, which the submit carries).
**Verification keys on that exact provider-returned id.** The wrapper then `GET`s that one record directly — `GET /issues/comments/{id}` or `GET /pulls/{n}/reviews/{id}` — and requires that its `id` equals the created id, its **author login equals the acting identity** (resolved via `GET /api/v1/user` for the token in use), and, for comments, its body exactly matches what was submitted **and its returned web URL belongs to this exact provider and repository** (the `issue_url` / `pull_request_url` origin — scheme, host, and effective port — and full path, i.e. deployment prefix + exact `owner/repo` + kind + number, must match; a suffix/`endsWith` test would accept a look-alike host or a decoy path prefix, so the whole normalized URL is compared). The `comment` action of `pr-review.sh` additionally requires the returned resource be a **pull request** (a populated `pull_request_url`); a bare `issue_url` is rejected, so if issue `#N` exists but PR `#N` does not, an issue comment cannot be reported as a verified PR comment. (`issue-comment.sh` legitimately keeps the broader issue-or-PR acceptance.) For reviews, its state matches the requested action, its reviewed `commit_id` equals the PR head, **and its persisted body equals the submitted body** — an exact, presence- and type-checked equality (a missing/`null` persisted body no longer counts as an empty match), because Gitea can finalize/reuse a pending review id whose stored content was authored elsewhere, so the body is bound too. The write, the `/user` identity lookup, and the read-back all use the **same** credential — the effective login's token, or the host credential when no login is named — so the write is verified against the identity that actually performed it.
**A review's pinned head is re-checked after verification (current-head TOCTOU).** The `commit_id` is pinned to the PR head read _before_ the submit; between that read and the read-back the branch could advance (a force-push or a new commit), leaving a verified review attached to a now-superseded commit while the live tip carries unreviewed code. After the exact-id read-back succeeds, the wrapper re-reads the live PR head (`GET …/pulls/{n}`) and requires it still equals the submitted SHA; if the head advanced it fails closed (non-zero, no success line) rather than reporting a review that no longer covers the PR's current commit.
**This closes the concurrency window rather than documenting it.** Because verification keys on the id the create returned, a no-op create yields no id and fails closed with no list-scan fallback, and a _concurrent_ record — even one written by the _same_ identity with an identical body/state — has a _different_ id and cannot be mistaken for this write. There is no residual same-identity window: the earlier boundary-and-author heuristic (accept any `id > pre-write-max` with a matching author) is replaced entirely by exact-id attribution.
**Exact-id read-back is the sole authority.** Verification is a direct `GET` of the one record the create returned; there is no follow-up list enumeration. An earlier redundant pass that re-listed the record's page (`?limit=&page=1,2,…`) was removed: server-capped page sizes and list-pagination quirks made it a false-failure source (a durable, exact-id-verified record could be missed by a non-exhaustive enumeration), and it added nothing over the authoritative exact-id `GET`.
## Credential handling
The Gitea API token is **never passed on a curl command line.** An `Authorization: token <value>` argument would be visible to any local process that can read the process table (`ps` / `/proc/<pid>/cmdline`) for the lifetime of the request. Instead, every authenticated curl call writes the header into a private, mode-`0600` config file under `$TMPDIR` and passes it with `curl --config <file>` (`gitea_write_auth_config`), so only the file _path_ — never the token — appears in argv. Each such file is unlinked on every exit path (success and failure) by the caller's `RETURN` trap.
## `tea` invocation notes (Gitea)
- tea v0.11.1 has **no `comment` subcommand under `tea pr` or `tea issue`** — the `tea pr comment` / `tea issue comment` forms don't error, they silently fall through to a no-op and still exit 0, producing a false-success write (#865). tea's write subcommands (`tea comment`, `tea pr approve`/`reject`) also cannot report the id of the record they create, so their exit code cannot prove a durable write. These wrappers therefore do **not** write reviews or comments through `tea` at all; they use direct Gitea REST `POST`s that return the created record's id (see "Durable review provenance" above). `tea` is consulted only to enumerate the login list for host→login resolution.
- Because the review body is carried in the `POST …/reviews` submit itself, there is no separate detached review comment, and the historical `tea pr approve`/`reject` trailing-positional-argument vs. nonexistent `--comment`/`-comment` flag hazard (#835) no longer applies to these wrappers — no review comment is ever passed to `tea`.
### `--login` override
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation. The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.

View File

@@ -81,32 +81,7 @@ get_repo_slug() {
gitea_url_matches_host() {
local url="${1:-}" host="${2:-}"
[[ -n "$url" && -n "$host" ]] || return 1
python3 - "$url" "$host" <<'PY'
import sys
from urllib.parse import urlparse
url, remote_host = sys.argv[1:]
configured = urlparse(url)
remote = urlparse(f"//{remote_host}")
if configured.scheme not in {"http", "https"} or configured.hostname != remote.hostname:
raise SystemExit(1)
# Normalize by scheme: an implicit (portless) HTTP(S) URL and its explicit
# default-port form (":80" for http, ":443" for https) name the same
# provider endpoint. Apply that equivalence symmetrically -- whichever side
# omits the port is treated as carrying the scheme's default port -- so
# "configured implicit vs. remote explicit" and "configured explicit vs.
# remote implicit" both match. (The remote side here is always an HTTP(S)
# authority; an SSH remote's transport port is stripped by get_remote_host
# before reaching this comparison, since it identifies an unrelated
# service on the same host, not the HTTP(S) provider port.)
default_port = 80 if configured.scheme == "http" else 443
normalized_configured = configured.port if configured.port is not None else default_port
normalized_remote = remote.port if remote.port is not None else default_port
if normalized_configured != normalized_remote:
raise SystemExit(1)
raise SystemExit(0)
PY
[[ "${url%/}" == "https://$host" || "${url%/}" == "http://$host" || "${url%/}" == *"//$host" ]]
}
get_gitea_service_for_host() {
@@ -372,47 +347,6 @@ get_gitea_api_host_for_repo_override() {
get_host_from_url "${GITEA_URL:-}"
}
# Resolve owner/repo relative to a configured Gitea base URL. HTTP(S) clone
# URLs can include the deployment prefix (for example /gitea/owner/repo.git),
# but Gitea's /repos API expects only owner/repo. Root-mounted and SSH clone
# forms retain their existing owner/repo behavior.
get_gitea_repo_slug_for_url() {
local configured_url="$1" remote_url
remote_url=$(git remote get-url origin 2>/dev/null) || return 1
if [[ "$remote_url" =~ ^https?:// ]]; then
python3 - "$remote_url" "$configured_url" <<'PY'
import sys
from urllib.parse import urlparse
remote = urlparse(sys.argv[1])
base = urlparse(sys.argv[2])
remote_path = remote.path.strip("/")
if remote_path.endswith(".git"):
remote_path = remote_path[:-4]
base_path = base.path.strip("/")
remote_parts = [part for part in remote_path.split("/") if part]
base_parts = [part for part in base_path.split("/") if part]
if base_parts and remote_parts[:len(base_parts)] == base_parts:
repo_parts = remote_parts[len(base_parts):]
elif len(remote_parts) == 2:
# Preserve a root-shaped clone URL when provider API configuration carries
# a reverse-proxy prefix separately.
repo_parts = remote_parts
else:
raise SystemExit(1)
if len(repo_parts) != 2:
raise SystemExit(1)
print("/".join(repo_parts))
PY
return
fi
get_repo_slug
}
get_gitea_repo_args() {
local repo host login
repo=$(get_repo_slug) || return 1
@@ -436,15 +370,6 @@ get_remote_host() {
echo "${host##*@}"
return 0
fi
if [[ "$remote_url" =~ ^ssh://([^/]+)/ ]]; then
local host="${BASH_REMATCH[1]}"
host="${host##*@}"
# Strip an SSH transport port (e.g. "git.example:2222"): it names the
# SSH daemon port, not the HTTP(S) provider API port, and must not
# feed gitea_url_matches_host's port comparison (#850).
echo "${host%%:*}"
return 0
fi
if [[ "$remote_url" =~ ^git@([^:]+): ]]; then
echo "${BASH_REMATCH[1]}"
return 0
@@ -452,51 +377,6 @@ get_remote_host() {
return 1
}
# Resolve the configured Gitea base URL for a host from the same credential
# source used by get_gitea_token. The scheme and any deployment path prefix are
# provider configuration and must not be reconstructed from the git remote.
get_gitea_url_for_host() {
local host="$1" script_dir cred_loader url
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cred_loader="$script_dir/../_lib/credentials.sh"
if [[ -f "$cred_loader" ]]; then
url=$(
# shellcheck source=/dev/null
source "$cred_loader"
unset GITEA_TOKEN GITEA_URL
case "$host" in
git.mosaicstack.dev) load_credentials gitea-mosaicstack 2>/dev/null ;;
git.uscllc.com) load_credentials gitea-usc 2>/dev/null ;;
*)
for svc in gitea-mosaicstack gitea-usc; do
unset GITEA_TOKEN GITEA_URL
load_credentials "$svc" 2>/dev/null || continue
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
break
fi
unset GITEA_TOKEN GITEA_URL
done
;;
esac
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
printf '%s' "${GITEA_URL%/}"
fi
)
if [[ -n "$url" ]]; then
printf '%s\n' "$url"
return 0
fi
fi
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
printf '%s\n' "${GITEA_URL%/}"
return 0
fi
return 1
}
# Resolve a Gitea API token for the given host.
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
get_gitea_token() {
@@ -523,7 +403,7 @@ get_gitea_token() {
for svc in gitea-mosaicstack gitea-usc; do
unset GITEA_TOKEN GITEA_URL
load_credentials "$svc" 2>/dev/null || continue
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
if [[ "${GITEA_URL:-}" == "https://$host" || "${GITEA_URL:-}" == "http://$host" || "${GITEA_URL:-}" == *"//$host" ]]; then
matched=true
break
fi
@@ -543,7 +423,7 @@ get_gitea_token() {
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
if [[ -n "${GITEA_TOKEN:-}" ]]; then
if [[ -z "${GITEA_URL:-}" ]] || gitea_url_matches_host "$GITEA_URL" "$host"; then
if [[ -z "${GITEA_URL:-}" || "${GITEA_URL:-}" == "https://$host" || "${GITEA_URL:-}" == "http://$host" || "${GITEA_URL:-}" == *"//$host" ]]; then
echo "$GITEA_TOKEN"
return 0
fi
@@ -563,623 +443,6 @@ get_gitea_token() {
return 1
}
# Stage the Gitea bearer credential for curl OUTSIDE the process argument vector.
# Passing "-H 'Authorization: token <value>'" on the curl command line exposes
# the token to anyone who can read the process table (ps / /proc/<pid>/cmdline)
# for the lifetime of the request. Instead, write the header into a private
# (mode 0600) curl config file and have callers pass it with `curl --config`, so
# only the FILE PATH — never the token — appears in argv. Prints the temp file
# path on success; the caller OWNS the file and MUST remove it on every exit
# path (success and failure). $1 = bearer token. Callers must not log the token.
gitea_write_auth_config() {
local token="$1" auth_file
auth_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-gitea-auth.XXXXXX") || return 1
# mktemp already creates the file with 0600; be explicit in case of an
# unusual umask so the credential is never briefly group/other readable.
chmod 600 "$auth_file" 2>/dev/null || true
# `header = "..."` is curl's config syntax for an extra request header. Only
# this filename reaches curl's argv; the token stays on disk, readable solely
# by this user, and is unlinked by the caller's trap after the request.
if ! printf 'header = "Authorization: token %s"\n' "$token" > "$auth_file"; then
rm -f "$auth_file"
return 1
fi
printf '%s' "$auth_file"
}
# Resolve the API token for a SPECIFIC tea login name from tea's own config
# (the same store tea itself writes/reads for `--login <name>`). This is what
# lets a REST write be performed AS the selected --login identity: tea keys its
# per-login tokens by `name` in $XDG_CONFIG_HOME/tea/config.yml (default
# ~/.config/tea/config.yml), exactly as the `tea` CLI resolves them, so a
# --login override and its REST read-back bind to the SAME credential/identity.
#
# $2 (repo host) binds the selected credential to the TARGET host: a tea login
# also records the `url` it authenticates against, and the matched login's URL
# host MUST equal the repo host. This fails closed when an override login is
# configured for a DIFFERENT host than the repo remote, so a login name shared
# across hosts (or a mistargeted override) can never send one host's credential
# to another host (cross-host credential leak). When $2 is empty the host bind
# is skipped (host-agnostic lookup) — callers that write should always pass it.
#
# Prints the token on success; returns non-zero (no output) if the config, a
# matching login token, or the host bind cannot be satisfied. Callers must not
# log the result.
get_gitea_token_for_login() {
local login_name="$1" repo_host="${2:-}" config_file
[[ -n "$login_name" ]] || return 1
config_file="${XDG_CONFIG_HOME:-$HOME/.config}/tea/config.yml"
[[ -f "$config_file" ]] || return 1
LOGIN_NAME="$login_name" REPO_HOST="$repo_host" python3 - "$config_file" <<'PY'
import datetime
import os
import re
import sys
from urllib.parse import urlparse
wanted = os.environ["LOGIN_NAME"]
repo_host = os.environ.get("REPO_HOST", "").strip().lower()
config_path = sys.argv[1]
# PyYAML 6.0.3 SafeLoader implicit resolver patterns (YAML 1.1). An UNQUOTED
# plain scalar matching any of these is resolved by PyYAML to a NON-string type
# (null->None, bool, int, float, timestamp->date/datetime); a quoted scalar is
# ALWAYS a string. These mirror yaml/resolver.py so the PyYAML-absent fallback
# types unquoted scalars exactly as PyYAML would (see _implicit_nonstring).
_IMPLICIT_NULL = re.compile(r"^(?:~|null|Null|NULL|)$")
_IMPLICIT_BOOL = re.compile(
r"^(?:yes|Yes|YES|no|No|NO|true|True|TRUE|false|False|FALSE"
r"|on|On|ON|off|Off|OFF)$"
)
_IMPLICIT_INT = re.compile(
r"^(?:[-+]?0b[0-1_]+"
r"|[-+]?0[0-7_]+"
r"|[-+]?(?:0|[1-9][0-9_]*)"
r"|[-+]?0x[0-9a-fA-F_]+"
r"|[-+]?[1-9][0-9_]*(?::[0-5]?[0-9])+)$"
)
_IMPLICIT_FLOAT = re.compile(
r"^(?:[-+]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+]?[0-9]+)?"
r"|\.[0-9][0-9_]*(?:[eE][-+]?[0-9]+)?"
r"|[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*"
r"|[-+]?\.(?:inf|Inf|INF)"
r"|\.(?:nan|NaN|NAN))$"
)
_IMPLICIT_TIMESTAMP = re.compile(
r"^(?:[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]"
r"|[0-9][0-9][0-9][0-9]-[0-9][0-9]?-[0-9][0-9]?"
r"(?:[Tt]|[ \t]+)[0-9][0-9]?"
r":[0-9][0-9]:[0-9][0-9](?:\.[0-9]*)?"
r"(?:[ \t]*(?:Z|[-+][0-9][0-9]?(?::[0-9][0-9])?))?)$"
)
def _implicit_nonstring(text):
# True when an UNQUOTED plain scalar would be resolved by PyYAML's SafeLoader
# to a non-string type (null/bool/int/float/timestamp). Fuzzed against real
# PyYAML 6.0.3: it never returns False where PyYAML types the scalar as a
# non-string (i.e. never fail-open), and is at worst MORE conservative on a
# couple of degenerate float spellings (e.g. "4.e8") that PyYAML keeps as a
# string -- the safe direction for this credential-selecting fallback.
return bool(
_IMPLICIT_NULL.match(text)
or _IMPLICIT_BOOL.match(text)
or _IMPLICIT_INT.match(text)
or _IMPLICIT_FLOAT.match(text)
or _IMPLICIT_TIMESTAMP.match(text)
)
# PyYAML 6.0.3 SafeConstructor timestamp regexp (yaml/constructor.py). The
# constructor RE-parses a timestamp-tagged scalar with THIS pattern and then
# builds a datetime.date/datetime, which raises ValueError for an out-of-range
# calendar field (e.g. month 99, hour 25). _IMPLICIT_TIMESTAMP (the RESOLVER
# pattern) is byte-identical to PyYAML's resolver, so anything it tags is also
# tagged by PyYAML and re-matched here.
_TIMESTAMP_CONSTRUCT = re.compile(
r"""^(?P<year>[0-9][0-9][0-9][0-9])
-(?P<month>[0-9][0-9]?)
-(?P<day>[0-9][0-9]?)
(?:(?:[Tt]|[ \t]+)
(?P<hour>[0-9][0-9]?)
:(?P<minute>[0-9][0-9])
:(?P<second>[0-9][0-9])
(?:\.(?P<fraction>[0-9]*))?
(?:[ \t]*(?P<tz>Z|(?P<tz_sign>[-+])(?P<tz_hour>[0-9][0-9]?)
(?::(?P<tz_minute>[0-9][0-9]))?))?)?$""",
re.X,
)
def _int_constructible(text):
# Replicate PyYAML SafeConstructor.construct_yaml_int and report whether it
# would succeed. A resolver-tagged int whose radix body is empty after
# underscore removal (e.g. "0b_", "0x_", "0x__") makes int(base) raise, so
# PyYAML fails the WHOLE document -> the fallback must fail closed too.
value = text.replace("_", "")
if value[:1] in ("+", "-"):
value = value[1:]
if value == "0":
return True
try:
if value.startswith("0b"):
int(value[2:], 2)
elif value.startswith("0x"):
int(value[2:], 16)
elif value[:1] == "0":
int(value, 8)
elif ":" in value:
[int(part) for part in value.split(":")]
else:
int(value)
except ValueError:
return False
return True
def _float_constructible(text):
# Replicate PyYAML SafeConstructor.construct_yaml_float. Retained for the
# WHOLE-document invariant even though PyYAML's float-tagged set is always
# float()-constructible: the fallback's float RESOLVER pattern is a strict
# superset of PyYAML's (it also matches unsigned-exponent spellings PyYAML
# keeps as strings), and every such extra is likewise constructible, so this
# never fails closed where PyYAML would emit a token.
value = text.replace("_", "").lower()
if value[:1] in ("+", "-"):
value = value[1:]
if value in (".inf", ".nan"):
return True
try:
if ":" in value:
[float(part) for part in value.split(":")]
else:
float(value)
except ValueError:
return False
return True
def _timestamp_constructible(text):
# Replicate PyYAML SafeConstructor.construct_yaml_timestamp: build the same
# datetime.date/datetime and report whether it raises. Returns False for an
# out-of-range calendar field (month/day/hour/...), matching PyYAML's
# whole-document ValueError.
match = _TIMESTAMP_CONSTRUCT.match(text)
if not match:
return False
values = match.groupdict()
try:
year = int(values["year"])
month = int(values["month"])
day = int(values["day"])
if not values["hour"]:
datetime.date(year, month, day)
return True
hour = int(values["hour"])
minute = int(values["minute"])
second = int(values["second"])
fraction = 0
if values["fraction"]:
frac = values["fraction"][:6]
frac += "0" * (6 - len(frac))
fraction = int(frac)
tzinfo = None
if values["tz_sign"]:
tz_hour = int(values["tz_hour"])
tz_minute = int(values["tz_minute"] or 0)
delta = datetime.timedelta(hours=tz_hour, minutes=tz_minute)
if values["tz_sign"] == "-":
delta = -delta
tzinfo = datetime.timezone(delta)
elif values["tz"]:
tzinfo = datetime.timezone.utc
datetime.datetime(
year, month, day, hour, minute, second, fraction, tzinfo=tzinfo
)
except (ValueError, OverflowError):
return False
return True
def _constructible(text):
# WHOLE-DOCUMENT INVARIANT: the fallback resolves the SAME login token as
# PyYAML safe_load or fails closed -- never less conservative -- INCLUDING
# when PyYAML raises a CONSTRUCTOR error anywhere in the document. A plain
# scalar can match a typed implicit resolver (int/float/timestamp) yet NOT be
# constructible (e.g. 2023-99-99, 0b_, 0x_); PyYAML then raises on the whole
# load and yields no token, so the fallback MUST fail closed for the whole
# document too. This returns True only when PyYAML's constructor would build
# the scalar (null/bool token sets are always constructible), else False so
# the caller fails closed. `text` is assumed to satisfy _implicit_nonstring.
if _IMPLICIT_NULL.match(text) or _IMPLICIT_BOOL.match(text):
return True
if _IMPLICIT_TIMESTAMP.match(text):
return _timestamp_constructible(text)
if _IMPLICIT_INT.match(text):
return _int_constructible(text)
if _IMPLICIT_FLOAT.match(text):
return _float_constructible(text)
return True
# Sentinel: "outside the supported subset -> fail closed". Distinct from a
# genuine null (None), which is a valid resolved value.
_FAIL = object()
# Separators are SPACE-only: PyYAML rejects a tab used as key/value whitespace
# (before or after the ':') with a scanner error, so a tab there must NOT be
# treated as a benign separator. Space before the colon and one space after it
# stay valid (PyYAML strips a plain key's trailing spaces); a tab in either
# position makes the whole line fail to match -> the caller fails closed.
_KEY_RE = re.compile(r"^([A-Za-z0-9_][A-Za-z0-9_.\-]*) *:(?:[ ](.*)|)$")
_FLOW = set("[]{}*&!")
class _Bail(Exception):
# Raised the instant the document leaves the narrow tea-config subset this
# recognizer can prove it resolves IDENTICALLY to PyYAML. Caught by
# _safe_parse, which then fails closed (returns _FAIL) rather than guess.
pass
def _scalar(raw):
# Resolve a single flow scalar (quoted or plain) the way PyYAML would for
# tea's simple values, or _FAIL when it is outside the supported subset so
# the caller fails closed instead of guessing.
#
# A quoted scalar is ALWAYS a string (its contents returned verbatim); a '#'
# inside quotes is data. An UNQUOTED scalar ends at the first whitespace
# -preceded '#' (a YAML comment must be preceded by whitespace or line start,
# so "abc#def" stays literal while "abc # note" becomes "abc"), and is then
# subject to PyYAML's implicit typing: forms like 12345 / null / ~ / yes /
# 3.14 / a timestamp resolve to a NON-string (int/None/bool/float/date), so
# they return None here (PyYAML's path rejects a non-str token via _accept).
# Strip SPACES only, never tabs: PyYAML raises a scanner error on a tab in a
# plain/leading/trailing scalar position, so a tab must be PRESERVED here to
# trip the fail-closed guard below rather than be silently normalized away.
value = raw.strip(" ")
if not value:
return None # empty plain scalar -> null
if value[0] in ("'", '"'):
quote = value[0]
end = value.find(quote, 1)
if end == -1:
return _FAIL # unterminated quote: PyYAML would error / continue
rest = value[end + 1:].strip(" ")
if rest and not rest.startswith("#"):
return _FAIL # trailing junk after a quoted scalar
inner = value[1:end]
# Single-quote '' escaping and double-quote backslash escapes are NOT
# interpreted here; reject any scalar that uses them so we never diverge
# from PyYAML on escape handling.
if quote == "'" and "'" in inner:
return _FAIL
if quote == '"' and "\\" in inner:
return _FAIL
return inner
for i, ch in enumerate(value):
if ch == "#" and (i == 0 or value[i - 1] in (" ", "\t")):
value = value[:i]
break
value = value.strip(" ") # spaces only; a tab must survive to fail closed
if not value:
return None
if value[0] in _FLOW or value[0] in ("|", ">", "?", "@", "`", '"', "'", "%", ","):
return _FAIL # flow / block-scalar / reserved / directive / anchor / quote
if value[0] in ("-", "?", ":") and (len(value) == 1 or value[1] in (" ", "\t")):
# A bare block indicator, not a plain scalar: '-'/'- ' opens a sequence
# entry, '?'/'? ' a complex mapping key, ':'/': ' a mapping value -- all
# illegal in a value position, where PyYAML raises a scanner error on the
# whole document. "-x"/"-1"/"?x"/":x" (indicator NOT followed by space)
# remain valid plain scalars and fall through. Fail closed on the bare
# indicator so the fallback never emits a token PyYAML would refuse.
return _FAIL
if any(ch in _FLOW for ch in value):
return _FAIL
if "\t" in value:
# A tab anywhere in a plain scalar (leading, trailing, or embedded) is a
# PyYAML scanner error on the whole document -- it accepts tabs ONLY
# inside quoted scalars (handled above, returned verbatim). Fail closed
# so the fallback never emits a token PyYAML would refuse over a tab.
return _FAIL
if ": " in value or value.endswith(":"):
return _FAIL # nested-mapping-in-scalar / ambiguous
if _implicit_nonstring(value):
# A plain scalar PyYAML would tag as a non-string (null/bool/int/float/
# timestamp). If PyYAML's CONSTRUCTOR would build it, the value is a
# non-string -> null-equivalent for a token field: return None and keep
# parsing (as before). But if it matches a typed implicit resolver yet is
# NOT constructible (e.g. 2023-99-99, 0b_, 0x_), PyYAML raises on the
# WHOLE document and yields no token, so the fallback MUST fail closed
# for the whole document too -> _FAIL (which the caller turns into _Bail).
if not _constructible(value):
return _FAIL
return None
return value
class _Parser:
# A deliberately NARROW, conservative recognizer for the block-style YAML
# subset tea writes (mappings of scalar fields; a `logins:` block SEQUENCE of
# such mappings; optional shallow nested mappings for e.g. preferences). It
# reconstructs the SAME Python object PyYAML's SafeLoader would, but the
# instant it meets anything it cannot prove it handles identically -- a
# document marker (--- / ...), a block scalar (| / >), a flow collection, a
# duplicate mapping key, inconsistent indentation, an escape, or any line
# outside the grammar -- it raises _Bail so the whole resolution fails
# closed. This guarantees the module invariant (only ever MORE conservative
# than PyYAML, never less) at the DOCUMENT level, closing the structural
# fail-open classes (nested-logins shadow, block-scalar shadow, duplicate
# root key, malformed-after-valid, and extra-document) that a line scan that
# does not validate whole-document structure would miss.
def __init__(self, lines):
self.toks = []
for raw in lines:
if not raw.strip():
continue
lead = raw[: len(raw) - len(raw.lstrip(" \t"))]
if "\t" in lead:
raise _Bail() # tab in indentation: PyYAML scanner error
indent = len(lead)
body = raw[indent:]
if body.lstrip().startswith("#"):
continue
if re.match(r"^(---|\.\.\.)(\s|$)", body) or body in ("---", "..."):
raise _Bail() # document / end marker -> multi-doc -> fail closed
# rstrip SPACES only: a trailing tab is a PyYAML scanner error, so it
# must be kept on the token body to reach the fail-closed guards
# (rstrip() would swallow it and let a bad line resolve a token).
self.toks.append((indent, body.rstrip(" ")))
self.i = 0
def peek(self):
return self.toks[self.i] if self.i < len(self.toks) else None
def parse_document(self):
if not self.toks:
return None
node = self.parse_node(0)
if self.i != len(self.toks):
raise _Bail() # trailing unconsumed content -> malformed
return node
def parse_node(self, min_indent):
tok = self.peek()
if tok is None:
return None
indent, body = tok
if indent < min_indent:
return None
if body.startswith("-") and (len(body) == 1 or body[1] in (" ", "\t")):
return self.parse_seq(indent)
return self.parse_map(indent)
def parse_map(self, indent):
result = {}
while True:
tok = self.peek()
if tok is None:
break
cur_indent, body = tok
if cur_indent < indent:
break
if cur_indent > indent:
raise _Bail() # unexpected deeper line (bad indentation)
if body.startswith("-") and (len(body) == 1 or body[1] in (" ", "\t")):
raise _Bail() # sequence item where a mapping entry was expected
m = _KEY_RE.match(body)
if not m:
raise _Bail()
key = m.group(1)
inline = m.group(2)
self.i += 1
if key in result:
raise _Bail() # duplicate mapping key (PyYAML last-wins; we bail)
if inline is not None and inline.strip(" ") != "":
val = _scalar(inline)
if val is _FAIL:
raise _Bail()
result[key] = val
else:
result[key] = self.parse_block_value(indent)
return result
def parse_block_value(self, key_indent):
# The value after a "key:" with no inline scalar. A block SEQUENCE may sit
# at the same indent as the key (YAML permits `- ` aligned with the key --
# tea's own on-disk shape) or deeper; a block MAPPING must be strictly
# deeper; otherwise the value is null.
nxt = self.peek()
if nxt is None:
return None
ni, nb = nxt
is_item = nb.startswith("-") and (len(nb) == 1 or nb[1] in (" ", "\t"))
if is_item and ni >= key_indent:
return self.parse_seq(ni)
if ni > key_indent:
return self.parse_node(ni)
return None
def parse_seq(self, indent):
result = []
while True:
tok = self.peek()
if tok is None:
break
cur_indent, body = tok
if cur_indent < indent:
break
if cur_indent > indent:
raise _Bail()
if not (body.startswith("-") and (len(body) == 1 or body[1] in (" ", "\t"))):
break # a mapping entry at this indent ends the sequence
rest = body[1:].strip(" ") # spaces only; a tab must survive to bail
self.i += 1
if rest == "":
nxt = self.peek()
if nxt is not None and nxt[0] > indent:
result.append(self.parse_node(indent + 1))
else:
result.append(None)
continue
km = _KEY_RE.match(rest)
if km:
# "- key: value" opens a mapping whose fields continue at the
# column where the content after the dash began.
field_indent = indent + (len(body) - len(body[1:].lstrip()))
result.append(self.parse_inline_map(field_indent, km))
else:
val = _scalar(rest)
if val is _FAIL:
raise _Bail()
result.append(val)
return result
def parse_inline_map(self, field_indent, first_match):
result = {}
key = first_match.group(1)
inline = first_match.group(2)
if inline is not None and inline.strip(" ") != "":
val = _scalar(inline)
if val is _FAIL:
raise _Bail()
result[key] = val
else:
result[key] = self.parse_block_value(field_indent)
while True:
tok = self.peek()
if tok is None:
break
cur_indent, body = tok
if cur_indent != field_indent:
if cur_indent > field_indent:
raise _Bail()
break
if body.startswith("-") and (len(body) == 1 or body[1] in (" ", "\t")):
raise _Bail()
m = _KEY_RE.match(body)
if not m:
raise _Bail()
k = m.group(1)
iv = m.group(2)
self.i += 1
if k in result:
raise _Bail()
if iv is not None and iv.strip(" ") != "":
v = _scalar(iv)
if v is _FAIL:
raise _Bail()
result[k] = v
else:
result[k] = self.parse_block_value(field_indent)
return result
def _safe_parse(lines):
# Return the parsed root object (dict/list/scalar/None) when the WHOLE
# document is inside the supported subset, else _FAIL (fail closed).
try:
return _Parser(lines).parse_document()
except _Bail:
return _FAIL
except Exception:
return _FAIL
def _url_matches_repo_host(url):
# Mirror gitea_url_matches_host (detect-platform.sh): the login's recorded
# URL must name the SAME host AND the SAME effective port as the repo remote
# host, not merely the same hostname. A login configured for an explicit,
# non-default provider port (e.g. :9443) must NOT satisfy a portless (default
# -port) repo host, and a login on the matching port (e.g. :8443) must NOT be
# rejected. Ports are normalized by applying the login URL's scheme default
# (80 for http, else 443) to whichever side omits the port, symmetrically, so
# an implicit port and its explicit default-port form compare equal.
if not isinstance(url, str) or not url:
return False
configured = urlparse(url if "//" in url else f"//{url}")
remote = urlparse(f"//{repo_host}")
configured_host = configured.hostname
if not configured_host or configured_host.lower() != (remote.hostname or "").lower():
return False
default_port = 80 if configured.scheme == "http" else 443
configured_port = configured.port if configured.port is not None else default_port
remote_port = remote.port if remote.port is not None else default_port
return configured_port == remote_port
def _accept(token, url):
# Enforce the host bind before surfacing a token. When a repo host is given,
# the login's recorded URL host AND port must match it; a login with no
# usable URL (or a mismatched host/port) is rejected (fail closed) so a
# cross-host (or cross-port) credential is never emitted.
if not isinstance(token, str) or not token:
return None
if repo_host:
if not _url_matches_repo_host(url):
return None
return token
def _token_via_pyyaml():
# Preferred, fully general path when PyYAML is installed. Raises ImportError
# (caught by the caller) when the module is unavailable so the environment
# -robust fallback can take over instead of failing closed on every host
# that lacks PyYAML.
import yaml
with open(config_path, encoding="utf-8") as handle:
config = yaml.safe_load(handle)
logins = config.get("logins") if isinstance(config, dict) else None
if not isinstance(logins, list):
return None
for login in logins:
if isinstance(login, dict) and str(login.get("name") or "") == wanted:
return _accept(login.get("token"), login.get("url"))
return None
def _token_via_lines():
# Conservative fallback for hosts without PyYAML. It parses config.yml with a
# strict recognizer (_safe_parse) of the narrow block-style subset tea writes,
# which reconstructs the SAME object PyYAML would OR fails closed (_FAIL) on
# ANYTHING it cannot prove it resolves identically -- document markers, block
# scalars, flow collections, duplicate keys, inconsistent indentation, or any
# line outside the grammar. On a recognized document it then resolves the
# login EXACTLY as the PyYAML path does (root `logins` list -> first entry
# whose `name` equals the request -> host/port-bound token), so the fallback
# can only ever be MORE conservative than PyYAML, never less.
with open(config_path, encoding="utf-8") as handle:
lines = handle.read().splitlines()
config = _safe_parse(lines)
if config is _FAIL:
return None
logins = config.get("logins") if isinstance(config, dict) else None
if not isinstance(logins, list):
return None
for login in logins:
if isinstance(login, dict) and str(login.get("name") or "") == wanted:
return _accept(login.get("token"), login.get("url"))
return None
try:
try:
token = _token_via_pyyaml()
except ImportError:
token = _token_via_lines()
except Exception:
raise SystemExit(1)
if isinstance(token, str) and token:
print(token)
raise SystemExit(0)
raise SystemExit(1)
PY
}
# Resolve HTTPS basic auth credentials for a Gitea host from ~/.git-credentials.
# Prints "username:password" for direct curl -u consumption. Callers must not log it.
get_gitea_basic_auth() {

View File

@@ -1,26 +1,6 @@
#!/bin/bash
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea
# Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]
#
# tea v0.11.1 defines no `comment` subcommand under `tea issue` (or `tea pr`);
# the non-existent `tea issue comment ...` form does not error — tea silently
# no-ops and still exits 0, so a caller trusting the exit code believes a
# comment was posted when it was not (#865). tea 0.11.1 also cannot reliably
# emit the id of a record it created, so an exit code is the ONLY signal it
# offers — and that signal is untrustworthy. This script therefore does not
# write via tea at all: it POSTs the comment through the Gitea REST API (which
# returns the created comment object, including its id), then GETs that exact
# id back and fails closed unless it matches. Keying verification to the
# provider-returned created id means a concurrent comment cannot masquerade as
# this write and a no-op create simply yields no id to verify.
#
# --login override: the default login is resolved from the local `tea` login
# list for this repo's host (get_gitea_login). Pass --login <name> to override
# it for this invocation only. The REST write, the /user identity read, and the
# read-back are ALL performed with the token of the EFFECTIVE login (the
# override when given), so the write and its verification bind to the same
# identity — a --login override is never written under one credential and
# verified under a different default one.
# Usage: issue-comment.sh -i <issue_number> -c <comment>
set -e
@@ -30,7 +10,6 @@ source "$SCRIPT_DIR/detect-platform.sh"
# Parse arguments
ISSUE_NUMBER=""
COMMENT=""
LOGIN_OVERRIDE=""
while [[ $# -gt 0 ]]; do
case $1 in
@@ -42,17 +21,12 @@ while [[ $# -gt 0 ]]; do
COMMENT="$2"
shift 2
;;
-l|--login)
LOGIN_OVERRIDE="$2"
shift 2
;;
-h|--help)
echo "Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]"
echo "Usage: issue-comment.sh -i <issue_number> -c <comment>"
echo ""
echo "Options:"
echo " -i, --issue Issue number (required)"
echo " -c, --comment Comment text (required)"
echo " -l, --login Override the detected Gitea tea login for this call"
echo " -h, --help Show this help"
exit 0
;;
@@ -75,273 +49,20 @@ fi
detect_platform >/dev/null
# Resolve and cache the Gitea REST endpoint + token for the current remote,
# bound to a SPECIFIC login identity ($1). Populates GITEA_API_ROOT (…/api/v1),
# GITEA_API_BASE (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
#
# The token is resolved for the EFFECTIVE login (the --login override when
# given, otherwise the detected default) so that the single credential used for
# the write ALSO drives the /user identity read and the read-back — write token
# and read-back token are the same identity by construction (this is the
# credential-ordering fix: a --login override is no longer written under one
# credential and verified under a different default one). Falls back to the
# host-scoped credential ONLY when NO --login override was supplied (the
# best-effort default path). When $2 is "explicit" the login came from a
# caller-supplied --login: that exact login's token MUST resolve, and we FAIL
# CLOSED rather than silently downgrading the write to the host default
# identity — otherwise a caller relying on a dedicated per-role credential would
# be told the write succeeded as requested while it was attributed to the shared
# default. Returns non-zero (clear stderr) on any resolution failure.
gitea_resolve_api_for_login() {
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
host=$(get_remote_host)
if [[ -n "$override_explicit" ]]; then
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (comment write/read-back)" >&2
return 1
}
else
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for login '$effective_login' (comment write/read-back)" >&2
return 1
}
fi
configured_url=$(get_gitea_url_for_host "$host") || {
echo "Error: Configured Gitea URL not found for comment read-back verification" >&2
return 1
}
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
return 1
}
GITEA_API_ROOT="${configured_url%/}/api/v1"
GITEA_API_BASE="$GITEA_API_ROOT/repos/$repo"
# The provider WEB base (scheme + host + effective port + any deployment path
# prefix) that Gitea uses to build a comment's html issue_url/pull_request_url.
# Read-back verification pins the returned URL's origin + path prefix to THIS,
# not just a repo/issue suffix.
GITEA_WEB_BASE="${configured_url%/}"
return 0
}
# Resolve the login of the identity the API token authenticates as (GET
# /user). Used to attribute a read-back record to THIS invocation's writer so
# a concurrent write from a DIFFERENT identity cannot satisfy verification.
# Prints the login on success.
gitea_authenticated_login() {
local response_file auth_config status
response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-whoami.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$response_file"
echo "Error: could not stage Gitea credential for identity read" >&2
return 1
}
trap 'rm -f "$response_file" "$auth_config"' RETURN
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
--config "$auth_config" \
"$GITEA_API_ROOT/user"); then
echo "Error: Gitea authenticated-identity read transport failed" >&2
return 1
fi
if [[ "$status" != "200" ]]; then
echo "Error: Gitea authenticated-identity read failed with HTTP $status" >&2
return 1
fi
python3 - "$response_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
user = json.load(response)
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login:
raise ValueError("missing authenticated login")
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
print(f"Error: could not resolve authenticated Gitea identity: {error}", file=sys.stderr)
raise SystemExit(1)
print(login)
PY
}
# Post a comment to a Gitea issue via the supported REST API and verify it
# durably against a PROVIDER-RETURNED created id — never trust an exit code
# (#865 defect class: tea's non-existent `tea issue comment` no-ops yet exits
# 0). The write is a direct POST that returns the created comment object, so we
# learn the exact id of THIS write; we then GET that exact id and require
# id == created id AND author == acting identity AND exact body AND that it
# belongs to this issue. Because verification is keyed to the id the create
# returned, a concurrent comment (even same identity, same body) CANNOT
# masquerade as this write, and a suppressed/no-op write yields no created id
# and fails closed — there is no fallback list scan that a concurrent record
# could satisfy. Prints the created comment id on success.
#
# Args: $1 = issue number, $2 = comment body, $3 = acting identity login.
gitea_create_comment_verified() {
local issue_number="$1" comment_body="$2" acting_login="$3"
local payload write_file readback_file auth_config write_status readback_status created_id
payload=$(COMMENT_BODY="$comment_body" python3 -c '
import json
import os
print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
')
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-write.XXXXXX")
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-getid.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$write_file" "$readback_file"
echo "Error: could not stage Gitea credential for comment write" >&2
return 1
}
trap 'rm -f "$write_file" "$readback_file" "$auth_config"' RETURN
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
-X POST \
--config "$auth_config" \
-H 'Content-Type: application/json' \
-d "$payload" \
"$GITEA_API_BASE/issues/$issue_number/comments"); then
echo "Error: Gitea comment write transport failed" >&2
return 1
fi
if [[ "$write_status" != "201" ]]; then
echo "Error: Gitea comment write failed with HTTP $write_status (#865: no durable comment created)" >&2
return 1
fi
created_id=$(python3 - "$write_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
comment = json.load(response)
created_id = comment.get("id") if isinstance(comment, dict) else None
if not isinstance(created_id, int) or created_id <= 0:
raise ValueError("create response carried no positive comment id")
except (OSError, json.JSONDecodeError, ValueError) as error:
print(f"Error: could not identify created Gitea comment: {error}", file=sys.stderr)
raise SystemExit(1)
print(created_id)
PY
) || return 1
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
--config "$auth_config" \
"$GITEA_API_BASE/issues/comments/$created_id"); then
echo "Error: Gitea comment read-back transport failed" >&2
return 1
fi
if [[ "$readback_status" != "200" ]]; then
echo "Error: Gitea comment read-back failed with HTTP $readback_status" >&2
return 1
fi
EXPECTED_COMMENT_ID="$created_id" EXPECTED_COMMENT_BODY="$comment_body" \
ACTING_LOGIN="$acting_login" EXPECTED_REPO_SLUG="${GITEA_API_BASE##*/repos/}" \
EXPECTED_NUMBER="$issue_number" EXPECTED_WEB_BASE="$GITEA_WEB_BASE" \
python3 - "$readback_file" <<'PY' || return 1
import json
import os
import sys
from urllib.parse import urlparse
def _origin_and_path(url):
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
# port and its explicit default form compare equal.
parsed = urlparse(url or "")
scheme = (parsed.scheme or "").lower()
host = (parsed.hostname or "").lower()
default_port = 80 if scheme == "http" else 443
port = parsed.port if parsed.port is not None else default_port
return (scheme, host, port), parsed.path.rstrip("/")
try:
with open(sys.argv[1], encoding="utf-8") as response:
comment = json.load(response)
if not isinstance(comment, dict):
raise ValueError("response is not a comment object")
expected_id = int(os.environ["EXPECTED_COMMENT_ID"])
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
acting_login = os.environ["ACTING_LOGIN"]
slug = os.environ["EXPECTED_REPO_SLUG"]
number = os.environ["EXPECTED_NUMBER"]
web_base = os.environ["EXPECTED_WEB_BASE"]
# Gitea populates WEB (html) URLs here, not API paths. A plain issue comment
# carries issue_url = <web_base>/<owner>/<repo>/issues/<n> (pull_request_url
# empty); a comment posted to a PR's conversation carries
# pull_request_url = <web_base>/<owner>/<repo>/pulls/<n> (issue_url empty).
# Pin the returned URL's ORIGIN (scheme+host+port) and its FULL path to this
# provider + repo + kind + number — an endswith/suffix test would accept a
# look-alike host (evil.example/deceptive/<slug>/issues/N) or a same-host
# decoy prefix (/other/<slug>/issues/N), so compare the whole thing.
base_origin, base_path = _origin_and_path(web_base)
expected_issue_path = f"{base_path}/{slug}/issues/{number}"
expected_pr_path = f"{base_path}/{slug}/pulls/{number}"
def _belongs(url, expected_path):
if not url:
return False
origin, path = _origin_and_path(url)
return origin == base_origin and path == expected_path
if comment.get("id") != expected_id:
raise ValueError("read-back id does not match the created id")
if (comment.get("user") or {}).get("login") != acting_login:
raise ValueError("created comment is not authored by the acting identity")
if comment.get("body") != expected_body:
raise ValueError("created comment body does not match")
if not (
_belongs(comment.get("issue_url"), expected_issue_path)
or _belongs(comment.get("pull_request_url"), expected_pr_path)
):
raise ValueError("created comment does not belong to this issue on this provider/repo")
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
raise SystemExit(1)
PY
echo "$created_id"
return 0
}
if [[ "$PLATFORM" == "github" ]]; then
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
# Resolve the login this comment should be attributed to: the --login
# override when given, otherwise the detected default for this repo's host.
# A --login override always wins. Otherwise name this repo host's login only
# as a best effort: the login name merely selects a per-login token, and
# gitea_resolve_api_for_login falls back to the host credential
# (get_gitea_token) when no tea login is named, so the default credential
# still resolves even when the host tea has no matching login entry.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login 2>/dev/null || true)
# Bind the REST endpoint + token to the effective login, then derive the
# acting identity from that SAME credential (GET /user). The write below and
# its read-back both use this credential, so the write is verified against
# the identity that actually performed it. Passing "explicit" when --login
# was supplied forbids the host-default fallback: an unresolvable explicit
# override fails closed instead of writing under the default identity.
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
comment_id=$(gitea_create_comment_verified "$ISSUE_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
echo "Error: could not create and verify a comment on Gitea issue #$ISSUE_NUMBER via a provider-returned created id (#865)." >&2
# Build the invocation as an argv array (not unquoted $(get_gitea_repo_args)
# word-splitting) so the comment body — including Markdown backticks, $(...),
# and quotes — is passed verbatim and never re-split or shell-evaluated.
REPO_SLUG=$(get_repo_slug)
GITEA_LOGIN_NAME=$(get_gitea_login) || {
echo "Error: could not resolve a Gitea login for this repo; cannot comment on issue #$ISSUE_NUMBER." >&2
exit 1
}
echo "Added and verified comment on Gitea issue #$ISSUE_NUMBER (comment ID $comment_id)"
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME"
echo "Added comment to Gitea issue #$ISSUE_NUMBER"
else
echo "Error: Unknown platform"
exit 1

View File

@@ -1,33 +1,16 @@
#!/bin/bash
# pr-review.sh - Review a pull request on GitHub or Gitea
# Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>]
#
# Gitea reviews and comments are written through the supported REST API, not
# `tea`: tea 0.11.1 cannot emit the id of a record it creates and can silently
# no-op while exiting 0 (#865 defect class), so an exit code is the only — and
# untrustworthy — signal it offers. approve/request-changes POST to
# /pulls/{n}/reviews (returns the created review with its id); the `comment`
# action POSTs to /issues/{n}/comments (returns the created comment with its
# id). Each write is then verified by GETting that exact returned id, so a
# concurrent record cannot masquerade as this write and a no-op fails closed.
#
# --login override: the default login is resolved from the local tea login list
# for this repo's host (get_gitea_login_for_host). Pass --login <name> to
# override it for this invocation only. The REST write, the /user identity read,
# and every read-back are ALL performed with the token of the EFFECTIVE login,
# so the write and its verification bind to the same identity.
# Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>]
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh
source "$SCRIPT_DIR/detect-platform.sh"
# Parse arguments
PR_NUMBER=""
ACTION=""
COMMENT=""
LOGIN_OVERRIDE=""
while [[ $# -gt 0 ]]; do
case $1 in
@@ -43,18 +26,13 @@ while [[ $# -gt 0 ]]; do
COMMENT="$2"
shift 2
;;
-l|--login)
LOGIN_OVERRIDE="$2"
shift 2
;;
-h|--help)
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>]"
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>]"
echo ""
echo "Options:"
echo " -n, --number PR number (required)"
echo " -a, --action Review action: approve, request-changes, comment (required)"
echo " -c, --comment Review comment (required for request-changes)"
echo " -l, --login Override the detected Gitea tea login (approve/request-changes only)"
echo " -h, --help Show this help"
exit 0
;;
@@ -77,448 +55,6 @@ fi
detect_platform >/dev/null
# Post a comment to a Gitea PR (PR comments ARE issue comments) via the
# supported REST API and verify it against a PROVIDER-RETURNED created id. The
# write is a direct POST that returns the created comment object, so we learn
# the exact id of THIS write; we GET that exact id and require id == created id
# AND author == acting identity AND exact body AND that it belongs to this PR.
# Keying to the returned id means no concurrent comment (even same identity /
# body) can masquerade as this write, and a no-op create yields no id and fails
# closed. Requires GITEA_API_BASE / GITEA_API_TOKEN to be resolved first (via
# gitea_resolve_api_for_login). Prints the created comment id on success.
#
# Args: $1 = PR number, $2 = comment body, $3 = acting identity login.
gitea_create_comment_verified() {
local pr_number="$1" comment_body="$2" acting_login="$3"
local payload write_file readback_file auth_config write_status readback_status created_id
payload=$(COMMENT_BODY="$comment_body" python3 -c '
import json
import os
print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
')
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-write.XXXXXX")
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-getid.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$write_file" "$readback_file"
echo "Error: could not stage Gitea credential for comment write" >&2
return 1
}
trap 'rm -f "$write_file" "$readback_file" "$auth_config"' RETURN
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
-X POST \
--config "$auth_config" \
-H 'Content-Type: application/json' \
-d "$payload" \
"$GITEA_API_BASE/issues/$pr_number/comments"); then
echo "Error: Gitea comment write transport failed" >&2
return 1
fi
if [[ "$write_status" != "201" ]]; then
echo "Error: Gitea comment write failed with HTTP $write_status" >&2
return 1
fi
created_id=$(python3 - "$write_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
comment = json.load(response)
created_id = comment.get("id") if isinstance(comment, dict) else None
if not isinstance(created_id, int) or created_id <= 0:
raise ValueError("create response carried no positive comment id")
except (OSError, json.JSONDecodeError, ValueError) as error:
print(f"Error: could not identify created Gitea comment: {error}", file=sys.stderr)
raise SystemExit(1)
print(created_id)
PY
) || return 1
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
--config "$auth_config" \
"$GITEA_API_BASE/issues/comments/$created_id"); then
echo "Error: Gitea comment read-back transport failed" >&2
return 1
fi
if [[ "$readback_status" != "200" ]]; then
echo "Error: Gitea comment read-back failed with HTTP $readback_status" >&2
return 1
fi
EXPECTED_COMMENT_ID="$created_id" EXPECTED_COMMENT_BODY="$comment_body" \
ACTING_LOGIN="$acting_login" EXPECTED_REPO_SLUG="${GITEA_API_BASE##*/repos/}" \
EXPECTED_NUMBER="$pr_number" EXPECTED_WEB_BASE="$GITEA_WEB_BASE" \
python3 - "$readback_file" <<'PY' || return 1
import json
import os
import sys
from urllib.parse import urlparse
def _origin_and_path(url):
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
# port and its explicit default form compare equal.
parsed = urlparse(url or "")
scheme = (parsed.scheme or "").lower()
host = (parsed.hostname or "").lower()
default_port = 80 if scheme == "http" else 443
port = parsed.port if parsed.port is not None else default_port
return (scheme, host, port), parsed.path.rstrip("/")
try:
with open(sys.argv[1], encoding="utf-8") as response:
comment = json.load(response)
if not isinstance(comment, dict):
raise ValueError("response is not a comment object")
expected_id = int(os.environ["EXPECTED_COMMENT_ID"])
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
acting_login = os.environ["ACTING_LOGIN"]
slug = os.environ["EXPECTED_REPO_SLUG"]
number = os.environ["EXPECTED_NUMBER"]
web_base = os.environ["EXPECTED_WEB_BASE"]
# Gitea populates WEB (html) URLs here, not API paths. A PR-conversation
# comment carries pull_request_url = <web_base>/<owner>/<repo>/pulls/<n> (with
# issue_url empty), while a plain issue comment carries
# issue_url = <web_base>/<owner>/<repo>/issues/<n> (with pull_request_url empty).
# This is the pr-review `comment` action, so the comment MUST land on a pull
# request: require pull_request_url. A plain issue_url is REJECTED — if issue
# #N exists but PR #N does not, POST /issues/N/comments creates an issue
# comment, and accepting that issue_url would let the wrapper falsely report a
# verified PR comment (issue-comment.sh legitimately keeps the broader
# issue-or-PR acceptance; a PR review does not).
# Pin the returned URL's ORIGIN (scheme+host+port) and its FULL path to this
# provider + repo + kind + number — an endswith/suffix test would accept a
# look-alike host (evil.example/deceptive/<slug>/pulls/N) or a same-host
# decoy prefix (/other/<slug>/pulls/N), so compare the whole thing.
base_origin, base_path = _origin_and_path(web_base)
expected_pr_path = f"{base_path}/{slug}/pulls/{number}"
def _belongs(url, expected_path):
if not url:
return False
origin, path = _origin_and_path(url)
return origin == base_origin and path == expected_path
if comment.get("id") != expected_id:
raise ValueError("read-back id does not match the created id")
if (comment.get("user") or {}).get("login") != acting_login:
raise ValueError("created comment is not authored by the acting identity")
if comment.get("body") != expected_body:
raise ValueError("created comment body does not match")
if not _belongs(comment.get("pull_request_url"), expected_pr_path):
raise ValueError("claimed PR comment did not land on a pull request (kind=pulls) on this provider/repo")
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
raise SystemExit(1)
PY
echo "$created_id"
return 0
}
# Resolve and cache the Gitea REST endpoint + token for the current remote,
# bound to a SPECIFIC login identity ($1). Populates GITEA_API_ROOT (…/api/v1),
# GITEA_API_BASE (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
#
# The token is resolved for the EFFECTIVE login (the --login override when
# given, otherwise the detected default), so the one credential used to submit
# the review/comment ALSO drives the /user identity read and every read-back —
# write token and read-back token are the same identity by construction. This
# is the credential-ordering fix: a --login override is no longer submitted
# under one credential and verified under a different default one. Falls back to
# the host-scoped credential ONLY when NO --login override was supplied (the
# best-effort default path). When $2 is "explicit" the login came from a
# caller-supplied --login: that exact login's token MUST resolve, and we FAIL
# CLOSED rather than silently downgrading the review/comment to the host default
# identity. Returns non-zero (clear stderr) on any resolution failure.
gitea_resolve_api_for_login() {
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
host=$(get_remote_host)
if [[ -n "$override_explicit" ]]; then
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (review write/read-back)" >&2
return 1
}
else
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for login '$effective_login' (review write/read-back)" >&2
return 1
}
fi
configured_url=$(get_gitea_url_for_host "$host") || {
echo "Error: Configured Gitea URL not found for review read-back verification" >&2
return 1
}
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
return 1
}
GITEA_API_ROOT="${configured_url%/}/api/v1"
GITEA_API_BASE="$GITEA_API_ROOT/repos/$repo"
# The provider WEB base (scheme + host + effective port + any deployment path
# prefix) that Gitea uses to build a comment's html issue_url/pull_request_url.
# Read-back verification pins the returned URL's origin + path prefix to THIS,
# not just a repo/PR suffix.
GITEA_WEB_BASE="${configured_url%/}"
return 0
}
# Resolve the login of the identity the API token authenticates as (GET
# /user). Used to attribute a read-back review to THIS action's reviewer so a
# concurrent review from a DIFFERENT identity cannot satisfy verification.
# Prints the login on success.
gitea_authenticated_login() {
local response_file auth_config status
response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-whoami.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$response_file"
echo "Error: could not stage Gitea credential for identity read" >&2
return 1
}
trap 'rm -f "$response_file" "$auth_config"' RETURN
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
--config "$auth_config" \
"$GITEA_API_ROOT/user"); then
echo "Error: Gitea authenticated-identity read transport failed" >&2
return 1
fi
if [[ "$status" != "200" ]]; then
echo "Error: Gitea authenticated-identity read failed with HTTP $status" >&2
return 1
fi
python3 - "$response_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
user = json.load(response)
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login:
raise ValueError("missing authenticated login")
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
print(f"Error: could not resolve authenticated Gitea identity: {error}", file=sys.stderr)
raise SystemExit(1)
print(login)
PY
}
# GET /pulls/{n} into a caller-owned response file and print its head commit
# SHA. This core sets NO RETURN trap and reuses a caller-provided auth config +
# response file, so it is safe to call from INSIDE another trapped function
# (the post-verify re-read below) without clobbering that function's cleanup
# trap. $1 = PR number, $2 = response file, $3 = curl auth config file.
gitea_read_pr_head_into() {
local pr_number="$1" pr_file="$2" auth_config="$3" status
if ! status=$(curl -sS -o "$pr_file" -w '%{http_code}' \
--config "$auth_config" \
"$GITEA_API_BASE/pulls/$pr_number"); then
echo "Error: Gitea PR head read transport failed" >&2
return 1
fi
if [[ "$status" != "200" ]]; then
echo "Error: Gitea PR head read failed with HTTP $status" >&2
return 1
fi
python3 - "$pr_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
pr = json.load(response)
head_sha = pr.get("head", {}).get("sha") if isinstance(pr, dict) else None
if not isinstance(head_sha, str) or not head_sha:
raise ValueError("missing PR head sha")
except (OSError, json.JSONDecodeError, AttributeError, TypeError, ValueError) as error:
print(f"Error: could not resolve PR head commit: {error}", file=sys.stderr)
raise SystemExit(1)
print(head_sha)
PY
}
# Resolve the PR's current head commit SHA (GET /pulls/{n}). The review is
# submitted against — and later verified as pinned to — this exact commit, so a
# stale review left over from an earlier push cannot be mistaken for this one.
# Prints the head SHA on success.
gitea_pr_head_sha() {
local pr_number="$1" pr_file auth_config
pr_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-head.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$pr_file"
echo "Error: could not stage Gitea credential for PR head read" >&2
return 1
}
trap 'rm -f "$pr_file" "$auth_config"' RETURN
gitea_read_pr_head_into "$pr_number" "$pr_file" "$auth_config"
}
# Submit a review to a Gitea PR via the supported REST API and verify it against
# a PROVIDER-RETURNED created id. tea 0.11.1's `pr approve`/`reject` cannot emit
# the id of the review it created and can silently no-op while exiting 0 (#865
# defect class), so this does NOT shell out to tea: it POSTs to
# /pulls/{n}/reviews with the event (APPROVED / REQUEST_CHANGES), the PR head
# commit_id, and the review body, which returns the created review object
# including its id. It then GETs that exact review id and requires
# id == created id AND author == acting identity AND state == expected AND
# commit_id == PR head. Keying to the returned id means no concurrent review
# (even same identity/state/head) can masquerade as this one, and a no-op
# submit yields no id and fails closed. Prints the created review id on success.
#
# Args: $1 = PR number, $2 = event (APPROVED|REQUEST_CHANGES),
# $3 = review body (may be empty for APPROVED), $4 = acting login,
# $5 = PR head sha.
gitea_submit_review_verified() {
local pr_number="$1" event="$2" review_body="$3" acting_login="$4" head_sha="$5"
local payload write_file readback_file recheck_file auth_config
local write_status readback_status created_id live_head
payload=$(REVIEW_EVENT="$event" REVIEW_BODY="$review_body" REVIEW_COMMIT="$head_sha" python3 -c '
import json
import os
print(json.dumps({
"event": os.environ["REVIEW_EVENT"],
"body": os.environ["REVIEW_BODY"],
"commit_id": os.environ["REVIEW_COMMIT"],
}))
')
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-submit.XXXXXX")
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-getid.XXXXXX")
recheck_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-recheck.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$write_file" "$readback_file" "$recheck_file"
echo "Error: could not stage Gitea credential for review submit" >&2
return 1
}
trap 'rm -f "$write_file" "$readback_file" "$recheck_file" "$auth_config"' RETURN
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
-X POST \
--config "$auth_config" \
-H 'Content-Type: application/json' \
-d "$payload" \
"$GITEA_API_BASE/pulls/$pr_number/reviews"); then
echo "Error: Gitea review submit transport failed" >&2
return 1
fi
# Gitea returns 200 (occasionally 201) with the created review object.
if [[ "$write_status" != "200" && "$write_status" != "201" ]]; then
echo "Error: Gitea review submit failed with HTTP $write_status (#865: no durable review created)" >&2
return 1
fi
created_id=$(python3 - "$write_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
review = json.load(response)
created_id = review.get("id") if isinstance(review, dict) else None
if not isinstance(created_id, int) or created_id <= 0:
raise ValueError("submit response carried no positive review id")
except (OSError, json.JSONDecodeError, ValueError) as error:
print(f"Error: could not identify created Gitea review: {error}", file=sys.stderr)
raise SystemExit(1)
print(created_id)
PY
) || return 1
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
--config "$auth_config" \
"$GITEA_API_BASE/pulls/$pr_number/reviews/$created_id"); then
echo "Error: Gitea review read-back transport failed" >&2
return 1
fi
if [[ "$readback_status" != "200" ]]; then
echo "Error: Gitea review read-back failed with HTTP $readback_status" >&2
return 1
fi
EXPECTED_REVIEW_ID="$created_id" EXPECTED_STATE="$event" ACTING_LOGIN="$acting_login" \
EXPECTED_HEAD_SHA="$head_sha" EXPECTED_REVIEW_BODY="$review_body" \
python3 - "$readback_file" <<'PY' || return 1
import json
import os
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
review = json.load(response)
if not isinstance(review, dict):
raise ValueError("response is not a review object")
expected_id = int(os.environ["EXPECTED_REVIEW_ID"])
expected_state = os.environ["EXPECTED_STATE"]
acting_login = os.environ["ACTING_LOGIN"]
expected_head = os.environ["EXPECTED_HEAD_SHA"]
expected_body = os.environ["EXPECTED_REVIEW_BODY"]
if review.get("id") != expected_id:
raise ValueError("read-back id does not match the created id")
if (review.get("user") or {}).get("login") != acting_login:
raise ValueError("created review is not authored by the acting identity")
if review.get("state") != expected_state:
raise ValueError("created review is not in the expected state")
if review.get("commit_id") != expected_head:
raise ValueError("created review is not pinned to the PR head commit")
# Bind to the exact submitted body. On Gitea v1.25.4 SubmitReview may
# finalize/reuse a pending review id whose Content was authored elsewhere;
# the exact GET exposes the persisted body, so a mismatch (a reused/foreign
# review carrying different Content) fails closed even when id/author/state/
# head all line up. Require presence + string TYPE + exact equality rather
# than `(body or "")`: the old coalesce treated a missing/null persisted body
# as equal to an empty submitted one, so a non-empty submitted body that
# persisted as null (a suppressed/lost body) would have passed. When a
# non-empty body was submitted the persisted value MUST be that exact string;
# when an empty body was submitted the persisted value must be empty or
# absent (a non-empty persisted body is likewise a divergence — vice-versa).
persisted_body = review.get("body")
if expected_body == "":
if persisted_body not in (None, ""):
raise ValueError("created review carries a body but none was submitted")
elif not isinstance(persisted_body, str) or persisted_body != expected_body:
raise ValueError("created review body does not match the submitted body")
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
print(f"Error: Gitea review persistence verification failed: {error}", file=sys.stderr)
raise SystemExit(1)
PY
# Current-head TOCTOU close-out: the review verified above is pinned to
# head_sha, but that head was read BEFORE the submit. Between then and now
# the PR branch may have advanced (a force-push or a new commit), which would
# leave this verified review attached to a now-superseded commit while the
# live tip carries unreviewed code — yet the wrapper would still report
# success. Re-read the LIVE PR head and require it STILL equals the submitted
# SHA; if it advanced, fail closed (nonzero, no created id emitted, no
# success line). This reuses the submit-scoped auth config + recheck file so
# it neither leaks the token to argv nor clobbers this function's cleanup.
live_head=$(gitea_read_pr_head_into "$pr_number" "$recheck_file" "$auth_config") || {
echo "Error: could not re-read Gitea PR head after review verification" >&2
return 1
}
if [[ "$live_head" != "$head_sha" ]]; then
echo "Error: Gitea PR head advanced from $head_sha to $live_head between review submit and verification; refusing to report a review pinned to a superseded commit (#865 current-head TOCTOU)" >&2
return 1
fi
echo "$created_id"
return 0
}
if [[ "$PLATFORM" == "github" ]]; then
case $ACTION in
approve)
@@ -549,77 +85,24 @@ if [[ "$PLATFORM" == "github" ]]; then
elif [[ "$PLATFORM" == "gitea" ]]; then
case $ACTION in
approve)
host=$(get_remote_host)
# A --login override always wins. Otherwise name this host's login
# only as a best effort: the login name merely selects a per-login
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
# Bind the REST endpoint + token to the effective login, then derive
# the acting identity from that SAME credential so the review submit
# and its read-back verify against the identity that performed them.
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
# The review body (if any) travels with the review itself in the REST
# submit — the created review record carries it — so there is no
# separate detached comment to reconcile.
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "APPROVED" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
echo "Error: could not submit and verify an APPROVED review on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
exit 1
}
echo "Approved and verified Gitea PR #$PR_NUMBER (review ID $review_id)"
tea pr approve "$PR_NUMBER" $(get_gitea_repo_args) ${COMMENT:+--comment "$COMMENT"}
echo "Approved Gitea PR #$PR_NUMBER"
;;
request-changes)
if [[ -z "$COMMENT" ]]; then
echo "Error: Comment required for request-changes"
exit 1
fi
host=$(get_remote_host)
# A --login override always wins. Otherwise name this host's login
# only as a best effort: the login name merely selects a per-login
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "REQUEST_CHANGES" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
echo "Error: could not submit and verify a REQUEST_CHANGES review on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
exit 1
}
echo "Requested changes and verified on Gitea PR #$PR_NUMBER (review ID $review_id)"
tea pr reject "$PR_NUMBER" $(get_gitea_repo_args) --comment "$COMMENT"
echo "Requested changes on Gitea PR #$PR_NUMBER"
;;
comment)
if [[ -z "$COMMENT" ]]; then
echo "Error: Comment required"
exit 1
fi
host=$(get_remote_host)
# A --login override always wins. Otherwise name this host's login
# only as a best effort: the login name merely selects a per-login
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
comment_id=$(gitea_create_comment_verified "$PR_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
echo "Error: could not create and verify a comment on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
exit 1
}
echo "Added and verified comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args)
echo "Added comment to Gitea PR #$PR_NUMBER"
;;
*)
echo "Error: Unknown action: $ACTION"

View File

@@ -312,468 +312,4 @@ if [[ "$override_wins" != "mosaicstack" ]]; then
fi
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
# ---------------------------------------------------------------------------
# #865 Blocker 1 & 2: get_gitea_token_for_login must resolve the SAME token as
# PyYAML would (or fail closed identically) even when PyYAML is ABSENT, and must
# bind the credential to the repo host's scheme + host + EFFECTIVE PORT — not the
# hostname alone. These fixtures probe the ImportError-dispatched line-parser
# fallback under FORCED PyYAML absence with adversarial YAML shapes, asserting it
# NEVER misattributes a token from a nested sub-map or a mis-indented line, strips
# inline comments like PyYAML, fails closed where PyYAML errors, and rejects a
# port mismatch while accepting an exact / default-port match. When PyYAML is
# available the same fixtures also assert the PyYAML path agrees (equivalence).
# ---------------------------------------------------------------------------
FIXTURE_XDG="$WORK_DIR/tokenfix"
NOYAML_DIR="$WORK_DIR/noyaml"
mkdir -p "$FIXTURE_XDG/tea" "$NOYAML_DIR"
# A shadow `yaml` module that raises ImportError, forcing the fallback path.
printf 'raise ImportError("forced-absent for #865 fallback regression")\n' > "$NOYAML_DIR/yaml.py"
if python3 -c 'import yaml' >/dev/null 2>&1; then HAVE_PYYAML=true; else HAVE_PYYAML=false; fi
# Confirm the shim really does force ImportError, so the fallback is exercised.
if python3 -c 'import yaml' >/dev/null 2>&1; then
if PYTHONPATH="$NOYAML_DIR" python3 -c 'import yaml' >/dev/null 2>&1; then
echo "FAIL: PyYAML-absence shim did not force ImportError (fallback not exercised)" >&2
exit 1
fi
fi
write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; }
# Resolve a token via the FORCED-fallback path (PyYAML shimmed to ImportError).
token_fallback() {
(
cd "$REPO_DIR"
XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_token_for_login "$1" "$2"
' _ "$1" "$2"
) 2>/dev/null || true
}
# Resolve a token via the normal path (uses PyYAML when installed).
token_pyyaml() {
(
cd "$REPO_DIR"
XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_token_for_login "$1" "$2"
' _ "$1" "$2"
) 2>/dev/null || true
}
assert_token() {
local desc="$1" expected="$2" login="$3" host="$4" got
got=$(token_fallback "$login" "$host")
if [[ "$got" != "$expected" ]]; then
echo "FAIL fallback [$desc]: expected [$expected] got [$got]" >&2
exit 1
fi
if [[ "$HAVE_PYYAML" == true ]]; then
got=$(token_pyyaml "$login" "$host")
if [[ "$got" != "$expected" ]]; then
echo "FAIL pyyaml [$desc]: expected [$expected] got [$got]" >&2
exit 1
fi
fi
}
# 1. Plain, well-formed entry resolves its token.
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
'
assert_token "plain scalar" "TOK_PLAIN" primary git.example
# 2. A token nested inside a deeper SUB-MAP must NOT attach to the entry — PyYAML
# resolves the entry's own token to None here, so the fallback must too.
write_fixture 'logins:
- name: primary
url: https://git.example
extra:
token: TOK_NESTED_ATTACKER
- name: other
url: https://git.example
token: TOK_OTHER
'
assert_token "nested sub-map token is not attributed" "" primary git.example
assert_token "sibling entry still resolves its own token" "TOK_OTHER" other git.example
# 3. A MIS-INDENTED token line (deeper than the entry's fields) must not attach;
# PyYAML errors on this shape, so both fail closed.
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_MISINDENT
'
assert_token "mis-indented token fails closed" "" primary git.example
# 4. A trailing inline comment on a scalar is stripped, exactly as PyYAML does.
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_INLINE # trailing note
'
assert_token "inline comment stripped" "TOK_INLINE" primary git.example
# 5. A PyYAML-fail-closed case: tab indentation. PyYAML raises a scanner error;
# the fallback resolves no token. Both fail closed identically.
write_fixture "$(printf 'logins:\n - name: primary\n url: https://git.example\n\ttoken: TOK_TAB\n')"
assert_token "tab-indent fails closed like PyYAML" "" primary git.example
# 6. Host binding is scheme + host + EFFECTIVE PORT, not hostname alone.
write_fixture 'logins:
- name: ported
url: https://git.example:8443
token: TOK_PORTED
'
assert_token "explicit port exact match accepted" "TOK_PORTED" ported git.example:8443
assert_token "portless repo host rejects :8443 login" "" ported git.example
assert_token "wrong explicit port rejected" "" ported git.example:9443
# 7. An implicit (portless) login URL equals the scheme's explicit default port.
write_fixture 'logins:
- name: defported
url: https://git.example
token: TOK_DEFPORT
'
assert_token "implicit https vs explicit :443 match" "TOK_DEFPORT" defported git.example:443
assert_token "implicit https vs :8443 rejected" "" defported git.example:8443
# 8. An UNQUOTED token whose raw text PyYAML's implicit resolver types as a
# NON-string (int / null / bool / float) must fail closed: PyYAML yields a
# non-str value that _accept rejects, so the fallback must NOT surface the
# stringified scalar as a credential. Each raw form fails closed IDENTICALLY
# to PyYAML (a prior residual emitted "12345"/"null"/"true"/etc. here).
assert_nonstring_token_fails_closed() {
local desc="$1" raw="$2"
write_fixture "logins:
- name: primary
url: https://git.example
token: ${raw}
"
assert_token "$desc" "" primary git.example
}
assert_nonstring_token_fails_closed "unquoted int token fails closed" "12345"
assert_nonstring_token_fails_closed "unquoted null token fails closed" "null"
assert_nonstring_token_fails_closed "unquoted tilde-null token fails closed" "~"
assert_nonstring_token_fails_closed "unquoted yes(bool) token fails closed" "yes"
assert_nonstring_token_fails_closed "unquoted true(bool) token fails closed" "true"
assert_nonstring_token_fails_closed "unquoted float token fails closed" "3.14"
# 9. A QUOTED scalar is ALWAYS a string, even when its contents look like a
# non-string implicit form. The quotes force str typing in PyYAML, so the
# fallback must accept the literal (quote-stripped) contents as the token.
write_fixture 'logins:
- name: primary
url: https://git.example
token: "12345"
'
assert_token "double-quoted digit token is a literal string" "12345" primary git.example
write_fixture "logins:
- name: primary
url: https://git.example
token: 'abc'
"
assert_token "single-quoted token is a literal string" "abc" primary git.example
# assert_fallback_fails_closed: the forced-fallback path MUST resolve no token
# (fail closed). Used for STRUCTURAL cases where PyYAML would resolve a DIFFERENT
# token (e.g. duplicate-key last-wins) — the fallback must never surface the
# wrong/stale token, so it fails closed instead; when PyYAML is present we also
# confirm it really does resolve a (divergent) token, proving the fallback is the
# strictly-more-conservative side and the case is a genuine fail-open guard.
assert_fallback_fails_closed() {
local desc="$1" login="$2" host="$3" got
got=$(token_fallback "$login" "$host")
if [[ -n "$got" ]]; then
echo "FAIL fallback [$desc]: expected fail-closed, got a token" >&2
exit 1
fi
if [[ "$HAVE_PYYAML" == true ]]; then
got=$(token_pyyaml "$login" "$host")
if [[ -z "$got" ]]; then
echo "FAIL [$desc]: expected PyYAML to resolve a divergent token" >&2
exit 1
fi
fi
}
# 10. tea's REAL on-disk shape: the `logins:` block SEQUENCE items sit at the
# SAME indentation as the key (dash at column 0), with extra scalar fields.
# The recognizer must resolve this exactly like PyYAML (regression guard so
# the stricter whole-document recognizer does not fail closed on real input).
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_REAL
default: false
ssh_host: ""
- name: other
url: https://other.example
token: TOK_REAL_OTHER
preferences:
editor: false
flags: null
'
assert_token "tea dash-at-column-0 real shape resolves" "TOK_REAL" primary git.example
assert_token "tea real shape sibling resolves own token" "TOK_REAL_OTHER" other other.example
# 11. NESTED-SHADOW: a nested `logins:` (NOT at root scope) must not be mistaken
# for the real root logins. The recognizer parses whole-document structure,
# so it selects the ROOT logins token exactly as PyYAML does — never the
# nested attacker token. (A prior line scan matched the FIRST logins at ANY
# indent and returned ATTACKER.)
write_fixture 'outer:
logins:
- name: primary
url: https://git.example
token: ATTACKER_NESTED
logins:
- name: primary
url: https://git.example
token: ROOT_TOK
'
assert_token "nested logins shadow selects ROOT token" "ROOT_TOK" primary git.example
# 12. BLOCK-SCALAR-SHADOW: text inside a YAML literal/folded block ( | or > ) is
# an OPAQUE scalar to PyYAML (so `logins` is a string, not a list) and must
# not be scanned as live logins entries. Both fail closed.
write_fixture 'logins: |
- name: primary
url: https://git.example
token: ATTACKER_BLOCK
'
assert_token "block-scalar logins value fails closed" "" primary git.example
# A folded/literal block scalar anywhere is outside the recognizer's subset, so
# the fallback fails closed (conservative) even though PyYAML can still resolve
# the real root token past the opaque scalar. Fail-closed is the safe side.
write_fixture 'note: >
logins:
- name: primary
token: ATTACKER_FOLDED
logins:
- name: primary
url: https://git.example
token: ROOT_OK
'
assert_fallback_fails_closed "folded block scalar present fails closed" primary git.example
# 13. DUPLICATE-ROOT / DUPLICATE-FIELD: a duplicated `logins:` root key (PyYAML
# last-wins) or a duplicated field within a login must fail closed rather
# than take the FIRST (stale) value. PyYAML resolves the LAST; the fallback
# refuses to guess.
write_fixture 'logins:
- name: primary
url: https://git.example
token: FIRST_DUP
logins:
- name: primary
url: https://git.example
token: LAST_DUP
'
assert_fallback_fails_closed "duplicate root logins key fails closed" primary git.example
write_fixture 'logins:
- name: primary
url: https://git.example
token: FIRST_FIELD
token: SECOND_FIELD
'
assert_fallback_fails_closed "duplicate token field fails closed" primary git.example
# 14. MALFORMED-AFTER-VALID: a syntax error LATER in the file makes PyYAML reject
# the WHOLE document; the recognizer must too (not emit the earlier token).
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
broken: a: b: c
'
assert_token "malformed line after valid login fails closed" "" primary git.example
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
broken: [unclosed
'
assert_token "unclosed flow after valid login fails closed" "" primary git.example
# 15. EXTRA-DOCUMENT: a multi-document file (--- separator, or ... end marker)
# makes PyYAML safe_load reject multi-document input; the recognizer fails
# closed on ANY document marker rather than emit the first doc's token.
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
---
logins:
- name: primary
url: https://git.example
token: SECOND_DOC
'
assert_token "second document (--- separator) fails closed" "" primary git.example
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
...
trailing: 1
'
assert_token "end marker then more content fails closed" "" primary git.example
# 16. CONSTRUCTOR-VALIDITY / INVALID-INDICATOR: a plain scalar can match a typed
# implicit resolver (int/float/timestamp) yet be NON-constructible, or begin
# with an indicator a plain scalar may not start with. PyYAML then RAISES on
# the WHOLE document (constructor error / scanner error) and yields NO token,
# so the fallback must ALSO fail closed for the whole document -- even though
# the (unrelated) malformed key sits alongside an otherwise-valid logins
# block whose token is itself well-formed. A prior residual proved STRUCTURE
# and implicit TYPE but not constructor validity, so it ignored the malformed
# key and still emitted the valid login token (fail-open in the dangerous
# direction). assert_both_fail_closed asserts fallback == PyYAML == no token.
assert_both_fail_closed() {
local desc="$1" login="$2" host="$3" got
got=$(token_fallback "$login" "$host")
if [[ -n "$got" ]]; then
echo "FAIL fallback [$desc]: expected fail-closed, got a token" >&2
exit 1
fi
if [[ "$HAVE_PYYAML" == true ]]; then
got=$(token_pyyaml "$login" "$host")
if [[ -n "$got" ]]; then
echo "FAIL pyyaml [$desc]: expected PyYAML to also fail closed (raise/no token), got a token" >&2
exit 1
fi
fi
}
# write_bad_key_fixture: an unrelated root key carrying $1 as its plain scalar,
# followed by an otherwise-valid logins block whose token is well-formed.
write_bad_key_fixture() {
write_fixture "bad: $1
logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
"
}
# Non-constructible TIMESTAMP-tagged scalars: match the resolver, but the
# calendar field is out of range so PyYAML's datetime construction raises.
write_bad_key_fixture '2023-99-99' # month 99 / day 99 invalid
assert_both_fail_closed "bad-date 2023-99-99 fails closed like PyYAML" primary git.example
write_bad_key_fixture '2023-13-01' # month 13 invalid
assert_both_fail_closed "bad-month 2023-13-01 fails closed like PyYAML" primary git.example
write_bad_key_fixture '2023-01-15T25:00:00' # hour 25 invalid
assert_both_fail_closed "bad-hour timestamp fails closed like PyYAML" primary git.example
# Non-constructible INT-tagged scalars: match the int resolver, but the radix
# body is empty after underscore removal so int(base) raises.
write_bad_key_fixture '0b_'
assert_both_fail_closed "empty-binary 0b_ fails closed like PyYAML" primary git.example
write_bad_key_fixture '0x_'
assert_both_fail_closed "empty-hex 0x_ fails closed like PyYAML" primary git.example
write_bad_key_fixture '0x__'
assert_both_fail_closed "empty-hex 0x__ (multi-underscore) fails closed" primary git.example
# Invalid plain-scalar INDICATOR forms: a plain scalar may not begin with '%'
# (directive) or ',' (flow) -- PyYAML raises a scanner/parser error on the whole
# document, so the fallback fails closed on the leading indicator.
write_bad_key_fixture '%broken'
assert_both_fail_closed "leading-%% directive indicator fails closed" primary git.example
write_bad_key_fixture ',bad'
assert_both_fail_closed "leading-comma flow indicator fails closed" primary git.example
# Bare block indicators in a value position ('-'/'- ', '?'/'? ', ':'/': '):
# PyYAML raises a scanner error on the whole document, so the fallback must fail
# closed rather than accept the indicator as a plain-scalar string.
write_bad_key_fixture '-'
assert_both_fail_closed "bare dash (seq indicator) fails closed" primary git.example
write_bad_key_fixture '- x'
assert_both_fail_closed "dash-space (seq entry) fails closed" primary git.example
write_bad_key_fixture '? key'
assert_both_fail_closed "question-space (complex key) fails closed" primary git.example
# ...but an indicator NOT followed by whitespace is a valid plain scalar string,
# so the token still resolves (no over-broad fail-close).
write_bad_key_fixture '-x'
assert_token "dash-not-space is a plain string, token resolves" "TOK_PLAIN" primary git.example
write_bad_key_fixture ':x'
assert_token "colon-not-space is a plain string, token resolves" "TOK_PLAIN" primary git.example
# NOT over-broad: a genuinely CONSTRUCTIBLE typed scalar (or a look-alike PyYAML
# keeps as a plain string) leaves the document valid, so BOTH still resolve the
# login token -- the fix must not fail closed on these.
write_bad_key_fixture '2023-01-15'
assert_token "valid date unrelated key still resolves token" "TOK_PLAIN" primary git.example
write_bad_key_fixture '2023-01-15 10:00:00'
assert_token "valid datetime unrelated key still resolves token" "TOK_PLAIN" primary git.example
# '0o_' is NOT matched by PyYAML's int resolver (YAML 1.1 octal is 0[0-7]+, not
# 0o...), so PyYAML keeps it a STRING and resolves the token; the fallback must
# agree (no spurious fail-close).
write_bad_key_fixture '0o_'
assert_token "0o_ is a plain string in PyYAML, token still resolves" "TOK_PLAIN" primary git.example
# '4.e8' matches the fallback's (superset) float pattern but PyYAML keeps it a
# string; either way it is constructible, so the token still resolves in both.
write_bad_key_fixture '4.e8'
assert_token "4.e8 float look-alike still resolves token" "TOK_PLAIN" primary git.example
# A valid radix int as an unrelated key must not fail closed.
write_bad_key_fixture '0x1f'
assert_token "valid hex int unrelated key still resolves token" "TOK_PLAIN" primary git.example
# 17. TAB / SCANNER PARITY: PyYAML raises a ScannerError on a tab used anywhere
# outside a quoted scalar -- leading, trailing, or embedded in a plain value,
# immediately after a key colon, before a key colon, or as indentation -- and
# yields NO token, accepting tabs ONLY inside single/double-quoted scalars
# (where the tab is preserved as string content). A prior fallback swallowed
# those tabs (via .strip()/.rstrip() normalization and [ \t] key separators)
# and still emitted the login token -- a fail-open in the dangerous direction.
# The recognizer now fails CLOSED for the whole document on any tab PyYAML
# rejects, while preserving the tabs PyYAML keeps (inside quotes). All tab
# positions were verified empirically against PyYAML 6.0.3 (ScannerError for
# each rejected position; string-preserved for quoted inner tabs).
TAB=$'\t'
# Fail-close: a tab in a plain value position (trailing / leading / embedded).
write_bad_key_fixture "l4o${TAB}"
assert_both_fail_closed "trailing tab in plain value fails closed" primary git.example
write_bad_key_fixture "${TAB}9"
assert_both_fail_closed "leading tab in plain value fails closed" primary git.example
write_bad_key_fixture "a${TAB}b"
assert_both_fail_closed "embedded tab in plain value fails closed" primary git.example
# Fail-close: a tab immediately after the key colon (no separating space).
write_fixture "bad:${TAB}9
logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
"
assert_both_fail_closed "tab immediately after key colon fails closed" primary git.example
# Fail-close: a tab used as indentation (before a sequence dash).
write_fixture "logins:
${TAB}- name: primary
url: https://git.example
token: TOK_PLAIN
"
assert_both_fail_closed "tab used as indentation fails closed" primary git.example
# Fail-close: a tab trailing a sequence-mapping field value.
write_fixture "logins:
- name: primary
url: https://git.example
token: TOK_PLAIN${TAB}
"
assert_both_fail_closed "tab trailing a seq field value fails closed" primary git.example
# NOT over-broad: a tab strictly INSIDE a quoted scalar is valid YAML (PyYAML
# keeps it as string content), so the document parses and the login token still
# resolves in BOTH paths -- double-quoted and single-quoted.
write_bad_key_fixture "\"a${TAB}b\""
assert_token "tab inside a double-quoted value still resolves token" "TOK_PLAIN" primary git.example
write_bad_key_fixture "'a${TAB}b'"
assert_token "tab inside a single-quoted value still resolves token" "TOK_PLAIN" primary git.example
# ...and a quoted token value carrying an inner tab resolves to the exact string
# (tab preserved), identical to PyYAML's construction.
write_fixture "logins:
- name: primary
url: https://git.example
token: \"T${TAB}OK\"
"
assert_token "quoted token with inner tab resolves verbatim" "T${TAB}OK" primary git.example
echo "Gitea login resolution regression harness passed"

View File

@@ -1,571 +0,0 @@
#!/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.
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"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
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
)
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
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
)
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
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"
PATH="$BIN_DIR:$PATH" \
TMPDIR="$TMP_SCRATCH" \
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
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" -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.
echo "issue-comment.sh REST create + exact-id read-back regression passed"

View File

@@ -1,923 +0,0 @@
#!/usr/bin/env bash
# Regression harness for pr-review.sh's Gitea review + comment writes (#865,
# #812, #835).
#
# The #865 defect class: tea 0.11.1 can silently no-op while exiting 0 and
# cannot emit the id of a record it creates, so its exit code is worthless as
# proof of a durable write. The wrapper therefore does NOT write reviews or
# comments via tea. approve/request-changes POST to /pulls/{n}/reviews (with the
# event, the PR head commit_id, and the review body) and read the created review
# back by its EXACT provider-returned id; the `comment` action POSTs to
# /issues/{n}/comments and reads that created comment back by its exact id.
# Because verification keys on the id the create returned, no concurrent record
# can masquerade as this write and a no-op create fails closed. tea is only ever
# consulted for the login list.
#
# The curl stub models a REAL server with persistent review/comment state on
# disk: a POST actually CREATES and PERSISTS a record and returns its id, and
# the read-back reads that same state. There is no independently fabricated
# record for the wrapper to "find" — verification passes only when the POST
# genuinely created the record the read-back retrieves.
#
# #865 Round-4: the curl stub also maps the presented bearer token to the
# identity it authenticates as and logs it per request, so tests can prove
# credential attribution. An explicit --login override must drive the entire
# write→read-back chain under THAT login's token (resolvable case) or FAIL
# CLOSED (unresolvable case) — never silently downgrade to the host-default
# identity. The host-default best-effort fallback is reserved for the
# no-override default path.
#
# #865 Round-5: the exact-id read-back is the SOLE authority — the wrapper does
# NO follow-up list enumeration (the stub exposes no review/comment list
# endpoint, so a residual enumeration would fail the run). A --login override is
# host-bound: an override configured for a DIFFERENT host than the repo remote
# FAILS CLOSED rather than leaking a cross-host credential. And every run leaves
# no scratch temp files behind on any exit path (POST/GET bodies + metadata).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-review-gitea-comment}"
REPO_DIR="$WORK_DIR/repo"
BIN_DIR="$WORK_DIR/bin"
XDG_DIR="$WORK_DIR/xdg"
STATE_DIR="$WORK_DIR/state"
REVIEWS_FILE="$STATE_DIR/reviews.json"
COMMENTS_FILE="$STATE_DIR/comments.json"
SUBMIT_PAYLOAD_FILE="$STATE_DIR/review_payload.json"
# Counts GET /pulls/{n} calls within a single run so a race mode can advance the
# reported head between the pre-submit read and the post-verify re-read.
HEAD_CALLS_FILE="$STATE_DIR/head_calls"
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"
# 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"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
ACTING_LOGIN="review-bot"
FOREIGN_LOGIN="other-writer"
HEAD_SHA="HEADSHA_FEEDFACE"
# A dedicated per-role --login override identity with its own token in tea's
# config (the author-not-equal-reviewer hardening path).
OVERRIDE_LOGIN="primary-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). Host-bound selection must reject it
# rather than send its token cross-host.
CROSS_HOST_LOGIN="foreign-host-reviewer"
CROSS_HOST_TOKEN="cross-host-token-placeholder"
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$STATE_DIR" "$TMP_SCRATCH"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
# tea config: the override login carries its own token here. The default login
# name ("mosaicstack") is deliberately absent, 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
write_credentials() {
local configured_url="$1"
CONFIGURED_GITEA_URL="$configured_url" 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. The wrapper must never write a
# review or comment through tea (#865 defect class); any other tea invocation is
# an error.
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> "$PR_REVIEW_TEA_LOG"
if [[ "$*" == "login list --output json" ]]; then
printf '[{"name":"mosaicstack","url":"%s"}]\n' "$PR_REVIEW_LOGIN_URL"
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 review/comment
# state.
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
# Record the FULL argv exactly as spawned, BEFORE any consumption. The bearer
# token must NOT appear here — it is delivered via a curl --config file, so only
# the config file PATH may show up. (#865 ITEM 3a credential-in-argv exposure.)
printf '%s\n' "$*" >> "$PR_REVIEW_CURL_ARGV_LOG"
output_file=""
method="GET"
payload=""
url=""
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) payload="$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);
# only fall back to an -H header 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" >> "$PR_REVIEW_CURL_LOG"
# Map the presented bearer token to the identity it authenticates as (as Gitea's
# /user does). The write, /user lookup, and read-back must all carry the SAME
# token, so the identity logged here reveals which credential performed each
# request — proving an explicit --login override is honored, not downgraded.
acting_identity=""
case "$auth_token" in
"$PR_REVIEW_DEFAULT_TOKEN") acting_identity="$PR_REVIEW_ACTING_LOGIN" ;;
"$PR_REVIEW_OVERRIDE_TOKEN") acting_identity="$PR_REVIEW_OVERRIDE_LOGIN" ;;
"$PR_REVIEW_CROSS_HOST_TOKEN") acting_identity="$PR_REVIEW_CROSS_HOST_LOGIN" ;;
esac
printf '%s %s %s\n' "$method" "$path" "${acting_identity:-<unauthenticated>}" >> "$PR_REVIEW_AUTH_LOG"
write_response() {
local status="$1" body="$2"
[[ -n "$output_file" ]] || exit 96
printf '%s' "$body" > "$output_file"
printf '%s' "$status"
}
emit() {
# Split a two-line "status\n<json body>" python result into the response.
local result="$1"
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
}
mode="${PR_REVIEW_TEST_MODE:-}"
if [[ "$method" == "GET" && "$path" == "$PR_REVIEW_API_ROOT/user" ]]; then
[[ -n "$acting_identity" ]] || { write_response 401 '{"message":"unauthenticated"}'; exit 0; }
write_response 200 "$(PR_REVIEW_LOGIN="$acting_identity" python3 - <<'PY'
import json
import os
print(json.dumps({"login": os.environ["PR_REVIEW_LOGIN"]}))
PY
)"
elif [[ "$method" == "GET" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE/pulls/123" ]]; then
write_response 200 "$(PR_REVIEW_HEAD_SHA="$PR_REVIEW_HEAD_SHA" python3 - <<'PY'
import json
import os
head = os.environ["PR_REVIEW_HEAD_SHA"]
mode = os.environ.get("PR_REVIEW_TEST_MODE", "")
calls_path = os.environ.get("PR_REVIEW_HEAD_CALLS", "")
# Count GET /pulls/{n} calls within this run: call 1 is the pre-submit head read
# that pins the review; call 2+ is the post-verify re-read (current-head TOCTOU
# close-out). In the race mode the branch "advances" after the pin.
n = 1
if calls_path:
try:
with open(calls_path, encoding="utf-8") as handle:
n = int(handle.read() or "0") + 1
except (OSError, ValueError):
n = 1
with open(calls_path, "w", encoding="utf-8") as handle:
handle.write(str(n))
if mode == "head-advanced-race" and n >= 2:
head = "HEADSHA_ADVANCED_DEADBEEF"
print(json.dumps({"head": {"sha": head}}))
PY
)"
elif [[ "$method" == "POST" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE/pulls/123/reviews" ]]; then
printf '%s' "$payload" > "$PR_REVIEW_SUBMIT_PAYLOAD"
emit "$(PR_REVIEW_ACTING_LOGIN="${acting_identity:-$PR_REVIEW_ACTING_LOGIN}" PR_REVIEW_PAYLOAD="$payload" python3 - <<'PY'
import json
import os
state_path = os.environ["PR_REVIEW_REVIEWS"]
mode = os.environ["PR_REVIEW_TEST_MODE"]
acting = os.environ["PR_REVIEW_ACTING_LOGIN"]
foreign = os.environ["PR_REVIEW_FOREIGN_LOGIN"]
submitted = json.loads(os.environ["PR_REVIEW_PAYLOAD"])
with open(state_path, encoding="utf-8") as handle:
reviews = json.load(handle)
# no-op-concurrent-review: the wrapper's own submit is SUPPRESSED (200, no
# created object) even though a concurrent same-identity, same-state review at
# the same head already exists. Nothing is persisted; no created id to verify.
if mode == "no-op-concurrent-review":
print("200")
print(json.dumps({}))
raise SystemExit(0)
# review-body-reuse (#865 Blocker 4): Gitea v1.25.4's SubmitReview can finalize
# and REUSE a pending review id whose Content was authored earlier — NOT this
# submit's body. id/author/state/head all line up with the request; only the
# persisted body diverges, so only body verification catches it. The read-back
# GET returns this same divergent-body record.
if mode == "review-body-reuse":
new_id = (max((r["id"] for r in reviews), default=0)) + 1
record = {
"id": new_id,
"state": submitted.get("event"),
"commit_id": submitted.get("commit_id"),
"body": "leftover-pending-content-not-this-submit",
"user": {"login": acting},
}
reviews.append(record)
with open(state_path, "w", encoding="utf-8") as handle:
json.dump(reviews, handle)
print("201")
print(json.dumps(record))
raise SystemExit(0)
# review-body-null (#865 ITEM 3b): a non-empty body was submitted but the
# persisted review carries body == null. id/author/state/head all line up; only
# strict presence + string-type body verification catches the lost body. The old
# `(body or "")` coalesce would have treated null as an empty string and passed.
if mode == "review-body-null":
new_id = (max((r["id"] for r in reviews), default=0)) + 1
record = {
"id": new_id,
"state": submitted.get("event"),
"commit_id": submitted.get("commit_id"),
"body": None,
"user": {"login": acting},
}
reviews.append(record)
with open(state_path, "w", encoding="utf-8") as handle:
json.dump(reviews, handle)
print("201")
print(json.dumps(record))
raise SystemExit(0)
author = foreign if mode == "author-mismatch-review" else acting
new_id = (max((r["id"] for r in reviews), default=0)) + 1
record = {
"id": new_id,
"state": submitted.get("event"),
"commit_id": submitted.get("commit_id"),
"body": submitted.get("body"),
"user": {"login": author},
}
reviews.append(record)
with open(state_path, "w", encoding="utf-8") as handle:
json.dump(reviews, handle)
print("201")
print(json.dumps(record))
PY
)"
elif [[ "$method" == "GET" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE"/pulls/123/reviews/* ]]; then
emit "$(PR_REVIEW_GET_ID="${path##*/}" python3 - <<'PY'
import json
import os
state_path = os.environ["PR_REVIEW_REVIEWS"]
wanted = int(os.environ["PR_REVIEW_GET_ID"])
with open(state_path, encoding="utf-8") as handle:
reviews = json.load(handle)
match = next((r for r in reviews if r["id"] == wanted), None)
if match is None:
print("404")
print(json.dumps({"message": "not found"}))
else:
print("200")
print(json.dumps(match))
PY
)"
elif [[ "$method" == "POST" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE/issues/123/comments" ]]; then
case "$mode" in
write-transport-failure)
echo "simulated transport failure" >&2
exit 7
;;
write-http-failure)
write_response 500 '{"message":"simulated rejection"}'
;;
*)
emit "$(PR_REVIEW_PAYLOAD="$payload" PR_REVIEW_TEST_MODE="$mode" python3 - <<'PY'
import json
import os
from urllib.parse import urlparse
state_path = os.environ["PR_REVIEW_COMMENTS"]
acting = os.environ["PR_REVIEW_ACTING_LOGIN"]
web_base = os.environ["PR_REVIEW_WEB_BASE"]
mode = os.environ.get("PR_REVIEW_TEST_MODE", "")
body = json.loads(os.environ["PR_REVIEW_PAYLOAD"]).get("body")
# REAL Gitea shape for a comment posted to a PR's conversation
# (/issues/{n}/comments on a PR): pull_request_url is the WEB pulls path and
# issue_url is left empty. This is what the wrapper must tolerate — it must NOT
# require an API-shaped issue_url.
pr_url = f"{web_base}/pulls/123"
# comment-plain-issue (#865 ITEM 2): #123 is a plain ISSUE, not a PR. POST
# /issues/123/comments lands an issue comment whose issue_url is set and
# pull_request_url is empty. The pr-review `comment` action MUST reject this — it
# claimed a PR comment, so a bare issue_url is not acceptable proof.
if mode == "comment-plain-issue":
record = {
"id": 456,
"body": body,
"user": {"login": acting},
"issue_url": f"{web_base}/issues/123",
"pull_request_url": "",
}
with open(state_path, "w", encoding="utf-8") as handle:
json.dump([record], handle)
print("201")
print(json.dumps(record))
raise SystemExit(0)
# URL-injection modes (#865 Blocker 3): id/author/body are all correct but the
# provider-returned pull_request_url is forged, so ONLY origin+full-path
# verification can catch them.
_p = urlparse(web_base)
_origin = f"{_p.scheme}://{_p.netloc}"
_slug = _p.path # /<owner>/<repo>
if mode == "comment-url-wrong-host":
pr_url = f"https://evil.example{_slug}/pulls/123"
elif mode == "comment-url-wrong-owner":
pr_url = f"{_origin}/attacker/stack/pulls/123"
elif mode == "comment-url-wrong-repo":
pr_url = f"{_origin}/mosaicstack/other/pulls/123"
elif mode == "comment-url-suffix-injection":
# Prefix-injected: a bare endswith("/<slug>/pulls/123") test would ACCEPT it.
pr_url = f"{_origin}/deceptive{_slug}/pulls/123"
record = {
"id": 456,
"body": body,
"user": {"login": acting},
"issue_url": "",
"pull_request_url": pr_url,
}
with open(state_path, "w", encoding="utf-8") as handle:
json.dump([record], handle)
print("201")
print(json.dumps(record))
PY
)"
;;
esac
elif [[ "$method" == "GET" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE"/issues/comments/* ]]; then
emit "$(PR_REVIEW_GET_ID="${path##*/}" python3 - <<'PY'
import json
import os
state_path = os.environ["PR_REVIEW_COMMENTS"]
mode = os.environ["PR_REVIEW_TEST_MODE"]
wanted = int(os.environ["PR_REVIEW_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"}))
raise SystemExit(0)
if mode == "readback-failure":
# The server returns a DIFFERENT body than was created — a genuine
# provider-side mismatch the wrapper must reject.
match = dict(match, body="different-body")
print("200")
print(json.dumps(match))
PY
)"
else
echo "Unexpected curl request: $method $url" >&2
exit 97
fi
SH
chmod +x "$BIN_DIR/curl"
# Seed persistent server state for a mode before the wrapper runs.
seed_state() {
local mode="$1"
printf '[]' > "$COMMENTS_FILE"
rm -f "$SUBMIT_PAYLOAD_FILE" "$HEAD_CALLS_FILE"
PR_REVIEW_SEED_MODE="$mode" PR_REVIEW_SEED_ACTING="$ACTING_LOGIN" \
PR_REVIEW_SEED_HEAD="$HEAD_SHA" python3 - "$REVIEWS_FILE" <<'PY'
import json
import os
import sys
mode = os.environ["PR_REVIEW_SEED_MODE"]
acting = os.environ["PR_REVIEW_SEED_ACTING"]
head = os.environ["PR_REVIEW_SEED_HEAD"]
def review(rid, state, commit, login):
return {"id": rid, "state": state, "commit_id": commit, "user": {"login": login}}
if mode == "many-prior-approve":
# 50 pre-existing reviews already exist; the review this run submits becomes
# id 51, proving exact-id read-back works regardless of how many reviews
# precede it (no list enumeration is involved).
reviews = [review(i, "COMMENT", "oldsha0000", acting) for i in range(1, 51)]
elif mode == "no-op-concurrent-review":
# A concurrent SAME-IDENTITY APPROVED review at the CURRENT head already
# exists. The wrapper's own submit will be a no-op; it must fail closed
# because no created id is returned — it must not scan and accept this one.
reviews = [review(77, "APPROVED", head, acting)]
else:
reviews = [review(100, "COMMENT", "oldsha0000", acting)]
with open(sys.argv[1], "w", encoding="utf-8") as handle:
json.dump(reviews, handle)
PY
}
run_review() {
local mode="$1" action="$2" comment="${3:-}"
local configured_url="${4:-https://git.mosaicstack.dev}"
local remote_url="${5:-https://git.mosaicstack.dev/mosaicstack/stack.git}"
local expected_repo="${6:-mosaicstack/stack}"
local login_override="${7:-}"
local expected_api_base="${configured_url%/}/api/v1/repos/$expected_repo"
local expected_api_root="${configured_url%/}/api/v1"
local expected_web_base="${configured_url%/}/$expected_repo"
git -C "$REPO_DIR" remote set-url origin "$remote_url"
write_credentials "$configured_url"
: > "$TEA_LOG"
: > "$CURL_LOG"
: > "$CURL_ARGV_LOG"
: > "$AUTH_LOG"
: > "$OUTPUT_FILE"
seed_state "$mode"
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
TMPDIR="$TMP_SCRATCH" \
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
PR_REVIEW_TEA_LOG="$TEA_LOG" \
PR_REVIEW_LOGIN_URL="${configured_url%/}" \
PR_REVIEW_CURL_LOG="$CURL_LOG" \
PR_REVIEW_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
PR_REVIEW_AUTH_LOG="$AUTH_LOG" \
PR_REVIEW_REVIEWS="$REVIEWS_FILE" \
PR_REVIEW_COMMENTS="$COMMENTS_FILE" \
PR_REVIEW_SUBMIT_PAYLOAD="$SUBMIT_PAYLOAD_FILE" \
PR_REVIEW_HEAD_CALLS="$HEAD_CALLS_FILE" \
PR_REVIEW_TEST_MODE="$mode" \
PR_REVIEW_EXPECTED_BODY="$comment" \
PR_REVIEW_EXPECTED_API_BASE="$expected_api_base" \
PR_REVIEW_API_ROOT="$expected_api_root" \
PR_REVIEW_WEB_BASE="$expected_web_base" \
PR_REVIEW_HEAD_SHA="$HEAD_SHA" \
PR_REVIEW_ACTING_LOGIN="$ACTING_LOGIN" \
PR_REVIEW_FOREIGN_LOGIN="$FOREIGN_LOGIN" \
PR_REVIEW_OVERRIDE_LOGIN="$OVERRIDE_LOGIN" \
PR_REVIEW_CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" \
PR_REVIEW_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
PR_REVIEW_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
PR_REVIEW_CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
"$SCRIPT_DIR/pr-review.sh" -n 123 -a "$action" ${comment:+-c "$comment"} ${login_override:+--login "$login_override"}
) > "$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-pr-review-*' -o -name 'mosaic-gitea-auth-*' \) 2>/dev/null || true)
if [[ -n "$leaked" ]]; then
echo "FAIL: pr-review 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
}
assert_no_tea_write() {
# tea must only ever be used for the login list, never to write.
if grep -qvE '^login list --output json$' "$TEA_LOG"; then
echo "FAIL: wrapper invoked tea for something other than the login list" >&2
cat "$TEA_LOG" >&2
exit 1
fi
}
# Case 1: a plain approve submits a review via REST and verifies it by its exact
# provider-returned id (id 101), attributed to the acting identity, pinned to
# the PR head, with no separate comment.
run_review approve approve
grep -q 'Approved and verified Gitea PR #123 (review ID 101)' "$OUTPUT_FILE"
grep -q '^GET https://git.mosaicstack.dev/api/v1/user$' "$CURL_LOG"
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123$' "$CURL_LOG"
grep -q '^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews$' "$CURL_LOG"
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews/101$' "$CURL_LOG"
# No review-list enumeration is performed — the exact-id GET is authoritative.
if grep -Eq '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews(\?|$)' "$CURL_LOG"; then
echo "FAIL: wrapper performed a redundant review-list enumeration" >&2
exit 1
fi
# No-override default path: the write, /user lookup, and read-back all resolve
# via the host-default credential and authenticate as the acting identity.
grep -q "^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews $ACTING_LOGIN\$" "$AUTH_LOG"
grep -q "^GET https://git.mosaicstack.dev/api/v1/user $ACTING_LOGIN\$" "$AUTH_LOG"
assert_no_tea_write
assert_no_temp_leak "approve"
# ITEM 3a: the host-default token drove this whole chain, yet never appeared in
# any curl argv — it was passed via a curl --config file.
assert_token_not_in_argv "approve default-token"
# The submitted review payload carries the event and the PR head commit_id.
PR_REVIEW_HEAD_SHA="$HEAD_SHA" python3 - "$SUBMIT_PAYLOAD_FILE" <<'PY'
import json
import os
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert payload["event"] == "APPROVED", payload
assert payload["commit_id"] == os.environ["PR_REVIEW_HEAD_SHA"], payload
PY
# A plain approve (no body) must not POST a comment.
if grep -q '/issues/123/comments' "$CURL_LOG"; then
echo "FAIL: plain approve unexpectedly posted a comment" >&2
exit 1
fi
# Case 2: a submitted review NOT authored by the acting identity must FAIL
# CLOSED — the exact-id read-back enforces authorship.
if run_review author-mismatch-review approve; then
echo "FAIL: approve accepted a review authored by a different identity" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Approved and verified' "$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.
assert_no_temp_leak "author-mismatch-review"
# Case 3: a no-op submit with a concurrent SAME-IDENTITY, same-state review at
# the current head already present must FAIL CLOSED — the closed concurrency
# window. The wrapper must not read back (or accept) the concurrent id 77.
if run_review no-op-concurrent-review approve; then
echo "FAIL: approve reported success when its submit no-opped but a concurrent review existed" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
echo "FAIL: approve accepted a concurrent review for a no-op submit (window not closed)" >&2
exit 1
fi
if grep -q '/pulls/123/reviews/77$' "$CURL_LOG"; then
echo "FAIL: wrapper read back the concurrent review id 77 (illegitimate fallback)" >&2
exit 1
fi
# Case 4: a genuine matching review (id 51) created after 50 pre-existing reviews
# is still verified by its EXACT provider-returned id — no list enumeration is
# needed regardless of how many reviews precede it.
run_review many-prior-approve approve
grep -q 'Approved and verified Gitea PR #123 (review ID 51)' "$OUTPUT_FILE"
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews/51$' "$CURL_LOG"
if grep -Eq '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews(\?|$)' "$CURL_LOG"; then
echo "FAIL: wrapper performed a redundant review-list enumeration" >&2
exit 1
fi
# Case 5: an approve WITH a body carries that body in the review submit itself —
# there is no separate detached comment POST.
run_review approve approve approve-note
grep -q 'Approved and verified Gitea PR #123 (review ID 101)' "$OUTPUT_FILE"
PR_REVIEW_EXPECTED_BODY="approve-note" python3 - "$SUBMIT_PAYLOAD_FILE" <<'PY'
import json
import os
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert payload["body"] == os.environ["PR_REVIEW_EXPECTED_BODY"], payload
PY
if grep -q '/issues/123/comments' "$CURL_LOG"; then
echo "FAIL: approve-with-body posted a separate comment instead of carrying the body on the review" >&2
exit 1
fi
# Case 6: request-changes requires a body and carries it on the REQUEST_CHANGES
# review submit.
run_review request-changes request-changes changes-required
grep -q 'Requested changes and verified on Gitea PR #123 (review ID 101)' "$OUTPUT_FILE"
grep -q '^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews$' "$CURL_LOG"
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews/101$' "$CURL_LOG"
PR_REVIEW_EXPECTED_BODY="changes-required" python3 - "$SUBMIT_PAYLOAD_FILE" <<'PY'
import json
import os
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert payload["event"] == "REQUEST_CHANGES", payload
assert payload["body"] == os.environ["PR_REVIEW_EXPECTED_BODY"], payload
PY
assert_no_tea_write
# Case 7: the `comment` action creates a comment via REST and verifies it by its
# exact created id, attributed to the acting identity. This also exercises
# owner/repo + base-URL resolution across clone-URL shapes.
complex_body=$'durable "body"\n-- marker'
run_review comment-success comment "$complex_body"
grep -q '^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
assert_no_tea_write
assert_no_temp_leak "comment-success"
run_review http-success comment durable-body http://git.mosaicstack.dev
grep -q '^POST http://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
grep -q '^GET http://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
run_review prefix-success comment durable-body https://git.mosaicstack.dev/gitea/
grep -q '^POST https://git.mosaicstack.dev/gitea/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
grep -q '^GET https://git.mosaicstack.dev/gitea/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
run_review subpath-success comment durable-body https://git.example/gitea https://git.example/gitea/owner/repo.git owner/repo
grep -q '^POST https://git.example/gitea/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
grep -q '^GET https://git.example/gitea/api/v1/repos/owner/repo/issues/comments/456$' "$CURL_LOG"
if grep -q '/repos/gitea/owner/repo/' "$CURL_LOG"; then
echo "Configured Gitea path prefix leaked into the repository slug" >&2
exit 1
fi
run_review port-success comment durable-body http://git.example:3000 http://git.example:3000/owner/repo.git owner/repo
grep -q '^POST http://git.example:3000/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
grep -q '^GET http://git.example:3000/api/v1/repos/owner/repo/issues/comments/456$' "$CURL_LOG"
run_review scp-ssh-success comment durable-body https://git.example git@git.example:owner/repo.git owner/repo
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
run_review url-ssh-success comment durable-body https://git.example ssh://git@git.example/owner/repo.git owner/repo
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
# #850: an SSH remote's transport port must not be compared against the
# configured HTTP(S) API URL's port.
run_review ssh-transport-port-success comment durable-body https://git.example ssh://git@git.example:2222/owner/repo.git owner/repo
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
# #850: an explicit default HTTP(S) port on the remote must equal an implicit
# (portless) configured URL.
run_review explicit-default-port-success comment durable-body https://git.example https://git.example:443/owner/repo.git owner/repo
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
# Comment write/read-back failure modes must all fail closed.
if run_review write-transport-failure comment durable-body; then
echo "Expected provider transport failure to return nonzero" >&2
exit 1
fi
if run_review write-http-failure comment durable-body; then
echo "Expected non-201 provider write to return nonzero" >&2
exit 1
fi
if run_review readback-failure comment durable-body; then
echo "Expected mismatched provider read-back to return nonzero" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "Read-back mismatch reported durable success" >&2
exit 1
fi
# Case 8 (#865 Round-4): a RESOLVABLE explicit --login override must attribute
# the entire write→read-back chain to THAT login's token/identity, never the
# host-default identity. The override login carries its own token in the tea
# config, so /user, the review POST, and the exact-id read-back all authenticate
# as the override identity — and NOTHING is performed under the default identity.
run_review override-success approve "" https://git.mosaicstack.dev \
https://git.mosaicstack.dev/mosaicstack/stack.git mosaicstack/stack "$OVERRIDE_LOGIN"
grep -q 'Approved and verified Gitea PR #123 (review ID 101)' "$OUTPUT_FILE"
grep -q "^GET https://git.mosaicstack.dev/api/v1/user $OVERRIDE_LOGIN\$" "$AUTH_LOG"
grep -q "^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews $OVERRIDE_LOGIN\$" "$AUTH_LOG"
grep -q "^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews/101 $OVERRIDE_LOGIN\$" "$AUTH_LOG"
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: an explicit --login override was silently downgraded to the host-default identity" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
assert_no_tea_write
# ITEM 3a: the override token likewise never leaked into curl argv.
assert_token_not_in_argv "override-success override-token"
# Case 9 (#865 Round-4): an UNRESOLVABLE explicit --login override (a name absent
# from the tea config) must FAIL CLOSED — nonzero exit, no success line, no review
# POST, and above all NO request performed under the host-default identity. The
# host-default best-effort fallback is reserved for the no-override path only.
if run_review override-unresolvable approve "" https://git.mosaicstack.dev \
https://git.mosaicstack.dev/mosaicstack/stack.git mosaicstack/stack "nonexistent-typo-login"; then
echo "FAIL: an unresolvable --login override was not rejected (silently used the host default)" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
echo "FAIL: unresolvable --login override reported success" >&2
exit 1
fi
if grep -q '/pulls/123/reviews ' "$AUTH_LOG" && grep -qE '^POST .*/pulls/123/reviews ' "$AUTH_LOG"; then
echo "FAIL: unresolvable --login override performed a review POST" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: unresolvable --login override fell back to the host-default identity" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
# Case 10 (#865 Round-5): 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
# review POST occurs.
if run_review cross-host approve "" https://git.mosaicstack.dev \
https://git.mosaicstack.dev/mosaicstack/stack.git mosaicstack/stack "$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 'Approved and verified' "$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 -qE '^POST .*/pulls/123/reviews ' "$AUTH_LOG"; then
echo "FAIL: cross-host --login override performed a review POST" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: cross-host --login override fell back to the host-default identity" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
assert_no_temp_leak "cross-host"
# Case 11 (#865 Blocker 4): SubmitReview finalizes/reuses a pending review id
# whose persisted body is NOT this submit's body. id/author/state/head all match
# the request, so ONLY body verification can catch the divergence — it must FAIL
# CLOSED. (Submit a non-empty body so the mismatch is meaningful.)
if run_review review-body-reuse approve real-submitted-review-body; then
echo "FAIL: review with a reused/foreign body was accepted (body not verified)" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
echo "FAIL: read-back did not enforce the submitted review body" >&2
exit 1
fi
assert_no_temp_leak "review-body-reuse"
# Cases 12-15 (#865 Blocker 3): a PR comment whose id/author/body are all correct
# but whose provider-returned pull_request_url is forged must FAIL CLOSED.
# Verification pins the URL's ORIGIN (scheme+host+effective-port) and FULL path
# (deployment prefix + exact owner/repo + kind + number); a bare endswith/suffix
# test would wrongly accept the look-alike-host and prefix-injection variants.
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo comment-url-suffix-injection; do
if run_review "$bad_mode" comment durable-body; 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
# Case 16 (#865 ITEM 1, current-head TOCTOU): the PR head advances between the
# pre-submit head read (which pins the review) and the post-verify re-read. The
# review is genuinely created and verified as pinned to the OLD head, but the
# live tip has moved on, so the wrapper must FAIL CLOSED rather than report a
# review that no longer covers the PR's current commit.
if run_review head-advanced-race approve; then
echo "FAIL: approve reported success though the PR head advanced after submit" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
echo "FAIL: current-head TOCTOU close-out did not fail closed on an advanced head" >&2
exit 1
fi
# The head was re-read after the submit/verify (2nd GET /pulls/123).
if [[ "$(grep -c '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123$' "$CURL_LOG")" -lt 2 ]]; then
echo "FAIL: wrapper did not re-read the PR head after review verification" >&2
cat "$CURL_LOG" >&2
exit 1
fi
assert_no_temp_leak "head-advanced-race"
# Case 17 (#865 ITEM 2): a claimed PR comment that actually lands as a plain
# ISSUE comment (issue #123 exists, PR #123 does not — issue_url set,
# pull_request_url empty) must FAIL CLOSED. The pr-review `comment` verifier
# requires a pull_request_url (kind=pulls) and rejects a bare issue_url.
if run_review comment-plain-issue comment durable-body; then
echo "FAIL: pr-review accepted a plain issue comment as a verified PR comment" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: a plain issue_url satisfied the PR comment verifier" >&2
exit 1
fi
assert_no_temp_leak "comment-plain-issue"
# Case 18 (#865 ITEM 3b): a non-empty review body submitted but persisted as null
# must FAIL CLOSED. id/author/state/head all match; only strict presence +
# string-type + exact body equality (not the old `(body or "")` coalesce) catches
# the lost body.
if run_review review-body-null approve real-submitted-review-body; then
echo "FAIL: review whose non-empty body persisted as null was accepted" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
echo "FAIL: a null persisted body passed strict review-body verification" >&2
exit 1
fi
assert_no_temp_leak "review-body-null"
echo "pr-review.sh REST review + comment create/read-back regression passed"

View File

@@ -50,21 +50,6 @@ if ! [[ "$FILE_PATH" =~ \.(ts|tsx|js|jsx|mjs|cjs)$ ]]; then
exit 0
fi
# Deps preflight (#856): this hook is the common gate-entry seam the delivery
# cycle invokes on every Edit/Write/MultiEdit — it fires before any pnpm-based
# gate (test/lint/typecheck/format:check) runs against the edited file. In a
# freshly created git worktree (pnpm workspaces do NOT share node_modules
# across worktrees), node_modules/.bin is empty until `pnpm install` has run,
# so gate binaries (tsc/eslint/prettier/vitest) fail with a raw, illegible
# `sh: 1: <tool>: not found` that is indistinguishable from a real failure.
# Fail legibly here instead, before that raw error has a chance to surface.
BIN_DIR="$PROJECT_ROOT/node_modules/.bin"
if [ ! -d "$BIN_DIR" ] || [ -z "$(ls -A "$BIN_DIR" 2>/dev/null)" ]; then
echo "deps not installed — run pnpm install" >&2
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] deps not installed — run pnpm install ($BIN_DIR is missing or empty)" >> "$LOG_FILE"
exit 1
fi
# Call the main QA handler with extracted parameters
if [ -f ~/.config/mosaic/tools/qa/qa-hook-handler.sh ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Calling QA handler for $FILE_PATH" >> "$LOG_FILE"

View File

@@ -1,116 +0,0 @@
#!/usr/bin/env bash
# Regression harness for #856: worker git-worktrees under a fresh `git worktree
# add` have no node_modules until `pnpm install` runs (pnpm workspaces do NOT
# share node_modules across worktrees). Before the fix, the gate-entry seam
# (qa-hook-stdin.sh, registered as the PostToolUse hook for every Edit/Write/
# MultiEdit in runtime/claude/settings.json) silently let a raw
# `sh: 1: <tool>: not found` surface from any downstream gate invocation —
# indistinguishable from a real test/lint failure (false-red).
#
# Asserts:
# 1. RED (documented): a completely fresh worktree with no node_modules/.bin
# at all produces the raw "not found" for a gate binary — this is the
# defect the fix prevents from reaching the operator un-annotated.
# 2. With node_modules/.bin missing entirely, the seam exits nonzero with
# the legible sentinel "deps not installed — run pnpm install" instead
# of silently proceeding (exit 0) into a would-be raw not-found.
# 3. With node_modules/.bin present but empty, same legible-sentinel
# behavior (covers `git worktree add` immediately followed by an
# as-yet-incomplete/interrupted install).
# 4. Once node_modules/.bin is populated (post `pnpm install`), the seam
# proceeds normally (exit 0) — the preflight does not false-positive.
# 5. Non-JS/TS files are unaffected (existing skip behavior preserved).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOOK="$SCRIPT_DIR/qa-hook-stdin.sh"
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
fail=0
fail_msg() {
echo "FAIL: $*" >&2
fail=1
}
run_hook() {
local file_path="$1"
printf '{"tool_name":"Edit","tool_input":{"file_path":"%s"}}' "$file_path" | "$HOOK"
}
make_fixture_repo() {
local dir="$1"
mkdir -p "$dir"
git -C "$dir" init -q .
git -C "$dir" -c user.email=fixture@test -c user.name=fixture commit -q --allow-empty -m init
}
# --- Scenario 1: RED — document the pre-fix raw not-found a gate hits when
# node_modules/.bin is entirely absent (this is what the preflight now
# intercepts before any gate command runs).
RED_DIR="$TMP_DIR/red-fixture"
make_fixture_repo "$RED_DIR"
RED_OUTPUT=$(PATH="/usr/bin:/bin" sh -c 'tsc --noEmit' 2>&1) && RED_STATUS=0 || RED_STATUS=$?
case "$RED_OUTPUT" in
*"not found"*) ;;
*) fail_msg "expected the raw un-preflighted invocation to demonstrate 'not found'; got: $RED_OUTPUT" ;;
esac
[[ "$RED_STATUS" -ne 0 ]] || fail_msg "expected raw invocation without deps installed to fail"
# --- Scenario 2: node_modules/.bin missing entirely -> legible sentinel, nonzero.
MISSING_DIR="$TMP_DIR/missing-bin"
make_fixture_repo "$MISSING_DIR"
echo "console.log(1)" > "$MISSING_DIR/x.ts"
OUTPUT=$(cd "$MISSING_DIR" && run_hook "$MISSING_DIR/x.ts" 2>&1) && STATUS=0 || STATUS=$?
[[ "$STATUS" -ne 0 ]] || fail_msg "missing node_modules/.bin: expected nonzero exit, got 0"
case "$OUTPUT" in
*"deps not installed"*"pnpm install"*) ;;
*) fail_msg "missing node_modules/.bin: expected legible sentinel, got: $OUTPUT" ;;
esac
# --- Scenario 3: node_modules/.bin present but empty -> legible sentinel, nonzero.
EMPTY_DIR="$TMP_DIR/empty-bin"
make_fixture_repo "$EMPTY_DIR"
mkdir -p "$EMPTY_DIR/node_modules/.bin"
echo "console.log(1)" > "$EMPTY_DIR/x.ts"
OUTPUT=$(cd "$EMPTY_DIR" && run_hook "$EMPTY_DIR/x.ts" 2>&1) && STATUS=0 || STATUS=$?
[[ "$STATUS" -ne 0 ]] || fail_msg "empty node_modules/.bin: expected nonzero exit, got 0"
case "$OUTPUT" in
*"deps not installed"*"pnpm install"*) ;;
*) fail_msg "empty node_modules/.bin: expected legible sentinel, got: $OUTPUT" ;;
esac
# --- Scenario 4: node_modules/.bin populated (post `pnpm install`) -> proceeds normally.
OK_DIR="$TMP_DIR/installed-bin"
make_fixture_repo "$OK_DIR"
mkdir -p "$OK_DIR/node_modules/.bin"
printf '#!/bin/sh\necho ok\n' > "$OK_DIR/node_modules/.bin/tsc"
chmod +x "$OK_DIR/node_modules/.bin/tsc"
echo "console.log(1)" > "$OK_DIR/x.ts"
OUTPUT=$(cd "$OK_DIR" && run_hook "$OK_DIR/x.ts" 2>&1) && STATUS=0 || STATUS=$?
[[ "$STATUS" -eq 0 ]] || fail_msg "populated node_modules/.bin: expected exit 0, got $STATUS ($OUTPUT)"
case "$OUTPUT" in
*"deps not installed"*) fail_msg "populated node_modules/.bin: unexpected sentinel fired: $OUTPUT" ;;
*) ;;
esac
# --- Scenario 5: non-JS/TS files are unaffected by the preflight (still
# skipped before the deps check, regardless of node_modules state).
NONJS_DIR="$TMP_DIR/nonjs"
make_fixture_repo "$NONJS_DIR"
echo "# doc" > "$NONJS_DIR/README.md"
OUTPUT=$(cd "$NONJS_DIR" && run_hook "$NONJS_DIR/README.md" 2>&1) && STATUS=0 || STATUS=$?
[[ "$STATUS" -eq 0 ]] || fail_msg "non-JS/TS file: expected exit 0 (skip), got $STATUS ($OUTPUT)"
case "$OUTPUT" in
*"deps not installed"*) fail_msg "non-JS/TS file: preflight incorrectly fired: $OUTPUT" ;;
*) ;;
esac
if [[ "$fail" -eq 0 ]]; then
echo "deps-preflight regression passed (5/5 scenarios)"
fi
exit "$fail"

View File

@@ -25,7 +25,7 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_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/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_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-review-gitea-comment.sh"
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_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/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",

View File

@@ -661,27 +661,13 @@ describe('whole mutator-class lease gate', () => {
test('observer revocation and monotonic TTL expiry deny the next mutator', async () => {
const { socket } = await startBroker();
const sessionId = await register(socket);
// Establish the lease with a normal (non-racing) TTL first and prove it
// authorizes. This "still valid" check is setup, not a TTL-expiry
// assertion, so it must not share a lease with a 1-second TTL: on a
// contended push-CI host, scheduling delay alone between promote() and
// this authorize() call can consume that entire 1-second margin and
// spuriously deny it (CI#1945). Using a generous TTL here removes that
// real-time race without touching lease-gate security semantics.
const pending = await beginVerification(socket, sessionId, 'claude');
const pending = await beginVerification(socket, sessionId, 'claude', 1, 1);
await promote(socket, sessionId, pending.receipt_challenge!);
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
ok: true,
decision: 'allow',
});
// A dedicated, isolated short-TTL lease drives the deliberate monotonic
// expiry demonstration below. It is never used for anything but the
// wait-then-expire assertion, so there is no setup work racing its
// 1-second window.
const shortLived = await beginVerification(socket, sessionId, 'claude', 1, 1, 2);
await promote(socket, sessionId, shortLived.receipt_challenge!);
await new Promise((resolve) => setTimeout(resolve, 1_100));
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
ok: false,
@@ -689,7 +675,7 @@ describe('whole mutator-class lease gate', () => {
decision: 'deny',
});
const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 3);
const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 2);
await promote(socket, sessionId, refreshed.receipt_challenge!);
expect(
await request(socket, {

View File

@@ -217,22 +217,12 @@ git fetch origin
mkdir -p ~/src/${projectName}-worktrees
git worktree add ~/src/${projectName}-worktrees/<task-slug> -b <branch-name> origin/main
cd ~/src/${projectName}-worktrees/<task-slug>
pnpm install --frozen-lockfile --prefer-offline
# ... all work happens here ...
git push origin <branch-name>
cd ~/src/${projectName} && git worktree remove ~/src/${projectName}-worktrees/<task-slug>
\`\`\`
Worktrees path: \`~/src/<repo>-worktrees/<task-slug>\` — NEVER use /tmp.
\`pnpm install --frozen-lockfile --prefer-offline\` MUST run immediately after
\`git worktree add\`/\`cd\`, BEFORE any gate (\`pnpm test\`/\`lint\`/\`typecheck\`/\`format:check\`)
is invoked. pnpm workspaces do NOT share \`node_modules\` across separate git
worktrees — a fresh worktree has an empty \`node_modules/.bin\`, so every gate
binary (\`tsc\`/\`eslint\`/\`prettier\`/\`vitest\`) fails \`sh: 1: <tool>: not found\`
until deps are installed. That failure is indistinguishable from a real
test/lint failure — a false-red gate. Never skip this step and never reorder
it after the first gate invocation.`);
Worktrees path: \`~/src/<repo>-worktrees/<task-slug>\` — NEVER use /tmp.`);
// 6. Completion gates
sections.push(`# Completion Gates — ENFORCED

View File

@@ -1,50 +0,0 @@
# Skill: glpi-create — Open a New GLPI Ticket
> Create a new GLPI helpdesk ticket. Mutates GLPI — confirm the details before running.
## When to use
- Logging a new incident or request that should live in the helpdesk queue.
## Required information
- **title** — short subject line.
- **content** — description of the issue / request.
## Optional
- **priority** — `1`=VeryLow, `2`=Low, `3`=Medium (default), `4`=High, `5`=VeryHigh, `6`=Major.
- **type** — `1`=Incident (default), `2`=Request.
## Command
Wraps the existing tooling:
```bash
~/.config/mosaic/tools/glpi/ticket-create.sh \
-t "<title>" \
-c "<content>" \
[-p <priority>] \
[-y <type>] \
[-f json]
```
Example:
```bash
~/.config/mosaic/tools/glpi/ticket-create.sh \
-t "Paint-area camera install" \
-c "Ordered 2 cameras for Paint and stock; schedule mounting + NVR config." \
-p 3 -y 2
```
## After creating
- Note the returned **ticket ID** — you'll need it for **[[glpi-followup]]** and
**[[glpi-solve]]**.
- If it should also be tracked as brain work, add a matching task (see the `add-task` skill).
## Guardrails
- Confirm title/content/priority with the user before creating — a ticket is outward-facing.
- Never echo GLPI tokens.

View File

@@ -1,56 +0,0 @@
# Skill: glpi-followup — Add a Followup to a GLPI Ticket
> Post a followup (comment / progress note / resolution writeup) to a GLPI ticket.
> This documents work but does **not** change the ticket status — to close a ticket
> out, follow with **[[glpi-solve]]** to set status to Solved.
## When to use
- Recording progress, a decision, or a root-cause/resolution note on a ticket.
- The documentation step that usually precedes closing a ticket out (`glpi-solve`).
## Critical quirk
Use the **top-level `/ITILFollowup` endpoint**, NOT `/Ticket/<id>/ITILFollowup`. The
sub-resource path returns permission errors even with a Super-Admin profile.
## Procedure
### 1. Session + creds
```bash
SESSION=$(~/.config/mosaic/tools/glpi/session-init.sh -q)
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials glpi
```
### 2. Post the followup
```bash
TICKET_ID=<id>
CONTENT="<the followup text>"
curl -sk -X POST "${GLPI_URL}/ITILFollowup" \
-H "App-Token: $GLPI_APP_TOKEN" \
-H "Session-Token: $SESSION" \
-H "Content-Type: application/json" \
-d "$(jq -n --argjson id "$TICKET_ID" --arg c "$CONTENT" \
'{input:{itemtype:"Ticket", items_id:$id, content:$c}}')"
```
Expect HTTP 201. Building the payload with `jq` keeps quotes/newlines in the content safe.
### 3. Long or multi-paragraph content
Write the note to a file first, then read it into the payload:
```bash
curl -sk -X POST "${GLPI_URL}/ITILFollowup" \
-H "App-Token: $GLPI_APP_TOKEN" -H "Session-Token: $SESSION" \
-H "Content-Type: application/json" \
-d "$(jq -n --argjson id "$TICKET_ID" --rawfile c /path/to/note.md \
'{input:{itemtype:"Ticket", items_id:$id, content:$c}}')"
```
## Guardrails
- Never echo the GLPI app/user/session tokens.
- A followup alone leaves the ticket open. If the work is done, run **[[glpi-solve]]** next.

View File

@@ -1,57 +0,0 @@
# Skill: glpi-list — Query GLPI Tickets
> Quick lookups of GLPI helpdesk tickets by status or recency. Read-only.
## When to use
- "What tickets are open / pending?" · "Show recent tickets" · finding a ticket ID
before running **[[glpi-followup]]** or **[[glpi-solve]]**.
## Command
Wraps the existing tooling:
```bash
GLPI=~/.config/mosaic/tools/glpi
# Most recent tickets (default 50, newest first)
"$GLPI/ticket-list.sh"
# Filter by status: new | processing | pending | solved | closed
"$GLPI/ticket-list.sh" -s pending
# JSON output (for parsing / piping to jq) and a custom limit
"$GLPI/ticket-list.sh" -s processing -f json -l 20
```
Status IDs: 1 New · 2/3 Processing · 4 Pending · 5 Solved · 6 Closed.
## Details lookup for one ticket
When you have an ID and want the full record:
```bash
SESSION=$(~/.config/mosaic/tools/glpi/session-init.sh -q)
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials glpi
curl -sk "${GLPI_URL}/Ticket/<id>?expand_dropdowns=true" \
-H "App-Token: $GLPI_APP_TOKEN" -H "Session-Token: $SESSION" \
| jq '{id, name, status, date, date_mod}'
# Followups on a ticket
curl -sk "${GLPI_URL}/Ticket/<id>/ITILFollowup" \
-H "App-Token: $GLPI_APP_TOKEN" -H "Session-Token: $SESSION" \
| jq '.[] | {date, content}'
```
(Reading followups via the sub-resource is fine — only _creating_ them requires the
top-level `/ITILFollowup` endpoint. See **[[glpi-followup]]**.)
## Present to user
Group by status, one line per ticket: `#<id> · <title> · <status> · <last-modified>`.
Use neutral phrasing — no "OVERDUE"/"URGENT".
## Guardrails
- Read-only. Never echo GLPI tokens.
- To sync tickets into brain data instead, use `python tools/sync_glpi.py` (not this skill).

View File

@@ -1,96 +0,0 @@
# Skill: glpi-solve — Close Out a GLPI Ticket
> Properly close out a completed GLPI helpdesk ticket. Completing the work is not
> enough — the ticket **status must be set to "Solved"**, which is what triggers
> GLPI's config-driven auto-close. Posting a resolution followup documents the work
> but does **not** change status, so a ticket left at Solved-less status stays open.
## When to use
- Any time work on a GLPI ticket is finished and it should be closed out.
- After posting a root-cause / resolution writeup as an `/ITILFollowup`.
- During a cleanup sweep of tickets that are done in reality but still open in GLPI.
## The rule (from an operator, 2026-07-20)
**"Solved" is the correct terminal state to set — not "Closed."** GLPI is configured
to auto-close Solved tickets after its delay. If you only post a followup and never set
status, the ticket sits open (this bit us on a real incident where resolution followups
were posted but status was never advanced, leaving tickets open, which the operator had
to mark Solved by hand).
Close-out = **followup (optional but preferred) + set status to Solved.**
## GLPI status IDs
| ID | Status | |
| ----- | --------------------- | -------------------------------------------- |
| 1 | New | |
| 2 | Processing (assigned) | |
| 3 | Processing (planned) | |
| 4 | Pending / Waiting | |
| **5** | **Solved** | ← set this on close-out |
| 6 | Closed | ← happens automatically; do not set manually |
## Procedure
### 1. Get a session token
```bash
SESSION=$(~/.config/mosaic/tools/glpi/session-init.sh -q)
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials glpi
```
### 2. (Preferred) Post the resolution followup
Use the **top-level `/ITILFollowup` endpoint** — the `/Ticket/<id>/ITILFollowup`
sub-resource returns permission errors even as Super-Admin (known GLPI quirk).
```bash
TICKET_ID=<id>
curl -sk -X POST "${GLPI_URL}/ITILFollowup" \
-H "App-Token: $GLPI_APP_TOKEN" \
-H "Session-Token: $SESSION" \
-H "Content-Type: application/json" \
-d "{\"input\":{\"itemtype\":\"Ticket\",\"items_id\":${TICKET_ID},\"content\":\"<resolution summary>\"}}"
```
### 3. Set status to Solved (the step that actually closes it out)
```bash
curl -sk -X PUT "${GLPI_URL}/Ticket/${TICKET_ID}" \
-H "App-Token: $GLPI_APP_TOKEN" \
-H "Session-Token: $SESSION" \
-H "Content-Type: application/json" \
-d "{\"input\":{\"id\":${TICKET_ID},\"status\":5}}"
```
Expect HTTP 200/201. GLPI will auto-close it later per its config — leave status at 5.
### 4. Verify
```bash
curl -sk "${GLPI_URL}/Ticket/${TICKET_ID}?expand_dropdowns=true" \
-H "App-Token: $GLPI_APP_TOKEN" -H "Session-Token: $SESSION" \
| jq '{id, name, status}'
```
`status` should read `Solved` (or `5`).
## Optional: sweep for done-but-open tickets
List tickets still open (New/Processing/Pending) to spot ones whose work is actually
finished but were never marked Solved:
```bash
~/.config/mosaic/tools/glpi/ticket-list.sh -s processing -f table
~/.config/mosaic/tools/glpi/ticket-list.sh -s pending -f table
```
Review each; for any that are genuinely resolved, run steps 23.
## Guardrails
- Read-only until you intend to close — confirm the ticket is actually done first.
- Never echo the GLPI app/user/session tokens.
- Set **Solved (5)**, never Closed (6) — auto-close owns that transition.

View File

@@ -1,62 +0,0 @@
# Skill: glpi-sweep — Find Done-But-Open Tickets
> Read-only sweep for tickets that are finished in reality but still sitting open in
> GLPI (never moved to Solved). Surfaces the exact miss an operator caught on 2026-07-20
> (a real incident where an affected ticket had resolution followups posted but was left
> open). For each one that's genuinely done, close it out with **[[glpi-solve]]**.
## When to use
- Periodic hygiene pass (e.g. before a weekly update or month-end).
- After a burst of ticket work, to catch any you resolved-in-followup but never Solved.
## Why this exists
Posting an `/ITILFollowup` documents work but does **not** change status. Tickets only
auto-close once set to **Solved (status 5)**. Anything left at New/Processing/Pending
stays open indefinitely. This sweep finds those.
## Procedure
### 1. List still-open tickets by status
```bash
GLPI=~/.config/mosaic/tools/glpi
"$GLPI/ticket-list.sh" -s new -f table
"$GLPI/ticket-list.sh" -s processing -f table
"$GLPI/ticket-list.sh" -s pending -f table
```
(GLPI status IDs: 1 New · 2/3 Processing · 4 Pending · 5 Solved · 6 Closed.)
### 2. Triage
For each open ticket, judge whether the underlying work is actually finished — check
its latest followups and cross-reference brain tasks / recent work. Read-only here;
change nothing yet.
Reasonable "probably done" signals:
- A resolution/root-cause followup already posted, but status never advanced.
- The related brain task is `done`, or the fix shipped and was confirmed.
- Requester confirmed resolution but the ticket was never Solved.
### 3. Present the candidates
List them for review before touching anything — never bulk-solve blindly:
```
Open tickets that look resolved:
- #<id> "<title>" — <why it looks done> → glpi-solve?
```
### 4. Close out the confirmed ones
For each ticket the user (or clear evidence) confirms is done, run **[[glpi-solve]]**
(optionally **[[glpi-followup]]** first if a closing note is warranted).
## Guardrails
- Read-only until a ticket is confirmed done — do not auto-solve on a guess.
- Never echo GLPI tokens.
- Set **Solved (5)**, never Closed (6) — GLPI auto-close owns that transition.