Compare commits
25 Commits
docs/758-l
...
fix/865-te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac7e70f0d | ||
|
|
2d821324fe | ||
|
|
0905cdc292 | ||
|
|
acf7955f87 | ||
|
|
6053e1ee4c | ||
|
|
044744339b | ||
|
|
4822291707 | ||
|
|
3012b5c5e5 | ||
|
|
99856c5567 | ||
|
|
2bb3ac4549 | ||
|
|
6168f9ac86 | ||
|
|
9384f0bc0a | ||
|
|
16481ece3d | ||
|
|
10fdd49e32 | ||
|
|
a27f1fa7df | ||
| 4e5af23214 | |||
|
|
880c28b191 | ||
|
|
7bc2dfb6c8 | ||
| b0d78d8632 | |||
| 344d86a635 | |||
| acd7d380f6 | |||
| 3b70c66c07 | |||
| 11d2818453 | |||
| aa999daf1b | |||
| 77c9a82614 |
@@ -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-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-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-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 squash `627cf2bb`; exact-head RoR (head `a39bafb8`) and PR/main terminal-green CI 1907; accepted fleet documentation IA, how-to/operations/migration references, and link/example validation delivered |
|
| 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-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 |
|
| 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
|
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
||||||
|
|||||||
58
docs/scratchpads/812-pr-review-comment.md
Normal file
58
docs/scratchpads/812-pr-review-comment.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# 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.
|
||||||
35
packages/mosaic/framework/tools/git/README.md
Normal file
35
packages/mosaic/framework/tools/git/README.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# 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.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,26 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea
|
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea
|
||||||
# Usage: issue-comment.sh -i <issue_number> -c <comment>
|
# 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.
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -10,6 +30,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
# Parse arguments
|
# Parse arguments
|
||||||
ISSUE_NUMBER=""
|
ISSUE_NUMBER=""
|
||||||
COMMENT=""
|
COMMENT=""
|
||||||
|
LOGIN_OVERRIDE=""
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
@@ -21,12 +42,17 @@ while [[ $# -gt 0 ]]; do
|
|||||||
COMMENT="$2"
|
COMMENT="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
-l|--login)
|
||||||
|
LOGIN_OVERRIDE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
echo "Usage: issue-comment.sh -i <issue_number> -c <comment>"
|
echo "Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " -i, --issue Issue number (required)"
|
echo " -i, --issue Issue number (required)"
|
||||||
echo " -c, --comment Comment text (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"
|
echo " -h, --help Show this help"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
@@ -49,20 +75,273 @@ fi
|
|||||||
|
|
||||||
detect_platform >/dev/null
|
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
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||||
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
# Build the invocation as an argv array (not unquoted $(get_gitea_repo_args)
|
# Resolve the login this comment should be attributed to: the --login
|
||||||
# word-splitting) so the comment body — including Markdown backticks, $(...),
|
# override when given, otherwise the detected default for this repo's host.
|
||||||
# and quotes — is passed verbatim and never re-split or shell-evaluated.
|
# A --login override always wins. Otherwise name this repo host's login only
|
||||||
REPO_SLUG=$(get_repo_slug)
|
# as a best effort: the login name merely selects a per-login token, and
|
||||||
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
# gitea_resolve_api_for_login falls back to the host credential
|
||||||
echo "Error: could not resolve a Gitea login for this repo; cannot comment on issue #$ISSUE_NUMBER." >&2
|
# (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
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME"
|
echo "Added and verified comment on Gitea issue #$ISSUE_NUMBER (comment ID $comment_id)"
|
||||||
echo "Added comment to Gitea issue #$ISSUE_NUMBER"
|
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform"
|
echo "Error: Unknown platform"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -1,16 +1,33 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pr-review.sh - Review a pull request on GitHub or Gitea
|
# pr-review.sh - Review a pull request on GitHub or Gitea
|
||||||
# Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>]
|
# 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.
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh
|
||||||
source "$SCRIPT_DIR/detect-platform.sh"
|
source "$SCRIPT_DIR/detect-platform.sh"
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
PR_NUMBER=""
|
PR_NUMBER=""
|
||||||
ACTION=""
|
ACTION=""
|
||||||
COMMENT=""
|
COMMENT=""
|
||||||
|
LOGIN_OVERRIDE=""
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
@@ -26,13 +43,18 @@ while [[ $# -gt 0 ]]; do
|
|||||||
COMMENT="$2"
|
COMMENT="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
-l|--login)
|
||||||
|
LOGIN_OVERRIDE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>]"
|
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " -n, --number PR number (required)"
|
echo " -n, --number PR number (required)"
|
||||||
echo " -a, --action Review action: approve, request-changes, comment (required)"
|
echo " -a, --action Review action: approve, request-changes, comment (required)"
|
||||||
echo " -c, --comment Review comment (required for request-changes)"
|
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"
|
echo " -h, --help Show this help"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
@@ -55,6 +77,448 @@ fi
|
|||||||
|
|
||||||
detect_platform >/dev/null
|
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
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
case $ACTION in
|
case $ACTION in
|
||||||
approve)
|
approve)
|
||||||
@@ -85,24 +549,77 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
case $ACTION in
|
case $ACTION in
|
||||||
approve)
|
approve)
|
||||||
tea pr approve "$PR_NUMBER" $(get_gitea_repo_args) ${COMMENT:+--comment "$COMMENT"}
|
host=$(get_remote_host)
|
||||||
echo "Approved Gitea PR #$PR_NUMBER"
|
# 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)"
|
||||||
;;
|
;;
|
||||||
request-changes)
|
request-changes)
|
||||||
if [[ -z "$COMMENT" ]]; then
|
if [[ -z "$COMMENT" ]]; then
|
||||||
echo "Error: Comment required for request-changes"
|
echo "Error: Comment required for request-changes"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
tea pr reject "$PR_NUMBER" $(get_gitea_repo_args) --comment "$COMMENT"
|
host=$(get_remote_host)
|
||||||
echo "Requested changes on Gitea PR #$PR_NUMBER"
|
# 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)"
|
||||||
;;
|
;;
|
||||||
comment)
|
comment)
|
||||||
if [[ -z "$COMMENT" ]]; then
|
if [[ -z "$COMMENT" ]]; then
|
||||||
echo "Error: Comment required"
|
echo "Error: Comment required"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
host=$(get_remote_host)
|
||||||
echo "Added comment to Gitea PR #$PR_NUMBER"
|
# 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)"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Error: Unknown action: $ACTION"
|
echo "Error: Unknown action: $ACTION"
|
||||||
|
|||||||
@@ -312,4 +312,901 @@ if [[ "$override_wins" != "mosaicstack" ]]; then
|
|||||||
fi
|
fi
|
||||||
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
|
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
|
||||||
|
|
||||||
|
# 18. CONTROL-CHARACTER FAIL-CLOSE (#865 round-9 blocker 1): PyYAML's Reader
|
||||||
|
# scans the ENTIRE raw document stream (not merely scalar contents, and NOT
|
||||||
|
# scoped by quoting) for bytes outside its printable set and raises
|
||||||
|
# ReaderError -- a WHOLE-DOCUMENT reject -- the instant one is found,
|
||||||
|
# regardless of where it sits: an unrelated field's plain scalar, inside a
|
||||||
|
# double- or single-quoted scalar, or a comment. Verified empirically against
|
||||||
|
# real installed PyYAML 6.0.3 (see detect-platform.sh's _FORBIDDEN_CONTROL
|
||||||
|
# comment): every C0 control byte {0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F} plus DEL
|
||||||
|
# (0x7F) rejects in ALL THREE contexts (plain / double-quoted / single-quoted);
|
||||||
|
# only TAB(0x09), LF(0x0A), CR(0x0D) are accepted among the low byte range
|
||||||
|
# (TAB has its own narrower, position-aware coverage in section 17 above; LF/CR
|
||||||
|
# are line separators). A prior fallback ONLY guarded tabs and emitted the
|
||||||
|
# login token from documents PyYAML rejects over an UNRELATED field's control
|
||||||
|
# byte -- a dangerous fail-open (credential emission from a document PyYAML
|
||||||
|
# refuses). write_control_char_fixture writes the raw byte directly via
|
||||||
|
# printf's octal escape (never through a bash string/variable, which cannot
|
||||||
|
# hold an embedded NUL) so 0x00 is exercised faithfully alongside the rest.
|
||||||
|
write_control_char_fixture() {
|
||||||
|
local octal="$1" quote="${2:-}"
|
||||||
|
{
|
||||||
|
if [[ -n "$quote" ]]; then
|
||||||
|
printf 'bad: %sx' "$quote"
|
||||||
|
# shellcheck disable=SC2059 # deliberate: $octal supplies printf's
|
||||||
|
# own \NNN octal escape so the raw control byte reaches the file
|
||||||
|
# directly, never passing through a bash string (which truncates
|
||||||
|
# at an embedded NUL and so cannot represent byte 0x00 otherwise).
|
||||||
|
printf "\\${octal}"
|
||||||
|
printf 'y%s\n' "$quote"
|
||||||
|
else
|
||||||
|
printf 'bad: x'
|
||||||
|
# shellcheck disable=SC2059 # deliberate: $octal supplies printf's
|
||||||
|
# own \NNN octal escape so the raw control byte reaches the file
|
||||||
|
# directly, never passing through a bash string (which truncates
|
||||||
|
# at an embedded NUL and so cannot represent byte 0x00 otherwise).
|
||||||
|
printf "\\${octal}"
|
||||||
|
printf 'y\n'
|
||||||
|
fi
|
||||||
|
printf 'logins:\n - name: primary\n url: https://git.example\n token: TOK_PLAIN\n'
|
||||||
|
} > "$FIXTURE_XDG/tea/config.yml"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Plain (unquoted) unrelated-field placement: the full empirically-confirmed
|
||||||
|
# forbidden C0/DEL set.
|
||||||
|
for octal in 000 001 002 003 004 005 006 007 010 013 014 \
|
||||||
|
016 017 020 021 022 023 024 025 026 027 \
|
||||||
|
030 031 032 033 034 035 036 037 177; do
|
||||||
|
write_control_char_fixture "$octal"
|
||||||
|
assert_both_fail_closed "control byte \\$octal in unrelated plain field fails closed" primary git.example
|
||||||
|
done
|
||||||
|
|
||||||
|
# Inside-quote variants (double and single) for the six bytes called out
|
||||||
|
# explicitly in the round-9 blocker report: 0x00,0x01,0x07,0x0e,0x1f,0x7f.
|
||||||
|
for octal in 000 001 007 016 037 177; do
|
||||||
|
write_control_char_fixture "$octal" '"'
|
||||||
|
assert_both_fail_closed "control byte \\$octal inside double-quoted unrelated field fails closed" primary git.example
|
||||||
|
write_control_char_fixture "$octal" "'"
|
||||||
|
assert_both_fail_closed "control byte \\$octal inside single-quoted unrelated field fails closed" primary git.example
|
||||||
|
done
|
||||||
|
|
||||||
|
# Same forbidden byte inside a comment line -- PyYAML's Reader check is
|
||||||
|
# stream-wide, so it rejects here too, not merely inside live scalar content.
|
||||||
|
{
|
||||||
|
printf '# note'
|
||||||
|
printf '\007'
|
||||||
|
printf 'here\nlogins:\n - name: primary\n url: https://git.example\n token: TOK_PLAIN\n'
|
||||||
|
} > "$FIXTURE_XDG/tea/config.yml"
|
||||||
|
assert_both_fail_closed "control byte in a comment line fails closed" primary git.example
|
||||||
|
|
||||||
|
# NOT over-broad: TAB/LF/CR remain accepted where PyYAML already accepts them
|
||||||
|
# (covered by section 17's tab fixtures and the ordinary newline-delimited
|
||||||
|
# fixtures used throughout this file), so no additional assertion is needed
|
||||||
|
# here beyond confirming the forbidden-control guard does not fire on them.
|
||||||
|
|
||||||
|
# 19. UNSIGNED-EXPONENT FLOAT OVER-REJECTION (#865 round-9 blocker 2): PyYAML
|
||||||
|
# 6.0.3's implicit float resolver requires an EXPLICIT SIGN on the exponent
|
||||||
|
# ([eE][-+][0-9]+); an unsigned exponent is NOT matched, so PyYAML resolves
|
||||||
|
# the scalar as a plain STRING, not a float. A prior fallback's float
|
||||||
|
# recognizer accepted an OPTIONAL sign ([eE][-+]?[0-9]+), over-matching these
|
||||||
|
# spellings as floats and dropping the token PyYAML would emit verbatim
|
||||||
|
# (over-rejection). Verified empirically against real PyYAML 6.0.3.
|
||||||
|
for form in '1.0e10' '+1.0e10' '-1.0e10' '1.0E10' '.5e10' '4.e8'; do
|
||||||
|
write_fixture "logins:
|
||||||
|
- name: primary
|
||||||
|
url: https://git.example
|
||||||
|
token: ${form}
|
||||||
|
"
|
||||||
|
assert_token "unsigned-exponent form '$form' is a PyYAML string, token resolves" "$form" primary git.example
|
||||||
|
done
|
||||||
|
|
||||||
|
# Parity guard: a genuine SIGNED-exponent float is still typed as a non-string
|
||||||
|
# float by PyYAML and must still fail closed (not regress into over-acceptance).
|
||||||
|
write_fixture 'logins:
|
||||||
|
- name: primary
|
||||||
|
url: https://git.example
|
||||||
|
token: 1.0e+10
|
||||||
|
'
|
||||||
|
assert_token "signed-exponent genuine float still fails closed" "" primary git.example
|
||||||
|
write_fixture 'logins:
|
||||||
|
- name: primary
|
||||||
|
url: https://git.example
|
||||||
|
token: 1.0e-10
|
||||||
|
'
|
||||||
|
assert_token "signed-exponent (negative) genuine float still fails closed" "" primary git.example
|
||||||
|
|
||||||
|
# 20. RESIDUAL OVER-REJECTION found via round-9 differential fuzzing (folded into
|
||||||
|
# this round, not split off): a plain scalar starting with "?" NOT followed by
|
||||||
|
# whitespace (e.g. "?x") is a valid PyYAML string -- only a bare "?" or "? "
|
||||||
|
# (question mark followed by space/EOL) opens a complex mapping key and is
|
||||||
|
# illegal in a value position. A prior blanket-reject set treated EVERY
|
||||||
|
# leading "?" as illegal, over-rejecting a token PyYAML accepts verbatim.
|
||||||
|
write_fixture 'logins:
|
||||||
|
- name: primary
|
||||||
|
url: https://git.example
|
||||||
|
token: ?x
|
||||||
|
'
|
||||||
|
assert_token "question-mark-not-space is a plain string, token resolves" "?x" primary git.example
|
||||||
|
|
||||||
|
# 21. PRINTABLE-BOUNDARY FAIL-CLOSE (#865 round-10 blocker 1): the round-9 guard
|
||||||
|
# used a hand-rolled C0/DEL subset that MISSED code points PyYAML's Reader
|
||||||
|
# also rejects -- the C1 block (U+0080-0084, U+0086-009F) and the BMP
|
||||||
|
# noncharacters U+FFFE/U+FFFF -- so the fallback still emitted the token from
|
||||||
|
# documents PyYAML rejects whole (fail-open). The guard now uses PyYAML
|
||||||
|
# 6.0.3's EXACT Reader.NON_PRINTABLE character class (see detect-platform.sh
|
||||||
|
# _FORBIDDEN_CONTROL). Verified empirically against real PyYAML 6.0.3:
|
||||||
|
# PRINTABLE = {0x09,0x0A,0x0D, 0x20-0x7E, 0x85(NEL), 0xA0-0xD7FF,
|
||||||
|
# 0xE000-0xFFFD, 0x10000-0x10FFFF}; everything else fails the whole document
|
||||||
|
# closed. write_codepoint_fixture emits a chosen Unicode code point's real
|
||||||
|
# UTF-8 bytes (via python3, since bash strings cannot faithfully carry many
|
||||||
|
# of these) into a selectable position, then the login block follows.
|
||||||
|
write_codepoint_fixture() {
|
||||||
|
# $1 = hex code point (e.g. 0x80); $2 = position: field|comment|token|nelterm
|
||||||
|
CP_HEX="$1" CP_POS="$2" python3 - "$FIXTURE_XDG/tea/config.yml" <<'PY'
|
||||||
|
import sys
|
||||||
|
cp = int(__import__("os").environ["CP_HEX"], 16)
|
||||||
|
pos = __import__("os").environ["CP_POS"]
|
||||||
|
ch = chr(cp)
|
||||||
|
head = "logins:\n - name: primary\n url: https://git.example\n token: TOK_PLAIN\n"
|
||||||
|
if pos == "field":
|
||||||
|
doc = head + "other: x" + ch + "y\n"
|
||||||
|
elif pos == "comment":
|
||||||
|
doc = head + "# note x" + ch + "y here\n"
|
||||||
|
elif pos == "token":
|
||||||
|
doc = "logins:\n - name: primary\n url: https://git.example\n token: T" + ch + "K\n"
|
||||||
|
elif pos == "nelterm":
|
||||||
|
# NEL (U+0085) used as the line terminator throughout: PyYAML treats it as a
|
||||||
|
# line break (printable, NOT a ReaderError) and resolves the token; the
|
||||||
|
# fallback's _split_logical_lines splits on NEL identically OUTSIDE a quote
|
||||||
|
# -> parity, token resolves. (Round 23 covers NEL/LS/PS INSIDE a quote, where
|
||||||
|
# PyYAML folds rather than breaks and a naive splitlines() would over-split.)
|
||||||
|
doc = ("logins:" + ch + " - name: primary" + ch
|
||||||
|
+ " url: https://git.example" + ch + " token: TOK_NEL" + ch)
|
||||||
|
else:
|
||||||
|
raise SystemExit("bad pos")
|
||||||
|
with open(sys.argv[1], "w", encoding="utf-8") as f:
|
||||||
|
f.write(doc)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
# C1-block + BMP-noncharacter code points fail the WHOLE document closed in an
|
||||||
|
# unrelated field and in a comment, exactly as PyYAML's ReaderError does.
|
||||||
|
for cphex in 0x80 0x81 0x84 0x86 0x9f 0xfffe 0xffff; do
|
||||||
|
write_codepoint_fixture "$cphex" field
|
||||||
|
assert_both_fail_closed "code point $cphex in unrelated field fails closed" primary git.example
|
||||||
|
write_codepoint_fixture "$cphex" comment
|
||||||
|
assert_both_fail_closed "code point $cphex in a comment fails closed" primary git.example
|
||||||
|
done
|
||||||
|
|
||||||
|
# NOT over-broad: printable code points PyYAML ACCEPTS must still resolve the
|
||||||
|
# token in BOTH paths -- NEL(0x85) as a line separator, U+00A0 (NBSP) inside a
|
||||||
|
# value, and an astral code point (U+1F600) inside the token value.
|
||||||
|
write_codepoint_fixture 0x85 nelterm
|
||||||
|
assert_token "NEL (U+0085) line-terminator resolves token" "TOK_NEL" primary git.example
|
||||||
|
write_codepoint_fixture 0xa0 field
|
||||||
|
assert_token "U+00A0 in unrelated value still resolves token" "TOK_PLAIN" primary git.example
|
||||||
|
write_codepoint_fixture 0x1f600 token
|
||||||
|
assert_token "astral U+1F600 inside token resolves verbatim" "$(printf 'T\360\237\230\200K')" primary git.example
|
||||||
|
|
||||||
|
# 22. INTERNAL-INDICATOR OVER-REJECTION (#865 round-10 blocker 2): the round-9
|
||||||
|
# recognizer blanket-rejected any plain scalar CONTAINING a flow indicator
|
||||||
|
# ([]{}*&!), but in BLOCK context PyYAML treats ',[]{}' as ordinary content
|
||||||
|
# and treats '!&*#...' as significant ONLY at the FIRST non-space char. So an
|
||||||
|
# INTERNAL indicator is legal plain-string content and PyYAML emits the token
|
||||||
|
# verbatim; the fallback dropped it (over-rejection). Verified empirically
|
||||||
|
# against real PyYAML 6.0.3. The fix removes the blanket internal scan while
|
||||||
|
# the leading-char guard and the ' #'/': '/trailing-':' guards keep the
|
||||||
|
# fail-OPEN direction shut.
|
||||||
|
for tv in 'a!b' 'a,b' 'a[b' 'a]b' 'a{b' 'a}b' 'a&b' 'a*b' 'a[b]c' 'a{b}c' 'a,b,c' 'a#b' 'a:b'; do
|
||||||
|
write_fixture "logins:
|
||||||
|
- name: primary
|
||||||
|
url: https://git.example
|
||||||
|
token: ${tv}
|
||||||
|
"
|
||||||
|
assert_token "internal-indicator token '$tv' resolves verbatim" "$tv" primary git.example
|
||||||
|
done
|
||||||
|
|
||||||
|
# Fail-OPEN direction stays shut: a LEADING indicator, a ' #' comment tail, an
|
||||||
|
# internal ': ' (colon-space) inline map, and a trailing ':' each make PyYAML
|
||||||
|
# resolve NO usable string token (tag/flow/anchor reject or None, comment strip,
|
||||||
|
# or a mapping), so BOTH must fail closed. (Leading tag/anchor/flow are the
|
||||||
|
# documented, round-9-approved structural fail-closed class; kept intact here.)
|
||||||
|
write_fixture 'logins:
|
||||||
|
- name: primary
|
||||||
|
url: https://git.example
|
||||||
|
token: !x
|
||||||
|
'
|
||||||
|
assert_both_fail_closed "leading '!' tag token fails closed" primary git.example
|
||||||
|
write_fixture 'logins:
|
||||||
|
- name: primary
|
||||||
|
url: https://git.example
|
||||||
|
token: [a]
|
||||||
|
'
|
||||||
|
assert_both_fail_closed "leading '[' flow-seq token fails closed" primary git.example
|
||||||
|
write_fixture 'logins:
|
||||||
|
- name: primary
|
||||||
|
url: https://git.example
|
||||||
|
token: a # trailing comment
|
||||||
|
'
|
||||||
|
# ' #' comment tail: PyYAML strips the comment -> token is the string 'a', which
|
||||||
|
# still resolves. This is the NOT-over-broad boundary partner of the guard.
|
||||||
|
assert_token "space-hash comment tail strips to plain token" "a" primary git.example
|
||||||
|
write_fixture 'logins:
|
||||||
|
- name: primary
|
||||||
|
url: https://git.example
|
||||||
|
token: a: b
|
||||||
|
'
|
||||||
|
assert_both_fail_closed "internal colon-space (inline map) token fails closed" primary git.example
|
||||||
|
write_fixture 'logins:
|
||||||
|
- name: primary
|
||||||
|
url: https://git.example
|
||||||
|
token: ab:
|
||||||
|
'
|
||||||
|
assert_both_fail_closed "trailing colon (map indicator) token fails closed" primary git.example
|
||||||
|
|
||||||
|
# 23. EMBEDDED LINE-BREAK INSIDE A QUOTED SCALAR (#865 round-11 blocker A): the
|
||||||
|
# round-10 fallback split the raw document with str.splitlines(), which breaks
|
||||||
|
# at NEL(U+0085), LS(U+2028) and PS(U+2029) -- code points that are PRINTABLE
|
||||||
|
# to PyYAML's Reader. Inside a flow (quoted) scalar PyYAML does NOT break at
|
||||||
|
# these: it LINE-FOLDS a double/single-quoted scalar (NEL/LF/CR -> a single
|
||||||
|
# space; LS/PS -> the char verbatim), so it resolves ONE token, while
|
||||||
|
# splitlines() cut the value mid-quote and failed the whole document closed
|
||||||
|
# (over-rejection). The fallback now uses _split_logical_lines, which
|
||||||
|
# reproduces PyYAML's flow-folding. Verified empirically vs real PyYAML 6.0.3.
|
||||||
|
# write_break_fixture emits a chosen break code point in a selectable context.
|
||||||
|
write_break_fixture() {
|
||||||
|
# $1 = hex code point of the break; $2 = context: dq|sq|plain|comment|dq2
|
||||||
|
CP_HEX="$1" Q_STYLE="$2" python3 - "$FIXTURE_XDG/tea/config.yml" <<'PY'
|
||||||
|
import sys, os
|
||||||
|
cp = int(os.environ["CP_HEX"], 16)
|
||||||
|
q = os.environ["Q_STYLE"]
|
||||||
|
ch = chr(cp)
|
||||||
|
head = "logins:\n - name: primary\n url: https://git.example\n token: "
|
||||||
|
if q == "dq":
|
||||||
|
doc = head + '"tok' + ch + 'en"'
|
||||||
|
elif q == "sq":
|
||||||
|
doc = head + "'tok" + ch + "en'"
|
||||||
|
elif q == "plain":
|
||||||
|
doc = head + "tok" + ch + "en"
|
||||||
|
elif q == "dq2":
|
||||||
|
# blank line inside a quoted scalar: PyYAML folds a two-break run to a literal
|
||||||
|
# newline, which the recognizer's key regex cannot carry -> endorsed
|
||||||
|
# fail-closed over-reject (see assert_fallback_fails_closed below).
|
||||||
|
doc = head + '"tok' + ch + ch + 'en"'
|
||||||
|
elif q == "comment":
|
||||||
|
doc = ("logins:\n - name: primary\n url: https://git.example\n"
|
||||||
|
" token: TOK_PLAIN\n# c" + ch + "x")
|
||||||
|
else:
|
||||||
|
raise SystemExit("bad q")
|
||||||
|
with open(sys.argv[1], "w", encoding="utf-8") as f:
|
||||||
|
f.write(doc + "\n")
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
# NEL folds to a single space inside double- AND single-quoted scalars: the token
|
||||||
|
# resolves identically in both paths (fallback no longer over-splits).
|
||||||
|
write_break_fixture 0x85 dq
|
||||||
|
assert_token "NEL inside double-quote folds to space, token resolves" "tok en" primary git.example
|
||||||
|
write_break_fixture 0x85 sq
|
||||||
|
assert_token "NEL inside single-quote folds to space, token resolves" "tok en" primary git.example
|
||||||
|
# LS(U+2028)/PS(U+2029) are preserved VERBATIM by PyYAML's flow fold (they are
|
||||||
|
# not \n-class breaks); the fallback must surface them byte-for-byte.
|
||||||
|
write_break_fixture 0x2028 dq
|
||||||
|
assert_token "LS inside double-quote is verbatim" "$(printf 'tok\342\200\250en')" primary git.example
|
||||||
|
write_break_fixture 0x2028 sq
|
||||||
|
assert_token "LS inside single-quote is verbatim" "$(printf 'tok\342\200\250en')" primary git.example
|
||||||
|
write_break_fixture 0x2029 dq
|
||||||
|
assert_token "PS inside double-quote is verbatim" "$(printf 'tok\342\200\251en')" primary git.example
|
||||||
|
|
||||||
|
# Direction-sensitivity: the SAME code points UNQUOTED (a plain scalar) or in a
|
||||||
|
# COMMENT make PyYAML raise a scanner error, so both paths must fail closed. The
|
||||||
|
# fold rule applies ONLY inside a quoted scalar.
|
||||||
|
write_break_fixture 0x85 plain
|
||||||
|
assert_token "NEL in an unquoted plain scalar fails closed" "" primary git.example
|
||||||
|
write_break_fixture 0x2028 plain
|
||||||
|
assert_token "LS in an unquoted plain scalar fails closed" "" primary git.example
|
||||||
|
write_break_fixture 0x85 comment
|
||||||
|
assert_token "NEL in a comment fails closed" "" primary git.example
|
||||||
|
|
||||||
|
# Endorsed fail-closed over-reject: a blank line inside a quoted scalar folds to a
|
||||||
|
# literal newline that the recognizer cannot carry -- PyYAML resolves a
|
||||||
|
# (newline-bearing) token, the fallback fails closed (strictly safer).
|
||||||
|
write_break_fixture 0x85 dq2
|
||||||
|
assert_fallback_fails_closed "blank-line-in-quote (NEL run) fails closed" primary git.example
|
||||||
|
|
||||||
|
# 24. LEADING NON-SPECIFIC TAG / ANCHOR PROPERTY (#865 round-11 blocker B, revised
|
||||||
|
# in round 12): the round-10 recognizer blanket-rejected any scalar beginning
|
||||||
|
# with '!' or '&'. Round 11 taught it to strip a transparent NON-SPECIFIC tag
|
||||||
|
# ('! ' bang + SPACE) and a transparent plain ANCHOR ('&name ') so '! x' /
|
||||||
|
# '&a x' resolve the STRING 'x', matching PyYAML. Round 12 discovered that the
|
||||||
|
# anchor half of that was a HIGH fail-open: PyYAML's Composer tracks anchor
|
||||||
|
# NAMES in a document-scoped registry and raises ComposerError ("found
|
||||||
|
# duplicate anchor") the instant the SAME name is declared on a SECOND node
|
||||||
|
# ANYWHERE in the document (even an unrelated one) -- the fallback's
|
||||||
|
# per-scalar-only view has no such registry and would emit the later token.
|
||||||
|
# Round 12's fix: reject EVERY '&'-anchor property, unconditionally. The
|
||||||
|
# transparent NON-SPECIFIC TAG behavior ('! x' -> 'x') is unchanged and still
|
||||||
|
# verified below; only the anchor half now fails closed (deliberate
|
||||||
|
# conservative over-reject, verified safe both ways against real PyYAML 6.0.3).
|
||||||
|
for pair in '! x=x' '! x y=x y' '! "q"=q' "! 'q'=q"; do
|
||||||
|
tv="${pair%%=*}"; want="${pair#*=}"
|
||||||
|
write_fixture "logins:
|
||||||
|
- name: primary
|
||||||
|
url: https://git.example
|
||||||
|
token: ${tv}
|
||||||
|
"
|
||||||
|
assert_token "tag property token '$tv' resolves node string" "$want" primary git.example
|
||||||
|
done
|
||||||
|
|
||||||
|
# Fail-OPEN direction stays shut. '!x' (bang + NON-space) is a tag HANDLE ->
|
||||||
|
# ConstructorError; '!foo x'/'* a'/'! !x' raise; a property over a NON-string node
|
||||||
|
# ('! 123'/'! true'/'! null') types non-str -> no usable token. All fail closed in
|
||||||
|
# BOTH paths.
|
||||||
|
for tv in '!x' '!foo x' '* a' '! !x' '! 123' '! true' '! null' '&a &b x'; do
|
||||||
|
write_fixture "logins:
|
||||||
|
- name: primary
|
||||||
|
url: https://git.example
|
||||||
|
token: ${tv}
|
||||||
|
"
|
||||||
|
assert_token "non-resolving property token '$tv' fails closed" "" primary git.example
|
||||||
|
done
|
||||||
|
|
||||||
|
# Round 12: a SINGLE, non-duplicated '&a x' is valid YAML that real PyYAML
|
||||||
|
# resolves to the string 'x' (round-11 behavior, and still true of the oracle).
|
||||||
|
# The fallback now rejects it anyway -- a deliberate, endorsed CONSERVATIVE
|
||||||
|
# over-reject (see the round-12 comment block above): fail-closed can only cost
|
||||||
|
# an emitted token PyYAML would have allowed, never emit one PyYAML rejects, and
|
||||||
|
# a per-scalar recognizer cannot safely prove document-wide anchor-name
|
||||||
|
# uniqueness. assert_fallback_fails_closed also confirms PyYAML really does
|
||||||
|
# resolve a token here, proving this is a genuine (safe-direction) divergence
|
||||||
|
# and not an accidental parity loss.
|
||||||
|
write_fixture 'logins:
|
||||||
|
- name: primary
|
||||||
|
url: https://git.example
|
||||||
|
token: &a x
|
||||||
|
'
|
||||||
|
assert_fallback_fails_closed "round-12: single non-duplicated anchor '&a x' now fails closed (conservative over-reject; PyYAML resolves x)" primary git.example
|
||||||
|
# Same over-reject for the combined tag+anchor forms round 11 used to resolve.
|
||||||
|
write_fixture 'logins:
|
||||||
|
- name: primary
|
||||||
|
url: https://git.example
|
||||||
|
token: ! &b x
|
||||||
|
'
|
||||||
|
assert_fallback_fails_closed "round-12: '! &b x' (tag+anchor) now fails closed (conservative over-reject)" primary git.example
|
||||||
|
write_fixture 'logins:
|
||||||
|
- name: primary
|
||||||
|
url: https://git.example
|
||||||
|
token: &b ! x
|
||||||
|
'
|
||||||
|
assert_fallback_fails_closed "round-12: '&b ! x' (anchor+tag) now fails closed (conservative over-reject)" primary git.example
|
||||||
|
|
||||||
|
# Endorsed fail-closed over-reject: an EXPLICIT tag ('!!str x', verbose
|
||||||
|
# '!<tag:yaml.org,2002:str> x') forces a string PyYAML resolves, but the fallback
|
||||||
|
# recognizes only the transparent non-specific tag and fails closed (safer).
|
||||||
|
for tv in '!!str x' '!<tag:yaml.org,2002:str> x'; do
|
||||||
|
write_fixture "logins:
|
||||||
|
- name: primary
|
||||||
|
url: https://git.example
|
||||||
|
token: ${tv}
|
||||||
|
"
|
||||||
|
assert_fallback_fails_closed "explicit-tag token '$tv' fails closed" primary git.example
|
||||||
|
done
|
||||||
|
|
||||||
|
# 25. #865 round 12 HIGH fail-open closure: DUPLICATE ANCHOR NAME across separate
|
||||||
|
# nodes. Real PyYAML's Composer tracks anchor names in a DOCUMENT-SCOPED
|
||||||
|
# registry and raises ComposerError ("found duplicate anchor ... first
|
||||||
|
# occurrence") the instant the SAME anchor name is declared a second time
|
||||||
|
# ANYWHERE in the document -- failing the WHOLE document closed, no token,
|
||||||
|
# regardless of how far the duplicate sits from the logins block. The round-11
|
||||||
|
# fallback tracked anchors only WITHIN a single scalar's `_strip_properties`
|
||||||
|
# call, so it had no visibility into a duplicate declared on an unrelated
|
||||||
|
# node and would still emit the (later) token: fail-open. The round-12 fix
|
||||||
|
# (reject every '&'-anchor property, unconditionally -- see section 24 above)
|
||||||
|
# closes this as a strict superset: since NO anchor is ever accepted, a
|
||||||
|
# duplicate anchor can never slip through. These cases exercise that
|
||||||
|
# document-wide duplicate-anchor invariant specifically (as opposed to
|
||||||
|
# section 24's single-anchor-on-the-token-field cases) and pair each fallback
|
||||||
|
# assertion with confirmation that real PyYAML also fails closed here (via
|
||||||
|
# ComposerError), proving this was a genuine fail-open, not a hypothetical.
|
||||||
|
write_fixture 'first: &same one
|
||||||
|
second: &same two
|
||||||
|
logins:
|
||||||
|
- name: primary
|
||||||
|
url: https://git.example
|
||||||
|
token: TOK_DUP_ROOT
|
||||||
|
'
|
||||||
|
assert_both_fail_closed "round-12: duplicate anchor name on two unrelated root nodes fails closed" primary git.example
|
||||||
|
|
||||||
|
write_fixture 'outer:
|
||||||
|
nested: &dup x
|
||||||
|
dup_root: &dup y
|
||||||
|
logins:
|
||||||
|
- name: primary
|
||||||
|
url: https://git.example
|
||||||
|
token: TOK_DUP_NESTED
|
||||||
|
'
|
||||||
|
assert_both_fail_closed "round-12: duplicate anchor name across a nested node and a root node fails closed" primary git.example
|
||||||
|
|
||||||
|
write_fixture 'first: &dup one
|
||||||
|
logins:
|
||||||
|
- name: primary
|
||||||
|
url: https://git.example
|
||||||
|
token: &dup TOK_DUP_TOKEN_NODE
|
||||||
|
'
|
||||||
|
assert_both_fail_closed "round-12: anchor name declared earlier and repeated on the token-bearing node fails closed" primary git.example
|
||||||
|
|
||||||
|
# Regression guard: the non-specific TAG half of section 24 ('! x' -> 'x') is
|
||||||
|
# UNCHANGED by the round-12 anchor fix and must still resolve.
|
||||||
|
write_fixture 'logins:
|
||||||
|
- name: primary
|
||||||
|
url: https://git.example
|
||||||
|
token: ! x
|
||||||
|
'
|
||||||
|
assert_token "round-12 regression: '! x' (tag, no anchor) still resolves 'x'" "x" primary git.example
|
||||||
|
|
||||||
echo "Gitea login resolution regression harness passed"
|
echo "Gitea login resolution regression harness passed"
|
||||||
|
|||||||
571
packages/mosaic/framework/tools/git/test-issue-comment-readback.sh
Executable file
571
packages/mosaic/framework/tools/git/test-issue-comment-readback.sh
Executable file
@@ -0,0 +1,571 @@
|
|||||||
|
#!/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"
|
||||||
@@ -0,0 +1,923 @@
|
|||||||
|
#!/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"
|
||||||
@@ -50,6 +50,21 @@ if ! [[ "$FILE_PATH" =~ \.(ts|tsx|js|jsx|mjs|cjs)$ ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
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
|
# Call the main QA handler with extracted parameters
|
||||||
if [ -f ~/.config/mosaic/tools/qa/qa-hook-handler.sh ]; then
|
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"
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Calling QA handler for $FILE_PATH" >> "$LOG_FILE"
|
||||||
|
|||||||
116
packages/mosaic/framework/tools/qa/test-deps-preflight.sh
Executable file
116
packages/mosaic/framework/tools/qa/test-deps-preflight.sh
Executable file
@@ -0,0 +1,116 @@
|
|||||||
|
#!/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"
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"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"
|
"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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -31,17 +31,26 @@ PI_EXTENSION = FRAMEWORK / "runtime/pi/mosaic-extension.ts"
|
|||||||
|
|
||||||
|
|
||||||
def request(socket_path: Path, value: dict[str, object]) -> dict[str, object]:
|
def request(socket_path: Path, value: dict[str, object]) -> dict[str, object]:
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
deadline = time.monotonic() + 5.0
|
||||||
connection.settimeout(3.0)
|
while True:
|
||||||
connection.connect(str(socket_path))
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||||
connection.sendall((json.dumps(value, separators=(",", ":")) + "\n").encode())
|
connection.settimeout(3.0)
|
||||||
connection.shutdown(socket.SHUT_WR)
|
try:
|
||||||
response = bytearray()
|
connection.connect(str(socket_path))
|
||||||
while True:
|
except ConnectionRefusedError:
|
||||||
chunk = connection.recv(4096)
|
if time.monotonic() >= deadline:
|
||||||
if not chunk:
|
raise
|
||||||
break
|
time.sleep(0.02)
|
||||||
response.extend(chunk)
|
continue
|
||||||
|
connection.sendall((json.dumps(value, separators=(",", ":")) + "\n").encode())
|
||||||
|
connection.shutdown(socket.SHUT_WR)
|
||||||
|
response = bytearray()
|
||||||
|
while True:
|
||||||
|
chunk = connection.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
response.extend(chunk)
|
||||||
|
break
|
||||||
if not response.endswith(b"\n") or response.count(b"\n") != 1:
|
if not response.endswith(b"\n") or response.count(b"\n") != 1:
|
||||||
raise AssertionError(f"unframed broker response: {bytes(response)!r}")
|
raise AssertionError(f"unframed broker response: {bytes(response)!r}")
|
||||||
reply = json.loads(response[:-1])
|
reply = json.loads(response[:-1])
|
||||||
|
|||||||
@@ -661,13 +661,27 @@ describe('whole mutator-class lease gate', () => {
|
|||||||
test('observer revocation and monotonic TTL expiry deny the next mutator', async () => {
|
test('observer revocation and monotonic TTL expiry deny the next mutator', async () => {
|
||||||
const { socket } = await startBroker();
|
const { socket } = await startBroker();
|
||||||
const sessionId = await register(socket);
|
const sessionId = await register(socket);
|
||||||
const pending = await beginVerification(socket, sessionId, 'claude', 1, 1);
|
|
||||||
await promote(socket, sessionId, pending.receipt_challenge!);
|
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
await promote(socket, sessionId, pending.receipt_challenge!);
|
||||||
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||||
ok: true,
|
ok: true,
|
||||||
decision: 'allow',
|
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));
|
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||||
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -675,7 +689,7 @@ describe('whole mutator-class lease gate', () => {
|
|||||||
decision: 'deny',
|
decision: 'deny',
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 2);
|
const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 3);
|
||||||
await promote(socket, sessionId, refreshed.receipt_challenge!);
|
await promote(socket, sessionId, refreshed.receipt_challenge!);
|
||||||
expect(
|
expect(
|
||||||
await request(socket, {
|
await request(socket, {
|
||||||
|
|||||||
@@ -217,12 +217,22 @@ git fetch origin
|
|||||||
mkdir -p ~/src/${projectName}-worktrees
|
mkdir -p ~/src/${projectName}-worktrees
|
||||||
git worktree add ~/src/${projectName}-worktrees/<task-slug> -b <branch-name> origin/main
|
git worktree add ~/src/${projectName}-worktrees/<task-slug> -b <branch-name> origin/main
|
||||||
cd ~/src/${projectName}-worktrees/<task-slug>
|
cd ~/src/${projectName}-worktrees/<task-slug>
|
||||||
|
pnpm install --frozen-lockfile --prefer-offline
|
||||||
# ... all work happens here ...
|
# ... all work happens here ...
|
||||||
git push origin <branch-name>
|
git push origin <branch-name>
|
||||||
cd ~/src/${projectName} && git worktree remove ~/src/${projectName}-worktrees/<task-slug>
|
cd ~/src/${projectName} && git worktree remove ~/src/${projectName}-worktrees/<task-slug>
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Worktrees path: \`~/src/<repo>-worktrees/<task-slug>\` — NEVER use /tmp.`);
|
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.`);
|
||||||
|
|
||||||
// 6. Completion gates
|
// 6. Completion gates
|
||||||
sections.push(`# Completion Gates — ENFORCED
|
sections.push(`# Completion Gates — ENFORCED
|
||||||
|
|||||||
50
skills/glpi-create/SKILL.md
Normal file
50
skills/glpi-create/SKILL.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# 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.
|
||||||
56
skills/glpi-followup/SKILL.md
Normal file
56
skills/glpi-followup/SKILL.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# 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.
|
||||||
57
skills/glpi-list/SKILL.md
Normal file
57
skills/glpi-list/SKILL.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# 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).
|
||||||
96
skills/glpi-solve/SKILL.md
Normal file
96
skills/glpi-solve/SKILL.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# 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 2–3.
|
||||||
|
|
||||||
|
## 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.
|
||||||
62
skills/glpi-sweep/SKILL.md
Normal file
62
skills/glpi-sweep/SKILL.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# 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.
|
||||||
Reference in New Issue
Block a user