diff --git a/docs/scratchpads/pr-merge-message-field.md b/docs/scratchpads/pr-merge-message-field.md new file mode 100644 index 00000000..b86112c5 --- /dev/null +++ b/docs/scratchpads/pr-merge-message-field.md @@ -0,0 +1,99 @@ +# PR merge squash message field + +- **Charter:** `/home/hermes/agent-work/CHARTER-PRMERGE-MESSAGE-FIELD.md` +- **Owner:** `be-coder-08` +- **Branch:** `fix/pr-merge-message-field` +- **Base:** remote `main` / local `origin/main` at `85d2108e4ed15c744ad3b87a5b629e7b2d39405a` +- **Estate:** HOMELAB tooling shared by HOMELAB and USC + +## Objective + +Add an optional, identity-checked Gitea squash message to `pr-merge.sh` so genuine multi-author PRs retain non-poster branch authors without weakening hardcoded squash behavior. + +## Binding requirements + +1. `Do` remains hardcoded to `squash`; no provider/repository default may select merge style. +2. A verified trailer uses a PR commit's linked `author.login` and that same commit's author email. No `/users/{login}` primary-email lookup occurs. Recorded rationale: this asks only what the provider can answer. +3. A commit with `author.login` null blocks before merge, prints both the null provider fact and commit email fact, and names the escalation principal. +4. The BLOCK arm must be observed firing; a normal canonical single-author API payload remains explicit squash plus its reviewed `head_commit_id`. +5. Every provider mutation is read back from the provider; no real PR is merged during tests. + +## Derived interface decisions + +- Add `--co-author-trailers` rather than accepting arbitrary message text. The wrapper enumerates PR commits and constructs trailers, making an unchecked `Co-authored-by` line unexpressible. +- Require `--escalate-to PRINCIPAL` with `--co-author-trailers`, so the BLOCK diagnostic always names a principal rather than a generic role. +- Do not expose `MergeTitleField` separately. When trailers exist, set it from the provider PR title and set `MergeMessageField` only to construction-generated trailers. This preserves one provider source for the title and avoids an unrelated caller-controlled degree of freedom. +- Preserve first-commit order and emit one trailer per distinct non-poster `author.login`, using that first linked commit's own email. + +## Canonical delivery plan + +1. Port the capability into the installed source of truth, `packages/mosaic/framework/tools/git/pr-merge.sh`; do not retain `infra/fleet/tools/git` as a second copy. +2. Preserve canonical `--expect-head`, exact head branch/repository/SHA queue inspection, Gitea atomic head pinning, GitHub `--match-head-commit`, and delete-after-merge semantics. +3. Do not port the deployed-only `--skip-queue-guard` bypass. Add the focused harness to the canonical framework-shell suite and re-establish RED/GREEN on the packaged baseline. +4. Deliver through a reviewed package release followed by `mosaic update` with its default framework reseed. The installer snapshots, manifest-syncs framework-owned `tools/**`, and rolls back on failure. +5. Before either estate relies on the change, require installed/package hash equality, `MergeMessageField` presence, and a green focused harness. Release/reseed ownership is currently unassigned and blocks activation after source merge. + +## Evidence + +- RED against the byte-identical deployed baseline (`sha256 08a65e8584c5…`): rc 1 with eight named failures. The wrapper rejected `--co-author-trailers`; the null-login path emitted none of the required BLOCK facts/principal; and both verified/ordinary API paths failed the stdin-config credential assertion (ordinary path exposed the fixture token through curl argv). Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-red.log`. +- GREEN on the deployed-baseline candidate: verified linked multi-author payload, null-login BLOCK, required named principal, explicit squash, stdin-config token transport, and absence of `/users` lookup all passed. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-green.log`. +- RED against canonical packaged baseline `c581ef48…`: rc 1 with 32 assertions. It rejects the new option, and the first harness version did not satisfy canonical head branch/repository/SHA metadata. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-packaged-baseline-red.log`. The port adapts the fixture rather than weakening canonical head controls. +- Provider capability probe against `git.mosaicstack.dev`: authenticated `be-coder-08` POST to deliberately nonexistent PR `2147483647` with both message fields returned JSON HTTP 404; the unauthenticated same request returned JSON HTTP 401 (not the charter's predicted 403). The authenticated-vs-unauthenticated differential proves write authorization resolved while no mergeable subject existed. `tl-mosaic` ruled the literal non-load-bearing: preserve the observed 404/401 pair and do not manufacture a 403 case. No cause was inferred and no real PR was targeted. +- Provider-generated trailer behavior is not treated as exclusive or absent. The wrapper's VERIFIED/BLOCK decision binds each requested non-poster trailer to commit `author.login` plus that commit's email; it does not assume `MergeMessageField` is the squash's only trailer source. The poster is omitted from the constructed list because the resulting squash author already records the poster; any additional provider-generated trailer is outside this change's unmeasured mechanism. +- An early candidate SHA-256 `5de32876990e4f26920448cb3220cc7f1146d558b4dd2bc1ee1a2abee2f2cbe6` passed the initial harness, then author-side review found credential-fallback and argv-exposure defects. The live deployed wrapper was atomically restored to baseline SHA-256 `08a65e8584c52c6d41ea1c686f8b95585c21e4b37320a2447eba09359a0e02c1`; the remediated candidate remains only in the worktree. + +## Remediation and current review state + +1. Token and Basic Auth now use stdin curl configuration, not argv. PR title, contributor email, and the JSON payload also remain out of child argv. +2. Each credential attempt binds commit inspection and merge. A token failure during either inspection or mutation causes Basic fallback to repeat inspection before mutation; the payload pins the inspected `head_commit_id`. +3. Focused tests cover token-resolution fail-closed behavior, both HTTP-401 fallback seams, metadata/credential argv absence, null-login BLOCK, explicit squash, canonical reviewed-head binding, unchanged ordinary payload, and retained log-safe provider diagnostics. Token-resolution RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-token-resolution-red.log`. +4. Codex review rounds 3–5 requested retained provider error text, log-safe provider diagnostics, fail-closed credential fallback, stable value-option parsing, and PR-title trailer-injection prevention. These are remediated with regression assertions. A post-remediation independent review is still required. +5. **Accepted linkage limitation:** `author.login` resolution proves that the commit address maps to a registered provider account. It does not prove that the named principal authored the commit because Git author metadata is self-asserted. This gate checks attribution linkage, not authorship; commit signing is out of scope and currently unadopted. Coordinators explicitly ruled that this does not add a third state. +6. Codex's sandbox could not execute the harness because its checkout was read-only; that environmental limitation is recorded separately from host-side test results. + +## Disposable provider fixture acceptance + +- Use a retained scratch repository only, with two branch authors and `author != committer` on at least one commit. +- Arm A supplies a message-field trailer for one non-poster; record whether that value lands without forcing the partial-pair result into under-specified `APPENDS`/`REPLACES` labels. Demonstrate an absence control. +- Arm B includes a registered trailer for a different non-poster on a branch commit; record whether it survives or drops. Verify identity through an existing commit whose `author.login` resolves and demonstrate an absence control. +- Parse landed trailers key-agnostically with `^[A-Za-z-]+-[Bb]y:` and record generated poster pair presence/absence plus resulting poster attribution. +- Record `/users/` status and raw email only as non-gating estate telemetry. Never read `active`, `visibility`, or any profile field as an identity gate. +- Use distinct principals: poster `be-coder-08`, merger `Mos`, Arm A `be-coder-07`, and Arm B `be-coder-06`. Capture every trailer-shaped line verbatim and in order. Zero trailer lines means the generator did not fire and the run is `VOID`, not evidence that either arm dropped. +- Report the same read-back evidence to `mos-claude` on socket `default` and `tl-mosaic` on socket `mosaic-fleet`. Report values rather than mechanism inferences and stop on any poster-attribution regression. + +## Fixture preflight + +- Retained public repository: `mosaicstack/prmerge-trailer-fixture`; PR `#1`, posted by `be-coder-08` and reserved for merge by `Mos`. +- Existing `mosaicstack/stack` commits resolve `be-coder-07` and `be-coder-06` through `author.login`; exact addresses are `be-coder-07@fleet.mosaicstack.dev` and `be-coder-06@fleet.mosaicstack.dev`. +- Non-gating HOMELAB telemetry for authenticated reader `be-coder-08`: `/api/v1/users/be-coder-06` returned HTTP 200 with raw `email` value `be-coder-06@fleet.mosaicstack.dev`. +- Provider preflight showed PR commit enumeration is newest-first. A new RED test proved that deriving `head_commit_id` from the final array element selected the wrong commit. The candidate now reads `.head.sha` from the authenticated PR endpoint before enumeration, verifies it appears in the commit set, and atomically pins that SHA in the explicit squash payload. RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-head-order-red.log`. +- Fixture PR head `f6ba6e5105031fa21f5ff7bd8e4379d99c16e1de` has `author.login=be-coder-07`, `committer.login=be-coder-08`, and branch-message trailer `Co-authored-by: be-coder-06 `. + +## Fixture result + +- `Mos` merged retained fixture PR `#1` through staged candidate SHA-256 `60e779a85fd13b729d859ea7c986d1e9b1641b97991611329226c1b3113ffb6e`; resulting squash commit: `3f550715d9bc716426fd355a65fe997b3a90fa7d` with one parent. +- Provider read-back: poster/commit author `be-coder-08`, committer/merger `Mos`. The run is non-void. +- Trailer-shaped lines, verbatim and in order: + 1. `Co-authored-by: be-coder-07 ` + 2. `Co-authored-by: be-coder-08 ` +- Arm A supplied field value (`be-coder-07`) landed. Arm B branch trailer (`be-coder-06`) dropped. Both fabricated absence controls remained absent. No `Co-committed-by:` line landed. +- The candidate payload construction explicitly excludes the poster and supplied only the Arm A `be-coder-07` line. Therefore the landed poster line was provider-generated, not candidate-composed. The raw result supports `FIELD LANDS`, `BRANCH DROPS`, and `POSTER GENERATED`; it does not support a claim that candidate code supplied the poster. Evidence: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-fixture-readback.log` and the retained provider object. +- Retained fixture PR `#2` measured the N=2 shape needed by `#1030`: supplied `be-coder-07` then `be-coder-06`; both landed in that order, followed by the provider-generated poster line. No truncation or dedup occurred at N=2. Resulting squash: `39db9d13aed0…`. + +## Current hold point + +PR `mosaicstack/stack#1066` is open. Its first frozen head `f4b162fa…` was terminal-green in Woodpecker `mosaic` pipeline `#2225`, but that evidence becomes stale when the canonical port moves the head. The deployed wrapper remains baseline `08a65e85…`; no manual copy will occur. Canonical port tests, commit amendment, rebase, one guarded force-with-lease, exact-head CI, and new independent review remain. Even after source merge, activation remains blocked on an assigned package-release/reseed owner and installed-byte read-back. + +## Security review 96 remediation + +Exact reviewed predecessor head: `1ceb11058f64dd7f4a817ceb2124f980a1c4dd23`. + +RED-first focused harness produced 10 named failures: all curl calls lacked size/time/connect bounds; raw ESC email reached mutation; oversized and stalled curl failures were discarded and reached mutation; nonempty Basic output with resolver rc 91 authorized mutation. + +Security remediation: + +- Removed the cross-principal HTTP-401 Basic fallback. Both inspection-401 and merge-401 paths now refuse without Basic resolution or mutation; `get_gitea_basic_auth` references in the merge subject are 0. +- Applied `--max-filesize`, `--max-time`, and `--connect-timeout` to all 3/3 provider curl sites and fail closed on curl transport rc at all 3/3 sites. +- Required linked email bytes to be ASCII and printable before constructing `MergeMessageField`; guarded construction sites 1/1. + +GREEN: message-field, exact-head, empty-UID/API, queue branch/repository/SHA, bash syntax, ShellCheck, and diff check pass. R7 total-removal mutants went RED: email guard 3 rows; bound switches 1 row; transport-rc guards 4 rows; HTTP-401 refusal 3 rows. R7 bound: mutants prove total removal only; explicit denominators above prove site coverage. diff --git a/packages/mosaic/framework/tools/git/pr-merge.sh b/packages/mosaic/framework/tools/git/pr-merge.sh index 403ac056..495cbb47 100755 --- a/packages/mosaic/framework/tools/git/pr-merge.sh +++ b/packages/mosaic/framework/tools/git/pr-merge.sh @@ -1,6 +1,6 @@ #!/bin/bash # pr-merge.sh - Merge pull requests on Gitea or GitHub -# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] +# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL] set -euo pipefail @@ -14,6 +14,8 @@ MERGE_METHOD="squash" DELETE_BRANCH=false DRY_RUN=false EXPECT_HEAD="" +CO_AUTHOR_TRAILERS=false +ESCALATE_TO="" usage() { cat <&2 + exit 1 + fi EXPECT_HEAD="$2" shift 2 ;; + --co-author-trailers) + CO_AUTHOR_TRAILERS=true + shift + ;; + --escalate-to) + if [[ $# -lt 2 ]]; then + echo "Error: --escalate-to requires one principal name." >&2 + exit 1 + fi + ESCALATE_TO="$2" + shift 2 + ;; -h|--help) usage 0 ;; @@ -88,17 +110,30 @@ if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2 exit 1 fi +if [[ "$CO_AUTHOR_TRAILERS" == true && -z "$ESCALATE_TO" ]]; then + echo "Error: --co-author-trailers requires --escalate-to with a named principal." >&2 + exit 1 +fi +if [[ -n "$ESCALATE_TO" && ! "$ESCALATE_TO" =~ ^[A-Za-z0-9_.-]+$ ]]; then + echo "Error: --escalate-to must be one exact principal name." >&2 + exit 1 +fi +if [[ "$CO_AUTHOR_TRAILERS" != true && -n "$ESCALATE_TO" ]]; then + echo "Error: --escalate-to is valid only with --co-author-trailers." >&2 + exit 1 +fi PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")" BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')" HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')" HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')" HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')" +PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')" +PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')" if [[ "$BASE_BRANCH" != "main" ]]; then echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2 exit 1 fi - if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2 exit 1 @@ -122,70 +157,442 @@ PLATFORM=$(detect_platform) OWNER=$(get_repo_owner) REPO=$(get_repo_name) -merge_gitea_with_api() { - local host="$1" api_url token basic_auth body_file raw_code payload - api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge" - mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}" - body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX") - payload=$(python3 - "$HEAD_SHA" "$DELETE_BRANCH" <<'PY' +write_curl_auth_config() { + local mode="$1" credential="$2" + printf '%s' "$credential" | python3 -c ' +import sys +mode = sys.argv[1] +credential = sys.stdin.read() +if not credential or any(char in credential for char in "\r\n"): + raise SystemExit(1) +escaped = credential.replace("\\", "\\\\").replace("\"", "\\\"") +if mode == "token": + print(f"header = \"Authorization: token {escaped}\"") +elif mode == "basic": + print(f"user = \"{escaped}\"") +else: + raise SystemExit(1) +' "$mode" +} + +LAST_GITEA_HTTP_CODE="000" +LAST_GITEA_ERROR="" +MERGE_TEMP_DIRS=() +GITEA_CURL_MAX_BYTES="${MOSAIC_GITEA_CURL_MAX_BYTES:-1048576}" +GITEA_CURL_MAX_TIME="${MOSAIC_GITEA_CURL_MAX_TIME_SEC:-30}" +GITEA_CURL_CONNECT_TIMEOUT="${MOSAIC_GITEA_CURL_CONNECT_TIMEOUT_SEC:-10}" +for bound in "$GITEA_CURL_MAX_BYTES" "$GITEA_CURL_MAX_TIME" "$GITEA_CURL_CONNECT_TIMEOUT"; do + if [[ ! "$bound" =~ ^[1-9][0-9]*$ ]]; then + echo "Error: Gitea curl bounds must be positive integers; refusing request." >&2 + exit 1 + fi +done +GITEA_CURL_BOUNDS=( + --max-filesize "$GITEA_CURL_MAX_BYTES" + --max-time "$GITEA_CURL_MAX_TIME" + --connect-timeout "$GITEA_CURL_CONNECT_TIMEOUT" +) + +format_gitea_error_response() { + local response_file="$1" + python3 - "$response_file" <<'PY' import json import sys -head_sha, delete_branch = sys.argv[1:] +with open(sys.argv[1], "rb") as handle: + raw = handle.read(65536) +try: + response = json.loads(raw.decode("utf-8", errors="replace")) +except (UnicodeDecodeError, json.JSONDecodeError): + message = "non-JSON response omitted" +else: + if isinstance(response, dict): + message = response.get("message") or response.get("error") + if not message and response.get("errors") is not None: + message = json.dumps(response["errors"], separators=(",", ":")) + else: + message = None + if not message: + message = "JSON response contained no error message" +message = str(message) +if len(message) > 500: + message = message[:500] + "..." +print(ascii(message)) +PY +} + +cleanup_merge_temp_dirs() { + local path + for path in "${MERGE_TEMP_DIRS[@]}"; do + [[ -n "$path" ]] && rm -rf -- "$path" + done +} +trap cleanup_merge_temp_dirs EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +fetch_gitea_pr_head() { + local host="$1" auth_mode="$2" credential="$3" work_root="$4" + local response_file raw_code api_url auth_config curl_rc + response_file=$(mktemp "$work_root/pr-merge-pr.XXXXXX") + api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}" + if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then + echo "Error: Could not construct Gitea authentication config; refusing request." >&2 + rm -f "$response_file" + return 1 + fi + raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$response_file" \ + -H "User-Agent: curl/8" "$api_url" <<<"$auth_config") + curl_rc=$? + LAST_GITEA_HTTP_CODE="${raw_code:-000}" + if [[ "$curl_rc" -ne 0 ]]; then + LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)" + rm -f "$response_file" + return 1 + fi + if [[ ! "$raw_code" =~ ^2 ]]; then + LAST_GITEA_ERROR=$(format_gitea_error_response "$response_file") + rm -f "$response_file" + return 1 + fi + if ! python3 - "$response_file" <<'PY' +import json +import re +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + pull = json.load(handle) +head = pull.get("head") if isinstance(pull, dict) else None +sha = str(head.get("sha") or "") if isinstance(head, dict) else "" +if not re.fullmatch(r"[0-9a-fA-F]{40}", sha): + raise SystemExit(1) +print(sha) +PY + then + echo "Error: Gitea PR response has no valid head SHA; refusing merge." >&2 + rm -f "$response_file" + return 1 + fi + rm -f "$response_file" +} + +fetch_gitea_pr_commits() { + local host="$1" auth_mode="$2" credential="$3" work_root="$4" + local page page_file combined_file merged_file raw_code page_count api_url auth_config curl_rc + mkdir -p "$work_root" + if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then + echo "Error: Could not construct Gitea authentication config; refusing request." >&2 + return 1 + fi + combined_file=$(mktemp "$work_root/pr-merge-commits.XXXXXX") + printf '[]' > "$combined_file" + + page=1 + while true; do + page_file=$(mktemp "$work_root/pr-merge-commits-page.XXXXXX") + api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/commits?limit=50&page=${page}" + raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$page_file" \ + -H "User-Agent: curl/8" "$api_url" <<<"$auth_config") + curl_rc=$? + LAST_GITEA_HTTP_CODE="${raw_code:-000}" + if [[ "$curl_rc" -ne 0 ]]; then + LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)" + rm -f "$page_file" "$combined_file" + return 1 + fi + if [[ ! "$raw_code" =~ ^2 ]]; then + LAST_GITEA_ERROR=$(format_gitea_error_response "$page_file") + rm -f "$page_file" "$combined_file" + return 1 + fi + + if ! page_count=$(python3 - "$page_file" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + page = json.load(handle) +if not isinstance(page, list): + raise SystemExit(1) +print(len(page)) +PY + ); then + echo "Error: Gitea PR commits response is not a JSON array; refusing merge." >&2 + rm -f "$page_file" "$combined_file" + return 1 + fi + + merged_file=$(mktemp "$work_root/pr-merge-commits-merged.XXXXXX") + if ! python3 - "$combined_file" "$page_file" > "$merged_file" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + combined = json.load(handle) +with open(sys.argv[2], encoding="utf-8") as handle: + page = json.load(handle) +json.dump(combined + page, sys.stdout, separators=(",", ":")) +PY + then + echo "Error: Could not combine paginated PR commit metadata; refusing merge." >&2 + rm -f "$page_file" "$combined_file" "$merged_file" + return 1 + fi + mv "$merged_file" "$combined_file" + rm -f "$page_file" + + if [[ "$page_count" -lt 50 ]]; then + break + fi + page=$((page + 1)) + if [[ "$page" -gt 1000 ]]; then + echo "Error: PR commit pagination exceeded 1000 pages; refusing merge." >&2 + rm -f "$combined_file" + return 1 + fi + done + + cat "$combined_file" + rm -f "$combined_file" +} + +# LIMITATION: author.login resolution proves the commit address maps to a registered account. +# It does NOT prove the named principal authored the commit — git author metadata is self-asserted. +# This gate checks ATTRIBUTION LINKAGE, not AUTHORSHIP. Commit signing is out of scope and unadopted. +build_coauthor_message_fields() { + local commits_file="$1" context_file="$2" head_file="$3" + python3 - "$commits_file" "$context_file" "$head_file" <<'PY' +import json +import re +import sys + +commits_path, context_path, head_path = sys.argv[1:] +with open(commits_path, encoding="utf-8") as handle: + commits = json.load(handle) +head_sha = open(head_path, encoding="utf-8").read().strip() +context_parts = open(context_path, "rb").read().split(b"\0") +if len(context_parts) != 4 or context_parts[-1] != b"": + raise SystemExit(1) +poster, title, principal = (part.decode("utf-8") for part in context_parts[:3]) + +if not isinstance(commits, list) or not commits: + print( + f"BLOCK: provider returned no PR commits; author identity is unmeasurable. " + f"Refusing merge; escalate to named principal '{principal}'.", + file=sys.stderr, + ) + raise SystemExit(75) +if not poster: + print( + f"BLOCK: PR poster login is empty; refusing merge; " + f"escalate to named principal '{principal}'.", + file=sys.stderr, + ) + raise SystemExit(75) + +if not re.fullmatch(r"[0-9a-fA-F]{40}", head_sha): + print( + f"BLOCK: inspected PR head SHA is invalid; refusing merge; " + f"escalate to named principal '{principal}'.", + file=sys.stderr, + ) + raise SystemExit(75) + +seen = set() +trailers = [] +head_seen = False +for item in commits: + if not isinstance(item, dict): + print(f"BLOCK: malformed PR commit metadata; escalate to named principal '{principal}'.", file=sys.stderr) + raise SystemExit(75) + sha = str(item.get("sha") or "") + if sha == head_sha: + head_seen = True + commit = item.get("commit") if isinstance(item.get("commit"), dict) else {} + commit_author = commit.get("author") if isinstance(commit.get("author"), dict) else {} + email = str(commit_author.get("email") or "").strip() + provider_author = item.get("author") if isinstance(item.get("author"), dict) else {} + login = str(provider_author.get("login") or "").strip() + + if not login: + diagnostic_email = email or "" + print( + f"BLOCK: commit {sha!r} has author.login=NULL while " + f"commit.author.email={diagnostic_email!r}; refusing merge; " + f"escalate to named principal '{principal}'.", + file=sys.stderr, + ) + raise SystemExit(75) + if ( + not email.isascii() + or not email.isprintable() + or not re.fullmatch(r"[A-Za-z0-9_.-]+", login) + or not re.fullmatch(r"[^<>\s]+@[^<>\s]+", email) + ): + print( + f"BLOCK: commit {sha!r} has unusable linked identity " + f"author.login={login!r}, commit.author.email={email!r}; refusing merge; " + f"escalate to named principal '{principal}'.", + file=sys.stderr, + ) + raise SystemExit(75) + if login == poster or login in seen: + continue + seen.add(login) + trailers.append(f"Co-authored-by: {login} <{email}>") + +if not head_seen: + print( + f"BLOCK: inspected PR head is absent from commit enumeration; refusing merge; " + f"escalate to named principal '{principal}'.", + file=sys.stderr, + ) + raise SystemExit(75) +if not trailers: + print("{}") + raise SystemExit(0) +if not title: + print( + f"BLOCK: PR title is empty; refusing merge; escalate to named principal '{principal}'.", + file=sys.stderr, + ) + raise SystemExit(75) +if not title.isprintable() or re.match(r"^[A-Za-z-]+-[Bb]y:", title): + print( + f"BLOCK: PR title is not one printable, non-trailer line; refusing merge; " + f"escalate to named principal '{principal}'.", + file=sys.stderr, + ) + raise SystemExit(75) + +print(json.dumps({ + "MergeTitleField": title, + "MergeMessageField": "\n".join(trailers), +}, separators=(",", ":"))) +PY +} + +merge_gitea_api_attempt() { + local host="$1" auth_mode="$2" credential="$3" + local api_url attempt_dir body_file raw_code commits_file fields_file context_file head_file payload_file work_root attempt_rc auth_config curl_rc + LAST_GITEA_HTTP_CODE="000" + LAST_GITEA_ERROR="" + api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge" + work_root="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}" + mkdir -p "$work_root" + attempt_dir=$(mktemp -d "$work_root/pr-merge-attempt.XXXXXX") + chmod 0700 "$attempt_dir" + MERGE_TEMP_DIRS+=("$attempt_dir") + body_file=$(mktemp "$attempt_dir/api-response.XXXXXX") + fields_file=$(mktemp "$attempt_dir/message-fields.XXXXXX") + payload_file=$(mktemp "$attempt_dir/payload.XXXXXX") + printf '{}' > "$fields_file" + + if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then + commits_file=$(mktemp "$attempt_dir/pr-merge-commits-input.XXXXXX") + context_file=$(mktemp "$attempt_dir/pr-merge-message-context.XXXXXX") + head_file=$(mktemp "$attempt_dir/pr-merge-head-input.XXXXXX") + printf '%s\0%s\0%s\0' "$PR_AUTHOR" "$PR_TITLE" "$ESCALATE_TO" > "$context_file" + if fetch_gitea_pr_head "$host" "$auth_mode" "$credential" "$attempt_dir" > "$head_file"; then + : + else + attempt_rc=$? + rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file" + return "$attempt_rc" + fi + if [[ "$(<"$head_file")" != "$HEAD_SHA" ]]; then + echo "BLOCK: authenticated PR head moved from reviewed $HEAD_SHA to $(<"$head_file"); refusing merge; escalate to named principal '$ESCALATE_TO'." >&2 + rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file" + return 75 + fi + if fetch_gitea_pr_commits "$host" "$auth_mode" "$credential" "$attempt_dir" > "$commits_file"; then + : + else + attempt_rc=$? + rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file" + return "$attempt_rc" + fi + if build_coauthor_message_fields "$commits_file" "$context_file" "$head_file" > "$fields_file"; then + : + else + attempt_rc=$? + rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file" + return "$attempt_rc" + fi + rm -f "$commits_file" "$context_file" "$head_file" + fi + + if ! python3 - "$fields_file" "$HEAD_SHA" "$DELETE_BRANCH" > "$payload_file" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + fields = json.load(handle) +head_sha, delete_branch = sys.argv[2:] payload = {"Do": "squash", "head_commit_id": head_sha} if delete_branch == "true": payload["delete_branch_after_merge"] = True +payload.update(fields) +allowed = {"Do", "head_commit_id", "delete_branch_after_merge", "MergeTitleField", "MergeMessageField"} +if payload.get("Do") != "squash" or set(payload) - allowed: + raise SystemExit(1) print(json.dumps(payload, separators=(",", ":"))) PY -) - - token=$(get_gitea_token "$host" || true) - if [[ -n "$token" ]]; then - raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \ - -X POST \ - -H "User-Agent: curl/8" \ - -H "Authorization: token $token" \ - -H 'Content-Type: application/json' \ - -d "$payload" \ - "$api_url" || true) - if [[ "$raw_code" =~ ^2 ]]; then - rm -f "$body_file" - return 0 - fi + then + rm -f "$body_file" "$fields_file" "$payload_file" + return 1 fi + rm -f "$fields_file" - basic_auth=$(get_gitea_basic_auth "$host" || true) - if [[ -n "$basic_auth" ]]; then - raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \ - -X POST \ - -u "$basic_auth" \ - -H "User-Agent: curl/8" \ - -H 'Content-Type: application/json' \ - -d "$payload" \ - "$api_url" || true) - if [[ "$raw_code" =~ ^2 ]]; then - rm -f "$body_file" - return 0 - fi + if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then + echo "Error: Could not construct Gitea authentication config; refusing request." >&2 + rm -f "$body_file" "$payload_file" + return 1 fi + raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$body_file" \ + -X POST -H "User-Agent: curl/8" \ + -H 'Content-Type: application/json' \ + --data-binary "@$payload_file" "$api_url" <<<"$auth_config") + curl_rc=$? + LAST_GITEA_HTTP_CODE="${raw_code:-000}" + if [[ "$curl_rc" -ne 0 ]]; then + LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)" + rm -f "$body_file" "$payload_file" + rm -rf -- "$attempt_dir" + return 1 + fi + if [[ ! "$raw_code" =~ ^2 ]]; then + LAST_GITEA_ERROR=$(format_gitea_error_response "$body_file") + fi + rm -f "$body_file" "$payload_file" + rm -rf -- "$attempt_dir" + [[ "$raw_code" =~ ^2 ]] +} - python3 - "${raw_code:-000}" "$body_file" <<'PY' >&2 -import json -import sys -code, path = sys.argv[1], sys.argv[2] -try: - with open(path, encoding="utf-8", errors="replace") as handle: - raw = handle.read(500) - data = json.loads(raw) if raw else {} - message = data.get("message") or data.get("error") or raw or "empty response" -except Exception: - try: - message = open(path, encoding="utf-8", errors="replace").read(500) or "empty response" - except Exception: - message = "unreadable response" -print(f"Error: Gitea API merge failed with HTTP {code}: {message}") -PY - rm -f "$body_file" +merge_gitea_with_api() { + local host="$1" token attempt_rc + + if ! token=$(get_gitea_token "$host"); then + echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2 + return 1 + fi + if [[ -z "$token" ]]; then + echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2 + return 1 + fi + if merge_gitea_api_attempt "$host" token "$token"; then + return 0 + else + attempt_rc=$? + fi + if [[ "$attempt_rc" -eq 75 ]]; then + return 75 + fi + if [[ "$LAST_GITEA_HTTP_CODE" != "401" ]]; then + echo "Error: Gitea API merge failed with the identity-bound token (HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR}" >&2 + return 1 + fi + echo "Error: Gitea API rejected the identity-bound token with HTTP 401; refusing cross-principal credential fallback." >&2 return 1 } @@ -195,11 +602,10 @@ if [[ "$DRY_RUN" == true ]]; then echo "Error: Cannot determine host from origin remote URL" >&2 exit 1 } - TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)" - if [[ -n "$TEA_LOGIN" ]]; then - echo "Dry run: would merge PR #$PR_NUMBER on $HOST with tea login '$TEA_LOGIN' (base=$BASE_BRANCH, method=squash)." + if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then + echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)." else - echo "Dry run: would merge PR #$PR_NUMBER on $HOST with authenticated Gitea API fallback (base=$BASE_BRANCH, method=squash)." + echo "Dry run: would merge PR #$PR_NUMBER on $HOST with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)." fi else echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)." @@ -209,6 +615,10 @@ fi case "$PLATFORM" in github) + if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then + echo "Error: --co-author-trailers currently requires the Gitea REST message-field contract." >&2 + exit 1 + fi cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA") [[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch) "${cmd[@]}" @@ -219,7 +629,7 @@ case "$PLATFORM" in exit 1 } # Gitea's API head_commit_id is an atomic compare-and-merge precondition. - # tea cannot express it, so exact-head merges use the authenticated API path. + # tea cannot express it, so every Gitea merge uses the authenticated API path. merge_gitea_with_api "$HOST" ;; *) diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh b/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh index 24d4d51e..ab2ccee6 100755 --- a/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh @@ -51,22 +51,23 @@ for arg in "$@"; do prev="" continue fi - if [[ "$prev" == "-d" ]]; then + if [[ "$prev" == "data" ]]; then post_data="$arg" + [[ "$post_data" == @* ]] && post_data=$(<"${post_data#@}") prev="" continue fi - if [[ "$arg" == "-o" ]]; then - prev="-o" + if [[ "$prev" == "config" ]]; then + [[ "$arg" == "-" ]] && cat >/dev/null + prev="" continue fi - if [[ "$arg" == "-d" ]]; then - prev="-d" - continue - fi - if [[ "$arg" == "-w" ]]; then - write_code=true - fi + case "$arg" in + -o) prev="-o" ;; + -d|--data|--data-binary) prev="data" ;; + -K|--config) prev="config" ;; + -w) write_code=true ;; + esac done emit_response() { local body="$1" diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh b/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh index 0eec10f0..03ced683 100644 --- a/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh @@ -36,13 +36,30 @@ cat > "$WORK_DIR/gitea/curl" <<'SH' #!/usr/bin/env bash set -euo pipefail payload="" -for ((i=1; i<=$#; i++)); do - if [[ "${!i}" == "-d" ]]; then - j=$((i + 1)) - payload="${!j}" - fi +out_file="" +while [[ $# -gt 0 ]]; do + case "$1" in + -d|--data|--data-binary) + payload="$2" + [[ "$payload" == @* ]] && payload=$(<"${payload#@}") + shift 2 + ;; + -o) + out_file="$2" + shift 2 + ;; + -K|--config) + [[ "$2" == "-" ]] && cat >/dev/null + shift 2 + ;; + -w|-X|-H) + shift 2 + ;; + *) shift ;; + esac done printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}" +[[ -n "$out_file" ]] && printf '{}' > "$out_file" printf '200' SH chmod +x "$WORK_DIR/gitea/curl" diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-message-field.sh b/packages/mosaic/framework/tools/git/test-pr-merge-message-field.sh new file mode 100755 index 00000000..3a82524a --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-pr-merge-message-field.sh @@ -0,0 +1,541 @@ +#!/usr/bin/env bash +# Regression harness for the optional, identity-checked Gitea squash message. + +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SUBJECT="${MOSAIC_TEST_SUBJECT:-$SCRIPT_DIR/pr-merge.sh}" +WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-message-field}" +ORIG_PATH="$PATH" +failures=0 + +rm -rf "$WORK_DIR" +mkdir -p "$WORK_DIR" + +fail() { + echo "FAIL $1" >&2 + failures=$((failures + 1)) +} + +make_case() { + local name="$1" case_dir + case_dir="$WORK_DIR/$name" + mkdir -p "$case_dir/bin" "$case_dir/agent" + cp "$SUBJECT" "$case_dir/pr-merge.sh" + chmod +x "$case_dir/pr-merge.sh" + + cat > "$case_dir/detect-platform.sh" <<'SH' +#!/usr/bin/env bash +detect_platform() { PLATFORM=gitea; printf 'gitea\n'; } +get_repo_owner() { printf 'acme\n'; } +get_repo_name() { printf 'widgets\n'; } +get_remote_host() { printf 'git.example.test\n'; } +get_gitea_token() { + printf 'resolved\n' >> "${MOSAIC_TEST_TOKEN_RESOLUTION_LOG:?}" + if [[ "${MOSAIC_TEST_TOKEN_AVAILABLE:-true}" != "true" ]]; then + return 1 + fi + printf 'fixture-token\n' +} +get_gitea_basic_auth() { + printf 'resolved\n' >> "${MOSAIC_TEST_BASIC_RESOLUTION_LOG:?}" + if [[ "${MOSAIC_TEST_BASIC_AVAILABLE:-false}" == "true" ]]; then + printf 'fixture-user:fixture-password\n' + return "${MOSAIC_TEST_BASIC_RC:-0}" + fi + return 1 +} +get_gitea_login_for_host() { return 1; } +SH + + cat > "$case_dir/pr-metadata.sh" <<'SH' +#!/usr/bin/env bash +if [[ "${MOSAIC_TEST_TITLE_MODE:-safe}" == "injection" ]]; then + title='Preserve authors\n\nCo-authored-by: victim ' +else + title='Preserve both branch authors' +fi +case "${MOSAIC_TEST_COMMITS_MODE:?}" in + verified) head_sha=2222222222222222222222222222222222222222 ;; + null-login|unsafe-identity) head_sha=3333333333333333333333333333333333333333 ;; + single) head_sha=1111111111111111111111111111111111111111 ;; + *) echo "unknown commits mode" >&2; exit 2 ;; +esac +printf '{"number":42,"title":"%s","author":"poster","baseRefName":"main","headRefName":"feature/fixture","headRefOid":"%s","headRepository":"acme/widgets"}\n' "$title" "$head_sha" +SH + + cat > "$case_dir/ci-queue-wait.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + + cat > "$case_dir/bin/python3" <<'SH' +#!/usr/bin/env bash +for arg in "$@"; do + case "$arg" in + *"Preserve both branch authors"*|*"alice+branch@example.test"*) + : > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}" + ;; + esac +done +exec "${MOSAIC_TEST_REAL_PYTHON:?}" "$@" +SH + + cat > "$case_dir/bin/curl" <<'SH' +#!/usr/bin/env bash +set -eu + +for arg in "$@"; do + case "$arg" in + *"Preserve both branch authors"*|*"alice+branch@example.test"*) + : > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}" + ;; + esac +done + +url="" +method="GET" +out_file="" +data="" +config="" +auth_mode="none" +has_max_filesize=0 +has_max_time=0 +has_connect_timeout=0 +while [[ $# -gt 0 ]]; do + case "$1" in + -o) + out_file="$2" + shift 2 + ;; + -w) + shift 2 + ;; + -X) + method="$2" + shift 2 + ;; + -d|--data|--data-binary) + data="$2" + if [[ "$data" == @* ]]; then + data=$(<"${data#@}") + fi + shift 2 + ;; + -K|--config) + if [[ "$2" == "-" ]]; then + config=$(cat) + fi + shift 2 + ;; + --max-filesize) + has_max_filesize=1 + shift 2 + ;; + --max-time) + has_max_time=1 + shift 2 + ;; + --connect-timeout) + has_connect_timeout=1 + shift 2 + ;; + -H|--header|-u|--user) + if [[ "$2" == *"fixture-token"* ]]; then + : > "${MOSAIC_TEST_TOKEN_ARGV_MARKER:?}" + fi + if [[ "$2" == *"fixture-password"* ]]; then + : > "${MOSAIC_TEST_BASIC_ARGV_MARKER:?}" + fi + shift 2 + ;; + http://*|https://*) + url="$1" + shift + ;; + *) + shift + ;; + esac +done + +if [[ "$config" == *"Authorization: token fixture-token"* ]]; then + auth_mode="token" + : > "${MOSAIC_TEST_AUTH_CONFIG_MARKER:?}" +elif [[ "$config" == *"user = \"fixture-user:fixture-password\""* ]]; then + auth_mode="basic" + : > "${MOSAIC_TEST_BASIC_CONFIG_MARKER:?}" +fi +printf '%s %s %s\n' "$method" "$auth_mode" "$url" >> "${MOSAIC_TEST_CURL_LOG:?}" +printf '%s:%s:%s\n' "$has_max_filesize" "$has_max_time" "$has_connect_timeout" >> "${MOSAIC_TEST_CURL_BOUNDS_LOG:?}" + +case "$url" in + */pulls/42) + case "${MOSAIC_TEST_COMMITS_MODE:?}" in + verified) head_sha=2222222222222222222222222222222222222222 ;; + null-login|unsafe-identity) head_sha=3333333333333333333333333333333333333333 ;; + single) head_sha=1111111111111111111111111111111111111111 ;; + *) echo "unknown commits mode" >&2; exit 2 ;; + esac + if [[ "${MOSAIC_TEST_HEAD_MODE:-stable}" == "moved" ]]; then + head_sha=4444444444444444444444444444444444444444 + fi + body="{\"head\":{\"sha\":\"$head_sha\"}}" + code=200 + if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then + body='{"message":"token rejected"}' + code=401 + fi + ;; + */pulls/42/commits*) + case "${MOSAIC_TEST_COMMITS_MODE:?}" in + verified) + if [[ "${MOSAIC_TEST_EMAIL_MODE:-safe}" == "escape" ]]; then + body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"alice+\u001b[31m@example.test"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"poster@example.test"}},"author":{"login":"poster"}}]' + else + body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"alice+branch@example.test"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"poster@example.test"}},"author":{"login":"poster"}}]' + fi + ;; + null-login) + body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"poster@example.test"}},"author":{"login":"poster"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Unresolved Author","email":"unresolved@example.test\n\u001b[31m"}},"author":null}]' + ;; + unsafe-identity) + body='[{"sha":"unsafe\n\u001b[31m","commit":{"author":{"name":"Unsafe","email":"not-an-email"}},"author":{"login":"unsafe"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Poster","email":"poster@example.test"}},"author":{"login":"poster"}}]' + ;; + single) + body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"poster@example.test"}},"author":{"login":"poster"}}]' + ;; + *) + echo "unknown commits mode" >&2 + exit 2 + ;; + esac + code=200 + if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then + body='{"message":"token rejected"}' + code=401 + fi + ;; + */pulls/42/merge) + body='{}' + code=200 + if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "merge" && "$auth_mode" == "token" ]]; then + body='{"message":"token rejected"}' + code=401 + elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "provider-error" ]]; then + body='{"message":"branch policy rejected\n\u001b[31m"}' + code=409 + elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "forbidden" ]]; then + body='{"message":"permission denied"}' + code=403 + else + printf '%s' "$data" > "${MOSAIC_TEST_MERGE_PAYLOAD:?}" + fi + ;; + */users/*) + body='{"message":"not found"}' + code=404 + ;; + *) + body='{"message":"unexpected URL"}' + code=500 + ;; +esac + +if [[ -n "$out_file" ]]; then + printf '%s' "$body" > "$out_file" +else + printf '%s' "$body" +fi +printf '%s' "$code" +case "${MOSAIC_TEST_CURL_FAILURE:-none}" in + oversize) exit 63 ;; + stalled) exit 28 ;; +esac +SH + + chmod +x "$case_dir/detect-platform.sh" "$case_dir/pr-metadata.sh" \ + "$case_dir/ci-queue-wait.sh" "$case_dir/bin/curl" "$case_dir/bin/python3" + printf '%s\n' "$case_dir" +} + +run_case() { + local case_dir="$1" mode="$2" + shift 2 + MOSAIC_TEST_COMMITS_MODE="$mode" \ + MOSAIC_TEST_CURL_LOG="$case_dir/curl.log" \ + MOSAIC_TEST_CURL_BOUNDS_LOG="$case_dir/curl-bounds.log" \ + MOSAIC_TEST_MERGE_PAYLOAD="$case_dir/merge-payload.json" \ + MOSAIC_TEST_TOKEN_ARGV_MARKER="$case_dir/token-in-argv" \ + MOSAIC_TEST_BASIC_ARGV_MARKER="$case_dir/basic-in-argv" \ + MOSAIC_TEST_AUTH_CONFIG_MARKER="$case_dir/auth-via-config" \ + MOSAIC_TEST_BASIC_CONFIG_MARKER="$case_dir/basic-via-config" \ + MOSAIC_TEST_TOKEN_RESOLUTION_LOG="$case_dir/token-resolution.log" \ + MOSAIC_TEST_BASIC_RESOLUTION_LOG="$case_dir/basic-resolution.log" \ + MOSAIC_TEST_METADATA_ARGV_MARKER="$case_dir/metadata-in-argv" \ + MOSAIC_TEST_REAL_PYTHON="$(command -v python3)" \ + AGENT_WORK_ROOT="$case_dir/agent" \ + PATH="$case_dir/bin:$ORIG_PATH" \ + "$case_dir/pr-merge.sh" -n 42 "$@" +} + +# Verified multi-author path: the non-poster trailer is built from one commit's +# linked author.login and that same commit's author email. No /users lookup. +verified_dir=$(make_case verified) +set +e +verified_output=$(run_case "$verified_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1) +verified_rc=$? +set -e +if [[ "$verified_rc" -ne 0 ]]; then + fail "verified multi-author merge expected rc=0, got rc=$verified_rc: $verified_output" +elif [[ ! -s "$verified_dir/merge-payload.json" ]]; then + fail "verified multi-author merge did not reach the API payload" +else + python3 - "$verified_dir/merge-payload.json" <<'PY' || fail "verified payload did not preserve squash and exact message fields" +import json +import sys +payload = json.load(open(sys.argv[1], encoding="utf-8")) +assert payload == { + "Do": "squash", + "head_commit_id": "2222222222222222222222222222222222222222", + "MergeTitleField": "Preserve both branch authors", + "MergeMessageField": "Co-authored-by: alice ", +}, payload +PY +fi +[[ -e "$verified_dir/auth-via-config" ]] || fail "verified path did not authenticate curl through stdin config" +[[ ! -e "$verified_dir/token-in-argv" ]] || fail "verified path placed the Gitea token in curl argv" +[[ ! -e "$verified_dir/metadata-in-argv" ]] || fail "verified path placed PR title or contributor email in child argv" +[[ "$(wc -l < "$verified_dir/token-resolution.log")" -eq 1 ]] || fail "verified path did not bind inspection and merge to one credential resolution" +if grep -q '/users/' "$verified_dir/curl.log" 2>/dev/null; then + fail "verified path performed a forbidden second /users lookup" +fi +if grep -qv '^1:1:1$' "$verified_dir/curl-bounds.log"; then + fail "verified path did not apply size/max-time/connect-time bounds to every provider download" +fi + +# A linked email containing a terminal escape must block before mutation. +escape_email_dir=$(make_case escape-email) +set +e +escape_email_output=$(MOSAIC_TEST_EMAIL_MODE=escape run_case "$escape_email_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1) +escape_email_rc=$? +set -e +[[ "$escape_email_rc" -ne 0 ]] || fail "control-byte email unexpectedly passed" +[[ "$escape_email_output" == *"unusable linked identity"* ]] || fail "control-byte email refusal lost its diagnostic" +[[ ! -e "$escape_email_dir/merge-payload.json" ]] || fail "control-byte email reached the merge API" + +# Curl transfer and duration failures must remain failures even with HTTP 200. +for failure_mode in oversize stalled; do + failure_dir=$(make_case "curl-$failure_mode") + set +e + failure_output=$(MOSAIC_TEST_CURL_FAILURE="$failure_mode" run_case "$failure_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1) + failure_rc=$? + set -e + [[ "$failure_rc" -ne 0 ]] || fail "curl $failure_mode failure was discarded: $failure_output" + [[ ! -e "$failure_dir/merge-payload.json" ]] || fail "curl $failure_mode failure reached the merge API" +done + +# The authenticated head is re-read under the mutation credential but cannot +# replace the canonical preflight/review head. A move blocks before enumeration +# or mutation even though the provider returned a valid new SHA. +moved_dir=$(make_case moved-head) +set +e +moved_output=$(MOSAIC_TEST_HEAD_MODE=moved \ + run_case "$moved_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1) +moved_rc=$? +set -e +[[ "$moved_rc" -ne 0 ]] || fail "moved authenticated head unexpectedly passed" +[[ "$moved_output" == *"authenticated PR head moved from reviewed"* ]] || fail "moved head refusal lost its diagnostic" +[[ "$moved_output" == *"tl-mosaic"* ]] || fail "moved head refusal omitted the named escalation principal" +[[ ! -e "$moved_dir/merge-payload.json" ]] || fail "moved head refusal reached the merge API" +moved_sequence=$(awk '{print $1 ":" $2}' "$moved_dir/curl.log" | paste -sd, -) +[[ "$moved_sequence" == "GET:token" ]] || fail "moved head refusal performed post-move inspection/mutation (calls=$moved_sequence)" + +# Token resolution failure is not an authentication response. It must fail +# closed instead of borrowing a Basic credential under a different principal. +token_missing_dir=$(make_case token-missing) +set +e +token_missing_output=$(MOSAIC_TEST_TOKEN_AVAILABLE=false MOSAIC_TEST_BASIC_AVAILABLE=true \ + run_case "$token_missing_dir" single 2>&1) +token_missing_rc=$? +set -e +[[ "$token_missing_rc" -ne 0 ]] || fail "missing token unexpectedly borrowed Basic Auth" +[[ "$token_missing_output" == *"required Gitea token"* ]] || fail "missing token refusal lost its diagnostic" +[[ ! -e "$token_missing_dir/basic-resolution.log" ]] || fail "missing token resolved Basic Auth after identity failure" +[[ ! -e "$token_missing_dir/curl.log" ]] || fail "missing token reached a provider request" + +# A failed Basic resolver must never use its nonempty output or reach mutation. +basic_rc_dir=$(make_case basic-resolver-rc) +set +e +basic_rc_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_BASIC_RC=91 MOSAIC_TEST_FALLBACK_MODE=inspection \ + run_case "$basic_rc_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1) +basic_rc_rc=$? +set -e +[[ "$basic_rc_rc" -ne 0 ]] || fail "failed Basic resolver output unexpectedly authorized a merge: $basic_rc_output" +[[ ! -e "$basic_rc_dir/merge-payload.json" ]] || fail "failed Basic resolver reached the merge API" + +# HTTP 401 never changes principals: inspection rejection fails closed without +# resolving or attempting Basic Auth. +fallback_inspect_dir=$(make_case fallback-inspection) +set +e +fallback_inspect_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=inspection \ + run_case "$fallback_inspect_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1) +fallback_inspect_rc=$? +set -e +[[ "$fallback_inspect_rc" -ne 0 ]] || fail "inspection token rejection unexpectedly changed principals" +[[ "$fallback_inspect_output" == *"refusing cross-principal credential fallback"* ]] || fail "inspection token rejection lost its refusal diagnostic" +[[ ! -e "$fallback_inspect_dir/basic-resolution.log" ]] || fail "inspection token rejection resolved Basic Auth" +[[ ! -e "$fallback_inspect_dir/merge-payload.json" ]] || fail "inspection token rejection reached merge mutation" +inspect_sequence=$(awk '{print $1 ":" $2}' "$fallback_inspect_dir/curl.log" | paste -sd, -) +[[ "$inspect_sequence" == "GET:token" ]] || fail "inspection rejection made unexpected provider calls (calls=$inspect_sequence)" + +# Token rejection at merge likewise fails closed without cross-principal retry. +fallback_merge_dir=$(make_case fallback-merge) +set +e +fallback_merge_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=merge \ + run_case "$fallback_merge_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1) +fallback_merge_rc=$? +set -e +[[ "$fallback_merge_rc" -ne 0 ]] || fail "merge token rejection unexpectedly changed principals" +[[ "$fallback_merge_output" == *"refusing cross-principal credential fallback"* ]] || fail "merge token rejection lost its refusal diagnostic" +[[ ! -e "$fallback_merge_dir/basic-resolution.log" ]] || fail "merge token rejection resolved Basic Auth" +[[ ! -e "$fallback_merge_dir/merge-payload.json" ]] || fail "merge token rejection recorded a successful payload" +merge_sequence=$(awk '{print $1 ":" $2}' "$fallback_merge_dir/curl.log" | paste -sd, -) +[[ "$merge_sequence" == "GET:token,GET:token,POST:token" ]] || fail "merge rejection made unexpected provider calls (calls=$merge_sequence)" + +# BLOCK path: a commit email exists but author.login is null. It must name both +# facts, name the escalation principal, and never reach the merge endpoint. +null_dir=$(make_case null-login) +set +e +null_output=$(run_case "$null_dir" null-login --co-author-trailers --escalate-to tl-mosaic 2>&1) +null_rc=$? +set -e +[[ "$null_rc" -ne 0 ]] || fail "null-login author expected a non-zero BLOCK" +[[ "$null_output" == *"BLOCK"* ]] || fail "null-login author omitted BLOCK diagnostic" +[[ "$null_output" == *"author.login=NULL"* ]] || fail "null-login author omitted the null provider fact" +[[ "$null_output" == *"unresolved@example.test"* ]] || fail "null-login author omitted the commit email fact" +[[ "$null_output" == *'\n\x1b[31m'* ]] || fail "null-login author diagnostic did not escape control characters" +[[ "$null_output" != *$'\033'* ]] || fail "null-login author diagnostic emitted a raw terminal escape" +[[ "$(printf '%s\n' "$null_output" | wc -l)" -eq 1 ]] || fail "null-login author diagnostic permitted newline injection" +[[ "$null_output" == *"tl-mosaic"* ]] || fail "null-login author omitted the named escalation principal" +[[ ! -e "$null_dir/merge-payload.json" ]] || fail "null-login BLOCK still reached the merge API" + +# Every provider-derived field in alternate BLOCK diagnostics is log-safe too, +# including an invalid non-head SHA that contains control characters. +unsafe_dir=$(make_case unsafe-identity) +set +e +unsafe_output=$(run_case "$unsafe_dir" unsafe-identity --co-author-trailers --escalate-to tl-mosaic 2>&1) +unsafe_rc=$? +set -e +[[ "$unsafe_rc" -ne 0 ]] || fail "unsafe identity expected a non-zero BLOCK" +[[ "$unsafe_output" == *"unusable linked identity"* ]] || fail "unsafe identity omitted its BLOCK reason" +[[ "$unsafe_output" == *'\n\x1b[31m'* ]] || fail "unsafe identity SHA did not escape control characters" +[[ "$unsafe_output" != *$'\033'* ]] || fail "unsafe identity diagnostic emitted a raw terminal escape" +[[ "$(printf '%s\n' "$unsafe_output" | wc -l)" -eq 1 ]] || fail "unsafe identity diagnostic permitted newline injection" +[[ ! -e "$unsafe_dir/merge-payload.json" ]] || fail "unsafe identity BLOCK still reached the merge API" + +# The provider PR title cannot add an unchecked trailer outside the constructed +# message field: multi-line and trailer-shaped titles block before mutation. +title_dir=$(make_case title-injection) +set +e +title_output=$(MOSAIC_TEST_TITLE_MODE=injection \ + run_case "$title_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1) +title_rc=$? +set -e +[[ "$title_rc" -ne 0 ]] || fail "title trailer injection unexpectedly passed" +[[ "$title_output" == *"not one printable, non-trailer line"* ]] || fail "title injection refusal lost its diagnostic" +[[ ! -e "$title_dir/merge-payload.json" ]] || fail "title injection reached the merge API" + +# Provider failures remain diagnosable after their temporary response file is +# removed, but provider-controlled control characters stay log-safe. +error_dir=$(make_case provider-error) +set +e +error_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=provider-error \ + run_case "$error_dir" single 2>&1) +error_rc=$? +set -e +[[ "$error_rc" -ne 0 ]] || fail "provider error unexpectedly passed" +[[ "$error_output" == *"HTTP 409"* ]] || fail "provider error omitted the HTTP status" +[[ "$error_output" == *"branch policy rejected"* ]] || fail "provider error response was discarded" +[[ "$error_output" == *'\n\x1b[31m'* ]] || fail "provider error response did not escape control characters" +[[ "$error_output" != *$'\033'* ]] || fail "provider error response emitted a raw terminal escape" +[[ "$error_output" != *"Basic Auth fallback"* ]] || fail "provider error advertised removed Basic Auth fallback" +[[ ! -e "$error_dir/basic-resolution.log" ]] || fail "HTTP 409 policy denial incorrectly triggered Basic Auth fallback" + +# Authorization denials likewise fail closed instead of changing principals. +forbidden_dir=$(make_case forbidden) +set +e +forbidden_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=forbidden \ + run_case "$forbidden_dir" single 2>&1) +forbidden_rc=$? +set -e +[[ "$forbidden_rc" -ne 0 ]] || fail "HTTP 403 authorization denial unexpectedly passed" +[[ "$forbidden_output" == *"HTTP 403"* ]] || fail "authorization denial omitted the HTTP status" +[[ "$forbidden_output" != *"Basic Auth fallback"* ]] || fail "authorization denial advertised removed Basic Auth fallback" +[[ ! -e "$forbidden_dir/basic-resolution.log" ]] || fail "HTTP 403 authorization denial incorrectly triggered Basic Auth fallback" + +# The BLOCK destination cannot be generic or inferred after failure: opting in +# without a named principal is refused before any provider operation. +principal_dir=$(make_case missing-principal) +set +e +principal_output=$(run_case "$principal_dir" verified --co-author-trailers 2>&1) +principal_rc=$? +set -e +[[ "$principal_rc" -ne 0 ]] || fail "co-author mode without a named principal unexpectedly passed" +[[ "$principal_output" == *"requires --escalate-to with a named principal"* ]] || fail "missing-principal refusal lost its diagnostic" +[[ ! -e "$principal_dir/merge-payload.json" ]] || fail "missing-principal refusal reached the merge API" + +# A trailing value-taking option receives a stable CLI diagnostic instead of a +# set -u unbound-variable crash. +value_dir=$(make_case missing-principal-value) +set +e +value_output=$(run_case "$value_dir" verified --co-author-trailers --escalate-to 2>&1) +value_rc=$? +set -e +[[ "$value_rc" -ne 0 ]] || fail "missing --escalate-to value unexpectedly passed" +[[ "$value_output" == *"--escalate-to requires one principal name"* ]] || fail "missing --escalate-to value lost its diagnostic" +[[ "$value_output" != *"unbound variable"* ]] || fail "missing --escalate-to value crashed under set -u" +[[ ! -e "$value_dir/merge-payload.json" ]] || fail "missing --escalate-to value reached the merge API" + +# Negative control: ordinary single-author merge remains byte-for-byte payload +# compatible and hardcoded to squash, with no optional message fields. +single_dir=$(make_case single) +set +e +single_output=$(run_case "$single_dir" single 2>&1) +single_rc=$? +set -e +if [[ "$single_rc" -ne 0 ]]; then + fail "ordinary single-author merge expected rc=0, got rc=$single_rc: $single_output" +elif [[ ! -s "$single_dir/merge-payload.json" ]]; then + fail "ordinary single-author merge did not reach the API payload" +else + python3 - "$single_dir/merge-payload.json" <<'PY' || fail "ordinary single-author payload changed" +import json +import sys +payload = json.load(open(sys.argv[1], encoding="utf-8")) +assert payload == { + "Do": "squash", + "head_commit_id": "1111111111111111111111111111111111111111", +}, payload +PY +fi +[[ -e "$single_dir/auth-via-config" ]] || fail "ordinary path did not authenticate curl through stdin config" +[[ ! -e "$single_dir/token-in-argv" ]] || fail "ordinary path placed the Gitea token in curl argv" +[[ "$(wc -l < "$single_dir/token-resolution.log")" -eq 1 ]] || fail "ordinary path did not use exactly one credential resolution" + +# Squash is not defaultable: an explicit non-squash method must remain refused. +method_dir=$(make_case method-refusal) +set +e +method_output=$(run_case "$method_dir" single -m merge 2>&1) +method_rc=$? +set -e +[[ "$method_rc" -ne 0 ]] || fail "non-squash method unexpectedly passed" +[[ "$method_output" == *"enforces squash merge only"* ]] || fail "non-squash refusal lost its policy diagnostic" +[[ ! -e "$method_dir/merge-payload.json" ]] || fail "non-squash refusal reached the merge API" + +if [[ "$failures" -ne 0 ]]; then + echo "pr-merge message-field regression failed ($failures assertions)" >&2 + exit 1 +fi + +echo "pr-merge message-field regression passed (verified, BLOCK, and unchanged squash control)" diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 5daa5d06..23234ef3 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -25,7 +25,7 @@ "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell", - "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 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 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh" + "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && 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 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh" }, "dependencies": { "@mosaicstack/brain": "workspace:*",