From bbfdacaa386cb23fe3709322644aaf28313e4d97 Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 13:54:56 -0600 Subject: [PATCH 01/26] feat(framework): draft normalized forge status and review adapters (#1841) Co-Authored-By: GPT-6.1-Sol (Codex) --- docs/scratchpads/1841-forge-wrapper-gaps.md | 44 ++ .../framework/tools/git/detect-platform.sh | 52 +- .../mosaic/framework/tools/git/forge-api.py | 531 ++++++++++++++++++ .../framework/tools/git/forge-capabilities.sh | 4 + .../framework/tools/git/pr-review-comments.sh | 4 + .../framework/tools/git/pr-review-reply.sh | 4 + .../framework/tools/git/pr-review-resolve.sh | 4 + .../framework/tools/git/pr-status.schema.json | 24 + .../mosaic/framework/tools/git/pr-status.sh | 4 + .../framework/tools/git/pr-watch-check.sh | 4 + .../tools/git/test-forge-capabilities.sh | 4 + .../tools/git/test-forge-contract.py | 191 +++++++ 12 files changed, 844 insertions(+), 26 deletions(-) create mode 100644 docs/scratchpads/1841-forge-wrapper-gaps.md create mode 100644 packages/mosaic/framework/tools/git/forge-api.py create mode 100755 packages/mosaic/framework/tools/git/forge-capabilities.sh create mode 100755 packages/mosaic/framework/tools/git/pr-review-comments.sh create mode 100755 packages/mosaic/framework/tools/git/pr-review-reply.sh create mode 100755 packages/mosaic/framework/tools/git/pr-review-resolve.sh create mode 100644 packages/mosaic/framework/tools/git/pr-status.schema.json create mode 100755 packages/mosaic/framework/tools/git/pr-status.sh create mode 100755 packages/mosaic/framework/tools/git/pr-watch-check.sh create mode 100755 packages/mosaic/framework/tools/git/test-forge-capabilities.sh create mode 100644 packages/mosaic/framework/tools/git/test-forge-contract.py diff --git a/docs/scratchpads/1841-forge-wrapper-gaps.md b/docs/scratchpads/1841-forge-wrapper-gaps.md new file mode 100644 index 000000000..95c15db45 --- /dev/null +++ b/docs/scratchpads/1841-forge-wrapper-gaps.md @@ -0,0 +1,44 @@ +# Forge wrapper gaps (#1841) + +Base: 924438016e2eb1c2ca352bfbf9107143b8b342bb (`origin/next`, fetched 2026-11-04). + +Scope: normalized capability/status/watch contract, review conversations, integration controls, +guards, packaged workflow skills and transport parity. Source of truth is upstream framework; +local changes are considered individually. No live provider writes in tests. + +Authority: ordinary task-branch commits and push only. Coordinator arranges independent review +before PR creation. No merge, deployment, framework installation or local rollout. + +Preflight: B consumes A's opaque provider IDs and capability profiles. C consumes A's exact-head +status and protection evidence. D and skills consume A's exit contract. Watch transports must +not supply readiness evidence. Unknown integration state blocks full-stop. + +Tracking: issue #1841 created through issue-create.sh after open issue listing. Existing supplied +branch name retained per coordinator instruction. Worktree preserved at the T3-assigned path. + +Progress: intake complete; implementation pending. + +## Implementation checkpoint + +Partial A/B scaffolding exists and is uncommitted. The normalized Python adapter, shell +entry points, envelope schema and focused offline tests are drafts, not accepted delivery. +One local fix carried independently: Python import isolation in detect-platform.sh. + +Verification: `python3 -I packages/mosaic/framework/tools/git/test-forge-contract.py` +returned 0, runner reported 32 tests (inherited cases repeat across test classes). +`bash -n packages/mosaic/framework/tools/git/detect-platform.sh` returned 0. +Earlier red runs demonstrated missing adapter functions and incorrect HTTP access mapping. +No full wrapper suite, shellcheck, parity, package discovery or hook checks have run. +No dependencies installed. No commits or push. No PR created. + +BLOCKED on plan section 4C's authority representation. Existing repo-decl.sh lines 151–195 +define direct/trunk-release branch transitions only. Neither that helper nor the current +repository declaration defines merge styles or an absorb classification. The plan requires +repository declaration/PR shape to choose allowed methods, including task=squash and +absorb/release=merge when required. A command-line method flag cannot become authority +(the existing helper explicitly states this). Coordinator must specify whether to extend the +repository declaration with a merge-policy contract, or identify an existing authoritative +shape/policy source. No merge-policy representation has been invented in this patch. + +Preserved task scratch: /var/src/_scratch/tmp.Ys2ggENH8n. Contains drift comparison, +public schema and assembly fragments needed to resume. No scratch directories removed. diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 8398757f2..85af8efbe 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -15,22 +15,22 @@ detect_platform() { return 1 fi - # Check for GitHub - if [[ "$remote_url" == *"github.com"* ]]; then - PLATFORM="github" - export PLATFORM - echo "github" - return 0 + # Parse the authority, rather than matching github.com anywhere in a URL. + local host profile rc + host=$(get_host_from_url "$remote_url") || return 1 + if [[ "$host" == github.com || ( -n "${GH_HOST:-}" && "$host" == "$GH_HOST" ) ]]; then + PLATFORM=github; export PLATFORM; echo github; return 0 fi - - # Check for common Gitea indicators - # Gitea URLs typically don't contain github.com, gitlab.com, bitbucket.org - if [[ "$remote_url" != *"gitlab.com"* ]] && \ - [[ "$remote_url" != *"bitbucket.org"* ]]; then - # Assume Gitea for self-hosted repos - PLATFORM="gitea" - export PLATFORM - echo "gitea" + profile=$(python3 -I "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/forge-api.py" capabilities --host "$host" --repo "$(get_repo_info)" --json) || { + echo "error: forge metadata could not be verified for $host" >&2 + return 1 + } + rc=$(printf '%s' "$profile" | python3 -I -c 'import json,sys; print(json.load(sys.stdin)["provider"])') || return 1 + if [[ "$rc" == gitea || "$rc" == forgejo ]]; then + # Legacy scripts dispatch both API-compatible providers as gitea. + FORGE_PROVIDER="$rc"; PLATFORM=gitea + export FORGE_PROVIDER PLATFORM + echo gitea return 0 fi @@ -87,7 +87,7 @@ get_repo_slug() { gitea_url_matches_host() { local url="${1:-}" host="${2:-}" [[ -n "$url" && -n "$host" ]] || return 1 - python3 - "$url" "$host" <<'PY' + python3 -I - "$url" "$host" <<'PY' import sys from urllib.parse import urlparse @@ -156,7 +156,7 @@ find_tea_login_for_host() { command -v tea >/dev/null 2>&1 || return 1 logins_json=$(tea login list --output json 2>/dev/null) || return 1 - TEA_LOGINS_JSON="$logins_json" python3 - "$host" <<'PY' + TEA_LOGINS_JSON="$logins_json" python3 -I - "$host" <<'PY' import json import os import sys @@ -226,7 +226,7 @@ tea_login_exists() { local logins_json command -v tea >/dev/null 2>&1 || return 1 logins_json=$(tea login list --output json 2>/dev/null) || return 1 - TEA_LOGINS_JSON="$logins_json" python3 - "$login_name" <<'PY_INNER' + TEA_LOGINS_JSON="$logins_json" python3 -I - "$login_name" <<'PY_INNER' import json, os, sys want = sys.argv[1] try: @@ -246,7 +246,7 @@ tea_login_matches_host() { command -v tea >/dev/null 2>&1 || return 1 logins_json=$(tea login list --output json 2>/dev/null) || return 1 - TEA_LOGINS_JSON="$logins_json" python3 - "$login_name" "$host" <<'PY' + TEA_LOGINS_JSON="$logins_json" python3 -I - "$login_name" "$host" <<'PY' import json import os import sys @@ -281,7 +281,7 @@ print_gitea_login_diagnostic() { available=$( command -v tea >/dev/null 2>&1 || { echo "(tea CLI not installed)"; exit 0; } logins_json=$(tea login list --output json 2>/dev/null) || { echo "(could not query tea login list)"; exit 0; } - TEA_LOGINS_JSON="$logins_json" python3 - <<'PY' + TEA_LOGINS_JSON="$logins_json" python3 -I - <<'PY' import json, os from urllib.parse import urlparse try: @@ -390,7 +390,7 @@ get_gitea_authenticated_user() { command -v tea >/dev/null 2>&1 || return 1 response=$(tea api --login "$login_name" /user 2>/dev/null) || return 1 - TEA_AUTHENTICATED_USER_JSON="$response" python3 - <<'PY' + TEA_AUTHENTICATED_USER_JSON="$response" python3 -I - <<'PY' import json import os @@ -412,7 +412,7 @@ get_default_tea_login() { command -v tea >/dev/null 2>&1 || return 1 logins_json=$(tea login list --output json 2>/dev/null) || return 1 - TEA_LOGINS_JSON="$logins_json" python3 - <<'PY' + TEA_LOGINS_JSON="$logins_json" python3 -I - <<'PY' import json import os @@ -501,7 +501,7 @@ get_host_from_url() { local url="${1:-}" [[ -n "$url" ]] || return 1 - python3 - "$url" <<'PY' + python3 -I - "$url" <<'PY' import sys from urllib.parse import urlparse @@ -531,7 +531,7 @@ get_gitea_repo_slug_for_url() { remote_url=$(git remote get-url origin 2>/dev/null) || return 1 if [[ "$remote_url" =~ ^https?:// ]]; then - python3 - "$remote_url" "$configured_url" <<'PY' + python3 -I - "$remote_url" "$configured_url" <<'PY' import sys from urllib.parse import urlparse @@ -828,7 +828,7 @@ get_gitea_token_for_login() { config_file="${XDG_CONFIG_HOME:-$HOME/.config}/tea/config.yml" [[ -f "$config_file" ]] || return 1 - LOGIN_NAME="$login_name" REPO_HOST="$repo_host" python3 - "$config_file" <<'PY' + LOGIN_NAME="$login_name" REPO_HOST="$repo_host" python3 -I - "$config_file" <<'PY' import datetime import os import re @@ -1656,7 +1656,7 @@ get_gitea_basic_auth() { return 1 fi - python3 - "$host" "$creds" <<'PY' + python3 -I - "$host" "$creds" <<'PY' import sys from pathlib import Path from urllib.parse import unquote, urlparse diff --git a/packages/mosaic/framework/tools/git/forge-api.py b/packages/mosaic/framework/tools/git/forge-api.py new file mode 100644 index 000000000..f4b375f7e --- /dev/null +++ b/packages/mosaic/framework/tools/git/forge-api.py @@ -0,0 +1,531 @@ +#!/usr/bin/env python3 +"""Provider adapters for the versioned forge wrapper contract. + +Run with Python -I. No repository modules, provider code or fixture files are imported. +All network operations have finite timeouts and pagination bounds. Writes are never retried. +""" +import argparse +import datetime +import hashlib +import json +import os +import pathlib +import re +import subprocess +import sys +import urllib.parse + +HERE = pathlib.Path(__file__).resolve().parent +LIMIT = 100 +MAX_PAGES = 100 + + +class Failure(Exception): + def __init__(self, code, kind, message, retry=None, outcome='none'): + super().__init__(message) + self.code, self.kind, self.retry, self.outcome = code, kind, retry, outcome + + +def capabilities(provider, version): + names = ('status', 'review_comments', 'review_reply', 'review_resolve', 'merge', + 'rebase_merge', 'fast_forward_only', 'auto_merge', 'merge_queue') + result = {n: {'support': 'unknown', 'reason': 'No qualified API profile', 'schema_source': None} for n in names} + def mark(names, support, reason, source): + for n in names: + result[n] = dict(support=support, reason=reason, schema_source=source) + if provider == 'github': + source = 'https://docs.github.com/en/graphql/reference/objects#pullrequest' + mark(names, 'supported', 'Documented API; repository permissions and settings still apply', source) + mark(('rebase_merge', 'fast_forward_only'), 'unsupported', 'GitHub merge API has no such method', 'https://docs.github.com/en/rest/pulls/pulls#merge-a-pull-request') + elif (provider, version) in (('gitea', '28.2.0'), ('gitea', '1.24.7'), ('forgejo', '16.0.0-dev')): + source = 'swagger.v1.json:' + provider + ':' + version + mark(('status', 'review_comments', 'merge', 'rebase_merge', 'fast_forward_only'), 'supported', 'Qualified versioned schema', source) + mark(('review_reply', 'review_resolve'), 'supported' if version == '28.2.0' else 'unsupported', 'Native conversation routes present' if version == '28.2.0' else 'No native conversation mutation routes in qualified schema', source) + mark(('auto_merge', 'merge_queue'), 'unsupported', 'No documented schedule readback or native merge queue; raw scheduling/cancellation is not safely verifiable', source) + return result + + +def fingerprint(data): + stable = {k: v for k, v in data.items() if k not in ('observed_at', 'fingerprint')} + return hashlib.sha256(json.dumps(stable, sort_keys=True, separators=(',', ':')).encode()).hexdigest() + + +def checks(statuses, runs, required, allow_none=False): + observed = {} + for s in sorted(statuses, key=lambda s: s.get('id', 0)): + name = s.get('context') + if not name: + raise Failure(6, 'malformed', 'Status context missing') + observed[name] = s.get('state', 'unknown') + for r in sorted(runs, key=lambda r: r.get('id', 0)): + name = r.get('name') + if not name: + raise Failure(6, 'malformed', 'Check run name missing') + conclusion = r.get('conclusion') + observed[name] = ('pending' if r.get('status') != 'completed' else + 'success' if conclusion in ('success', 'neutral', 'skipped') else + 'cancelled' if conclusion == 'cancelled' else 'failure' if conclusion in ('failure', 'timed_out', 'action_required', 'startup_failure') else 'unknown') + state = 'unknown' + if required is not None: + selected = [observed.get(n, 'missing') for n in required] if required else list(observed.values()) + if not selected: + state = 'not_required' if allow_none and required == [] else 'missing' + else: + state = next((s for s in ('failure', 'error', 'cancelled', 'missing', 'unknown', 'pending', 'in_progress', 'queued') if s in selected), 'success') + state = {'error': 'failure', 'in_progress': 'pending', 'queued': 'pending'}.get(state, state) + return dict(state=state, required=required, observed=observed, complete=True) + + +def reviews(items, head, author): + latest = {} + for r in sorted(items, key=lambda r: r.get('id', 0)): + login = (r.get('user') or {}).get('login') + state = str(r.get('state', '')).upper() + if login and state not in ('COMMENTED', 'PENDING'): + latest[login] = r + rejected = any(str(r.get('state')).upper() in ('REQUEST_CHANGES', 'CHANGES_REQUESTED') for r in latest.values()) + approved = any(login != author and str(r.get('state')).upper() == 'APPROVED' and r.get('commit_id') == head for login, r in latest.items()) and not rejected + return dict(decision='changes_requested' if rejected else 'approved' if approved else 'missing', reviewed_sha=head if approved else None, complete=True, approved=approved) + + +def run(argv, data=None): + try: + return subprocess.run(argv, input=data, text=True, capture_output=True, timeout=45) + except (OSError, subprocess.TimeoutExpired): + raise Failure(75, 'transient', 'Provider client unavailable or timed out') + + +class Client: + def __init__(self, host, repo): + self.host, self.repo = host, repo + self.provider, self.version, self.token = 'unknown', '', None + self.root = '/repos/' + repo + + def detect(self): + if self.host == 'github.com' or self.host == os.environ.get('GH_HOST'): + self.provider, self.version = 'github', 'documented' + return + schema = self.http('/swagger.v1.json', public=True) + info = schema.get('info', {}) + title, version = str(info.get('title', '')).lower(), str(info.get('version', '')) + if 'forgejo' in title or '+gitea-' in version: + self.provider = 'forgejo' + # This profile is the inspected 16.0.0 development schema only. + self.version = '16.0.0-dev' if version.startswith('16.0.0-dev') else version + elif 'gitea' in title: + self.provider, self.version = 'gitea', version + else: + raise Failure(4, 'unsupported', 'Host metadata does not identify a qualified forge') + + def credential(self): + if self.token is None: + # Fixed script/arguments; no user input becomes shell source. + p = run(['bash', '-c', 'source "$1/detect-platform.sh"; get_gitea_token "$2"', 'forge-auth', str(HERE), self.host]) + if p.returncode or not p.stdout.strip(): + raise Failure(5, 'access', 'Acting identity has no credential for this host') + self.token = p.stdout.strip() + return self.token + + def http(self, path, method='GET', body=None, public=False): + writing = method != 'GET' and not (path == 'graphql' and body and body.get('query', '').lstrip().startswith('query')) + if self.provider == 'github' and not public: + args = ['gh', 'api', '--hostname', self.host, '--include', '--method', method, path] + if body is not None: + args += ['--input', '-'] + p = run(args, json.dumps(body) if body is not None else None) + else: + url = 'https://' + self.host + (path if public else '/api/v1' + path) + args = ['curl', '--silent', '--show-error', '--include', '--max-time', '30', '--request', method, '--header', 'Accept: application/json', '--header', 'User-Agent: mosaic-forge-wrapper'] + if not public: + args += ['--header', 'Authorization: token ' + self.credential()] + if body is not None: + args += ['--header', 'Content-Type: application/json', '--data-binary', '@-'] + args += ['--url', url] + p = run(args, json.dumps(body) if body is not None else None) + if p.returncode and not p.stdout.startswith('HTTP/'): + raise Failure(75, 'transient', 'Provider transport failed; reconcile before retrying a mutation', outcome='unknown' if writing else 'none') + raw = p.stdout.replace('\r\n', '\n') + # Proxies can emit an initial CONNECT response. Only consume header blocks. + status, headers = None, {} + while raw.startswith('HTTP/'): + block, sep, raw = raw.partition('\n\n') + if not sep: + raise Failure(6, 'malformed', 'Provider response headers incomplete') + lines = block.splitlines() + try: + status = int(lines[0].split()[1]) + except (ValueError, IndexError): + raise Failure(6, 'malformed', 'Provider HTTP status malformed') + headers = {k.lower().strip(): v.strip() for line in lines[1:] if ':' in line for k, v in [line.split(':', 1)]} + if status is None: + raise Failure(6, 'malformed', 'Provider did not return HTTP metadata') + if status == 429 or status == 403 and (headers.get('x-ratelimit-remaining') == '0' or 'retry-after' in headers): + retry = headers.get('retry-after', '60') + raise Failure(75, 'rate_limit', 'Provider rate limit; use a bounded later wake', int(retry) if retry.isdigit() else 60, 'unknown' if writing else 'none') + if status in (401, 403, 404): + raise Failure(5, 'access', 'Provider access denied or resource hidden (HTTP %s)' % status, outcome='unknown' if writing else 'none') + if status >= 500: + raise Failure(75, 'transient', 'Provider server error', outcome='unknown' if writing else 'none') + if not 200 <= status < 300: + raise Failure(3 if writing else 6, 'gate' if writing else 'malformed', 'Provider refused request (HTTP %s)' % status) + if not raw.strip() and status == 204: + return None + try: + result = json.loads(raw) + except ValueError: + raise Failure(6, 'malformed', 'Provider returned invalid JSON', outcome='unknown' if writing else 'none') + return result + + def pages(self, path, key=None): + result = [] + for page in range(1, MAX_PAGES + 1): + query = ('&' if '?' in path else '?') + ('per_page' if self.provider == 'github' else 'limit') + '=100&page=' + str(page) + value = self.http(path + query) + if key: + value = value.get(key) + if not isinstance(value, list): + raise Failure(6, 'malformed', 'Provider collection is not a list') + result.extend(value) + if len(value) < LIMIT: + return result + raise Failure(6, 'incomplete', 'Pagination bound reached; collection incomplete') + + def gql(self, query, variables): + result = self.http('graphql', 'POST', {'query': query, 'variables': variables}) + if not isinstance(result, dict) or result.get('errors') or not isinstance(result.get('data'), dict): + raise Failure(6, 'incomplete', 'GraphQL returned errors or incomplete data') + return result['data'] + + def pr(self, number): + value = self.http(self.root + '/pulls/' + str(number)) + if not isinstance(value, dict) or value.get('number') != number or not re.fullmatch('[0-9a-fA-F]{40}', (value.get('head') or {}).get('sha', '')): + raise Failure(6, 'malformed', 'PR identity or head SHA missing') + return value + + +def require(client, operation): + cap = capabilities(client.provider, client.version)[operation] + if cap['support'] != 'supported': + raise Failure(4, 'unsupported', cap['reason']) + + +def same_head(client, number, expected): + p = client.pr(number) + if p['head']['sha'] != expected: + raise Failure(75, 'head_moved', 'PR head changed; discard readiness and refresh review') + return p + + +def github_conversations(client, number): + owner, repo = client.repo.split('/') + cursor, seen, threads, integration = None, set(), [], None + fields = '''id isResolved isOutdated comments(first:100) { nodes { databaseId id replyTo { databaseId } } pageInfo { hasNextPage endCursor } }''' + for _ in range(MAX_PAGES): + q = '''query($owner:String!,$repo:String!,$number:Int!,$cursor:String) { + repository(owner:$owner,name:$repo) { pullRequest(number:$number) { + id autoMergeRequest { mergeMethod } mergeQueueEntry { id } + reviewThreads(first:100,after:$cursor) { nodes { ''' + fields + ''' } pageInfo { hasNextPage endCursor } } + } } }''' + pr = client.gql(q, dict(owner=owner, repo=repo, number=number, cursor=cursor))['repository']['pullRequest'] + if not isinstance(pr, dict) or not pr.get('id') or 'autoMergeRequest' not in pr or 'mergeQueueEntry' not in pr: + raise Failure(6, 'incomplete', 'Missing integration state') + if integration is None: + integration = dict(id=pr['id'], auto_merge={'state':'enabled' if pr['autoMergeRequest'] else 'disabled', 'method':(pr['autoMergeRequest'] or {}).get('mergeMethod')}, queue={'state':'queued' if pr['mergeQueueEntry'] else 'absent', 'entry_id':(pr['mergeQueueEntry'] or {}).get('id')}) + connection = pr['reviewThreads'] + for thread in connection['nodes']: + comments = thread['comments'] + nodes = list(comments['nodes']) + nested_seen = set() + for _ in range(MAX_PAGES): + if comments['pageInfo']['hasNextPage'] is False: + break + nested_cursor = comments['pageInfo']['endCursor'] + if not nested_cursor or nested_cursor in nested_seen: + raise Failure(6, 'incomplete', 'Review comment cursor missing or repeated') + nested_seen.add(nested_cursor) + comments = client.gql('''query($id:ID!,$cursor:String!) { node(id:$id) { ... on PullRequestReviewThread { comments(first:100,after:$cursor) { nodes { databaseId id replyTo { databaseId } } pageInfo { hasNextPage endCursor } } } } }''', {'id':thread['id'], 'cursor':nested_cursor})['node']['comments'] + nodes.extend(comments['nodes']) + else: + raise Failure(6, 'incomplete', 'Nested comment pagination bound reached') + thread['comments'] = nodes + threads.append(thread) + if connection['pageInfo']['hasNextPage'] is False: + return threads, integration + cursor = connection['pageInfo']['endCursor'] + if not cursor or cursor in seen: + raise Failure(6, 'incomplete', 'Review thread cursor missing or repeated') + seen.add(cursor) + raise Failure(6, 'incomplete', 'Review thread pagination bound reached') + + +def review_comments(client, number): + require(client, 'review_comments') + root = client.root + '/pulls/' + str(number) + revs = client.pages(root + '/reviews') + comments, threads, integration = [], [], None + if client.provider == 'github': + raw = client.pages(root + '/comments') + threads, integration = github_conversations(client, number) + mapping = {} + for t in threads: + for c in t['comments']: + cid = c.get('databaseId') + if cid is None or cid in mapping: + raise Failure(6, 'incomplete', 'Thread comment identity missing or duplicated') + mapping[cid] = t + for c in raw: + if c.get('id') not in mapping: + raise Failure(6, 'incomplete', 'REST comment absent from authoritative thread graph') + if set(mapping) != {c.get('id') for c in raw}: + raise Failure(6, 'incomplete', 'Comment collections disagree') + else: + raw = [] + mapping = {} + for r in revs: + if r.get('id') is None: + raise Failure(6, 'malformed', 'Review ID missing') + page = client.pages(root + '/reviews/' + str(r['id']) + '/comments') + for c in page: + c = dict(c, pull_request_review_id=r['id']) + raw.append(c) + seen = set() + for c in raw: + cid = c.get('id') + if cid is None or cid in seen: + raise Failure(6, 'incomplete', 'Comment ID missing or duplicated') + seen.add(cid) + t = mapping.get(cid) + parent = c.get('in_reply_to_id') + # A resolver proves a resolved comment; it does not prove a thread graph. + resolved = t['isResolved'] if t else bool(c.get('resolver')) if 'resolver' in c else None + comments.append(dict(id=f'{client.provider}:comment:{cid}', review_id=c.get('pull_request_review_id'), thread_id=f"github:thread:{t['id']}" if t else None, reply_to=f'{client.provider}:comment:{parent}' if parent else None, author=(c.get('user') or {}).get('login'), body=c.get('body'), path=c.get('path'), line=c.get('line') or c.get('new_position'), commit_sha=c.get('commit_id'), outdated=t['isOutdated'] if t else c.get('outdated'), resolved=resolved, url=c.get('html_url') or c.get('url'))) + return dict(complete=True, reviews=revs, comments=comments), threads, integration + + +def protection(client, base): + if client.provider == 'github': + p = client.http(client.root + '/branches/' + urllib.parse.quote(base, safe='') + '/protection') + status = p.get('required_status_checks') + required = sorted(set((status or {}).get('contexts', []) + [c['context'] for c in (status or {}).get('checks', [])])) if status is not None else [] + review = p.get('required_pull_request_reviews') or {} + enforced = bool(required and review.get('required_approving_review_count', 0) > 0 and review.get('dismiss_stale_reviews') is True and (p.get('enforce_admins') or {}).get('enabled') is True) + return required, enforced + rules = client.pages(client.root + '/branch_protections') + matching = [r for r in rules if r.get('branch_name') == base] + if len(matching) != 1: + return None, False + r = matching[0] + return (r.get('status_check_contexts') if r.get('enable_status_check') is True else None), False + + +def snapshot(client, number, expected): + require(client, 'status') + p = client.pr(number) + head = p['head']['sha'] + if expected and head != expected: + raise Failure(3, 'gate', 'Expected head differs from PR head') + base = p['base'] + required, enforced = protection(client, base['ref']) + root = client.root + statuses = client.pages(root + '/commits/' + head + '/statuses') + runs = client.pages(root + '/commits/' + head + '/check-runs', 'check_runs') if client.provider == 'github' else [] + check = checks(statuses, runs, required) + comments, threads, integration = review_comments(client, number) + issue_comments = client.pages(root + '/issues/' + str(number) + '/comments') + review = reviews(comments['reviews'], head, (p.get('user') or {}).get('login')) + unresolved = sum(t['isResolved'] is False for t in threads) if client.provider == 'github' else sum(c['resolved'] is False for c in comments['comments']) if all(c['resolved'] is not None for c in comments['comments']) else None + thread_state = dict(unresolved=unresolved, complete=comments['complete'] and unresolved is not None) + state = 'merged' if p.get('merged') is True else p['state'] + mergeability = 'conflict' if p.get('mergeable') is False else 'clean' if p.get('mergeable') is True else 'unknown' + blockers = [] + if state != 'open': + blockers.append(state) + if p.get('draft') is not False: + blockers.append('draft_or_unknown') + if check['state'] not in ('success', 'not_required'): + blockers.append('checks_' + check['state']) + if not review['approved']: + blockers.append('review_' + review['decision']) + if unresolved != 0: + blockers.append('review_threads_unresolved_or_unknown') + if mergeability != 'clean': + blockers.append('mergeability_' + mergeability) + data = dict(url=p.get('html_url'), state=state, draft=p.get('draft'), head_ref=p['head'].get('ref'), head_sha=head, base_ref=base.get('ref'), base_sha=base.get('sha'), head_repository=(p['head'].get('repo') or {}).get('full_name'), base_repository=(base.get('repo') or {}).get('full_name'), merge_sha=p.get('merge_commit_sha') if state == 'merged' else None, mergeability=mergeability, checks=check, reviews=review, threads=thread_state, auto_merge=integration['auto_merge'] if integration else {'state':'unsupported'}, queue=integration['queue'] if integration else {'state':'unsupported'}, capabilities=capabilities(client.provider, client.version), ready=not blockers, blockers=blockers, comments=comments['comments'], issue_comments=issue_comments, protection_enforced=enforced, observed_at=datetime.datetime.now(datetime.timezone.utc).isoformat()) + after = same_head(client, number, head) + if after['base'].get('sha') != base.get('sha') or after.get('state') != p.get('state') or after.get('merged') != p.get('merged'): + raise Failure(75, 'snapshot_changed', 'PR base or closure changed during aggregation') + data['fingerprint'] = fingerprint(data) + return data + + +def owned_comment(client, number, opaque): + data, threads, integration = review_comments(client, number) + matches = [c for c in data['comments'] if c['id'] == opaque] + if len(matches) != 1: + raise Failure(3, 'gate', 'Comment is not uniquely owned by the requested PR') + return matches[0] + + +def reply(client, number, opaque, body, expected): + require(client, 'review_reply') + same_head(client, number, expected) + target = owned_comment(client, number, opaque) + if not re.fullmatch(re.escape(client.provider) + r':comment:[1-9][0-9]*', opaque): + raise Failure(2, 'usage', 'Use the provider-qualified comment ID from pr-review-comments') + actor = client.http('/user').get('login') + if not actor: + raise Failure(5, 'access', 'Acting user could not be verified') + same_head(client, number, expected) + cid = opaque.rsplit(':', 1)[1] + path = client.root + '/pulls/' + str(number) + '/comments/' + cid + '/replies' + created = client.http(path, 'POST', {'body':body}) + if not isinstance(created, dict) or not isinstance(created.get('id'), int): + raise Failure(6, 'verification', 'Reply response has no created ID; reconcile before retry', outcome='unknown') + new_id = f"{client.provider}:comment:{created['id']}" + try: + if client.provider == 'github': + item = client.http(client.root + '/pulls/comments/' + str(created['id'])) + expected_url = '/repos/' + client.repo + '/pulls/' + str(number) + parsed = urllib.parse.urlparse(item.get('pull_request_url', '')) + if parsed.path != expected_url or parsed.hostname != ('api.github.com' if client.host == 'github.com' else client.host) or item.get('in_reply_to_id') != int(cid): + raise Failure(6, 'verification', 'Reply repository or parent readback differs', outcome='unknown') + actual = {'id':f"github:comment:{item.get('id')}", 'body':item.get('body'), 'author':(item.get('user') or {}).get('login'), 'commit_sha':item.get('commit_id')} + else: + actual = owned_comment(client, number, new_id) + if actual.get('id') != new_id or actual.get('body') != body or actual.get('author') != actor or target.get('commit_sha') and actual.get('commit_sha') != target['commit_sha']: + raise Failure(6, 'verification', 'Created reply identity, author, body or commit readback differs', outcome='unknown') + same_head(client, number, expected) + except Failure as e: + e.outcome = 'unknown' + raise + return dict(id=new_id, reply_to=opaque, thread_id=target.get('thread_id'), verified=True, head_sha=expected) + + +def resolve(client, number, opaque, desired, expected): + require(client, 'review_resolve') + same_head(client, number, expected) + data, threads, integration = review_comments(client, number) + if client.provider == 'github': + matches = [t for t in threads if 'github:thread:' + t['id'] == opaque] + if len(matches) != 1: + raise Failure(3, 'gate', 'Thread is not uniquely owned by the requested PR') + target = matches[0] + if target.get('isResolved') is desired: + return dict(thread_id=opaque, resolved=desired, verified=True, changed=False) + same_head(client, number, expected) + mutation = 'resolveReviewThread' if desired else 'unresolveReviewThread' + client.gql('mutation($id:ID!) { ' + mutation + '(input:{threadId:$id}) { thread { id isResolved } } }', {'id':target['id']}) + else: + # This API resolves a review comment. Its ID is a provider selector, + # never an invented path/line-based thread graph. + matches = [c for c in data['comments'] if c['id'] == opaque] + if len(matches) != 1 or not re.fullmatch('gitea:comment:[1-9][0-9]*', opaque): + raise Failure(3, 'gate', 'Resolution selector is not owned by this PR') + if matches[0]['resolved'] is desired: + return dict(thread_id=opaque, resolved=desired, verified=True, changed=False) + same_head(client, number, expected) + client.http(client.root + '/pulls/comments/' + opaque.rsplit(':',1)[1] + ('/resolve' if desired else '/unresolve'), 'POST') + try: + new_data, new_threads, _ = review_comments(client, number) + states = [t['isResolved'] for t in new_threads if 'github:thread:' + t['id'] == opaque] if client.provider == 'github' else [c['resolved'] for c in new_data['comments'] if c['id'] == opaque] + if states != [desired]: + raise Failure(6, 'verification', 'Resolution readback does not prove requested state', outcome='unknown') + same_head(client, number, expected) + except Failure as e: + e.outcome = 'unknown' + raise + return dict(thread_id=opaque, resolved=desired, verified=True, changed=True) + + +def main(): + parser = argparse.ArgumentParser(description='Versioned forge adapter. See README for provider limitations.') + parser.add_argument('operation', choices=['capabilities', 'status', 'watch-check', 'review-comments', 'review-reply', 'review-resolve']) + parser.add_argument('-n', '--number', type=int) + parser.add_argument('-r', '--repo') + parser.add_argument('--host') + parser.add_argument('--json', action='store_true') + parser.add_argument('--operation', dest='requested_operation') + parser.add_argument('--expect-head') + parser.add_argument('--baseline') + parser.add_argument('--comment-id') + parser.add_argument('--thread-id') + parser.add_argument('--body-file') + resolution = parser.add_mutually_exclusive_group() + resolution.add_argument('--resolve', action='store_true') + resolution.add_argument('--unresolve', action='store_true') + args = parser.parse_args() + client, data, head, error, code = None, None, None, None, 0 + try: + if args.number is not None and args.number < 1: + raise Failure(2, 'usage', 'PR number must be positive') + if args.operation != 'capabilities' and not args.number: + raise Failure(2, 'usage', '-n NUMBER is required') + if args.expect_head and not re.fullmatch('[0-9a-fA-F]{40}', args.expect_head): + raise Failure(2, 'usage', '--expect-head requires a full SHA') + if not args.host or not args.repo: + p = run(['git', 'remote', 'get-url', 'origin']) + remote = p.stdout.strip() + if remote.startswith('git@'): + remote = 'ssh://' + remote.replace(':', '/', 1) + parsed = urllib.parse.urlparse(remote) + args.host = args.host or parsed.hostname + args.repo = args.repo or parsed.path.strip('/').removesuffix('.git') + if not args.host or not re.fullmatch(r'[A-Za-z0-9.-]+(?::[0-9]+)?', args.host) or not args.repo or not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', args.repo): + raise Failure(2, 'usage', 'Use an unambiguous origin or --host HOST --repo OWNER/REPO') + client = Client(args.host, args.repo) + client.detect() + if args.operation == 'capabilities': + data = {'version': client.version, 'operations': capabilities(client.provider, client.version)} + if args.requested_operation: + if args.requested_operation not in data['operations']: + raise Failure(2, 'usage', 'Unknown capability operation') + require(client, args.requested_operation) + elif args.operation == 'review-comments': + before = client.pr(args.number)['head']['sha'] + data, _, _ = review_comments(client, args.number) + same_head(client, args.number, before) + head = before + elif args.operation in ('review-reply', 'review-resolve'): + if not args.expect_head: + raise Failure(2, 'usage', 'Mutation requires --expect-head') + head = args.expect_head + if args.operation == 'review-reply': + if not args.comment_id or not args.body_file: + raise Failure(2, 'usage', 'Reply requires --comment-id and --body-file') + try: + body = pathlib.Path(args.body_file).read_text() + except OSError: + raise Failure(2, 'usage', 'Body file unreadable') + if not body.strip(): + raise Failure(2, 'usage', 'Reply body must not be empty') + data = reply(client, args.number, args.comment_id, body, args.expect_head) + else: + if not args.thread_id or not (args.resolve or args.unresolve): + raise Failure(2, 'usage', 'Resolution requires --thread-id and --resolve or --unresolve') + data = resolve(client, args.number, args.thread_id, args.resolve, args.expect_head) + else: + data = snapshot(client, args.number, args.expect_head) + head = data['head_sha'] + if args.operation == 'watch-check': + if not args.baseline or not os.path.isabs(args.baseline): + raise Failure(2, 'usage', '--baseline must be an absolute JSON path') + try: + baseline = json.loads(pathlib.Path(args.baseline).read_text()) + except (OSError, ValueError): + raise Failure(6, 'malformed', 'Baseline cannot be read as JSON') + if any(baseline.get(k) != v for k, v in [('provider', client.provider), ('host', args.host), ('repo', args.repo), ('number', args.number)]): + raise Failure(6, 'malformed', 'Baseline belongs to a different PR/watch owner') + code = 0 if data['state'] != 'open' or data['ready'] or data['checks']['state'] in ('failure', 'cancelled') or data['fingerprint'] != (baseline.get('data') or {}).get('fingerprint') else 1 + except Failure as e: + code = e.code + error = dict(kind=e.kind, message=str(e), retry_after_seconds=e.retry, write_outcome=e.outcome) + print(str(e), file=sys.stderr) + except (ValueError, TypeError, KeyError, AttributeError) as e: + code = 6 + error = dict(kind='malformed', message='Provider evidence malformed: ' + str(e), retry_after_seconds=None, write_outcome='none') + print(json.dumps(dict(schema_version=1, operation=args.operation, provider=client.provider if client else 'unknown', host=args.host, repo=args.repo, number=args.number, head_sha=head, data=data, error=error), sort_keys=True)) + return code + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/packages/mosaic/framework/tools/git/forge-capabilities.sh b/packages/mosaic/framework/tools/git/forge-capabilities.sh new file mode 100755 index 000000000..3415035ff --- /dev/null +++ b/packages/mosaic/framework/tools/git/forge-capabilities.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 -I "$SCRIPT_DIR/forge-api.py" capabilities "$@" diff --git a/packages/mosaic/framework/tools/git/pr-review-comments.sh b/packages/mosaic/framework/tools/git/pr-review-comments.sh new file mode 100755 index 000000000..0727a84bc --- /dev/null +++ b/packages/mosaic/framework/tools/git/pr-review-comments.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 -I "$SCRIPT_DIR/forge-api.py" review-comments "$@" diff --git a/packages/mosaic/framework/tools/git/pr-review-reply.sh b/packages/mosaic/framework/tools/git/pr-review-reply.sh new file mode 100755 index 000000000..8513de362 --- /dev/null +++ b/packages/mosaic/framework/tools/git/pr-review-reply.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 -I "$SCRIPT_DIR/forge-api.py" review-reply "$@" diff --git a/packages/mosaic/framework/tools/git/pr-review-resolve.sh b/packages/mosaic/framework/tools/git/pr-review-resolve.sh new file mode 100755 index 000000000..f6ea47e5a --- /dev/null +++ b/packages/mosaic/framework/tools/git/pr-review-resolve.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 -I "$SCRIPT_DIR/forge-api.py" review-resolve "$@" diff --git a/packages/mosaic/framework/tools/git/pr-status.schema.json b/packages/mosaic/framework/tools/git/pr-status.schema.json new file mode 100644 index 000000000..464095d1c --- /dev/null +++ b/packages/mosaic/framework/tools/git/pr-status.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Forge wrapper envelope v1", + "type": "object", + "required": ["schema_version", "operation", "provider", "host", "repo", "number", "head_sha", "data", "error"], + "additionalProperties": false, + "properties": { + "schema_version": {"const": 1}, + "operation": {"type": "string"}, + "provider": {"enum": ["github", "gitea", "forgejo", "unknown"]}, + "host": {"type": ["string", "null"]}, + "repo": {"type": ["string", "null"]}, + "number": {"type": ["integer", "null"]}, + "head_sha": {"type": ["string", "null"]}, + "data": {"type": ["object", "null"]}, + "error": { + "oneOf": [ + {"type": "null"}, + {"type": "object", "required": ["kind", "message", "retry_after_seconds", "write_outcome"], "additionalProperties": false, + "properties": {"kind": {"type": "string"}, "message": {"type": "string"}, "retry_after_seconds": {"type": ["integer", "null"]}, "write_outcome": {"enum": ["none", "confirmed", "unknown"]}}} + ] + } + } +} diff --git a/packages/mosaic/framework/tools/git/pr-status.sh b/packages/mosaic/framework/tools/git/pr-status.sh new file mode 100755 index 000000000..f7d686677 --- /dev/null +++ b/packages/mosaic/framework/tools/git/pr-status.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 -I "$SCRIPT_DIR/forge-api.py" status "$@" diff --git a/packages/mosaic/framework/tools/git/pr-watch-check.sh b/packages/mosaic/framework/tools/git/pr-watch-check.sh new file mode 100755 index 000000000..e5f2f841d --- /dev/null +++ b/packages/mosaic/framework/tools/git/pr-watch-check.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 -I "$SCRIPT_DIR/forge-api.py" watch-check "$@" diff --git a/packages/mosaic/framework/tools/git/test-forge-capabilities.sh b/packages/mosaic/framework/tools/git/test-forge-capabilities.sh new file mode 100755 index 000000000..0c9730845 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-forge-capabilities.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 -I "$SCRIPT_DIR/test-forge-contract.py" diff --git a/packages/mosaic/framework/tools/git/test-forge-contract.py b/packages/mosaic/framework/tools/git/test-forge-contract.py new file mode 100644 index 000000000..464046514 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-forge-contract.py @@ -0,0 +1,191 @@ +"""Offline contracts. Fake responses replace the provider HTTP boundary.""" +import importlib.util +import pathlib +import unittest + +PATH = pathlib.Path(__file__).with_name('forge-api.py') + +class Contract(unittest.TestCase): + @classmethod + def setUpClass(cls): + if PATH.is_file(): + spec = importlib.util.spec_from_file_location('forge_api', PATH) + cls.api = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.api) + + def setUp(self): + self.assertTrue(PATH.is_file(), 'normalized adapter is missing') + + def test_profiles_require_schema_evidence(self): + for provider, version, reply in [('gitea', '28.2.0', 'supported'), ('gitea', '1.24.7', 'unsupported'), ('forgejo', '16.0.0-dev', 'unsupported')]: + p = self.api.capabilities(provider, version) + self.assertEqual(p['review_reply']['support'], reply) + self.assertEqual(p['auto_merge']['support'], 'unsupported') + self.assertEqual(self.api.capabilities('gitea', '99.0.0')['review_reply']['support'], 'unknown') + self.assertEqual(self.api.capabilities('unknown', '')['status']['support'], 'unknown') + + def test_fingerprint_ignores_clock_but_tracks_evidence(self): + a = {'head_sha': 'a', 'observed_at': 'one', 'checks': {'state': 'pending'}} + b = dict(a, observed_at='two') + self.assertEqual(self.api.fingerprint(a), self.api.fingerprint(b)) + b['checks'] = {'state': 'failure'} + self.assertNotEqual(self.api.fingerprint(a), self.api.fingerprint(b)) + + def test_empty_checks_are_not_green(self): + result = self.api.checks([], [], ['build']) + self.assertEqual(result['state'], 'missing') + self.assertTrue(result['complete']) + self.assertEqual(self.api.checks([], [], None)['state'], 'unknown') + self.assertEqual(self.api.checks([], [], [], allow_none=True)['state'], 'not_required') + self.assertEqual(self.api.checks([], [], [])['state'], 'missing') + + def test_required_checks_include_check_runs_and_latest_context(self): + statuses = [{'context':'build','state':'failure','id':1}, {'context':'build','state':'success','id':2}] + runs = [{'name':'test','status':'completed','conclusion':'success','id':3}] + self.assertEqual(self.api.checks(statuses, runs, ['build', 'test'])['state'], 'success') + runs[0]['conclusion'] = 'cancelled' + self.assertEqual(self.api.checks(statuses, runs, ['build', 'test'])['state'], 'cancelled') + self.assertEqual(self.api.checks(statuses, [], ['test'])['state'], 'missing') + + def test_review_must_be_independent_and_current(self): + reviews = [{'id':1,'user':{'login':'reviewer'},'state':'APPROVED','commit_id':'old'}] + self.assertFalse(self.api.reviews(reviews, 'new', 'author')['approved']) + reviews[0]['commit_id'] = 'new' + self.assertTrue(self.api.reviews(reviews, 'new', 'author')['approved']) + reviews[0]['user']['login'] = 'author' + self.assertFalse(self.api.reviews(reviews, 'new', 'author')['approved']) + + +class SnapshotContract(Contract): + def fake(self, race=False, missing=False): + api = self.api + class Fake(api.Client): + def __init__(self): + super().__init__('github.com', 'owner/repo') + self.provider, self.version, self.reads = 'github', 'documented', 0 + def pr(self, number): + self.reads += 1 + return {'number':number,'state':'open','draft':False,'merged':False,'html_url':'https://github.com/owner/repo/pull/1','head':{'sha':('b' if race and self.reads > 1 else 'a')*40,'ref':'task','repo':{'full_name':'fork/repo'}},'base':{'sha':'c'*40,'ref':'integration','repo':{'full_name':'owner/repo'}},'user':{'login':'author'},'mergeable':True,'mergeable_state':'clean'} + def http(self, path, method='GET', body=None, public=False): + if '/protection' in path: + return {'required_status_checks':{'contexts':['build']},'required_pull_request_reviews':{'required_approving_review_count':1,'dismiss_stale_reviews':True}} + raise AssertionError(path) + def pages(self, path, key=None): + if '/statuses/' in path or path.endswith('/statuses'): + return [] if missing else [{'context':'build','state':'success','id':1}] + if '/check-runs' in path: + return [] + if '/reviews' in path: + return [{'id':1,'state':'APPROVED','user':{'login':'reviewer'},'commit_id':'a'*40}] + if '/comments' in path: + return [] + raise AssertionError(path) + def gql(self, query, variables): + return {'repository':{'pullRequest':{'id':'PR_one','autoMergeRequest':None,'mergeQueueEntry':None,'reviewThreads':{'nodes':[],'pageInfo':{'hasNextPage':False,'endCursor':None}}}}} + return Fake() + + def test_ready_needs_all_evidence(self): + result = self.api.snapshot(self.fake(), 1, None) + self.assertTrue(result['ready']) + self.assertEqual(result['head_repository'], 'fork/repo') + result = self.api.snapshot(self.fake(missing=True), 1, None) + self.assertFalse(result['ready']) + self.assertIn('checks_missing', result['blockers']) + + def test_head_race_discards_readiness(self): + with self.assertRaises(self.api.Failure) as ctx: + self.api.snapshot(self.fake(race=True), 1, None) + self.assertEqual(ctx.exception.code, 75) + + def test_partial_collections_fail(self): + c = self.fake() + def broken(path, key=None): + raise self.api.Failure(5, 'access', 'denied') + c.pages = broken + with self.assertRaises(self.api.Failure) as ctx: + self.api.snapshot(c, 1, None) + self.assertEqual(ctx.exception.code, 5) + + +class TransportContract(Contract): + def test_access_and_rate_limit_remain_distinct(self): + import types + original = self.api.run + c = self.api.Client('github.com', 'owner/repo') + c.provider = 'github' + try: + for status, headers, expected in [(403, '', 5), (404, '', 5), (429, 'Retry-After: 37\n', 75)]: + self.api.run = lambda *a, **kw: types.SimpleNamespace(returncode=1, stdout=f'HTTP/2 {status}\n{headers}\n{{"message":"denied"}}') + with self.assertRaises(self.api.Failure) as ctx: + c.http('/repos/owner/repo') + self.assertEqual(ctx.exception.code, expected) + if status == 429: + self.assertEqual(ctx.exception.retry, 37) + finally: + self.api.run = original + + def test_pagination_failure_is_not_empty_success(self): + c = self.api.Client('github.com', 'owner/repo') + c.provider = 'github' + calls = [] + def response(path): + calls.append(path) + if 'page=2' in path: + raise self.api.Failure(5, 'access', 'second page denied') + return [{'id':i} for i in range(100)] + c.http = response + with self.assertRaises(self.api.Failure): + c.pages('/collection') + self.assertEqual(len(calls), 2) + + +class ReviewContract(SnapshotContract): + def test_unsupported_reply_makes_no_write(self): + c = self.fake() + c.provider, c.version = 'forgejo', '16.0.0-dev' + calls = [] + c.http = lambda *args, **kwargs: calls.append(args) + with self.assertRaises(self.api.Failure) as ctx: + self.api.reply(c, 1, 'forgejo:comment:3', 'body', 'a'*40) + self.assertEqual(ctx.exception.code, 4) + self.assertEqual(calls, []) + + def test_wrong_pr_comment_refuses_write(self): + c = self.fake() + with self.assertRaises(self.api.Failure) as ctx: + self.api.reply(c, 1, 'github:comment:99', 'body', 'a'*40) + self.assertEqual(ctx.exception.code, 3) + + def test_reply_verifies_exact_returned_identity(self): + c = self.fake() + original = self.api.review_comments + calls = [] + def collection(*args): + return {'complete':True,'comments':[{'id':'github:comment:3','thread_id':'github:thread:T','resolved':False}], 'reviews':[]}, [], None + def request(path, method='GET', body=None, public=False): + calls.append((path,method,body)) + if path == '/user': + return {'login':'acting-user'} + if method == 'POST': + return {'id':8} + if path.endswith('/8'): + return {'id':8,'body':'body','user':{'login':'acting-user'},'in_reply_to_id':3,'pull_request_url':'https://api.github.com/repos/owner/repo/pulls/1','commit_id':'a'*40} + raise AssertionError(path) + c.http = request + try: + self.api.review_comments = collection + result = self.api.reply(c, 1, 'github:comment:3', 'body', 'a'*40) + self.assertEqual(result['id'], 'github:comment:8') + self.assertTrue(result['verified']) + finally: + self.api.review_comments = original + self.assertEqual(sum(method == 'POST' for _,method,_ in calls), 1) + + def test_resolve_requires_thread_owned_by_pr(self): + c = self.fake() + with self.assertRaises(self.api.Failure) as ctx: + self.api.resolve(c, 1, 'github:thread:foreign', True, 'a'*40) + self.assertEqual(ctx.exception.code, 3) + +if __name__ == '__main__': + unittest.main() -- 2.54.0 From f59a4c3483bc53d33de24ea94bf355a388b315cc Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 14:17:10 -0600 Subject: [PATCH 02/26] feat(framework): enforce declared merge methods and verified integration controls (#1841) Add schema-v3 merge policy with squash-only defaults, protected scheduling, explicit capability failures, normalized CI checks and guarded review mutations. Qualify legacy fake providers with public metadata fixtures. Co-Authored-By: GPT-6.1-Sol (Codex) --- .../framework/tools/git/ci-queue-wait.sh | 2 +- .../framework/tools/git/detect-platform.sh | 12 +- .../mosaic/framework/tools/git/forge-api.py | 279 ++++++++++++- .../framework/tools/git/merge-policy.py | 66 ++++ .../framework/tools/git/mosaic-worktree.sh | 2 +- .../framework/tools/git/pr-auto-merge.sh | 4 + .../mosaic/framework/tools/git/pr-ci-wait.sh | 373 +----------------- .../mosaic/framework/tools/git/pr-create.sh | 4 +- .../framework/tools/git/pr-merge-queue.sh | 4 + .../mosaic/framework/tools/git/pr-merge.sh | 24 +- .../mosaic/framework/tools/git/repo-decl.sh | 32 +- .../git/test-ci-queue-wait-branch-absent.sh | 7 + .../git/test-ci-queue-wait-no-ci-expected.sh | 7 + .../tools/git/test-ci-queue-wait-no-status.sh | 7 + .../tools/git/test-ci-queue-wait-tristate.sh | 7 + .../framework/tools/git/test-forge-cli.py | 119 ++++++ .../tools/git/test-forge-contract.py | 152 ++++++- .../framework/tools/git/test-forge-guard.py | 19 + .../tools/git/test-gitea-login-resolution.sh | 37 +- .../tools/git/test-grant-reviewer.sh | 7 + .../tools/git/test-issue-close-fail-closed.sh | 7 + .../tools/git/test-issue-comment-readback.sh | 7 + .../git/test-issue-create-interactive-auth.sh | 7 + .../tools/git/test-issue-view-comments.sh | 7 + .../framework/tools/git/test-merge-policy.py | 34 ++ .../framework/tools/git/test-pr-auto-merge.sh | 4 + .../test-pr-create-fallback-default-base.sh | 7 + .../framework/tools/git/test-pr-edit.sh | 7 + .../git/test-pr-merge-gitea-empty-uid.sh | 7 + .../tools/git/test-pr-merge-head-pin.sh | 7 + .../tools/git/test-pr-merge-message-field.sh | 9 +- .../tools/git/test-pr-merge-methods.sh | 4 + .../tools/git/test-pr-merge-queue.sh | 4 + .../tools/git/test-pr-metadata-gitea.sh | 7 + .../tools/git/test-pr-review-comments.sh | 4 + .../tools/git/test-pr-review-gitea-comment.sh | 7 + .../tools/git/test-pr-review-reply.sh | 4 + .../git/test-pr-review-repo-host-override.sh | 7 + .../tools/git/test-pr-review-resolve.sh | 4 + .../tools/git/test-pr-skill-contract.py | 30 ++ .../framework/tools/git/test-pr-status.sh | 4 + .../tools/git/test-pr-watch-check.sh | 4 + .../tools/git/test-pr-watch-parity.py | 75 ++++ .../tools/git/test-pr-watch-parity.sh | 4 + .../tools/git/test-repo-decl-consumption.sh | 29 +- .../framework/tools/git/test-wrapper-guard.sh | 2 + .../framework/tools/git/wrapper-guard.sh | 7 + .../structure/test-validate-repo-json.sh | 4 +- .../tools/structure/validate-repo-json.sh | 14 +- 49 files changed, 1049 insertions(+), 432 deletions(-) create mode 100644 packages/mosaic/framework/tools/git/merge-policy.py create mode 100755 packages/mosaic/framework/tools/git/pr-auto-merge.sh create mode 100755 packages/mosaic/framework/tools/git/pr-merge-queue.sh create mode 100644 packages/mosaic/framework/tools/git/test-forge-cli.py create mode 100644 packages/mosaic/framework/tools/git/test-forge-guard.py create mode 100644 packages/mosaic/framework/tools/git/test-merge-policy.py create mode 100755 packages/mosaic/framework/tools/git/test-pr-auto-merge.sh create mode 100755 packages/mosaic/framework/tools/git/test-pr-merge-methods.sh create mode 100755 packages/mosaic/framework/tools/git/test-pr-merge-queue.sh create mode 100755 packages/mosaic/framework/tools/git/test-pr-review-comments.sh create mode 100755 packages/mosaic/framework/tools/git/test-pr-review-reply.sh create mode 100755 packages/mosaic/framework/tools/git/test-pr-review-resolve.sh create mode 100644 packages/mosaic/framework/tools/git/test-pr-skill-contract.py create mode 100755 packages/mosaic/framework/tools/git/test-pr-status.sh create mode 100755 packages/mosaic/framework/tools/git/test-pr-watch-check.sh create mode 100644 packages/mosaic/framework/tools/git/test-pr-watch-parity.py create mode 100755 packages/mosaic/framework/tools/git/test-pr-watch-parity.sh diff --git a/packages/mosaic/framework/tools/git/ci-queue-wait.sh b/packages/mosaic/framework/tools/git/ci-queue-wait.sh index 07f3b87b6..961c4e625 100755 --- a/packages/mosaic/framework/tools/git/ci-queue-wait.sh +++ b/packages/mosaic/framework/tools/git/ci-queue-wait.sh @@ -514,7 +514,7 @@ if git rev-parse --show-toplevel >/dev/null 2>&1 \ repo_decl_load if [[ "$DECL_STATE" == invalid ]]; then repo_decl_report_invalid - elif [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then + elif [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" -ge 2 ]]; then route="feature" if [[ "$BRANCH" == "$DECL_TRUNK" ]]; then route="trunk (integration head)" diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 85af8efbe..15c778e89 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -21,11 +21,19 @@ detect_platform() { if [[ "$host" == github.com || ( -n "${GH_HOST:-}" && "$host" == "$GH_HOST" ) ]]; then PLATFORM=github; export PLATFORM; echo github; return 0 fi - profile=$(python3 -I "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/forge-api.py" capabilities --host "$host" --repo "$(get_repo_info)" --json) || { + profile=$(curl --silent --show-error --fail --max-time 10 --url "https://$host/swagger.v1.json") || { echo "error: forge metadata could not be verified for $host" >&2 return 1 } - rc=$(printf '%s' "$profile" | python3 -I -c 'import json,sys; print(json.load(sys.stdin)["provider"])') || return 1 + rc=$(printf '%s' "$profile" | python3 -I -c ' +import json,sys +try: + info=json.load(sys.stdin)["info"] + title=str(info["title"]).lower(); version=str(info["version"]) + print("forgejo" if "forgejo" in title or "gitea" in title and "+gitea-" in version else "gitea" if "gitea" in title else "unknown") +except (ValueError, KeyError, TypeError): + print("unknown") +') || return 1 if [[ "$rc" == gitea || "$rc" == forgejo ]]; then # Legacy scripts dispatch both API-compatible providers as gitea. FORGE_PROVIDER="$rc"; PLATFORM=gitea diff --git a/packages/mosaic/framework/tools/git/forge-api.py b/packages/mosaic/framework/tools/git/forge-api.py index f4b375f7e..4301ad41c 100644 --- a/packages/mosaic/framework/tools/git/forge-api.py +++ b/packages/mosaic/framework/tools/git/forge-api.py @@ -13,6 +13,7 @@ import pathlib import re import subprocess import sys +import time import urllib.parse HERE = pathlib.Path(__file__).resolve().parent @@ -56,7 +57,7 @@ def checks(statuses, runs, required, allow_none=False): name = s.get('context') if not name: raise Failure(6, 'malformed', 'Status context missing') - observed[name] = s.get('state', 'unknown') + observed[name] = s.get('state') if s.get('state') in ('success','failure','error','pending','cancelled') else 'unknown' for r in sorted(runs, key=lambda r: r.get('id', 0)): name = r.get('name') if not name: @@ -76,7 +77,7 @@ def checks(statuses, runs, required, allow_none=False): return dict(state=state, required=required, observed=observed, complete=True) -def reviews(items, head, author): +def reviews(items, head, author, required_count=1): latest = {} for r in sorted(items, key=lambda r: r.get('id', 0)): login = (r.get('user') or {}).get('login') @@ -84,13 +85,13 @@ def reviews(items, head, author): if login and state not in ('COMMENTED', 'PENDING'): latest[login] = r rejected = any(str(r.get('state')).upper() in ('REQUEST_CHANGES', 'CHANGES_REQUESTED') for r in latest.values()) - approved = any(login != author and str(r.get('state')).upper() == 'APPROVED' and r.get('commit_id') == head for login, r in latest.items()) and not rejected + approved = sum(login != author and str(r.get('state')).upper() == 'APPROVED' and r.get('commit_id') == head for login, r in latest.items()) >= max(1, required_count) and not rejected return dict(decision='changes_requested' if rejected else 'approved' if approved else 'missing', reviewed_sha=head if approved else None, complete=True, approved=approved) -def run(argv, data=None): +def run(argv, data=None, cwd=None, env=None): try: - return subprocess.run(argv, input=data, text=True, capture_output=True, timeout=45) + return subprocess.run(argv, input=data, text=True, capture_output=True, timeout=45, cwd=cwd, env=env) except (OSError, subprocess.TimeoutExpired): raise Failure(75, 'transient', 'Provider client unavailable or timed out') @@ -120,7 +121,7 @@ class Client: def credential(self): if self.token is None: # Fixed script/arguments; no user input becomes shell source. - p = run(['bash', '-c', 'source "$1/detect-platform.sh"; get_gitea_token "$2"', 'forge-auth', str(HERE), self.host]) + p = run(['bash', '-c', 'source "$1/detect-platform.sh"; get_gitea_token "$2"', 'forge-auth', str(HERE), self.host], cwd='/') if p.returncode or not p.stdout.strip(): raise Failure(5, 'access', 'Acting identity has no credential for this host') self.token = p.stdout.strip() @@ -132,7 +133,7 @@ class Client: args = ['gh', 'api', '--hostname', self.host, '--include', '--method', method, path] if body is not None: args += ['--input', '-'] - p = run(args, json.dumps(body) if body is not None else None) + p = self.transport(args, body, writing) else: url = 'https://' + self.host + (path if public else '/api/v1' + path) args = ['curl', '--silent', '--show-error', '--include', '--max-time', '30', '--request', method, '--header', 'Accept: application/json', '--header', 'User-Agent: mosaic-forge-wrapper'] @@ -141,8 +142,8 @@ class Client: if body is not None: args += ['--header', 'Content-Type: application/json', '--data-binary', '@-'] args += ['--url', url] - p = run(args, json.dumps(body) if body is not None else None) - if p.returncode and not p.stdout.startswith('HTTP/'): + p = self.transport(args, body, writing) + if p.returncode and (self.provider != 'github' or not p.stdout.startswith('HTTP/')): raise Failure(75, 'transient', 'Provider transport failed; reconcile before retrying a mutation', outcome='unknown' if writing else 'none') raw = p.stdout.replace('\r\n', '\n') # Proxies can emit an initial CONNECT response. Only consume header blocks. @@ -176,6 +177,14 @@ class Client: raise Failure(6, 'malformed', 'Provider returned invalid JSON', outcome='unknown' if writing else 'none') return result + def transport(self, args, body, writing): + try: + return run(args, json.dumps(body) if body is not None else None) + except Failure as e: + if writing: + e.outcome = 'unknown' + raise + def pages(self, path, key=None): result = [] for page in range(1, MAX_PAGES + 1): @@ -193,7 +202,7 @@ class Client: def gql(self, query, variables): result = self.http('graphql', 'POST', {'query': query, 'variables': variables}) if not isinstance(result, dict) or result.get('errors') or not isinstance(result.get('data'), dict): - raise Failure(6, 'incomplete', 'GraphQL returned errors or incomplete data') + raise Failure(6, 'incomplete', 'GraphQL returned errors or incomplete data', outcome='unknown' if query.lstrip().startswith('mutation') else 'none') return result['data'] def pr(self, number): @@ -206,7 +215,7 @@ class Client: def require(client, operation): cap = capabilities(client.provider, client.version)[operation] if cap['support'] != 'supported': - raise Failure(4, 'unsupported', cap['reason']) + raise Failure(4 if cap['support'] == 'unsupported' else 6, 'unsupported' if cap['support'] == 'unsupported' else 'incomplete', cap['reason']) def same_head(client, number, expected): @@ -223,14 +232,14 @@ def github_conversations(client, number): for _ in range(MAX_PAGES): q = '''query($owner:String!,$repo:String!,$number:Int!,$cursor:String) { repository(owner:$owner,name:$repo) { pullRequest(number:$number) { - id autoMergeRequest { mergeMethod } mergeQueueEntry { id } + id reviewDecision autoMergeRequest { mergeMethod } mergeQueueEntry { id } reviewThreads(first:100,after:$cursor) { nodes { ''' + fields + ''' } pageInfo { hasNextPage endCursor } } } } }''' pr = client.gql(q, dict(owner=owner, repo=repo, number=number, cursor=cursor))['repository']['pullRequest'] if not isinstance(pr, dict) or not pr.get('id') or 'autoMergeRequest' not in pr or 'mergeQueueEntry' not in pr: raise Failure(6, 'incomplete', 'Missing integration state') if integration is None: - integration = dict(id=pr['id'], auto_merge={'state':'enabled' if pr['autoMergeRequest'] else 'disabled', 'method':(pr['autoMergeRequest'] or {}).get('mergeMethod')}, queue={'state':'queued' if pr['mergeQueueEntry'] else 'absent', 'entry_id':(pr['mergeQueueEntry'] or {}).get('id')}) + integration = dict(id=pr['id'], review_decision=pr.get('reviewDecision'), auto_merge={'state':'enabled' if pr['autoMergeRequest'] else 'disabled', 'method':(pr['autoMergeRequest'] or {}).get('mergeMethod')}, queue={'state':'queued' if pr['mergeQueueEntry'] else 'absent', 'entry_id':(pr['mergeQueueEntry'] or {}).get('id')}) connection = pr['reviewThreads'] for thread in connection['nodes']: comments = thread['comments'] @@ -298,7 +307,7 @@ def review_comments(client, number): parent = c.get('in_reply_to_id') # A resolver proves a resolved comment; it does not prove a thread graph. resolved = t['isResolved'] if t else bool(c.get('resolver')) if 'resolver' in c else None - comments.append(dict(id=f'{client.provider}:comment:{cid}', review_id=c.get('pull_request_review_id'), thread_id=f"github:thread:{t['id']}" if t else None, reply_to=f'{client.provider}:comment:{parent}' if parent else None, author=(c.get('user') or {}).get('login'), body=c.get('body'), path=c.get('path'), line=c.get('line') or c.get('new_position'), commit_sha=c.get('commit_id'), outdated=t['isOutdated'] if t else c.get('outdated'), resolved=resolved, url=c.get('html_url') or c.get('url'))) + comments.append(dict(id=f'{client.provider}:comment:{cid}', review_id=c.get('pull_request_review_id'), thread_id=f"github:thread:{t['id']}" if t else None, reply_to=f'{client.provider}:comment:{parent}' if parent else None, author=(c.get('user') or {}).get('login'), body=c.get('body'), path=c.get('path'), line=c.get('line') or c.get('position') or c.get('new_position'), commit_sha=c.get('commit_id'), outdated=t['isOutdated'] if t else c.get('outdated'), resolved=resolved, url=c.get('html_url') or c.get('url'))) return dict(complete=True, reviews=revs, comments=comments), threads, integration @@ -308,13 +317,19 @@ def protection(client, base): status = p.get('required_status_checks') required = sorted(set((status or {}).get('contexts', []) + [c['context'] for c in (status or {}).get('checks', [])])) if status is not None else [] review = p.get('required_pull_request_reviews') or {} + client.review_count = review.get('required_approving_review_count', 1) + client.codeowners_required = review.get('require_code_owner_reviews') is True enforced = bool(required and review.get('required_approving_review_count', 0) > 0 and review.get('dismiss_stale_reviews') is True and (p.get('enforce_admins') or {}).get('enabled') is True) + if status is not None and any(c.get('app_id') not in (None,-1) for c in status.get('checks',[])): + # App-bound contexts need source-specific matching; unknown never clears a gate. + required = None return required, enforced rules = client.pages(client.root + '/branch_protections') matching = [r for r in rules if r.get('branch_name') == base] if len(matching) != 1: return None, False r = matching[0] + client.review_count = r.get('required_approvals',1) return (r.get('status_check_contexts') if r.get('enable_status_check') is True else None), False @@ -332,7 +347,9 @@ def snapshot(client, number, expected): check = checks(statuses, runs, required) comments, threads, integration = review_comments(client, number) issue_comments = client.pages(root + '/issues/' + str(number) + '/comments') - review = reviews(comments['reviews'], head, (p.get('user') or {}).get('login')) + review = reviews(comments['reviews'], head, (p.get('user') or {}).get('login'), getattr(client,'review_count',1)) + if getattr(client,'codeowners_required',False) and (integration or {}).get('review_decision') != 'APPROVED': + review.update(approved=False,decision='codeowner_approval_unknown',reviewed_sha=None) unresolved = sum(t['isResolved'] is False for t in threads) if client.provider == 'github' else sum(c['resolved'] is False for c in comments['comments']) if all(c['resolved'] is not None for c in comments['comments']) else None thread_state = dict(unresolved=unresolved, complete=comments['complete'] and unresolved is not None) state = 'merged' if p.get('merged') is True else p['state'] @@ -350,7 +367,7 @@ def snapshot(client, number, expected): blockers.append('review_threads_unresolved_or_unknown') if mergeability != 'clean': blockers.append('mergeability_' + mergeability) - data = dict(url=p.get('html_url'), state=state, draft=p.get('draft'), head_ref=p['head'].get('ref'), head_sha=head, base_ref=base.get('ref'), base_sha=base.get('sha'), head_repository=(p['head'].get('repo') or {}).get('full_name'), base_repository=(base.get('repo') or {}).get('full_name'), merge_sha=p.get('merge_commit_sha') if state == 'merged' else None, mergeability=mergeability, checks=check, reviews=review, threads=thread_state, auto_merge=integration['auto_merge'] if integration else {'state':'unsupported'}, queue=integration['queue'] if integration else {'state':'unsupported'}, capabilities=capabilities(client.provider, client.version), ready=not blockers, blockers=blockers, comments=comments['comments'], issue_comments=issue_comments, protection_enforced=enforced, observed_at=datetime.datetime.now(datetime.timezone.utc).isoformat()) + data = dict(url=p.get('html_url'), state=state, draft=p.get('draft'), head_ref=p['head'].get('ref'), head_sha=head, base_ref=base.get('ref'), base_sha=base.get('sha'), head_repository=(p['head'].get('repo') or {}).get('full_name'), base_repository=(base.get('repo') or {}).get('full_name'), merge_sha=p.get('merge_commit_sha') if state == 'merged' else None, mergeability=mergeability, checks=check, reviews=review, threads=thread_state, auto_merge=integration['auto_merge'] if integration else {'state':'unsupported'}, queue=integration['queue'] if integration else {'state':'unsupported'}, capabilities=capabilities(client.provider, client.version), ready=not blockers, blockers=blockers, comments=comments['comments'], review_records=comments['reviews'], issue_comments=issue_comments, protection_enforced=enforced, observed_at=datetime.datetime.now(datetime.timezone.utc).isoformat()) after = same_head(client, number, head) if after['base'].get('sha') != base.get('sha') or after.get('state') != p.get('state') or after.get('merged') != p.get('merged'): raise Failure(75, 'snapshot_changed', 'PR base or closure changed during aggregation') @@ -437,9 +454,200 @@ def resolve(client, number, opaque, desired, expected): return dict(thread_id=opaque, resolved=desired, verified=True, changed=True) +def forge_method(client, method): + if method not in ('squash','merge','rebase','rebase-merge','fast-forward-only'): + raise Failure(2, 'usage', 'Unknown merge method') + if client.provider == 'github' and method in ('rebase-merge','fast-forward-only'): + raise Failure(4, 'unsupported', 'GitHub has no ' + method + ' merge method') + settings = client.http(client.root) + key = ({'squash':'allow_squash_merge','merge':'allow_merge_commit','rebase':'allow_rebase_merge'} if client.provider == 'github' else {'squash':'allow_squash_merge','merge':'allow_merge_commits','rebase':'allow_rebase','rebase-merge':'allow_rebase_explicit','fast-forward-only':'allow_fast_forward_only_merge'})[method] + if settings.get(key) is False: + raise Failure(4, 'unsupported', 'Forge repository setting ' + key + ' disables method') + if settings.get(key) is not True: + raise Failure(6, 'incomplete', 'Forge repository setting ' + key + ' could not be verified') + return settings + + +def declared_method(client, p, requested): + root = run(['git','rev-parse','--show-toplevel']) + if root.returncode: + raise Failure(3,'policy','POLICY-REFUSED: mutation requires a local repository declaration context') + declaration = pathlib.Path(root.stdout.strip())/'.mosaic/repo.json' + origin = run(['git','remote','get-url','origin']).stdout.strip() + parsed = urllib.parse.urlparse(origin if not origin.startswith('git@') else 'ssh://' + origin.replace(':','/',1)) + if parsed.hostname != client.host.split(':')[0] or parsed.path.strip('/').removesuffix('.git') != client.repo: + raise Failure(3,'policy','POLICY-REFUSED: explicit target differs from local origin') + if not declaration.exists(): + if requested not in (None, 'squash'): + raise Failure(3,'policy','POLICY-REFUSED: absent merge policy permits squash only') + # Preserve legacy base behavior, requiring the provider default branch. + default = client.http(client.root).get('default_branch') + if p['base']['ref'] != default: + raise Failure(3,'policy','POLICY-REFUSED: absent declaration requires the repository default base') + return 'squash' + validator = run([str(HERE.parent/'structure/validate-repo-json.sh'),str(declaration),'--mode','display']) + if validator.returncode: + raise Failure(6,'malformed','Invalid repository declaration: ' + validator.stdout.strip()) + # Validate canonical authority through the existing shared helper as well. + gate = run(['bash','-c','source "$1/repo-decl.sh"; repo_decl_load; [[ "$DECL_STATE" == valid ]] && repo_decl_remote_matches','forge-policy',str(HERE)]) + if gate.returncode: + raise Failure(3,'policy','POLICY-REFUSED: declaration canonical remote differs from origin') + args = ['python3','-I',str(HERE/'merge-policy.py'),str(declaration),'--head',p['head']['ref'],'--base',p['base']['ref']] + if requested: + args += ['--method',requested] + result = run(args) + value = json.loads(result.stdout) + if result.returncode: + raise Failure(result.returncode,'policy' if result.returncode == 3 else 'malformed',value.get('error','Merge policy refused')) + return value['method'] + + +def queue_guard(client, p): + # This is the CI push/merge guard, never GitHub's PR merge queue. + git_dir = run(['git','rev-parse','--absolute-git-dir']) + work_tree = run(['git','rev-parse','--show-toplevel']) + if git_dir.returncode or work_tree.returncode: + raise Failure(3,'gate','CI admission needs the verified local repository context') + guard_env = dict(os.environ,GIT_DIR=git_dir.stdout.strip(),GIT_WORK_TREE=work_tree.stdout.strip()) + result = run([str(HERE/'ci-queue-wait.sh'),'--purpose','merge','--repo',client.repo,'--sha',p['head']['sha'],'-B',p['head']['ref'],'-t','1','-i','1'],cwd='/',env=guard_env) + if result.returncode: + mapping = {75:75, 78:3, 124:124} + raise Failure(mapping.get(result.returncode,3),'gate','CI queue guard refused (legacy exit %s)' % result.returncode) + + +def merge(client, number, requested, expected): + require(client, 'merge') + p = same_head(client,number,expected) + method = declared_method(client,p,requested) + forge_method(client,method) + status = snapshot(client,number,expected) + if not status['ready']: + raise Failure(3,'gate','Merge readiness failed: ' + ', '.join(status['blockers'])) + queue_guard(client,p) + same_head(client,number,expected) + if client.provider == 'github': + client.http(client.root+'/pulls/'+str(number)+'/merge','PUT',{'merge_method':method,'sha':expected}) + else: + key = 'do' if client.version == '28.2.0' else 'Do' + client.http(client.root+'/pulls/'+str(number)+'/merge','POST',{key:method,'head_commit_id':expected}) + try: + after = same_head(client,number,expected) + if after.get('merged') is not True or not after.get('merge_commit_sha'): + raise Failure(6,'verification','Merge not proven by PR readback',outcome='unknown') + except Failure as e: + e.outcome='unknown' + raise + return dict(requested_method=requested, effective_method=method, merged=True, merge_sha=after['merge_commit_sha'],head_sha=expected,base_ref=after['base']['ref']) + + +def integration(client, number, operation, action, requested, expected): + require(client,operation) + if action not in (('inspect','enable','disable') if operation == 'auto_merge' else ('inspect','enqueue','dequeue')): + raise Failure(2,'usage','Invalid action for integration operation') + p = client.pr(number) + if action != 'inspect': + if not expected: + raise Failure(2,'usage','Mutation requires --expect-head') + p = same_head(client,number,expected) + _, state = github_conversations(client,number) + key = 'auto_merge' if operation == 'auto_merge' else 'queue' + before = state[key] + if action == 'inspect': + same_head(client,number,p['head']['sha']) + return dict(supported=True,state=before['state'],method=before.get('method'),entry_id=before.get('entry_id'),expected_head=expected,verified=True) + enabling = action in ('enable','enqueue') + method = before.get('method') + if enabling: + method = declared_method(client,p,requested) + settings = forge_method(client,method) + _, enforced = protection(client,p['base']['ref']) + if not enforced: + raise Failure(3,'gate','Scheduling requires enforced checks, independent reviews, stale approval dismissal and no administrator bypass') + if p.get('state') != 'open' or p.get('draft') is not False: + raise Failure(3,'gate','Scheduling requires an open non-draft PR') + owner, repo = client.repo.split('/') + queue = client.gql('query($owner:String!,$repo:String!,$branch:String!) { repository(owner:$owner,name:$repo) { mergeQueue(branch:$branch) { configuration { mergeMethod } } } }',{'owner':owner,'repo':repo,'branch':p['base']['ref']})['repository']['mergeQueue'] + queue_method = ((queue or {}).get('configuration') or {}).get('mergeMethod') + if action == 'enqueue' and not queue_method: + raise Failure(4,'unsupported','No configured merge queue method available') + if queue_method and queue_method.lower() != method: + raise Failure(3,'policy','POLICY-REFUSED: queue effective method conflicts with requested/declaration method') + if action == 'enable' and settings.get('allow_auto_merge') is not True: + raise Failure(4,'unsupported','Repository allow_auto_merge is disabled or unknown') + queue_guard(client,p) + same_head(client,number,expected) + variables = {'id':state['id']} + if action == 'enable': + query = 'mutation($id:ID!,$head:GitObjectID!,$method:PullRequestMergeMethod!) { enablePullRequestAutoMerge(input:{pullRequestId:$id,expectedHeadOid:$head,mergeMethod:$method}) { pullRequest { id } } }' + variables.update(head=expected,method=method.upper()) + elif action == 'disable': + query = 'mutation($id:ID!) { disablePullRequestAutoMerge(input:{pullRequestId:$id}) { pullRequest { id } } }' + elif action == 'enqueue': + query = 'mutation($id:ID!,$head:GitObjectID!) { enqueuePullRequest(input:{pullRequestId:$id,expectedHeadOid:$head}) { mergeQueueEntry { id } } }' + variables['head']=expected + else: + query = 'mutation($id:ID!) { dequeuePullRequest(input:{id:$id}) { mergeQueueEntry { id } } }' + client.gql(query,variables) + try: + after = same_head(client,number,expected) + if after.get('merged') is True: + if not after.get('merge_commit_sha'): + raise Failure(6,'verification','Merged PR lacks merge SHA',outcome='unknown') + return dict(supported=True,state='merged',method=method,entry_id=None,expected_head=expected,verified=True,merge_sha=after['merge_commit_sha']) + _, readback = github_conversations(client,number) + desired = ('enabled' if enabling else 'disabled') if operation == 'auto_merge' else ('queued' if enabling else 'absent') + actual = readback[key] + if actual['state'] != desired or enabling and operation == 'auto_merge' and actual.get('method','').lower() != method or desired == 'queued' and not actual.get('entry_id'): + raise Failure(6,'verification','Integration readback does not prove requested state',outcome='unknown') + same_head(client,number,expected) + except Failure as e: + e.outcome='unknown' + raise + return dict(supported=True,state=desired,method=method,entry_id=actual.get('entry_id'),expected_head=expected,verified=True) + + +def workflow_verdict(data, mode): + if data.get('state') == 'merged': + return 'merged' + if data.get('state') == 'closed': + return 'closed_unmerged' + if mode == 'full-stop' and (data.get('auto_merge',{}).get('state') != 'disabled' or data.get('queue',{}).get('state') != 'absent'): + return 'blocked' + if data.get('reviews',{}).get('decision') in ('changes_requested','codeowner_approval_unknown'): + return 'blocked' + if data.get('checks',{}).get('state') in ('failure','cancelled','unknown','missing'): + return 'blocked' + if data.get('ready') is True: + return 'ready_to_merge' + if data.get('mergeability') in ('conflict','unknown') or any(b.startswith('review_threads') for b in data.get('blockers',[])): + return 'blocked' + return 'waiting' + + +def ci_wait(client, number, expected, timeout, interval): + if timeout <= 0 or interval <= 0: + raise Failure(2,'usage','Timeout and interval must be positive') + deadline = time.monotonic() + timeout + while True: + data = snapshot(client,number,expected) + state = data['checks']['state'] + if state in ('success','not_required'): + return data + if state in ('failure','cancelled'): + raise Failure(3,'gate','CI reached terminal ' + state) + if time.monotonic() >= deadline: + raise Failure(124,'timeout','CI wait timed out') + time.sleep(min(interval,max(0,deadline-time.monotonic()))) + + def main(): - parser = argparse.ArgumentParser(description='Versioned forge adapter. See README for provider limitations.') - parser.add_argument('operation', choices=['capabilities', 'status', 'watch-check', 'review-comments', 'review-reply', 'review-resolve']) + class Parser(argparse.ArgumentParser): + def error(self, message): + print(message, file=sys.stderr) + print(json.dumps(dict(schema_version=1,operation=sys.argv[1] if len(sys.argv)>1 else 'unknown',provider='unknown',host=None,repo=None,number=None,head_sha=None,data=None,error=dict(kind='usage',message=message,retry_after_seconds=None,write_outcome='none')))) + raise SystemExit(2) + parser = Parser(description='Versioned forge adapter. See README for provider limitations.') + parser.add_argument('operation', choices=['capabilities', 'status', 'watch-check', 'review-comments', 'review-reply', 'review-resolve', 'merge', 'auto-merge', 'merge-queue', 'ci-wait']) parser.add_argument('-n', '--number', type=int) parser.add_argument('-r', '--repo') parser.add_argument('--host') @@ -447,6 +655,10 @@ def main(): parser.add_argument('--operation', dest='requested_operation') parser.add_argument('--expect-head') parser.add_argument('--baseline') + parser.add_argument('-m', '--method') + parser.add_argument('--action') + parser.add_argument('-t','--timeout',type=int,default=1800) + parser.add_argument('-i','--interval',type=int,default=15) parser.add_argument('--comment-id') parser.add_argument('--thread-id') parser.add_argument('--body-file') @@ -462,6 +674,18 @@ def main(): raise Failure(2, 'usage', '-n NUMBER is required') if args.expect_head and not re.fullmatch('[0-9a-fA-F]{40}', args.expect_head): raise Failure(2, 'usage', '--expect-head requires a full SHA') + if args.operation in ('review-reply','review-resolve','merge') and not args.expect_head: + raise Failure(2,'usage','Mutation requires --expect-head') + if args.operation == 'review-reply' and (not args.comment_id or not args.body_file or not pathlib.Path(args.body_file).is_file()): + raise Failure(2,'usage','Reply requires --comment-id and a readable --body-file') + if args.operation == 'review-resolve' and (not args.thread_id or not (args.resolve or args.unresolve)): + raise Failure(2,'usage','Resolution requires --thread-id and --resolve or --unresolve') + if args.operation in ('auto-merge','merge-queue') and args.action not in (('inspect','enable','disable') if args.operation == 'auto-merge' else ('inspect','enqueue','dequeue')): + raise Failure(2,'usage','Explicit --action is required') + if args.operation in ('auto-merge','merge-queue') and args.action != 'inspect' and not args.expect_head: + raise Failure(2,'usage','Mutation requires --expect-head') + if args.operation == 'watch-check' and (not args.baseline or not os.path.isabs(args.baseline)): + raise Failure(2,'usage','--baseline requires an absolute path') if not args.host or not args.repo: p = run(['git', 'remote', 'get-url', 'origin']) remote = p.stdout.strip() @@ -480,6 +704,17 @@ def main(): if args.requested_operation not in data['operations']: raise Failure(2, 'usage', 'Unknown capability operation') require(client, args.requested_operation) + elif args.operation == 'ci-wait': + data = ci_wait(client,args.number,args.expect_head,args.timeout,args.interval) + head = data['head_sha'] + elif args.operation in ('merge','auto-merge','merge-queue'): + if args.operation == 'merge': + if not args.expect_head: + raise Failure(2,'usage','Merge requires --expect-head') + data = merge(client,args.number,args.method,args.expect_head) + else: + data = integration(client,args.number,'auto_merge' if args.operation == 'auto-merge' else 'merge_queue',args.action,args.method,args.expect_head) + head = args.expect_head elif args.operation == 'review-comments': before = client.pr(args.number)['head']['sha'] data, _, _ = review_comments(client, args.number) @@ -513,10 +748,14 @@ def main(): baseline = json.loads(pathlib.Path(args.baseline).read_text()) except (OSError, ValueError): raise Failure(6, 'malformed', 'Baseline cannot be read as JSON') + if baseline.get('schema_version') != 1 or baseline.get('error') is not None or not re.fullmatch('[0-9a-f]{64}',(baseline.get('data') or {}).get('fingerprint','')): + raise Failure(6,'malformed','Baseline is not a successful version-1 snapshot') if any(baseline.get(k) != v for k, v in [('provider', client.provider), ('host', args.host), ('repo', args.repo), ('number', args.number)]): raise Failure(6, 'malformed', 'Baseline belongs to a different PR/watch owner') - code = 0 if data['state'] != 'open' or data['ready'] or data['checks']['state'] in ('failure', 'cancelled') or data['fingerprint'] != (baseline.get('data') or {}).get('fingerprint') else 1 + code = 0 if data['state'] != 'open' or data['ready'] or workflow_verdict(data,'full-send') == 'blocked' or data['fingerprint'] != (baseline.get('data') or {}).get('fingerprint') else 1 except Failure as e: + if data is None and args.operation in ('status','watch-check','ci-wait'): + data = dict(ready=False,checks={'state':'unknown','complete':False},reviews={'decision':'unknown','complete':False},threads={'unresolved':None,'complete':False}) code = e.code error = dict(kind=e.kind, message=str(e), retry_after_seconds=e.retry, write_outcome=e.outcome) print(str(e), file=sys.stderr) @@ -524,6 +763,8 @@ def main(): code = 6 error = dict(kind='malformed', message='Provider evidence malformed: ' + str(e), retry_after_seconds=None, write_outcome='none') print(json.dumps(dict(schema_version=1, operation=args.operation, provider=client.provider if client else 'unknown', host=args.host, repo=args.repo, number=args.number, head_sha=head, data=data, error=error), sort_keys=True)) + if args.operation == 'ci-wait' and not args.json and code not in (0,2,124): + return 1 return code diff --git a/packages/mosaic/framework/tools/git/merge-policy.py b/packages/mosaic/framework/tools/git/merge-policy.py new file mode 100644 index 000000000..52d7c7f2c --- /dev/null +++ b/packages/mosaic/framework/tools/git/merge-policy.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Validate and resolve the optional v3 merge declaration. No provider writes.""" +import argparse +import json +import pathlib +import sys + +METHODS = {'squash', 'merge', 'rebase', 'rebase-merge', 'fast-forward-only'} +SHAPES = {'task', 'promotion', 'absorb'} + + +def policy(doc): + if 'merge' not in doc: + return {'allowed_methods':['squash'], 'default_method':'squash', 'shapes':{}} + if doc.get('schema_version') != 3: + raise ValueError('merge requires schema_version 3') + p = doc['merge'] + if not isinstance(p, dict) or set(p) - {'allowed_methods','default_method','shapes'}: + raise ValueError('merge must be an object containing only allowed_methods, default_method and shapes') + allowed = p.get('allowed_methods') + if not isinstance(allowed, list) or not allowed or any(not isinstance(m,str) or m not in METHODS for m in allowed) or len(allowed) != len(set(allowed)): + raise ValueError('merge.allowed_methods must be a non-empty unique subset of supported methods') + if not isinstance(p.get('default_method'), str) or p['default_method'] not in allowed: + raise ValueError('merge.default_method must be in allowed_methods') + shapes = p.get('shapes', {}) + if not isinstance(shapes, dict) or any(k not in SHAPES or not isinstance(v,str) or v not in allowed for k,v in shapes.items()): + raise ValueError('merge.shapes accepts task, promotion and absorb, with methods in allowed_methods') + return dict(p, shapes=shapes) + + +def resolve(doc, head, base, request): + p = policy(doc) + trunk, release = doc.get('integration_trunk'), doc.get('release_branch') + shape = 'promotion' if head == trunk and base == release and trunk != release else 'absorb' if head == release and base == trunk and trunk != release else 'task' + mapped = p['shapes'].get(shape) + selected = request or mapped or p['default_method'] + if selected not in p['allowed_methods'] or mapped and selected != mapped: + return 3, {'error':'POLICY-REFUSED: requested method conflicts with repository merge policy', 'shape':shape} + if shape == 'absorb' and not mapped: + return 3, {'error':'POLICY-REFUSED: absorb transition requires an explicit shape mapping', 'shape':shape} + if base != trunk and not (shape == 'promotion' and doc.get('flow') == 'trunk-release'): + return 3, {'error':'POLICY-REFUSED: undeclared target transition', 'shape':shape} + return 0, dict(method=selected, shape=shape, allowed_methods=p['allowed_methods']) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('file') + parser.add_argument('--validate', action='store_true') + parser.add_argument('--head') + parser.add_argument('--base') + parser.add_argument('--method') + a = parser.parse_args() + try: + doc = json.loads(pathlib.Path(a.file).read_text()) + if not isinstance(doc,dict): + raise ValueError('declaration must be an object') + p = policy(doc) + code, value = (0,p) if a.validate else resolve(doc,a.head,a.base,a.method) + except (OSError, ValueError, TypeError) as e: + code, value = 6, {'error':'VALIDATION_ERROR merge: ' + str(e)} + print(json.dumps(value, sort_keys=True)) + return code + +if __name__ == '__main__': + sys.exit(main()) diff --git a/packages/mosaic/framework/tools/git/mosaic-worktree.sh b/packages/mosaic/framework/tools/git/mosaic-worktree.sh index ef8ff4dbe..cc35ede98 100755 --- a/packages/mosaic/framework/tools/git/mosaic-worktree.sh +++ b/packages/mosaic/framework/tools/git/mosaic-worktree.sh @@ -221,7 +221,7 @@ fi repo_decl_warn_absent_irreversible "worktree branch-creation" ;; valid) - if [ "$DECL_SCHEMA" = 2 ] && [ "$DECL_POLICY" = "orchestrator-precreated" ]; then + if [ "$DECL_SCHEMA" -ge 2 ] && [ "$DECL_POLICY" = "orchestrator-precreated" ]; then echo "repo-decl: worktree_policy=orchestrator-precreated (TRANSITIONAL, spec 4.5): tasking pre-creates worktrees; creating one directly is the interim path until the J3/#1174 amendment unblocks the wrapper consumer." >&2 fi if [ -n "${MOSAIC_HOST_ROOT:-}" ] && [ -n "$DECL_WT_ROOT" ]; then diff --git a/packages/mosaic/framework/tools/git/pr-auto-merge.sh b/packages/mosaic/framework/tools/git/pr-auto-merge.sh new file mode 100755 index 000000000..52b983c5f --- /dev/null +++ b/packages/mosaic/framework/tools/git/pr-auto-merge.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 -I "$SCRIPT_DIR/forge-api.py" auto-merge "$@" diff --git a/packages/mosaic/framework/tools/git/pr-ci-wait.sh b/packages/mosaic/framework/tools/git/pr-ci-wait.sh index 1a4775939..6e44e3ee7 100755 --- a/packages/mosaic/framework/tools/git/pr-ci-wait.sh +++ b/packages/mosaic/framework/tools/git/pr-ci-wait.sh @@ -1,372 +1,5 @@ -#!/bin/bash -# pr-ci-wait.sh - Wait for PR CI status to reach terminal state (GitHub/Gitea) -# Usage: pr-ci-wait.sh -n [-r owner/repo] [-t timeout_sec] [-i interval_sec] - +#!/usr/bin/env bash +# CI wait consumes normalized checks; default exits preserve 0/1/2/124. set -euo pipefail - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/detect-platform.sh" - -PR_NUMBER="" -TIMEOUT_SEC=1800 -INTERVAL_SEC=15 -REPO_OVERRIDE="" -HOST_OVERRIDE="" - -usage() { - cat < [-t timeout_sec] [-i interval_sec] - -Options: - -n, --number NUMBER PR number (required) - -r, --repo OWNER/REPO Repository slug (default: infer from git origin) - --host HOST Gitea host for --repo API calls (or set GITEA_HOST/GITEA_URL) - -t, --timeout SECONDS Max wait time in seconds (default: 1800) - -i, --interval SECONDS Poll interval in seconds (default: 15) - -h, --help Show this help - -Examples: - $(basename "$0") -n 643 - $(basename "$0") -n 643 --repo ddk/ai-bma - $(basename "$0") -n 643 -t 900 -i 10 -EOF -} - -# get_remote_host and get_gitea_token are provided by detect-platform.sh - -extract_state_from_status_json() { - # Capture piped JSON BEFORE invoking `python3 - <&2 - usage >&2 - exit 1 - ;; - esac -done - -if [[ -z "$PR_NUMBER" ]]; then - echo "Error: PR number is required (-n)." >&2 - usage >&2 - exit 1 -fi - -if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; then - echo "Error: timeout and interval must be integer seconds." >&2 - exit 1 -fi - -if [[ -n "$REPO_OVERRIDE" ]]; then - REPO_INFO="$REPO_OVERRIDE" - PLATFORM=$(detect_platform 2>/dev/null || echo gitea) -else - detect_platform > /dev/null - REPO_INFO=$(get_repo_info) -fi - -if [[ -z "$REPO_INFO" || "$REPO_INFO" == error:* || "$REPO_INFO" != */* ]]; then - echo "Error: Could not determine repository from git origin. Run from a repo or pass --repo owner/repo." >&2 - exit 1 -fi - -OWNER=${REPO_INFO%%/*} -REPO=${REPO_INFO##*/} -START_TS=$(date +%s) -DEADLINE_TS=$((START_TS + TIMEOUT_SEC)) - -if [[ "$PLATFORM" == "github" ]]; then - if ! command -v gh >/dev/null 2>&1; then - echo "Error: gh CLI is required for GitHub CI status polling." >&2 - exit 1 - fi - HEAD_SHA=$(github_get_pr_head_sha) - if [[ -z "$HEAD_SHA" ]]; then - echo "Error: Could not resolve head SHA for PR #$PR_NUMBER." >&2 - exit 1 - fi - echo "[pr-ci-wait] Platform=github PR=#${PR_NUMBER} head_sha=${HEAD_SHA}" -elif [[ "$PLATFORM" == "gitea" ]]; then - if [[ -n "$HOST_OVERRIDE" ]]; then - HOST="$HOST_OVERRIDE" - elif [[ -n "$REPO_OVERRIDE" ]]; then - HOST=$(get_gitea_api_host_for_repo_override) || { - echo "Error: Gitea host is required with --repo. Pass --host or set GITEA_HOST/GITEA_URL." >&2 - exit 1 - } - else - HOST=$(get_remote_host) || { - echo "Error: Could not determine Gitea host from git origin." >&2 - exit 1 - } - fi - TOKEN=$(get_gitea_token "$HOST") || { - echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2 - exit 1 - } - HEAD_SHA=$(gitea_get_pr_head_sha "$HOST" "$OWNER/$REPO" "$TOKEN") - if [[ -z "$HEAD_SHA" ]]; then - echo "Error: Could not resolve head SHA for PR #$PR_NUMBER." >&2 - exit 1 - fi - echo "[pr-ci-wait] Platform=gitea host=${HOST} repo=${OWNER}/${REPO} PR=#${PR_NUMBER} head_sha=${HEAD_SHA}" -else - echo "Error: Unsupported platform '${PLATFORM}'." >&2 - exit 1 -fi - -# No-CI determination is TWO-TIER (primary: CI history; secondary: empty-poll streak). -# -# PRIMARY — "does this repo run CI at all?" Probed once, up front, from the DEFAULT -# BRANCH's commit status. A repo whose default branch carries CI statuses -# demonstrably runs CI, so an EMPTY status on the PR head means the pipeline simply -# has not registered YET (webhook/queue lag) — NOT that the repo is CI-less. In that -# case we must NEVER fast-green; we keep polling until the pipeline registers or the -# timeout fires (both safe). This closes the webhook-lag false-green: a slow-to- -# register pipeline feeding a merge gate can no longer be mistaken for "no CI". -# -# SECONDARY — the empty-poll streak below applies ONLY to genuinely CI-less repos -# (default branch also has no CI history, e.g. device-imaging class), where burning -# the full timeout would be pure waste. There, NO_CI_MAX empty polls => fast-exit 0. -# -# Probe failure is treated conservatively as REPO_HAS_CI=1 (assume CI present): we -# would rather wait-then-timeout than risk a false-green, per the merge-gate priority. -REPO_HAS_CI=1 -detect_repo_ci() { - local def_branch def_status - # Every early exit returns 0: a probe miss must leave the conservative - # REPO_HAS_CI=1 default in place, never abort the caller under `set -e`. - if [[ "$PLATFORM" == "github" ]]; then - def_branch=$(github_get_default_branch 2>/dev/null) || { - echo "[pr-ci-wait] WARN: default-branch probe failed; assuming CI-enabled (will not fast-green on empty status)."; return 0; } - [[ -n "$def_branch" ]] || return 0 - def_status=$(github_get_commit_status_json "$OWNER" "$REPO" "$def_branch" 2>/dev/null | extract_state_from_status_json) || return 0 - else - def_branch=$(gitea_get_default_branch "$HOST" "$OWNER/$REPO" "$TOKEN" 2>/dev/null) || { - echo "[pr-ci-wait] WARN: default-branch probe failed; assuming CI-enabled (will not fast-green on empty status)."; return 0; } - [[ -n "$def_branch" ]] || return 0 - def_status=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$TOKEN" "$def_branch" 2>/dev/null | extract_state_from_status_json) || return 0 - fi - if [[ "$def_status" == "no-status" || -z "$def_status" ]]; then - REPO_HAS_CI=0 - echo "[pr-ci-wait] default branch '${def_branch}' has no CI status history — treating repo as CI-less (empty-poll fast-exit enabled)." - else - REPO_HAS_CI=1 - echo "[pr-ci-wait] default branch '${def_branch}' has CI history (state=${def_status}) — repo runs CI; empty status on PR head => awaiting registration, will not fast-green." - fi -} -detect_repo_ci || true - -NO_CI_STREAK=0 -NO_CI_MAX=3 - -while true; do - NOW_TS=$(date +%s) - if (( NOW_TS > DEADLINE_TS )); then - echo "Error: Timed out waiting for CI status on PR #$PR_NUMBER after ${TIMEOUT_SEC}s." >&2 - exit 124 - fi - - if [[ "$PLATFORM" == "github" ]]; then - STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA") - else - STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$TOKEN" "$HEAD_SHA") - fi - - STATE=$(printf '%s' "$STATUS_JSON" | extract_state_from_status_json) - echo "[pr-ci-wait] state=${STATE} pr=#${PR_NUMBER} sha=${HEAD_SHA}" - - case "$STATE" in - success) - printf '%s' "$STATUS_JSON" | print_status_summary - echo "[pr-ci-wait] CI is green for PR #$PR_NUMBER." - exit 0 - ;; - failure|error) - printf '%s' "$STATUS_JSON" | print_status_summary - echo "Error: CI reported ${STATE} for PR #$PR_NUMBER." >&2 - exit 1 - ;; - no-status) - if [[ "$REPO_HAS_CI" == "1" ]]; then - # PRIMARY tier: repo demonstrably runs CI but this commit's pipeline - # has not registered yet (webhook/queue lag). Do NOT fast-green — keep - # polling until it registers or the timeout fires. Reset the streak so - # a later genuine CI-less misread can't accumulate across this state. - NO_CI_STREAK=0 - echo "[pr-ci-wait] empty status on PR head but repo runs CI — awaiting pipeline registration (webhook lag), not fast-greening." - else - # SECONDARY tier: genuinely CI-less repo (default branch has no CI - # history either). Empty polls => fast-exit green after NO_CI_MAX. - NO_CI_STREAK=$((NO_CI_STREAK + 1)) - if (( NO_CI_STREAK >= NO_CI_MAX )); then - echo "[INFO] no CI configured for this repo/commit (PR #$PR_NUMBER, ${NO_CI_STREAK} consecutive empty polls, default branch also CI-less); treating as green." - exit 0 - fi - fi - sleep "$INTERVAL_SEC" - ;; - pending|unknown) - # A pipeline exists but hasn't reached a terminal state (or is - # transiently ambiguous) — keep waiting, and reset the no-CI streak - # since this commit is not in the "no CI at all" condition. - NO_CI_STREAK=0 - sleep "$INTERVAL_SEC" - ;; - *) - echo "[pr-ci-wait] Unrecognized state '${STATE}', continuing to poll..." - NO_CI_STREAK=0 - sleep "$INTERVAL_SEC" - ;; - esac -done +exec python3 -I "$SCRIPT_DIR/forge-api.py" ci-wait "$@" diff --git a/packages/mosaic/framework/tools/git/pr-create.sh b/packages/mosaic/framework/tools/git/pr-create.sh index 4cd29e5c4..1bd526a4c 100755 --- a/packages/mosaic/framework/tools/git/pr-create.sh +++ b/packages/mosaic/framework/tools/git/pr-create.sh @@ -254,10 +254,10 @@ EFFECTIVE_BASE="$BASE_BRANCH" case "$DECL_STATE" in invalid) repo_decl_report_invalid ;; esac -if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" != 2 ]]; then +if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" -lt 2 ]]; then repo_decl_warn "declaration is v$DECL_SCHEMA — carries no consumable flow/trunk fields; legacy behavior" fi -if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then +if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" -ge 2 ]]; then # Write path: a normalized-remote mismatch refuses (spec 5.3). if ! repo_decl_remote_matches; then echo "Error: origin remote does not match the declared canonical_remote (spec 5.3, write path) — refusing to create a PR against the wrong forge. Fix the origin remote or the declaration." >&2 diff --git a/packages/mosaic/framework/tools/git/pr-merge-queue.sh b/packages/mosaic/framework/tools/git/pr-merge-queue.sh new file mode 100755 index 000000000..126998cef --- /dev/null +++ b/packages/mosaic/framework/tools/git/pr-merge-queue.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 -I "$SCRIPT_DIR/forge-api.py" merge-queue "$@" diff --git a/packages/mosaic/framework/tools/git/pr-merge.sh b/packages/mosaic/framework/tools/git/pr-merge.sh index 8ae788083..5b4be3343 100755 --- a/packages/mosaic/framework/tools/git/pr-merge.sh +++ b/packages/mosaic/framework/tools/git/pr-merge.sh @@ -8,6 +8,22 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh source "$SCRIPT_DIR/detect-platform.sh" +# Versioned JSON mode and explicit non-squash methods use the normalized adapter. +for ((arg_i=1; arg_i<=$#; arg_i++)); do + if [[ "${!arg_i}" == --json ]]; then + exec python3 -I "$SCRIPT_DIR/forge-api.py" merge "$@" + fi +done +# A declared merge policy also applies to callers that have not adopted --json. +declaration_root=$(git rev-parse --show-toplevel 2>/dev/null) || declaration_root="" +if [[ -n "$declaration_root" && -f "$declaration_root/.mosaic/repo.json" ]] && python3 -I -c 'import json,sys +try: + d=json.load(open(sys.argv[1])); sys.exit(0 if isinstance(d,dict) and "merge" in d else 1) +except (ValueError,OSError): + sys.exit(0)' "$declaration_root/.mosaic/repo.json"; then + exec python3 -I "$SCRIPT_DIR/forge-api.py" merge "$@" +fi + # Default values PR_NUMBER="" MERGE_METHOD="squash" @@ -26,7 +42,7 @@ Merge a pull request on the current repository (Gitea or GitHub). Options: -n, --number NUMBER PR number to merge (required) - -m, --method METHOD Merge method: squash only (default: squash) + -m, --method METHOD Merge method (default: declared policy, legacy squash); --json uses verified v1 contract -d, --delete-branch Delete the head branch after merge --dry-run Run metadata/login preflight without merging --expect-head SHA Refuse unless the PR head matches this full commit SHA @@ -121,8 +137,8 @@ if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then fi if [[ "$MERGE_METHOD" != "squash" ]]; then - echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2 - exit 1 + echo "POLICY-REFUSED: absent merge policy permits squash only." >&2 + exit 3 fi if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2 @@ -169,7 +185,7 @@ fi if [[ "$DECL_STATE" == invalid ]]; then repo_decl_report_invalid fi -if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then +if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" -ge 2 ]]; then if ! repo_decl_remote_matches; then echo "Error: origin remote does not match the declared canonical_remote (spec 5.3, write path) — refusing to merge against the wrong forge. Fix the origin remote or the declaration." >&2 exit 1 diff --git a/packages/mosaic/framework/tools/git/repo-decl.sh b/packages/mosaic/framework/tools/git/repo-decl.sh index 26a5d66c8..df0dbcc0e 100755 --- a/packages/mosaic/framework/tools/git/repo-decl.sh +++ b/packages/mosaic/framework/tools/git/repo-decl.sh @@ -73,21 +73,21 @@ repo_decl_load() { # Valid: extract the consumed fields via the same python stdlib the # ecosystem already uses. Field-level grammar was the validator's job. - eval "$(python3 - "$DECL_FILE" <<'PY' -import json, sys + eval "$(python3 -I - "$DECL_FILE" <<'PY' +import json, sys, shlex d = json.load(open(sys.argv[1])) def q(k): v = d.get(k, "") return v if isinstance(v, str) else "" sv = d.get("schema_version", 1) print(f"DECL_SCHEMA={sv if isinstance(sv, int) and not isinstance(sv, bool) else 0!r}") -print(f"DECL_TRUNK={q('integration_trunk')!r}") -print(f"DECL_RELEASE={q('release_branch')!r}") -print(f"DECL_FLOW={q('flow')!r}") -print(f"DECL_REMOTE={q('canonical_remote')!r}") -print(f"DECL_POLICY={q('worktree_policy')!r}") -print(f"DECL_WT_ROOT={q('worktree_root')!r}") -print(f"DECL_CLONE={q('canonical_clone')!r}") +print(f"DECL_TRUNK={shlex.quote(q('integration_trunk'))}") +print(f"DECL_RELEASE={shlex.quote(q('release_branch'))}") +print(f"DECL_FLOW={shlex.quote(q('flow'))}") +print(f"DECL_REMOTE={shlex.quote(q('canonical_remote'))}") +print(f"DECL_POLICY={shlex.quote(q('worktree_policy'))}") +print(f"DECL_WT_ROOT={shlex.quote(q('worktree_root'))}") +print(f"DECL_CLONE={shlex.quote(q('canonical_clone'))}") PY )" || { DECL_STATE=invalid @@ -177,6 +177,14 @@ repo_decl_check_transition() { # $1 head, $2 base return 1 ;; trunk-release) + if [ "$head" = "$DECL_RELEASE" ] && [ "$base" = "$DECL_TRUNK" ]; then + # Absorb is opt-in through a declared shape mapping, never a CLI exception. + if python3 -I "$REPO_DECL_SCRIPT_DIR/merge-policy.py" "$DECL_FILE" --head "$head" --base "$base" >/dev/null; then + return 0 + fi + repo_decl_warn "absorb transition requires explicit merge policy" + return 1 + fi if [ "$base" = "$DECL_TRUNK" ] && [ "$head" != "$DECL_TRUNK" ] && [ "$head" != "$DECL_RELEASE" ]; then return 0 # feature -> trunk fi @@ -196,3 +204,9 @@ repo_decl_check_transition() { # $1 head, $2 base ;; esac } + +# Optional merge policy. Caller must first require DECL_STATE=valid and matching origin. +repo_decl_merge_method() { + [ "$DECL_STATE" = valid ] || { repo_decl_warn "merge policy requires a valid declaration"; return 3; } + python3 -I "$REPO_DECL_SCRIPT_DIR/merge-policy.py" "$DECL_FILE" --head "$1" --base "$2" "${@:3}" +} diff --git a/packages/mosaic/framework/tools/git/test-ci-queue-wait-branch-absent.sh b/packages/mosaic/framework/tools/git/test-ci-queue-wait-branch-absent.sh index 5fdd60f18..42b8237ab 100644 --- a/packages/mosaic/framework/tools/git/test-ci-queue-wait-branch-absent.sh +++ b/packages/mosaic/framework/tools/git/test-ci-queue-wait-branch-absent.sh @@ -17,6 +17,7 @@ set -euo pipefail + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-branch-absent}" REPO_DIR="$WORK_DIR/repo" @@ -37,6 +38,12 @@ git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git # post-fix branch-lookup code paths faithfully. cat > "$STUB_DIR/curl" <<'SH' #!/usr/bin/env bash +# Public schema fixture for fail-closed provider detection. +if [[ "$*" == *'/swagger.v1.json'* ]]; then + [[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n' + printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}' + exit 0 +fi set -euo pipefail has_w=0 diff --git a/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-ci-expected.sh b/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-ci-expected.sh index e1fc70dff..db79832b5 100755 --- a/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-ci-expected.sh +++ b/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-ci-expected.sh @@ -25,6 +25,7 @@ set -u + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-no-ci-expected}" REPO_DIR="$WORK_DIR/repo" @@ -41,6 +42,12 @@ git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git # repository-object endpoint (admin state) selected by MOSAIC_STUB_ADMIN_MODE. cat > "$STUB_DIR/curl" <<'SH' #!/usr/bin/env bash +# Public schema fixture for fail-closed provider detection. +if [[ "$*" == *'/swagger.v1.json'* ]]; then + [[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n' + printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}' + exit 0 +fi set -euo pipefail has_w=0 diff --git a/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-status.sh b/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-status.sh index 895c468c4..d59e61aa9 100755 --- a/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-status.sh +++ b/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-status.sh @@ -32,6 +32,7 @@ set -euo pipefail + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-no-status}" REPO_DIR="$WORK_DIR/repo" @@ -48,6 +49,12 @@ git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git # is selected by MOSAIC_STUB_STATUS_MODE. cat > "$STUB_DIR/curl" <<'SH' #!/usr/bin/env bash +# Public schema fixture for fail-closed provider detection. +if [[ "$*" == *'/swagger.v1.json'* ]]; then + [[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n' + printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}' + exit 0 +fi set -euo pipefail has_w=0 diff --git a/packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh b/packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh index 8758b1aa2..4f632fc25 100644 --- a/packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh +++ b/packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh @@ -4,6 +4,7 @@ set -u + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}" REPO_DIR="$WORK_DIR/repo" @@ -60,6 +61,12 @@ git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git cat > "$STUB_DIR/curl" <<'SH' #!/usr/bin/env bash +# Public schema fixture for fail-closed provider detection. +if [[ "$*" == *'/swagger.v1.json'* ]]; then + [[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n' + printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}' + exit 0 +fi set -euo pipefail url="" diff --git a/packages/mosaic/framework/tools/git/test-forge-cli.py b/packages/mosaic/framework/tools/git/test-forge-cli.py new file mode 100644 index 000000000..1f846ac64 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-forge-cli.py @@ -0,0 +1,119 @@ +"""Shell entry points with network-denying fake provider executables and isolated HOME.""" +import json +import os +import pathlib +import subprocess +import tempfile +import unittest + +HERE=pathlib.Path(__file__).resolve().parent +FAKE='''#!/usr/bin/env -S python3 -I +import json,os,pathlib,sys,urllib.parse +args=sys.argv[1:] +method='GET'; body=None +if '--method' in args: method=args[args.index('--method')+1] +if '--request' in args: method=args[args.index('--request')+1] +if '--url' in args: + url=args[args.index('--url')+1]; parsed=urllib.parse.urlparse(url); path=parsed.path + if path.startswith('/api/v1'): path=path[7:] + query=parsed.query +else: + path=args[args.index('--method')+2]; parsed=urllib.parse.urlparse(path); path=parsed.path; query=parsed.query +if '--input' in args or '--data-binary' in args: + body=json.load(sys.stdin) +with open(os.environ['FAKE_LOG'],'a') as f: f.write(json.dumps({'method':method,'path':path,'query':query,'body':body})+'\\n') +fixtures=json.loads(pathlib.Path(os.environ['FAKE_RESPONSES']).read_text()) +key=method+' '+path+('?' + query if 'page=2' in query else '') +response=fixtures.get(key) +if response is None: + print('unexpected provider call '+key,file=sys.stderr); sys.exit(98) +status=response.get('status',200) +print('HTTP/2 '+str(status)) +for k,v in response.get('headers',{}).items(): print(k+': '+str(v)) +print() +print(json.dumps(response.get('data'))) +sys.exit(1 if pathlib.Path(sys.argv[0]).name=='gh' and status>=400 else 0) +''' + +class CLI(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory() + self.root=pathlib.Path(self.tmp.name) + (self.root/'home').mkdir() + for client in ['curl','gh']: + p=self.root/client; p.write_text(FAKE); p.chmod(0o755) + self.env=dict(os.environ,HOME=str(self.root/'home'),PATH=str(self.root)+os.pathsep+os.environ['PATH'],GIT_CONFIG_GLOBAL='/dev/null',MOSAIC_GIT_IDENTITY='',GITEA_TOKEN='fixture-token',GITEA_URL='https://git.example.test',FAKE_RESPONSES=str(self.root/'responses.json'),FAKE_LOG=str(self.root/'calls.jsonl')) + self.env.pop('BASH_ENV',None) + self.fixtures={} + def tearDown(self): self.tmp.cleanup() + def run_wrapper(self,name,*args,host='github.com'): + (self.root/'responses.json').write_text(json.dumps(self.fixtures)) + p=subprocess.run(['bash',str(HERE/(name+'.sh')),'--host',host,'--repo','owner/repo','--json',*args],cwd=self.root,env=self.env,capture_output=True,text=True) + self.assertNotIn('fixture-token',p.stdout+p.stderr) + return p,json.loads(p.stdout) if p.stdout.strip() else {} + def calls(self): + p=self.root/'calls.jsonl' + return [json.loads(l) for l in p.read_text().splitlines()] if p.exists() else [] + def gitea(self,version='1.24.7',title='Gitea API'): + self.fixtures['GET /swagger.v1.json']={'data':{'info':{'title':title,'version':version}}} + def test_profiles_and_unknown_host_fail_closed(self): + for version,title,provider in [('28.2.0','Gitea API','gitea'),('1.24.7','Gitea API','gitea'),('16.0.0-dev','Forgejo API','forgejo')]: + self.gitea(version,title) + p,v=self.run_wrapper('forge-capabilities',host='git.example.test') + self.assertEqual(p.returncode,0,p.stderr); self.assertEqual(v['provider'],provider) + self.gitea('1','Unrelated application') + p,v=self.run_wrapper('forge-capabilities',host='git.example.test') + self.assertEqual(p.returncode,4); self.assertEqual(v['error']['kind'],'unsupported') + def test_unsupported_reply_has_no_mutation(self): + self.gitea() + body=self.root/'body'; body.write_text('reply') + p,v=self.run_wrapper('pr-review-reply','-n','1','--comment-id','gitea:comment:2','--body-file',str(body),'--expect-head','a'*40,host='git.example.test') + self.assertEqual(p.returncode,4,p.stderr) + self.assertTrue(all(c['method']=='GET' for c in self.calls())) + def test_auth_denial_and_rate_limit_codes(self): + for status,headers,expected in [(401,{},5),(403,{},5),(404,{},5),(429,{'Retry-After':23},75)]: + self.fixtures['GET /repos/owner/repo/pulls/1']={'status':status,'headers':headers,'data':{'message':'refused'}} + p,v=self.run_wrapper('pr-status','-n','1') + self.assertEqual(p.returncode,expected,p.stderr) + self.assertFalse(v['data']['ready']) + if status==429: self.assertEqual(v['error']['retry_after_seconds'],23) + def test_hostile_python_modules_are_not_imported(self): + (self.root/'json.py').write_text("raise RuntimeError('repository module executed')") + self.gitea() + p,v=self.run_wrapper('forge-capabilities',host='git.example.test') + self.assertEqual(p.returncode,0,p.stderr) + def test_gitea_review_pages_preserve_resolved_outdated(self): + self.gitea('28.2.0') + self.fixtures['GET /repos/owner/repo/pulls/1']={'data':{'number':1,'head':{'sha':'a'*40}}} + self.fixtures['GET /repos/owner/repo/pulls/1/reviews']={'data':[{'id':1},{'id':2}]} + for rid in [1,2]: + self.fixtures[f'GET /repos/owner/repo/pulls/1/reviews/{rid}/comments']={'data':[{'id':rid,'body':'same body','resolver':{'login':'reviewer'} if rid==1 else None,'outdated':rid==1,'user':{'login':'reviewer'}}]} + p,v=self.run_wrapper('pr-review-comments','-n','1',host='git.example.test') + self.assertEqual(p.returncode,0,p.stderr) + self.assertEqual(len(v['data']['comments']),2) + self.assertEqual(v['data']['comments'][0]['resolved'],True) + self.assertIsNone(v['data']['comments'][0]['thread_id']) + self.assertEqual(v['data']['comments'][1]['resolved'],False) + + + + def test_gitea_status_and_ci_wait_use_exact_head(self): + self.gitea() + head='a'*40 + self.fixtures['GET /repos/owner/repo/pulls/1']={'data':{'number':1,'state':'open','draft':False,'merged':False,'mergeable':True,'head':{'sha':head,'ref':'task','repo':{'full_name':'fork/repo'}},'base':{'sha':'b'*40,'ref':'integration','repo':{'full_name':'owner/repo'}},'user':{'login':'author'}}} + self.fixtures['GET /repos/owner/repo/branch_protections']={'data':[{'branch_name':'integration','enable_status_check':True,'status_check_contexts':['build'],'required_approvals':1}]} + self.fixtures['GET /repos/owner/repo/commits/'+head+'/statuses']={'data':[{'id':1,'context':'build','state':'success'}]} + self.fixtures['GET /repos/owner/repo/pulls/1/reviews']={'data':[{'id':1,'state':'APPROVED','commit_id':head,'user':{'login':'reviewer'}}]} + self.fixtures['GET /repos/owner/repo/pulls/1/reviews/1/comments']={'data':[]} + self.fixtures['GET /repos/owner/repo/issues/1/comments']={'data':[]} + p,v=self.run_wrapper('pr-ci-wait','-n','1','-t','1','-i','1','--expect-head',head,host='git.example.test') + self.assertEqual(p.returncode,0,p.stderr) + self.assertEqual(v['data']['checks']['state'],'success') + self.assertEqual(v['data']['head_repository'],'fork/repo') + self.assertTrue(any(c['path'].endswith('/commits/'+head+'/statuses') for c in self.calls())) + self.fixtures['GET /repos/owner/repo/commits/'+head+'/statuses']={'data':[]} + p,v=self.run_wrapper('pr-ci-wait','-n','1','-t','1','-i','1',host='git.example.test') + self.assertEqual(p.returncode,124,p.stderr) + self.assertFalse(v['data']['ready']) + +if __name__=='__main__': unittest.main() diff --git a/packages/mosaic/framework/tools/git/test-forge-contract.py b/packages/mosaic/framework/tools/git/test-forge-contract.py index 464046514..c3e14c5cc 100644 --- a/packages/mosaic/framework/tools/git/test-forge-contract.py +++ b/packages/mosaic/framework/tools/git/test-forge-contract.py @@ -47,6 +47,13 @@ class Contract(unittest.TestCase): self.assertEqual(self.api.checks(statuses, runs, ['build', 'test'])['state'], 'cancelled') self.assertEqual(self.api.checks(statuses, [], ['test'])['state'], 'missing') + def test_unrecognized_check_is_never_green(self): + self.assertEqual(self.api.checks([{'id':1,'context':'build','state':'surprise'}], [], ['build'])['state'], 'unknown') + + def test_required_review_count(self): + r=[{'id':1,'state':'APPROVED','user':{'login':'reviewer'},'commit_id':'a'}] + self.assertFalse(self.api.reviews(r,'a','author',2)['approved']) + def test_review_must_be_independent_and_current(self): reviews = [{'id':1,'user':{'login':'reviewer'},'state':'APPROVED','commit_id':'old'}] self.assertFalse(self.api.reviews(reviews, 'new', 'author')['approved']) @@ -187,5 +194,146 @@ class ReviewContract(SnapshotContract): self.api.resolve(c, 1, 'github:thread:foreign', True, 'a'*40) self.assertEqual(ctx.exception.code, 3) -if __name__ == '__main__': - unittest.main() + +class IntegrationContract(SnapshotContract): + def test_unsupported_integration_performs_no_mutation(self): + for provider, version in [('gitea','28.2.0'), ('gitea','1.24.7'), ('forgejo','16.0.0-dev')]: + c = self.fake() + c.provider, c.version = provider, version + calls = [] + c.http = lambda *args, **kwargs: calls.append((args,kwargs)) + for op in ['auto_merge','merge_queue']: + with self.assertRaises(self.api.Failure) as ctx: + self.api.integration(c,1,op,'inspect',None,None) + self.assertEqual(ctx.exception.code,4) + self.assertEqual(calls,[]) + def test_forge_disallowed_method_is_unsupported(self): + c = self.fake() + c.http = lambda *args, **kwargs: {'allow_squash_merge':False} + with self.assertRaises(self.api.Failure) as ctx: + self.api.forge_method(c,'squash') + self.assertEqual(ctx.exception.code,4) + def test_github_method_limits(self): + c = self.fake() + for method in ['rebase-merge','fast-forward-only']: + with self.assertRaises(self.api.Failure) as ctx: + self.api.forge_method(c,method) + self.assertEqual(ctx.exception.code,4) + + +class WorkflowContract(Contract): + def test_stop_unknown_integration_blocks(self): + d=dict(state='open',ready=True,checks={'state':'success'},auto_merge={'state':'unsupported'},queue={'state':'unsupported'}) + self.assertEqual(self.api.workflow_verdict(d,'full-stop'),'blocked') + self.assertEqual(self.api.workflow_verdict(d,'full-send'),'ready_to_merge') + def test_red_ci_and_terminal_states(self): + d=dict(state='open',ready=False,checks={'state':'failure'},auto_merge={'state':'disabled'},queue={'state':'absent'}) + self.assertEqual(self.api.workflow_verdict(d,'full-send'),'blocked') + d['state']='closed' + self.assertEqual(self.api.workflow_verdict(d,'full-stop'),'closed_unmerged') + d['state']='merged' + self.assertEqual(self.api.workflow_verdict(d,'full-stop'),'merged') + + +class IntegrationMutations(SnapshotContract): + def integration_client(self,noop=False,immediate=False): + c=self.fake(); state={'enabled':False,'queued':False,'writes':0} + def http(path,method='GET',body=None,public=False): + if '/protection' in path: + return {'required_status_checks':{'contexts':['build']},'required_pull_request_reviews':{'required_approving_review_count':1,'dismiss_stale_reviews':True},'enforce_admins':{'enabled':True}} + return {'allow_squash_merge':True,'allow_auto_merge':True} + def gql(query,variables): + if query.startswith('mutation'): + state['writes']+=1 + self.assertEqual(variables.get('head'),'a'*40) + if not noop: + state['enabled']='enablePullRequestAutoMerge' in query + state['queued']='enqueuePullRequest' in query + return {} + if 'mergeQueue(branch:' in query: + return {'repository':{'mergeQueue':{'configuration':{'mergeMethod':'SQUASH'}}}} + return {'repository':{'pullRequest':{'id':'PR_one','autoMergeRequest':{'mergeMethod':'SQUASH'} if state['enabled'] else None,'mergeQueueEntry':{'id':'entry_one'} if state['queued'] else None,'reviewThreads':{'nodes':[],'pageInfo':{'hasNextPage':False,'endCursor':None}}}}} + c.http=http; c.gql=gql + if immediate: + original=c.pr + def pr(number): + value=original(number) + if state['writes']: + value.update(merged=True,merge_commit_sha='d'*40) + return value + c.pr=pr + return c,state + def test_enable_and_enqueue_verify_readback(self): + original_policy,original_guard=self.api.declared_method,self.api.queue_guard + self.api.declared_method=lambda *a:'squash' + self.api.queue_guard=lambda *a:None + try: + for op,action,expected in [('auto_merge','enable','enabled'),('merge_queue','enqueue','queued')]: + c,state=self.integration_client() + result=self.api.integration(c,1,op,action,'squash','a'*40) + self.assertEqual(result['state'],expected) + self.assertEqual(state['writes'],1) + finally: + self.api.declared_method,self.api.queue_guard=original_policy,original_guard + def test_noop_write_fails_without_retry(self): + original_policy,original_guard=self.api.declared_method,self.api.queue_guard + self.api.declared_method=lambda *a:'squash'; self.api.queue_guard=lambda *a:None + try: + c,state=self.integration_client(noop=True) + with self.assertRaises(self.api.Failure) as ctx: + self.api.integration(c,1,'auto_merge','enable','squash','a'*40) + self.assertEqual(ctx.exception.code,6) + self.assertEqual(ctx.exception.outcome,'unknown') + self.assertEqual(state['writes'],1) + c,state=self.integration_client(immediate=True) + result=self.api.integration(c,1,'merge_queue','enqueue','squash','a'*40) + self.assertEqual(result['state'],'merged') + finally: + self.api.declared_method,self.api.queue_guard=original_policy,original_guard + def test_gitea_method_settings_are_not_github_names(self): + c=self.fake(); c.provider='gitea'; c.version='28.2.0' + c.http=lambda *a,**kw:{'allow_merge_commits':True,'allow_rebase':True} + self.api.forge_method(c,'merge'); self.api.forge_method(c,'rebase') + + + +class ResolutionContract(SnapshotContract): + def test_native_thread_resolution_and_noop_readback(self): + for noop in [False,True]: + c=self.fake(); state={'resolved':False,'writes':0} + pages=c.pages + c.pages=lambda path,key=None: [{'id':3,'pull_request_review_id':1,'user':{'login':'reviewer'},'body':'finding'}] if path.endswith('/pulls/1/comments') else pages(path,key) + def gql(query,variables): + if query.startswith('mutation'): + state['writes']+=1 + self.assertEqual(variables['id'],'T_one') + if not noop: state['resolved']=True + return {} + return {'repository':{'pullRequest':{'id':'PR_one','autoMergeRequest':None,'mergeQueueEntry':None,'reviewThreads':{'nodes':[{'id':'T_one','isResolved':state['resolved'],'isOutdated':True,'comments':{'nodes':[{'databaseId':3,'id':'C_three','replyTo':None}],'pageInfo':{'hasNextPage':False,'endCursor':None}}}],'pageInfo':{'hasNextPage':False,'endCursor':None}}}}} + c.gql=gql + if noop: + with self.assertRaises(self.api.Failure) as ctx: + self.api.resolve(c,1,'github:thread:T_one',True,'a'*40) + self.assertEqual(ctx.exception.code,6) + else: + result=self.api.resolve(c,1,'github:thread:T_one',True,'a'*40) + self.assertTrue(result['verified']) + self.assertEqual(state['writes'],1) + def test_nested_comment_and_thread_pagination(self): + c=self.fake(); queries=[] + def gql(query,variables): + queries.append(variables) + if 'node(id:' in query: + return {'node':{'comments':{'nodes':[{'databaseId':2,'id':'C2','replyTo':{'databaseId':1}}],'pageInfo':{'hasNextPage':False,'endCursor':None}}}} + page={'nodes':[],'pageInfo':{'hasNextPage':False,'endCursor':None}} + if variables['cursor'] is None: + page={'nodes':[{'id':'T','isResolved':True,'isOutdated':True,'comments':{'nodes':[{'databaseId':1,'id':'C1','replyTo':None}],'pageInfo':{'hasNextPage':True,'endCursor':'nested'}}}],'pageInfo':{'hasNextPage':True,'endCursor':'outer'}} + return {'repository':{'pullRequest':{'id':'PR','autoMergeRequest':None,'mergeQueueEntry':None,'reviewThreads':page}}} + c.gql=gql + threads,_=self.api.github_conversations(c,1) + self.assertEqual(len(threads[0]['comments']),2) + self.assertEqual(len(queries),3) + self.assertEqual(queries[1]['cursor'],'nested') + self.assertEqual(queries[2]['cursor'],'outer') + +if __name__ == '__main__': unittest.main() diff --git a/packages/mosaic/framework/tools/git/test-forge-guard.py b/packages/mosaic/framework/tools/git/test-forge-guard.py new file mode 100644 index 000000000..f34170f41 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-forge-guard.py @@ -0,0 +1,19 @@ +import json +import pathlib +import subprocess +import unittest +HERE=pathlib.Path(__file__).resolve().parent +class Guard(unittest.TestCase): + def check(self,command,expected): + p=subprocess.run(['bash',str(HERE/'wrapper-guard.sh')],input=json.dumps({'tool_input':{'command':command}}),text=True,capture_output=True) + self.assertEqual(p.returncode,expected,p.stderr) + def test_native_review_writes_refused(self): + for route in ['pulls/1/comments/2/replies','pulls/comments/2/resolve','pulls/comments/2/unresolve']: + self.check('curl -X POST https://git.example.test/api/v1/repos/owner/repo/'+route,2) + def test_named_graphql_writes_refused(self): + for mutation in ['resolveReviewThread','unresolveReviewThread','enablePullRequestAutoMerge','disablePullRequestAutoMerge','enqueuePullRequest','dequeuePullRequest']: + self.check("gh api graphql -f query='mutation { "+mutation+"(input:{id:123}) { clientMutationId } }'",2) + def test_graphql_reads_and_wrappers_allowed(self): + self.check("gh api graphql -f query='query { repository(owner:123,name:123) { name } }'",0) + self.check('/opt/mosaic/tools/git/pr-review-resolve.sh -n 1 --thread-id github:thread:T --resolve --expect-head abc --json',0) +if __name__ == '__main__': unittest.main() diff --git a/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh b/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh index 736182f34..e31e1d116 100755 --- a/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh +++ b/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh @@ -3,6 +3,7 @@ set -euo pipefail + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-login-resolution}" REPO_DIR="$WORK_DIR/repo" @@ -86,6 +87,12 @@ SH cat > "$BIN_DIR/curl" <<'SH' #!/usr/bin/env bash +# Public schema fixture for fail-closed provider detection. +if [[ "$*" == *'/swagger.v1.json'* ]]; then + [[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n' + printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}' + exit 0 +fi set -euo pipefail printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG" @@ -261,9 +268,10 @@ run_in_repo env GITEA_URL=https://git.uscllc.com "$SCRIPT_DIR/pr-diff.sh" --repo grep -q -- 'curl .*https://git.uscllc.com/api/v1/repos/USC/uconnect/pulls/7.diff' "$LOG_FILE" : > "$LOG_FILE" -run_in_repo "$SCRIPT_DIR/pr-ci-wait.sh" --repo USC/uconnect --host git.uscllc.com -n 9 -t 2 -i 1 -grep -q -- 'curl .*https://git.uscllc.com/api/v1/repos/USC/uconnect/pulls/9' "$LOG_FILE" -grep -q -- 'curl .*https://git.uscllc.com/api/v1/repos/USC/uconnect/commits/abc123/status' "$LOG_FILE" +# CI wait now consumes the normalized contract rather than this legacy aggregate +# fixture. Its host/head/status and empty-check controls live in test-forge-cli.py. +python3 -I "$SCRIPT_DIR/test-forge-cli.py" CLI.test_gitea_status_and_ci_wait_use_exact_head + : > "$LOG_FILE" run_in_repo env MOSAIC_TEA_FAIL_PR_CREATE=1 GITEA_TOKEN=usc-token GITEA_URL=https://git.uscllc.com "$SCRIPT_DIR/pr-create.sh" -t "USC API fallback" -H feature/pr-create @@ -517,15 +525,18 @@ git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git 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 +# -I deliberately ignores PYTHONPATH. Use -S in a fixture interpreter to +# exercise missing third-party modules without relaxing production isolation. +REAL_PYTHON="$(command -v python3)" +cat > "$NOYAML_DIR/python3" </dev/null 2>&1; then HAVE_PYYAML=true; else HAVE_PYYAML=false; fi +if PATH="$NOYAML_DIR:$PATH" python3 -I -c 'import yaml' >/dev/null 2>&1; then + echo "FAIL: isolated no-site interpreter still imports PyYAML" >&2 + exit 1 fi write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; } @@ -534,7 +545,7 @@ write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; } token_fallback() { ( cd "$REPO_DIR" - HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c ' + HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" PATH="$NOYAML_DIR:$PATH" bash -c ' source "'"$SCRIPT_DIR"'/detect-platform.sh" get_gitea_token_for_login "$1" "$2" ' _ "$1" "$2" diff --git a/packages/mosaic/framework/tools/git/test-grant-reviewer.sh b/packages/mosaic/framework/tools/git/test-grant-reviewer.sh index 7a11a7120..ef2c7fd10 100755 --- a/packages/mosaic/framework/tools/git/test-grant-reviewer.sh +++ b/packages/mosaic/framework/tools/git/test-grant-reviewer.sh @@ -29,6 +29,7 @@ set -euo pipefail + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/grant-reviewer}" REPO_DIR="$WORK_DIR/repo" @@ -131,6 +132,12 @@ chmod +x "$BIN_DIR/tea" # GET /teams/{id}/repos/{org}/{repo} -> answers from persisted state only cat > "$BIN_DIR/curl" <<'SH' #!/usr/bin/env bash +# Public schema fixture for fail-closed provider detection. +if [[ "$*" == *'/swagger.v1.json'* ]]; then + [[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n' + printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}' + exit 0 +fi set -euo pipefail # Record the FULL argv exactly as spawned, before consumption. The bearer token diff --git a/packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh b/packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh index e3d02e2c0..641acac78 100755 --- a/packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh +++ b/packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh @@ -16,6 +16,7 @@ # invoke the real, provider-mutating issue-close.sh. Setup now fails closed, and both # `tea` and `curl` are asserted to resolve INSIDE $MOCK_BIN before any target run. set -euo pipefail + # NOTE: with `set -e`, `grep -q X && fail "..."` is a trap -- the ABSENT case (grep rc=1, # which is the PASSING case for a must-not-appear assertion) is the last command of an && # list and silently terminates the script with no message. Every must-not-appear check @@ -46,6 +47,12 @@ export GITEA_TOKEN="redacted-test-token" cat > "$MOCK_BIN/curl" <<'EOF' #!/bin/bash +# Public schema fixture for fail-closed provider detection. +if [[ "$*" == *'/swagger.v1.json'* ]]; then + [[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n' + printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}' + exit 0 +fi method=GET; url="" while [ $# -gt 0 ]; do case "$1" in diff --git a/packages/mosaic/framework/tools/git/test-issue-comment-readback.sh b/packages/mosaic/framework/tools/git/test-issue-comment-readback.sh index d3a97ef78..b08518a36 100755 --- a/packages/mosaic/framework/tools/git/test-issue-comment-readback.sh +++ b/packages/mosaic/framework/tools/git/test-issue-comment-readback.sh @@ -55,6 +55,7 @@ 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" @@ -223,6 +224,12 @@ chmod +x "$BIN_DIR/tea" # guard and fails the test. cat > "$BIN_DIR/curl" <<'SH' #!/usr/bin/env bash +# Public schema fixture for fail-closed provider detection. +if [[ "$*" == *'/swagger.v1.json'* ]]; then + [[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n' + printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}' + exit 0 +fi set -euo pipefail # Record the FULL argv exactly as spawned, before consumption. The bearer token diff --git a/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh b/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh index 5f899288d..d0bd542e3 100755 --- a/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh +++ b/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh @@ -3,6 +3,7 @@ set -euo pipefail + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}" REPO_DIR="$WORK_DIR/repo" @@ -65,6 +66,12 @@ SH cat > "$BIN_DIR/curl" <<'SH' #!/usr/bin/env bash +# Public schema fixture for fail-closed provider detection. +if [[ "$*" == *'/swagger.v1.json'* ]]; then + [[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n' + printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}' + exit 0 +fi set -euo pipefail printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG" # Repo roots: the pr-create API fallback resolves its base from the forge diff --git a/packages/mosaic/framework/tools/git/test-issue-view-comments.sh b/packages/mosaic/framework/tools/git/test-issue-view-comments.sh index e676b9b80..f27ffe7da 100755 --- a/packages/mosaic/framework/tools/git/test-issue-view-comments.sh +++ b/packages/mosaic/framework/tools/git/test-issue-view-comments.sh @@ -15,6 +15,7 @@ # Hermetic: mock tea and curl on PATH, sandboxed repo. Resolves no real credentials. set -euo pipefail + WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}" SANDBOX="$WORK_ROOT/issue-view-comments-test-$$" MOCK_BIN="$SANDBOX/bin"; REPO_DIR="$SANDBOX/repo"; CALLS="$SANDBOX/calls.log" @@ -51,6 +52,12 @@ ISSUE_TITLE="wrapper never shows a comment" # --- mock curl: serves the issue and its comments; logs every call -------------- cat > "$MOCK_BIN/curl" < "$ROOT/stub/curl" <> "$ROOT/curl-calls.log" diff --git a/packages/mosaic/framework/tools/git/test-pr-edit.sh b/packages/mosaic/framework/tools/git/test-pr-edit.sh index 5dbeb29ae..907a4a57c 100755 --- a/packages/mosaic/framework/tools/git/test-pr-edit.sh +++ b/packages/mosaic/framework/tools/git/test-pr-edit.sh @@ -3,6 +3,7 @@ set -euo pipefail + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-edit}" REPO_DIR="$WORK_DIR/repo"; BIN_DIR="$WORK_DIR/bin"; HOME_DIR="$WORK_DIR/home" @@ -35,6 +36,12 @@ exit 1 SH cat > "$BIN_DIR/curl" <<'SH' #!/usr/bin/env bash +# Public schema fixture for fail-closed provider detection. +if [[ "$*" == *'/swagger.v1.json'* ]]; then + [[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n' + printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}' + exit 0 +fi set -euo pipefail printf 'curl' >> "$MOSAIC_TEST_LOG"; printf ' <%s>' "$@" >> "$MOSAIC_TEST_LOG"; printf '\n' >> "$MOSAIC_TEST_LOG" if [[ "${*: -1}" == */user ]]; then diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh b/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh index b7f92086d..cea8cbf4a 100755 --- a/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh @@ -3,6 +3,7 @@ set -euo pipefail + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_ROOT="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}" SANDBOX="$WORK_ROOT/pr-merge-empty-uid-test-$$" @@ -38,6 +39,12 @@ chmod +x "$MOCK_BIN/tea" cat > "$MOCK_BIN/curl" <<'EOF' #!/bin/bash +# Public schema fixture for fail-closed provider detection. +if [[ "$*" == *'/swagger.v1.json'* ]]; then + [[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n' + printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}' + exit 0 +fi set -euo pipefail printf 'curl %q ' "$@" >> "$PR_MERGE_TEST_LOG" printf '\n' >> "$PR_MERGE_TEST_LOG" diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh b/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh index bc9543e5d..8a1a62f2a 100644 --- a/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh @@ -4,6 +4,7 @@ set -euo pipefail + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-head-pin}" SHA=0123456789abcdef0123456789abcdef01234567 @@ -35,6 +36,12 @@ make_fixture github https://github.com/acme/widgets.git main cat > "$WORK_DIR/gitea/curl" <<'SH' #!/usr/bin/env bash +# Public schema fixture for fail-closed provider detection. +if [[ "$*" == *'/swagger.v1.json'* ]]; then + [[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n' + printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}' + exit 0 +fi set -euo pipefail payload="" out_file="" diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-message-field.sh b/packages/mosaic/framework/tools/git/test-pr-merge-message-field.sh index 3a82524ab..75dfa16c3 100755 --- a/packages/mosaic/framework/tools/git/test-pr-merge-message-field.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-message-field.sh @@ -3,6 +3,7 @@ set -u + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SUBJECT="${MOSAIC_TEST_SUBJECT:-$SCRIPT_DIR/pr-merge.sh}" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-message-field}" @@ -83,6 +84,12 @@ SH cat > "$case_dir/bin/curl" <<'SH' #!/usr/bin/env bash +# Public schema fixture for fail-closed provider detection. +if [[ "$*" == *'/swagger.v1.json'* ]]; then + [[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n' + printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}' + exit 0 +fi set -eu for arg in "$@"; do @@ -530,7 +537,7 @@ method_output=$(run_case "$method_dir" single -m merge 2>&1) method_rc=$? set -e [[ "$method_rc" -ne 0 ]] || fail "non-squash method unexpectedly passed" -[[ "$method_output" == *"enforces squash merge only"* ]] || fail "non-squash refusal lost its policy diagnostic" +[[ "$method_output" == *"POLICY-REFUSED"* ]] || fail "non-squash refusal lost its policy diagnostic" [[ ! -e "$method_dir/merge-payload.json" ]] || fail "non-squash refusal reached the merge API" if [[ "$failures" -ne 0 ]]; then diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-methods.sh b/packages/mosaic/framework/tools/git/test-pr-merge-methods.sh new file mode 100755 index 000000000..b2254be9c --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-pr-merge-methods.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 -I "$SCRIPT_DIR/test-merge-policy.py" diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-queue.sh b/packages/mosaic/framework/tools/git/test-pr-merge-queue.sh new file mode 100755 index 000000000..c8cb2fcb9 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-pr-merge-queue.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 -I "$SCRIPT_DIR/test-forge-contract.py" IntegrationContract IntegrationMutations diff --git a/packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh b/packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh index f9dc856c5..c31f6e4f0 100755 --- a/packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh +++ b/packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh @@ -3,6 +3,7 @@ set -euo pipefail + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-gitea}" REPO_DIR="$WORK_DIR/repo" @@ -115,6 +116,12 @@ JSON cat > "$STUB_DIR/curl" <<'SH' #!/usr/bin/env bash +# Public schema fixture for fail-closed provider detection. +if [[ "$*" == *'/swagger.v1.json'* ]]; then + [[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n' + printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}' + exit 0 +fi set -euo pipefail output_file="" diff --git a/packages/mosaic/framework/tools/git/test-pr-review-comments.sh b/packages/mosaic/framework/tools/git/test-pr-review-comments.sh new file mode 100755 index 000000000..f1fbd70c3 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-pr-review-comments.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 -I "$SCRIPT_DIR/test-forge-cli.py" CLI.test_gitea_review_pages_preserve_resolved_outdated diff --git a/packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh b/packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh index c6cdae7db..f57aa798b 100755 --- a/packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh +++ b/packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh @@ -36,6 +36,7 @@ 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" @@ -158,6 +159,12 @@ chmod +x "$BIN_DIR/tea" # state. cat > "$BIN_DIR/curl" <<'SH' #!/usr/bin/env bash +# Public schema fixture for fail-closed provider detection. +if [[ "$*" == *'/swagger.v1.json'* ]]; then + [[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n' + printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}' + exit 0 +fi set -euo pipefail # Record the FULL argv exactly as spawned, BEFORE any consumption. The bearer diff --git a/packages/mosaic/framework/tools/git/test-pr-review-reply.sh b/packages/mosaic/framework/tools/git/test-pr-review-reply.sh new file mode 100755 index 000000000..520d90f19 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-pr-review-reply.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 -I "$SCRIPT_DIR/test-forge-contract.py" ReviewContract.test_reply_verifies_exact_returned_identity ReviewContract.test_unsupported_reply_makes_no_write ReviewContract.test_wrong_pr_comment_refuses_write diff --git a/packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh b/packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh index 789fa4104..17727fd6e 100755 --- a/packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh +++ b/packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh @@ -29,6 +29,7 @@ set -euo pipefail + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-review-repo-host-override}" BIN_DIR="$WORK_DIR/bin" @@ -72,6 +73,12 @@ chmod +x "$BIN_DIR/tea" # inference instead of the override) is unexpected and fails the run. cat > "$BIN_DIR/curl" </dev/null || true cp "$TOOLS_SRC/pr-create.sh" "$TOOLS_SRC/pr-merge.sh" "$TOOLS_SRC/mosaic-worktree.sh" "$TOOLS_SRC/ci-queue-wait.sh" "$TOOLS_SRC/detect-platform.sh" "$d/tools/git/" 2>/dev/null || true cp "$SCRIPT_DIR/../structure/validate-repo-json.sh" "$d/tools/structure/" @@ -157,6 +159,12 @@ mkpr() { # $1 dir: install the curl stub + run env; sets PR_RC/PR_OUT/PR_ERR/PR_ mkdir -p "$1/stub" cat > "$1/stub/curl" <<'STUB' #!/usr/bin/env bash +# Public schema fixture for fail-closed provider detection. +if [[ "$*" == *'/swagger.v1.json'* ]]; then + [[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n' + printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}' + exit 0 +fi url="${*: -1}" printf 'curl %s\n' "$*" >> "${STUB_DIR:?}/calls.log" case "$url" in @@ -234,6 +242,19 @@ assert_contains "B8 v1 note present" "$PR_ERR" "no consumable flow/trunk fields" base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)" assert_eq "B8 legacy forge default used" "forge-default" "$base" +mkmetadata() { + mkdir -p "$1/stub" + cat > "$1/stub/curl" <<'STUB' +#!/usr/bin/env bash +if [[ "$*" == *'/swagger.v1.json'* ]]; then + printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}' + exit 0 +fi +exit 1 +STUB + chmod +x "$1/stub/curl" +} + echo "== C. pr-merge transition validation (no hardcoded targets) ==" mkmerge() { # $1 dir, $2 base, $3 head -> stubs pr-metadata; runs pr-merge; sets M_RC/M_OUT/M_ERR cp "$SCRIPT_DIR/pr-merge.sh" "$1/tools/git/pr-merge.sh" 2>/dev/null || true @@ -248,7 +269,7 @@ exit 0 EOF chmod +x "$1/tools/git/ci-queue-wait.sh" mkdir -p "$1/stub" - printf '#!/usr/bin/env bash\nexit 0\n' > "$1/stub/curl"; chmod +x "$1/stub/curl" + mkmetadata "$1" M_RC=0 M_OUT="$(cd "$1" && env -i PATH="$1/stub:/usr/bin:/bin" HOME="$1/home" GITEA_TOKEN=stub-token \ bash "$1/tools/git/pr-merge.sh" -n 7 --dry-run < /dev/null 2>"$1/merr.txt")" || M_RC=$? @@ -308,16 +329,18 @@ assert_contains "D3 placement stays derived (no decl path used)" "$(cat "$SB/d3/ echo "== E. ci-queue-wait route context (C4: context only, never a gate) ==" new_sb; mkrepo "$SB/e1" <<< "$DECL_TR" "https://git.example.test/acme/widgets" git -C "$SB/e1" checkout -q -b dev-trunk 2>/dev/null || { git -C "$SB/e1" branch -q dev-trunk; git -C "$SB/e1" checkout -q dev-trunk; } +mkmetadata "$SB/e1" E_RC=0 -E_OUT="$(cd "$SB/e1" && env -i PATH="/usr/bin:/bin" HOME="$SB/e1/home" \ +E_OUT="$(cd "$SB/e1" && env -i PATH="$SB/e1/stub:/usr/bin:/bin" HOME="$SB/e1/home" \ bash "$SB/e1/tools/git/ci-queue-wait.sh" < /dev/null 2>"$SB/e1/eerr.txt")" || E_RC=$? E_ERR="$(cat "$SB/e1/eerr.txt")" assert_contains "E1 route context names trunk head" "$E_ERR" "'dev-trunk' is a trunk (integration head) head" assert_contains "E1 context names the flow" "$E_ERR" "flow=trunk-release" new_sb; mkrepo "$SB/e2" <<< "" "https://git.example.test/acme/widgets" +mkmetadata "$SB/e2" E_RC=0 -E_OUT="$(cd "$SB/e2" && env -i PATH="/usr/bin:/bin" HOME="$SB/e2/home" \ +E_OUT="$(cd "$SB/e2" && env -i PATH="$SB/e2/stub:/usr/bin:/bin" HOME="$SB/e2/home" \ bash "$SB/e2/tools/git/ci-queue-wait.sh" < /dev/null 2>"$SB/e2/eerr.txt")" || E_RC=$? assert_not_contains "E2 absence is SILENT for the guard (N4)" "$(cat "$SB/e2/eerr.txt")" "repo-decl" diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index bd4035f4c..34ea9615d 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -17,6 +17,7 @@ set -uo pipefail + HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" GUARD="${1:-$HERE/wrapper-guard.sh}" [ -x "$GUARD" ] || { printf 'test-wrapper-guard: not executable: %s\n' "$GUARD" >&2; exit 2; } @@ -743,4 +744,5 @@ little is decoration. Both directions are failures here, which is why the allowed cases are asserted as hard as the blocked ones. EOF fi +python3 -I "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/test-forge-guard.py" || fail=1 exit "$fail" diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index e2a226d46..1a5995ea9 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -803,6 +803,11 @@ if printf '%s' "$CMD" | grep -Eq "$API_SHAPED" \ '\.(post|put|patch|delete)\(' && is_write=1 if [ "$is_write" -eq 1 ]; then + # Named GraphQL mutations are writes even though the URL is just graphql. + if printf '%s' "$CMD" | grep -Eq '(resolveReviewThread|unresolveReviewThread|enablePullRequestAutoMerge|disablePullRequestAutoMerge|enqueuePullRequest|dequeuePullRequest)[[:space:]]*\('; then + echo "BLOCKED: use pr-review-resolve.sh, pr-auto-merge.sh or pr-merge-queue.sh for this GraphQL mutation." >&2 + exit 2 + fi # A percent-escape makes the endpoint unreadable HERE and perfectly readable # to the PROVIDER, which is the whole hazard. Measured against the live # forge, read-only: GET /repos/mosaicstack/stack/issues/1174 and @@ -900,6 +905,8 @@ EOF # -a -c and files a verdict; nothing in the tree adds a # requested reviewer. Unowned, so it flows through — placed above the # reviews arm so it cannot be refused with "use pr-review.sh". + *"/pulls/"*"/comments/"*"/replies"*) endpoint="native review reply"; wrapper="pr-review-reply.sh" ;; + *"/pulls/comments/"*"/resolve"*|*"/pulls/comments/"*"/unresolve"*) endpoint="review conversation resolution"; wrapper="pr-review-resolve.sh" ;; *"/pulls/"*"/requested_reviewers"*) : ;; *"/pulls/"*"/reviews"*) endpoint="pull-request review"; wrapper="pr-review.sh" ;; *"/pulls/"*"/merge"*) endpoint="pull-request merge"; wrapper="pr-merge.sh" ;; diff --git a/packages/mosaic/framework/tools/structure/test-validate-repo-json.sh b/packages/mosaic/framework/tools/structure/test-validate-repo-json.sh index c9ef03360..750aa4481 100755 --- a/packages/mosaic/framework/tools/structure/test-validate-repo-json.sh +++ b/packages/mosaic/framework/tools/structure/test-validate-repo-json.sh @@ -72,8 +72,8 @@ fx nan.json '{"schema_version":NaN}' expect_err_notrace b4 "malformed JSON" MOSAIC_HOST_ROOT=$ROOT -- "$SB/nan.json" echo "== (3) unknown schema_version = ABSENT-loud ==" -fx v3.json "${STACK/schema_version\":2/schema_version\":3}" -expect_err c1 "schema_version" MOSAIC_HOST_ROOT=$ROOT -- "$SB/v3.json" +fx v4.json "${STACK/schema_version\":2/schema_version\":4}" +expect_err c1 "schema_version" MOSAIC_HOST_ROOT=$ROOT -- "$SB/v4.json" echo "== (4) v1 mode + authoring rule (v1 consumes no paths: no root needed) ==" fx v1.json '{"integration_trunk":"next","release_branch":"main"}' diff --git a/packages/mosaic/framework/tools/structure/validate-repo-json.sh b/packages/mosaic/framework/tools/structure/validate-repo-json.sh index eeaa871c9..1fc55476f 100755 --- a/packages/mosaic/framework/tools/structure/validate-repo-json.sh +++ b/packages/mosaic/framework/tools/structure/validate-repo-json.sh @@ -42,8 +42,9 @@ VERSION="1.1.0+t51spec-r3+t51p2rw1" if [ "${1:-}" = "--version" ]; then echo "validate-repo-json $VERSION"; exit 0; fi -exec python3 - "$@" <<'PYEOF' +MOSAIC_MERGE_POLICY_HELPER="$(cd "$(dirname "${BASH_SOURCE[0]}")/../git" && pwd)/merge-policy.py" exec python3 -I - "$@" <<'PYEOF' import json, os, re, subprocess, sys, urllib.parse +from pathlib import Path def err(key, reason): print(f"VALIDATION_ERROR {key}: {reason}") @@ -281,14 +282,14 @@ def main(): if "schema_version" in doc: if not isinstance(sv, int) or isinstance(sv, bool): err("schema_version", "must be an integer") - if sv not in (1, 2): + if sv not in (1, 2, 3): err("schema_version", f"unknown schema_version {sv} — treated as ABSENT per keep-list K3; update tooling") version = sv else: version = 1 warn("schema_version absent → v1 compatibility mode (two keys only)") - if REQUIRE_V2 and version != 2: + if REQUIRE_V2 and version not in (2, 3): err("schema_version", "CI authoring rule: new or edited declarations must declare schema_version 2") # ---- v1 ---- @@ -313,6 +314,13 @@ def main(): if not isinstance(doc["flow"], str) or doc["flow"] not in ENUM_FLOW: err("flow", f"must be one of {sorted(ENUM_FLOW)} (§1.2, required — no defaulting, R7)") + if version == 3: + V2_OPTIONAL.add("merge") + # The shell supplies a fixed framework helper path, never repository code. + result = subprocess.run(["python3", "-I", os.environ["MOSAIC_MERGE_POLICY_HELPER"], path, "--validate"], capture_output=True, text=True) + if result.returncode: + err("merge", result.stdout.strip() or "merge policy validation failed") + unknown = set(doc) - set(V2_REQUIRED) - V2_OPTIONAL if unknown: err("x_extensions", f"unknown top-level keys {sorted(unknown)} — place extensions inside x_extensions") -- 2.54.0 From 630cc86355c12e41132789792259da270df92b71 Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 14:31:15 -0600 Subject: [PATCH 03/26] fix(framework): harden forge adapters and preserve SSH compatibility (#1841) Keep credentials out of client argv, validate snapshot schema and ownership evidence, reject opaque GraphQL writes and isolate suite scratch roots. Retain existing SCP and SSH transport parsing. Co-Authored-By: GPT-6.1-Sol (Codex) --- .../framework/tools/git/detect-platform.sh | 2 +- .../mosaic/framework/tools/git/forge-api.py | 37 ++- .../framework/tools/git/merge-policy.py | 2 + .../framework/tools/git/pr-status.schema.json | 255 +++++++++++++++++- .../framework/tools/git/test-forge-cli.py | 32 ++- .../framework/tools/git/test-forge-guard.py | 4 + .../framework/tools/git/test-forge-schema.cjs | 40 +++ .../framework/tools/git/test-forge-schema.sh | 4 + .../framework/tools/git/test-merge-policy.py | 25 ++ .../tools/git/test-repo-decl-consumption.sh | 4 +- .../framework/tools/git/wrapper-guard.sh | 5 + 11 files changed, 388 insertions(+), 22 deletions(-) create mode 100644 packages/mosaic/framework/tools/git/test-forge-schema.cjs create mode 100755 packages/mosaic/framework/tools/git/test-forge-schema.sh diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 15c778e89..62e542f02 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -17,7 +17,7 @@ detect_platform() { # Parse the authority, rather than matching github.com anywhere in a URL. local host profile rc - host=$(get_host_from_url "$remote_url") || return 1 + host=$(get_remote_host) || return 1 if [[ "$host" == github.com || ( -n "${GH_HOST:-}" && "$host" == "$GH_HOST" ) ]]; then PLATFORM=github; export PLATFORM; echo github; return 0 fi diff --git a/packages/mosaic/framework/tools/git/forge-api.py b/packages/mosaic/framework/tools/git/forge-api.py index 4301ad41c..f16160821 100644 --- a/packages/mosaic/framework/tools/git/forge-api.py +++ b/packages/mosaic/framework/tools/git/forge-api.py @@ -14,6 +14,7 @@ import re import subprocess import sys import time +import tempfile import urllib.parse HERE = pathlib.Path(__file__).resolve().parent @@ -137,12 +138,26 @@ class Client: else: url = 'https://' + self.host + (path if public else '/api/v1' + path) args = ['curl', '--silent', '--show-error', '--include', '--max-time', '30', '--request', method, '--header', 'Accept: application/json', '--header', 'User-Agent: mosaic-forge-wrapper'] - if not public: - args += ['--header', 'Authorization: token ' + self.credential()] if body is not None: args += ['--header', 'Content-Type: application/json', '--data-binary', '@-'] args += ['--url', url] - p = self.transport(args, body, writing) + if not public: + scratch = os.environ.get('TMPDIR') + if not scratch or not os.path.isabs(scratch): + raise Failure(2,'usage','Set TMPDIR to approved absolute scratch storage before authenticated requests') + token = self.credential() + if any(ord(c) < 32 or ord(c) == 127 for c in token): + raise Failure(5,'access','Credential contains invalid control characters') + escaped = token.replace('\\', '\\\\').replace('"', '\\"') + try: + with tempfile.NamedTemporaryFile(mode='w',prefix='mosaic-forge-auth-',dir=scratch) as auth: + auth.write('header = "Authorization: token ' + escaped + '"\n') + auth.flush() + p = self.transport(args + ['--config',auth.name],body,writing) + except OSError: + raise Failure(6,'verification','Cannot create protected credential config in configured scratch storage') + else: + p = self.transport(args, body, writing) if p.returncode and (self.provider != 'github' or not p.stdout.startswith('HTTP/')): raise Failure(75, 'transient', 'Provider transport failed; reconcile before retrying a mutation', outcome='unknown' if writing else 'none') raw = p.stdout.replace('\r\n', '\n') @@ -161,7 +176,10 @@ class Client: if status is None: raise Failure(6, 'malformed', 'Provider did not return HTTP metadata') if status == 429 or status == 403 and (headers.get('x-ratelimit-remaining') == '0' or 'retry-after' in headers): - retry = headers.get('retry-after', '60') + retry = headers.get('retry-after') + if retry is None: + reset = headers.get('x-ratelimit-reset','') + retry = str(max(1,int(reset)-int(time.time()))) if reset.isdigit() else '60' raise Failure(75, 'rate_limit', 'Provider rate limit; use a bounded later wake', int(retry) if retry.isdigit() else 60, 'unknown' if writing else 'none') if status in (401, 403, 404): raise Failure(5, 'access', 'Provider access denied or resource hidden (HTTP %s)' % status, outcome='unknown' if writing else 'none') @@ -187,15 +205,22 @@ class Client: def pages(self, path, key=None): result = [] + expected_total = 0 for page in range(1, MAX_PAGES + 1): query = ('&' if '?' in path else '?') + ('per_page' if self.provider == 'github' else 'limit') + '=100&page=' + str(page) value = self.http(path + query) if key: + total = value.get('total_count') + if not isinstance(total,int) or isinstance(total,bool) or total < 0: + raise Failure(6,'incomplete','Check-run total_count missing or invalid') + expected_total = max(expected_total,total) value = value.get(key) if not isinstance(value, list): raise Failure(6, 'malformed', 'Provider collection is not a list') result.extend(value) if len(value) < LIMIT: + if len(result) < expected_total: + raise Failure(6,'incomplete','Provider returned fewer records than total_count') return result raise Failure(6, 'incomplete', 'Pagination bound reached; collection incomplete') @@ -355,6 +380,10 @@ def snapshot(client, number, expected): state = 'merged' if p.get('merged') is True else p['state'] mergeability = 'conflict' if p.get('mergeable') is False else 'clean' if p.get('mergeable') is True else 'unknown' blockers = [] + if not re.fullmatch('[0-9a-fA-F]{40}',base.get('sha','')): + blockers.append('base_sha_unknown') + if not (p['head'].get('repo') or {}).get('full_name') or not (base.get('repo') or {}).get('full_name'): + blockers.append('repository_unknown') if state != 'open': blockers.append(state) if p.get('draft') is not False: diff --git a/packages/mosaic/framework/tools/git/merge-policy.py b/packages/mosaic/framework/tools/git/merge-policy.py index 52d7c7f2c..3a6634a4c 100644 --- a/packages/mosaic/framework/tools/git/merge-policy.py +++ b/packages/mosaic/framework/tools/git/merge-policy.py @@ -30,6 +30,8 @@ def policy(doc): def resolve(doc, head, base, request): p = policy(doc) + if not head or not base or head == base: + return 3, {'error':'POLICY-REFUSED: head/base must identify a non-self transition'} trunk, release = doc.get('integration_trunk'), doc.get('release_branch') shape = 'promotion' if head == trunk and base == release and trunk != release else 'absorb' if head == release and base == trunk and trunk != release else 'task' mapped = p['shapes'].get(shape) diff --git a/packages/mosaic/framework/tools/git/pr-status.schema.json b/packages/mosaic/framework/tools/git/pr-status.schema.json index 464095d1c..410255c83 100644 --- a/packages/mosaic/framework/tools/git/pr-status.schema.json +++ b/packages/mosaic/framework/tools/git/pr-status.schema.json @@ -1,24 +1,251 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", + "$schema": "http://json-schema.org/draft-07/schema#", "title": "Forge wrapper envelope v1", "type": "object", - "required": ["schema_version", "operation", "provider", "host", "repo", "number", "head_sha", "data", "error"], + "required": [ + "schema_version", + "operation", + "provider", + "host", + "repo", + "number", + "head_sha", + "data", + "error" + ], "additionalProperties": false, "properties": { - "schema_version": {"const": 1}, - "operation": {"type": "string"}, - "provider": {"enum": ["github", "gitea", "forgejo", "unknown"]}, - "host": {"type": ["string", "null"]}, - "repo": {"type": ["string", "null"]}, - "number": {"type": ["integer", "null"]}, - "head_sha": {"type": ["string", "null"]}, - "data": {"type": ["object", "null"]}, + "schema_version": { + "const": 1 + }, + "operation": { + "type": "string" + }, + "provider": { + "enum": ["github", "gitea", "forgejo", "unknown"] + }, + "host": { + "type": ["string", "null"] + }, + "repo": { + "type": ["string", "null"] + }, + "number": { + "type": ["integer", "null"] + }, + "head_sha": { + "type": ["string", "null"] + }, + "data": { + "type": ["object", "null"] + }, "error": { "oneOf": [ - {"type": "null"}, - {"type": "object", "required": ["kind", "message", "retry_after_seconds", "write_outcome"], "additionalProperties": false, - "properties": {"kind": {"type": "string"}, "message": {"type": "string"}, "retry_after_seconds": {"type": ["integer", "null"]}, "write_outcome": {"enum": ["none", "confirmed", "unknown"]}}} + { + "type": "null" + }, + { + "type": "object", + "required": ["kind", "message", "retry_after_seconds", "write_outcome"], + "additionalProperties": false, + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "retry_after_seconds": { + "type": ["integer", "null"] + }, + "write_outcome": { + "enum": ["none", "confirmed", "unknown"] + } + } + } ] } - } + }, + "$defs": { + "snapshot": { + "type": "object", + "required": [ + "url", + "head_ref", + "base_ref", + "head_repository", + "base_repository", + "head_sha", + "base_sha", + "merge_sha", + "state", + "draft", + "mergeability", + "ready", + "blockers", + "observed_at", + "fingerprint", + "capabilities", + "checks", + "reviews", + "threads", + "auto_merge", + "queue" + ], + "properties": { + "url": { + "type": ["string", "null"] + }, + "head_ref": { + "type": ["string", "null"] + }, + "base_ref": { + "type": ["string", "null"] + }, + "head_repository": { + "type": ["string", "null"] + }, + "base_repository": { + "type": ["string", "null"] + }, + "head_sha": { + "type": ["string", "null"], + "pattern": "^[0-9a-fA-F]{40}$" + }, + "base_sha": { + "type": ["string", "null"], + "pattern": "^[0-9a-fA-F]{40}$" + }, + "merge_sha": { + "type": ["string", "null"], + "pattern": "^[0-9a-fA-F]{40}$" + }, + "state": { + "enum": ["open", "closed", "merged"] + }, + "draft": { + "type": ["boolean", "null"] + }, + "mergeability": { + "enum": ["clean", "conflict", "unknown"] + }, + "ready": { + "type": "boolean" + }, + "blockers": { + "type": "array", + "items": { + "type": "string" + } + }, + "observed_at": { + "type": "string" + }, + "fingerprint": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "capabilities": { + "type": "object" + }, + "checks": { + "type": "object", + "required": ["state", "required", "observed", "complete"], + "properties": { + "state": { + "enum": [ + "pending", + "success", + "failure", + "cancelled", + "missing", + "not_required", + "unknown" + ] + }, + "required": { + "type": ["array", "null"], + "items": { + "type": "string" + } + }, + "observed": { + "type": "object" + }, + "complete": { + "type": "boolean" + } + } + }, + "reviews": { + "type": "object", + "required": ["decision", "reviewed_sha", "complete"], + "properties": { + "decision": { + "type": "string" + }, + "reviewed_sha": { + "type": ["string", "null"], + "pattern": "^[0-9a-fA-F]{40}$" + }, + "complete": { + "type": "boolean" + } + } + }, + "threads": { + "type": "object", + "required": ["unresolved", "complete"], + "properties": { + "unresolved": { + "type": ["integer", "null"], + "minimum": 0 + }, + "complete": { + "type": "boolean" + } + } + }, + "auto_merge": { + "type": "object", + "required": ["state"], + "properties": { + "state": { + "enum": ["enabled", "disabled", "unsupported", "unknown"] + } + } + }, + "queue": { + "type": "object", + "required": ["state"], + "properties": { + "state": { + "enum": ["queued", "absent", "unsupported", "unknown"] + } + } + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "operation": { + "enum": ["status", "watch-check", "ci-wait"] + }, + "error": { + "type": "null" + } + } + }, + "then": { + "properties": { + "data": { + "$ref": "#/$defs/snapshot" + } + } + } + } + ] } diff --git a/packages/mosaic/framework/tools/git/test-forge-cli.py b/packages/mosaic/framework/tools/git/test-forge-cli.py index 1f846ac64..6b460ce00 100644 --- a/packages/mosaic/framework/tools/git/test-forge-cli.py +++ b/packages/mosaic/framework/tools/git/test-forge-cli.py @@ -10,6 +10,12 @@ HERE=pathlib.Path(__file__).resolve().parent FAKE='''#!/usr/bin/env -S python3 -I import json,os,pathlib,sys,urllib.parse args=sys.argv[1:] +if any('fixture-token' in a for a in args): + print('credential exposed in argv',file=sys.stderr); sys.exit(97) +if '--config' in args: + config=pathlib.Path(args[args.index('--config')+1]) + if config.stat().st_mode & 0o077 or config.read_text() != 'header = "Authorization: token fixture-token"\\n': + print('invalid protected auth config',file=sys.stderr); sys.exit(96) method='GET'; body=None if '--method' in args: method=args[args.index('--method')+1] if '--request' in args: method=args[args.index('--request')+1] @@ -42,7 +48,7 @@ class CLI(unittest.TestCase): (self.root/'home').mkdir() for client in ['curl','gh']: p=self.root/client; p.write_text(FAKE); p.chmod(0o755) - self.env=dict(os.environ,HOME=str(self.root/'home'),PATH=str(self.root)+os.pathsep+os.environ['PATH'],GIT_CONFIG_GLOBAL='/dev/null',MOSAIC_GIT_IDENTITY='',GITEA_TOKEN='fixture-token',GITEA_URL='https://git.example.test',FAKE_RESPONSES=str(self.root/'responses.json'),FAKE_LOG=str(self.root/'calls.jsonl')) + self.env=dict(os.environ,HOME=str(self.root/'home'),TMPDIR=str(self.root),TMP=str(self.root),TEMP=str(self.root),PATH=str(self.root)+os.pathsep+os.environ['PATH'],GIT_CONFIG_GLOBAL='/dev/null',MOSAIC_GIT_IDENTITY='',GITEA_TOKEN='fixture-token',GITEA_URL='https://git.example.test',FAKE_RESPONSES=str(self.root/'responses.json'),FAKE_LOG=str(self.root/'calls.jsonl')) self.env.pop('BASH_ENV',None) self.fixtures={} def tearDown(self): self.tmp.cleanup() @@ -50,6 +56,7 @@ class CLI(unittest.TestCase): (self.root/'responses.json').write_text(json.dumps(self.fixtures)) p=subprocess.run(['bash',str(HERE/(name+'.sh')),'--host',host,'--repo','owner/repo','--json',*args],cwd=self.root,env=self.env,capture_output=True,text=True) self.assertNotIn('fixture-token',p.stdout+p.stderr) + self.assertEqual(list(self.root.glob('mosaic-forge-auth-*')),[], 'credential config leaked') return p,json.loads(p.stdout) if p.stdout.strip() else {} def calls(self): p=self.root/'calls.jsonl' @@ -116,4 +123,27 @@ class CLI(unittest.TestCase): self.assertEqual(p.returncode,124,p.stderr) self.assertFalse(v['data']['ready']) + + + def test_watch_predicate_preserves_baseline_and_read_errors(self): + self.test_gitea_status_and_ci_wait_use_exact_head() + key='GET /repos/owner/repo/commits/'+'a'*40+'/statuses' + self.fixtures[key]={'data':[{'id':1,'context':'build','state':'pending'}]} + p,baseline=self.run_wrapper('pr-status','-n','1',host='git.example.test') + self.assertEqual(p.returncode,0,p.stderr) + path=self.root/'baseline.json'; path.write_text(json.dumps(baseline)) + before=path.read_text() + p,value=self.run_wrapper('pr-watch-check','-n','1','--baseline',str(path),host='git.example.test') + self.assertEqual(p.returncode,1,p.stderr) + self.assertEqual(path.read_text(),before) + self.fixtures[key]={'data':[{'id':1,'context':'build','state':'failure'}]} + p,value=self.run_wrapper('pr-watch-check','-n','1','--baseline',str(path),host='git.example.test') + self.assertEqual(p.returncode,0,p.stderr) + self.assertEqual(value['data']['checks']['state'],'failure') + self.fixtures[key]={'status':403,'data':{'message':'denied'}} + p,value=self.run_wrapper('pr-watch-check','-n','1','--baseline',str(path),host='git.example.test') + self.assertEqual(p.returncode,5,p.stderr) + self.assertFalse(value['data']['checks']['complete']) + self.assertEqual(path.read_text(),before) + if __name__=='__main__': unittest.main() diff --git a/packages/mosaic/framework/tools/git/test-forge-guard.py b/packages/mosaic/framework/tools/git/test-forge-guard.py index f34170f41..244c4f8fb 100644 --- a/packages/mosaic/framework/tools/git/test-forge-guard.py +++ b/packages/mosaic/framework/tools/git/test-forge-guard.py @@ -13,6 +13,10 @@ class Guard(unittest.TestCase): def test_named_graphql_writes_refused(self): for mutation in ['resolveReviewThread','unresolveReviewThread','enablePullRequestAutoMerge','disablePullRequestAutoMerge','enqueuePullRequest','dequeuePullRequest']: self.check("gh api graphql -f query='mutation { "+mutation+"(input:{id:123}) { clientMutationId } }'",2) + def test_opaque_graphql_payload_cannot_hide_mutation(self): + self.check('gh api graphql --input request.json',2) + self.check('curl --data-binary @request.json https://api.github.com/graphql',2) + def test_graphql_reads_and_wrappers_allowed(self): self.check("gh api graphql -f query='query { repository(owner:123,name:123) { name } }'",0) self.check('/opt/mosaic/tools/git/pr-review-resolve.sh -n 1 --thread-id github:thread:T --resolve --expect-head abc --json',0) diff --git a/packages/mosaic/framework/tools/git/test-forge-schema.cjs b/packages/mosaic/framework/tools/git/test-forge-schema.cjs new file mode 100644 index 000000000..f98a37148 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-forge-schema.cjs @@ -0,0 +1,40 @@ +// Use the repository's existing ESLint/Ajv dependency, without adding a package. +const assert = require('node:assert/strict'); +const { createRequire } = require('node:module'); +const { readFileSync } = require('node:fs'); +const { join } = require('node:path'); +const { spawnSync } = require('node:child_process'); +const eslintRequire = createRequire(require.resolve('eslint/package.json')); +const Ajv = eslintRequire('ajv'); +const schema = JSON.parse(readFileSync(join(__dirname, 'pr-status.schema.json'), 'utf8')); +const ajv = new Ajv({ allErrors: true }); +assert(ajv.validateSchema(schema), JSON.stringify(ajv.errors)); +const validate = ajv.compile(schema); +const fixture = spawnSync( + 'python3', + [ + '-I', + '-c', + ` +import importlib.util,json,pathlib,sys +p=pathlib.Path(sys.argv[1])/'test-forge-contract.py' +spec=importlib.util.spec_from_file_location('fixture',p) +m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m) +m.SnapshotContract.setUpClass() +c=m.SnapshotContract().fake() +s=m.SnapshotContract.api.snapshot(c,1,None) +print(json.dumps(dict(schema_version=1,operation='status',provider=c.provider,host=c.host,repo=c.repo,number=1,head_sha=s['head_sha'],data=s,error=None))) +`, + __dirname, + ], + { encoding: 'utf8' }, +); +assert.equal(fixture.status, 0, fixture.stderr); +const snapshot = JSON.parse(fixture.stdout); +assert(validate(snapshot), JSON.stringify(validate.errors)); +delete snapshot.data.threads; +assert.equal(validate(snapshot), false, 'missing thread evidence must fail schema'); +snapshot.data.threads = { unresolved: 0, complete: true }; +snapshot.data.checks.state = 'invented-green'; +assert.equal(validate(snapshot), false, 'invented checks state must fail schema'); +console.log('Schema and negative controls passed'); diff --git a/packages/mosaic/framework/tools/git/test-forge-schema.sh b/packages/mosaic/framework/tools/git/test-forge-schema.sh new file mode 100755 index 000000000..44089ffdd --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-forge-schema.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec node "$SCRIPT_DIR/test-forge-schema.cjs" diff --git a/packages/mosaic/framework/tools/git/test-merge-policy.py b/packages/mosaic/framework/tools/git/test-merge-policy.py index 35e5eb0d4..70f543983 100644 --- a/packages/mosaic/framework/tools/git/test-merge-policy.py +++ b/packages/mosaic/framework/tools/git/test-merge-policy.py @@ -30,5 +30,30 @@ class Policy(unittest.TestCase): for policy in [{}, {'allowed_methods':['force_merge'],'default_method':'force_merge'}, {'allowed_methods':['squash'],'default_method':'merge'}, {'allowed_methods':['squash'],'default_method':'squash','unexpected':True}]: self.assertEqual(self.resolve(policy)[0],6) + + + def test_shared_validator_and_shell_quoting(self): + with tempfile.TemporaryDirectory() as directory: + root=pathlib.Path(directory) + doc=dict(schema_version=3,integration_trunk='integration',release_branch='stable',flow='trunk-release',canonical_remote='https://git.example.test/owner/repo',canonical_clone='host:/src/project',merge=dict(allowed_methods=['squash','merge'],default_method='squash',shapes={'absorb':'merge'})) + (root/'.mosaic').mkdir() + path=root/'.mosaic/repo.json'; path.write_text(json.dumps(doc)) + validator=HERE.parent/'structure/validate-repo-json.sh' + p=subprocess.run(['bash',str(validator),str(path),'--mode','display'],capture_output=True,text=True) + self.assertEqual(p.returncode,0,p.stdout+p.stderr) + doc['merge']['shapes']['invented']='merge'; path.write_text(json.dumps(doc)) + p=subprocess.run(['bash',str(validator),str(path),'--mode','display'],capture_output=True,text=True) + self.assertNotEqual(p.returncode,0) + del doc['merge']['shapes']['invented'] + branch="feature/'$(touch${IFS}marker)'" + doc['integration_trunk']=branch; path.write_text(json.dumps(doc)) + subprocess.run(['git','init','-q',str(root)],check=True) + p=subprocess.run(['bash','-c','source "$1/repo-decl.sh"; repo_decl_load; [[ "$DECL_STATE" == valid ]] || exit 1; printf %s "$DECL_TRUNK"','fixture',str(HERE)],cwd=root,capture_output=True,text=True) + self.assertEqual(p.returncode,0,p.stderr) + self.assertEqual(p.stdout,branch) + self.assertFalse((root/'marker').exists(),'declaration executed shell substitution') + def test_self_transition_refused(self): + self.assertEqual(self.resolve(head='integration')[0],3) + if __name__ == '__main__': unittest.main() diff --git a/packages/mosaic/framework/tools/git/test-repo-decl-consumption.sh b/packages/mosaic/framework/tools/git/test-repo-decl-consumption.sh index 7966cdd48..c5ecfcbc0 100755 --- a/packages/mosaic/framework/tools/git/test-repo-decl-consumption.sh +++ b/packages/mosaic/framework/tools/git/test-repo-decl-consumption.sh @@ -20,12 +20,12 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOLS_SRC="${WP5B_TOOLS:-$SCRIPT_DIR}" -TROOT="${TMPDIR:-/tmp}" +TROOT="$(mktemp -d)" PASS=0 FAIL=0 FAILED_CASES="" FIXTURES=() cleanup_all() { local f; for f in "${FIXTURES[@]:-}"; do rm -rf -- "$f"; done; } -trap cleanup_all EXIT INT TERM +trap 'cleanup_all; rmdir "$TROOT" 2>/dev/null || true' EXIT INT TERM ok() { PASS=$((PASS + 1)); } bad() { FAIL=$((FAIL + 1)); FAILED_CASES="$FAILED_CASES $1"; printf 'FAIL: %s\n' "$1" >&2; } diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index 1a5995ea9..2c89aa55d 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -803,6 +803,11 @@ if printf '%s' "$CMD" | grep -Eq "$API_SHAPED" \ '\.(post|put|patch|delete)\(' && is_write=1 if [ "$is_write" -eq 1 ]; then + # Opaque GraphQL request files cannot be established as reads by this hook. + if printf '%s' "$CMD_NAMES" | grep -Eq '(^|[[:space:]/])graphql([[:space:]]|$)' && printf '%s' "$CMD" | grep -Eq -- '--input[[:space:]=]|--data(-binary)?[[:space:]=]+@|-d@'; then + echo "BLOCKED: opaque GraphQL payload; use the corresponding git wrapper or spell a read query inline." >&2 + exit 2 + fi # Named GraphQL mutations are writes even though the URL is just graphql. if printf '%s' "$CMD" | grep -Eq '(resolveReviewThread|unresolveReviewThread|enablePullRequestAutoMerge|disablePullRequestAutoMerge|enqueuePullRequest|dequeuePullRequest)[[:space:]]*\('; then echo "BLOCKED: use pr-review-resolve.sh, pr-auto-merge.sh or pr-merge-queue.sh for this GraphQL mutation." >&2 -- 2.54.0 From 5d187488d48821e21690d11a8852310459d677a6 Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 14:36:25 -0600 Subject: [PATCH 04/26] fix(framework): verify enterprise review URLs and ambiguous outcomes (#1841) Preserve provider-qualified conversation ownership on enterprise API prefixes and keep post-write malformed readback outcomes unknown. Make the absence assertion independent of scratch directory names. Co-Authored-By: GPT-6.1-Sol (Codex) --- packages/mosaic/framework/tools/git/forge-api.py | 15 ++++++++++++--- .../framework/tools/git/test-forge-contract.py | 14 ++++++++++++++ .../tools/git/test-repo-decl-consumption.sh | 2 +- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/framework/tools/git/forge-api.py b/packages/mosaic/framework/tools/git/forge-api.py index f16160821..4cd1662a6 100644 --- a/packages/mosaic/framework/tools/git/forge-api.py +++ b/packages/mosaic/framework/tools/git/forge-api.py @@ -101,6 +101,7 @@ class Client: def __init__(self, host, repo): self.host, self.repo = host, repo self.provider, self.version, self.token = 'unknown', '', None + self.write_attempted = False self.root = '/repos/' + repo def detect(self): @@ -196,6 +197,8 @@ class Client: return result def transport(self, args, body, writing): + if writing: + self.write_attempted = True try: return run(args, json.dumps(body) if body is not None else None) except Failure as e: @@ -433,7 +436,9 @@ def reply(client, number, opaque, body, expected): item = client.http(client.root + '/pulls/comments/' + str(created['id'])) expected_url = '/repos/' + client.repo + '/pulls/' + str(number) parsed = urllib.parse.urlparse(item.get('pull_request_url', '')) - if parsed.path != expected_url or parsed.hostname != ('api.github.com' if client.host == 'github.com' else client.host) or item.get('in_reply_to_id') != int(cid): + authority = urllib.parse.urlparse('https://' + ('api.github.com' if client.host == 'github.com' else client.host)) + actual_path = parsed.path.removeprefix('/api/v3') if client.host != 'github.com' else parsed.path + if actual_path != expected_url or parsed.scheme != 'https' or parsed.hostname != authority.hostname or (parsed.port or 443) != (authority.port or 443) or parsed.username or parsed.password or item.get('in_reply_to_id') != int(cid): raise Failure(6, 'verification', 'Reply repository or parent readback differs', outcome='unknown') actual = {'id':f"github:comment:{item.get('id')}", 'body':item.get('body'), 'author':(item.get('user') or {}).get('login'), 'commit_sha':item.get('commit_id')} else: @@ -723,7 +728,9 @@ def main(): parsed = urllib.parse.urlparse(remote) args.host = args.host or parsed.hostname args.repo = args.repo or parsed.path.strip('/').removesuffix('.git') - if not args.host or not re.fullmatch(r'[A-Za-z0-9.-]+(?::[0-9]+)?', args.host) or not args.repo or not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', args.repo): + if args.host: + args.host = args.host.lower() + if not args.host or not re.fullmatch(r'[A-Za-z0-9.-]+(?::[0-9]+)?', args.host) or not args.repo or not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', args.repo) or any(part in ('.','..') for part in args.repo.split('/')): raise Failure(2, 'usage', 'Use an unambiguous origin or --host HOST --repo OWNER/REPO') client = Client(args.host, args.repo) client.detect() @@ -786,11 +793,13 @@ def main(): if data is None and args.operation in ('status','watch-check','ci-wait'): data = dict(ready=False,checks={'state':'unknown','complete':False},reviews={'decision':'unknown','complete':False},threads={'unresolved':None,'complete':False}) code = e.code + if client and client.write_attempted and e.outcome == 'none' and code in (6,75,124): + e.outcome = 'unknown' error = dict(kind=e.kind, message=str(e), retry_after_seconds=e.retry, write_outcome=e.outcome) print(str(e), file=sys.stderr) except (ValueError, TypeError, KeyError, AttributeError) as e: code = 6 - error = dict(kind='malformed', message='Provider evidence malformed: ' + str(e), retry_after_seconds=None, write_outcome='none') + error = dict(kind='malformed', message='Provider evidence malformed: ' + str(e), retry_after_seconds=None, write_outcome='unknown' if client and client.write_attempted else 'none') print(json.dumps(dict(schema_version=1, operation=args.operation, provider=client.provider if client else 'unknown', host=args.host, repo=args.repo, number=args.number, head_sha=head, data=data, error=error), sort_keys=True)) if args.operation == 'ci-wait' and not args.json and code not in (0,2,124): return 1 diff --git a/packages/mosaic/framework/tools/git/test-forge-contract.py b/packages/mosaic/framework/tools/git/test-forge-contract.py index c3e14c5cc..99cc89b77 100644 --- a/packages/mosaic/framework/tools/git/test-forge-contract.py +++ b/packages/mosaic/framework/tools/git/test-forge-contract.py @@ -188,6 +188,20 @@ class ReviewContract(SnapshotContract): self.api.review_comments = original self.assertEqual(sum(method == 'POST' for _,method,_ in calls), 1) + def test_enterprise_reply_readback_uses_api_prefix(self): + c=self.fake(); c.host='git.example.test' + original=self.api.review_comments + self.api.review_comments=lambda *args: ({'complete':True,'reviews':[],'comments':[{'id':'github:comment:3','thread_id':'github:thread:T'}]},[],None) + def request(path,method='GET',body=None,public=False): + if path=='/user': return {'login':'reviewer'} + if method=='POST': return {'id':8} + return {'id':8,'body':'body','user':{'login':'reviewer'},'in_reply_to_id':3,'pull_request_url':'https://git.example.test/api/v3/repos/owner/repo/pulls/1'} + c.http=request + try: + self.assertTrue(self.api.reply(c,1,'github:comment:3','body','a'*40)['verified']) + finally: + self.api.review_comments=original + def test_resolve_requires_thread_owned_by_pr(self): c = self.fake() with self.assertRaises(self.api.Failure) as ctx: diff --git a/packages/mosaic/framework/tools/git/test-repo-decl-consumption.sh b/packages/mosaic/framework/tools/git/test-repo-decl-consumption.sh index c5ecfcbc0..d5895ee8e 100755 --- a/packages/mosaic/framework/tools/git/test-repo-decl-consumption.sh +++ b/packages/mosaic/framework/tools/git/test-repo-decl-consumption.sh @@ -342,7 +342,7 @@ mkmetadata "$SB/e2" E_RC=0 E_OUT="$(cd "$SB/e2" && env -i PATH="$SB/e2/stub:/usr/bin:/bin" HOME="$SB/e2/home" \ bash "$SB/e2/tools/git/ci-queue-wait.sh" < /dev/null 2>"$SB/e2/eerr.txt")" || E_RC=$? -assert_not_contains "E2 absence is SILENT for the guard (N4)" "$(cat "$SB/e2/eerr.txt")" "repo-decl" +assert_not_contains "E2 absence is SILENT for the guard (N4)" "$(cat "$SB/e2/eerr.txt")" "repo-decl:" cleanup_all assert_count "final: zero scratch residue" 0 "$(ls -d "$TROOT"/wp5b-test.* 2>/dev/null | wc -l | tr -d ' ')" -- 2.54.0 From 55d6cc18e378306146505303c9b73da01164a3a9 Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 14:41:45 -0600 Subject: [PATCH 05/26] feat(framework): package provider-neutral PR workflow skills and parity evidence (#1841) Promote the four attributed workflows and their git/watch skill dependencies. Document capabilities, merge declarations, unsupported profiles, optional notification prerequisites and measured verification limits. Exclude generated Python bytecode from published git tools. Co-Authored-By: GPT-6.1-Sol (Codex) --- .../1841-forge-wrapper-evidence.json | 465 ++++++++++++++++++ docs/scratchpads/1841-forge-wrapper-gaps.md | 91 ++-- .../framework/guides/TOOLS-REFERENCE.md | 8 +- .../framework/skills/mosaic-gitea/SKILL.md | 91 +--- .../framework/skills/ms-babysit-pr/LICENSE | 21 + .../framework/skills/ms-babysit-pr/SKILL.md | 24 + .../framework/skills/ms-council/LICENSE | 21 + .../framework/skills/ms-council/SKILL.md | 19 + .../framework/skills/ms-full-send/LICENSE | 21 + .../framework/skills/ms-full-send/SKILL.md | 23 + .../framework/skills/ms-full-stop/LICENSE | 21 + .../framework/skills/ms-full-stop/SKILL.md | 18 + .../skills/ms-git-operations/SKILL.md | 29 ++ .../mosaic/framework/skills/ms-watch/SKILL.md | 16 + .../mosaic/framework/tools/git/.npmignore | 2 + packages/mosaic/framework/tools/git/README.md | 153 ++++++ .../mosaic/framework/tools/git/forge-api.py | 18 +- .../framework/tools/git/test-forge-cli.py | 5 +- .../tools/git/test-forge-contract.py | 8 + 19 files changed, 922 insertions(+), 132 deletions(-) create mode 100644 docs/scratchpads/1841-forge-wrapper-evidence.json create mode 100644 packages/mosaic/framework/skills/ms-babysit-pr/LICENSE create mode 100644 packages/mosaic/framework/skills/ms-babysit-pr/SKILL.md create mode 100644 packages/mosaic/framework/skills/ms-council/LICENSE create mode 100644 packages/mosaic/framework/skills/ms-council/SKILL.md create mode 100644 packages/mosaic/framework/skills/ms-full-send/LICENSE create mode 100644 packages/mosaic/framework/skills/ms-full-send/SKILL.md create mode 100644 packages/mosaic/framework/skills/ms-full-stop/LICENSE create mode 100644 packages/mosaic/framework/skills/ms-full-stop/SKILL.md create mode 100644 packages/mosaic/framework/skills/ms-git-operations/SKILL.md create mode 100644 packages/mosaic/framework/skills/ms-watch/SKILL.md create mode 100644 packages/mosaic/framework/tools/git/.npmignore diff --git a/docs/scratchpads/1841-forge-wrapper-evidence.json b/docs/scratchpads/1841-forge-wrapper-evidence.json new file mode 100644 index 000000000..d91bfa9b5 --- /dev/null +++ b/docs/scratchpads/1841-forge-wrapper-evidence.json @@ -0,0 +1,465 @@ +{ + "base_sha": "924438016e2eb1c2ca352bfbf9107143b8b342bb", + "shell_suites": [ + { + "test": "test-ci-queue-wait-branch-absent.sh", + "exit_code": 0 + }, + { + "test": "test-ci-queue-wait-github-checks.sh", + "exit_code": 0 + }, + { + "test": "test-ci-queue-wait-no-ci-expected.sh", + "exit_code": 0 + }, + { + "test": "test-ci-queue-wait-no-status.sh", + "exit_code": 0 + }, + { + "test": "test-ci-queue-wait-tristate.sh", + "exit_code": 0 + }, + { + "test": "test-detect-platform-outside-repo.sh", + "exit_code": 0 + }, + { + "test": "test-explain-diagnostic-status-neutral.sh", + "exit_code": 0 + }, + { + "test": "test-forge-capabilities.sh", + "exit_code": 0 + }, + { + "test": "test-git-credential-mosaic.sh", + "exit_code": 0 + }, + { + "test": "test-gitea-login-resolution.sh", + "exit_code": 0 + }, + { + "test": "test-gitea-token-identity.sh", + "exit_code": 0 + }, + { + "test": "test-grant-reviewer.sh", + "exit_code": 0 + }, + { + "test": "test-help-exit-code.sh", + "exit_code": 0 + }, + { + "test": "test-issue-assign-usage-contract.sh", + "exit_code": 0 + }, + { + "test": "test-issue-close-fail-closed.sh", + "exit_code": 0 + }, + { + "test": "test-issue-close-usage-contract.sh", + "exit_code": 0 + }, + { + "test": "test-issue-comment-readback.sh", + "exit_code": 0 + }, + { + "test": "test-issue-comment-usage-contract.sh", + "exit_code": 0 + }, + { + "test": "test-issue-create-body-safety.sh", + "exit_code": 0 + }, + { + "test": "test-issue-create-interactive-auth.sh", + "exit_code": 0 + }, + { + "test": "test-issue-create-usage-contract.sh", + "exit_code": 0 + }, + { + "test": "test-issue-edit-usage-contract.sh", + "exit_code": 0 + }, + { + "test": "test-issue-list-usage-contract.sh", + "exit_code": 0 + }, + { + "test": "test-issue-reopen-usage-contract.sh", + "exit_code": 0 + }, + { + "test": "test-issue-view-comments.sh", + "exit_code": 0 + }, + { + "test": "test-issue-view-usage-contract.sh", + "exit_code": 0 + }, + { + "test": "test-lane-brief-pr-linkage.sh", + "exit_code": 0 + }, + { + "test": "test-lane-brief-usage-contract.sh", + "exit_code": 0 + }, + { + "test": "test-milestone-close-usage-contract.sh", + "exit_code": 0 + }, + { + "test": "test-milestone-create-usage-contract.sh", + "exit_code": 1, + "baseline": "Same exit 1 reproduced on pinned base: missing-title usage returns provider/no-origin error instead of usage exit 2." + }, + { + "test": "test-milestone-list-usage-contract.sh", + "exit_code": 0 + }, + { + "test": "test-mosaic-worktree-large-repo.sh", + "exit_code": 0 + }, + { + "test": "test-mutate-push-guard.sh", + "exit_code": 0 + }, + { + "test": "test-pr-auto-merge.sh", + "exit_code": 0 + }, + { + "test": "test-pr-close-usage-contract.sh", + "exit_code": 0 + }, + { + "test": "test-pr-create-fallback-default-base.sh", + "exit_code": 0 + }, + { + "test": "test-pr-create-usage-contract.sh", + "exit_code": 0 + }, + { + "test": "test-pr-diff-usage-contract.sh", + "exit_code": 0 + }, + { + "test": "test-pr-edit-usage-contract.sh", + "exit_code": 0 + }, + { + "test": "test-pr-edit.sh", + "exit_code": 0 + }, + { + "test": "test-pr-list-usage-contract.sh", + "exit_code": 0 + }, + { + "test": "test-pr-merge-baseline.sh", + "exit_code": 0 + }, + { + "test": "test-pr-merge-fork-ci-status.sh", + "exit_code": 0 + }, + { + "test": "test-pr-merge-gitea-empty-uid.sh", + "exit_code": 0 + }, + { + "test": "test-pr-merge-head-pin.sh", + "exit_code": 0 + }, + { + "test": "test-pr-merge-message-field.sh", + "exit_code": 0 + }, + { + "test": "test-pr-merge-methods.sh", + "exit_code": 0 + }, + { + "test": "test-pr-merge-no-ci-expected.sh", + "exit_code": 0 + }, + { + "test": "test-pr-merge-queue-branch.sh", + "exit_code": 0 + }, + { + "test": "test-pr-merge-queue.sh", + "exit_code": 0 + }, + { + "test": "test-pr-metadata-gitea.sh", + "exit_code": 0 + }, + { + "test": "test-pr-metadata-usage-contract.sh", + "exit_code": 0 + }, + { + "test": "test-pr-review-comments.sh", + "exit_code": 0 + }, + { + "test": "test-pr-review-gitea-comment.sh", + "exit_code": 0 + }, + { + "test": "test-pr-review-reply.sh", + "exit_code": 0 + }, + { + "test": "test-pr-review-repo-host-override.sh", + "exit_code": 0 + }, + { + "test": "test-pr-review-resolve.sh", + "exit_code": 0 + }, + { + "test": "test-pr-review-usage-contract.sh", + "exit_code": 0 + }, + { + "test": "test-pr-status.sh", + "exit_code": 0 + }, + { + "test": "test-pr-view-usage-contract.sh", + "exit_code": 0 + }, + { + "test": "test-pr-watch-check.sh", + "exit_code": 0 + }, + { + "test": "test-pr-watch-parity.sh", + "exit_code": 0 + }, + { + "test": "test-push-guard.sh", + "exit_code": 0 + }, + { + "test": "test-repo-decl-consumption.sh", + "initial_exit_code": 1, + "exit_code": 0, + "rerun_reason": "Assert diagnostic prefix, excluding incidental scratch directory names; isolated rerun passed all 68 assertions." + }, + { + "test": "test-verify-clean-clone.sh", + "exit_code": 0 + }, + { + "test": "test-wrapper-guard.sh", + "exit_code": 0 + } + ], + "additional_checks": [ + { + "command": "python3 -I tools/git/test-forge-contract.py", + "exit_code": 0, + "runner_cases": 89, + "note": "Inherited fixture cases repeat across classes." + }, + { + "command": "python3 -I tools/git/test-forge-cli.py", + "exit_code": 0, + "runner_cases": 7 + }, + { + "command": "python3 -I tools/git/test-merge-policy.py", + "exit_code": 0, + "runner_cases": 5 + }, + { + "command": "python3 -I tools/git/test-forge-guard.py", + "exit_code": 0, + "runner_cases": 4 + }, + { + "command": "python3 -I tools/git/test-pr-skill-contract.py", + "exit_code": 0, + "runner_cases": 4 + }, + { + "command": "bash tools/git/test-pr-watch-parity.sh", + "exit_code": 0, + "note": "Mock transport replay; no live delivery claim." + }, + { + "command": "bash tools/git/test-forge-schema.sh", + "exit_code": 0 + }, + { + "command": "bash tools/structure/test-validate-repo-json.sh", + "exit_code": 0, + "assertions": 101 + }, + { + "command": "pnpm exec vitest run src/commands/skill.spec.ts src/framework/manifest-parity.spec.ts src/framework/manifest.spec.ts", + "exit_code": 0, + "runner_cases": 97 + }, + { + "command": "Real loadSkillsCatalog against framework", + "exit_code": 0, + "skills_discovered": 6 + }, + { + "command": "bash -n all git shell scripts", + "exit_code": 0, + "files": 106 + }, + { + "command": "ShellCheck new entry points", + "exit_code": 0 + }, + { + "command": "ShellCheck changed existing wrappers", + "exit_code": 1, + "baseline_exit_code": 1, + "new_diagnostics": 0 + }, + { + "command": "pnpm preflight", + "exit_code": 0 + }, + { + "command": "pnpm typecheck", + "exit_code": 0 + }, + { + "command": "pnpm lint", + "exit_code": 0 + }, + { + "command": "pnpm format:check", + "exit_code": 0 + }, + { + "command": "npm pack --dry-run --json and publish-list assertions", + "exit_code": 0, + "skills": 6, + "attribution_licenses": 4, + "bytecode_entries": 0, + "negative_control_before_exclusion": 2 + } + ], + "limitations": [ + "No live provider mutation tests.", + "No authenticated GitHub/Forgejo or new Gitea operation qualification.", + "No live T3 or fallback notifier delivery qualification. Fallback notifier is operator-provisioned.", + "Older inspected forge conversation mutations and scheduled integration state explicitly unsupported.", + "App-bound required GitHub checks remain unknown and cannot establish readiness.", + "Independent exact-head review, PR, CI, integration and release pending." + ], + "dependencies": "Installed the locked workspace dependency graph for required push checks; extracted ShellCheck 0.10.0 in task scratch. No runtime services or containers started.", + "files_by_work_item": { + "A": [ + "tools/git/detect-platform.sh", + "tools/git/forge-api.py", + "tools/git/forge-capabilities.sh", + "tools/git/pr-status.sh", + "tools/git/pr-status.schema.json", + "tools/git/pr-watch-check.sh" + ], + "B": [ + "tools/git/pr-review-comments.sh", + "tools/git/pr-review-reply.sh", + "tools/git/pr-review-resolve.sh", + "tools/git/forge-api.py" + ], + "C": [ + "tools/git/merge-policy.py", + "tools/git/repo-decl.sh", + "tools/structure/validate-repo-json.sh", + "tools/git/pr-merge.sh", + "tools/git/pr-auto-merge.sh", + "tools/git/pr-merge-queue.sh", + "tools/git/pr-create.sh", + "tools/git/ci-queue-wait.sh", + "tools/git/mosaic-worktree.sh" + ], + "D": [ + "tools/git/wrapper-guard.sh", + "tools/git/pr-ci-wait.sh", + "tools/git/.npmignore", + "tools/git/README.md", + "guides/TOOLS-REFERENCE.md" + ], + "skills": [ + "skills/ms-full-send/SKILL.md", + "skills/ms-full-stop/SKILL.md", + "skills/ms-babysit-pr/SKILL.md", + "skills/ms-council/SKILL.md", + "skills/ms-git-operations/SKILL.md", + "skills/ms-watch/SKILL.md", + "skills/mosaic-gitea/SKILL.md", + "skills/ms-full-send/LICENSE", + "skills/ms-full-stop/LICENSE", + "skills/ms-babysit-pr/LICENSE", + "skills/ms-council/LICENSE" + ], + "fixture_and_parity_tests": [ + "packages/mosaic/framework/tools/git/test-ci-queue-wait-branch-absent.sh", + "packages/mosaic/framework/tools/git/test-ci-queue-wait-no-ci-expected.sh", + "packages/mosaic/framework/tools/git/test-ci-queue-wait-no-status.sh", + "packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh", + "packages/mosaic/framework/tools/git/test-forge-capabilities.sh", + "packages/mosaic/framework/tools/git/test-forge-cli.py", + "packages/mosaic/framework/tools/git/test-forge-contract.py", + "packages/mosaic/framework/tools/git/test-forge-guard.py", + "packages/mosaic/framework/tools/git/test-forge-schema.cjs", + "packages/mosaic/framework/tools/git/test-forge-schema.sh", + "packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh", + "packages/mosaic/framework/tools/git/test-grant-reviewer.sh", + "packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh", + "packages/mosaic/framework/tools/git/test-issue-comment-readback.sh", + "packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh", + "packages/mosaic/framework/tools/git/test-issue-view-comments.sh", + "packages/mosaic/framework/tools/git/test-merge-policy.py", + "packages/mosaic/framework/tools/git/test-pr-auto-merge.sh", + "packages/mosaic/framework/tools/git/test-pr-create-fallback-default-base.sh", + "packages/mosaic/framework/tools/git/test-pr-edit.sh", + "packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh", + "packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh", + "packages/mosaic/framework/tools/git/test-pr-merge-message-field.sh", + "packages/mosaic/framework/tools/git/test-pr-merge-methods.sh", + "packages/mosaic/framework/tools/git/test-pr-merge-queue.sh", + "packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh", + "packages/mosaic/framework/tools/git/test-pr-review-comments.sh", + "packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh", + "packages/mosaic/framework/tools/git/test-pr-review-reply.sh", + "packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh", + "packages/mosaic/framework/tools/git/test-pr-review-resolve.sh", + "packages/mosaic/framework/tools/git/test-pr-skill-contract.py", + "packages/mosaic/framework/tools/git/test-pr-status.sh", + "packages/mosaic/framework/tools/git/test-pr-watch-check.sh", + "packages/mosaic/framework/tools/git/test-pr-watch-parity.py", + "packages/mosaic/framework/tools/git/test-pr-watch-parity.sh", + "packages/mosaic/framework/tools/git/test-repo-decl-consumption.sh", + "packages/mosaic/framework/tools/git/test-wrapper-guard.sh", + "packages/mosaic/framework/tools/structure/test-validate-repo-json.sh", + "packages/mosaic/framework/tools/git/test-forge-schema.sh", + "packages/mosaic/framework/tools/git/test-forge-schema.cjs" + ] + }, + "carried_local_fixes": [ + "Python import isolation in the shared detection/auth helper. No wholesale directory replacement." + ] +} diff --git a/docs/scratchpads/1841-forge-wrapper-gaps.md b/docs/scratchpads/1841-forge-wrapper-gaps.md index 95c15db45..47a8ebfbd 100644 --- a/docs/scratchpads/1841-forge-wrapper-gaps.md +++ b/docs/scratchpads/1841-forge-wrapper-gaps.md @@ -1,44 +1,71 @@ # Forge wrapper gaps (#1841) -Base: 924438016e2eb1c2ca352bfbf9107143b8b342bb (`origin/next`, fetched 2026-11-04). +Baseline: 924438016e2eb1c2ca352bfbf9107143b8b342bb, fetched integration head. -Scope: normalized capability/status/watch contract, review conversations, integration controls, -guards, packaged workflow skills and transport parity. Source of truth is upstream framework; -local changes are considered individually. No live provider writes in tests. +The implementation keeps provider APIs in wrappers, workflow authority in skills and +notifications in transport adapters. No live provider mutation is part of fixture tests. +Independent candidate review and integration remain separate delivery gates. -Authority: ordinary task-branch commits and push only. Coordinator arranges independent review -before PR creation. No merge, deployment, framework installation or local rollout. +## Scope and decisions -Preflight: B consumes A's opaque provider IDs and capability profiles. C consumes A's exact-head -status and protection evidence. D and skills consume A's exit contract. Watch transports must -not supply readiness evidence. Unknown integration state blocks full-stop. +- A: versioned capability/status envelope, exact-head checks and watch predicate. +- B: complete review/comment enumeration and verified native replies/resolution. +- C: declared merge methods and separate automatic-integration inspection/control. +- D: direct-write guard coverage, CI-wait mapping, compatibility fixtures and documentation. +- Skills: packaged workflows/dependencies with upstream attribution and transport parity tests. -Tracking: issue #1841 created through issue-create.sh after open issue listing. Existing supplied -branch name retained per coordinator instruction. Worktree preserved at the T3-assigned path. +The optional schema-v3 merge policy was approved during implementation. Absent policy +remains squash-only. Task, promotion and explicit absorb mappings constrain method requests; +provider allow_* settings further restrict them. Invalid/unknown policy never grants authority. +Schema 1/2 stay readable; version-3 consumers retain branch gates. -Progress: intake complete; implementation pending. +Gitea/Forgejo scheduled merge state remains unsupported because inspected schemas lack +readback. Native review mutations remain unsupported on the older inspected profiles. +Unknown capabilities and required evidence fail closed. App-bound required GitHub checks +remain unknown pending a source-specific matcher. Live authentication, repository settings, +provider mutations and notifier delivery are unverified. -## Implementation checkpoint +## Compatibility and safety work -Partial A/B scaffolding exists and is uncommitted. The normalized Python adapter, shell -entry points, envelope schema and focused offline tests are drafts, not accepted delivery. -One local fix carried independently: Python import isolation in detect-platform.sh. +Source is upstream framework content. Local fixes are evaluated individually; Python import +isolation was carried into the shared detection/auth helper. No wholesale local tool copy was +used. Additional fixes bind metadata detection to authority, quote declaration values as shell +words, and execute legacy credential/CI bridges from a safe working directory. -Verification: `python3 -I packages/mosaic/framework/tools/git/test-forge-contract.py` -returned 0, runner reported 32 tests (inherited cases repeat across test classes). -`bash -n packages/mosaic/framework/tools/git/detect-platform.sh` returned 0. -Earlier red runs demonstrated missing adapter functions and incorrect HTTP access mapping. -No full wrapper suite, shellcheck, parity, package discovery or hook checks have run. -No dependencies installed. No commits or push. No PR created. +Legacy fixture clients now explicitly supply public API metadata instead of relying on host +name guessing. The no-PyYAML fixture uses an isolated no-site interpreter, because production +-I intentionally ignores PYTHONPATH. The obsolete aggregate-only CI fixture migrated to the +normalized exact-head/empty-check controls. The unknown-version validator fixture moved to +version 4 when version 3 became supported. -BLOCKED on plan section 4C's authority representation. Existing repo-decl.sh lines 151–195 -define direct/trunk-release branch transitions only. Neither that helper nor the current -repository declaration defines merge styles or an absorb classification. The plan requires -repository declaration/PR shape to choose allowed methods, including task=squash and -absorb/release=merge when required. A command-line method flag cannot become authority -(the existing helper explicitly states this). Coordinator must specify whether to extend the -repository declaration with a merge-policy contract, or identify an existing authoritative -shape/policy source. No merge-policy representation has been invented in this patch. +## Verification checkpoint -Preserved task scratch: /var/src/_scratch/tmp.Ys2ggENH8n. Contains drift comparison, -public schema and assembly fragments needed to resume. No scratch directories removed. +Focused adapter, shell-client, policy, guard, skill and parity tests pass. The declaration suite +also passes its schema-version migration controls. New shell entry points pass ShellCheck. +Preflight, workspace typecheck and lint pass after installing locked dependencies. The package +dry-run includes the new skills, licenses and helper assets. + +Final shell sweep ran 66 suites. After the isolated declaration rerun, 65 passed; the +milestone-create missing-title usage case remains exit 1 and reproduces on the pinned base. +Its provider/no-origin error precedes the expected usage exit 2. This unrelated baseline +failure is documented instead of absorbed. An earlier issue-close failure under ambient +host state disappeared in the fully isolated run. + +All 68 declaration consumption assertions pass. The unknown-version fixture now uses v4; +the v3 validator and shell-quoting control pass. New shell entry points pass ShellCheck; +changed existing wrappers have no diagnostics beyond the measured baseline. All 106 git +shell files pass bash syntax checks. Package skill/manifest tests pass 97 cases, and the real +catalog discovers all six promoted skills. Publish-list assertions retain source and licenses +and exclude test bytecode, with a two-entry bytecode negative control before the exclusion. + +See 1841-forge-wrapper-evidence.json for per-suite exit codes and file mapping. Locked workspace +dependencies were installed for required push checks. ShellCheck was extracted into temporary +scratch without a system install. No services, containers, migration runner, application server, +local framework rollout or provider mutation test was started. Independent review, PR, CI, +integration and release remain pending. A host notifier is an explicit optional prerequisite +outside supported T3 notifications; this change does not install one. + +Parity tests replay normalized provider evidence through mocked transports. They do not prove +live T3 support or notifier delivery. A failed required read blocks either workflow; unsupported +schedule inspection blocks full-stop in either transport. Scheduling success is never a merge +completion claim. diff --git a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md index 806f6947a..3520687a9 100644 --- a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md +++ b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md @@ -226,11 +226,11 @@ Multi-instance support: `-a ` selects a named instance (e.g. `personal ### Repo Structure Declaration (T51) ```bash -# Validate a .mosaic/repo.json declaration (schema v1/v2, host:/ grammar, +# Validate a .mosaic/repo.json declaration (schema v1/v2/v3, host:/ grammar, # ref grammar, cross-field rules, remote normalization; spec §5) ~/.config/mosaic/tools/structure/validate-repo-json.sh /.mosaic/repo.json -# CI authoring rule: new/edited declarations must be schema_version 2 +# CI authoring rule: new/edited declarations must be schema_version 2 or 3 ~/.config/mosaic/tools/structure/validate-repo-json.sh /.mosaic/repo.json --require-v2 # Display mode (warns and omits root-dependent checks when MOSAIC_HOST_ROOT unset) @@ -360,3 +360,7 @@ mosaic-ensure-excalidraw --check # verify registration - Prefer `trash` over `rm` when available — recoverable beats gone forever - Never run destructive commands without explicit instruction - Write it down — "mental notes" don't survive session restarts; files do + +Repository declarations also support schema v3 with an optional, strictly validated merge policy. +See [the git wrapper merge-policy contract](../tools/git/README.md#repository-merge-policy-schema-version-3). +Absent policy remains squash-only; command-line method requests do not grant merge authority. diff --git a/packages/mosaic/framework/skills/mosaic-gitea/SKILL.md b/packages/mosaic/framework/skills/mosaic-gitea/SKILL.md index 9ae65e625..c167a20c4 100644 --- a/packages/mosaic/framework/skills/mosaic-gitea/SKILL.md +++ b/packages/mosaic/framework/skills/mosaic-gitea/SKILL.md @@ -1,93 +1,8 @@ --- name: mosaic-gitea -description: Interact with Gitea repositories on git.mosaicstack.dev — create/merge/close PRs, manage issues, milestones, and CI queue waits. Use when working with Mosaic Stack git repos. Wraps scripts in ~/.config/mosaic/tools/git/. Works on both Gitea and GitHub (auto-detected via detect-platform.sh). +description: Use when an older workflow requests the Mosaic Gitea wrapper reference. --- -# mosaic-gitea +# Git wrapper compatibility -Git operations via Mosaic wrapper scripts. Platform-aware (Gitea or GitHub). - -## Setup - -Scripts auto-detect platform from git remote. Run from inside the repo directory. - -Credentials come from the framework credentials loader (never from a shared env -file): - -```bash -source ~/.config/mosaic/tools/_lib/credentials.sh -load_credentials gitea-mosaicstack -# Exports: GITEA_TOKEN, GITEA_URL -``` - -## Script Reference - -All scripts in `~/.config/mosaic/tools/git/`. - -### Pull Requests - -| Script | Purpose | Key flags | -| ---------------- | ----------------------- | -------------------------------------------------------- | -| `pr-create.sh` | Create a PR | `-t "Title" [-b "Body"] [-B base] [-H head] [-i issue#]` | -| `pr-list.sh` | List open PRs | `[-s state]` | -| `pr-view.sh` | View PR details | `-n ` | -| `pr-merge.sh` | Squash-merge a PR | `-n [-d]` (delete branch) | -| `pr-close.sh` | Close a PR | `-n ` | -| `pr-diff.sh` | Show PR diff | `-n ` | -| `pr-review.sh` | Submit a review | `-n [-a approve\|request-changes]` | -| `pr-ci-wait.sh` | Wait for CI on PR | `-n ` | -| `pr-metadata.sh` | Get PR metadata as JSON | `-n ` | - -### Issues - -| Script | Purpose | Key flags | -| ------------------ | --------------- | --------------------------------------------------- | -| `issue-create.sh` | Create an issue | `-t "Title" [-b "Body"] [-l labels] [-m milestone]` | -| `issue-list.sh` | List issues | `[-s state] [-l label]` | -| `issue-view.sh` | View issue | `-n ` | -| `issue-close.sh` | Close issue | `-n ` | -| `issue-comment.sh` | Add comment | `-n -c "Comment"` | -| `issue-assign.sh` | Assign issue | `-n -u username` | -| `issue-edit.sh` | Edit issue | `-n [-t title] [-b body]` | - -### Milestones - -| Script | Purpose | Key flags | -| --------------------- | ---------------- | ---------------------------- | -| `milestone-create.sh` | Create milestone | `-t "Title" [-d "due date"]` | -| `milestone-list.sh` | List milestones | — | -| `milestone-close.sh` | Close milestone | `-n ` | - -### CI / Queue - -| Script | Purpose | Key flags | -| -------------------- | ---------------------- | --------------------------- | -| `ci-queue-wait.sh` | Wait for CI queue slot | `[-t timeout] [-B branch]` | -| `detect-platform.sh` | Detect git platform | outputs `gitea` or `github` | - -## Common Workflows - -**Create PR from current branch:** - -```bash -cd ~/src/ -~/.config/mosaic/tools/git/pr-create.sh -t "feat: my feature" -b "Description" -i -``` - -**Merge a PR (squash, with CI queue guard):** - -```bash -cd ~/src/ -~/.config/mosaic/tools/git/pr-merge.sh -n -d -``` - -Branch protection is a gate, not an obstacle: if it blocks a merge, fix the cause — -a failing check, a moved head, or a missing review. Never bypass it with a raw -API call, a shared credential, or `force_merge`. Exceptional cases go to the -operator or the coordinating seat, still merged through the wrapper. - -## Notes - -- Mosaic policy: squash merges only, targeting `main` -- Gitea SSH: `git@git.mosaicstack.dev` → resolves via `~/.ssh/config` to the Gitea host's SSH port (e.g. `gitea.example.internal:2222`) -- Workers push branches and open PRs but **never merge** — orchestrator handles merges +Use [ms-git-operations](../ms-git-operations/SKILL.md) for the provider-neutral wrapper contract and capability table. Repository policy and provider capabilities determine behavior. For PR waiting use [ms-babysit-pr](../ms-babysit-pr/SKILL.md). Do not keep separate forge-specific write recipes. diff --git a/packages/mosaic/framework/skills/ms-babysit-pr/LICENSE b/packages/mosaic/framework/skills/ms-babysit-pr/LICENSE new file mode 100644 index 000000000..eccbd9f1c --- /dev/null +++ b/packages/mosaic/framework/skills/ms-babysit-pr/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Theo Browne + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/mosaic/framework/skills/ms-babysit-pr/SKILL.md b/packages/mosaic/framework/skills/ms-babysit-pr/SKILL.md new file mode 100644 index 000000000..22a09f6b9 --- /dev/null +++ b/packages/mosaic/framework/skills/ms-babysit-pr/SKILL.md @@ -0,0 +1,24 @@ +--- +name: ms-babysit-pr +description: Use when the user asks to monitor, watch or babysit a pull request through review and CI. +--- + +# Babysit a PR + +Follow ms-full-send's proof and authority gates, ms-git-operations and ms-watch. Monitoring grants no merge authority. Full-stop never merges or schedules. + +Handle current events before waiting. Read `pr-status.sh -n NUMBER --json`, pin head/base, then enumerate `pr-review-comments.sh -n NUMBER --json` completely. Verify findings against source, including old and resolved/outdated comments. Fix real in-scope defects, prove changes and refresh independent review on the new head. Use `pr-review-reply.sh --comment-id ID --body-file PATH --expect-head SHA --json` for native replies and `pr-review-resolve.sh --thread-id ID --resolve --expect-head SHA --json` for authorized resolution. PR number and repository targeting are required too. Never substitute a PR-level issue comment for a native reply. Unsupported conversation operations and incomplete lists leave explicit blockers. + +T3 linking and notifications are optional transports. When available, register the URL with `link_pull_request`, verify it through `list_thread_pull_requests`, and report failures. They never supply readiness truth. + +- Delegated children return URL, head SHA and pending evidence to their parent. They establish no competing watches. +- If `watch_pull_request` supports the forge, handle current events, arm one watch and yield. On wake, reread pr-status. A runtime error is not proof of unsupported forge; report it without creating a second owner. +- If T3 watching is absent or explicitly unsupported, use one bounded ms-watch over `pr-watch-check.sh -n NUMBER --baseline ABS_JSON --json`, with explicit host/repo. Store a private baseline envelope per owner/host/repo/PR. The predicate performs one snapshot and never runs pr-ci-wait or another blocking wait. +- Watch terminal success, failure, cancellation, closure, merge and relevant changes. Preserve retry metadata on rate limits. Never convert a read error to unchanged pending. +- No supported notifier means a watch-delivery blocker. Do not claim a shell polling loop is equivalent delivery. + +Wake verdicts are `merged`, `closed_unmerged`, `ready_to_merge`, `waiting`, `blocked` or `timed_out`. Failed CI is blocked. Unknown required evidence blocks readiness. Full-stop hands off a ready candidate; full-send may merge only with explicit role authority. Both paths use the same snapshot and exit/error mapping. + +Call `unwatch_pull_request` or stop the fallback watch before final handoff. Report URL, head/base/merge SHAs, checks, review verdict and SHA, thread completeness, integration state, blockers, authority and watch ownership. Scheduling success is not a terminal merge result. + +Adapted from [t3dotgg/tstack](https://github.com/t3dotgg/tstack/tree/1085050f3baed1ac724caa4a8166145fb2ac88af/skills/babysit-pr) (MIT), revision 1085050f3baed1ac724caa4a8166145fb2ac88af. See [LICENSE](LICENSE). diff --git a/packages/mosaic/framework/skills/ms-council/LICENSE b/packages/mosaic/framework/skills/ms-council/LICENSE new file mode 100644 index 000000000..eccbd9f1c --- /dev/null +++ b/packages/mosaic/framework/skills/ms-council/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Theo Browne + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/mosaic/framework/skills/ms-council/SKILL.md b/packages/mosaic/framework/skills/ms-council/SKILL.md new file mode 100644 index 000000000..19766c373 --- /dev/null +++ b/packages/mosaic/framework/skills/ms-council/SKILL.md @@ -0,0 +1,19 @@ +--- +name: ms-council +description: Use when the user asks for a council or an independent panel of reviews on one candidate. +--- + +# Council + +Collect independent reviews of one exact candidate. Follow ms-full-send's identity, proof, storage and repository boundaries. Council grants no merge authority. + +1. Pin candidate SHA, scope, acceptance criteria and raw test evidence. Resolve known defects first unless the requested review is about replacing the design. +2. Use the requested membership, subject to available policy and budget. Otherwise choose independent capable reviewers from the live model catalog. Do not hardcode coding/review roles by provider. Report unavailable models and explicit policy fallbacks. +3. In T3, use orchestrator_capabilities for live provider/model IDs and option descriptors. Delegate a separate read-only task per member, with explicit target options and retained taskId/clientRequestId. Elsewhere use supported delegation with equivalent scope. Do not create top-level threads for child reviews. Children neither mutate nor watch the candidate. +4. Give each member the same evidence without other members' findings. Require paths, severity, confidence, reproducible findings, verdict, reviewed SHA and optional score. Scores never replace approval. +5. Reproduce findings, fix accepted defects, explain rejected suggestions and preserve dissent. Changed candidates invalidate previous exact-head approval. Each new T3 round is a fresh delegate_task including original brief, prior findings/responses and unresolved objections, with a new stable round key. +6. Through pr-edit.sh, record reviewed SHA, verdicts, findings, dissent, round count and optional scores while preserving existing PR content. Without a PR return the same evidence to the coordinator. Missing scores remain missing; do not invent them. A co-author is not independent. + +Follow the repository's review gates and authority assignments. Budget/access exhaustion leaves an explicit incomplete verdict, never an approval. + +Adapted from [t3dotgg/tstack](https://github.com/t3dotgg/tstack/tree/1085050f3baed1ac724caa4a8166145fb2ac88af/skills/council) (MIT), revision 1085050f3baed1ac724caa4a8166145fb2ac88af. See [LICENSE](LICENSE). diff --git a/packages/mosaic/framework/skills/ms-full-send/LICENSE b/packages/mosaic/framework/skills/ms-full-send/LICENSE new file mode 100644 index 000000000..eccbd9f1c --- /dev/null +++ b/packages/mosaic/framework/skills/ms-full-send/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Theo Browne + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/mosaic/framework/skills/ms-full-send/SKILL.md b/packages/mosaic/framework/skills/ms-full-send/SKILL.md new file mode 100644 index 000000000..b40a23856 --- /dev/null +++ b/packages/mosaic/framework/skills/ms-full-send/SKILL.md @@ -0,0 +1,23 @@ +--- +name: ms-full-send +description: Use when the user explicitly asks for full send or unattended end-to-end delivery. +--- + +# Full send + +Own the requested change through the repository's declared completion gates. Invocation authorizes routine delivery within scope. It never waives independent review, hooks, protected operations or the assigned merge role. Repository instructions and the operator's authority boundary apply. + +Use ms-git-operations for every forge action. Inspect existing worktrees and the declared integration branch first. Preserve other authors' work. Use the acting identity's own credential. Keep generated data on operator-approved disk-backed storage and set TMPDIR, TMP and TEMP before tools that create temporary files. Never force-push or bypass hooks. + +1. Finish the scoped change and prove its behavior. Record baseline and candidate SHAs, test commands and actual exit codes. Follow the repository's delivery and review guides when present. +2. Obtain independent review on the exact candidate SHA. Select available models according to applicable policy, without hardcoding a provider/model division of labor. A co-author cannot approve the work. Use ms-council when a panel is requested. Changed candidates require fresh review evidence. +3. Run `ci-queue-wait.sh --purpose push` before an ordinary task-branch push. Create or edit the PR through wrappers, with explicit head/base and concise verification evidence. +4. Follow ms-babysit-pr for checks, findings and waiting. Require `pr-status.sh -n NUMBER --expect-head SHA --json` to return a complete, ready snapshot. Enumerate inline findings through `pr-review-comments.sh`; use native reply and resolution wrappers with exact-head pins. Unsupported operations leave explicit missing evidence, never fabricated issue-comment replies. +5. Revalidate the current intended base, independent review SHA, required checks, thread completeness and mergeability immediately before integration. Resolve the declared merge method; default remains squash when no merge policy is declared. `--method` requests a method and cannot override repository policy. Use `pr-merge.sh -n NUMBER --expect-head SHA --json`, within assigned merge authority. Workers return the candidate to the coordinator when the coordinator owns integration. +6. Verify merged SHA and required post-merge CI, then close tracking only when authorized. Merge, deployment and product acceptance are distinct states. Report remaining gates explicitly. + +Auto-merge and queue enabling can merge immediately. They require explicit merge authority, declared method resolution and verified server-side protection against stale approvals. They are off by default. Never use scheduling to bypass final-head review or failed gates. + +Report URL, head/base/merge SHAs, checks, reviewer verdict and reviewed SHA, thread completeness, auto/queue state, blockers, authority mode and watch ownership. A high review score, scheduling success or an open PR is not delivery completion. Retire any owned watch before final handoff. + +Adapted from [t3dotgg/tstack](https://github.com/t3dotgg/tstack/tree/1085050f3baed1ac724caa4a8166145fb2ac88af/skills/full-send) (MIT), revision 1085050f3baed1ac724caa4a8166145fb2ac88af. See [LICENSE](LICENSE). diff --git a/packages/mosaic/framework/skills/ms-full-stop/LICENSE b/packages/mosaic/framework/skills/ms-full-stop/LICENSE new file mode 100644 index 000000000..eccbd9f1c --- /dev/null +++ b/packages/mosaic/framework/skills/ms-full-stop/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Theo Browne + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/mosaic/framework/skills/ms-full-stop/SKILL.md b/packages/mosaic/framework/skills/ms-full-stop/SKILL.md new file mode 100644 index 000000000..09ed49879 --- /dev/null +++ b/packages/mosaic/framework/skills/ms-full-stop/SKILL.md @@ -0,0 +1,18 @@ +--- +name: ms-full-stop +description: Use when the user says full stop or asks for a reviewed PR ready for them to merge. +--- + +# Full stop + +Follow ms-full-send's preparation, implementation, proof, independent review and ms-babysit-pr workflow. These handoff limits override integration. Never merge, enable auto-merge, enqueue a PR, push the integration/default branch, or delegate any of those actions. Only scoped task-branch commits and ordinary pushes are authorized. + +Before editing an existing PR, before every push, and at handoff, inspect `pr-auto-merge.sh -n NUMBER --action inspect --json` and `pr-merge-queue.sh -n NUMBER --action inspect --json`. Establish disabled/absent states from verified readback. An unsupported inspection of a scheduler with raw scheduling support is unknown safety evidence, never proof it is disabled. Unknown, access-denied and unsupported inspection block dependent work identically inside and outside T3. For a new branch without a PR, record that no PR exists and inspect as soon as one is created. + +Disable/dequeue only when explicit authority permits cancellation; re-read afterward. Otherwise report the blocker to the operator/coordinator. Inspection and cancellation never authorize enabling. Direct-commit repositories receive a prepared diff and evidence within the declared handoff boundary. + +At handoff, fetch the intended base and reread `pr-status.sh -n NUMBER --expect-head SHA --json`. Require independent review on that exact SHA, complete required checks and conversations, open/non-draft state and clean mergeability. Report `ready_to_merge`, `waiting`, `blocked`, `closed_unmerged`, `merged` or `timed_out` from measured evidence. `ready_to_merge` means handoff, never permission to merge. A merge performed by someone else is reported as observed, not performed. + +Retire/unwatch before handoff. Include URL or prepared diff, head/base/merge SHAs, checks, reviewer verdict and SHA, thread completeness, auto/queue states, blockers, authority mode and watch ownership. Readiness is not delivery completion. + +Adapted from [t3dotgg/tstack](https://github.com/t3dotgg/tstack/tree/1085050f3baed1ac724caa4a8166145fb2ac88af/skills/full-stop) (MIT), revision 1085050f3baed1ac724caa4a8166145fb2ac88af. See [LICENSE](LICENSE). diff --git a/packages/mosaic/framework/skills/ms-git-operations/SKILL.md b/packages/mosaic/framework/skills/ms-git-operations/SKILL.md new file mode 100644 index 000000000..df41cf800 --- /dev/null +++ b/packages/mosaic/framework/skills/ms-git-operations/SKILL.md @@ -0,0 +1,29 @@ +--- +name: ms-git-operations +description: Use for git-host operations, pull requests, issues, milestones, reviews and CI queue guards. +--- + +# Git operations + +Use installed `tools/git/` wrappers for provider operations. They own API dialects and identity; skills own workflow authority. Never bypass wrappers, hooks, repository protection or exact-head gates. Use the acting identity's own credential without persisting it in shared git config. Missing credentials fail closed. Read wrapper help; do not invent flags. + +New PR commands accept `-n NUMBER --host HOST --repo OWNER/REPO --json`, or infer an unambiguous origin. Review mutations require `--expect-head FULL_SHA`. Python helper calls use import isolation. Preserve legacy wrapper codes unless using the versioned JSON contract. See [wrapper reference](../../tools/git/README.md) for full schemas and permissions. + +| Operation | Wrapper | GitHub | Qualified Gitea/Forgejo | +| ------------------------------ | ----------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------- | +| Capabilities | forge-capabilities.sh | supported | versioned profiles | +| Snapshot / predicate | pr-status.sh / pr-watch-check.sh | supported | partial fields explicit | +| Inline enumeration | pr-review-comments.sh | supported | supported | +| Native reply / resolution | pr-review-reply.sh / pr-review-resolve.sh | supported | Gitea 28.2 supported; older inspected profiles unsupported | +| Merge methods | pr-merge.sh --json | merge, squash, rebase | also rebase-merge and fast-forward-only | +| Auto-merge / queue | pr-auto-merge.sh / pr-merge-queue.sh | conditional on settings and verified protection | unsupported without documented state readback | +| Create / edit / review / close | existing pr wrappers | retained | retained API-compatible dispatch | +| CI guard / wait | ci-queue-wait.sh / pr-ci-wait.sh | retained separate contracts | retained separate contracts | + +Envelope v1 contains schema_version, operation, provider, host, repo, number, head_sha, data and error. Exit codes: 0 verified result, 1 unchanged pending predicate, 2 usage, 3 POLICY-REFUSED or gate failure, 4 unsupported method/operation or forge setting, 5 access/auth, 6 malformed/incomplete/unverified, 75 transient/unknown outcome, 124 timeout. Error contains kind, message, retry_after_seconds and write_outcome. A 401/403/404 is not capability evidence. Never blindly retry an ambiguous mutation. + +Merge policy lives in optional schema-v3 repo.json `merge`, validated through repo-decl.sh and its shared validator. allowed*methods is a non-empty subset of squash, merge, rebase, rebase-merge, fast-forward-only; default_method must belong to it. Optional shapes map task, promotion and absorb to allowed methods. No policy means squash only. An explicit method must be allowed and match any shape mapping. Forge allow*\* settings are intersected afterward; never change them. Absorb requires an explicit shape mapping. Task requests cannot invent integration authority. + +Inspect auto-merge/queue separately from enabling/cancellation. Enable/enqueue can merge immediately and require explicit merge authority plus server-enforced checks, reviews and stale-approval dismissal. Full-stop never enables. Unknown schedule state blocks full-stop even when raw scheduling endpoints exist. + +Use ms-babysit-pr and ms-watch for waiting. The CI queue guard controls push/merge admission, not a provider PR merge queue. Run `ci-queue-wait.sh --purpose push` before ordinary push. Required negative or unknown evidence closes the gate. diff --git a/packages/mosaic/framework/skills/ms-watch/SKILL.md b/packages/mosaic/framework/skills/ms-watch/SKILL.md new file mode 100644 index 000000000..63e2c7126 --- /dev/null +++ b/packages/mosaic/framework/skills/ms-watch/SKILL.md @@ -0,0 +1,16 @@ +--- +name: ms-watch +description: Use before creating a watch or arranging a later check. +--- + +# Watch + +Use an existing owner/notifier first. If one command answers now, run it. Durable recurring work needs a scheduler; this bounded watch is for a wait owned by the current agent. + +For PRs use ms-babysit-pr. Prefer a supported T3 watch. Otherwise verify an operator-provisioned notifier such as `tools/agent-watch/agent-watch.sh` with a unique owner-scoped name, absolute condition paths, interval at least ten seconds and a finite timeout. The notifier is an optional runtime prerequisite, not a bundled guarantee. If it is absent or cannot deliver to this agent, report a watch-delivery blocker. Do not create a polling loop in the agent turn. + +The condition returns 0 for a relevant event/terminal result, 1 for unchanged pending, and any other code for a broken read. Preserve errors and retry metadata. Never mask errors with grep/test, watch only for green, or call a blocking CI wait inside the predicate. + +Use `pr-watch-check.sh -n NUMBER --repo OWNER/REPO --host HOST --baseline ABS_JSON --json`. Initialize the baseline from a successful pr-status envelope in private disk-backed storage. Keep it separate per owner/host/repo/PR. Verify the control case returns 1 before arming. Watch timeout maps to timed_out, provider errors to blocked, and every wake rereads status. A watch notification is not readiness evidence. + +Arm exactly one watch, yield, and retire it on final handoff. No competing child watches. Do not retry ambiguous writes, and never use raw API or credential recipes in predicates. diff --git a/packages/mosaic/framework/tools/git/.npmignore b/packages/mosaic/framework/tools/git/.npmignore new file mode 100644 index 000000000..7a60b85e1 --- /dev/null +++ b/packages/mosaic/framework/tools/git/.npmignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/packages/mosaic/framework/tools/git/README.md b/packages/mosaic/framework/tools/git/README.md index 64de2aab5..00a377eb6 100644 --- a/packages/mosaic/framework/tools/git/README.md +++ b/packages/mosaic/framework/tools/git/README.md @@ -200,3 +200,156 @@ Windows' bundled `bash`/`sh` when configured via `credential.helper`, without a counterpart. A `tea`-login-based per-agent identity for the PowerShell wrappers is a separate, larger design (mapping identities to `tea login` profiles) and is out of scope here. + +## Versioned PR contract + +The provider-neutral entry points below accept `--repo OWNER/REPO --host HOST`, or +infer an unambiguous origin. PR operations require `-n NUMBER`. Use `--host`, not +`-H` (head in older wrappers). `--json` opts existing merge/CI-wait callers into +version 1; new commands always emit one JSON envelope. Diagnostics use stderr. + +| Command | Additional arguments | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| forge-capabilities.sh | `--operation status\|review_comments\|review_reply\|review_resolve\|merge\|rebase_merge\|fast_forward_only\|auto_merge\|merge_queue` | +| pr-status.sh | `--expect-head FULL_SHA` optional; one bounded snapshot | +| pr-watch-check.sh | `--baseline ABSOLUTE_ENVELOPE_JSON`; one snapshot, no internal wait | +| pr-review-comments.sh | Complete paginated reviews and inline comments | +| pr-review-reply.sh | `--comment-id OPAQUE_ID --body-file PATH --expect-head FULL_SHA` | +| pr-review-resolve.sh | `--thread-id OPAQUE_ID --resolve\|--unresolve --expect-head FULL_SHA` | +| pr-merge.sh | `--json --expect-head FULL_SHA [-m METHOD]` | +| pr-auto-merge.sh | `--action inspect\|enable\|disable [--method METHOD]`; mutations require expected head | +| pr-merge-queue.sh | `--action inspect\|enqueue\|dequeue [--method METHOD]`; mutations require expected head | +| pr-ci-wait.sh | `[-t SECONDS] [-i SECONDS] [--expect-head FULL_SHA] [--json]` | + +Methods are `squash`, `merge`, `rebase`, `rebase-merge`, `fast-forward-only`. +GitHub supports only the first three. Never request force-merge or manually-merged. +Normalized merge requires the local origin/declaration context, even with an explicit +host/repository, and preserves queue, branch, independent-review and exact-head gates. +Legacy squash mode retains its existing flags/output/codes. Explicit non-squash methods +use the verified adapter. Message-trailer and branch-deletion flags belong to legacy mode; +the normalized adapter refuses unknown flags rather than silently dropping them. + +The envelope schema is [pr-status.schema.json](pr-status.schema.json). Fields are +`schema_version`, `operation`, `provider`, `host`, `repo`, `number`, `head_sha`, `data` +and nullable `error`. Errors contain `kind`, `message`, nullable +`retry_after_seconds`, and `write_outcome` (`none`, `confirmed`, `unknown`). No blind +write retries. Reconcile an unknown outcome against the exact returned ID/state. + +| Code | Version 1 meaning | +| ---- | ----------------------------------------------------------------------- | +| 0 | Successful read or verified mutation; inspect ready/blockers separately | +| 1 | Watch predicate unchanged and pending only | +| 2 | Usage error | +| 3 | Gate failure or POLICY-REFUSED (`error.kind=policy`) | +| 4 | Unsupported API/method or forge allow\_\* setting disabled | +| 5 | Access/auth, including hidden-resource 404; never capability evidence | +| 6 | Malformed, incomplete, unknown capability or failed verification | +| 75 | Transient/rate limit, moved head or unknown transport outcome | +| 124 | Bounded wait timeout | + +CI wait consumes normalized checks. Default mode maps gate/provider/read failures to +legacy exit 1, usage to 2 and timeout to 124. `--json` retains version-1 error codes. +Missing checks never become green through repeated empty polls. The CI admission guard +and push guard retain their legacy codes; CI admission is separate from a PR merge queue. + +Status includes head/base refs, SHAs and repositories; closure/merge SHA; draft and +mergeability; checks with required/observed/complete; independent current-head reviews; +thread completeness; integration states; capabilities; ready/blockers; observation time +and canonical fingerprint. Head/base/closure are reread after aggregation. Incomplete +reads return nonzero with ready=false. Nullable fields remain unknown, never default false. +Fingerprint excludes observation time and includes review/comment and integration evidence. +The observed snapshot is not an atomic lock: mutations still pin the head at the provider. + +Required branch policy must be readable. Empty check history is not a declared no-CI policy. +App-bound GitHub required checks currently remain unknown rather than trusting a same-name +check from another app. Code-owner review additionally requires the provider decision. +Unsupported branch-pattern policy/readback leaves missing evidence and blocks readiness. + +## Qualified provider profiles and permissions + +GitHub uses documented REST and GraphQL APIs. `GH_HOST` explicitly selects a configured +enterprise host. Other hosts must identify Gitea/Forgejo through public swagger metadata; +unfamiliar hosts are never guessed to be Gitea. Qualified profiles are Gitea 1.24.7 and +28.2.0, and the inspected Forgejo 16.0.0 development schema. Unknown versions expose unknown +capabilities until qualified. Legacy dispatch reports `gitea` for the compatible Forgejo API. + +All profiles list every review and review-comment page. GitHub also paginates nested +reviewThreads/comments. Authoritative IDs remain provider-qualified. Gitea lists do not +invent a thread graph: thread_id/reply_to are null without evidence. For Gitea 28.2 resolution, +pass the listed `gitea:comment:ID` as the resolution selector; this names the API's review +comment, not a fabricated thread relationship. Resolved and outdated comments remain listed. + +| Operation | GitHub | Gitea 28.2 | Gitea 1.24.7 / inspected Forgejo | +| --------------------------------- | ----------- | ---------------------- | -------------------------------- | +| Snapshot / inline enumeration | supported | partial state explicit | partial state explicit | +| Native reply / resolution | supported | supported | exit 4 before mutation | +| Merge/squash/rebase | supported | supported | supported | +| Rebase-merge / fast-forward-only | exit 4 | supported | supported | +| Verified auto-merge / merge queue | conditional | exit 4 | exit 4 | +| Repository policy refuses method | exit 3 | exit 3 | exit 3 | +| Forge setting disables method | exit 4 | exit 4 | exit 4 | + +Public schemas inspected: Gitea swagger.v1.json; GitHub +[REST pulls](https://docs.github.com/en/rest/pulls/pulls#merge-a-pull-request) and +[GraphQL pull operations](https://docs.github.com/en/graphql/reference/pulls). +Gitea/Forgejo expose raw deferred-merge/cancellation endpoints, but the inspected schemas +provide no schedule-state readback. These wrappers therefore inspect/schedule/cancel as +unsupported without making a mutation. Deferred merge is not a native merge queue. + +Reads require PR/review/check and branch-protection visibility. Replies/resolution require +conversation write permission. Merge and integration controls require repository merge +permission and applicable product settings. 401/403 is access failure; documented rate-limit +headers yield 75 and a retry delay. Neither permissions nor feature settings are modified. + +Enable/enqueue require explicit authority, declared method policy, repository allow\_\* settings, +server-enforced checks and independent reviews, stale-approval dismissal and no admin bypass. +Queue configuration must agree with the policy method. Inspect/disable/dequeue are separate +operations and do not require merge policy. Every mutation requires expected head/readback; +scheduling may merge immediately and returns observed merged state rather than queue success. + +## Repository merge policy (schema version 3) + +Schema 1/2 remain readable. Schema 3 adds optional `merge`, with strict key/value validation +through the shared declaration validator and repo-decl helper. Consumers apply the existing +branch gates to both versions 2 and 3. Absent merge policy means allowed=[squash], default=squash. + +```json +{ + "schema_version": 3, + "integration_trunk": "integration", + "release_branch": "stable", + "flow": "trunk-release", + "canonical_remote": "https://git.example.test/team/project", + "canonical_clone": "host:/src/project", + "merge": { + "allowed_methods": ["squash", "merge"], + "default_method": "squash", + "shapes": { "task": "squash", "promotion": "merge", "absorb": "merge" } + } +} +``` + +`allowed_methods` is non-empty and unique; default and shape methods must belong to it. +Unknown keys/shape names fail validation. Promotion is declared trunk→release; absorb is +release→trunk and requires its explicit shape mapping; other allowed contributions are task. +Other targets still fail transition validation. Method requests cannot override a shape mapping. +Invalid declarations never fall back to a wider merge policy. + +## Watch transport parity and release + +T3 linking/watching is optional notification transport. Read normalized status after every wake. +Fallback watching uses pr-watch-check with one owner-scoped private baseline and a supported +notifier, never pr-ci-wait in a predicate. Unknown schedule state blocks full-stop in both paths. +Children return pending evidence instead of owning watches. Retire watches at handoff. + +Run `test-pr-watch-parity.sh`, `test-forge-contract.py`, `test-forge-cli.py`, +`test-merge-policy.py`, `test-pr-skill-contract.py` and all legacy test-\*.sh suites. +Fixtures deny unexpected network calls. Parity tests mock notification transports; they do not +claim live notifier delivery, authenticated provider writes or T3 forge support. + +The package manifest includes framework assets. Release/install the reviewed package before +operator-owned tool/skill synchronization. Verify deployed files by hash against that release; +an upgrade alone does not prove owned copies were synchronized. No local rollout is performed +by these tests or by promoting the skill sources. + +Authenticated Gitea/Forgejo requests require absolute `TMPDIR` pointing to approved scratch storage. Credentials use mode-0600 temporary client config files, removed after each request, and never appear in client arguments. Fallback notification requires an operator-provisioned notifier; its absence is an explicit watch-delivery blocker. This change packages the status/predicate and skills, and does not install a host notifier. diff --git a/packages/mosaic/framework/tools/git/forge-api.py b/packages/mosaic/framework/tools/git/forge-api.py index 4cd1662a6..f9e768a51 100644 --- a/packages/mosaic/framework/tools/git/forge-api.py +++ b/packages/mosaic/framework/tools/git/forge-api.py @@ -267,7 +267,7 @@ def github_conversations(client, number): if not isinstance(pr, dict) or not pr.get('id') or 'autoMergeRequest' not in pr or 'mergeQueueEntry' not in pr: raise Failure(6, 'incomplete', 'Missing integration state') if integration is None: - integration = dict(id=pr['id'], review_decision=pr.get('reviewDecision'), auto_merge={'state':'enabled' if pr['autoMergeRequest'] else 'disabled', 'method':(pr['autoMergeRequest'] or {}).get('mergeMethod')}, queue={'state':'queued' if pr['mergeQueueEntry'] else 'absent', 'entry_id':(pr['mergeQueueEntry'] or {}).get('id')}) + integration = dict(id=pr['id'], review_decision=pr.get('reviewDecision'), auto_merge={'state':'enabled' if pr['autoMergeRequest'] else 'disabled', 'method':((pr['autoMergeRequest'] or {}).get('mergeMethod') or '').lower() or None}, queue={'state':'queued' if pr['mergeQueueEntry'] else 'absent', 'entry_id':(pr['mergeQueueEntry'] or {}).get('id')}) connection = pr['reviewThreads'] for thread in connection['nodes']: comments = thread['comments'] @@ -381,6 +381,8 @@ def snapshot(client, number, expected): unresolved = sum(t['isResolved'] is False for t in threads) if client.provider == 'github' else sum(c['resolved'] is False for c in comments['comments']) if all(c['resolved'] is not None for c in comments['comments']) else None thread_state = dict(unresolved=unresolved, complete=comments['complete'] and unresolved is not None) state = 'merged' if p.get('merged') is True else p['state'] + if state not in ('open','closed','merged'): + raise Failure(6,'malformed','Provider PR state is unknown') mergeability = 'conflict' if p.get('mergeable') is False else 'clean' if p.get('mergeable') is True else 'unknown' blockers = [] if not re.fullmatch('[0-9a-fA-F]{40}',base.get('sha','')): @@ -578,17 +580,15 @@ def integration(client, number, operation, action, requested, expected): require(client,operation) if action not in (('inspect','enable','disable') if operation == 'auto_merge' else ('inspect','enqueue','dequeue')): raise Failure(2,'usage','Invalid action for integration operation') - p = client.pr(number) - if action != 'inspect': - if not expected: - raise Failure(2,'usage','Mutation requires --expect-head') - p = same_head(client,number,expected) + if action != 'inspect' and not expected: + raise Failure(2,'usage','Mutation requires --expect-head') + p = same_head(client,number,expected) if expected else client.pr(number) _, state = github_conversations(client,number) key = 'auto_merge' if operation == 'auto_merge' else 'queue' before = state[key] if action == 'inspect': same_head(client,number,p['head']['sha']) - return dict(supported=True,state=before['state'],method=before.get('method'),entry_id=before.get('entry_id'),expected_head=expected,verified=True) + return dict(supported=True,state=before['state'],method=before.get('method'),entry_id=before.get('entry_id'),expected_head=expected,head_sha=p['head']['sha'],verified=True) enabling = action in ('enable','enqueue') method = before.get('method') if enabling: @@ -653,7 +653,7 @@ def workflow_verdict(data, mode): return 'blocked' if data.get('ready') is True: return 'ready_to_merge' - if data.get('mergeability') in ('conflict','unknown') or any(b.startswith('review_threads') for b in data.get('blockers',[])): + if data.get('mergeability') in ('conflict','unknown') or any(b.startswith('review_threads') or 'unknown' in b for b in data.get('blockers',[])): return 'blocked' return 'waiting' @@ -750,7 +750,7 @@ def main(): data = merge(client,args.number,args.method,args.expect_head) else: data = integration(client,args.number,'auto_merge' if args.operation == 'auto-merge' else 'merge_queue',args.action,args.method,args.expect_head) - head = args.expect_head + head = data.get('head_sha') or args.expect_head elif args.operation == 'review-comments': before = client.pr(args.number)['head']['sha'] data, _, _ = review_comments(client, args.number) diff --git a/packages/mosaic/framework/tools/git/test-forge-cli.py b/packages/mosaic/framework/tools/git/test-forge-cli.py index 6b460ce00..4fd131873 100644 --- a/packages/mosaic/framework/tools/git/test-forge-cli.py +++ b/packages/mosaic/framework/tools/git/test-forge-cli.py @@ -49,7 +49,10 @@ class CLI(unittest.TestCase): for client in ['curl','gh']: p=self.root/client; p.write_text(FAKE); p.chmod(0o755) self.env=dict(os.environ,HOME=str(self.root/'home'),TMPDIR=str(self.root),TMP=str(self.root),TEMP=str(self.root),PATH=str(self.root)+os.pathsep+os.environ['PATH'],GIT_CONFIG_GLOBAL='/dev/null',MOSAIC_GIT_IDENTITY='',GITEA_TOKEN='fixture-token',GITEA_URL='https://git.example.test',FAKE_RESPONSES=str(self.root/'responses.json'),FAKE_LOG=str(self.root/'calls.jsonl')) - self.env.pop('BASH_ENV',None) + for key in ['BASH_ENV','GH_TOKEN','GITHUB_TOKEN','GH_ENTERPRISE_TOKEN','GH_HOST','GITEA_LOGIN','GIT_DIR','GIT_WORK_TREE']: + self.env.pop(key,None) + self.env.update(MOSAIC_GIT_IDENTITY='fixture-user',MOSAIC_BRAIN_HOME=str(self.root/'home/.mosaic'),XDG_CONFIG_HOME=str(self.root/'home/.config'),MOSAIC_CREDENTIALS_FILE=str(self.root/'fixture-credentials.json')) + (self.root/'fixture-credentials.json').write_text('{}') self.fixtures={} def tearDown(self): self.tmp.cleanup() def run_wrapper(self,name,*args,host='github.com'): diff --git a/packages/mosaic/framework/tools/git/test-forge-contract.py b/packages/mosaic/framework/tools/git/test-forge-contract.py index 99cc89b77..f2da702e1 100644 --- a/packages/mosaic/framework/tools/git/test-forge-contract.py +++ b/packages/mosaic/framework/tools/git/test-forge-contract.py @@ -221,6 +221,14 @@ class IntegrationContract(SnapshotContract): self.api.integration(c,1,op,'inspect',None,None) self.assertEqual(ctx.exception.code,4) self.assertEqual(calls,[]) + def test_integration_inspect_honors_optional_head_pin(self): + c=self.fake() + with self.assertRaises(self.api.Failure) as ctx: + self.api.integration(c,1,'auto_merge','inspect',None,'b'*40) + self.assertEqual(ctx.exception.code,75) + result=self.api.integration(c,1,'auto_merge','inspect',None,'a'*40) + self.assertEqual(result['head_sha'],'a'*40) + def test_forge_disallowed_method_is_unsupported(self): c = self.fake() c.http = lambda *args, **kwargs: {'allow_squash_merge':False} -- 2.54.0 From d939601116c950dd9691392cc001b16feb869ded Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 15:21:39 -0600 Subject: [PATCH 06/26] fix(framework): bind forge policy and credentials to trusted identities (#1841) Resolve merge authority from the pinned target commit; reject full PR identity races and malformed conversation state. Require explicit host-bound actors, verify mutation identity and deliver auth through anonymous descriptors. Migrate legacy fixture credentials to explicit actors. Co-Authored-By: GPT-6.1-Sol (Codex) --- .../skills/ms-git-operations/SKILL.md | 4 + packages/mosaic/framework/tools/git/README.md | 22 ++ .../framework/tools/git/ci-queue-wait.sh | 6 + .../framework/tools/git/detect-platform.sh | 134 +------- .../tools/git/fixture-forge-credential.py | 30 ++ .../mosaic/framework/tools/git/forge-api.py | 167 ++++++---- .../git/test-ci-queue-wait-branch-absent.sh | 3 + .../git/test-ci-queue-wait-no-ci-expected.sh | 8 +- .../tools/git/test-ci-queue-wait-no-status.sh | 3 + .../tools/git/test-ci-queue-wait-tristate.sh | 3 + .../framework/tools/git/test-forge-auth.py | 81 +++++ .../framework/tools/git/test-forge-cli.py | 34 +- .../tools/git/test-forge-contract.py | 67 +++- .../tools/git/test-gitea-login-resolution.sh | 8 +- .../tools/git/test-gitea-token-identity.sh | 315 +----------------- .../tools/git/test-grant-reviewer.sh | 5 +- .../tools/git/test-issue-comment-readback.sh | 10 +- .../git/test-issue-create-interactive-auth.sh | 6 + .../tools/git/test-issue-view-comments.sh | 10 +- .../framework/tools/git/test-merge-policy.py | 40 +++ .../test-pr-create-fallback-default-base.sh | 12 +- .../git/test-pr-merge-gitea-empty-uid.sh | 8 + .../tools/git/test-pr-merge-head-pin.sh | 20 +- .../tools/git/test-pr-review-gitea-comment.sh | 6 +- .../git/test-pr-review-repo-host-override.sh | 2 + .../tools/git/test-repo-decl-consumption.sh | 3 +- 26 files changed, 497 insertions(+), 510 deletions(-) create mode 100644 packages/mosaic/framework/tools/git/fixture-forge-credential.py create mode 100644 packages/mosaic/framework/tools/git/test-forge-auth.py diff --git a/packages/mosaic/framework/skills/ms-git-operations/SKILL.md b/packages/mosaic/framework/skills/ms-git-operations/SKILL.md index df41cf800..a9008a1f0 100644 --- a/packages/mosaic/framework/skills/ms-git-operations/SKILL.md +++ b/packages/mosaic/framework/skills/ms-git-operations/SKILL.md @@ -7,6 +7,8 @@ description: Use for git-host operations, pull requests, issues, milestones, rev Use installed `tools/git/` wrappers for provider operations. They own API dialects and identity; skills own workflow authority. Never bypass wrappers, hooks, repository protection or exact-head gates. Use the acting identity's own credential without persisting it in shared git config. Missing credentials fail closed. Read wrapper help; do not invent flags. +Authenticated normalized adapters require explicit `MOSAIC_GIT_IDENTITY` and a host-bound token for every provider. Missing identity refuses before requests; mutations verify the authenticated actor. Shared token variables, git config identity and inherited provider logins grant no authority. See the wrapper reference for credential provisioning. + New PR commands accept `-n NUMBER --host HOST --repo OWNER/REPO --json`, or infer an unambiguous origin. Review mutations require `--expect-head FULL_SHA`. Python helper calls use import isolation. Preserve legacy wrapper codes unless using the versioned JSON contract. See [wrapper reference](../../tools/git/README.md) for full schemas and permissions. | Operation | Wrapper | GitHub | Qualified Gitea/Forgejo | @@ -22,6 +24,8 @@ New PR commands accept `-n NUMBER --host HOST --repo OWNER/REPO --json`, or infe Envelope v1 contains schema_version, operation, provider, host, repo, number, head_sha, data and error. Exit codes: 0 verified result, 1 unchanged pending predicate, 2 usage, 3 POLICY-REFUSED or gate failure, 4 unsupported method/operation or forge setting, 5 access/auth, 6 malformed/incomplete/unverified, 75 transient/unknown outcome, 124 timeout. Error contains kind, message, retry_after_seconds and write_outcome. A 401/403/404 is not capability evidence. Never blindly retry an ambiguous mutation. +Merge policy is read from the trusted target/base commit pinned by the provider, never from the PR head or uncommitted checkout. Fetch that revision first; missing objects refuse. Head and base ref, SHA and repository must remain unchanged through policy, readiness and the final mutation check. + Merge policy lives in optional schema-v3 repo.json `merge`, validated through repo-decl.sh and its shared validator. allowed*methods is a non-empty subset of squash, merge, rebase, rebase-merge, fast-forward-only; default_method must belong to it. Optional shapes map task, promotion and absorb to allowed methods. No policy means squash only. An explicit method must be allowed and match any shape mapping. Forge allow*\* settings are intersected afterward; never change them. Absorb requires an explicit shape mapping. Task requests cannot invent integration authority. Inspect auto-merge/queue separately from enabling/cancellation. Enable/enqueue can merge immediately and require explicit merge authority plus server-enforced checks, reviews and stale-approval dismissal. Full-stop never enables. Unknown schedule state blocks full-stop even when raw scheduling endpoints exist. diff --git a/packages/mosaic/framework/tools/git/README.md b/packages/mosaic/framework/tools/git/README.md index 00a377eb6..18073f75a 100644 --- a/packages/mosaic/framework/tools/git/README.md +++ b/packages/mosaic/framework/tools/git/README.md @@ -353,3 +353,25 @@ an upgrade alone does not prove owned copies were synchronized. No local rollout by these tests or by promoting the skill sources. Authenticated Gitea/Forgejo requests require absolute `TMPDIR` pointing to approved scratch storage. Credentials use mode-0600 temporary client config files, removed after each request, and never appear in client arguments. Fallback notification requires an operator-provisioned notifier; its absence is an explicit watch-delivery blocker. This change packages the status/predicate and skills, and does not install a host notifier. + +### Trusted policy and authentication + +The normalized merge and scheduling adapters read `.mosaic/repo.json` from the +PR target's pinned base commit, using the matching local origin's object store. +Fetch the target revision before invoking a mutation. Missing objects refuse; +head commits and uncommitted declarations cannot grant merge authority. Full +head and base identities (ref, SHA, repository) must remain unchanged through +policy evaluation, readiness aggregation, admission, mutation and readback. + +Authenticated adapter reads and writes require explicit `MOSAIC_GIT_IDENTITY`. +A mutation first verifies `/user` matches that identity. Inherited client login +sessions and shared token environment variables grant no authority. Existing +provider-specific seat/service token stores remain supported. Other hosts, +including GitHub, use `/credentials/forge//.token`, where +`MOSAIC_BRAIN_HOME` selects `` (default `~/.mosaic`). Provision the +selected actor's token separately. Authorization reaches curl through an +anonymous pipe; request JSON uses stdin. Tokens never enter named scratch files. + +The skill firewall accepts optional newline-separated forbidden patterns via +`MOSAIC_OPERATOR_FORBIDDEN_PATTERNS`; its default is empty. Deployment-specific +patterns belong in operator configuration, outside the framework source. diff --git a/packages/mosaic/framework/tools/git/ci-queue-wait.sh b/packages/mosaic/framework/tools/git/ci-queue-wait.sh index 961c4e625..93ff4aae9 100755 --- a/packages/mosaic/framework/tools/git/ci-queue-wait.sh +++ b/packages/mosaic/framework/tools/git/ci-queue-wait.sh @@ -473,6 +473,12 @@ if [[ "$NO_CI_EXPECTED" -eq 1 && "$REQUIRE_STATUS" -eq 1 ]]; then exit 1 fi +if [[ "$NO_CI_EXPECTED" -eq 1 && -z "${MOSAIC_GIT_IDENTITY:-}" ]]; then + record_assertion_event 'ASSERTION_UNATTRIBUTABLE' 'actor-unattributable' 'unknown' || echo 'Warning: attribution audit unavailable.' >&2 + echo "Error: ASSERTION_UNATTRIBUTABLE state=unknown purpose=${PURPOSE} asserted-by=unknown reason=no-ci-expected branch=${BRANCH}; explicit MOSAIC_GIT_IDENTITY is required (exit 78)." >&2 + exit 78 +fi + OWNER="unknown" REPO="unknown" PLATFORM="unknown" diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 62e542f02..b7ee7784b 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -656,137 +656,37 @@ get_gitea_url_for_host() { } # Resolve a Gitea API token for the given host. -# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials -get_gitea_token() { - local host="$1" - local script_dir - script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - local cred_loader="$script_dir/../_lib/credentials.sh" - - # 0. Per-agent identity (Gate-16 author≠reviewer). If MOSAIC_GIT_IDENTITY, or the - # per-worktree `git config mosaic.gitIdentity`, resolves to an agent that has a - # stored credential for this host, act AS that agent so API tooling - # (pr-create, issue-create, …) authors under the right identity — matching the - # git credential helper, which this block deliberately mirrors. - local _ident="${MOSAIC_GIT_IDENTITY:-}" - local _ident_src="MOSAIC_GIT_IDENTITY" - if [[ -z "$_ident" ]]; then - _ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)" - _ident_src="git config mosaic.gitIdentity" +# Explicit identity and host-bound storage only. Disable tracing before handling +# credentials; the subshell preserves the caller's xtrace state. +get_gitea_token() ( + set +x + local host="$1" _ident="${MOSAIC_GIT_IDENTITY:-}" + if [[ ! "$_ident" =~ ^[A-Za-z0-9_.-]+$ || ! "$host" =~ ^[A-Za-z0-9.-]+(:[0-9]+)?$ ]]; then + echo 'Error: explicit MOSAIC_GIT_IDENTITY and a valid host are required.' >&2 + return 1 fi - - # Recognized Gitea hosts are the ones carrying the per-identity token scheme. local _idpfx="" case "$host" in git.uscllc.com) _idpfx=gitea-usc ;; git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;; esac - # Brain-home resolution mirrors packages/mosaic/src/fleet/brain-home.ts and - # tools/fleet/start-agent-session.sh: MOSAIC_BRAIN_HOME wins, else ~/.mosaic. - local _brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}" - - if [[ -n "$_ident" && -n "$_idpfx" ]]; then - # Credential store selection: an identity is a SEAT or a SERVICE, and which - # one it is decides where its credential lives. No precedence between the - # two stores and no fallback from one to the other — a seat with an empty - # slot fails loud rather than reading a service credential of the same name. - # One credential, one location: two copies diverge, and the stale copy fails - # in a way that reads as a revoked token rather than as drift. - local _idtok _ident_kind + local _brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}" _idtok + if [[ -n "$_idpfx" ]]; then if [[ -d "$_brain_home/fleet/agents/$_ident" ]]; then - _ident_kind="seat" _idtok="$_brain_home/fleet/agents/$_ident/secrets/${_idpfx}-${_ident}.token" else - _ident_kind="service identity" _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token" fi - if [[ -r "$_idtok" ]]; then - cat "$_idtok" - return 0 - fi - # FAIL LOUD: an explicit git identity was requested for a recognized Gitea host, - # but no credential exists for THAT identity. Refuse to fall through to the - # shared/default credential loader below — silently borrowing another identity's - # token would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review - # attributed to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop - # instead so the caller aborts loudly rather than acting as the wrong identity. - echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2 - echo " Resolved as a ${_ident_kind}; there is no fallback between the seat and service stores." >&2 - echo " Refusing to borrow another slot's token. Provision the credential at that path, or unset the identity to use shared credentials." >&2 + else + _idtok="$_brain_home/credentials/forge/$host/$_ident.token" + fi + if [[ ! -r "$_idtok" ]]; then + echo 'Error: selected identity has no readable host-bound credential.' >&2 return 1 fi - - # FAIL LOUD: no identity resolved, on a host that HAS a fleet. Where seats exist, - # every record must name the agent that made it, so an unattributable request is - # refused rather than handed the shared account. `fleet/agents` existing is the - # same signal brain-home.ts uses to decide a brain is active. A host with no fleet - # keeps the shared path below unchanged: there the shared account is the operator's - # own and there is no attribution to lose. - if [[ -z "$_ident" && -n "$_idpfx" && -d "$_brain_home/fleet/agents" ]]; then - echo "Error: no git identity resolved for host '$host', but this host runs a fleet ($_brain_home/fleet/agents)." >&2 - echo " Refusing to fall back to the shared account: records it creates cannot be attributed to the agent that made them." >&2 - echo " Set MOSAIC_GIT_IDENTITY= or 'git config mosaic.gitIdentity '." >&2 - return 1 - fi - - # 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env) - if [[ -f "$cred_loader" ]]; then - local token - token=$( - # shellcheck source=/dev/null - source "$cred_loader" - # Host-specific wrapper resolution must not inherit caller/global GITEA_*. - # load_credentials intentionally preserves existing env vars for interactive use, - # but metadata/merge wrappers need credentials matching the remote host. - unset GITEA_TOKEN GITEA_URL - case "$host" in - git.mosaicstack.dev) load_credentials gitea-mosaicstack 2>/dev/null ;; - git.uscllc.com) load_credentials gitea-usc 2>/dev/null ;; - *) - local matched=false - for svc in gitea-mosaicstack gitea-usc; do - unset GITEA_TOKEN GITEA_URL - load_credentials "$svc" 2>/dev/null || continue - if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then - matched=true - break - fi - done - if [[ "$matched" != true ]]; then - unset GITEA_TOKEN GITEA_URL - fi - ;; - esac - echo "${GITEA_TOKEN:-}" - ) - if [[ -n "$token" ]]; then - echo "$token" - return 0 - fi - fi - - # 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host) - if [[ -n "${GITEA_TOKEN:-}" ]]; then - if [[ -z "${GITEA_URL:-}" ]] || gitea_url_matches_host "$GITEA_URL" "$host"; then - echo "$GITEA_TOKEN" - return 0 - fi - fi - - # 3. ~/.git-credentials file - local creds="$HOME/.git-credentials" - if [[ -f "$creds" ]]; then - local token - token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1) - if [[ -n "$token" ]]; then - echo "$token" - return 0 - fi - fi - - return 1 -} + cat "$_idtok" +) # Stage the Gitea bearer credential for curl OUTSIDE the process argument vector. # Passing "-H 'Authorization: token '" on the curl command line exposes diff --git a/packages/mosaic/framework/tools/git/fixture-forge-credential.py b/packages/mosaic/framework/tools/git/fixture-forge-credential.py new file mode 100644 index 000000000..cc11be601 --- /dev/null +++ b/packages/mosaic/framework/tools/git/fixture-forge-credential.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Provision synthetic, actor/host-bound credentials for offline wrapper suites.""" +import json +import re +import pathlib +import sys + +home, brain, actor, host, token = sys.argv[1:] +root = pathlib.Path(brain) +p = root / 'credentials/forge' / host / (actor + '.token') +p.parent.mkdir(parents=True, exist_ok=True) +p.write_text(token) +# Existing provider-specific stores derive their prefixes from configured +# service keys. The fixture never imports an operator's credential loader. +credentials = pathlib.Path(home) / '.config/mosaic/credentials.json' +if credentials.is_file(): + for service, value in json.loads(credentials.read_text()).get('gitea', {}).items(): + if value.get('url', '').removeprefix('https://').rstrip('/') == host: + p = pathlib.Path(home) / '.config/mosaic/secrets/gitea-tokens' / ('gitea-' + service + '-' + actor + '.token') + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(token) + +# Provider store layout is an inherited compatibility contract. Reuse its +# declared mapping without embedding deployment names in new fixture code. +source=(pathlib.Path(__file__).parent/'detect-platform.sh').read_text() +for mapped_host, prefix in re.findall(r'^\s*([A-Za-z0-9.-]+)\)\s+_idpfx=([A-Za-z0-9-]+)',source,re.MULTILINE): + if mapped_host == host: + p=pathlib.Path(home)/'.config/mosaic/secrets/gitea-tokens'/(prefix+'-'+actor+'.token') + p.parent.mkdir(parents=True,exist_ok=True) + p.write_text(token) diff --git a/packages/mosaic/framework/tools/git/forge-api.py b/packages/mosaic/framework/tools/git/forge-api.py index f9e768a51..946e069e5 100644 --- a/packages/mosaic/framework/tools/git/forge-api.py +++ b/packages/mosaic/framework/tools/git/forge-api.py @@ -90,9 +90,9 @@ def reviews(items, head, author, required_count=1): return dict(decision='changes_requested' if rejected else 'approved' if approved else 'missing', reviewed_sha=head if approved else None, complete=True, approved=approved) -def run(argv, data=None, cwd=None, env=None): +def run(argv, data=None, cwd=None, env=None, pass_fds=()): try: - return subprocess.run(argv, input=data, text=True, capture_output=True, timeout=45, cwd=cwd, env=env) + return subprocess.run(argv, input=data, text=True, capture_output=True, timeout=45, cwd=cwd, env=env, pass_fds=pass_fds) except (OSError, subprocess.TimeoutExpired): raise Failure(75, 'transient', 'Provider client unavailable or timed out') @@ -102,6 +102,7 @@ class Client: self.host, self.repo = host, repo self.provider, self.version, self.token = 'unknown', '', None self.write_attempted = False + self.actor_verified = False self.root = '/repos/' + repo def detect(self): @@ -131,34 +132,43 @@ class Client: def http(self, path, method='GET', body=None, public=False): writing = method != 'GET' and not (path == 'graphql' and body and body.get('query', '').lstrip().startswith('query')) - if self.provider == 'github' and not public: - args = ['gh', 'api', '--hostname', self.host, '--include', '--method', method, path] - if body is not None: - args += ['--input', '-'] - p = self.transport(args, body, writing) + if not public: + actor = os.environ.get('MOSAIC_GIT_IDENTITY', '') + if not re.fullmatch(r'[A-Za-z0-9_.-]+', actor): + raise Failure(5, 'access', 'Explicit MOSAIC_GIT_IDENTITY is required') + token = self.credential() + if writing and not self.actor_verified: + user = self.http('/user') + if not isinstance(user, dict) or user.get('login') != actor: + raise Failure(5, 'access', 'Authenticated actor differs from explicit identity') + self.actor_verified = True + prefix = '/api/v3' if self.provider == 'github' and self.host != 'github.com' else '' if self.provider == 'github' else '/api/v1' + route = ('/graphql' if self.host == 'github.com' else '/api/graphql') if path == 'graphql' else prefix + path + url = 'https://' + self.host + (path if public else route) + args = ['curl', '--silent', '--show-error', '--include', '--max-time', '30', '--request', method, '--header', 'Accept: application/json', '--header', 'User-Agent: mosaic-forge-wrapper'] + if body is not None: + args += ['--header', 'Content-Type: application/json', '--data-binary', '@-'] + args += ['--url', url] + if not public: + if len(token) > 4096 or any(ord(c) < 32 or ord(c) == 127 for c in token): + raise Failure(5, 'access', 'Credential contains invalid control characters') + escaped = token.replace('\\', '\\\\').replace('"', '\\"') + # Anonymous pipe: no credential pathname survives termination. The body + # retains stdin, while curl reads authentication from the inherited fd. + read_fd, write_fd = os.pipe() + try: + os.write(write_fd, ('header = "Authorization: token ' + escaped + '"\n').encode()) + os.close(write_fd) + write_fd = None + self.auth_fd = read_fd + p = self.transport(args + ['--config', '/dev/fd/' + str(read_fd)], body, writing) + finally: + self.auth_fd = None + os.close(read_fd) + if write_fd is not None: + os.close(write_fd) else: - url = 'https://' + self.host + (path if public else '/api/v1' + path) - args = ['curl', '--silent', '--show-error', '--include', '--max-time', '30', '--request', method, '--header', 'Accept: application/json', '--header', 'User-Agent: mosaic-forge-wrapper'] - if body is not None: - args += ['--header', 'Content-Type: application/json', '--data-binary', '@-'] - args += ['--url', url] - if not public: - scratch = os.environ.get('TMPDIR') - if not scratch or not os.path.isabs(scratch): - raise Failure(2,'usage','Set TMPDIR to approved absolute scratch storage before authenticated requests') - token = self.credential() - if any(ord(c) < 32 or ord(c) == 127 for c in token): - raise Failure(5,'access','Credential contains invalid control characters') - escaped = token.replace('\\', '\\\\').replace('"', '\\"') - try: - with tempfile.NamedTemporaryFile(mode='w',prefix='mosaic-forge-auth-',dir=scratch) as auth: - auth.write('header = "Authorization: token ' + escaped + '"\n') - auth.flush() - p = self.transport(args + ['--config',auth.name],body,writing) - except OSError: - raise Failure(6,'verification','Cannot create protected credential config in configured scratch storage') - else: - p = self.transport(args, body, writing) + p = self.transport(args, body, writing) if p.returncode and (self.provider != 'github' or not p.stdout.startswith('HTTP/')): raise Failure(75, 'transient', 'Provider transport failed; reconcile before retrying a mutation', outcome='unknown' if writing else 'none') raw = p.stdout.replace('\r\n', '\n') @@ -200,7 +210,7 @@ class Client: if writing: self.write_attempted = True try: - return run(args, json.dumps(body) if body is not None else None) + return run(args, json.dumps(body) if body is not None else None, pass_fds=(self.auth_fd,) if getattr(self, 'auth_fd', None) is not None else ()) except Failure as e: if writing: e.outcome = 'unknown' @@ -246,6 +256,18 @@ def require(client, operation): raise Failure(4 if cap['support'] == 'unsupported' else 6, 'unsupported' if cap['support'] == 'unsupported' else 'incomplete', cap['reason']) +def base_identity(p): + b = p['base'] + h = p['head'] + return (b.get('ref'), b.get('sha'), (b.get('repo') or {}).get('full_name'), h.get('ref'), h.get('sha'), (h.get('repo') or {}).get('full_name')) + + +def same_base(p, original): + if base_identity(p) != base_identity(original): + raise Failure(75, 'base_moved', 'PR target changed; discard policy and readiness') + return p + + def same_head(client, number, expected): p = client.pr(number) if p['head']['sha'] != expected: @@ -266,10 +288,17 @@ def github_conversations(client, number): pr = client.gql(q, dict(owner=owner, repo=repo, number=number, cursor=cursor))['repository']['pullRequest'] if not isinstance(pr, dict) or not pr.get('id') or 'autoMergeRequest' not in pr or 'mergeQueueEntry' not in pr: raise Failure(6, 'incomplete', 'Missing integration state') + auto, queue = pr['autoMergeRequest'], pr['mergeQueueEntry'] + if auto is not None and (not isinstance(auto, dict) or auto.get('mergeMethod') not in ('SQUASH', 'MERGE', 'REBASE')): + raise Failure(6, 'malformed', 'Unknown auto-merge state') + if queue is not None and (not isinstance(queue, dict) or not isinstance(queue.get('id'), str) or not queue['id']): + raise Failure(6, 'malformed', 'Unknown merge-queue state') if integration is None: integration = dict(id=pr['id'], review_decision=pr.get('reviewDecision'), auto_merge={'state':'enabled' if pr['autoMergeRequest'] else 'disabled', 'method':((pr['autoMergeRequest'] or {}).get('mergeMethod') or '').lower() or None}, queue={'state':'queued' if pr['mergeQueueEntry'] else 'absent', 'entry_id':(pr['mergeQueueEntry'] or {}).get('id')}) connection = pr['reviewThreads'] for thread in connection['nodes']: + if type(thread.get('isResolved')) is not bool: + raise Failure(6, 'malformed', 'Review thread resolution must be boolean') comments = thread['comments'] nodes = list(comments['nodes']) nested_seen = set() @@ -403,7 +432,8 @@ def snapshot(client, number, expected): blockers.append('mergeability_' + mergeability) data = dict(url=p.get('html_url'), state=state, draft=p.get('draft'), head_ref=p['head'].get('ref'), head_sha=head, base_ref=base.get('ref'), base_sha=base.get('sha'), head_repository=(p['head'].get('repo') or {}).get('full_name'), base_repository=(base.get('repo') or {}).get('full_name'), merge_sha=p.get('merge_commit_sha') if state == 'merged' else None, mergeability=mergeability, checks=check, reviews=review, threads=thread_state, auto_merge=integration['auto_merge'] if integration else {'state':'unsupported'}, queue=integration['queue'] if integration else {'state':'unsupported'}, capabilities=capabilities(client.provider, client.version), ready=not blockers, blockers=blockers, comments=comments['comments'], review_records=comments['reviews'], issue_comments=issue_comments, protection_enforced=enforced, observed_at=datetime.datetime.now(datetime.timezone.utc).isoformat()) after = same_head(client, number, head) - if after['base'].get('sha') != base.get('sha') or after.get('state') != p.get('state') or after.get('merged') != p.get('merged'): + same_base(after, p) + if after.get('state') != p.get('state') or after.get('merged') != p.get('merged'): raise Failure(75, 'snapshot_changed', 'PR base or closure changed during aggregation') data['fingerprint'] = fingerprint(data) return data @@ -508,34 +538,49 @@ def declared_method(client, p, requested): root = run(['git','rev-parse','--show-toplevel']) if root.returncode: raise Failure(3,'policy','POLICY-REFUSED: mutation requires a local repository declaration context') - declaration = pathlib.Path(root.stdout.strip())/'.mosaic/repo.json' origin = run(['git','remote','get-url','origin']).stdout.strip() parsed = urllib.parse.urlparse(origin if not origin.startswith('git@') else 'ssh://' + origin.replace(':','/',1)) if parsed.hostname != client.host.split(':')[0] or parsed.path.strip('/').removesuffix('.git') != client.repo: raise Failure(3,'policy','POLICY-REFUSED: explicit target differs from local origin') - if not declaration.exists(): + base = p['base'] + if (base.get('repo') or {}).get('full_name') != client.repo or not re.fullmatch('[0-9a-fA-F]{40}', base.get('sha', '')): + raise Failure(3, 'policy', 'POLICY-REFUSED: trusted target revision unavailable') + revision = base['sha'] + if run(['git', 'cat-file', '-e', revision + '^{commit}']).returncode: + raise Failure(3, 'policy', 'POLICY-REFUSED: fetch the pinned target revision before merging') + listing = run(['git', 'ls-tree', revision, '--', '.mosaic/repo.json']) + if listing.returncode: + raise Failure(6, 'verification', 'Cannot inspect pinned target declaration') + if not listing.stdout.strip(): if requested not in (None, 'squash'): - raise Failure(3,'policy','POLICY-REFUSED: absent merge policy permits squash only') - # Preserve legacy base behavior, requiring the provider default branch. - default = client.http(client.root).get('default_branch') - if p['base']['ref'] != default: - raise Failure(3,'policy','POLICY-REFUSED: absent declaration requires the repository default base') + raise Failure(3, 'policy', 'POLICY-REFUSED: absent merge policy permits squash only') + if base['ref'] != client.http(client.root).get('default_branch'): + raise Failure(3, 'policy', 'POLICY-REFUSED: absent declaration requires the repository default base') return 'squash' - validator = run([str(HERE.parent/'structure/validate-repo-json.sh'),str(declaration),'--mode','display']) - if validator.returncode: - raise Failure(6,'malformed','Invalid repository declaration: ' + validator.stdout.strip()) - # Validate canonical authority through the existing shared helper as well. - gate = run(['bash','-c','source "$1/repo-decl.sh"; repo_decl_load; [[ "$DECL_STATE" == valid ]] && repo_decl_remote_matches','forge-policy',str(HERE)]) - if gate.returncode: - raise Failure(3,'policy','POLICY-REFUSED: declaration canonical remote differs from origin') - args = ['python3','-I',str(HERE/'merge-policy.py'),str(declaration),'--head',p['head']['ref'],'--base',p['base']['ref']] - if requested: - args += ['--method',requested] - result = run(args) - value = json.loads(result.stdout) - if result.returncode: - raise Failure(result.returncode,'policy' if result.returncode == 3 else 'malformed',value.get('error','Merge policy refused')) - return value['method'] + content = run(['git', 'show', revision + ':.mosaic/repo.json']) + if content.returncode: + raise Failure(6, 'verification', 'Cannot read pinned target declaration') + # This temporary file contains public policy, never authentication material. + with tempfile.TemporaryDirectory() as directory: + declaration = pathlib.Path(directory) / 'repo.json' + declaration.write_text(content.stdout) + validator = run([str(HERE.parent/'structure/validate-repo-json.sh'), str(declaration), '--mode', 'display']) + if validator.returncode: + raise Failure(6, 'malformed', 'Invalid target repository declaration: ' + validator.stdout.strip()) + doc = json.loads(content.stdout) + canonical = doc.get('canonical_remote', '').removesuffix('.git').rstrip('/') + normal = run([str(HERE.parent/'structure/validate-repo-json.sh'), '--normalize-remote', canonical]) + origin_normal = run([str(HERE.parent/'structure/validate-repo-json.sh'), '--normalize-remote', origin]) + if normal.returncode or origin_normal.returncode or normal.stdout != origin_normal.stdout: + raise Failure(3, 'policy', 'POLICY-REFUSED: target declaration canonical remote differs from origin') + args = ['python3', '-I', str(HERE/'merge-policy.py'), str(declaration), '--head', p['head']['ref'], '--base', base['ref']] + if requested: + args += ['--method', requested] + result = run(args) + value = json.loads(result.stdout) + if result.returncode: + raise Failure(result.returncode, 'policy' if result.returncode == 3 else 'malformed', value.get('error', 'Merge policy refused')) + return value['method'] def queue_guard(client, p): @@ -557,17 +602,19 @@ def merge(client, number, requested, expected): method = declared_method(client,p,requested) forge_method(client,method) status = snapshot(client,number,expected) + if (status['base_ref'], status['base_sha'], status['base_repository'], status['head_ref'], status['head_sha'], status['head_repository']) != base_identity(p): + raise Failure(75, 'base_moved', 'PR identity changed after policy evaluation') if not status['ready']: raise Failure(3,'gate','Merge readiness failed: ' + ', '.join(status['blockers'])) queue_guard(client,p) - same_head(client,number,expected) + same_base(same_head(client,number,expected), p) if client.provider == 'github': client.http(client.root+'/pulls/'+str(number)+'/merge','PUT',{'merge_method':method,'sha':expected}) else: key = 'do' if client.version == '28.2.0' else 'Do' client.http(client.root+'/pulls/'+str(number)+'/merge','POST',{key:method,'head_commit_id':expected}) try: - after = same_head(client,number,expected) + after = same_base(same_head(client,number,expected), p) if after.get('merged') is not True or not after.get('merge_commit_sha'): raise Failure(6,'verification','Merge not proven by PR readback',outcome='unknown') except Failure as e: @@ -587,7 +634,7 @@ def integration(client, number, operation, action, requested, expected): key = 'auto_merge' if operation == 'auto_merge' else 'queue' before = state[key] if action == 'inspect': - same_head(client,number,p['head']['sha']) + same_base(same_head(client,number,p['head']['sha']), p) return dict(supported=True,state=before['state'],method=before.get('method'),entry_id=before.get('entry_id'),expected_head=expected,head_sha=p['head']['sha'],verified=True) enabling = action in ('enable','enqueue') method = before.get('method') @@ -609,7 +656,7 @@ def integration(client, number, operation, action, requested, expected): if action == 'enable' and settings.get('allow_auto_merge') is not True: raise Failure(4,'unsupported','Repository allow_auto_merge is disabled or unknown') queue_guard(client,p) - same_head(client,number,expected) + same_base(same_head(client,number,expected), p) variables = {'id':state['id']} if action == 'enable': query = 'mutation($id:ID!,$head:GitObjectID!,$method:PullRequestMergeMethod!) { enablePullRequestAutoMerge(input:{pullRequestId:$id,expectedHeadOid:$head,mergeMethod:$method}) { pullRequest { id } } }' @@ -623,7 +670,7 @@ def integration(client, number, operation, action, requested, expected): query = 'mutation($id:ID!) { dequeuePullRequest(input:{id:$id}) { mergeQueueEntry { id } } }' client.gql(query,variables) try: - after = same_head(client,number,expected) + after = same_base(same_head(client,number,expected), p) if after.get('merged') is True: if not after.get('merge_commit_sha'): raise Failure(6,'verification','Merged PR lacks merge SHA',outcome='unknown') @@ -633,7 +680,7 @@ def integration(client, number, operation, action, requested, expected): actual = readback[key] if actual['state'] != desired or enabling and operation == 'auto_merge' and actual.get('method','').lower() != method or desired == 'queued' and not actual.get('entry_id'): raise Failure(6,'verification','Integration readback does not prove requested state',outcome='unknown') - same_head(client,number,expected) + same_base(same_head(client,number,expected), p) except Failure as e: e.outcome='unknown' raise @@ -732,6 +779,8 @@ def main(): args.host = args.host.lower() if not args.host or not re.fullmatch(r'[A-Za-z0-9.-]+(?::[0-9]+)?', args.host) or not args.repo or not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', args.repo) or any(part in ('.','..') for part in args.repo.split('/')): raise Failure(2, 'usage', 'Use an unambiguous origin or --host HOST --repo OWNER/REPO') + if args.operation != 'capabilities' and not re.fullmatch(r'[A-Za-z0-9_.-]+', os.environ.get('MOSAIC_GIT_IDENTITY', '')): + raise Failure(5, 'access', 'Explicit MOSAIC_GIT_IDENTITY is required before provider requests') client = Client(args.host, args.repo) client.detect() if args.operation == 'capabilities': diff --git a/packages/mosaic/framework/tools/git/test-ci-queue-wait-branch-absent.sh b/packages/mosaic/framework/tools/git/test-ci-queue-wait-branch-absent.sh index 42b8237ab..112ae7798 100644 --- a/packages/mosaic/framework/tools/git/test-ci-queue-wait-branch-absent.sh +++ b/packages/mosaic/framework/tools/git/test-ci-queue-wait-branch-absent.sh @@ -96,6 +96,9 @@ run_ci_queue_wait() { cd "$REPO_DIR" export PATH="$STUB_DIR:$PATH" export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json" + export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain" + export MOSAIC_GIT_IDENTITY=test-identity + python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" git.example.test stub-token export GITEA_TOKEN="stub-token" export GITEA_URL="https://git.example.test" export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl" diff --git a/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-ci-expected.sh b/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-ci-expected.sh index db79832b5..767dd44a1 100755 --- a/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-ci-expected.sh +++ b/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-ci-expected.sh @@ -112,6 +112,9 @@ run_guard() { cd "$REPO_DIR" || exit export PATH="$STUB_DIR:$PATH" export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json" + export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain" + export MOSAIC_GIT_IDENTITY=test-identity + python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" git.example.test stub-token export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit-$name.jsonl" export MOSAIC_STUB_URL_LOG="$URL_LOG" export GITEA_TOKEN="stub-token" @@ -130,6 +133,9 @@ run_guard_no_identity() { cd "$REPO_DIR" || exit export PATH="$STUB_DIR:$PATH" export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json" + export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain" + export MOSAIC_GIT_IDENTITY=test-identity + python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" git.example.test stub-token export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit-$name.jsonl" export MOSAIC_STUB_URL_LOG="$URL_LOG" export GITEA_TOKEN="stub-token" @@ -310,7 +316,7 @@ out_h=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=admin run_guard rc_h=$? set -u if expect_rc h 78 "$rc_h"; then - expect_text h "ASSERTION_UNATTRIBUTABLE state=no-status purpose=merge asserted-by=unknown reason=no-ci-expected branch=main" "$out_h" + expect_text h "ASSERTION_UNATTRIBUTABLE state=unknown purpose=merge asserted-by=unknown reason=no-ci-expected branch=main" "$out_h" expect_text h "queue-clear" "$out_h" absent if ! grep -q '"outcome":"ASSERTION_UNATTRIBUTABLE"' "$WORK_DIR/audit-h.jsonl" 2>/dev/null; then echo "FAIL h: expected an ASSERTION_UNATTRIBUTABLE JSONL audit record" >&2 diff --git a/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-status.sh b/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-status.sh index d59e61aa9..2d7316596 100755 --- a/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-status.sh +++ b/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-status.sh @@ -110,6 +110,9 @@ run_ci_queue_wait() { cd "$REPO_DIR" export PATH="$STUB_DIR:$PATH" export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json" + export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain" + export MOSAIC_GIT_IDENTITY=test-identity + python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" git.example.test stub-token export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl" export GITEA_TOKEN="stub-token" export GITEA_URL="https://git.example.test" diff --git a/packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh b/packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh index 4f632fc25..a775c8673 100644 --- a/packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh +++ b/packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh @@ -150,6 +150,9 @@ run_guard() { cd "$REPO_DIR" || exit export PATH="$STUB_DIR:$PATH" export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json" + export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain" + export MOSAIC_GIT_IDENTITY=test-identity + python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" git.example.test stub-token if [[ "$status_mode" == "credential-unresolvable" ]]; then export HOME="$WORK_DIR/empty-home" mkdir -p "$HOME" diff --git a/packages/mosaic/framework/tools/git/test-forge-auth.py b/packages/mosaic/framework/tools/git/test-forge-auth.py new file mode 100644 index 000000000..335ddf0f9 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-forge-auth.py @@ -0,0 +1,81 @@ +"""Synthetic-only credential, xtrace, and termination controls.""" +import importlib.util +import os +import pathlib +import signal +import subprocess +import tempfile +import time +import unittest + +HERE=pathlib.Path(__file__).resolve().parent + + +class Auth(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory(); self.addCleanup(self.tmp.cleanup) + self.root=pathlib.Path(self.tmp.name) + self.env=dict(os.environ,HOME=str(self.root),MOSAIC_BRAIN_HOME=str(self.root/'brain'),GIT_CONFIG_GLOBAL='/dev/null',GITEA_TOKEN='shared-synthetic',GITEA_URL='https://forge.example.test',TMPDIR=str(self.root),TMP=str(self.root),TEMP=str(self.root)) + self.env.pop('MOSAIC_GIT_IDENTITY',None) + self.token=self.root/'brain/credentials/forge/forge.example.test/fixture-user.token' + self.token.parent.mkdir(parents=True); self.token.write_text('private-synthetic-token') + def credential(self,trace=False): + return subprocess.run(['bash',*(['-x'] if trace else []),'-c','source "$1/detect-platform.sh"; get_gitea_token forge.example.test','fixture',str(HERE)],env=self.env,cwd=self.root,capture_output=True,text=True) + def test_no_identity_cannot_borrow_shared_or_git_config(self): + subprocess.run(['git','init','-q',str(self.root)],check=True,env=self.env) + subprocess.run(['git','-C',str(self.root),'config','mosaic.gitIdentity','fixture-user'],check=True,env=self.env) + p=self.credential(); self.assertNotEqual(p.returncode,0); self.assertEqual(p.stdout,'') + def test_host_bound_credential_and_xtrace(self): + self.env['MOSAIC_GIT_IDENTITY']='fixture-user' + p=self.credential(True); self.assertEqual(p.returncode,0,p.stderr) + self.assertEqual(p.stdout.strip(),'private-synthetic-token') + self.assertNotIn('private-synthetic-token',p.stderr) + self.token.unlink(); p=self.credential(); self.assertNotEqual(p.returncode,0); self.assertEqual(p.stdout,'') + def test_provider_store_separates_seat_and_service(self): + import re + mappings=re.findall(r'^\s*([A-Za-z0-9.-]+)\)\s+_idpfx=([A-Za-z0-9-]+)',(HERE/'detect-platform.sh').read_text(),re.MULTILINE) + self.assertTrue(mappings) + self.env['MOSAIC_GIT_IDENTITY']='fixture-user' + for host,prefix in mappings: + service=self.root/'.config/mosaic/secrets/gitea-tokens'/(prefix+'-fixture-user.token') + service.parent.mkdir(parents=True,exist_ok=True); service.write_text('service-synthetic') + seat=self.root/'brain/fleet/agents/fixture-user/secrets' + seat.mkdir(parents=True,exist_ok=True) + command=['bash','-c','source "$1/detect-platform.sh"; get_gitea_token "$2"','fixture',str(HERE),host] + p=subprocess.run(command,env=self.env,capture_output=True,text=True) + self.assertNotEqual(p.returncode,0,'seat borrowed service token') + token=seat/(prefix+'-fixture-user.token'); token.write_text('seat-synthetic') + p=subprocess.run(command,env=self.env,capture_output=True,text=True) + self.assertEqual(p.returncode,0,p.stderr); self.assertEqual(p.stdout,'seat-synthetic') + + def test_sigterm_leaves_no_credential_files(self): + # Child blocks inside curl's transport; only anonymous fds hold auth. + self.env['MOSAIC_GIT_IDENTITY']='fixture-user' + script='''import importlib.util,os,pathlib,signal,sys,time +s=importlib.util.spec_from_file_location('a',sys.argv[1]); a=importlib.util.module_from_spec(s); s.loader.exec_module(a) +c=a.Client('forge.example.test','owner/repo'); c.provider='gitea' +def transport(args,body,writing): + assert all('private-synthetic-token' not in x for x in args) + config=args[args.index('--config')+1] + assert config.startswith('/dev/fd/') + assert 'private-synthetic-token' in pathlib.Path(config).read_text() + pathlib.Path(sys.argv[2]).write_text('ready') + while True: time.sleep(1) +c.transport=transport +c.http('/repos/owner/repo') +''' + ready=self.root/'ready' + before=set(self.root.rglob('*')) + child=subprocess.Popen(['python3','-I','-c',script,str(HERE/'forge-api.py'),str(ready)],env=self.env,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True) + try: + deadline=time.monotonic()+10 + while not ready.exists() and child.poll() is None and time.monotonic()1: + side='head' if field.startswith('head_') else 'base' + key=field.removeprefix('head_') + p[side][key]='forbidden' if key=='ref' else {'full_name':'other/repo'} + return p + c.pr=retarget + with self.assertRaises(self.api.Failure) as caught: + self.api.snapshot(c,1,None) + self.assertEqual(caught.exception.code,75) + + def test_retarget_merge_never_writes(self): + from unittest.mock import patch + c=self.fake(); original=c.pr; writes=[] + def retarget(n): + p=original(n) + if c.reads>1: p['base']['ref']='forbidden-target' + return p + c.pr=retarget + http=c.http + def request(path,method='GET',body=None,public=False): + if method!='GET': writes.append(path) + if path==c.root: return {'allow_squash_merge':True} + return http(path,method,body,public) + c.http=request + with patch.object(self.api,'declared_method',return_value='squash'), patch.object(self.api,'queue_guard'): + with self.assertRaises(self.api.Failure) as caught: + self.api.merge(c,1,'squash','a'*40) + self.assertEqual(caught.exception.code,75) + self.assertEqual(writes,[]) + + def test_null_thread_is_not_resolved(self): + c=self.fake() + gql=c.gql + def response(q,v): + data=gql(q,v) + data['repository']['pullRequest']['reviewThreads']['nodes']=[{'id':'T','isResolved':None,'comments':{'nodes':[],'pageInfo':{'hasNextPage':False}}}] + return data + c.gql=response + with self.assertRaises(self.api.Failure) as caught: + self.api.snapshot(c,1,None) + self.assertEqual(caught.exception.code,6) + + def test_malformed_integration_is_unknown(self): + for key in ['autoMergeRequest','mergeQueueEntry']: + for value in [{},False,'',{'mergeMethod':None},{'id':False}]: + c=self.fake(); gql=c.gql + def response(q,v): + data=gql(q,v); data['repository']['pullRequest'][key]=value; return data + c.gql=response + with self.assertRaises(self.api.Failure) as caught: + self.api.integration(c,1,'auto_merge','inspect',None,None) + self.assertEqual(caught.exception.code,6) + if __name__ == '__main__': unittest.main() diff --git a/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh b/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh index e31e1d116..2b9c8dc11 100755 --- a/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh +++ b/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh @@ -273,8 +273,10 @@ grep -q -- 'curl .*https://git.uscllc.com/api/v1/repos/USC/uconnect/pulls/7.diff python3 -I "$SCRIPT_DIR/test-forge-cli.py" CLI.test_gitea_status_and_ci_wait_use_exact_head +fixture_host=$(git -C "$REPO_DIR" remote get-url origin | python3 -c 'import sys,urllib.parse; print(urllib.parse.urlparse(sys.stdin.read().strip()).hostname)') +python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$HOME_DIR/.mosaic" fixture-user "$fixture_host" "$(python3 -I -c 'import json,sys; d=json.load(open(sys.argv[1])); print(next(v["token"] for v in d["gitea"].values() if v["url"].endswith(sys.argv[2])))' "$CREDENTIALS_FILE" "$fixture_host")" : > "$LOG_FILE" -run_in_repo env MOSAIC_TEA_FAIL_PR_CREATE=1 GITEA_TOKEN=usc-token GITEA_URL=https://git.uscllc.com "$SCRIPT_DIR/pr-create.sh" -t "USC API fallback" -H feature/pr-create +run_in_repo env MOSAIC_GIT_IDENTITY=fixture-user MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" GITEA_LOGIN="$(python3 -I -c 'import json,sys; d=json.load(open(sys.argv[1])); print(next(k for k,v in d["gitea"].items() if v["url"].endswith(sys.argv[2])))' "$CREDENTIALS_FILE" "$fixture_host")" MOSAIC_TEA_FAIL_PR_CREATE=1 GITEA_TOKEN=usc-token GITEA_URL=https://git.uscllc.com "$SCRIPT_DIR/pr-create.sh" -t "USC API fallback" -H feature/pr-create grep -q -- 'tea pr create --repo USC/uconnect --login usc --title USC API fallback --head feature/pr-create' "$LOG_FILE" grep -q -- 'curl .*Authorization: token usc-token .*https://git.uscllc.com/api/v1/repos/USC/uconnect/pulls' "$LOG_FILE" if grep -q -- 'git.mosaicstack.dev/api/v1/repos/USC/uconnect/pulls' "$LOG_FILE"; then @@ -283,8 +285,10 @@ if grep -q -- 'git.mosaicstack.dev/api/v1/repos/USC/uconnect/pulls' "$LOG_FILE"; fi git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git +fixture_host=$(git -C "$REPO_DIR" remote get-url origin | python3 -c 'import sys,urllib.parse; print(urllib.parse.urlparse(sys.stdin.read().strip()).hostname)') +python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$HOME_DIR/.mosaic" fixture-user "$fixture_host" "$(python3 -I -c 'import json,sys; d=json.load(open(sys.argv[1])); print(next(v["token"] for v in d["gitea"].values() if v["url"].endswith(sys.argv[2])))' "$CREDENTIALS_FILE" "$fixture_host")" : > "$LOG_FILE" -run_in_repo env GITEA_TOKEN=mosaic-token GITEA_URL=https://git.mosaicstack.dev "$SCRIPT_DIR/issue-close.sh" -i 536 +run_in_repo env MOSAIC_GIT_IDENTITY=fixture-user MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" GITEA_TOKEN=mosaic-token GITEA_URL=https://git.mosaicstack.dev "$SCRIPT_DIR/issue-close.sh" -i 536 grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/536' "$LOG_FILE" if grep -q -- 'tea issue close 536 .*--login mosaicstack' "$LOG_FILE"; then echo "issue-close.sh invented a mosaicstack tea login instead of using API fallback" >&2 diff --git a/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh b/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh index bc049b615..e2146e78a 100755 --- a/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh +++ b/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh @@ -1,316 +1,5 @@ #!/usr/bin/env bash -# Regression harness for detect-platform.sh's get_gitea_token() per-agent -# identity resolution (Gate-16 author≠reviewer separation) — the API-tooling -# counterpart to git-credential-mosaic, so pr-create.sh/issue-create.sh/etc. -# open records under the resolved agent identity, not the shared account. -# -# Covers: -# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config -# mosaic.gitIdentity (per-worktree). -# 2. Correct per-slot token file path chosen per host -# (gitea-usc-.token vs gitea-mosaicstack-.token). -# 3. Per-slot token present -> that token is returned (agent-authored calls). -# 4. No identity requested -> shared credential-loader token (backward -# compat, unchanged). -# 5. Patch 2b — explicit identity + recognized Gitea host + ABSENT per-slot -# token for that identity -> FAIL LOUD (nonzero return, empty stdout, a -# stderr diagnostic naming identity/source/host/expected path). Must NOT -# fall through to the shared/default token (Gate-16 author≠reviewer -# integrity — never silently borrow another slot's credentials). Covered -# for both identity sources (git config, MOSAIC_GIT_IDENTITY env) and -# for a same-identity cross-host case (token exists for one host, not -# the other). -# 6. Scope containment: identity requested + an UNRECOGNIZED Gitea host (no -# per-slot token scheme) -> Patch 2b does not apply; existing -# fall-through behavior is unchanged. -# 7. Credential store selection: an identity with a directory under -# /fleet/agents/ is a SEAT and is read ONLY from its own secrets/ -# slot; any other identity is a SERVICE and is read from the framework -# store. No precedence between them and NO fallback from one to the -# other — a seat with an empty slot is REFUSED even when a same-named -# token sits in the framework store. -# 8. Fail loud when NO identity resolves on a host that runs a fleet: where -# seats exist, an unattributable API call is refused rather than made -# under the shared account. On a host with no fleet the same call still -# returns the shared token (case 1), which is what keeps this change a -# no-op for non-fleet operators of the framework. -# -# Uses a stubbed credentials.json + stubbed per-slot token files under a fake -# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets. - +# Explicit actor/host-bound credentials, shared/config refusal and trace controls. set -euo pipefail - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}" -FAKE_HOME="$WORK_DIR/home" -REPO_DIR="$WORK_DIR/repo" -CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json" - -rm -rf "$WORK_DIR" -mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$REPO_DIR" - -git -C "$REPO_DIR" init -q -git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git - -cat > "$CREDENTIALS_FILE" <<'JSON' -{ - "gitea": { - "mosaicstack": { - "url": "https://git.mosaicstack.dev", - "token": "shared-mosaicstack-token" - }, - "usc": { - "url": "https://git.uscllc.com", - "token": "shared-usc-token" - } - } -} -JSON - -fail=0 -assert_eq() { - local desc="$1" expected="$2" actual="$3" - if [[ "$expected" != "$actual" ]]; then - echo "FAIL: $desc — expected '$expected', got '$actual'" >&2 - fail=1 - fi -} - -# Runs get_gitea_token for $1=host inside REPO_DIR (per-worktree git config -# resolves there) with a fake HOME + the stub credentials.json, plus any -# extra env passed in $@. -call_get_gitea_token() { - local host="$1"; shift - ( - cd "$REPO_DIR" - # shellcheck disable=SC2016 # deliberately deferred: $DETECT_PLATFORM_SH is - # expanded by the INNER bash -c (via the exported env var below), not here. - env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ - DETECT_PLATFORM_SH="$SCRIPT_DIR/detect-platform.sh" "$@" \ - bash -c 'source "$DETECT_PLATFORM_SH"; get_gitea_token "$1"' _ "$host" - ) -} - -# --------------------------------------------------------------------------- -# 1. No identity resolvable -> shared credential-loader token (unchanged). -# --------------------------------------------------------------------------- -git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true -out=$(call_get_gitea_token "git.mosaicstack.dev") -assert_eq "shared fallback (no identity)" "shared-mosaicstack-token" "$out" - -# --------------------------------------------------------------------------- -# 2. git config mosaic.gitIdentity resolves to an agent WITH a per-slot -# token -> that token wins over the shared account. -# --------------------------------------------------------------------------- -echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token" -git -C "$REPO_DIR" config mosaic.gitIdentity agentA -out=$(call_get_gitea_token "git.mosaicstack.dev") -assert_eq "git-config identity token" "agentA-mosaicstack-token" "$out" - -# --------------------------------------------------------------------------- -# 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity. -# --------------------------------------------------------------------------- -echo -n "agentB-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentB.token" -out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentB) -assert_eq "env beats git-config identity token" "agentB-mosaicstack-token" "$out" - -# --------------------------------------------------------------------------- -# 4. FAIL LOUD (Patch 2b): an identity is explicitly requested (via git config -# mosaic.gitIdentity, and separately via MOSAIC_GIT_IDENTITY env) for a -# RECOGNIZED Gitea host, but no per-slot token exists for THAT identity. -# Must NOT fall through to the shared/default token — silently borrowing -# another slot's credentials would post PRs/issues/reviews as the WRONG -# agent (Gate-16 author≠reviewer integrity break). Expect: nonzero return, -# EMPTY stdout (no token — shared or otherwise — leaked), and a stderr -# diagnostic naming the identity, its source, the host, and the expected -# per-slot token path. -# --------------------------------------------------------------------------- -assert_failloud() { - local desc="$1" host="$2" ident="$3" expected_tok_path="$4"; shift 4 - local stderr_file="$WORK_DIR/stderr.tmp" - : > "$stderr_file" - set +e - local stdout - stdout=$(call_get_gitea_token "$host" "$@" 2>"$stderr_file") - local rc=$? - set -e - local stderr - stderr=$(cat "$stderr_file") - if [[ "$rc" -eq 0 ]]; then - echo "FAIL: $desc — expected nonzero return, got 0 (stdout='$stdout')" >&2 - fail=1 - fi - if [[ -n "$stdout" ]]; then - echo "FAIL: $desc — expected empty stdout (no token leaked), got '$stdout'" >&2 - fail=1 - fi - if [[ "$stderr" != *"$ident"* ]]; then - echo "FAIL: $desc — stderr does not name the requested identity '$ident':" >&2 - echo "$stderr" >&2 - fail=1 - fi - if [[ "$stderr" != *"$host"* ]]; then - echo "FAIL: $desc — stderr does not name the host '$host':" >&2 - echo "$stderr" >&2 - fail=1 - fi - if [[ "$stderr" != *"$expected_tok_path"* ]]; then - echo "FAIL: $desc — stderr does not name the expected per-slot token path '$expected_tok_path':" >&2 - echo "$stderr" >&2 - fail=1 - fi - if [[ "$stderr" == *"shared"*"token"* ]]; then - echo "FAIL: $desc — stderr unexpectedly mentions a shared token value:" >&2 - echo "$stderr" >&2 - fail=1 - fi -} - -# 4a. git config mosaic.gitIdentity source, recognized host (mosaicstack), -# shared token IS present but must not be borrowed. -git -C "$REPO_DIR" config mosaic.gitIdentity no-such-agent -assert_failloud "fail-loud via git-config identity (recognized host)" \ - "git.mosaicstack.dev" "no-such-agent" \ - "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-no-such-agent.token" -git -C "$REPO_DIR" config --unset mosaic.gitIdentity - -# 4b. MOSAIC_GIT_IDENTITY env source (takes priority over git config), same -# recognized-host / absent-token scenario -> also fails loud. -assert_failloud "fail-loud via MOSAIC_GIT_IDENTITY env (recognized host)" \ - "git.mosaicstack.dev" "no-such-agent-env" \ - "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-no-such-agent-env.token" \ - MOSAIC_GIT_IDENTITY=no-such-agent-env - -# --------------------------------------------------------------------------- -# 5. Correct per-slot token PATH per host: same agent id, only a usc token -# exists. usc host returns it (happy path, unchanged). mosaicstack host -# has NO per-slot token for this identity -> Patch 2b fail-loud applies -# there too (must NOT fall back to the shared mosaicstack token, and must -# NOT leak the agent's usc token either). -# --------------------------------------------------------------------------- -echo -n "agentD-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentD.token" -git -C "$REPO_DIR" config mosaic.gitIdentity agentD -out=$(call_get_gitea_token "git.uscllc.com") -assert_eq "host-scoped token path (usc)" "agentD-usc-token" "$out" -assert_failloud "fail-loud on cross-host absence (no fallback, no cross-host leak)" \ - "git.mosaicstack.dev" "agentD" \ - "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentD.token" -git -C "$REPO_DIR" config --unset mosaic.gitIdentity - -# --------------------------------------------------------------------------- -# 6. Scope containment: identity explicitly requested, but the host is NOT a -# recognized Gitea host (no per-slot token scheme at all) -> Patch 2b does -# NOT apply; existing fall-through behavior is unchanged (ends in the -# pre-existing generic failure since no shared credentials match either, -# NOT the fail-loud diagnostic path). -# --------------------------------------------------------------------------- -git -C "$REPO_DIR" config mosaic.gitIdentity no-such-agent -set +e -out=$(call_get_gitea_token "github.com" 2>"$WORK_DIR/stderr-scope.tmp") -rc=$? -set -e -err=$(cat "$WORK_DIR/stderr-scope.tmp") -if [[ "$rc" -eq 0 ]]; then - echo "FAIL: unrecognized host + identity — expected nonzero (no credentials configured), got 0" >&2 - fail=1 -fi -if [[ "$err" == *"no per-slot token at"* ]]; then - echo "FAIL: unrecognized host + identity — fail-loud diagnostic must not fire for a host with no per-slot scheme:" >&2 - echo "$err" >&2 - fail=1 -fi -git -C "$REPO_DIR" config --unset mosaic.gitIdentity - -# --------------------------------------------------------------------------- -# 7. SEAT identity: an id with a directory under /fleet/agents/ is read -# from its OWN secrets/ slot, not from the framework store. The brain home -# is resolved exactly as packages/mosaic/src/fleet/brain-home.ts does it: -# MOSAIC_BRAIN_HOME, else ~/.mosaic. -# --------------------------------------------------------------------------- -BRAIN_DIR="$WORK_DIR/brain" -mkdir -p "$BRAIN_DIR/fleet/agents/seatE/secrets" -echo -n "seatE-slot-token" > "$BRAIN_DIR/fleet/agents/seatE/secrets/gitea-mosaicstack-seatE.token" -out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatE MOSAIC_BRAIN_HOME="$BRAIN_DIR") -assert_eq "seat reads its own slot" "seatE-slot-token" "$out" - -# --------------------------------------------------------------------------- -# 8. NO CROSS-STORE FALLBACK. seatF is a seat (it has a directory) with an -# EMPTY slot, while a framework-store token of the identical name is -# present and readable. It must be REFUSED rather than served that token: -# one credential, one location. A seat that silently reads a same-named -# service credential is the same substitution failure as the shared-account -# fallback, one store further down. -# --------------------------------------------------------------------------- -mkdir -p "$BRAIN_DIR/fleet/agents/seatF/secrets" -echo -n "seatF-SERVICE-STORE-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-seatF.token" -assert_failloud "seat with empty slot does NOT fall back to the framework store" \ - "git.mosaicstack.dev" "seatF" \ - "$BRAIN_DIR/fleet/agents/seatF/secrets/gitea-mosaicstack-seatF.token" \ - MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$BRAIN_DIR" -# assert_failloud only screens stderr for the word "shared"; this store's token -# is not named that, so check for its value explicitly. -set +e -xstore_out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr-xstore.tmp") -set -e -if [[ "$xstore_out$(cat "$WORK_DIR/stderr-xstore.tmp")" == *"seatF-SERVICE-STORE-token"* ]]; then - echo "FAIL: cross-store fallback — a seat was served the framework store's same-named token" >&2 - fail=1 -fi -# Control: that framework-store token IS readable, so the refusal above is the -# store rule firing and not an unreadable file. The same id, resolved against a -# brain home where it is not a seat, gets it. -out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$WORK_DIR/no-such-brain") -assert_eq "control — same file IS readable for a non-seat identity" "seatF-SERVICE-STORE-token" "$out" - -# --------------------------------------------------------------------------- -# 9. FAIL LOUD — no identity resolvable, but this host runs a fleet. Contrast -# with case 1: the identical call on a host with no fleet still returns the -# shared token. -# --------------------------------------------------------------------------- -git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true -set +e -noid_out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr-noid.tmp") -noid_rc=$? -set -e -noid_err=$(cat "$WORK_DIR/stderr-noid.tmp") -if [[ "$noid_rc" -eq 0 ]]; then - echo "FAIL: no identity on a fleet host — expected nonzero return, got 0 (stdout='$noid_out')" >&2 - fail=1 -fi -if [[ -n "$noid_out" ]]; then - echo "FAIL: no identity on a fleet host — expected empty stdout, got '$noid_out'" >&2 - fail=1 -fi -if [[ "$noid_out" == *"shared-mosaicstack-token"* || "$noid_err" == *"shared-mosaicstack-token"* ]]; then - echo "FAIL: no identity on a fleet host — the shared token was served anyway:" >&2 - echo "$noid_out$noid_err" >&2 - fail=1 -fi -if [[ "$noid_err" != *"MOSAIC_GIT_IDENTITY"* ]]; then - echo "FAIL: no identity on a fleet host — stderr does not say how to set an identity:" >&2 - echo "$noid_err" >&2 - fail=1 -fi - -# --------------------------------------------------------------------------- -# 10. The brain home defaults to ~/.mosaic when MOSAIC_BRAIN_HOME is unset — -# the fleet gate must fire on the default path too, not only on an -# explicitly injected one. Case 1 ran before this directory existed; the -# same call now refuses, which also proves case 1 was measuring the -# no-fleet branch rather than passing for an unrelated reason. -# --------------------------------------------------------------------------- -mkdir -p "$FAKE_HOME/.mosaic/fleet/agents" -set +e -dflt_out=$(call_get_gitea_token "git.mosaicstack.dev" 2>"$WORK_DIR/stderr-dflt.tmp") -dflt_rc=$? -set -e -if [[ "$dflt_rc" -eq 0 || -n "$dflt_out" ]]; then - echo "FAIL: fleet gate did not fire on the default ~/.mosaic brain home (rc=$dflt_rc stdout='$dflt_out')" >&2 - fail=1 -fi - -if [[ "$fail" -eq 0 ]]; then - echo "get_gitea_token identity resolution regression passed" -fi - -exit "$fail" +exec python3 -I "$SCRIPT_DIR/test-forge-auth.py" diff --git a/packages/mosaic/framework/tools/git/test-grant-reviewer.sh b/packages/mosaic/framework/tools/git/test-grant-reviewer.sh index ef2c7fd10..e378d3e88 100755 --- a/packages/mosaic/framework/tools/git/test-grant-reviewer.sh +++ b/packages/mosaic/framework/tools/git/test-grant-reviewer.sh @@ -112,6 +112,9 @@ with open(sys.argv[1], "w", encoding="utf-8") as credentials: }, credentials) PY +mkdir -p "$HOME_DIR/.config/mosaic" +cp "$CREDENTIALS_FILE" "$HOME_DIR/.config/mosaic/credentials.json" +python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$HOME_DIR/.mosaic" "$DEFAULT_IDENTITY" "$(python3 -c 'import sys,urllib.parse; print(urllib.parse.urlparse(sys.argv[1]).hostname)' "$API_ROOT")" "$DEFAULT_TOKEN" # tea stub: grant-reviewer.sh must never shell out to tea at all. cat > "$BIN_DIR/tea" <<'SH' #!/usr/bin/env bash @@ -386,7 +389,7 @@ run_grant() { XDG_CONFIG_HOME="$XDG_DIR" \ MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" \ - MOSAIC_GIT_IDENTITY= \ + MOSAIC_GIT_IDENTITY="$DEFAULT_IDENTITY" \ GITEA_LOGIN= \ GITEA_TOKEN= \ GITEA_URL= \ diff --git a/packages/mosaic/framework/tools/git/test-issue-comment-readback.sh b/packages/mosaic/framework/tools/git/test-issue-comment-readback.sh index b08518a36..08b1f349d 100755 --- a/packages/mosaic/framework/tools/git/test-issue-comment-readback.sh +++ b/packages/mosaic/framework/tools/git/test-issue-comment-readback.sh @@ -444,6 +444,8 @@ run_comment() { : > "$AUTH_LOG" : > "$OUTPUT_FILE" seed_state "$mode" + fixture_host="${API_ROOT#https://}"; fixture_host="${fixture_host%%/*}" + python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$HOME_DIR/.mosaic" "$ACTING_LOGIN" "$fixture_host" "$DEFAULT_TOKEN" ( cd "$REPO_DIR" # Provisioned seats export MOSAIC_GIT_IDENTITY and MOSAIC_BRAIN_HOME @@ -466,8 +468,8 @@ run_comment() { HOME="$HOME_DIR" \ XDG_CONFIG_HOME="$XDG_DIR" \ MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ - MOSAIC_GIT_IDENTITY="" \ - MOSAIC_BRAIN_HOME="" \ + MOSAIC_GIT_IDENTITY="$ACTING_LOGIN" \ + MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" \ ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \ ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \ ISSUE_COMMENT_CURL_ARGV_LOG="$CURL_ARGV_LOG" \ @@ -504,6 +506,8 @@ run_comment_seat() { : > "$AUTH_LOG" : > "$OUTPUT_FILE" seed_state "$mode" + fixture_host="${API_ROOT#https://}"; fixture_host="${fixture_host%%/*}" + python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$HOME_DIR/.mosaic" "$ACTING_LOGIN" "$fixture_host" "$DEFAULT_TOKEN" ( cd "$REPO_DIR" PATH="$BIN_DIR:$PATH" \ @@ -512,7 +516,7 @@ run_comment_seat() { XDG_CONFIG_HOME="$XDG_DIR" \ MOSAIC_CREDENTIALS_FILE="$EMPTY_CREDENTIALS_FILE" \ MOSAIC_GIT_IDENTITY="$SEAT_IDENTITY" \ - MOSAIC_BRAIN_HOME="" \ + MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" \ ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \ ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \ ISSUE_COMMENT_CURL_ARGV_LOG="$CURL_ARGV_LOG" \ diff --git a/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh b/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh index d0bd542e3..1858a2b6f 100755 --- a/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh +++ b/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh @@ -88,6 +88,12 @@ case "$url" in esac SH chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl" +export MOSAIC_GIT_IDENTITY=current-user +export GITEA_LOGIN="$(git -C "$REPO_DIR" remote get-url origin | python3 -c 'import sys,urllib.parse; print(urllib.parse.urlparse(sys.stdin.read().strip()).hostname.split(".")[1])')" +export MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" +mkdir -p "$HOME_DIR/.config/mosaic" +cp "$CREDENTIALS_FILE" "$HOME_DIR/.config/mosaic/credentials.json" +python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" "$(git -C "$REPO_DIR" remote get-url origin | python3 -c 'import sys,urllib.parse; print(urllib.parse.urlparse(sys.stdin.read().strip()).hostname)')" test-token run_wrapper() { ( diff --git a/packages/mosaic/framework/tools/git/test-issue-view-comments.sh b/packages/mosaic/framework/tools/git/test-issue-view-comments.sh index f27ffe7da..202f43083 100755 --- a/packages/mosaic/framework/tools/git/test-issue-view-comments.sh +++ b/packages/mosaic/framework/tools/git/test-issue-view-comments.sh @@ -37,12 +37,20 @@ export GITEA_URL="https://git.mosaicstack.dev" export GITEA_TOKEN="redacted-test-token" # The identity ladder must not reach for this seat's real login; the mock tea below # defines the only login that exists in this sandbox. -unset MOSAIC_GIT_IDENTITY +export MOSAIC_GIT_IDENTITY=fixture-user +export GITEA_LOGIN="$(git -C "$REPO_DIR" remote get-url origin | python3 -c 'import sys,urllib.parse; print(urllib.parse.urlparse(sys.stdin.read().strip()).hostname.split(".")[1])')" # No fleet in the sandbox: on a host that runs one, get_gitea_token fails closed for an # identity-less caller (by design), which would make this test measure the host, not # the wrapper. An empty brain home makes the sandbox the same on every host. export MOSAIC_BRAIN_HOME="$SANDBOX/brain" mkdir -p "$MOSAIC_BRAIN_HOME" || fail "setup: cannot create sandbox brain home" +mkdir -p "$SANDBOX/home/.config/mosaic" +export HOME="$SANDBOX/home" +python3 -I - "$HOME/.config/mosaic/credentials.json" "$GITEA_URL" <<'PYFIX' +import json,sys +open(sys.argv[1],'w').write(json.dumps({'gitea':{sys.argv[2].split('//')[1].split('.')[1]:{'url':sys.argv[2]}}})) +PYFIX +python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" "${GITEA_URL#https://}" "$GITEA_TOKEN" # Distinctive strings: a comment body that appears nowhere else, and an issue title # that contains the word "comment" so a count-of-the-word instrument would misread. diff --git a/packages/mosaic/framework/tools/git/test-merge-policy.py b/packages/mosaic/framework/tools/git/test-merge-policy.py index 70f543983..8dcba6078 100644 --- a/packages/mosaic/framework/tools/git/test-merge-policy.py +++ b/packages/mosaic/framework/tools/git/test-merge-policy.py @@ -55,5 +55,45 @@ class Policy(unittest.TestCase): def test_self_transition_refused(self): self.assertEqual(self.resolve(head='integration')[0],3) + def test_head_and_uncommitted_policy_cannot_authorize_merge(self): + import os + import importlib.util + spec=importlib.util.spec_from_file_location('trusted_policy',HERE/'forge-api.py') + api=importlib.util.module_from_spec(spec); spec.loader.exec_module(api) + with tempfile.TemporaryDirectory() as directory: + root=pathlib.Path(directory); (root/'.mosaic').mkdir() + def git(*args): + r=subprocess.run(['git',*args],cwd=root,capture_output=True,text=True,check=True) + return r.stdout.strip() + git('init','-q'); git('remote','add','origin','https://github.com/owner/repo.git') + git('-c','user.name=Fixture','-c','user.email=fixture@example.test','commit','--allow-empty','-qm','absent policy target') + absent=git('rev-parse','HEAD') + doc=dict(schema_version=3,integration_trunk='integration',release_branch='stable',flow='trunk-release',canonical_remote='https://github.com/owner/repo',canonical_clone='host:/src/project',merge=dict(allowed_methods=['squash'],default_method='squash')) + path=root/'.mosaic/repo.json'; path.write_text(json.dumps(doc)); git('add','.mosaic/repo.json') + git('-c','user.name=Fixture','-c','user.email=fixture@example.test','commit','-qm','trusted target') + base=git('rev-parse','HEAD') + doc['merge']['allowed_methods'].append('merge'); path.write_text(json.dumps(doc)) + git('add','.mosaic/repo.json'); git('-c','user.name=Fixture','-c','user.email=fixture@example.test','commit','-qm','weaken head') + doc['merge']['default_method']='merge'; path.write_text(json.dumps(doc)) + p={'head':{'ref':'task'},'base':{'ref':'integration','sha':base,'repo':{'full_name':'owner/repo'}}} + client=api.Client('github.com','owner/repo') + old=os.getcwd() + try: + os.chdir(root) + with self.assertRaises(api.Failure) as caught: api.declared_method(client,p,'merge') + self.assertEqual(caught.exception.code,3) + self.assertEqual(api.declared_method(client,p,None),'squash') + p['base']['sha']=absent + client.http=lambda *a, **kw: {'default_branch':'integration','allow_squash_merge':False} + self.assertEqual(api.declared_method(client,p,None),'squash') + with self.assertRaises(api.Failure) as caught: api.declared_method(client,p,'merge') + self.assertEqual(caught.exception.code,3) + with self.assertRaises(api.Failure) as caught: api.forge_method(client,'squash') + self.assertEqual(caught.exception.code,4) + p['base']['sha']='e'*40 + with self.assertRaises(api.Failure) as caught: api.declared_method(client,p,None) + self.assertEqual(caught.exception.code,3) + finally: os.chdir(old) + if __name__ == '__main__': unittest.main() diff --git a/packages/mosaic/framework/tools/git/test-pr-create-fallback-default-base.sh b/packages/mosaic/framework/tools/git/test-pr-create-fallback-default-base.sh index bc350bb75..a42150029 100755 --- a/packages/mosaic/framework/tools/git/test-pr-create-fallback-default-base.sh +++ b/packages/mosaic/framework/tools/git/test-pr-create-fallback-default-base.sh @@ -46,6 +46,10 @@ git -C "$ROOT/repo" init -q -b fix/e4 git -C "$ROOT/repo" -c user.name=fixture -c user.email=fixture@test commit -q --allow-empty -m base git -C "$ROOT/repo" remote add origin https://git.example.test/acme/widgets.git +export MOSAIC_GIT_IDENTITY=fixture-user +export MOSAIC_BRAIN_HOME="$ROOT/home/.mosaic" +python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$ROOT/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" git.example.test stub-token + # curl stub: GET repo -> default_branch JSON (or failure mode); POST pulls -> # capture payload, answer with a minimal PR JSON. Every call is logged. cat > "$ROOT/stub/curl" < sets RC/OUT/ERR RC=0 OUT=$(cd "$ROOT/repo" && env -i \ PATH="$ROOT/stub:/usr/bin:/bin" \ - HOME="$ROOT/home" \ + HOME="$ROOT/home" MOSAIC_GIT_IDENTITY=fixture-user MOSAIC_BRAIN_HOME="$ROOT/home/.mosaic" \ GITEA_TOKEN=stub-token \ bash "$TOOLS/pr-create.sh" "$@" 2>"$ROOT/err.txt") RC=$? @@ -126,7 +130,7 @@ echo "== (3) default-branch lookup failure: loud refusal, no POST ==" RC=0 OUT=$(cd "$ROOT/repo" && env -i \ PATH="$ROOT/stub:/usr/bin:/bin" \ - HOME="$ROOT/home" \ + HOME="$ROOT/home" MOSAIC_GIT_IDENTITY=fixture-user MOSAIC_BRAIN_HOME="$ROOT/home/.mosaic" \ GITEA_TOKEN=stub-token \ CURL_STUB_GET_MODE=fail \ bash "$TOOLS/pr-create.sh" -t "fix thing" 2>"$ROOT/err.txt") @@ -148,7 +152,7 @@ echo "== (5) B2: HTTP failure with parseable JSON on stdout is a FAILED resoluti RC=0 OUT=$(cd "$ROOT/repo" && env -i \ PATH="$ROOT/stub:/usr/bin:/bin" \ - HOME="$ROOT/home" \ + HOME="$ROOT/home" MOSAIC_GIT_IDENTITY=fixture-user MOSAIC_BRAIN_HOME="$ROOT/home/.mosaic" \ GITEA_TOKEN=stub-token \ CURL_STUB_GET_MODE=fail-json \ bash "$TOOLS/pr-create.sh" -t "fix thing" 2>"$ROOT/err.txt") @@ -165,7 +169,7 @@ for bad in null numeric blank; do RC=0 OUT=$(cd "$ROOT/repo" && env -i \ PATH="$ROOT/stub:/usr/bin:/bin" \ - HOME="$ROOT/home" \ + HOME="$ROOT/home" MOSAIC_GIT_IDENTITY=fixture-user MOSAIC_BRAIN_HOME="$ROOT/home/.mosaic" \ GITEA_TOKEN=stub-token \ CURL_STUB_GET_MODE="$bad" \ bash "$TOOLS/pr-create.sh" -t "fix thing" 2>"$ROOT/err.txt") diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh b/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh index cea8cbf4a..7ba7a859b 100755 --- a/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh @@ -164,6 +164,14 @@ export PR_MERGE_TEST_LOG="$LOG_FILE" export GITEA_LOGIN="git.mosaicstack.dev" export GITEA_URL="https://git.mosaicstack.dev" export GITEA_TOKEN="redacted-test-token" +export MOSAIC_GIT_IDENTITY=fixture-user +export MOSAIC_BRAIN_HOME="$SANDBOX/brain" +mkdir -p "$HOME/.config/mosaic" +python3 -I - "$HOME/.config/mosaic/credentials.json" "$GITEA_URL" <<'PYFIX' +import json,sys +open(sys.argv[1],'w').write(json.dumps({'gitea':{sys.argv[2].split('//')[1].split('.')[1]:{'url':sys.argv[2]}}})) +PYFIX +python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" "${GITEA_URL#https://}" "$GITEA_TOKEN" OUTPUT="$SANDBOX/output.log" if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh b/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh index 8a1a62f2a..299b04f37 100644 --- a/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh @@ -78,9 +78,12 @@ set +e export PATH="$WORK_DIR/gitea:$PATH" export GITEA_TOKEN=stub-token export GITEA_URL=https://git.example.test + export MOSAIC_GIT_IDENTITY=fixture-user + export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain" + python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" "${GITEA_URL#https://}" "$GITEA_TOKEN" export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json" export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload.json" - env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 + env MOSAIC_GIT_IDENTITY=fixture-user "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 ) >"$WORK_DIR/gitea.out" 2>&1 gitea_rc=$? set -e @@ -107,9 +110,12 @@ set +e export PATH="$WORK_DIR/gitea:$PATH" export GITEA_TOKEN=stub-token export GITEA_URL=https://git.example.test + export MOSAIC_GIT_IDENTITY=fixture-user + export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain" + python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" "${GITEA_URL#https://}" "$GITEA_TOKEN" export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json" export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-stale.json" - env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --expect-head "$wrong_sha" + env MOSAIC_GIT_IDENTITY=fixture-user "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --expect-head "$wrong_sha" ) >"$WORK_DIR/gitea-stale.out" 2>&1 stale_rc=$? set -e @@ -132,9 +138,12 @@ set +e export PATH="$WORK_DIR/gitea:$PATH" export GITEA_TOKEN=stub-token export GITEA_URL=https://git.example.test + export MOSAIC_GIT_IDENTITY=fixture-user + export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain" + python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" "${GITEA_URL#https://}" "$GITEA_TOKEN" export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json" export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-bypass.json" - env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --skip-queue-guard + env MOSAIC_GIT_IDENTITY=fixture-user "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --skip-queue-guard ) >"$WORK_DIR/gitea-bypass.out" 2>&1 bypass_rc=$? set -e @@ -151,9 +160,12 @@ rm -f "$WORK_DIR/gitea-payload-dry-run.json" export PATH="$WORK_DIR/gitea:$PATH" export GITEA_TOKEN=stub-token export GITEA_URL=https://git.example.test + export MOSAIC_GIT_IDENTITY=fixture-user + export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain" + python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" "${GITEA_URL#https://}" "$GITEA_TOKEN" export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json" export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-dry-run.json" - env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --dry-run + env MOSAIC_GIT_IDENTITY=fixture-user "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --dry-run ) >"$WORK_DIR/gitea-dry-run.out" 2>&1 if [[ -e "$WORK_DIR/gitea-payload-dry-run.json" ]]; then echo "FAIL dry-run: non-merging preflight reached the provider merge path" >&2 diff --git a/packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh b/packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh index f57aa798b..079b36559 100755 --- a/packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh +++ b/packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh @@ -560,6 +560,10 @@ run_review() { local expected_web_base="${configured_url%/}/$expected_repo" git -C "$REPO_DIR" remote set-url origin "$remote_url" write_credentials "$configured_url" + fixture_host=$(python3 -I -c 'import sys,urllib.parse; print(urllib.parse.urlsplit(sys.argv[1]).netloc)' "$configured_url") + python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$HOME_DIR/.mosaic" "$ACTING_LOGIN" "$fixture_host" "$DEFAULT_TOKEN" + fixture_remote_host=$(cd "$REPO_DIR" && source "$SCRIPT_DIR/detect-platform.sh" && get_remote_host) + python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$HOME_DIR/.mosaic" "$ACTING_LOGIN" "$fixture_remote_host" "$DEFAULT_TOKEN" : > "$TEA_LOG" : > "$CURL_LOG" : > "$CURL_ARGV_LOG" @@ -571,7 +575,7 @@ run_review() { PATH="$BIN_DIR:$PATH" \ TMPDIR="$TMP_SCRATCH" \ HOME="$HOME_DIR" \ - MOSAIC_GIT_IDENTITY="" \ + MOSAIC_GIT_IDENTITY="$ACTING_LOGIN" MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" \ XDG_CONFIG_HOME="$XDG_DIR" \ MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ PR_REVIEW_TEA_LOG="$TEA_LOG" \ diff --git a/packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh b/packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh index 17727fd6e..5f60287fa 100755 --- a/packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh +++ b/packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh @@ -167,6 +167,7 @@ chmod +x "$BIN_DIR/curl" run() { local repo_dir="$1"; shift + python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$WORK_DIR/home/.mosaic" fixture-user "$HOST_OVERRIDE_VAL" "$TOKEN_VAL" : > "$CURL_LOG" : > "$UA_LOG" : > "$OUTPUT_FILE" @@ -174,6 +175,7 @@ run() { cd "$repo_dir" PATH="$BIN_DIR:$PATH" \ TMPDIR="$TMP_SCRATCH" \ + HOME="$WORK_DIR/home" MOSAIC_GIT_IDENTITY=fixture-user MOSAIC_BRAIN_HOME="$WORK_DIR/home/.mosaic" \ MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ GITEA_TOKEN="$TOKEN_VAL" \ GITEA_URL="https://$HOST_OVERRIDE_VAL" \ diff --git a/packages/mosaic/framework/tools/git/test-repo-decl-consumption.sh b/packages/mosaic/framework/tools/git/test-repo-decl-consumption.sh index d5895ee8e..7f0544728 100755 --- a/packages/mosaic/framework/tools/git/test-repo-decl-consumption.sh +++ b/packages/mosaic/framework/tools/git/test-repo-decl-consumption.sh @@ -181,8 +181,9 @@ STUB } run_pr() { # $1 dir, rest args -> pr-create local prdir="$1"; shift + python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$prdir/home" "$prdir/home/.mosaic" fixture-user git.example.test stub-token PR_RC=0 - PR_OUT="$(cd "$prdir" && env -i PATH="$prdir/stub:/usr/bin:/bin" HOME="$prdir/home" \ + PR_OUT="$(cd "$prdir" && env -i PATH="$prdir/stub:/usr/bin:/bin" HOME="$prdir/home" MOSAIC_GIT_IDENTITY=fixture-user MOSAIC_BRAIN_HOME="$prdir/home/.mosaic" \ GITEA_TOKEN=stub-token STUB_DIR="$prdir" \ bash "$prdir/tools/git/pr-create.sh" "$@" < /dev/null 2>"$prdir/err.txt")" || PR_RC=$? PR_ERR="$(cat "$prdir/err.txt")" -- 2.54.0 From e62adbcc27ee1be7831cf0f8305dbb83a7c81681 Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 15:22:25 -0600 Subject: [PATCH 07/26] test(framework): use independent offline workflow controls (#1841) Exercise separate T3-wake and notifier wrapper paths against expected verdicts; reject wrong-ready and missing-full-stop mutants. Keep schema fixtures offline, operator patterns external and generated guard fixtures in scratch. Co-Authored-By: GPT-6.1-Sol (Codex) --- .../tools/git/fixture-schema-client.sh | 8 + .../git/test-detect-platform-outside-repo.sh | 4 + .../git/test-issue-create-body-safety.sh | 1 + .../tools/git/test-pr-merge-methods.sh | 2 +- .../tools/git/test-pr-skill-contract.py | 7 +- .../tools/git/test-pr-watch-parity.py | 214 +++++++++++++----- .../framework/tools/git/test-push-guard.sh | 3 +- 7 files changed, 176 insertions(+), 63 deletions(-) create mode 100755 packages/mosaic/framework/tools/git/fixture-schema-client.sh diff --git a/packages/mosaic/framework/tools/git/fixture-schema-client.sh b/packages/mosaic/framework/tools/git/fixture-schema-client.sh new file mode 100755 index 000000000..33af12beb --- /dev/null +++ b/packages/mosaic/framework/tools/git/fixture-schema-client.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Public schema fixture; all other network calls fail closed. +if [[ "$*" == *'/swagger.v1.json'* ]]; then + printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}' + exit 0 +fi +printf 'Unexpected network request in offline suite\n' >&2 +exit 97 diff --git a/packages/mosaic/framework/tools/git/test-detect-platform-outside-repo.sh b/packages/mosaic/framework/tools/git/test-detect-platform-outside-repo.sh index 13955f0e3..3ead9f59d 100755 --- a/packages/mosaic/framework/tools/git/test-detect-platform-outside-repo.sh +++ b/packages/mosaic/framework/tools/git/test-detect-platform-outside-repo.sh @@ -20,6 +20,10 @@ fail=0 HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +mkdir -p "$TMP/bin" +cp "$HERE/fixture-schema-client.sh" "$TMP/bin/curl" +export PATH="$TMP/bin:$PATH" + run_outside() { # $1=function name -> "rc:sawmessage" local fn="$1" out rc out=$( cd "$TMP" && bash -c "set -e; source '$HERE/detect-platform.sh'; $fn" 2>&1 ); rc=$? diff --git a/packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh b/packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh index 1759e09b2..8e9ff6bdc 100755 --- a/packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh +++ b/packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh @@ -82,6 +82,7 @@ fi exit 0 SH chmod +x "$BIN_DIR/tea" +cp "$SCRIPT_DIR/fixture-schema-client.sh" "$BIN_DIR/curl" ( cd "$REPO_DIR" diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-methods.sh b/packages/mosaic/framework/tools/git/test-pr-merge-methods.sh index b2254be9c..53b7a1ef6 100755 --- a/packages/mosaic/framework/tools/git/test-pr-merge-methods.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-methods.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -exec python3 -I "$SCRIPT_DIR/test-merge-policy.py" +exec python3 -I "$SCRIPT_DIR/test-merge-policy.py" diff --git a/packages/mosaic/framework/tools/git/test-pr-skill-contract.py b/packages/mosaic/framework/tools/git/test-pr-skill-contract.py index 1f28c75c3..f4a2e4752 100644 --- a/packages/mosaic/framework/tools/git/test-pr-skill-contract.py +++ b/packages/mosaic/framework/tools/git/test-pr-skill-contract.py @@ -1,4 +1,5 @@ import json +import os import pathlib import unittest ROOT=pathlib.Path(__file__).resolve().parents[2] @@ -9,11 +10,15 @@ class Skills(unittest.TestCase): p=SKILLS/name/'SKILL.md' self.assertTrue(p.is_file(),name+' not packaged') text=p.read_text() - for forbidden in ['git.uscllc.com','git.mosaicstack.dev','jason.woltje','/home/jason','code-infra-01','GraphQL quota']: + for forbidden in os.environ.get('MOSAIC_OPERATOR_FORBIDDEN_PATTERNS', '').splitlines(): self.assertNotIn(forbidden,text) if name in ['ms-full-send','ms-full-stop','ms-babysit-pr','ms-council']: self.assertTrue((p.parent/'LICENSE').is_file()) self.assertIn('t3dotgg/tstack',text) + def test_optional_firewall_patterns(self): + fixture = 'example operator placeholder' + self.assertIn('operator placeholder', fixture) + self.assertNotIn('operator placeholder', (SKILLS/'ms-full-stop/SKILL.md').read_text()) def test_stop_fails_closed_and_never_schedules(self): text=(SKILLS/'ms-full-stop/SKILL.md').read_text() self.assertIn('unsupported',text) diff --git a/packages/mosaic/framework/tools/git/test-pr-watch-parity.py b/packages/mosaic/framework/tools/git/test-pr-watch-parity.py index 90cfe3d5c..d49b78ef3 100644 --- a/packages/mosaic/framework/tools/git/test-pr-watch-parity.py +++ b/packages/mosaic/framework/tools/git/test-pr-watch-parity.py @@ -1,75 +1,169 @@ -"""Replay provider evidence through mocked T3 wakes and a bounded fallback owner. +"""Independent wake and notifier entry paths, with a literal verdict oracle. -This proves normalized decisions, not live notifier delivery or T3 forge support. +Provider delivery is simulated; both paths reread through real shell wrappers. +Every fake provider request is recorded and writes are rejected by fixtures. """ -import copy import importlib.util import pathlib +import tempfile import unittest -HERE=pathlib.Path(__file__).resolve().parent -spec=importlib.util.spec_from_file_location('contract_fixtures', HERE/'test-forge-contract.py') -fixtures=importlib.util.module_from_spec(spec); spec.loader.exec_module(fixtures) -fixtures.SnapshotContract.setUpClass() -api=fixtures.SnapshotContract.api -class Transport: - def __init__(self,mode,notifier=True): - self.mode,self.notifier=mode,notifier - self.active=0; self.max_active=0; self.merges=0 - def run(self,timeline,authority): - output=[] - for data,code in timeline: - if self.active: self.active=0 # wake retires the previous subscription - canonical=copy.deepcopy(data) - canonical.pop('observed_at',None) - verdict='timed_out' if code==124 else 'blocked' if code not in (0,1) else api.workflow_verdict(data,authority) - if verdict=='waiting': - if not self.notifier: - verdict='blocked' - else: - self.active+=1; self.max_active=max(self.max_active,self.active) - output.append((canonical,code,verdict)) - self.active=0 # handoff retires owned watch - return output +HERE = pathlib.Path(__file__).resolve().parent + + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +api = load('watch_api', HERE/'forge-api.py') +cli = load('watch_cli', HERE/'test-forge-cli.py') + + +class T3Wake: + def __init__(self, fixture): + self.fixture, self.calls, self.active = fixture, [], False + + def arm(self): + assert not self.active + self.calls.append('watch_pull_request') + self.active = True + + def wake(self, authority): + self.calls.append('wake') + self.active = False + process, envelope = self.fixture.run_wrapper('pr-status', '-n', '1') + self.calls.append('pr-status') + return api.workflow_verdict(envelope['data'], authority) if process.returncode == 0 else 'blocked' + + def retire(self): + self.calls.append('unwatch_pull_request') + self.active = False + + +class FallbackNotifier: + def __init__(self, fixture, available=True): + self.fixture, self.calls, self.active = fixture, [], False + self.available = available + + def arm(self, baseline): + assert not self.active + if not self.available: + self.calls.append('watch-delivery-blocker') + return 'blocked' + self.baseline = baseline + self.active = True + self.calls.append('notifier:arm') + + def wake(self, authority): + process, _ = self.fixture.run_wrapper('pr-watch-check', '-n', '1', '--baseline', str(self.baseline)) + self.calls.append(('predicate', process.returncode)) + if process.returncode not in (0, 1): + self.active = False + return 'blocked' + # A delivered notification always causes a fresh read; it is not proof. + process, envelope = self.fixture.run_wrapper('pr-status', '-n', '1') + self.calls.append('notifier:reread') + self.active = False + return api.workflow_verdict(envelope['data'], authority) if process.returncode == 0 else 'blocked' + + def retire(self): + self.calls.append('notifier:cancel') + self.active = False + class Parity(unittest.TestCase): - def snapshot(self,provider,version): - c=fixtures.SnapshotContract().fake() - c.provider,c.version=provider,version - if provider!='github': - pages=c.pages - c.pages=lambda path,key=None: [{'branch_name':'integration','enable_status_check':True,'status_check_contexts':['build'],'required_approvals':1}] if path.endswith('/branch_protections') else pages(path,key) - return api.snapshot(c,1,None) - def test_identical_verdicts_and_one_owner_for_all_profiles(self): - for provider,version in [('github','documented'),('gitea','28.2.0'),('gitea','1.24.7'),('forgejo','16.0.0-dev')]: - green=self.snapshot(provider,version) - pending=copy.deepcopy(green); pending['ready']=False; pending['checks']['state']='pending' - red=copy.deepcopy(pending); red['checks']['state']='failure' - cancelled=copy.deepcopy(pending); cancelled['checks']['state']='cancelled' - closed=copy.deepcopy(pending); closed['state']='closed' - merged=copy.deepcopy(pending); merged['state']='merged'; merged['merge_sha']='d'*40 - conflict=copy.deepcopy(pending); conflict['mergeability']='conflict' - moved=copy.deepcopy(pending); moved['head_sha']='b'*40; moved['base_sha']='e'*40 - comment=copy.deepcopy(pending); comment['comments']=[{'id':provider+':comment:9','body':'finding'}] - review=copy.deepcopy(pending); review['reviews']['decision']='changes_requested' - for final,rc in [(green,0),(red,0),(cancelled,0),(closed,0),(merged,0),(conflict,0),(moved,0),(comment,0),(review,0),(pending,5),(pending,75),(pending,124)]: - for mode in ['full-stop','full-send']: - a,b=Transport('T3'),Transport('fallback') - self.assertEqual(a.run([(pending,1),(final,rc)],mode),b.run([(pending,1),(final,rc)],mode)) - self.assertLessEqual(a.max_active,1); self.assertLessEqual(b.max_active,1) - self.assertEqual(a.active+b.active,0) - self.assertEqual(a.merges+b.merges,0) - if mode=='full-stop' and provider!='github' and final['state']=='open' and rc!=124: - self.assertEqual(a.run([(final,rc)],mode)[0][2],'blocked') - # Explicitly unsupported T3 transport selects the fallback owner; - # missing fallback notifier is blocked rather than a competing loop. - fallback=Transport('fallback',notifier=False) - self.assertEqual(fallback.run([(pending,1)],'full-send')[0][2],'blocked') - self.assertEqual(fallback.active,0) + def fixture(self, state='success', auto=None, queue=None, closed=False, conflict=False, rejected=False): + fixture = cli.CLI(); fixture.setUp(); self.addCleanup(fixture.tearDown) + root = '/repos/owner/repo' + pr = dict(number=1,state='closed' if closed else 'open',draft=False,merged=False,mergeable=not conflict,head={'sha':'a'*40,'ref':'task','repo':{'full_name':'fork/repo'}},base={'sha':'c'*40,'ref':'integration','repo':{'full_name':'owner/repo'}},user={'login':'author'}) + fixture.fixtures = { + 'GET '+root+'/pulls/1': {'data':pr}, + 'GET '+root+'/branches/integration/protection': {'data':{'required_status_checks':{'contexts':['build']},'required_pull_request_reviews':{'required_approving_review_count':1,'dismiss_stale_reviews':True}}}, + 'GET '+root+'/commits/'+'a'*40+'/statuses': {'data':[] if state=='missing' else [{'context':'build','state':state,'id':1}]}, + 'GET '+root+'/commits/'+'a'*40+'/check-runs': {'data':{'total_count':0,'check_runs':[]}}, + 'GET '+root+'/pulls/1/reviews': {'data':[{'id':1,'state':'CHANGES_REQUESTED' if rejected else 'APPROVED','user':{'login':'reviewer'},'commit_id':'a'*40}]}, + 'GET '+root+'/pulls/1/comments': {'data':[]}, + 'GET '+root+'/issues/1/comments': {'data':[]}, + 'POST graphql': {'data':{'data':{'repository':{'pullRequest':{'id':'PR','autoMergeRequest':auto,'mergeQueueEntry':queue,'reviewThreads':{'nodes':[],'pageInfo':{'hasNextPage':False,'endCursor':None}}}}}}}, + } + return fixture + + def test_independent_paths_match_expected_timeline_verdicts(self): + cases = [({},'ready_to_merge','ready_to_merge'),({'state':'pending'},'waiting','waiting'),({'state':'failure'},'blocked','blocked'),({'state':'cancelled'},'blocked','blocked'),({'state':'missing'},'blocked','blocked'),({'closed':True},'closed_unmerged','closed_unmerged'),({'conflict':True},'blocked','blocked'),({'rejected':True},'blocked','blocked'),({'auto':{'mergeMethod':'SQUASH'}},'blocked','ready_to_merge'),({'queue':{'id':'entry'}},'blocked','ready_to_merge'),({'auto':{}},'blocked','blocked')] + for options, stop_expected, send_expected in cases: + for authority, expected in [('full-stop',stop_expected),('full-send',send_expected)]: + with self.subTest(options=options,authority=authority): + fixture=self.fixture(**options) + # A valid pending baseline exists before the notifier is armed. + baseline_fixture=self.fixture(state='pending') + process, envelope=baseline_fixture.run_wrapper('pr-status','-n','1') + self.assertEqual(process.returncode,0) + baseline=fixture.root/'baseline.json' + import json + baseline.write_text(json.dumps(envelope)) + t3, fallback=T3Wake(fixture), FallbackNotifier(fixture) + t3.arm(); self.assertEqual(t3.wake(authority),expected); t3.retire() + fallback.arm(baseline); self.assertEqual(fallback.wake(authority),expected); fallback.retire() + self.assertFalse(t3.active or fallback.active) + self.assertEqual(t3.calls,['watch_pull_request','wake','pr-status','unwatch_pull_request']) + self.assertEqual(fallback.calls[0],'notifier:arm') + if options.get('auto') != {}: + self.assertIn('notifier:reread',fallback.calls) + for call in fixture.calls(): + self.assertTrue(call['method']=='GET' or call['path']=='graphql' and call['body']['query'].lstrip().startswith('query'), call) + + def test_profiles_use_same_independent_entry_paths(self): + for title, version in [('Gitea API','28.2.0'),('Gitea API','1.24.7'),('Forgejo API','16.0.0-dev')]: + fixture=self.fixture() + fixture.gitea(version,title) + root='/repos/owner/repo' + fixture.fixtures['GET '+root+'/branch_protections']={'data':[{'branch_name':'integration','enable_status_check':True,'status_check_contexts':['build'],'required_approvals':1}]} + fixture.fixtures['GET '+root+'/pulls/1/reviews/1/comments']={'data':[]} + original=fixture.run_wrapper + fixture.run_wrapper=lambda *a, **kw: original(*a,host='git.example.test',**kw) + process,envelope=fixture.run_wrapper('pr-status','-n','1') + self.assertEqual(process.returncode,0) + import json + baseline=fixture.root/'baseline.json'; baseline.write_text(json.dumps(envelope)) + for authority,expected in [('full-stop','blocked'),('full-send','ready_to_merge')]: + t3,fallback=T3Wake(fixture),FallbackNotifier(fixture) + t3.arm(); self.assertEqual(t3.wake(authority),expected); t3.retire() + fallback.arm(baseline); self.assertEqual(fallback.wake(authority),expected); fallback.retire() + self.assertFalse(t3.active or fallback.active) + self.assertTrue(all(c['method']=='GET' for c in fixture.calls())) + + def test_missing_notifier_cannot_arm(self): + fallback=FallbackNotifier(None,available=False) + self.assertEqual(fallback.arm(None),'blocked') + self.assertFalse(fallback.active) + self.assertEqual(fallback.calls,['watch-delivery-blocker']) + + def test_ready_and_stop_oracle(self): + ready=dict(state='open',ready=True,checks={'state':'success'},reviews={'decision':'approved'},auto_merge={'state':'disabled'},queue={'state':'absent'}) + self.assertEqual(api.workflow_verdict(ready,'full-stop'),'ready_to_merge') + ready['auto_merge']['state']='unsupported' + self.assertEqual(api.workflow_verdict(ready,'full-stop'),'blocked') + + def test_mutants_are_rejected_by_independent_oracle(self): + source=(HERE/'forge-api.py').read_text() + for mutant in [source.replace("return 'ready_to_merge'", "return 'waiting'"),source.replace("if mode == 'full-stop' and (", "if False and (")]: + with tempfile.TemporaryDirectory() as directory: + p=pathlib.Path(directory)/'api.py'; p.write_text(mutant) + module=load('mutant_api',p) + ready=dict(state='open',ready=True,checks={'state':'success'},reviews={'decision':'approved'},auto_merge={'state':'disabled'},queue={'state':'absent'}) + outcomes=[module.workflow_verdict(ready,'full-stop')] + ready['auto_merge']['state']='unsupported' + outcomes.append(module.workflow_verdict(ready,'full-stop')) + self.assertNotEqual(outcomes,['ready_to_merge','blocked']) + def test_predicate_never_calls_blocking_wait(self): source=(HERE/'forge-api.py').read_text() branch=source.split("if args.operation == 'watch-check':",1)[1].split(' except Failure',1)[0] self.assertNotIn('ci_wait(',branch) self.assertNotIn('sleep(',branch) + if __name__=='__main__': unittest.main() diff --git a/packages/mosaic/framework/tools/git/test-push-guard.sh b/packages/mosaic/framework/tools/git/test-push-guard.sh index 876408e76..c561188c6 100755 --- a/packages/mosaic/framework/tools/git/test-push-guard.sh +++ b/packages/mosaic/framework/tools/git/test-push-guard.sh @@ -14,7 +14,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" GUARD="$SCRIPT_DIR/push-guard.sh" -WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$SCRIPT_DIR/.work}" +WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$(mktemp -d)}" +trap 'rm -rf "${WORK_DIR:?}"' EXIT PASS=0 FAIL=0 -- 2.54.0 From 02c7f2b183e35156ee02856ab8ce93f2afaaef61 Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 15:25:38 -0600 Subject: [PATCH 08/26] test(framework): finish explicit credential fixture migration (#1841) Keep metadata fixture arms offline, bind explicit/default-port fixture credentials and avoid new shell diagnostics in isolated credential arms. Co-Authored-By: GPT-6.1-Sol (Codex) --- .../tools/git/test-ci-queue-wait-no-ci-expected.sh | 2 ++ .../tools/git/test-issue-create-interactive-auth.sh | 3 ++- .../framework/tools/git/test-issue-view-comments.sh | 3 ++- .../framework/tools/git/test-pr-metadata-gitea.sh | 11 +++++++++-- .../tools/git/test-pr-review-gitea-comment.sh | 2 +- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-ci-expected.sh b/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-ci-expected.sh index 767dd44a1..5b4528c91 100755 --- a/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-ci-expected.sh +++ b/packages/mosaic/framework/tools/git/test-ci-queue-wait-no-ci-expected.sh @@ -1,4 +1,6 @@ #!/usr/bin/env bash +# shellcheck disable=SC2030,SC2031 +# Each fixture subshell intentionally isolates its credential environment. # Regression harness for ci-queue-wait.sh's --no-ci-expected assertion: # the sanctioned merge path for a repository with no CI configured at all. # diff --git a/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh b/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh index 1858a2b6f..02623b8e7 100755 --- a/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh +++ b/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh @@ -89,7 +89,8 @@ esac SH chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl" export MOSAIC_GIT_IDENTITY=current-user -export GITEA_LOGIN="$(git -C "$REPO_DIR" remote get-url origin | python3 -c 'import sys,urllib.parse; print(urllib.parse.urlparse(sys.stdin.read().strip()).hostname.split(".")[1])')" +GITEA_LOGIN="$(git -C "$REPO_DIR" remote get-url origin | python3 -c 'import sys,urllib.parse; print(urllib.parse.urlparse(sys.stdin.read().strip()).hostname.split(".")[1])')" +export GITEA_LOGIN export MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" mkdir -p "$HOME_DIR/.config/mosaic" cp "$CREDENTIALS_FILE" "$HOME_DIR/.config/mosaic/credentials.json" diff --git a/packages/mosaic/framework/tools/git/test-issue-view-comments.sh b/packages/mosaic/framework/tools/git/test-issue-view-comments.sh index 202f43083..d9efb2de7 100755 --- a/packages/mosaic/framework/tools/git/test-issue-view-comments.sh +++ b/packages/mosaic/framework/tools/git/test-issue-view-comments.sh @@ -38,7 +38,8 @@ export GITEA_TOKEN="redacted-test-token" # The identity ladder must not reach for this seat's real login; the mock tea below # defines the only login that exists in this sandbox. export MOSAIC_GIT_IDENTITY=fixture-user -export GITEA_LOGIN="$(git -C "$REPO_DIR" remote get-url origin | python3 -c 'import sys,urllib.parse; print(urllib.parse.urlparse(sys.stdin.read().strip()).hostname.split(".")[1])')" +GITEA_LOGIN="$(git -C "$REPO_DIR" remote get-url origin | python3 -c 'import sys,urllib.parse; print(urllib.parse.urlparse(sys.stdin.read().strip()).hostname.split(".")[1])')" +export GITEA_LOGIN # No fleet in the sandbox: on a host that runs one, get_gitea_token fails closed for an # identity-less caller (by design), which would make this test measure the host, not # the wrapper. An empty brain home makes the sandbox the same on every host. diff --git a/packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh b/packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh index c31f6e4f0..93c0af205 100755 --- a/packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh +++ b/packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh @@ -72,6 +72,13 @@ cat > "$CREDENTIALS_FILE" <<'JSON' } JSON +export MOSAIC_GIT_IDENTITY=fixture-user +export MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" +mkdir -p "$HOME_DIR/.config/mosaic" +cp "$CREDENTIALS_FILE" "$HOME_DIR/.config/mosaic/credentials.json" +fixture_host=$(git -C "$REPO_DIR" remote get-url origin | python3 -c 'import sys,urllib.parse; print(urllib.parse.urlparse(sys.stdin.read().strip()).hostname)') +python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" "$fixture_host" stub-token + cat > "$FIXTURE_DIR/gitea-standard.json" <<'JSON' { "number": 1905, @@ -272,7 +279,7 @@ run_case() { local fixture="$1" expected_number="$2" expected_head="$3" local output output=$(cd "$REPO_DIR" && HOME="$HOME_DIR" MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ - MOSAIC_GITEA_PR_METADATA_RAW_FILE="$fixture" "$SCRIPT_DIR/pr-metadata.sh" -n "$expected_number") + PATH="$STUB_DIR:$PATH" MOSAIC_GITEA_PR_METADATA_RAW_FILE="$fixture" "$SCRIPT_DIR/pr-metadata.sh" -n "$expected_number") PR_METADATA_OUTPUT="$output" python3 - "$expected_number" "$expected_head" <<'PY' import json import os @@ -293,7 +300,7 @@ run_case "$FIXTURE_DIR/gitea-refs-pull-label.json" 1908 fix/t_23fa9e1d-portal-he run_curl_success_case run_curl_early_exit_cleanup_case -if cd "$REPO_DIR" && MOSAIC_GITEA_PR_METADATA_RAW_FILE="$FIXTURE_DIR/gitea-error.json" "$SCRIPT_DIR/pr-metadata.sh" -n 1909 >/dev/null 2>"$WORK_DIR/error.log"; then +if cd "$REPO_DIR" && PATH="$STUB_DIR:$PATH" MOSAIC_GITEA_PR_METADATA_RAW_FILE="$FIXTURE_DIR/gitea-error.json" "$SCRIPT_DIR/pr-metadata.sh" -n 1909 >/dev/null 2>"$WORK_DIR/error.log"; then echo "Expected API error fixture to fail" >&2 exit 1 fi diff --git a/packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh b/packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh index 079b36559..d29e6504f 100755 --- a/packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh +++ b/packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh @@ -562,7 +562,7 @@ run_review() { write_credentials "$configured_url" fixture_host=$(python3 -I -c 'import sys,urllib.parse; print(urllib.parse.urlsplit(sys.argv[1]).netloc)' "$configured_url") python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$HOME_DIR/.mosaic" "$ACTING_LOGIN" "$fixture_host" "$DEFAULT_TOKEN" - fixture_remote_host=$(cd "$REPO_DIR" && source "$SCRIPT_DIR/detect-platform.sh" && get_remote_host) + fixture_remote_host=$(python3 -I -c 'import sys,urllib.parse; print(urllib.parse.urlsplit(sys.argv[1]).netloc if sys.argv[1].startswith(("https://", "http://")) else sys.argv[2])' "$remote_url" "$fixture_host") python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$HOME_DIR/.mosaic" "$ACTING_LOGIN" "$fixture_remote_host" "$DEFAULT_TOKEN" : > "$TEA_LOG" : > "$CURL_LOG" -- 2.54.0 From 8325df70632336c8abc2eb97a7ce3ae61407dad0 Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 15:30:47 -0600 Subject: [PATCH 09/26] test(framework): isolate issue-close actor credentials (#1841) Replace the inherited shared-token fixture dependency with a synthetic actor and host-bound token in a private fixture home. Keep the comment-before-close negative and positive controls intact. Co-Authored-By: GPT-6.1-Sol (Codex) --- .../tools/git/test-issue-close-fail-closed.sh | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh b/packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh index 641acac78..d2e10a5b6 100755 --- a/packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh +++ b/packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh @@ -44,6 +44,11 @@ git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git || fail export PATH="$MOCK_BIN:$PATH" CALLS export GITEA_URL="https://git.mosaicstack.dev" export GITEA_TOKEN="redacted-test-token" +export HOME="$SANDBOX/home" +export MOSAIC_BRAIN_HOME="$HOME/.mosaic" +export MOSAIC_GIT_IDENTITY=fixture-user +mkdir -p "$HOME" +python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" "${GITEA_URL#https://}" "$GITEA_TOKEN" cat > "$MOCK_BIN/curl" <<'EOF' #!/bin/bash @@ -83,17 +88,8 @@ exit 0 EOF chmod +x "$MOCK_BIN/tea" } -# #1356: login resolution is now identity-aware, so the tea-branch fixture must -# offer the login the RUNNER's identity resolves to; otherwise every case below -# fails closed before reaching the branch under test. -# -# This does NOT make the suite hermetic, and it is not trying to. The API-path -# cases (5-7) need a usable Gitea token, and with an identity set the token path -# reads that seat's credential file rather than the GITEA_TOKEN exported above. -# So this suite passes only where the runner owns a real credential for its own -# identity, and fails with no identity at all -- on this branch and on its base -# alike. That is a pre-existing hole in the fixture, filed separately; pinning a -# synthetic identity here would only convert it into a confident-looking green. +# Match the synthetic actor in both tea and API fixture arms. No arm reads an +# operator credential; the API token is provisioned in this fixture's own HOME. _LOGIN_IDENT="${MOSAIC_GIT_IDENTITY:-}" LOGIN_JSON='[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]' if [[ -n "$_LOGIN_IDENT" ]]; then -- 2.54.0 From a193cf28458a7fc08f9ff4d6395526c5efd7918c Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 15:31:26 -0600 Subject: [PATCH 10/26] docs(framework): record forge review fixes and verification (#1841) Map review findings to fix commits and regressions; preserve offline sweep, targeted correction, mutant controls and baseline failures separately. Record deferred inherited literals and unverified live transport behavior. Co-Authored-By: GPT-6.1-Sol (Codex) --- docs/scratchpads/1841-forge-review-round-1.md | 81 ++++++ .../1841-forge-wrapper-evidence.json | 230 +++++++++++++++++- 2 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 docs/scratchpads/1841-forge-review-round-1.md diff --git a/docs/scratchpads/1841-forge-review-round-1.md b/docs/scratchpads/1841-forge-review-round-1.md new file mode 100644 index 000000000..41a684802 --- /dev/null +++ b/docs/scratchpads/1841-forge-review-round-1.md @@ -0,0 +1,81 @@ +# Forge wrapper review round 1 (#1841) + +Reviewed candidate: 55d6cc18e378306146505303c9b73da01164a3a9. +Original base: 924438016e2eb1c2ca352bfbf9107143b8b342bb. +Implementation checkpoint: 8325df706 (new commits, no history rewrite). + +The security and code findings are addressed by the following commits and controls. +All provider fixtures use synthetic credentials; no test writes to a live forge. + +| Finding | Fix commit | Regression evidence | +| --- | --- | --- | +| S1 | d93960111 | `Policy.test_head_and_uncommitted_policy_cannot_authorize_merge`: a committed head and uncommitted declaration cannot widen the pinned target policy; absent policy remains squash-only; unavailable target objects refuse. | +| S2 | d93960111 | `CLI.test_missing_identity_refuses_before_requests`, `test_mutation_verifies_actor`, `test_enterprise_auth_uses_explicit_host_store`: no inherited provider login authority. | +| S3 | d93960111 | `Auth.test_sigterm_leaves_no_credential_files`: anonymous descriptor delivery, no token in argv, no named credential file after termination. | +| S4 | d93960111 | `ReviewRegressions.test_malformed_integration_is_unknown`: empty, false, string and malformed objects refuse; only null means absence. | +| S5 | d93960111 | `ReviewRegressions.test_null_thread_is_not_resolved`: resolution must be boolean. | +| S6 | d93960111; fixture completion 02c7f2b18 and 8325df706 | `Auth.test_no_identity_cannot_borrow_shared_or_git_config`, `test_host_bound_credential_and_xtrace`, `test_provider_store_separates_seat_and_service`; explicit-actor fixture migration. | +| C1 | d93960111 | `ReviewRegressions.test_same_sha_retarget_snapshot_refused`, `test_retarget_merge_never_writes`: full head/base ref, SHA and repository identity stay pinned across policy, aggregation and pre-write/readback checks. | +| C2 | e62adbcc2 | `Parity.test_independent_paths_match_expected_timeline_verdicts`, `test_profiles_use_same_independent_entry_paths`, `test_ready_and_stop_oracle`, `test_missing_notifier_cannot_arm`: separate wake/status and notifier/predicate/reread paths, recorded calls, independent expected verdicts and no writes. | +| C3 | e62adbcc2, 02c7f2b18 | Offline schema fixtures in `test-detect-platform-outside-repo.sh`, `test-issue-create-body-safety.sh`, `test-pr-metadata-gitea.sh`; unexpected network clients denied in the isolated sweep. | +| C4 | e62adbcc2 | `test-pr-skill-contract.py`: optional operator-local forbidden patterns default to empty; generic placeholder control. | +| C5 | e62adbcc2 | `git diff --check 924438016e2eb1c2ca352bfbf9107143b8b342bb`: exit 0. | + +The policy adapter reads the declaration with `git show` from the provider's pinned +base commit in the origin-matched object store. It never consults the PR checkout's +policy or invokes its local declaration loader for mutation authority. The installed +validator still checks the target declaration. Missing target objects fail closed. + +GitHub uses the explicit host-bound credential transport rather than inherited CLI +sessions. Every authenticated adapter path requires an actor, and mutation transport +verifies the authenticated account first. The inherited credential resolver no longer +uses shared tokens or git-config identity. Anonymous pipes carry authorization while +stdin remains available for request JSON. Credential tracing is disabled in a subshell. + +The independent parity oracle was also run against two scratch-copy mutants. The +unmodified oracle exited 0; replacing ready with waiting exited 1; removing the +full-stop scheduled-integration guard exited 1. The mutation-control driver exited 0 +only after asserting those child results. Live T3 and notifier delivery remain unverified. + +## Verification + +The per-suite results are recorded under `review_round_1` in +[the evidence file](1841-forge-wrapper-evidence.json). Python suites all exit 0: +103 adapter contract cases, 10 CLI cases, 4 authentication cases, 4 guard cases, +6 policy cases, 5 skill cases and 6 parity cases (138 runner cases total). +The declaration validator passes 101 assertions; Ajv schema and negative controls +exit 0. Bash syntax checks pass for all 107 git shell files. Full ShellCheck exits 1 +on both the prior candidate (106 files) and the revised tree (107 files), with no +new file/code/message diagnostics. Corrected credential fixtures separately exit 0. + +Workspace preflight, typecheck, lint and format checks exit 0. The three package +skill/manifest Vitest suites pass 97 tests. No dependencies were installed during +this review round; the existing system ShellCheck binary was used. Generated +push-guard fixtures now use task scratch storage and clean up instead of leaving +malformed JSON beneath the source tree. + +The isolated shell sweep ran all 67 suites: 65 passed, the milestone baseline failed, +and the issue-close fixture still depended on a shared token. Commit 8325df706 +converted that final fixture to a private synthetic actor; its isolated rerun and +ShellCheck both exit 0. There are therefore 66 passing suites after the targeted +correction, with the original sweep codes and rerun preserved separately. + +The milestone-create usage suite still exits 1: missing title reaches the no-origin +error before the expected usage exit 2. The same isolated suite was run against the +pinned base and exits 1 there. This baseline failure is separate from the fixes. + +## Follow-up and limits + +S7 is intentionally excluded: inherited deployment-specific host/service literals +remain in `tools/git/detect-platform.sh` and `tools/git/README.md`. The eventual PR +body must list their cleanup under Follow-up, per the review scope decision. + +Qualified older native review mutations and unverifiable scheduled-integration +operations remain unsupported as previously documented. No server upgrade or +repository setting change was attempted. Live provider mutation, real integration +scheduling, CI delivery and notifier delivery are unverified. Independent review of +the new head, PR creation and merge remain separate gates. + +No additional local fixes were carried in this round. The original carried fix +remains Python import isolation in the shared detection/auth helper; no wholesale +operator-local tool directory was copied. diff --git a/docs/scratchpads/1841-forge-wrapper-evidence.json b/docs/scratchpads/1841-forge-wrapper-evidence.json index d91bfa9b5..0ca3d36cb 100644 --- a/docs/scratchpads/1841-forge-wrapper-evidence.json +++ b/docs/scratchpads/1841-forge-wrapper-evidence.json @@ -461,5 +461,233 @@ }, "carried_local_fixes": [ "Python import isolation in the shared detection/auth helper. No wholesale directory replacement." - ] + ], + "review_round_1": { + "reviewed_head": "55d6cc18e378306146505303c9b73da01164a3a9", + "implementation_head": "8325df70632336c8abc2eb97a7ce3ae61407dad0", + "fix_commits": [ + "d93960111", + "e62adbcc2", + "02c7f2b18", + "8325df706" + ], + "finding_commits": { + "S1": [ + "d93960111" + ], + "S2": [ + "d93960111" + ], + "S3": [ + "d93960111" + ], + "S4": [ + "d93960111" + ], + "S5": [ + "d93960111" + ], + "S6": [ + "d93960111", + "02c7f2b18", + "8325df706" + ], + "C1": [ + "d93960111" + ], + "C2": [ + "e62adbcc2" + ], + "C3": [ + "e62adbcc2", + "02c7f2b18" + ], + "C4": [ + "e62adbcc2" + ], + "C5": [ + "e62adbcc2" + ] + }, + "shell_sweep": { + "command": "bash tools/git/test-*.sh (each in an isolated scratch cwd/HOME with outer network clients denied)", + "runner_exit_code": 1, + "per_suite_exit_codes": { + "test-ci-queue-wait-branch-absent.sh": 0, + "test-ci-queue-wait-github-checks.sh": 0, + "test-ci-queue-wait-no-ci-expected.sh": 0, + "test-ci-queue-wait-no-status.sh": 0, + "test-ci-queue-wait-tristate.sh": 0, + "test-detect-platform-outside-repo.sh": 0, + "test-explain-diagnostic-status-neutral.sh": 0, + "test-forge-capabilities.sh": 0, + "test-forge-schema.sh": 0, + "test-git-credential-mosaic.sh": 0, + "test-gitea-login-resolution.sh": 0, + "test-gitea-token-identity.sh": 0, + "test-grant-reviewer.sh": 0, + "test-help-exit-code.sh": 0, + "test-issue-assign-usage-contract.sh": 0, + "test-issue-close-fail-closed.sh": 1, + "test-issue-close-usage-contract.sh": 0, + "test-issue-comment-readback.sh": 0, + "test-issue-comment-usage-contract.sh": 0, + "test-issue-create-body-safety.sh": 0, + "test-issue-create-interactive-auth.sh": 0, + "test-issue-create-usage-contract.sh": 0, + "test-issue-edit-usage-contract.sh": 0, + "test-issue-list-usage-contract.sh": 0, + "test-issue-reopen-usage-contract.sh": 0, + "test-issue-view-comments.sh": 0, + "test-issue-view-usage-contract.sh": 0, + "test-lane-brief-pr-linkage.sh": 0, + "test-lane-brief-usage-contract.sh": 0, + "test-milestone-close-usage-contract.sh": 0, + "test-milestone-create-usage-contract.sh": 1, + "test-milestone-list-usage-contract.sh": 0, + "test-mosaic-worktree-large-repo.sh": 0, + "test-mutate-push-guard.sh": 0, + "test-pr-auto-merge.sh": 0, + "test-pr-close-usage-contract.sh": 0, + "test-pr-create-fallback-default-base.sh": 0, + "test-pr-create-usage-contract.sh": 0, + "test-pr-diff-usage-contract.sh": 0, + "test-pr-edit-usage-contract.sh": 0, + "test-pr-edit.sh": 0, + "test-pr-list-usage-contract.sh": 0, + "test-pr-merge-baseline.sh": 0, + "test-pr-merge-fork-ci-status.sh": 0, + "test-pr-merge-gitea-empty-uid.sh": 0, + "test-pr-merge-head-pin.sh": 0, + "test-pr-merge-message-field.sh": 0, + "test-pr-merge-methods.sh": 0, + "test-pr-merge-no-ci-expected.sh": 0, + "test-pr-merge-queue-branch.sh": 0, + "test-pr-merge-queue.sh": 0, + "test-pr-metadata-gitea.sh": 0, + "test-pr-metadata-usage-contract.sh": 0, + "test-pr-review-comments.sh": 0, + "test-pr-review-gitea-comment.sh": 0, + "test-pr-review-reply.sh": 0, + "test-pr-review-repo-host-override.sh": 0, + "test-pr-review-resolve.sh": 0, + "test-pr-review-usage-contract.sh": 0, + "test-pr-status.sh": 0, + "test-pr-view-usage-contract.sh": 0, + "test-pr-watch-check.sh": 0, + "test-pr-watch-parity.sh": 0, + "test-push-guard.sh": 0, + "test-repo-decl-consumption.sh": 0, + "test-verify-clean-clone.sh": 0, + "test-wrapper-guard.sh": 0 + }, + "post_fix_reruns": { + "test-issue-close-fail-closed.sh": 0 + }, + "remaining_nonzero": { + "test-milestone-create-usage-contract.sh": 1 + } + }, + "python_suites": { + "test-forge-auth.py": { + "exit_code": 0, + "runner_cases": 4 + }, + "test-forge-cli.py": { + "exit_code": 0, + "runner_cases": 10 + }, + "test-forge-contract.py": { + "exit_code": 0, + "runner_cases": 103 + }, + "test-forge-guard.py": { + "exit_code": 0, + "runner_cases": 4 + }, + "test-merge-policy.py": { + "exit_code": 0, + "runner_cases": 6 + }, + "test-pr-skill-contract.py": { + "exit_code": 0, + "runner_cases": 5 + }, + "test-pr-watch-parity.py": { + "exit_code": 0, + "runner_cases": 6 + } + }, + "mutant_child_exits": { + "original": 0, + "wrong_ready": 1, + "removed_full_stop_guard": 1 + }, + "other_commands": { + "bash tools/structure/test-validate-repo-json.sh": { + "exit_code": 0, + "assertions": 101 + }, + "bash tools/git/test-forge-schema.sh": { + "exit_code": 0 + }, + "bash -n tools/git/*.sh": { + "exit_code": 0, + "files": 107 + }, + "shellcheck tools/git/*.sh": { + "current": { + "exit_code": 1, + "files": 107 + }, + "baseline": { + "exit_code": 1, + "files": 106 + }, + "new_diagnostics": [] + }, + "shellcheck corrected credential fixtures": { + "exit_code": 0 + }, + "shellcheck test-issue-close-fail-closed.sh": { + "exit_code": 0 + }, + "git diff --check 924438016e2eb1c2ca352bfbf9107143b8b342bb": { + "exit_code": 0 + }, + "pnpm preflight": { + "exit_code": 0 + }, + "pnpm typecheck": { + "exit_code": 0 + }, + "pnpm lint": { + "exit_code": 0 + }, + "pnpm format:check": { + "exit_code": 0 + }, + "pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/skill.spec.ts src/framework/manifest-parity.spec.ts src/framework/manifest.spec.ts": { + "exit_code": 0, + "files": 3, + "tests": 97 + }, + "pinned-base milestone usage suite": { + "exit_code": 1, + "base_sha": "924438016e2eb1c2ca352bfbf9107143b8b342bb" + } + }, + "follow_up": [ + "S7: inherited deployment-specific literals in detect-platform.sh and tools/git/README.md intentionally excluded; eventual PR body must list this follow-up.", + "Milestone missing-title usage ordering remains a reproduced baseline failure.", + "Existing full-tree ShellCheck diagnostics remain; no new diagnostics." + ], + "unverified": [ + "Live provider mutation and scheduler behavior", + "Live T3/notifier delivery", + "Independent review, PR, terminal-green CI and merge of the revised head" + ], + "dependencies_installed_this_round": [], + "additional_local_fixes_carried_this_round": [] + } } -- 2.54.0 From bec37b6fdfb116251d8f22222e471989f3685839 Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 16:01:05 -0600 Subject: [PATCH 11/26] fix(framework): bind provider URLs and reject curl errors (#1841) Validate complete public and enterprise API URLs in transport fixtures. Treat nonzero curl exits as transport failures for every provider, preserving unknown mutation outcomes. Co-Authored-By: GPT-6.1-Sol (Codex) --- .../mosaic/framework/tools/git/forge-api.py | 5 +- .../framework/tools/git/test-forge-cli.py | 80 +++++++++++++++---- .../tools/git/test-forge-contract.py | 20 +++++ 3 files changed, 88 insertions(+), 17 deletions(-) diff --git a/packages/mosaic/framework/tools/git/forge-api.py b/packages/mosaic/framework/tools/git/forge-api.py index 946e069e5..efbad0b6f 100644 --- a/packages/mosaic/framework/tools/git/forge-api.py +++ b/packages/mosaic/framework/tools/git/forge-api.py @@ -144,7 +144,8 @@ class Client: self.actor_verified = True prefix = '/api/v3' if self.provider == 'github' and self.host != 'github.com' else '' if self.provider == 'github' else '/api/v1' route = ('/graphql' if self.host == 'github.com' else '/api/graphql') if path == 'graphql' else prefix + path - url = 'https://' + self.host + (path if public else route) + authority = 'api.github.com' if self.provider == 'github' and self.host == 'github.com' and not public else self.host + url = 'https://' + authority + (path if public else route) args = ['curl', '--silent', '--show-error', '--include', '--max-time', '30', '--request', method, '--header', 'Accept: application/json', '--header', 'User-Agent: mosaic-forge-wrapper'] if body is not None: args += ['--header', 'Content-Type: application/json', '--data-binary', '@-'] @@ -169,7 +170,7 @@ class Client: os.close(write_fd) else: p = self.transport(args, body, writing) - if p.returncode and (self.provider != 'github' or not p.stdout.startswith('HTTP/')): + if p.returncode: raise Failure(75, 'transient', 'Provider transport failed; reconcile before retrying a mutation', outcome='unknown' if writing else 'none') raw = p.stdout.replace('\r\n', '\n') # Proxies can emit an initial CONNECT response. Only consume header blocks. diff --git a/packages/mosaic/framework/tools/git/test-forge-cli.py b/packages/mosaic/framework/tools/git/test-forge-cli.py index 5b0a46a2e..df22e901e 100644 --- a/packages/mosaic/framework/tools/git/test-forge-cli.py +++ b/packages/mosaic/framework/tools/git/test-forge-cli.py @@ -19,21 +19,18 @@ if '--config' in args: method='GET'; body=None if '--method' in args: method=args[args.index('--method')+1] if '--request' in args: method=args[args.index('--request')+1] -if '--url' in args: - url=args[args.index('--url')+1]; parsed=urllib.parse.urlparse(url); path=parsed.path - if path.startswith(('/api/v1','/api/v3')): path=path[7:] - if path in ('/graphql','/api/graphql'): path='graphql' - query=parsed.query -else: - path=args[args.index('--method')+2]; parsed=urllib.parse.urlparse(path); path=parsed.path; query=parsed.query +if '--url' not in args: + print('fake provider requires a full URL',file=sys.stderr); sys.exit(98) +url=args[args.index('--url')+1] +fixtures=json.loads(pathlib.Path(os.environ['FAKE_RESPONSES']).read_text()) +entry=fixtures.get(method+' '+url) +if entry is None: + print('unexpected provider URL '+url,file=sys.stderr); sys.exit(98) +response=entry['response']; path=entry['path'] +query=urllib.parse.urlsplit(url).query if '--input' in args or '--data-binary' in args: body=json.load(sys.stdin) -with open(os.environ['FAKE_LOG'],'a') as f: f.write(json.dumps({'method':method,'path':path,'query':query,'body':body})+'\\n') -fixtures=json.loads(pathlib.Path(os.environ['FAKE_RESPONSES']).read_text()) -key=method+' '+path+('?' + query if 'page=2' in query else '') -response=fixtures.get(key) -if response is None: - print('unexpected provider call '+key,file=sys.stderr); sys.exit(98) +with open(os.environ['FAKE_LOG'],'a') as f: f.write(json.dumps({'method':method,'url':url,'path':path,'query':query,'body':body})+'\\n') status=response.get('status',200) print('HTTP/2 '+str(status)) for k,v in response.get('headers',{}).items(): print(k+': '+str(v)) @@ -60,8 +57,24 @@ class CLI(unittest.TestCase): (token_dir/'fixture-user.token').write_text('fixture-token') self.fixtures={} def tearDown(self): self.tmp.cleanup() + def write_responses(self, host='github.com'): + # Test-owned endpoint table. Never derive expected URLs from the adapter. + github=host=='github.com' or host==self.env.get('GH_HOST') + rest=('https://api.github.com' if host=='github.com' else 'https://'+host+'/api/v3') if github else 'https://'+host+'/api/v1' + graphql='https://api.github.com/graphql' if host=='github.com' else 'https://'+host+'/api/graphql' + expected={} + for key,response in self.fixtures.items(): + method,path=key.split(' ',1) + url=graphql if path=='graphql' else 'https://'+host+path if path.startswith('/swagger.v1.json') else rest+path + entry={'path':path.split('?',1)[0],'response':response} + expected[method+' '+url]=entry + if '?' not in path and path!='graphql' and not path.startswith('/swagger.v1.json'): + pagination='per_page' if github else 'limit' + expected[method+' '+url+'?'+pagination+'=100&page=1']=entry + (self.root/'responses.json').write_text(json.dumps(expected)) + def run_wrapper(self,name,*args,host='github.com'): - (self.root/'responses.json').write_text(json.dumps(self.fixtures)) + self.write_responses(host) p=subprocess.run(['bash',str(HERE/(name+'.sh')),'--host',host,'--repo','owner/repo','--json',*args],cwd=self.root,env=self.env,capture_output=True,text=True) self.assertNotIn('fixture-token',p.stdout+p.stderr) self.assertEqual(list(self.root.glob('mosaic-forge-auth-*')),[], 'credential config leaked') @@ -81,7 +94,7 @@ class CLI(unittest.TestCase): self.fixtures['GET /user']={'data':{'login':'other-actor'}} # Trigger transport directly so a malformed PR cannot hide the actor gate. script = "import importlib.util; s=importlib.util.spec_from_file_location('a', %r); a=importlib.util.module_from_spec(s); s.loader.exec_module(a); c=a.Client('github.com','owner/repo'); c.provider='github'; c.http('/mutation','PUT',{})" % str(HERE/'forge-api.py') - (self.root/'responses.json').write_text(json.dumps(self.fixtures)) + self.write_responses() r=subprocess.run(['python3','-I','-c',script],env=self.env,capture_output=True,text=True) self.assertNotEqual(r.returncode,0) self.assertEqual([(c['method'],c['path']) for c in self.calls()],[('GET','/user')]) @@ -96,6 +109,43 @@ class CLI(unittest.TestCase): self.assertEqual(p.returncode,5) self.assertEqual(self.calls()[0]['path'],'/repos/owner/repo/pulls/1') + def test_public_and_enterprise_snapshot_use_exact_api_urls(self): + for host, rest, graphql in [ + ('github.com','https://api.github.com','https://api.github.com/graphql'), + ('enterprise.example.test','https://enterprise.example.test/api/v3','https://enterprise.example.test/api/graphql'), + ]: + with self.subTest(host=host): + if host!='github.com': + self.env['GH_HOST']=host + directory=self.root/'home/.mosaic/credentials/forge'/host + directory.mkdir(parents=True,exist_ok=True) + (directory/'fixture-user.token').write_text('fixture-token') + self.fixtures={ + 'GET /repos/owner/repo/pulls/1':{'data':dict(number=1,state='open',draft=False,merged=False,mergeable=True,head={'sha':'a'*40,'ref':'task','repo':{'full_name':'fork/repo'}},base={'sha':'b'*40,'ref':'integration','repo':{'full_name':'owner/repo'}},user={'login':'author'})}, + 'GET /repos/owner/repo/branches/integration/protection':{'data':{'required_status_checks':{'contexts':['build']},'required_pull_request_reviews':{'required_approving_review_count':1,'dismiss_stale_reviews':True}}}, + 'GET /repos/owner/repo/commits/'+'a'*40+'/statuses':{'data':[{'context':'build','state':'success','id':1}]}, + 'GET /repos/owner/repo/commits/'+'a'*40+'/check-runs':{'data':{'total_count':0,'check_runs':[]}}, + 'GET /repos/owner/repo/pulls/1/reviews':{'data':[{'id':1,'state':'APPROVED','commit_id':'a'*40,'user':{'login':'reviewer'}}]}, + 'GET /repos/owner/repo/pulls/1/comments':{'data':[]}, + 'GET /repos/owner/repo/issues/1/comments':{'data':[]}, + 'POST graphql':{'data':{'data':{'repository':{'pullRequest':{'id':'PR','autoMergeRequest':None,'mergeQueueEntry':None,'reviewThreads':{'nodes':[],'pageInfo':{'hasNextPage':False,'endCursor':None}}}}}}}, + } + log=self.root/'calls.jsonl' + if log.exists(): log.unlink() + p,value=self.run_wrapper('pr-status','-n','1',host=host) + self.assertEqual(p.returncode,0,p.stderr) + self.assertTrue(value['data']['ready']) + self.assertEqual(self.calls()[0]['url'],rest+'/repos/owner/repo/pulls/1') + self.assertIn(graphql,[c['url'] for c in self.calls()]) + self.assertTrue(all(c['url'].startswith(rest+'/repos/') or c['url']==graphql for c in self.calls())) + + def test_fake_rejects_unexpected_full_urls(self): + self.fixtures={'GET /repos/owner/repo':{'data':{}}} + self.write_responses() + for url in ['https://github.com/repos/owner/repo','http://api.github.com/repos/owner/repo','https://other.example.test/repos/owner/repo','https://api.github.com/wrong','https://api.github.com/repos/owner/repo?unexpected=1']: + p=subprocess.run([str(self.root/'curl'),'--request','GET','--url',url],env=self.env,capture_output=True,text=True) + self.assertEqual(p.returncode,98,url) + def test_profiles_and_unknown_host_fail_closed(self): for version,title,provider in [('28.2.0','Gitea API','gitea'),('1.24.7','Gitea API','gitea'),('16.0.0-dev','Forgejo API','forgejo')]: self.gitea(version,title) diff --git a/packages/mosaic/framework/tools/git/test-forge-contract.py b/packages/mosaic/framework/tools/git/test-forge-contract.py index 7d38c98ce..cb4147489 100644 --- a/packages/mosaic/framework/tools/git/test-forge-contract.py +++ b/packages/mosaic/framework/tools/git/test-forge-contract.py @@ -137,6 +137,26 @@ class TransportContract(Contract): self.api.run = original actor_patch.stop() + def test_curl_errors_never_prove_reads_or_mutations(self): + import types + import os + from unittest.mock import patch + for provider,host in [('github','github.com'),('github','enterprise.example.test'),('gitea','git.example.test'),('forgejo','forge.example.test')]: + for code in [0,28,56]: + for method in ['GET','PUT']: + with self.subTest(provider=provider,host=host,code=code,method=method): + c=self.api.Client(host,'owner/repo'); c.provider=provider + c.token='synthetic'; c.actor_verified=True + c.transport=lambda *a: types.SimpleNamespace(returncode=code,stdout='HTTP/2 200\n\n{"ok":true}') + with patch.dict(os.environ,MOSAIC_GIT_IDENTITY='fixture-user'): + if code==0: + self.assertEqual(c.http('/repos/owner/repo',method),{'ok':True}) + else: + with self.assertRaises(self.api.Failure) as caught: + c.http('/repos/owner/repo',method) + self.assertEqual(caught.exception.code,75) + self.assertEqual(caught.exception.outcome,'unknown' if method=='PUT' else 'none') + def test_pagination_failure_is_not_empty_success(self): c = self.api.Client('github.com', 'owner/repo') c.provider = 'github' -- 2.54.0 From fc7b9d61b3a7cd3632a1fd778df7826d0863c054 Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 16:03:00 -0600 Subject: [PATCH 12/26] fix(framework): keep shell credentials inside private workflows (#1841) Disable tracing before caller-side token capture and retain privacy through the entire workflow. Use anonymous curl config descriptors, keep request stdin available, and cover all token-consuming wrappers with trace and argv controls. Preserve signal handling and stateful readback fixtures. Co-Authored-By: GPT-6.1-Sol (Codex) --- .../framework/tools/git/ci-queue-wait.sh | 17 ++- .../framework/tools/git/detect-platform.sh | 35 ++--- .../framework/tools/git/grant-reviewer.sh | 19 +-- .../mosaic/framework/tools/git/issue-close.sh | 15 +- .../framework/tools/git/issue-comment.sh | 36 +++-- .../framework/tools/git/issue-create.sh | 12 +- .../framework/tools/git/issue-reopen.sh | 15 +- .../mosaic/framework/tools/git/issue-view.sh | 19 ++- .../mosaic/framework/tools/git/pr-create.sh | 19 ++- .../mosaic/framework/tools/git/pr-diff.sh | 11 +- .../mosaic/framework/tools/git/pr-edit.sh | 30 ++-- .../mosaic/framework/tools/git/pr-merge.sh | 14 ++ .../mosaic/framework/tools/git/pr-metadata.sh | 43 +++--- .../mosaic/framework/tools/git/pr-review.sh | 85 ++++------- .../framework/tools/git/test-forge-auth.py | 132 ++++++++++++++++++ .../tools/git/test-gitea-login-resolution.sh | 13 +- .../tools/git/test-grant-reviewer.sh | 2 +- .../tools/git/test-issue-comment-readback.sh | 2 +- .../tools/git/test-pr-review-gitea-comment.sh | 2 +- 19 files changed, 355 insertions(+), 166 deletions(-) diff --git a/packages/mosaic/framework/tools/git/ci-queue-wait.sh b/packages/mosaic/framework/tools/git/ci-queue-wait.sh index 93ff4aae9..f79a8a9c5 100755 --- a/packages/mosaic/framework/tools/git/ci-queue-wait.sh +++ b/packages/mosaic/framework/tools/git/ci-queue-wait.sh @@ -7,6 +7,14 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/detect-platform.sh" +# Credential resolution and all consumers execute inside this private boundary. +# Never restore xtrace while credentials remain in scope. +# All workflow state belongs to this one private subprocess. +# shellcheck disable=SC2030,SC2031 +mosaic_private_forge_main() ( + set +x + + BRANCH="" TARGET_REPO="" HEAD_SHA="" @@ -343,7 +351,7 @@ gitea_get_branch_head_sha() { # error. A not-yet-pushed feature branch has no in-flight pipeline, so the # pre-push queue guard must treat 404 as "queue clear", not crash. local resp code body - resp=$(curl -sS -H "User-Agent: curl/8" -H "Authorization: token ${token}" -w $'\n%{http_code}' "$url") + resp=$(gitea_curl_with_token "$token" -sS -H "User-Agent: curl/8" -w $'\n%{http_code}' "$url") code="${resp##*$'\n'}" body="${resp%$'\n'*}" if [[ "$code" == "404" ]]; then @@ -367,7 +375,7 @@ gitea_get_commit_status_json() { local sha="$3" local token="$4" local url="https://${host}/api/v1/repos/${repo}/commits/${sha}/status" - curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" + gitea_curl_with_token "$token" -fsSL -H "User-Agent: curl/8" "$url" } # Repository-admin state for the acting credential, Gitea flavor. The guard's @@ -382,7 +390,7 @@ gitea_repo_admin_state() { local token="$3" local url="https://${host}/api/v1/repos/${repo}" local resp code body - resp=$(curl -sS -H "User-Agent: curl/8" -H "Authorization: token ${token}" -w $'\n%{http_code}' "$url") || return 2 + resp=$(gitea_curl_with_token "$token" -sS -H "User-Agent: curl/8" -w $'\n%{http_code}' "$url") || return 2 code="${resp##*$'\n'}" body="${resp%$'\n'*}" if [[ "$code" != "200" ]]; then @@ -682,3 +690,6 @@ while true; do ;; esac done + +) +mosaic_private_forge_main "$@" diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index b7ee7784b..d51c4a3d2 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -688,28 +688,23 @@ get_gitea_token() ( cat "$_idtok" ) -# Stage the Gitea bearer credential for curl OUTSIDE the process argument vector. -# Passing "-H 'Authorization: token '" on the curl command line exposes -# the token to anyone who can read the process table (ps / /proc//cmdline) -# for the lifetime of the request. Instead, write the header into a private -# (mode 0600) curl config file and have callers pass it with `curl --config`, so -# only the FILE PATH — never the token — appears in argv. Prints the temp file -# path on success; the caller OWNS the file and MUST remove it on every exit -# path (success and failure). $1 = bearer token. Callers must not log the token. -gitea_write_auth_config() { - local token="$1" auth_file - auth_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-gitea-auth.XXXXXX") || return 1 - # mktemp already creates the file with 0600; be explicit in case of an - # unusual umask so the credential is never briefly group/other readable. - chmod 600 "$auth_file" 2>/dev/null || true - # `header = "..."` is curl's config syntax for an extra request header. Only - # this filename reaches curl's argv; the token stays on disk, readable solely - # by this user, and is unlinked by the caller's trap after the request. - if ! printf 'header = "Authorization: token %s"\n' "$token" > "$auth_file"; then - rm -f "$auth_file" +# Consume a credential inside an untraced shell function. Only an anonymous +# config descriptor reaches the curl process; stdin remains the request body. +# Callers must keep credential resolution and this call in their private boundary. +gitea_curl_with_token() { + set +x + local token="$1" escaped auth_fd request_rc=0 + shift + if [[ -z "$token" || ${#token} -gt 4096 || "$token" == *[[:cntrl:]]* ]]; then + echo 'Error: invalid authentication credential.' >&2 return 1 fi - printf '%s' "$auth_file" + escaped="${token//\\/\\\\}" + escaped="${escaped//\"/\\\"}" + exec {auth_fd}< <(printf 'header = "Authorization: token %s"\n' "$escaped") + curl --config "/dev/fd/$auth_fd" "$@" || request_rc=$? + exec {auth_fd}<&- + return "$request_rc" } # Resolve the API token for a SPECIFIC tea login name from tea's own config diff --git a/packages/mosaic/framework/tools/git/grant-reviewer.sh b/packages/mosaic/framework/tools/git/grant-reviewer.sh index 81995578b..3462791d8 100755 --- a/packages/mosaic/framework/tools/git/grant-reviewer.sh +++ b/packages/mosaic/framework/tools/git/grant-reviewer.sh @@ -48,6 +48,12 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/detect-platform.sh" +# Credential resolution and all consumers execute inside this private boundary. +# Never restore xtrace while credentials remain in scope. +mosaic_private_forge_main() ( + set +x + + usage() { echo "Usage: grant-reviewer.sh -u [-r /] [-t ]" echo "" @@ -170,12 +176,7 @@ ORG="${REPO_SLUG%%/*}" REPO_NAME="${REPO_SLUG#*/}" RESPONSE_FILE=$(mktemp "${TMPDIR:-/tmp}/mosaic-grant-reviewer-resp.XXXXXX") -AUTH_CONFIG=$(gitea_write_auth_config "$GITEA_API_TOKEN") || { - rm -f "$RESPONSE_FILE" - echo "Error: could not stage Gitea credential for reviewer grant" >&2 - exit 1 -} -trap 'rm -f "$RESPONSE_FILE" "$AUTH_CONFIG"' EXIT +trap 'rm -f "$RESPONSE_FILE"' EXIT # gitea_api [json-payload] # Runs one API call with the staged credential (token never in argv). Sets @@ -189,9 +190,8 @@ gitea_api() { if [[ -n "$payload" ]]; then payload_args=(-H 'Content-Type: application/json' -d "$payload") fi - if ! GITEA_API_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' \ + if ! GITEA_API_STATUS=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$RESPONSE_FILE" -w '%{http_code}' \ -X "$method" \ - --config "$AUTH_CONFIG" \ "${payload_args[@]}" \ "$GITEA_API_ROOT$path"); then echo "Error: Gitea transport failed during $step" >&2 @@ -341,3 +341,6 @@ fi echo "Granted: '$REVIEWER' is a member of team '$TEAM' (id $TEAM_ID) with access to '$REPO_SLUG' (code read, issues/pulls write) — verified by read-back" echo "Note: under branch protection with required approvals this reviewer's approvals are official=false unless the branch's approvals whitelist includes the team (operator decision; reviews submitted before whitelisting must be re-submitted)." + +) +mosaic_private_forge_main "$@" diff --git a/packages/mosaic/framework/tools/git/issue-close.sh b/packages/mosaic/framework/tools/git/issue-close.sh index 4bafa2af2..7fc8a28a0 100755 --- a/packages/mosaic/framework/tools/git/issue-close.sh +++ b/packages/mosaic/framework/tools/git/issue-close.sh @@ -9,6 +9,12 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/detect-platform.sh" +# Credential resolution and all consumers execute inside this private boundary. +# Never restore xtrace while credentials remain in scope. +mosaic_private_forge_main() ( + set +x + + # Parse arguments ISSUE_NUMBER="" COMMENT="" @@ -96,9 +102,8 @@ import os print(json.dumps({"body": os.environ["COMMENT"]})) PY ) - curl -fsS -X POST \ + gitea_curl_with_token "$token" -fsS -X POST \ -H "User-Agent: curl/8" \ - -H "Authorization: token ${token}" \ -H "Content-Type: application/json" \ -d "$payload" \ "$url" >/dev/null @@ -109,9 +114,8 @@ gitea_issue_close_api() { host=$(get_remote_host) || return 1 token=$(get_gitea_token "$host") || return 1 url="https://${host}/api/v1/repos/${OWNER}/${REPO}/issues/${ISSUE_NUMBER}" - curl -fsS -X PATCH \ + gitea_curl_with_token "$token" -fsS -X PATCH \ -H "User-Agent: curl/8" \ - -H "Authorization: token ${token}" \ -H "Content-Type: application/json" \ -d '{"state":"closed"}' \ "$url" >/dev/null @@ -175,3 +179,6 @@ else echo "Error: Unknown platform" exit 1 fi + +) +mosaic_private_forge_main "$@" diff --git a/packages/mosaic/framework/tools/git/issue-comment.sh b/packages/mosaic/framework/tools/git/issue-comment.sh index 08fe963d5..727b672ec 100755 --- a/packages/mosaic/framework/tools/git/issue-comment.sh +++ b/packages/mosaic/framework/tools/git/issue-comment.sh @@ -28,6 +28,12 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/detect-platform.sh" +# Credential resolution and all consumers execute inside this private boundary. +# Never restore xtrace while credentials remain in scope. +mosaic_private_forge_main() ( + set +x + + # Parse arguments ISSUE_NUMBER="" COMMENT="" @@ -184,18 +190,12 @@ gitea_resolve_api_for_login() { # a concurrent write from a DIFFERENT identity cannot satisfy verification. # Prints the login on success. gitea_authenticated_login() { - local response_file auth_config status + local response_file 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 + trap 'rm -f "$response_file"' RETURN - if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \ - --config "$auth_config" \ + if ! status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$response_file" -w '%{http_code}' \ "$GITEA_API_ROOT/user"); then echo "Error: Gitea authenticated-identity read transport failed" >&2 return 1 @@ -237,7 +237,7 @@ PY # 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 + local payload write_file readback_file write_status readback_status created_id payload=$(COMMENT_BODY="$comment_body" python3 -c ' import json @@ -247,16 +247,10 @@ 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 + trap 'rm -f "$write_file" "$readback_file"' RETURN - if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \ + if ! write_status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -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 @@ -285,8 +279,7 @@ print(created_id) PY ) || return 1 - if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \ - --config "$auth_config" \ + if ! readback_status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$readback_file" -w '%{http_code}' \ "$GITEA_API_BASE/issues/comments/$created_id"); then echo "Error: Gitea comment read-back transport failed" >&2 return 1 @@ -421,3 +414,6 @@ else echo "Error: Unknown platform" exit 1 fi + +) +mosaic_private_forge_main "$@" diff --git a/packages/mosaic/framework/tools/git/issue-create.sh b/packages/mosaic/framework/tools/git/issue-create.sh index b93292a51..99cb2889e 100755 --- a/packages/mosaic/framework/tools/git/issue-create.sh +++ b/packages/mosaic/framework/tools/git/issue-create.sh @@ -7,6 +7,12 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/detect-platform.sh" +# Credential resolution and all consumers execute inside this private boundary. +# Never restore xtrace while credentials remain in scope. +mosaic_private_forge_main() ( + set +x + + # Default values TITLE="" BODY="" @@ -49,9 +55,8 @@ PY ) url="https://${host}/api/v1/repos/${repo}/issues" - curl -fsS -X POST \ + gitea_curl_with_token "$token" -fsS -X POST \ -H "User-Agent: curl/8" \ - -H "Authorization: token ${token}" \ -H "Content-Type: application/json" \ -d "$payload" \ "$url" @@ -200,3 +205,6 @@ case "$PLATFORM" in exit 1 ;; esac + +) +mosaic_private_forge_main "$@" diff --git a/packages/mosaic/framework/tools/git/issue-reopen.sh b/packages/mosaic/framework/tools/git/issue-reopen.sh index 9f0725892..c51fe38f6 100755 --- a/packages/mosaic/framework/tools/git/issue-reopen.sh +++ b/packages/mosaic/framework/tools/git/issue-reopen.sh @@ -8,6 +8,12 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/detect-platform.sh" +# Credential resolution and all consumers execute inside this private boundary. +# Never restore xtrace while credentials remain in scope. +mosaic_private_forge_main() ( + set +x + + # Parse arguments ISSUE_NUMBER="" COMMENT="" @@ -94,9 +100,8 @@ import os print(json.dumps({"body": os.environ["COMMENT"]})) PY ) - curl -fsS -X POST \ + gitea_curl_with_token "$token" -fsS -X POST \ -H "User-Agent: curl/8" \ - -H "Authorization: token ${token}" \ -H "Content-Type: application/json" \ -d "$payload" \ "$url" >/dev/null @@ -107,9 +112,8 @@ gitea_issue_reopen_api() { host=$(get_remote_host) || return 1 token=$(get_gitea_token "$host") || return 1 url="https://${host}/api/v1/repos/${OWNER}/${REPO}/issues/${ISSUE_NUMBER}" - curl -fsS -X PATCH \ + gitea_curl_with_token "$token" -fsS -X PATCH \ -H "User-Agent: curl/8" \ - -H "Authorization: token ${token}" \ -H "Content-Type: application/json" \ -d '{"state":"open"}' \ "$url" >/dev/null @@ -156,3 +160,6 @@ else echo "Error: Unknown platform" exit 1 fi + +) +mosaic_private_forge_main "$@" diff --git a/packages/mosaic/framework/tools/git/issue-view.sh b/packages/mosaic/framework/tools/git/issue-view.sh index 2c412ac56..efb8b1eba 100755 --- a/packages/mosaic/framework/tools/git/issue-view.sh +++ b/packages/mosaic/framework/tools/git/issue-view.sh @@ -7,6 +7,12 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/detect-platform.sh" +# Credential resolution and all consumers execute inside this private boundary. +# Never restore xtrace while credentials remain in scope. +mosaic_private_forge_main() ( + set +x + + # Parse arguments # Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2, # distinct from provider, credential, and verification failures (exit 1). @@ -36,12 +42,12 @@ gitea_issue_view_api() { } url="https://${host}/api/v1/repos/${repo}/issues/${ISSUE_NUMBER}" - local -a curl_args=(-fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}") + local -a curl_args=(-fsS -H "User-Agent: curl/8") if ! command -v python3 >/dev/null 2>&1; then # No renderer: raw JSON is all this path can give. Comments are a # second resource, so fetch them too rather than only the count. - curl "${curl_args[@]}" "$url" - curl "${curl_args[@]}" "${url}/comments" + gitea_curl_with_token "$token" "${curl_args[@]}" "$url" + gitea_curl_with_token "$token" "${curl_args[@]}" "${url}/comments" return fi # Render issue + comments as text (#1357 F2). The old fallback dumped the @@ -49,10 +55,10 @@ gitea_issue_view_api() { # invisible on this path and the wrapper could never show what # `tea issues --comments` shows. { - curl "${curl_args[@]}" "$url" + gitea_curl_with_token "$token" "${curl_args[@]}" "$url" echo echo "__MOSAIC_COMMENTS__" - curl "${curl_args[@]}" "${url}/comments" + gitea_curl_with_token "$token" "${curl_args[@]}" "${url}/comments" } | python3 -c ' import json, sys raw = sys.stdin.read() @@ -145,3 +151,6 @@ else echo "Error: Unknown platform" exit 1 fi + +) +mosaic_private_forge_main "$@" diff --git a/packages/mosaic/framework/tools/git/pr-create.sh b/packages/mosaic/framework/tools/git/pr-create.sh index 1bd526a4c..460e2c2fa 100755 --- a/packages/mosaic/framework/tools/git/pr-create.sh +++ b/packages/mosaic/framework/tools/git/pr-create.sh @@ -7,6 +7,12 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/detect-platform.sh" +# Credential resolution and all consumers execute inside this private boundary. +# Never restore xtrace while credentials remain in scope. +mosaic_private_forge_main() ( + set +x + + # Default values TITLE="" BODY="" @@ -33,9 +39,8 @@ gitea_default_branch() { # `curl | python` reports only python's status, so an HTTP failure that # still emits parseable JSON would masquerade as success. curl's own # exit status is authoritative here. - if ! body=$(curl -fsS \ + if ! body=$(gitea_curl_with_token "$token" -fsS \ -H "User-Agent: curl/8" \ - -H "Authorization: token ${token}" \ "$url" 2>/dev/null); then return 1 fi @@ -105,9 +110,8 @@ PY ) url="https://${host}/api/v1/repos/${repo}/pulls" - curl -fsS -X POST \ + gitea_curl_with_token "$token" -fsS -X POST \ -H "User-Agent: curl/8" \ - -H "Authorization: token ${token}" \ -H "Content-Type: application/json" \ -d "$payload" \ "$url" @@ -243,9 +247,13 @@ if [ -f "$SCRIPT_DIR/repo-decl.sh" ]; then repo_decl_load else DECL_STATE=absent; DECL_SCHEMA="" + # Compatibility callbacks are invoked by declaration checks below. + # shellcheck disable=SC2317 repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; } repo_decl_report_invalid() { :; } + # shellcheck disable=SC2317 repo_decl_warn_absent_reversible() { :; } + # shellcheck disable=SC2317 repo_decl_warn_absent_irreversible() { :; } repo_decl_remote_matches() { return 0; } repo_decl_check_transition() { return 2; } @@ -358,3 +366,6 @@ case "$PLATFORM" in exit 1 ;; esac + +) +mosaic_private_forge_main "$@" diff --git a/packages/mosaic/framework/tools/git/pr-diff.sh b/packages/mosaic/framework/tools/git/pr-diff.sh index a09d1f782..8096c194e 100755 --- a/packages/mosaic/framework/tools/git/pr-diff.sh +++ b/packages/mosaic/framework/tools/git/pr-diff.sh @@ -7,6 +7,12 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/detect-platform.sh" +# Credential resolution and all consumers execute inside this private boundary. +# Never restore xtrace while credentials remain in scope. +mosaic_private_forge_main() ( + set +x + + # Parse arguments PR_NUMBER="" OUTPUT_FILE="" @@ -106,7 +112,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then GITEA_API_TOKEN=$(get_gitea_token "$HOST" || true) if [[ -n "$GITEA_API_TOKEN" ]]; then - DIFF_CONTENT=$(curl -sS -H "User-Agent: curl/8" -H "Authorization: token $GITEA_API_TOKEN" "$DIFF_URL") + DIFF_CONTENT=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -H "User-Agent: curl/8" "$DIFF_URL") else DIFF_CONTENT=$(curl -sS -H "User-Agent: curl/8" "$DIFF_URL") fi @@ -120,3 +126,6 @@ else echo "Error: Unknown platform" >&2 exit 1 fi + +) +mosaic_private_forge_main "$@" diff --git a/packages/mosaic/framework/tools/git/pr-edit.sh b/packages/mosaic/framework/tools/git/pr-edit.sh index ad05aac07..c4762045b 100755 --- a/packages/mosaic/framework/tools/git/pr-edit.sh +++ b/packages/mosaic/framework/tools/git/pr-edit.sh @@ -8,6 +8,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=detect-platform.sh source "$SCRIPT_DIR/detect-platform.sh" +# Credential resolution and all consumers execute inside this private boundary. +# Never restore xtrace while credentials remain in scope. +mosaic_private_forge_main() ( + set +x + + PR_NUMBER="" TITLE="" BODY="" @@ -17,16 +23,16 @@ DRAFT_MODE="" LOGIN_OVERRIDE="" REPO_OVERRIDE="" HOST_OVERRIDE="" -AUTH_CONFIG="" -cleanup() { - [[ -z "$AUTH_CONFIG" ]] || rm -f -- "$AUTH_CONFIG" -} +# Signal and EXIT traps invoke these callbacks. +# shellcheck disable=SC2317 +cleanup() { :; } +# shellcheck disable=SC2317 terminate() { local signal="$1" trap - "$signal" cleanup - kill -s "$signal" "$$" + kill -s "$signal" "$BASHPID" } trap cleanup EXIT trap 'terminate HUP' HUP @@ -166,18 +172,13 @@ case "$PLATFORM" in echo "Error: login '$GITEA_LOGIN_NAME' is not configured for target host '$HOST'" >&2 exit 1 } - AUTH_CONFIG=$(gitea_write_auth_config "$TOKEN") || { - echo "Error: could not stage private Gitea authentication" >&2 - exit 1 - } - unset TOKEN API_BASE="https://${HOST}/api/v1" # Resolve identity through the SAME private curl config used for the # mutation. Tea login names are globally scoped and can be duplicated # across hosts; a separate `tea api --login NAME` could validate another # credential than this host-bound token. - AUTHENTICATED_USER=$(curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$API_BASE/user" \ + AUTHENTICATED_USER=$(gitea_curl_with_token "$TOKEN" -fsS -H "User-Agent: mosaic-pr-edit" "$API_BASE/user" \ | python3 -c 'import json,sys; value=json.load(sys.stdin).get("login"); print(value) if isinstance(value,str) and value else sys.exit(1)') || { echo "Error: could not authenticate the host-bound credential for '$GITEA_LOGIN_NAME'" >&2 exit 1 @@ -188,7 +189,7 @@ case "$PLATFORM" in } REPO_API="$API_BASE/repos/${REPO_SLUG}" - curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$REPO_API" >/dev/null || { + gitea_curl_with_token "$TOKEN" -fsS -H "User-Agent: mosaic-pr-edit" "$REPO_API" >/dev/null || { echo "Error: target repository preflight failed for https://${HOST}/${REPO_SLUG}" >&2 exit 1 } @@ -204,10 +205,13 @@ if os.environ["DRAFT_MODE"]: payload["draft"] = os.environ["DRAFT_MODE"] == "dra print(json.dumps(payload)) PY ) - curl -fsS --config "$AUTH_CONFIG" -X PATCH \ + gitea_curl_with_token "$TOKEN" -fsS -X PATCH \ -H "User-Agent: mosaic-pr-edit" -H "Content-Type: application/json" \ -d "$PAYLOAD" "$REPO_API/pulls/${PR_NUMBER}" echo "Updated Gitea pull request #$PR_NUMBER as '$AUTHENTICATED_USER'" >&2 ;; *) echo "Error: Could not detect git platform" >&2; exit 1 ;; esac + +) +mosaic_private_forge_main "$@" diff --git a/packages/mosaic/framework/tools/git/pr-merge.sh b/packages/mosaic/framework/tools/git/pr-merge.sh index 5b4be3343..19c2c8a72 100755 --- a/packages/mosaic/framework/tools/git/pr-merge.sh +++ b/packages/mosaic/framework/tools/git/pr-merge.sh @@ -8,6 +8,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh source "$SCRIPT_DIR/detect-platform.sh" +# Credential resolution and all consumers execute inside this private boundary. +# Never restore xtrace while credentials remain in scope. +mosaic_private_forge_main() ( + set +x + + # Versioned JSON mode and explicit non-squash methods use the normalized adapter. for ((arg_i=1; arg_i<=$#; arg_i++)); do if [[ "${!arg_i}" == --json ]]; then @@ -175,8 +181,11 @@ if [ -f "$SCRIPT_DIR/repo-decl.sh" ]; then repo_decl_load else DECL_STATE=absent; DECL_SCHEMA="" + # Compatibility callbacks are invoked by declaration checks below. + # shellcheck disable=SC2317 repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; } repo_decl_report_invalid() { :; } + # shellcheck disable=SC2317 repo_decl_warn_absent_reversible() { :; } repo_decl_warn_absent_irreversible() { :; } repo_decl_remote_matches() { return 0; } @@ -314,6 +323,8 @@ print(ascii(message)) PY } +# EXIT invokes this cleanup callback. +# shellcheck disable=SC2317 cleanup_merge_temp_dirs() { local path for path in "${MERGE_TEMP_DIRS[@]}"; do @@ -732,3 +743,6 @@ case "$PLATFORM" in esac echo "PR #$PR_NUMBER merged successfully" + +) +mosaic_private_forge_main "$@" diff --git a/packages/mosaic/framework/tools/git/pr-metadata.sh b/packages/mosaic/framework/tools/git/pr-metadata.sh index 6089efc66..8c8bdf97a 100755 --- a/packages/mosaic/framework/tools/git/pr-metadata.sh +++ b/packages/mosaic/framework/tools/git/pr-metadata.sh @@ -8,6 +8,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh source "$SCRIPT_DIR/detect-platform.sh" +# Credential resolution and all consumers execute inside this private boundary. +# Never restore xtrace while credentials remain in scope. +mosaic_private_forge_main() ( + set +x + + # Parse arguments PR_NUMBER="" OUTPUT_FILE="" @@ -64,7 +70,7 @@ write_metadata() { curl_gitea_pull() { local api_url="$1" - local token basic_auth raw_code body_file http_code + local token raw_code body_file http_code body_file=$(mktemp) # shellcheck disable=SC2329 # Invoked by the RETURN trap below. @@ -76,30 +82,16 @@ curl_gitea_pull() { } trap cleanup_gitea_pull_body RETURN - token=$(get_gitea_token "$HOST" || true) - if [[ -n "$token" ]]; then - raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -H "User-Agent: curl/8" -H "Authorization: token $token" "$api_url" || true) - if [[ "$raw_code" =~ ^2 ]]; then - cat "$body_file" || return $? - return 0 - fi - http_code="$raw_code" - fi - - basic_auth=$(get_gitea_basic_auth "$HOST" || true) - if [[ -n "$basic_auth" ]]; then - raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -u "$basic_auth" -H "User-Agent: curl/8" "$api_url" || true) - if [[ "$raw_code" =~ ^2 ]]; then - cat "$body_file" || return $? - return 0 - fi - http_code="$raw_code" - fi - - if [[ -z "${http_code:-}" ]]; then - raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -H "User-Agent: curl/8" "$api_url" || true) - http_code="$raw_code" + token=$(get_gitea_token "$HOST") || { + echo 'Error: identity-bound metadata credential unavailable.' >&2 + return 1 + } + raw_code=$(gitea_curl_with_token "$token" -sS -w '%{http_code}' -o "$body_file" -H "User-Agent: curl/8" "$api_url") || return $? + if [[ "$raw_code" =~ ^2 ]]; then + cat "$body_file" || return $? + return 0 fi + http_code="$raw_code" python3 - "$http_code" "$body_file" <<'PY' >&2 import json @@ -262,3 +254,6 @@ else echo "Error: Unknown platform" >&2 exit 1 fi + +) +mosaic_private_forge_main "$@" diff --git a/packages/mosaic/framework/tools/git/pr-review.sh b/packages/mosaic/framework/tools/git/pr-review.sh index f230a96b5..98e51cf4c 100755 --- a/packages/mosaic/framework/tools/git/pr-review.sh +++ b/packages/mosaic/framework/tools/git/pr-review.sh @@ -35,6 +35,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh source "$SCRIPT_DIR/detect-platform.sh" +# Credential resolution and all consumers execute inside this private boundary. +# Never restore xtrace while credentials remain in scope. +mosaic_private_forge_main() ( + set +x + + # Parse arguments PR_NUMBER="" ACTION="" @@ -223,7 +229,7 @@ PY # 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 + local payload write_file readback_file write_status readback_status created_id payload=$(COMMENT_BODY="$comment_body" python3 -c ' import json @@ -233,16 +239,10 @@ 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 + trap 'rm -f "$write_file" "$readback_file"' RETURN - if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \ + if ! write_status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$write_file" -w '%{http_code}' \ -X POST \ - --config "$auth_config" \ -H 'User-Agent: mosaic-pr-review' \ -H 'Content-Type: application/json' \ -d "$payload" \ @@ -272,8 +272,7 @@ print(created_id) PY ) || return 1 - if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \ - --config "$auth_config" \ + if ! readback_status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$readback_file" -w '%{http_code}' \ -H 'User-Agent: mosaic-pr-review' \ "$GITEA_API_BASE/issues/comments/$created_id"); then echo "Error: Gitea comment read-back transport failed" >&2 @@ -411,7 +410,7 @@ PY # residue. gitea_resolve_api_for_login() { local effective_login="$1" override_explicit="${2:-}" host configured_url repo - local preflight_auth_config preflight_status + local preflight_status if [[ -n "$HOST_OVERRIDE" ]]; then host="$HOST_OVERRIDE" @@ -459,15 +458,9 @@ gitea_resolve_api_for_login() { GITEA_WEB_BASE="${configured_url%/}" if [[ -n "$REPO_OVERRIDE" ]]; then - preflight_auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || { - echo "Error: could not stage Gitea credential for --repo preflight" >&2 - return 1 - } - preflight_status=$(curl -sS -o /dev/null -w '%{http_code}' \ - --config "$preflight_auth_config" \ + preflight_status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o /dev/null -w '%{http_code}' \ -H 'User-Agent: mosaic-pr-review' \ "$GITEA_API_BASE") || preflight_status="000" - rm -f "$preflight_auth_config" if [[ "$preflight_status" != "200" ]]; then echo "Error: repo '$repo' not reachable at $configured_url (HTTP $preflight_status) — wrong host? pass -H/--host or cd into the target checkout" >&2 return 1 @@ -481,18 +474,12 @@ gitea_resolve_api_for_login() { # concurrent review from a DIFFERENT identity cannot satisfy verification. # Prints the login on success. gitea_authenticated_login() { - local response_file auth_config status + local response_file 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 + trap 'rm -f "$response_file"' RETURN - if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \ - --config "$auth_config" \ + if ! status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$response_file" -w '%{http_code}' \ -H 'User-Agent: mosaic-pr-review' \ "$GITEA_API_ROOT/user"); then echo "Error: Gitea authenticated-identity read transport failed" >&2 @@ -521,15 +508,14 @@ 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 + +# SHA. This core sets NO RETURN trap and reuses a caller-provided # 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. +# trap. $1 = PR number, $2 = response file. gitea_read_pr_head_into() { - local pr_number="$1" pr_file="$2" auth_config="$3" status + local pr_number="$1" pr_file="$2" status - if ! status=$(curl -sS -o "$pr_file" -w '%{http_code}' \ - --config "$auth_config" \ + if ! status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$pr_file" -w '%{http_code}' \ -H 'User-Agent: mosaic-pr-review' \ "$GITEA_API_BASE/pulls/$pr_number"); then echo "Error: Gitea PR head read transport failed" >&2 @@ -561,17 +547,12 @@ PY # 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 + local pr_number="$1" pr_file 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 + trap 'rm -f "$pr_file"' RETURN - gitea_read_pr_head_into "$pr_number" "$pr_file" "$auth_config" + gitea_read_pr_head_into "$pr_number" "$pr_file" } # Submit a review to a Gitea PR via the supported REST API and verify it against @@ -591,7 +572,7 @@ gitea_pr_head_sha() { # $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 payload write_file readback_file recheck_file local write_status readback_status created_id live_head payload=$(REVIEW_EVENT="$event" REVIEW_BODY="$review_body" REVIEW_COMMIT="$head_sha" python3 -c ' @@ -607,16 +588,10 @@ print(json.dumps({ 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 + trap 'rm -f "$write_file" "$readback_file" "$recheck_file"' RETURN - if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \ + if ! write_status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$write_file" -w '%{http_code}' \ -X POST \ - --config "$auth_config" \ -H 'User-Agent: mosaic-pr-review' \ -H 'Content-Type: application/json' \ -d "$payload" \ @@ -647,8 +622,7 @@ print(created_id) PY ) || return 1 - if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \ - --config "$auth_config" \ + if ! readback_status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$readback_file" -w '%{http_code}' \ -H 'User-Agent: mosaic-pr-review' \ "$GITEA_API_BASE/pulls/$pr_number/reviews/$created_id"); then echo "Error: Gitea review read-back transport failed" >&2 @@ -713,9 +687,9 @@ PY # 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 + # success line). This reuses the submit-scoped 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") || { + live_head=$(gitea_read_pr_head_into "$pr_number" "$recheck_file") || { echo "Error: could not re-read Gitea PR head after review verification" >&2 return 1 } @@ -856,3 +830,6 @@ else echo "Error: Unknown platform" exit 1 fi + +) +mosaic_private_forge_main "$@" diff --git a/packages/mosaic/framework/tools/git/test-forge-auth.py b/packages/mosaic/framework/tools/git/test-forge-auth.py index 335ddf0f9..048babc52 100644 --- a/packages/mosaic/framework/tools/git/test-forge-auth.py +++ b/packages/mosaic/framework/tools/git/test-forge-auth.py @@ -11,6 +11,59 @@ import unittest HERE=pathlib.Path(__file__).resolve().parent +LEGACY_CURL = '''#!/usr/bin/env -S python3 -I +import json,os,pathlib,sys,urllib.parse +a=sys.argv[1:]; secret=os.environ['TEST_AUTH_SECRET']; url=next((x for x in reversed(a) if x.startswith(('https://','http://'))),'') +u=urllib.parse.urlsplit(url) +if u.scheme!='https' or u.netloc!='forge.example.test' or not (u.path.startswith('/api/v1/') or u.path=='/swagger.v1.json'): sys.exit(98) +method='GET' +for flag in ['-X','--request']: + if flag in a: method=a[a.index(flag)+1] +config=''; anonymous=False +for flag in ['--config','-K']: + if flag in a: + value=a[a.index(flag)+1]; anonymous=value=='-' or value.startswith('/dev/fd/') + config=sys.stdin.read() if value=='-' else pathlib.Path(value).read_text() +observed={'path':u.path,'method':method,'argv_leak':any(secret in x for x in a),'authenticated':config=='header = '+json.dumps('Authorization: token '+secret)+chr(10),'anonymous':anonymous} +with open(os.environ['TEST_AUTH_OBSERVATIONS'],'a') as f: f.write(json.dumps(observed)+chr(10)) +if observed['argv_leak']: sys.exit(97) +head='a'*40; base='b'*40 +pull=dict(number=1,state='open',draft=False,merged=False,mergeable=True,title='Title',head={'sha':head,'ref':'task','repo':{'full_name':'owner/repo'}},base={'sha':base,'ref':'main','repo':{'full_name':'owner/repo'}},user={'login':'author'}) +if u.path=='/swagger.v1.json': body={'info':{'title':'Gitea API','version':'1.24.7'}} +elif u.path.endswith('/user'): body={'login':'fixture-user'} +elif '/branches/' in u.path: body={'commit':{'id':head}} +elif u.path.endswith('/status'): body={'state':'success','statuses':[{'context':'build','status':'success'}]} +elif u.path.endswith('/pulls/1'): body=pull +elif u.path.endswith('/pulls/1.diff'): body='diff --git a/example b/example' +elif u.path.endswith('/issues/1'): body=dict(number=1,title='Title',state='open',user={'login':'author'},created_at='',body='Body',comments=0) +elif u.path.endswith('/comments'): body=[] if method=='GET' else {'id':7,'body':'body','user':{'login':'fixture-user'},'issue_url':'https://forge.example.test/owner/repo/issues/1'} +elif u.path.endswith('/repos/owner/repo'): body={'default_branch':'main','permissions':{'admin':True}} +else: body={'id':7,'number':1,'login':'fixture-user','name':'reviewers','merged':True} +text=body if isinstance(body,str) else json.dumps(body) +if '-o' in a: pathlib.Path(a[a.index('-o')+1]).write_text(text) +else: print(text,end='') +if '-w' in a: + fmt=a[a.index('-w')+1].replace('%{http_code}','200').replace(chr(92)+'n',chr(10)) + print(fmt,end='') +''' + +LEGACY_CASES = { + 'ci-queue-wait.sh': (['--purpose','push','-B','task','-t','1','-i','1'], 'GET', '/commits/'+'a'*40+'/status'), + 'grant-reviewer.sh': (['-u','reviewer'], 'GET', '/orgs/owner'), + 'issue-close.sh': (['-i','1'], 'PATCH', '/issues/1'), + 'issue-comment.sh': (['-i','1','-b','body'], 'POST', '/issues/1/comments'), + 'issue-create.sh': (['-t','Title','-b','body'], 'POST', '/issues'), + 'issue-reopen.sh': (['-i','1'], 'PATCH', '/issues/1'), + 'issue-view.sh': (['-i','1'], 'GET', '/issues/1'), + 'pr-create.sh': (['-t','Title','-H','task','-B','main'], 'POST', '/pulls'), + 'pr-diff.sh': (['-n','1'], 'GET', '/pulls/1.diff'), + 'pr-edit.sh': (['-n','1','--login','fixture-user','--title','Title'], 'PATCH', '/pulls/1'), + 'pr-merge.sh': (['-n','1','--expect-head','a'*40], 'POST', '/pulls/1/merge'), + 'pr-metadata.sh': (['-n','1'], 'GET', '/pulls/1'), + 'pr-review.sh': (['-n','1','-a','comment','-c','body'], 'POST', '/issues/1/comments'), +} + + class Auth(unittest.TestCase): def setUp(self): self.tmp=tempfile.TemporaryDirectory(); self.addCleanup(self.tmp.cleanup) @@ -48,6 +101,85 @@ class Auth(unittest.TestCase): p=subprocess.run(command,env=self.env,capture_output=True,text=True) self.assertEqual(p.returncode,0,p.stderr); self.assertEqual(p.stdout,'seat-synthetic') + def legacy_fixture(self): + import shutil + tools=self.root/'tools' + shutil.copytree(HERE,tools,ignore=shutil.ignore_patterns('__pycache__','.work')) + repo=self.root/'repo'; repo.mkdir() + subprocess.run(['git','init','-q','-b','task',str(repo)],check=True,env=self.env) + subprocess.run(['git','-C',str(repo),'remote','add','origin','https://forge.example.test/owner/repo'],check=True,env=self.env) + bin_dir=self.root/'bin';bin_dir.mkdir() + (bin_dir/'curl').write_text(LEGACY_CURL);(bin_dir/'curl').chmod(0o755) + (bin_dir/'tea').write_text('#!/bin/sh\nexit 1\n');(bin_dir/'tea').chmod(0o755) + for name in ['gh','glab','wget','ssh']: + (bin_dir/name).write_text('#!/bin/sh\nexit 99\n');(bin_dir/name).chmod(0o755) + config=self.root/'.config/tea';config.mkdir(parents=True) + (config/'config.yml').write_text('logins:\n - name: fixture-user\n url: https://forge.example.test\n token: private-synthetic-token\n') + credentials=self.root/'credentials.json';credentials.write_text('{}') + self.env.update(PATH=str(bin_dir)+os.pathsep+os.environ['PATH'],MOSAIC_GIT_IDENTITY='fixture-user',XDG_CONFIG_HOME=str(self.root/'.config'),MOSAIC_CREDENTIALS_FILE=str(credentials),GITEA_LOGIN='fixture-user',GITEA_URL='https://forge.example.test',TEST_AUTH_SECRET='private-synthetic-token',TEST_AUTH_OBSERVATIONS=str(self.root/'observations.jsonl'),GIT_CONFIG_NOSYSTEM='1') + for key in ['GIT_DIR','GIT_WORK_TREE','BASH_ENV']: self.env.pop(key,None) + return tools,repo + + def legacy_call(self,tools,repo,name): + import json + log=self.root/'observations.jsonl' + if log.exists(): log.unlink() + args,method,suffix=LEGACY_CASES[name] + p=subprocess.run(['bash','-x',str(tools/name),*args],cwd=repo,env=self.env,capture_output=True,text=True,timeout=30) + observations=[json.loads(line) for line in log.read_text().splitlines()] if log.exists() else [] + # Never put captured traces, argv or credential values in assertion output. + leaked=self.env['TEST_AUTH_SECRET'] in p.stdout+p.stderr + authenticated=any(o['authenticated'] and o['method']==method and o['path'].endswith(suffix) for o in observations) + return dict(exit_code=p.returncode,trace_leak=leaked,argv_leak=any(o['argv_leak'] for o in observations),authenticated=authenticated,anonymous=all(o['anonymous'] for o in observations if o['authenticated'])) + + def test_all_legacy_callers_keep_credentials_private_under_xtrace(self): + tools,repo=self.legacy_fixture() + for name in LEGACY_CASES: + with self.subTest(wrapper=name): + observed=self.legacy_call(tools,repo,name) + self.assertTrue(observed['authenticated'],name+' did not reach its credential-consuming request; exit '+str(observed['exit_code'])) + self.assertFalse(observed['trace_leak'],name+' exposed credential in captured trace') + self.assertFalse(observed['argv_leak'],name+' exposed credential in argv') + self.assertTrue(observed['anonymous'],name+' did not use anonymous authentication') + + def test_legacy_anonymous_config_escapes_credential(self): + tools,repo=self.legacy_fixture() + secret='quoted"'+chr(92)+'credential' + self.token.write_text(secret) + self.env['TEST_AUTH_SECRET']=secret + observed=self.legacy_call(tools,repo,'pr-metadata.sh') + self.assertTrue(observed['authenticated'],'credential config escaping failed') + self.assertTrue(observed['anonymous']) + self.assertFalse(observed['trace_leak'] or observed['argv_leak']) + + def test_metadata_xtrace_mutant_is_detected(self): + tools,repo=self.legacy_fixture() + metadata=tools/'pr-metadata.sh' + source=metadata.read_text() + marker='mosaic_private_forge_main() (\n set +x' + self.assertIn(marker,source) + metadata.write_text(source.replace(marker,'mosaic_private_forge_main() (\n :',1)) + observed=self.legacy_call(tools,repo,'pr-metadata.sh') + self.assertTrue(observed['authenticated']) + self.assertTrue(observed['trace_leak'],'removing caller privacy was not detected') + + def test_legacy_argv_mutant_is_detected(self): + tools,repo=self.legacy_fixture() + helper=tools/'detect-platform.sh' + source=helper.read_text() + marker='curl --config "/dev/fd/$auth_fd" "$@"' + self.assertIn(marker,source) + helper.write_text(source.replace(marker,'curl -H "Authorization: token $token" "$@"',1)) + observed=self.legacy_call(tools,repo,'pr-metadata.sh') + self.assertTrue(observed['argv_leak'],'raw-header mutation was not detected') + self.assertFalse(observed['trace_leak']) + self.assertNotEqual(observed['exit_code'],0) + + def test_legacy_consumer_inventory_has_wrapper_controls(self): + import re + consumers={p.name for p in HERE.glob('*.sh') if not p.name.startswith(('test-','fixture-')) and p.name!='detect-platform.sh' and re.search(r'\$\(get_gitea_token(?:_for_login)?\b',p.read_text())} + self.assertEqual(consumers,set(LEGACY_CASES)) + def test_sigterm_leaves_no_credential_files(self): # Child blocks inside curl's transport; only anonymous fds hold auth. self.env['MOSAIC_GIT_IDENTITY']='fixture-user' diff --git a/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh b/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh index 2b9c8dc11..5abf645b7 100755 --- a/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh +++ b/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh @@ -96,6 +96,13 @@ fi set -euo pipefail printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG" +args=("$@") +for ((i=0; i<${#args[@]}; i++)); do + if [[ "${args[i]}" == --config && "${args[i+1]:-}" == /dev/fd/* ]]; then + grep -q '^header = "Authorization: token ' "${args[i+1]}" || exit 96 + printf '%s\n' 'anonymous-auth-config' >> "$MOSAIC_TEST_LOG" + fi +done url="${*: -1}" case "$url" in */pulls/*.diff) @@ -278,7 +285,11 @@ python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$HOME_DIR/.mos : > "$LOG_FILE" run_in_repo env MOSAIC_GIT_IDENTITY=fixture-user MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" GITEA_LOGIN="$(python3 -I -c 'import json,sys; d=json.load(open(sys.argv[1])); print(next(k for k,v in d["gitea"].items() if v["url"].endswith(sys.argv[2])))' "$CREDENTIALS_FILE" "$fixture_host")" MOSAIC_TEA_FAIL_PR_CREATE=1 GITEA_TOKEN=usc-token GITEA_URL=https://git.uscllc.com "$SCRIPT_DIR/pr-create.sh" -t "USC API fallback" -H feature/pr-create grep -q -- 'tea pr create --repo USC/uconnect --login usc --title USC API fallback --head feature/pr-create' "$LOG_FILE" -grep -q -- 'curl .*Authorization: token usc-token .*https://git.uscllc.com/api/v1/repos/USC/uconnect/pulls' "$LOG_FILE" +grep -q '^anonymous-auth-config$' "$LOG_FILE" +if grep -q 'Authorization:' "$LOG_FILE"; then + echo 'Credential header appeared in provider argv' >&2 + exit 1 +fi if grep -q -- 'git.mosaicstack.dev/api/v1/repos/USC/uconnect/pulls' "$LOG_FILE"; then echo "pr-create.sh API fallback defaulted USC repo to git.mosaicstack.dev" >&2 exit 1 diff --git a/packages/mosaic/framework/tools/git/test-grant-reviewer.sh b/packages/mosaic/framework/tools/git/test-grant-reviewer.sh index e378d3e88..4777f75be 100755 --- a/packages/mosaic/framework/tools/git/test-grant-reviewer.sh +++ b/packages/mosaic/framework/tools/git/test-grant-reviewer.sh @@ -172,7 +172,7 @@ done # Resolve the bearer token from the curl --config file (its real, secure # source). The config line is `header = "Authorization: token "`. -if [[ -z "$auth_token" && -n "$config_file" && -f "$config_file" ]]; then +if [[ -z "$auth_token" && -n "$config_file" && -r "$config_file" ]]; then config_hdr="$(grep -i 'Authorization' "$config_file" 2>/dev/null || true)" if [[ "$config_hdr" == *"token "* ]]; then auth_token="${config_hdr##*token }" diff --git a/packages/mosaic/framework/tools/git/test-issue-comment-readback.sh b/packages/mosaic/framework/tools/git/test-issue-comment-readback.sh index 08b1f349d..358bc3aab 100755 --- a/packages/mosaic/framework/tools/git/test-issue-comment-readback.sh +++ b/packages/mosaic/framework/tools/git/test-issue-comment-readback.sh @@ -262,7 +262,7 @@ 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 "`. -if [[ -z "$auth_token" && -n "$config_file" && -f "$config_file" ]]; then +if [[ -z "$auth_token" && -n "$config_file" && -r "$config_file" ]]; then config_hdr="$(grep -i 'Authorization' "$config_file" 2>/dev/null || true)" if [[ "$config_hdr" == *"token "* ]]; then auth_token="${config_hdr##*token }" diff --git a/packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh b/packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh index d29e6504f..9d7a7f37f 100755 --- a/packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh +++ b/packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh @@ -197,7 +197,7 @@ 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 "`. -if [[ -z "$auth_token" && -n "$config_file" && -f "$config_file" ]]; then +if [[ -z "$auth_token" && -n "$config_file" && -r "$config_file" ]]; then config_hdr="$(grep -i 'Authorization' "$config_file" 2>/dev/null || true)" if [[ "$config_hdr" == *"token "* ]]; then auth_token="${config_hdr##*token }" -- 2.54.0 From 5cde8cd15b41fef7cc00ed85c06592254c5f1537 Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 16:06:10 -0600 Subject: [PATCH 13/26] docs(framework): record round-two credential contract and evidence (#1841) Resolve R2-C3 by documenting anonymous authentication consistently. Record complete URL, transport-error and wrapper trace controls, correct the earlier S6 coverage claim, and preserve the independent baseline failure. Co-Authored-By: GPT-6.1-Sol (Codex) --- docs/scratchpads/1841-forge-review-round-2.md | 77 ++++ .../1841-forge-wrapper-evidence.json | 401 ++++++++++++++++++ docs/scratchpads/1841-forge-wrapper-gaps.md | 8 + packages/mosaic/framework/tools/git/README.md | 21 +- 4 files changed, 505 insertions(+), 2 deletions(-) create mode 100644 docs/scratchpads/1841-forge-review-round-2.md diff --git a/docs/scratchpads/1841-forge-review-round-2.md b/docs/scratchpads/1841-forge-review-round-2.md new file mode 100644 index 000000000..e4b89810d --- /dev/null +++ b/docs/scratchpads/1841-forge-review-round-2.md @@ -0,0 +1,77 @@ +# Forge wrapper review round 2 (#1841) + +Reviewed candidate: a193cf28458a7fc08f9ff4d6395526c5efd7918c. +Original base: 924438016e2eb1c2ca352bfbf9107143b8b342bb. + +## Fixes and regression controls + +R2-C1 and R2-S2 are fixed in bec37b6fd. Public GitHub REST and GraphQL use +`https://api.github.com`; enterprise REST uses `/api/v3` and GraphQL uses +`/api/graphql` on the declared host. Credential lookup remains bound to the +logical forge host. The CLI fake accepts only test-declared complete URLs and +rejects unexpected schemes, authorities, paths and queries. Its public and +enterprise snapshot regression requires a ready verdict, not merely equal +results from two paths. + +Every nonzero curl exit now refuses before HTTP status or body parsing, including +when stdout contains a complete-looking successful HTTP response. The transport +contract covers exit 28 and 56 for reads and writes on public GitHub, enterprise +GitHub, Gitea and Forgejo; successful controls remain accepted. Failed writes +retain an unknown outcome. The focused URL and curl controls each failed with +exit 1 before the corresponding fix and passed with exit 0 afterward. + +R2-S1 extends the earlier S6 fix to the complete shell credential lifetime. The +round-1 direct resolver test did not cover caller-side command-substitution +tracing; its earlier S6 completion claim was too broad. All 13 token-consuming +shell wrappers now execute credential resolution and all consumers in a private +subprocess with xtrace disabled. The caller never owns those token variables, +and tracing is never restored while they exist. The shared request helper sends +an escaped authentication config over an anonymous descriptor, preserves request +stdin, checks curl's exit status and closes the descriptor. Named authentication +config files and raw authentication headers in exec argv have been removed from +these consumers. The existing merge transport continues to use anonymous stdin +config and retains its once-only credential resolution. + +`Auth.test_all_legacy_callers_keep_credentials_private_under_xtrace` executes an +authenticated endpoint in each wrapper under `bash -x`, records only safe +observations and checks stderr and argv for exposure. An inventory assertion +requires controls for every shell token consumer. Quote/backslash escaping is +checked independently. Scratch-copy mutants that remove metadata's private +boundary or put its token in curl argv are detected. Existing termination and +identity-refusal controls remain. The edit wrapper's signal test also caught a +subprocess signal-target regression; it now targets its actual Bash process and +preserves termination status without continuing to mutate. + +Legacy stateful server fixtures now accept readable anonymous descriptors rather +than requiring regular authentication files. Review readback cleanup and edit +signal tests pass; these continue to assert persisted state, exact identity and +failure behavior. No live provider writes are involved. + +R2-C3 updates the README to one current credential contract: private untraced +credential lifetime and anonymous authentication config delivery. Temporary files +are limited to response and policy data in approved scratch storage. + +## Verification and limits + +The per-command results and finding mapping are recorded in the `review_round_2` +section of [the evidence file](1841-forge-wrapper-evidence.json). + +No dependencies were installed. No additional operator-local fixes were copied. +The inherited deployment-specific literals identified as S7 remain deferred to +the eventual PR's Follow-up section. Live provider writes, scheduler behavior, +notifier delivery, terminal-green CI and independent review of this revised head +remain unverified. This round does not create a PR or merge a branch. + +The final isolated run contains 75 suites: all seven Python suites pass (146 +runner cases), 66 of 67 git shell suites pass, and declaration validation passes. +The shell driver exits 1 solely for the separately reproduced milestone-create +missing-title baseline failure. Schema and negative controls, watch parity and +its mutant controls pass with external network clients denied. Bash syntax +passes all 107 shell files. Full ShellCheck exits 1 for both the prior candidate +and this tree; the final targeted callback annotation removes the last new +file/code/message diagnostic. Existing diagnostics remain. + +Workspace preflight, typecheck, lint and format checks exit 0. The three package +skill/manifest Vitest suites pass 97 tests. `git diff --check` against the original +base exits 0. Commit hooks emit existing template configuration warnings, then +exit 0; hooks were not bypassed. diff --git a/docs/scratchpads/1841-forge-wrapper-evidence.json b/docs/scratchpads/1841-forge-wrapper-evidence.json index 0ca3d36cb..72591e09f 100644 --- a/docs/scratchpads/1841-forge-wrapper-evidence.json +++ b/docs/scratchpads/1841-forge-wrapper-evidence.json @@ -689,5 +689,406 @@ ], "dependencies_installed_this_round": [], "additional_local_fixes_carried_this_round": [] + }, + "review_round_2": { + "reviewed_head": "a193cf28458a7fc08f9ff4d6395526c5efd7918c", + "base_sha": "924438016e2eb1c2ca352bfbf9107143b8b342bb", + "findings": { + "R2-C1": { + "commit": "bec37b6fd", + "tests": [ + "CLI.test_public_and_enterprise_snapshot_use_exact_api_urls", + "CLI.test_fake_rejects_unexpected_full_urls" + ] + }, + "R2-S2": { + "commit": "bec37b6fd", + "tests": [ + "TransportContract.test_curl_errors_never_prove_reads_or_mutations" + ] + }, + "R2-S1": { + "commit": "fc7b9d61b", + "tests": [ + "Auth.test_all_legacy_callers_keep_credentials_private_under_xtrace", + "Auth.test_legacy_consumer_inventory_has_wrapper_controls", + "Auth.test_metadata_xtrace_mutant_is_detected", + "Auth.test_legacy_argv_mutant_is_detected", + "Auth.test_legacy_anonymous_config_escapes_credential", + "Auth.test_sigterm_leaves_no_credential_files", + "test-pr-edit.sh", + "test-pr-review-gitea-comment.sh", + "test-issue-comment-readback.sh", + "test-grant-reviewer.sh", + "test-gitea-login-resolution.sh" + ] + }, + "R2-C3": { + "fix": "Current README credential contract; documentation commit follows implementation." + } + }, + "correction": "Round-1 S6 direct-resolver tests did not cover caller-side tracing. The complete credential lifetime is now covered for all 13 shell consumers.", + "red_controls": { + "public_api_authority": 1, + "nonzero_curl_status": 1 + }, + "green_controls": { + "public_api_authority": 0, + "nonzero_curl_status": 0, + "metadata_private_boundary_mutant_detected": 0, + "metadata_raw_argv_mutant_detected": 0 + }, + "dependencies_installed_this_round": [], + "additional_local_fixes_carried_this_round": [], + "follow_up": [ + "S7 inherited deployment-specific literals remain excluded and must appear in the eventual PR Follow-up.", + "Milestone missing-title usage ordering remains a reproduced baseline failure.", + "Existing ShellCheck diagnostics remain." + ], + "unverified": [ + "Live provider writes and scheduler behavior", + "Live notifier delivery", + "Independent review, terminal-green CI, PR creation and merge of revised head" + ], + "isolated_offline_suites": { + "bash test-ci-queue-wait-branch-absent.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-ci-queue-wait-github-checks.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-ci-queue-wait-no-ci-expected.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-ci-queue-wait-no-status.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-ci-queue-wait-tristate.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-detect-platform-outside-repo.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-explain-diagnostic-status-neutral.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-forge-capabilities.sh": { + "exit_code": 0, + "runner_cases": 104 + }, + "bash test-forge-schema.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-git-credential-mosaic.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-gitea-login-resolution.sh": { + "exit_code": 0, + "runner_cases": 1 + }, + "bash test-gitea-token-identity.sh": { + "exit_code": 0, + "runner_cases": 9 + }, + "bash test-grant-reviewer.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-help-exit-code.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-issue-assign-usage-contract.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-issue-close-fail-closed.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-issue-close-usage-contract.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-issue-comment-readback.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-issue-comment-usage-contract.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-issue-create-body-safety.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-issue-create-interactive-auth.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-issue-create-usage-contract.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-issue-edit-usage-contract.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-issue-list-usage-contract.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-issue-reopen-usage-contract.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-issue-view-comments.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-issue-view-usage-contract.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-lane-brief-pr-linkage.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-lane-brief-usage-contract.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-milestone-close-usage-contract.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-milestone-create-usage-contract.sh": { + "exit_code": 1, + "runner_cases": null + }, + "bash test-milestone-list-usage-contract.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-mosaic-worktree-large-repo.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-mutate-push-guard.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-pr-auto-merge.sh": { + "exit_code": 0, + "runner_cases": 27 + }, + "bash test-pr-close-usage-contract.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-pr-create-fallback-default-base.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-pr-create-usage-contract.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-pr-diff-usage-contract.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-pr-edit-usage-contract.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-pr-edit.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-pr-list-usage-contract.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-pr-merge-baseline.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-pr-merge-fork-ci-status.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-pr-merge-gitea-empty-uid.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-pr-merge-head-pin.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-pr-merge-message-field.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-pr-merge-methods.sh": { + "exit_code": 0, + "runner_cases": 6 + }, + "bash test-pr-merge-no-ci-expected.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-pr-merge-queue-branch.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-pr-merge-queue.sh": { + "exit_code": 0, + "runner_cases": 27 + }, + "bash test-pr-metadata-gitea.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-pr-metadata-usage-contract.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-pr-review-comments.sh": { + "exit_code": 0, + "runner_cases": 1 + }, + "bash test-pr-review-gitea-comment.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-pr-review-reply.sh": { + "exit_code": 0, + "runner_cases": 3 + }, + "bash test-pr-review-repo-host-override.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-pr-review-resolve.sh": { + "exit_code": 0, + "runner_cases": 13 + }, + "bash test-pr-review-usage-contract.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-pr-status.sh": { + "exit_code": 0, + "runner_cases": 10 + }, + "bash test-pr-view-usage-contract.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-pr-watch-check.sh": { + "exit_code": 0, + "runner_cases": 9 + }, + "bash test-pr-watch-parity.sh": { + "exit_code": 0, + "runner_cases": 6 + }, + "bash test-push-guard.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-repo-decl-consumption.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-verify-clean-clone.sh": { + "exit_code": 0, + "runner_cases": null + }, + "bash test-wrapper-guard.sh": { + "exit_code": 0, + "runner_cases": 4 + }, + "python3 -I test-forge-auth.py": { + "exit_code": 0, + "runner_cases": 9 + }, + "python3 -I test-forge-cli.py": { + "exit_code": 0, + "runner_cases": 12 + }, + "python3 -I test-forge-contract.py": { + "exit_code": 0, + "runner_cases": 104 + }, + "python3 -I test-forge-guard.py": { + "exit_code": 0, + "runner_cases": 4 + }, + "python3 -I test-merge-policy.py": { + "exit_code": 0, + "runner_cases": 6 + }, + "python3 -I test-pr-skill-contract.py": { + "exit_code": 0, + "runner_cases": 5 + }, + "python3 -I test-pr-watch-parity.py": { + "exit_code": 0, + "runner_cases": 6 + }, + "bash test-validate-repo-json.sh": { + "exit_code": 0, + "runner_cases": null + } + }, + "isolated_sweep_exit_code": 1, + "commands": { + "pnpm preflight": { + "exit_code": 0 + }, + "pnpm typecheck": { + "exit_code": 0 + }, + "pnpm lint": { + "exit_code": 0 + }, + "pnpm format:check": { + "exit_code": 0 + }, + "pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/skill.spec.ts src/framework/manifest-parity.spec.ts src/framework/manifest.spec.ts": { + "exit_code": 0, + "files": 3, + "tests": 97 + }, + "bash -n (all git shell files)": { + "exit_code": 0, + "files": 107 + }, + "/usr/bin/shellcheck (all git shell files)": { + "baseline_exit": 1, + "current_exit": 1, + "new_diagnostics": [], + "note": "Final pr-create targeted ShellCheck rerun removes the last new diagnostic; full-tree baseline and candidate both exit 1." + }, + "git diff --check 924438016e2eb1c2ca352bfbf9107143b8b342bb": { + "exit_code": 0 + }, + "isolated credential fixture reruns": { + "exit_code": 0, + "suites": 6 + } + }, + "python_runner_cases": 146 } } diff --git a/docs/scratchpads/1841-forge-wrapper-gaps.md b/docs/scratchpads/1841-forge-wrapper-gaps.md index 47a8ebfbd..97b4db90d 100644 --- a/docs/scratchpads/1841-forge-wrapper-gaps.md +++ b/docs/scratchpads/1841-forge-wrapper-gaps.md @@ -69,3 +69,11 @@ Parity tests replay normalized provider evidence through mocked transports. They live T3 support or notifier delivery. A failed required read blocks either workflow; unsupported schedule inspection blocks full-stop in either transport. Scheduling success is never a merge completion claim. + +## Review round 2 + +Public/enterprise API routing, transport-error refusal and complete shell +credential privacy are recorded in [round-2 evidence](1841-forge-review-round-2.md). +The round-1 S6 claim is corrected there: resolver tracing alone did not protect +caller-side token assignments. Review fixes remain on the original branch, with +new commits and no history rewrite; PR creation and merge remain held. diff --git a/packages/mosaic/framework/tools/git/README.md b/packages/mosaic/framework/tools/git/README.md index 18073f75a..486640c06 100644 --- a/packages/mosaic/framework/tools/git/README.md +++ b/packages/mosaic/framework/tools/git/README.md @@ -21,7 +21,11 @@ A successful provider write command—or a wrapper message based only on that co ## Credential handling -The Gitea API token is **never passed on a curl command line.** An `Authorization: token ` argument would be visible to any local process that can read the process table (`ps` / `/proc//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 ` (`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. +Credential resolution and the complete legacy request workflow run in a private +subprocess with xtrace disabled. Tokens stay out of caller traces and process +arguments. `gitea_curl_with_token` supplies an anonymous config descriptor to curl; +request bodies retain stdin. The normalized adapter uses the same anonymous +transport contract. No named authentication scratch files are created. ## `tea` invocation notes (Gitea) @@ -352,7 +356,10 @@ operator-owned tool/skill synchronization. Verify deployed files by hash against an upgrade alone does not prove owned copies were synchronized. No local rollout is performed by these tests or by promoting the skill sources. -Authenticated Gitea/Forgejo requests require absolute `TMPDIR` pointing to approved scratch storage. Credentials use mode-0600 temporary client config files, removed after each request, and never appear in client arguments. Fallback notification requires an operator-provisioned notifier; its absence is an explicit watch-delivery blocker. This change packages the status/predicate and skills, and does not install a host notifier. +Set absolute `TMPDIR` to approved scratch storage for response and policy files. +The credential transport contract is described below. Fallback notification requires +an operator-provisioned notifier; its absence is an explicit watch-delivery blocker. +This change packages the status/predicate and skills and does not install a notifier. ### Trusted policy and authentication @@ -371,7 +378,17 @@ including GitHub, use `/credentials/forge//.token`, where `MOSAIC_BRAIN_HOME` selects `` (default `~/.mosaic`). Provision the selected actor's token separately. Authorization reaches curl through an anonymous pipe; request JSON uses stdin. Tokens never enter named scratch files. +Legacy shell wrappers keep credential resolution and the complete request workflow +in a private subprocess with tracing disabled. Caller tracing never receives a +secret variable; each request consumes authorization through an anonymous descriptor. The skill firewall accepts optional newline-separated forbidden patterns via `MOSAIC_OPERATOR_FORBIDDEN_PATTERNS`; its default is empty. Deployment-specific patterns belong in operator configuration, outside the framework source. + +Public GitHub REST requests use `https://api.github.com`, and GraphQL uses +`https://api.github.com/graphql`. Enterprise REST requests use +`https:///api/v3`, and GraphQL uses `https:///api/graphql`. +Credentials remain bound to the selected forge host, such as `github.com`, +while the adapter selects its documented API authority. CLI fixtures match +complete request URLs, including scheme, authority, path and query. -- 2.54.0 From 00d62c62ca3a5f17f11ff28e36a63220a4f97a7a Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 16:35:17 -0600 Subject: [PATCH 14/26] ci(framework): enumerate forge wrapper regression suites (#1841) Add eleven shell suites and seven Python suites to the existing framework test command. Keep all prior invocations and exclusions unchanged. Co-Authored-By: GPT-6.1-Sol (Codex) --- packages/mosaic/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 20a551321..42f457cc5 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -25,7 +25,7 @@ "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell", - "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_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/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-repo-decl-consumption.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-baseline.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_lib/test-credentials-gitea-seats.sh && bash framework/tools/git/test-issue-comment-usage-contract.sh && bash framework/tools/git/test-issue-comment-readback.sh && bash framework/tools/git/test-issue-close-usage-contract.sh && bash framework/tools/git/test-issue-reopen-usage-contract.sh && bash framework/tools/git/test-pr-close-usage-contract.sh && bash framework/tools/git/test-pr-review-usage-contract.sh && bash framework/tools/git/test-pr-diff-usage-contract.sh && bash framework/tools/git/test-pr-view-usage-contract.sh && bash framework/tools/git/test-pr-metadata-usage-contract.sh && bash framework/tools/git/test-pr-list-usage-contract.sh && bash framework/tools/git/test-issue-edit-usage-contract.sh && bash framework/tools/git/test-issue-create-usage-contract.sh && bash framework/tools/git/test-pr-edit-usage-contract.sh && bash framework/tools/git/test-pr-create-usage-contract.sh && bash framework/tools/git/test-issue-assign-usage-contract.sh && bash framework/tools/git/test-milestone-close-usage-contract.sh && bash framework/tools/git/test-milestone-list-usage-contract.sh && bash framework/tools/git/test-issue-view-usage-contract.sh && bash framework/tools/git/test-issue-list-usage-contract.sh && bash framework/tools/git/test-milestone-create-usage-contract.sh && bash framework/tools/git/test-lane-brief-usage-contract.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh && bash framework/tools/git/test-grant-reviewer.sh" + "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_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/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-forge-capabilities.sh && bash framework/tools/git/test-forge-schema.sh && bash framework/tools/git/test-pr-auto-merge.sh && bash framework/tools/git/test-pr-merge-methods.sh && bash framework/tools/git/test-pr-merge-queue.sh && bash framework/tools/git/test-pr-review-comments.sh && bash framework/tools/git/test-pr-review-reply.sh && bash framework/tools/git/test-pr-review-resolve.sh && bash framework/tools/git/test-pr-status.sh && bash framework/tools/git/test-pr-watch-check.sh && bash framework/tools/git/test-pr-watch-parity.sh && python3 -I framework/tools/git/test-forge-auth.py && python3 -I framework/tools/git/test-forge-cli.py && python3 -I framework/tools/git/test-forge-contract.py && python3 -I framework/tools/git/test-forge-guard.py && python3 -I framework/tools/git/test-merge-policy.py && python3 -I framework/tools/git/test-pr-skill-contract.py && python3 -I framework/tools/git/test-pr-watch-parity.py && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-repo-decl-consumption.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-baseline.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_lib/test-credentials-gitea-seats.sh && bash framework/tools/git/test-issue-comment-usage-contract.sh && bash framework/tools/git/test-issue-comment-readback.sh && bash framework/tools/git/test-issue-close-usage-contract.sh && bash framework/tools/git/test-issue-reopen-usage-contract.sh && bash framework/tools/git/test-pr-close-usage-contract.sh && bash framework/tools/git/test-pr-review-usage-contract.sh && bash framework/tools/git/test-pr-diff-usage-contract.sh && bash framework/tools/git/test-pr-view-usage-contract.sh && bash framework/tools/git/test-pr-metadata-usage-contract.sh && bash framework/tools/git/test-pr-list-usage-contract.sh && bash framework/tools/git/test-issue-edit-usage-contract.sh && bash framework/tools/git/test-issue-create-usage-contract.sh && bash framework/tools/git/test-pr-edit-usage-contract.sh && bash framework/tools/git/test-pr-create-usage-contract.sh && bash framework/tools/git/test-issue-assign-usage-contract.sh && bash framework/tools/git/test-milestone-close-usage-contract.sh && bash framework/tools/git/test-milestone-list-usage-contract.sh && bash framework/tools/git/test-issue-view-usage-contract.sh && bash framework/tools/git/test-issue-list-usage-contract.sh && bash framework/tools/git/test-milestone-create-usage-contract.sh && bash framework/tools/git/test-lane-brief-usage-contract.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh && bash framework/tools/git/test-grant-reviewer.sh" }, "dependencies": { "@mosaicstack/brain": "workspace:*", -- 2.54.0 From 1effc9c143579da9e442ad98145c86498734e61a Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 17:02:18 -0600 Subject: [PATCH 15/26] docs(framework): index shipped forge wrappers and fixture helper (#1841) Add the nine missing git-tool index rows to the existing tables. Identify the schema fixture as an offline test helper and retain all existing tool entries. Co-Authored-By: GPT-6.1-Sol (Codex) --- .../framework/guides/TOOLS-REFERENCE.md | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md index 3520687a9..7c446bda8 100644 --- a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md +++ b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md @@ -30,18 +30,25 @@ supply it explicitly on any host where the provider CLI's default account is an | `issue-close.sh` | Close an issue | | `issue-reopen.sh` | Reopen a closed issue | -| Pull requests | | -| ---------------- | --------------------------------------------------------- | -| `pr-create.sh` | Open a pull request | -| `pr-edit.sh` | Edit PR title, body, base branch, or draft/ready state | -| `pr-view.sh` | Show one PR | -| `pr-list.sh` | List PRs | -| `pr-diff.sh` | Fetch a PR's diff | -| `pr-metadata.sh` | PR metadata as JSON (head SHA, base, state, mergeability) | -| `pr-review.sh` | **Place a review verdict — see the dialect note below** | -| `pr-ci-wait.sh` | Block until the PR's CI reaches a terminal state | -| `pr-merge.sh` | Merge a PR | -| `pr-close.sh` | Close a PR without merging | +| Pull requests | | +| ----------------------- | ----------------------------------------------------------- | +| `pr-create.sh` | Open a pull request | +| `pr-edit.sh` | Edit PR title, body, base branch, or draft/ready state | +| `pr-view.sh` | Show one PR | +| `pr-list.sh` | List PRs | +| `pr-diff.sh` | Fetch a PR's diff | +| `pr-metadata.sh` | PR metadata as JSON (head SHA, base, state, mergeability) | +| `pr-review.sh` | **Place a review verdict — see the dialect note below** | +| `pr-ci-wait.sh` | Block until the PR's CI reaches a terminal state | +| `pr-merge.sh` | Merge a PR | +| `pr-close.sh` | Close a PR without merging | +| `pr-status.sh` | Aggregate exact-head PR readiness and required evidence. | +| `pr-watch-check.sh` | Evaluate the watch predicate from a status snapshot. | +| `pr-review-comments.sh` | Enumerate review comments with provider-qualified IDs. | +| `pr-review-reply.sh` | Reply to a review thread when the provider supports it. | +| `pr-review-resolve.sh` | Resolve or reopen a supported review thread. | +| `pr-auto-merge.sh` | Inspect or control supported automatic merge scheduling. | +| `pr-merge-queue.sh` | Inspect or control supported native merge-queue membership. | | Milestones | | | --------------------- | ------------------ | @@ -60,10 +67,12 @@ supply it explicitly on any host where the provider CLI's default account is an | `mutate-push-guard.sh` | Regenerate the guard's mutation-coverage table from measurement, so the table cannot drift from the guard | | `verify-clean-clone.sh` | Prove the **committed** artifact runs, from a clean clone — not the working tree | -| Context | | -| -------------------- | ---------------------------------------------------------------------------------------- | -| `detect-platform.sh` | Resolve the provider (Gitea vs GitHub) for the current repo; every other wrapper uses it | -| `lane-brief.sh` | Live dispatch brief for a repo "lane" (milestone/label) straight from the provider | +| Context | | +| -------------------------- | ---------------------------------------------------------------------------------------- | +| `detect-platform.sh` | Resolve the provider (Gitea vs GitHub) for the current repo; every other wrapper uses it | +| `lane-brief.sh` | Live dispatch brief for a repo "lane" (milestone/label) straight from the provider | +| `forge-capabilities.sh` | Report versioned provider capabilities and unsupported operations. | +| `fixture-schema-client.sh` | Offline test helper returning a synthetic public schema; rejects other requests. | | Workspace | | | -------------------- | ------------------------------------------------------------------------ | -- 2.54.0 From 79682f703f15d6659acce447f4ec726a9e05425c Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 17:33:28 -0600 Subject: [PATCH 16/26] test(framework): make fake provider launchers portable to BusyBox (#1841) Replace only the two env -S fixture prefixes with a POSIX shell trampoline. Keep Python isolation, quoted argv, fixture bodies and assertions unchanged. Co-Authored-By: GPT-6.1-Sol (Codex) --- packages/mosaic/framework/tools/git/test-forge-auth.py | 5 ++++- packages/mosaic/framework/tools/git/test-forge-cli.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/framework/tools/git/test-forge-auth.py b/packages/mosaic/framework/tools/git/test-forge-auth.py index 048babc52..5e6cf8322 100644 --- a/packages/mosaic/framework/tools/git/test-forge-auth.py +++ b/packages/mosaic/framework/tools/git/test-forge-auth.py @@ -11,7 +11,10 @@ import unittest HERE=pathlib.Path(__file__).resolve().parent -LEGACY_CURL = '''#!/usr/bin/env -S python3 -I +LEGACY_CURL = '''#!/bin/sh +""":" +exec python3 -I "$0" "$@" +":""" import json,os,pathlib,sys,urllib.parse a=sys.argv[1:]; secret=os.environ['TEST_AUTH_SECRET']; url=next((x for x in reversed(a) if x.startswith(('https://','http://'))),'') u=urllib.parse.urlsplit(url) diff --git a/packages/mosaic/framework/tools/git/test-forge-cli.py b/packages/mosaic/framework/tools/git/test-forge-cli.py index df22e901e..0fc06ef2e 100644 --- a/packages/mosaic/framework/tools/git/test-forge-cli.py +++ b/packages/mosaic/framework/tools/git/test-forge-cli.py @@ -7,7 +7,10 @@ import tempfile import unittest HERE=pathlib.Path(__file__).resolve().parent -FAKE='''#!/usr/bin/env -S python3 -I +FAKE='''#!/bin/sh +""":" +exec python3 -I "$0" "$@" +":""" import json,os,pathlib,sys,urllib.parse args=sys.argv[1:] if any('fixture-token' in a for a in args): -- 2.54.0 From 85ea376dc00bfb04d5a0e9c87d42c0dddac46fe6 Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 18:26:34 -0600 Subject: [PATCH 17/26] fix(framework): constrain authenticated read redirects (#1841) Follow bounded same-origin HTTPS GET redirects without replaying mutations. Prove real wrapper entry paths and declare TLS fixture prerequisites in all consuming pipeline steps. Co-Authored-By: GPT-6.1-Sol (Codex) --- .woodpecker/ci.yml | 8 +- .woodpecker/publish.yml | 5 +- packages/mosaic/framework/tools/git/README.md | 8 + .../mosaic/framework/tools/git/forge-api.py | 170 ++++++++++------ .../framework/tools/git/test-forge-cli.py | 183 ++++++++++++++++++ .../commands/git-wrapper-redirects.spec.ts | 29 +-- 6 files changed, 326 insertions(+), 77 deletions(-) diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index 4eeafacfb..0487c1b94 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -72,7 +72,8 @@ steps: sanitization: image: *node_image commands: - - apk add --no-cache bash + # Real loopback TLS transport fixtures require curl and openssl. + - apk add --no-cache bash curl openssl - bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh # Resident line-count ceiling over framework-owned resident files # (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9. @@ -205,7 +206,7 @@ steps: # shared command; everything else in this step is PIPELINE-LEVEL # prerequisite the canonical command expects its caller to provide (SDLC-D-034): # the ci-postgres service + pg_isready wait + db:migrate (postgres path), - # `apk add openssl`, and the pinned pi install. None of those can move into + # `apk add openssl curl`, and the pinned pi install. None of those can move into # the runner (it must also work locally on the PGlite path with no database). test: image: *node_image @@ -220,7 +221,8 @@ steps: # Dockerfile.ci, but ci-base only rebuilds on push-to-main/tag — this # `apk add` guarantees openssl is present on PR pipelines too (and is a # fast no-op once the rebuilt image already ships it). - - apk add --no-cache openssl + # Real loopback TLS transport fixtures also require curl. + - apk add --no-cache openssl curl # Pi runtime (Invariant R): invariant_r_unittest.py hard-requires an # installed `pi` binary at exactly this measured version — the test # boots Pi's real tool registry to prove the read-only carve-out diff --git a/.woodpecker/publish.yml b/.woodpecker/publish.yml index b656c5db0..89cf138a8 100644 --- a/.woodpecker/publish.yml +++ b/.woodpecker/publish.yml @@ -97,12 +97,13 @@ steps: echo "[verify] commit identity confirmed: $CHECKOUT_SHA" # (b) Canonical terminal verification. Caller-provided prerequisites the # runner expects (see .woodpecker/ci.yml comments): bash/rsync for the - # guard stages, openssl + the pinned pi binary for the test stage. git is + # guard stages, openssl/curl for real loopback TLS transport tests, and + # the pinned pi binary for the test stage. git is # baked into ci-base but re-asserted here so the identity check above can # never silently depend on a stale baked image. DATABASE_URL is # deliberately NOT set: the canonical command must hold on the PGlite # path too and never sets or requires a database itself. - - apk add --no-cache bash rsync openssl git + - apk add --no-cache bash rsync openssl git curl - npm install -g @earendil-works/pi-coding-agent@0.84.1 - pnpm verify:release depends_on: diff --git a/packages/mosaic/framework/tools/git/README.md b/packages/mosaic/framework/tools/git/README.md index 486640c06..b25628f9c 100644 --- a/packages/mosaic/framework/tools/git/README.md +++ b/packages/mosaic/framework/tools/git/README.md @@ -389,6 +389,14 @@ patterns belong in operator configuration, outside the framework source. Public GitHub REST requests use `https://api.github.com`, and GraphQL uses `https://api.github.com/graphql`. Enterprise REST requests use `https:///api/v3`, and GraphQL uses `https:///api/graphql`. +The normalized adapter disables default curl configuration as its first curl +option. It follows at most three GET redirects (301, 302, 303, 307, 308), resolving +relative or absolute targets only within the original HTTPS API origin, including +its effective port. Missing or ambiguous Location headers, invalid targets, +userinfo, origin changes, downgrade, cycles and exhausted bounds refuse before a +next request. Non-GET requests, including mutations, are never redirected or replayed. +Authorization remains in anonymous descriptors throughout the read chain. + Credentials remain bound to the selected forge host, such as `github.com`, while the adapter selects its documented API authority. CLI fixtures match complete request URLs, including scheme, authority, path and query. diff --git a/packages/mosaic/framework/tools/git/forge-api.py b/packages/mosaic/framework/tools/git/forge-api.py index efbad0b6f..531e157b2 100644 --- a/packages/mosaic/framework/tools/git/forge-api.py +++ b/packages/mosaic/framework/tools/git/forge-api.py @@ -28,6 +28,37 @@ class Failure(Exception): self.code, self.kind, self.retry, self.outcome = code, kind, retry, outcome +REDIRECT_STATUSES = (301, 302, 303, 307, 308) +MAX_REDIRECTS = 3 + + +def redirect_target(current, location, origin): + """Resolve one unambiguous HTTPS target without changing API authority.""" + if (not location or any(ord(c) <= 32 or ord(c) == 127 for c in location) + or any(c in location for c in ('\\', ',', '#')) + or re.search(r'%(?![0-9A-Fa-f]{2})|%(?:0[0-9A-Fa-f]|1[0-9A-Fa-f]|7[fF]|5[cC])', location)): + raise Failure(6, 'malformed', 'Provider redirect target is invalid') + try: + given = urllib.parse.urlsplit(location) + if (given.scheme and (given.scheme.lower() != 'https' or not given.netloc) + or location.startswith('//') and not given.netloc): + raise ValueError('incomplete or non-HTTPS target') + target = urllib.parse.urlsplit(urllib.parse.urljoin(current, location)) + if (target.scheme.lower() != 'https' or not target.hostname + or not re.fullmatch(r'[A-Za-z0-9.-]+', target.hostname) + or target.username is not None or target.password is not None + or target.netloc.endswith(':') or target.fragment + or target.port is not None and not 1 <= target.port <= 65535): + raise ValueError('invalid authority') + target_origin = (target.scheme.lower(), target.hostname.lower(), target.port or 443) + if target_origin != origin: + raise ValueError('different API origin') + authority = target.hostname.lower() + (':' + str(target.port) if target.port is not None else '') + return urllib.parse.urlunsplit(('https', authority, target.path or '/', target.query, '')) + except ValueError: + raise Failure(6, 'malformed', 'Provider redirect changes or obscures the HTTPS API origin') + + def capabilities(provider, version): names = ('status', 'review_comments', 'review_reply', 'review_resolve', 'merge', 'rebase_merge', 'fast_forward_only', 'auto_merge', 'merge_queue') @@ -146,66 +177,89 @@ class Client: route = ('/graphql' if self.host == 'github.com' else '/api/graphql') if path == 'graphql' else prefix + path authority = 'api.github.com' if self.provider == 'github' and self.host == 'github.com' and not public else self.host url = 'https://' + authority + (path if public else route) - args = ['curl', '--silent', '--show-error', '--include', '--max-time', '30', '--request', method, '--header', 'Accept: application/json', '--header', 'User-Agent: mosaic-forge-wrapper'] - if body is not None: - args += ['--header', 'Content-Type: application/json', '--data-binary', '@-'] - args += ['--url', url] - if not public: - if len(token) > 4096 or any(ord(c) < 32 or ord(c) == 127 for c in token): - raise Failure(5, 'access', 'Credential contains invalid control characters') - escaped = token.replace('\\', '\\\\').replace('"', '\\"') - # Anonymous pipe: no credential pathname survives termination. The body - # retains stdin, while curl reads authentication from the inherited fd. - read_fd, write_fd = os.pipe() - try: - os.write(write_fd, ('header = "Authorization: token ' + escaped + '"\n').encode()) - os.close(write_fd) - write_fd = None - self.auth_fd = read_fd - p = self.transport(args + ['--config', '/dev/fd/' + str(read_fd)], body, writing) - finally: - self.auth_fd = None - os.close(read_fd) - if write_fd is not None: + return self._request(url, method, body, public, token if not public else None, writing) + + def _request(self, url, method, body, public, token, writing): + initial = urllib.parse.urlsplit(url) + origin = (initial.scheme.lower(), initial.hostname.lower(), initial.port or 443) + visited = {(initial.hostname.lower(), initial.port or 443, initial.path or '/', initial.query)} + for hop in range(MAX_REDIRECTS + 1): + args = ['curl', '--disable', '--silent', '--show-error', '--include', '--max-time', '30', '--request', method, '--header', 'Accept: application/json', '--header', 'User-Agent: mosaic-forge-wrapper'] + if body is not None: + args += ['--header', 'Content-Type: application/json', '--data-binary', '@-'] + args += ['--url', url] + if not public: + if len(token) > 4096 or any(ord(c) < 32 or ord(c) == 127 for c in token): + raise Failure(5, 'access', 'Credential contains invalid control characters') + escaped = token.replace('\\', '\\\\').replace('"', '\\"') + # Anonymous pipe: no credential pathname survives termination. The body + # retains stdin, while curl reads authentication from the inherited fd. + read_fd, write_fd = os.pipe() + try: + os.write(write_fd, ('header = "Authorization: token ' + escaped + '"\n').encode()) os.close(write_fd) - else: - p = self.transport(args, body, writing) - if p.returncode: - raise Failure(75, 'transient', 'Provider transport failed; reconcile before retrying a mutation', outcome='unknown' if writing else 'none') - raw = p.stdout.replace('\r\n', '\n') - # Proxies can emit an initial CONNECT response. Only consume header blocks. - status, headers = None, {} - while raw.startswith('HTTP/'): - block, sep, raw = raw.partition('\n\n') - if not sep: - raise Failure(6, 'malformed', 'Provider response headers incomplete') - lines = block.splitlines() + write_fd = None + self.auth_fd = read_fd + p = self.transport(args + ['--config', '/dev/fd/' + str(read_fd)], body, writing) + finally: + self.auth_fd = None + os.close(read_fd) + if write_fd is not None: + os.close(write_fd) + else: + p = self.transport(args, body, writing) + if p.returncode: + raise Failure(75, 'transient', 'Provider transport failed; reconcile before retrying a mutation', outcome='unknown' if writing else 'none') + raw = p.stdout.replace('\r\n', '\n') + # Proxies can emit an initial CONNECT response. Only consume header blocks. + status, headers = None, {} + while raw.startswith('HTTP/'): + block, sep, raw = raw.partition('\n\n') + if not sep: + raise Failure(6, 'malformed', 'Provider response headers incomplete') + lines = block.splitlines() + try: + status = int(lines[0].split()[1]) + except (ValueError, IndexError): + raise Failure(6, 'malformed', 'Provider HTTP status malformed') + headers = {k.lower().strip(): v.strip() for line in lines[1:] if ':' in line for k, v in [line.split(':', 1)]} + if status is None: + raise Failure(6, 'malformed', 'Provider did not return HTTP metadata') + if status in REDIRECT_STATUSES: + if method != 'GET': + raise Failure(3 if writing else 6, 'gate' if writing else 'malformed', 'Provider mutation or non-GET redirect refused', outcome='unknown' if writing else 'none') + locations = [(k, v.strip()) for line in lines[1:] if ':' in line for k, v in [line.split(':', 1)] if k.lower().strip() == 'location'] + if (len(locations) != 1 or locations[0][0].lower() != 'location' + or any(line[:1].isspace() or ':' not in line for line in lines[1:])): + raise Failure(6, 'malformed', 'Provider redirect Location is missing or ambiguous') + target = redirect_target(url, locations[0][1], origin) + # Normalize default ports for loop detection as well as origin checks. + parts = urllib.parse.urlsplit(target) + identity = (parts.hostname.lower(), parts.port or 443, parts.path, parts.query) + if identity in visited or hop >= MAX_REDIRECTS: + raise Failure(6, 'malformed', 'Provider redirect loop or hop limit exceeded') + visited.add(identity) + url = target + continue + if status == 429 or status == 403 and (headers.get('x-ratelimit-remaining') == '0' or 'retry-after' in headers): + retry = headers.get('retry-after') + if retry is None: + reset = headers.get('x-ratelimit-reset','') + retry = str(max(1,int(reset)-int(time.time()))) if reset.isdigit() else '60' + raise Failure(75, 'rate_limit', 'Provider rate limit; use a bounded later wake', int(retry) if retry.isdigit() else 60, 'unknown' if writing else 'none') + if status in (401, 403, 404): + raise Failure(5, 'access', 'Provider access denied or resource hidden (HTTP %s)' % status, outcome='unknown' if writing else 'none') + if status >= 500: + raise Failure(75, 'transient', 'Provider server error', outcome='unknown' if writing else 'none') + if not 200 <= status < 300: + raise Failure(3 if writing else 6, 'gate' if writing else 'malformed', 'Provider refused request (HTTP %s)' % status) + if not raw.strip() and status == 204: + return None try: - status = int(lines[0].split()[1]) - except (ValueError, IndexError): - raise Failure(6, 'malformed', 'Provider HTTP status malformed') - headers = {k.lower().strip(): v.strip() for line in lines[1:] if ':' in line for k, v in [line.split(':', 1)]} - if status is None: - raise Failure(6, 'malformed', 'Provider did not return HTTP metadata') - if status == 429 or status == 403 and (headers.get('x-ratelimit-remaining') == '0' or 'retry-after' in headers): - retry = headers.get('retry-after') - if retry is None: - reset = headers.get('x-ratelimit-reset','') - retry = str(max(1,int(reset)-int(time.time()))) if reset.isdigit() else '60' - raise Failure(75, 'rate_limit', 'Provider rate limit; use a bounded later wake', int(retry) if retry.isdigit() else 60, 'unknown' if writing else 'none') - if status in (401, 403, 404): - raise Failure(5, 'access', 'Provider access denied or resource hidden (HTTP %s)' % status, outcome='unknown' if writing else 'none') - if status >= 500: - raise Failure(75, 'transient', 'Provider server error', outcome='unknown' if writing else 'none') - if not 200 <= status < 300: - raise Failure(3 if writing else 6, 'gate' if writing else 'malformed', 'Provider refused request (HTTP %s)' % status) - if not raw.strip() and status == 204: - return None - try: - result = json.loads(raw) - except ValueError: - raise Failure(6, 'malformed', 'Provider returned invalid JSON', outcome='unknown' if writing else 'none') - return result + result = json.loads(raw) + except ValueError: + raise Failure(6, 'malformed', 'Provider returned invalid JSON', outcome='unknown' if writing else 'none') + return result def transport(self, args, body, writing): if writing: diff --git a/packages/mosaic/framework/tools/git/test-forge-cli.py b/packages/mosaic/framework/tools/git/test-forge-cli.py index 0fc06ef2e..c39aaae3c 100644 --- a/packages/mosaic/framework/tools/git/test-forge-cli.py +++ b/packages/mosaic/framework/tools/git/test-forge-cli.py @@ -149,6 +149,17 @@ class CLI(unittest.TestCase): p=subprocess.run([str(self.root/'curl'),'--request','GET','--url',url],env=self.env,capture_output=True,text=True) self.assertEqual(p.returncode,98,url) + def test_github_redirects_bind_actual_api_authority(self): + for target,expected in [('https://api.github.com/final',0),('https://github.com/final',6)]: + self.fixtures={'GET /probe':{'status':302,'headers':{'Location':target},'data':{}},'GET /final':{'data':{'ok':True}}} + log=self.root/'calls.jsonl' + if log.exists():log.unlink() + self.write_responses() + program='import importlib.util,json,sys; s=importlib.util.spec_from_file_location("a",sys.argv[1]); a=importlib.util.module_from_spec(s); s.loader.exec_module(a); c=a.Client("github.com","owner/repo"); c.provider="github"\ntry:\n print(json.dumps(c.http("/probe")))\nexcept a.Failure as e:sys.exit(e.code)\n' + p=subprocess.run(['python3','-I','-c',program,str(HERE/'forge-api.py')],env=self.env,cwd=self.root,capture_output=True,text=True) + self.assertEqual(p.returncode,expected,p.stderr) + self.assertEqual([c['url'] for c in self.calls()],['https://api.github.com/probe']+(['https://api.github.com/final'] if expected==0 else [])) + def test_profiles_and_unknown_host_fail_closed(self): for version,title,provider in [('28.2.0','Gitea API','gitea'),('1.24.7','Gitea API','gitea'),('16.0.0-dev','Forgejo API','forgejo')]: self.gitea(version,title) @@ -232,4 +243,176 @@ class CLI(unittest.TestCase): self.assertFalse(value['data']['checks']['complete']) self.assertEqual(path.read_text(),before) + + +class Redirects(unittest.TestCase): + """Real curl against loopback TLS providers; no live forge or real credentials.""" + def setUp(self): + import http.server + import shutil + import ssl + import threading + self.tmp=tempfile.TemporaryDirectory(); self.addCleanup(self.tmp.cleanup) + self.root=pathlib.Path(self.tmp.name); self.home=self.root/'home'; self.home.mkdir() + self.routes={}; self.calls=[]; self.trap_calls=[] + cert=self.root/'cert.pem'; key=self.root/'key.pem' + p=subprocess.run(['openssl','req','-x509','-newkey','rsa:2048','-nodes','-keyout',str(key),'-out',str(cert),'-days','1','-subj','/CN=localhost','-addext','subjectAltName=DNS:localhost'],capture_output=True) + self.assertEqual(p.returncode,0,'local TLS fixture certificate creation failed') + owner=self + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self,*args): pass + def do_GET(self): self.respond() + def do_PUT(self): self.respond() + def do_POST(self): self.respond() + def respond(self): + from urllib.parse import urlsplit + trap=self.server is owner.trap + (owner.trap_calls if trap else owner.calls).append({'method':self.command,'path':self.path,'authenticated':self.headers.get('Authorization')=='token fixture-token'}) + if self.headers.get('Content-Length'): self.rfile.read(int(self.headers['Content-Length'])) + route=(self.command,urlsplit(self.path).path) + status,headers,data=(200,[],{'ok':True}) if trap else owner.routes.get(route,(418,[],{'unexpected':True})) + self.send_response(status) + for k,v in headers: self.send_header(k,v) + payload=json.dumps(data).encode() + self.send_header('Content-Type','application/json'); self.send_header('Content-Length',str(len(payload))); self.end_headers() + self.wfile.write(payload) + context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER);context.load_cert_chain(cert,key) + def server(): + s=http.server.ThreadingHTTPServer(('127.0.0.1',0),Handler);s.daemon_threads=True + s.socket=context.wrap_socket(s.socket,server_side=True) + t=threading.Thread(target=s.serve_forever,daemon=True);t.start() + self.addCleanup(lambda:(s.shutdown(),s.server_close(),t.join())) + return s + self.server=server();self.trap=server() + self.host='localhost:'+str(self.server.server_port);self.origin='https://'+self.host + self.trap_origin='https://localhost:'+str(self.trap.server_port) + token=self.home/'.mosaic/credentials/forge'/self.host/'fixture-user.token' + token.parent.mkdir(parents=True);token.write_text('fixture-token') + self.env={k:v for k,v in os.environ.items() if k in ('PATH','LANG','SYSTEMROOT')} + self.env.update(HOME=str(self.home),CURL_HOME=str(self.home),MOSAIC_BRAIN_HOME=str(self.home/'.mosaic'),MOSAIC_GIT_IDENTITY='fixture-user',GIT_CONFIG_GLOBAL='/dev/null',GIT_CONFIG_NOSYSTEM='1',GIT_TERMINAL_PROMPT='0',CURL_CA_BUNDLE=str(cert),NO_PROXY='localhost,127.0.0.1',TMPDIR=str(self.root),TMP=str(self.root),TEMP=str(self.root)) + # Use the installed curl, never an inherited fixture/denial client. + real_curl=shutil.which('curl',path='/usr/bin:/bin');self.assertIsNotNone(real_curl) + bin_dir=self.root/'bin';bin_dir.mkdir(); self.argv_log=self.root/'argv.jsonl' + shim=bin_dir/'curl' + shim.write_text('#!/bin/sh\nexec python3 -I '+str(self.root/'curl-probe.py')+' "$@"\n');shim.chmod(0o755) + (self.root/'curl-probe.py').write_text('import json,os,sys\na=sys.argv[1:]\nwith open('+repr(str(self.argv_log))+',"a") as f:f.write(json.dumps({"disable_first":bool(a) and a[0] in ("--disable","-q"),"argv_leak":any("fixture-token" in x for x in a)})+"\\n")\nos.execv('+repr(real_curl)+',['+repr(real_curl)+']+a)\n') + self.env['PATH']=str(bin_dir)+':/usr/local/bin:/usr/bin:/bin' + self.api_path=HERE/'forge-api.py' + self.routes[('GET','/swagger.v1.json')]=(200,[],{'info':{'title':'Gitea API','version':'1.24.7'}}) + self.routes[('GET','/api/v1/user')]=(200,[],{'login':'fixture-user'}) + self.head='a'*40 + + def direct(self,path='/probe',method='GET'): + program='import importlib.util,json,sys; s=importlib.util.spec_from_file_location("adapter",sys.argv[1]); a=importlib.util.module_from_spec(s); s.loader.exec_module(a); c=a.Client(sys.argv[2],"owner/repo"); c.provider="gitea"; c.actor_verified=True\ntry:\n print(json.dumps(c.http(sys.argv[3],sys.argv[4],{} if sys.argv[4]!="GET" else None)))\nexcept a.Failure as e:\n print(json.dumps({"code":e.code,"kind":e.kind,"outcome":e.outcome}));sys.exit(e.code)\n' + return subprocess.run(['python3','-I','-c',program,str(self.api_path),self.host,path,method],env=self.env,cwd=self.root,capture_output=True,text=True,timeout=20) + + def reset(self): self.calls.clear();self.trap_calls.clear() + def redirect(self,path,target,status=302,extra=()): + self.routes[('GET',path)]=(status,[('Location',target),*extra],{}) + def assert_refused(self,target): + self.reset();self.redirect('/api/v1/probe',target) + p=self.direct();self.assertNotEqual(p.returncode,0) + self.assertEqual(len(self.calls),1);self.assertEqual(self.trap_calls,[]) + + def assert_read_chain(self,status=302): + self.reset();self.redirect('/api/v1/probe','/relative',status) + self.redirect('/relative',self.origin+'/absolute',status) + self.routes[('GET','/absolute')]=(200,[],{'ok':True}) + p=self.direct();self.assertEqual(p.returncode,0,p.stderr) + self.assertEqual(json.loads(p.stdout),{'ok':True}) + self.assertEqual([c['path'] for c in self.calls],['/api/v1/probe','/relative','/absolute']) + self.assertTrue(all(c['authenticated'] for c in self.calls));self.assertEqual(self.trap_calls,[]) + + def test_read_redirects_relative_absolute_and_statuses(self): + for status in (301,302,303,307,308): + with self.subTest(status=status):self.assert_read_chain(status) + + def test_unsafe_redirects_never_receive_a_request(self): + for target in (self.trap_origin+'/leak','https://127.0.0.1:'+str(self.server.server_port)+'/leak','http://'+self.host+'/leak','https://user@'+self.host+'/leak','https://'+self.host+':/bad','https://localhost:65536/bad','https://localhost:0/bad','https:///missing','//','/bad\\path','/bad%0dpath','/bad%zz','/bad path','/bad#fragment','/one, /two'): + with self.subTest(target=target):self.assert_refused(target) + for headers in ([],[('Location','/one'),('location','/two')],[('Location','')],[('Location','/one\r\n folded')],[('Location','/one\x0bmalformed')],[('Location ','/one')]): + self.reset();self.routes[('GET','/api/v1/probe')]=(302,headers,{}) + p=self.direct();self.assertNotEqual(p.returncode,0);self.assertEqual(len(self.calls),1);self.assertEqual(self.trap_calls,[]) + + def test_redirect_cycles_and_hop_limit(self): + self.redirect('/api/v1/probe','/api/v1/probe');p=self.direct() + self.assertNotEqual(p.returncode,0);self.assertEqual(len(self.calls),1) + self.reset() + for i in range(8):self.redirect('/api/v1/probe' if i==0 else '/hop'+str(i),'/hop'+str(i+1)) + p=self.direct();self.assertNotEqual(p.returncode,0);self.assertEqual(len(self.calls),4);self.assertEqual(self.trap_calls,[]) + self.reset();self.routes[('GET','/hop3')]=(200,[],{'ok':True}) + p=self.direct();self.assertEqual(p.returncode,0);self.assertEqual(len(self.calls),4) + + def test_mutations_are_not_replayed(self): + for status in (301,302,303,307,308): + for method in ('POST','PUT'): + self.reset();self.routes[(method,'/api/v1/probe')]=(status,[('Location',self.origin+'/mutation-target')],{}) + p=self.direct(method=method);self.assertEqual(p.returncode,3) + self.assertEqual([(c['method'],c['path']) for c in self.calls],[(method,'/api/v1/probe')]);self.assertEqual(self.trap_calls,[]) + + def test_default_curl_configuration_cannot_add_routing(self): + (self.home/'.curlrc').write_text('location\nurl = "'+self.trap_origin+'/configuration-leak"\n') + self.routes[('GET','/api/v1/probe')]=(200,[],{'ok':True}) + p=self.direct();self.assertEqual(p.returncode,0,p.stderr);self.assertEqual(self.trap_calls,[]) + records=[json.loads(x) for x in self.argv_log.read_text().splitlines()] + self.assertTrue(all(x['disable_first'] and not x['argv_leak'] for x in records)) + (self.home/'.curlrc').write_text('location\n') + self.assert_refused(self.trap_origin+'/curl-config-redirect') + + def pr_routes(self): + self.routes.update({ + ('GET','/api/v1/repos/owner/repo/pulls/1'):(200,[],dict(number=1,state='open',draft=False,merged=False,mergeable=True,head={'sha':self.head,'ref':'task','repo':{'full_name':'fork/repo'}},base={'sha':'b'*40,'ref':'integration','repo':{'full_name':'owner/repo'}},user={'login':'author'})), + ('GET','/api/v1/repos/owner/repo/branch_protections'):(200,[],[{'branch_name':'integration','enable_status_check':True,'status_check_contexts':['build'],'required_approvals':1}]), + ('GET','/api/v1/repos/owner/repo/commits/'+self.head+'/statuses'):(200,[],[{'id':1,'context':'build','state':'success'}]), + ('GET','/api/v1/repos/owner/repo/pulls/1/reviews'):(200,[],[{'id':1,'state':'APPROVED','commit_id':self.head,'user':{'login':'reviewer'}}]), + ('GET','/api/v1/repos/owner/repo/pulls/1/reviews/1/comments'):(200,[],[]), + ('GET','/api/v1/repos/owner/repo/issues/1/comments'):(200,[],[]), + }) + + def test_ci_wait_entry_redirects_preserve_exact_head(self): + self.pr_routes() + key=('GET','/api/v1/repos/owner/repo/commits/'+self.head+'/statuses') + self.routes[('GET','/moved-statuses')]=self.routes[key] + self.redirect(key[1],'/relative-statuses');self.redirect('/relative-statuses',self.origin+'/moved-statuses',307) + p=subprocess.run(['bash','-x',str(HERE/'pr-ci-wait.sh'),'--host',self.host,'--repo','owner/repo','--json','-n','1','-t','1','-i','1','--expect-head',self.head],cwd=self.root,env=self.env,capture_output=True,text=True,timeout=20) + self.assertEqual(p.returncode,0,p.stderr);data=json.loads(p.stdout) + self.assertEqual(data['head_sha'],self.head);self.assertEqual(data['data']['head_repository'],'fork/repo');self.assertEqual(data['data']['checks']['state'],'success') + self.assertTrue(any(c['path'].startswith(key[1]) for c in self.calls));self.assertTrue(any(c['path']=='/moved-statuses' for c in self.calls)) + self.assertEqual(self.trap_calls,[]) + self.assertTrue(all(c['authenticated'] for c in self.calls if not c['path'].startswith('/swagger.v1.json'))) + self.assertNotIn('fixture-token',p.stdout+p.stderr) + records=[json.loads(x) for x in self.argv_log.read_text().splitlines()] + self.assertTrue(all(x['disable_first'] and not x['argv_leak'] for x in records)) + + def test_queue_wait_status_redirect_uses_anonymous_auth(self): + repo=self.root/'repo';repo.mkdir() + subprocess.run(['git','init','-q',str(repo)],env=self.env,check=True) + subprocess.run(['git','-C',str(repo),'remote','add','origin',self.origin+'/owner/repo.git'],env=self.env,check=True) + path='/api/v1/repos/owner/repo/commits/'+self.head+'/status' + self.redirect(path,'/queue-status');self.routes[('GET','/queue-status')]=(200,[],{'statuses':[{'context':'build','status':'success'}]}) + p=subprocess.run(['bash','-x',str(HERE/'ci-queue-wait.sh'),'--purpose','push','--require-status','--sha',self.head,'-B','task','-t','1','-i','1'],cwd=repo,env=self.env,capture_output=True,text=True,timeout=20) + self.assertEqual(p.returncode,0,'queue wait refused the successful fixture') + self.assertNotIn('fixture-token',p.stdout+p.stderr) + statuses=[c for c in self.calls if c['path'] in (path,'/queue-status')] + self.assertEqual([c['path'] for c in statuses],[path,'/queue-status']);self.assertTrue(all(c['authenticated'] for c in statuses)) + records=[json.loads(x) for x in self.argv_log.read_text().splitlines()];self.assertTrue(all(not x['argv_leak'] for x in records)) + + def test_redirect_security_mutants_are_detected(self): + # Independent positive/negative oracles must reject scratch-only mutants. + import shutil + copy=self.root/'mutant';shutil.copytree(HERE,copy) + self.api_path=copy/'forge-api.py';original=self.api_path.read_text() + for marker,replacement,oracle in [ + ('if status in REDIRECT_STATUSES:', 'if False:', lambda:self.assert_read_chain()), + ('if target_origin != origin:', 'if False:', lambda:self.assert_refused(self.trap_origin+'/leak')), + ("['curl', '--disable',", "['curl',", lambda:self.test_default_curl_configuration_cannot_add_routing()), + ]: + self.assertIn(marker,original);self.api_path.write_text(original.replace(marker,replacement,1));self.reset() + with self.assertRaises(AssertionError):oracle() + if 'target_origin' in marker:self.assertTrue(self.trap_calls);self.assertTrue(all(c['authenticated'] for c in self.trap_calls)) + if 'disable' in marker:self.assertTrue(self.trap_calls) + self.api_path.write_text(original) + (self.home/'.curlrc').unlink(missing_ok=True) + + if __name__=='__main__': unittest.main() diff --git a/packages/mosaic/src/commands/git-wrapper-redirects.spec.ts b/packages/mosaic/src/commands/git-wrapper-redirects.spec.ts index f0e60ce2d..1f0990821 100644 --- a/packages/mosaic/src/commands/git-wrapper-redirects.spec.ts +++ b/packages/mosaic/src/commands/git-wrapper-redirects.spec.ts @@ -1,22 +1,23 @@ -import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; const packageRoot = join(import.meta.dirname, '..', '..'); -const gitToolsDir = join(packageRoot, 'framework', 'tools', 'git'); - -function readGitTool(scriptName: string): string { - return readFileSync(join(gitToolsDir, scriptName), 'utf-8'); -} +const fixtureSuite = join(packageRoot, 'framework', 'tools', 'git', 'test-forge-cli.py'); describe('Gitea git wrapper API calls', () => { - it.each(['ci-queue-wait.sh', 'pr-ci-wait.sh'])( - '%s follows Gitea API redirects before parsing JSON', - (scriptName) => { - const script = readGitTool(scriptName); + it.each([ + ['ci-queue-wait.sh', 'test_queue_wait_status_redirect_uses_anonymous_auth'], + ['pr-ci-wait.sh', 'test_ci_wait_entry_redirects_preserve_exact_head'], + ])('%s follows fixture API redirects before parsing JSON', (_scriptName, control) => { + const result = spawnSync('python3', ['-I', fixtureSuite, `Redirects.${control}`], { + cwd: packageRoot, + env: process.env, + encoding: 'utf8', + timeout: 25_000, + }); - expect(script).not.toMatch(/curl -fsS\s+(?:-H "[^"]+"\s+)*-H "Authorization: token/); - expect(script).toMatch(/curl -fsSL\s+(?:-H "[^"]+"\s+)*-H "Authorization: token/); - }, - ); + expect(result.error).toBeUndefined(); + expect(result.status, result.stdout + result.stderr).toBe(0); + }); }); -- 2.54.0 From b03f3a70bd495c221e363f472882329e96bd856a Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 18:54:25 -0600 Subject: [PATCH 18/26] fix(framework): reject redirect URI glob expansion (#1841) Validate raw and resolved URI components before transport and disable curl globbing. Preserve encoded literal targets and prove overlapping defenses with real loopback TLS controls. Co-Authored-By: GPT-6.1-Sol (Codex) --- .../mosaic/framework/tools/git/forge-api.py | 43 +++++++++++----- .../framework/tools/git/test-forge-cli.py | 50 ++++++++++++++++++- 2 files changed, 79 insertions(+), 14 deletions(-) diff --git a/packages/mosaic/framework/tools/git/forge-api.py b/packages/mosaic/framework/tools/git/forge-api.py index 531e157b2..ef5cbf633 100644 --- a/packages/mosaic/framework/tools/git/forge-api.py +++ b/packages/mosaic/framework/tools/git/forge-api.py @@ -8,6 +8,7 @@ import argparse import datetime import hashlib import json +import ipaddress import os import pathlib import re @@ -32,29 +33,45 @@ REDIRECT_STATUSES = (301, 302, 303, 307, 308) MAX_REDIRECTS = 3 +def redirect_uri(value): + """Check raw RFC 3986 URI components before parser normalization.""" + if (not value or re.search(r"[^A-Za-z0-9._~!$&'()*+;=:%/@?\[\]-]", value) + or re.search(r'%(?![0-9A-Fa-f]{2})|%(?:0[0-9A-Fa-f]|1[0-9A-Fa-f]|7[fF]|5[cC])', value)): + raise ValueError('invalid URI characters or encoding') + parts = urllib.parse.urlsplit(value) + # Brackets delimit an IP literal only in authority, never path/query globs. + if (not re.fullmatch(r"[A-Za-z0-9._~!$&'()*+;=:%/@-]*", parts.path) + or not re.fullmatch(r"[A-Za-z0-9._~!$&'()*+;=:%/@?-]*", parts.query)): + raise ValueError('invalid URI component') + return parts + + def redirect_target(current, location, origin): """Resolve one unambiguous HTTPS target without changing API authority.""" - if (not location or any(ord(c) <= 32 or ord(c) == 127 for c in location) - or any(c in location for c in ('\\', ',', '#')) - or re.search(r'%(?![0-9A-Fa-f]{2})|%(?:0[0-9A-Fa-f]|1[0-9A-Fa-f]|7[fF]|5[cC])', location)): - raise Failure(6, 'malformed', 'Provider redirect target is invalid') try: - given = urllib.parse.urlsplit(location) + given = redirect_uri(location) if (given.scheme and (given.scheme.lower() != 'https' or not given.netloc) or location.startswith('//') and not given.netloc): raise ValueError('incomplete or non-HTTPS target') - target = urllib.parse.urlsplit(urllib.parse.urljoin(current, location)) - if (target.scheme.lower() != 'https' or not target.hostname - or not re.fullmatch(r'[A-Za-z0-9.-]+', target.hostname) + target = redirect_uri(urllib.parse.urljoin(current, location)) + hostname = target.hostname + if hostname and ':' in hostname: + ipaddress.IPv6Address(hostname) + elif not hostname or not re.fullmatch(r'[A-Za-z0-9.-]+', hostname): + raise ValueError('invalid host') + if (target.scheme.lower() != 'https' or target.username is not None or target.password is not None or target.netloc.endswith(':') or target.fragment or target.port is not None and not 1 <= target.port <= 65535): raise ValueError('invalid authority') - target_origin = (target.scheme.lower(), target.hostname.lower(), target.port or 443) + target_origin = (target.scheme.lower(), hostname.lower(), target.port or 443) if target_origin != origin: raise ValueError('different API origin') - authority = target.hostname.lower() + (':' + str(target.port) if target.port is not None else '') - return urllib.parse.urlunsplit(('https', authority, target.path or '/', target.query, '')) + authority = ('[' + hostname.lower() + ']' if ':' in hostname else hostname.lower()) + authority += ':' + str(target.port) if target.port is not None else '' + resolved = urllib.parse.urlunsplit(('https', authority, target.path or '/', target.query, '')) + redirect_uri(resolved) + return resolved except ValueError: raise Failure(6, 'malformed', 'Provider redirect changes or obscures the HTTPS API origin') @@ -184,7 +201,7 @@ class Client: origin = (initial.scheme.lower(), initial.hostname.lower(), initial.port or 443) visited = {(initial.hostname.lower(), initial.port or 443, initial.path or '/', initial.query)} for hop in range(MAX_REDIRECTS + 1): - args = ['curl', '--disable', '--silent', '--show-error', '--include', '--max-time', '30', '--request', method, '--header', 'Accept: application/json', '--header', 'User-Agent: mosaic-forge-wrapper'] + args = ['curl', '--disable', '--globoff', '--silent', '--show-error', '--include', '--max-time', '30', '--request', method, '--header', 'Accept: application/json', '--header', 'User-Agent: mosaic-forge-wrapper'] if body is not None: args += ['--header', 'Content-Type: application/json', '--data-binary', '@-'] args += ['--url', url] @@ -228,7 +245,7 @@ class Client: if status in REDIRECT_STATUSES: if method != 'GET': raise Failure(3 if writing else 6, 'gate' if writing else 'malformed', 'Provider mutation or non-GET redirect refused', outcome='unknown' if writing else 'none') - locations = [(k, v.strip()) for line in lines[1:] if ':' in line for k, v in [line.split(':', 1)] if k.lower().strip() == 'location'] + locations = [(k, v.strip(' \t')) for line in lines[1:] if ':' in line for k, v in [line.split(':', 1)] if k.lower().strip() == 'location'] if (len(locations) != 1 or locations[0][0].lower() != 'location' or any(line[:1].isspace() or ':' not in line for line in lines[1:])): raise Failure(6, 'malformed', 'Provider redirect Location is missing or ambiguous') diff --git a/packages/mosaic/framework/tools/git/test-forge-cli.py b/packages/mosaic/framework/tools/git/test-forge-cli.py index c39aaae3c..05fbf95c8 100644 --- a/packages/mosaic/framework/tools/git/test-forge-cli.py +++ b/packages/mosaic/framework/tools/git/test-forge-cli.py @@ -295,7 +295,7 @@ class Redirects(unittest.TestCase): bin_dir=self.root/'bin';bin_dir.mkdir(); self.argv_log=self.root/'argv.jsonl' shim=bin_dir/'curl' shim.write_text('#!/bin/sh\nexec python3 -I '+str(self.root/'curl-probe.py')+' "$@"\n');shim.chmod(0o755) - (self.root/'curl-probe.py').write_text('import json,os,sys\na=sys.argv[1:]\nwith open('+repr(str(self.argv_log))+',"a") as f:f.write(json.dumps({"disable_first":bool(a) and a[0] in ("--disable","-q"),"argv_leak":any("fixture-token" in x for x in a)})+"\\n")\nos.execv('+repr(real_curl)+',['+repr(real_curl)+']+a)\n') + (self.root/'curl-probe.py').write_text('import json,os,sys\na=sys.argv[1:]\nwith open('+repr(str(self.argv_log))+',"a") as f:f.write(json.dumps({"disable_first":bool(a) and a[0] in ("--disable","-q"),"globoff":"--globoff" in a,"argv_leak":any("fixture-token" in x for x in a)})+"\\n")\nos.execv('+repr(real_curl)+',['+repr(real_curl)+']+a)\n') self.env['PATH']=str(bin_dir)+':/usr/local/bin:/usr/bin:/bin' self.api_path=HERE/'forge-api.py' self.routes[('GET','/swagger.v1.json')]=(200,[],{'info':{'title':'Gitea API','version':'1.24.7'}}) @@ -334,6 +334,54 @@ class Redirects(unittest.TestCase): self.reset();self.routes[('GET','/api/v1/probe')]=(302,headers,{}) p=self.direct();self.assertNotEqual(p.returncode,0);self.assertEqual(len(self.calls),1);self.assertEqual(self.trap_calls,[]) + def test_redirect_uri_characters_and_encoded_literals(self): + malformed=('/range[1-4]','/plain?page=[1-4]','/single{word}','/list{one,two}', + '/bad"path','/bad|path','/badpath','/bad^path','/bad`path', + '/bad\\path','/bad%','/bad%2','/bad%GG','/bad%00','/bad%1F','/bad%7f', + '/bad%5c','/bad\x01path','/bad\tpath','/bad\x0bpath') + for target in malformed: + for absolute in (False,True): + with self.subTest(target=target,absolute=absolute): + self.assert_refused((self.origin if absolute else '')+target) + for target in ('/ordinary?x=one&y=two','/literal%5B1-4%5D?x=%5B1-4%5D', + '/literal%7Bword%7D?x=%7Bone%2Ctwo%7D', + '/literal%22%7C%20?x=%22%7C%20',"/p!$&'()*+;=:@-._~?x=/:?@!$&'()*+;=~"): + for absolute in (False,True): + with self.subTest(target=target,absolute=absolute): + self.reset();self.redirect('/api/v1/probe',(self.origin if absolute else '')+target) + self.routes[('GET',target.split('?',1)[0])]=(200,[],{'ok':True}) + p=self.direct();self.assertEqual(p.returncode,0,p.stderr) + self.assertEqual([x['path'] for x in self.calls],['/api/v1/probe',target]) + self.assertTrue(all(x['authenticated'] for x in self.calls));self.assertEqual(self.trap_calls,[]) + records=[json.loads(x) for x in self.argv_log.read_text().splitlines()] + self.assertTrue(all(x['disable_first'] and x['globoff'] and not x['argv_leak'] for x in records)) + + def test_raw_uri_validation_precedes_parser_normalization(self): + import importlib.util + spec=importlib.util.spec_from_file_location('uri_adapter',self.api_path) + adapter=importlib.util.module_from_spec(spec);spec.loader.exec_module(adapter) + for value in (' /safe','\x00/safe','\n/safe','/safe\tquery','/safe\rquery','/safe\nquery'): + with self.assertRaises(ValueError):adapter.redirect_uri(value) + # RFC IP-literal authority brackets are not path/query glob brackets. + self.assertEqual(adapter.redirect_target('https://[::1]:444/start','https://[::1]:444/safe?x=%5B1%5D',('https','::1',444)), 'https://[::1]:444/safe?x=%5B1%5D') + + def test_uri_validation_and_globoff_overlap_controls(self): + # Deliberately bypass URI validation in a scratch copy: globoff must still + # send one literal target. Removing both defenses exposes curl expansion. + import shutil + copy=self.root/'uri-mutant';shutil.copytree(HERE,copy) + self.api_path=copy/'forge-api.py';original=self.api_path.read_text() + bypass=original.replace('given = redirect_uri(location)','given = urllib.parse.urlsplit(location)').replace('target = redirect_uri(urllib.parse.urljoin(current, location))','target = urllib.parse.urlsplit(urllib.parse.urljoin(current, location))').replace(' redirect_uri(resolved)\n','') + self.assertNotEqual(bypass,original);self.api_path.write_text(bypass) + with self.assertRaises(AssertionError):self.assert_refused('/range[1-4]') + self.assertEqual([x['path'] for x in self.calls],['/api/v1/probe','/range[1-4]']) + self.assertTrue(all(x['authenticated'] for x in self.calls)) + self.api_path.write_text(bypass.replace("'--globoff', ",''));self.reset() + self.direct() + self.assertEqual([x['path'] for x in self.calls],['/api/v1/probe','/range1','/range2','/range3','/range4']) + self.assertTrue(all(x['authenticated'] for x in self.calls));self.assertEqual(self.trap_calls,[]) + self.api_path.write_text(original) + def test_redirect_cycles_and_hop_limit(self): self.redirect('/api/v1/probe','/api/v1/probe');p=self.direct() self.assertNotEqual(p.returncode,0);self.assertEqual(len(self.calls),1) -- 2.54.0 From 1a569f734ccee1820f435372598dc6dad160f001 Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 19:00:27 -0600 Subject: [PATCH 19/26] test(framework): isolate local PR diff provider fixture (#1841) Inject a synthetic Gitea detector only for owned local fixture origins. Keep actual fetch/ref/diff and fail-closed reviewer assertions intact. Co-Authored-By: GPT-6.1-Sol (Codex) --- .../tools/codex/test-pr-diff-context.sh | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/framework/tools/codex/test-pr-diff-context.sh b/packages/mosaic/framework/tools/codex/test-pr-diff-context.sh index 518935817..0e606ab72 100644 --- a/packages/mosaic/framework/tools/codex/test-pr-diff-context.sh +++ b/packages/mosaic/framework/tools/codex/test-pr-diff-context.sh @@ -82,6 +82,27 @@ STUB chmod +x "$FAKE_BIN/tea" "$FAKE_BIN/codex" export PATH="$FAKE_BIN:$PATH" +# The local bare repositories have no forge authority. Inject a synthetic +# provider only for these owned fixture origins; production detection stays strict. +FIXTURE_DETECTOR="$TMP_DIR/detect-platform.sh" +export PR_FIXTURE_ROOT="$TMP_DIR" DETECT_LOG="$TMP_DIR/detect.log" +: > "$DETECT_LOG" +cat > "$FIXTURE_DETECTOR" <<'STUB' +detect_platform() { + local remote_url + remote_url=$(git remote get-url origin) || return 1 + case "$remote_url" in + "$PR_FIXTURE_ROOT"/*/origin.git) + PLATFORM=gitea + export PLATFORM + printf 'fixture-gitea\n' >> "$DETECT_LOG" + printf 'gitea\n' + ;; + *) return 1 ;; + esac +} +STUB + # The valid fixture is a fresh clone on the non-main base. The PR head exists only # at refs/pull/795/head, so local HEAD cannot accidentally satisfy the assertion. if [[ "${1:-all}" != "fail-closed" ]]; then @@ -90,6 +111,8 @@ if [[ "${1:-all}" != "fail-closed" ]]; then cd "$VALID_WORK" # shellcheck source=common.sh source "$SCRIPT_DIR/common.sh" + # shellcheck source=/dev/null + source "$FIXTURE_DETECTOR" diff_context=$(build_diff_context pr 795) assert_contains "$diff_context" "actual-pr-change" @@ -123,7 +146,7 @@ if [[ "${1:-all}" != "pr-head" ]]; then cp "$SCRIPT_DIR/schemas/code-review-schema.json" \ "$SCRIPT_DIR/schemas/security-review-schema.json" \ "$SANDBOX/tools/codex/schemas/" - cp "$SCRIPT_DIR/../git/detect-platform.sh" "$SANDBOX/tools/git/" + cp "$FIXTURE_DETECTOR" "$SANDBOX/tools/git/detect-platform.sh" cat > "$SANDBOX/tools/git/pr-review.sh" <<'STUB' #!/bin/bash @@ -156,3 +179,9 @@ STUB echo "ok - empty ${review_kind} PR diff fails closed before Codex and auto-post" done fi + +expected_detections=3 +[[ "${1:-all}" == "pr-head" ]] && expected_detections=1 +[[ "${1:-all}" == "fail-closed" ]] && expected_detections=2 +[[ "$(wc -l < "$DETECT_LOG")" -eq "$expected_detections" ]] || \ + fail "synthetic provider seam was not used by every fixture path" -- 2.54.0 From d3a2b8c8704fa3dfc0f415f72333ff61788a521f Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 19:40:54 -0600 Subject: [PATCH 20/26] fix(framework): validate raw provider header bytes (#1841) Capture transport bytes without universal newline conversion and validate CRLF framing and field bytes before parsing. Prove malformed redirects refuse before any follow-up while valid OWS and response blocks remain supported. Co-Authored-By: GPT-6.1-Sol (Codex) --- .../mosaic/framework/tools/git/forge-api.py | 32 +++++++++++-------- .../framework/tools/git/test-forge-cli.py | 29 +++++++++++++++-- .../tools/git/test-forge-contract.py | 15 +++++++-- 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/packages/mosaic/framework/tools/git/forge-api.py b/packages/mosaic/framework/tools/git/forge-api.py index ef5cbf633..b66b269d6 100644 --- a/packages/mosaic/framework/tools/git/forge-api.py +++ b/packages/mosaic/framework/tools/git/forge-api.py @@ -138,9 +138,9 @@ def reviews(items, head, author, required_count=1): return dict(decision='changes_requested' if rejected else 'approved' if approved else 'missing', reviewed_sha=head if approved else None, complete=True, approved=approved) -def run(argv, data=None, cwd=None, env=None, pass_fds=()): +def run(argv, data=None, cwd=None, env=None, pass_fds=(), binary=False): try: - return subprocess.run(argv, input=data, text=True, capture_output=True, timeout=45, cwd=cwd, env=env, pass_fds=pass_fds) + return subprocess.run(argv, input=data.encode() if binary and data is not None else data, text=not binary, capture_output=True, timeout=45, cwd=cwd, env=env, pass_fds=pass_fds) except (OSError, subprocess.TimeoutExpired): raise Failure(75, 'transient', 'Provider client unavailable or timed out') @@ -227,19 +227,25 @@ class Client: p = self.transport(args, body, writing) if p.returncode: raise Failure(75, 'transient', 'Provider transport failed; reconcile before retrying a mutation', outcome='unknown' if writing else 'none') - raw = p.stdout.replace('\r\n', '\n') - # Proxies can emit an initial CONNECT response. Only consume header blocks. + # Preserve curl's bytes: text-mode universal newlines can turn a + # forbidden bare CR into a plausible header boundary before validation. + raw = p.stdout + if not isinstance(raw, bytes): + raise Failure(6, 'malformed', 'Provider response bytes missing') status, headers = None, {} - while raw.startswith('HTTP/'): - block, sep, raw = raw.partition('\n\n') + # Proxies can emit CONNECT and servers interim response blocks. + while raw.startswith(b'HTTP/'): + block, sep, raw = raw.partition(b'\r\n\r\n') if not sep: raise Failure(6, 'malformed', 'Provider response headers incomplete') - lines = block.splitlines() - try: - status = int(lines[0].split()[1]) - except (ValueError, IndexError): - raise Failure(6, 'malformed', 'Provider HTTP status malformed') - headers = {k.lower().strip(): v.strip() for line in lines[1:] if ':' in line for k, v in [line.split(':', 1)]} + byte_lines = block.split(b'\r\n') + if (not re.fullmatch(rb'HTTP/[0-9.]+ [0-9]{3}(?: [\x09\x20-\x7e\x80-\xff]*)?', byte_lines[0]) + or any(not re.fullmatch(rb"[!#$%&'*+.^_`|~0-9A-Za-z-]+:[\x09\x20-\x7e\x80-\xff]*", line) for line in byte_lines[1:])): + raise Failure(6, 'malformed', 'Provider response header framing or value is invalid') + # Only validated CRLF separators and field values reach text parsing. + lines = [line.decode('latin-1') for line in byte_lines] + status = int(lines[0].split()[1]) + headers = {k.lower(): v.strip(' \t') for line in lines[1:] for k, v in [line.split(':', 1)]} if status is None: raise Failure(6, 'malformed', 'Provider did not return HTTP metadata') if status in REDIRECT_STATUSES: @@ -282,7 +288,7 @@ class Client: if writing: self.write_attempted = True try: - return run(args, json.dumps(body) if body is not None else None, pass_fds=(self.auth_fd,) if getattr(self, 'auth_fd', None) is not None else ()) + return run(args, json.dumps(body) if body is not None else None, pass_fds=(self.auth_fd,) if getattr(self, 'auth_fd', None) is not None else (), binary=True) except Failure as e: if writing: e.outcome = 'unknown' diff --git a/packages/mosaic/framework/tools/git/test-forge-cli.py b/packages/mosaic/framework/tools/git/test-forge-cli.py index 05fbf95c8..771dc362e 100644 --- a/packages/mosaic/framework/tools/git/test-forge-cli.py +++ b/packages/mosaic/framework/tools/git/test-forge-cli.py @@ -35,9 +35,9 @@ if '--input' in args or '--data-binary' in args: body=json.load(sys.stdin) with open(os.environ['FAKE_LOG'],'a') as f: f.write(json.dumps({'method':method,'url':url,'path':path,'query':query,'body':body})+'\\n') status=response.get('status',200) -print('HTTP/2 '+str(status)) -for k,v in response.get('headers',{}).items(): print(k+': '+str(v)) -print() +print('HTTP/2 '+str(status),end='\\r\\n') +for k,v in response.get('headers',{}).items(): print(k+': '+str(v),end='\\r\\n') +print(end='\\r\\n') print(json.dumps(response.get('data'))) sys.exit(1 if pathlib.Path(sys.argv[0]).name=='gh' and status>=400 else 0) ''' @@ -271,6 +271,8 @@ class Redirects(unittest.TestCase): if self.headers.get('Content-Length'): self.rfile.read(int(self.headers['Content-Length'])) route=(self.command,urlsplit(self.path).path) status,headers,data=(200,[],{'ok':True}) if trap else owner.routes.get(route,(418,[],{'unexpected':True})) + if isinstance(data,bytes): + self.connection.sendall(data);self.close_connection=True;return self.send_response(status) for k,v in headers: self.send_header(k,v) payload=json.dumps(data).encode() @@ -356,6 +358,27 @@ class Redirects(unittest.TestCase): records=[json.loads(x) for x in self.argv_log.read_text().splitlines()] self.assertTrue(all(x['disable_first'] and x['globoff'] and not x['argv_leak'] for x in records)) + def test_raw_response_header_framing(self): + invalid=(b'Location: /same\rX-Header: value',b'Location: /same\nX-Header: value', + b'Location: /same\x00suffix',b'Location: /same\x0bsuffix', + b'Location: /same\x0csuffix',b'Location: /same\x7fsuffix', + b'Location: /same\r\n folded',b'Location : /same', + b'Location: /same\r\nX-Header: bad\rvalue',b'Location: /same\r\nX-Header: bad\nvalue') + for field in invalid: + self.reset();wire=b'HTTP/1.1 302 Found\r\n'+field+b'\r\nContent-Length: 2\r\n\r\n{}' + self.routes[('GET','/api/v1/probe')]=(302,[],wire) + p=self.direct();self.assertNotEqual(p.returncode,0) + self.assertEqual([x['path'] for x in self.calls],['/api/v1/probe']);self.assertEqual(self.trap_calls,[]) + # Real CRLF separates real fields; it is not a bare-CR Location value. + for prefix in (b'',b'HTTP/1.1 100 Continue\r\n\r\n'): + for target in (b'/same',b'/literal%5B1-4%5D?x=%7Bword%7D'): + self.reset();wire=prefix+b'HTTP/1.1 302 Found\treason\r\nLocation: \t'+target+b' \t\r\nX-Header: value\tvalue\r\nContent-Length: 2\r\n\r\n{}' + self.routes[('GET','/api/v1/probe')]=(302,[],wire) + self.routes[('GET',target.decode().split('?',1)[0])]=(200,[],{'ok':True}) + p=self.direct();self.assertEqual(p.returncode,0,(prefix,target,p.stderr)) + self.assertEqual([x['path'] for x in self.calls],['/api/v1/probe',target.decode()]) + self.assertTrue(all(x['authenticated'] for x in self.calls));self.assertEqual(self.trap_calls,[]) + def test_raw_uri_validation_precedes_parser_normalization(self): import importlib.util spec=importlib.util.spec_from_file_location('uri_adapter',self.api_path) diff --git a/packages/mosaic/framework/tools/git/test-forge-contract.py b/packages/mosaic/framework/tools/git/test-forge-contract.py index cb4147489..8c4b389ae 100644 --- a/packages/mosaic/framework/tools/git/test-forge-contract.py +++ b/packages/mosaic/framework/tools/git/test-forge-contract.py @@ -115,6 +115,17 @@ class SnapshotContract(Contract): class TransportContract(Contract): + def test_valid_connect_and_interim_blocks_preserve_body(self): + import types + import os + from unittest.mock import patch + for prefix in (b'HTTP/1.1 200 Connection established\r\n\r\n',b'HTTP/1.1 100 Continue\r\n\r\n'): + c=self.api.Client('github.com','owner/repo');c.provider='github' + c.token='synthetic';c.actor_verified=True + c.transport=lambda *a: types.SimpleNamespace(returncode=0,stdout=prefix+b'HTTP/2 200\r\nX-Header: value\tvalue\r\n\r\n{"ok":true}') + with patch.dict(os.environ,MOSAIC_GIT_IDENTITY='fixture-user'): + self.assertEqual(c.http('/repos/owner/repo'),{'ok':True}) + def test_access_and_rate_limit_remain_distinct(self): import types original = self.api.run @@ -127,7 +138,7 @@ class TransportContract(Contract): actor_patch.start() try: for status, headers, expected in [(403, '', 5), (404, '', 5), (429, 'Retry-After: 37\n', 75)]: - self.api.run = lambda *a, **kw: types.SimpleNamespace(returncode=0, stdout=f'HTTP/2 {status}\n{headers}\n{{"message":"denied"}}') + self.api.run = lambda *a, **kw: types.SimpleNamespace(returncode=0, stdout=(f'HTTP/2 {status}\n{headers}\n{{"message":"denied"}}').replace('\n','\r\n').encode()) with self.assertRaises(self.api.Failure) as ctx: c.http('/repos/owner/repo') self.assertEqual(ctx.exception.code, expected) @@ -147,7 +158,7 @@ class TransportContract(Contract): with self.subTest(provider=provider,host=host,code=code,method=method): c=self.api.Client(host,'owner/repo'); c.provider=provider c.token='synthetic'; c.actor_verified=True - c.transport=lambda *a: types.SimpleNamespace(returncode=code,stdout='HTTP/2 200\n\n{"ok":true}') + c.transport=lambda *a: types.SimpleNamespace(returncode=code,stdout=b'HTTP/2 200\r\n\r\n{"ok":true}') with patch.dict(os.environ,MOSAIC_GIT_IDENTITY='fixture-user'): if code==0: self.assertEqual(c.http('/repos/owner/repo',method),{'ok':True}) -- 2.54.0 From ade6f3d26f39fee1a833b6c57edd176ada4fd1d4 Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 19:41:04 -0600 Subject: [PATCH 21/26] test(framework): isolate issue usage fixture origins (#1841) Use owned empty Git fixtures so parse-acceptance arms cannot inherit the enclosing checkout remote. Retain no-provider assertions and deliberate GitHub normalization coverage. Co-Authored-By: GPT-6.1-Sol (Codex) --- .../tools/git/test-issue-close-usage-contract.sh | 6 ++++++ .../tools/git/test-issue-comment-usage-contract.sh | 8 ++++++++ .../tools/git/test-issue-reopen-usage-contract.sh | 6 ++++++ 3 files changed, 20 insertions(+) diff --git a/packages/mosaic/framework/tools/git/test-issue-close-usage-contract.sh b/packages/mosaic/framework/tools/git/test-issue-close-usage-contract.sh index b0ef6ae4f..f72f50fe6 100755 --- a/packages/mosaic/framework/tools/git/test-issue-close-usage-contract.sh +++ b/packages/mosaic/framework/tools/git/test-issue-close-usage-contract.sh @@ -32,6 +32,8 @@ cleanup() { trap cleanup EXIT mkdir -p "$BIN_DIR" +# Owned no-origin Git boundary prevents discovery of an enclosing checkout. +git -C "$WORK_DIR" init -q : > "$PROBE_LOG" for tool in gh tea curl; do @@ -67,6 +69,10 @@ fail() { exit 1 } +if git -C "$WORK_DIR" remote get-url origin >/dev/null 2>&1; then + fail "owned sandbox unexpectedly has an origin" +fi + expect_rc() { # expect_rc local want="$1" desc="$2" rc=0 shift 2 diff --git a/packages/mosaic/framework/tools/git/test-issue-comment-usage-contract.sh b/packages/mosaic/framework/tools/git/test-issue-comment-usage-contract.sh index 498d162be..fa8b01177 100755 --- a/packages/mosaic/framework/tools/git/test-issue-comment-usage-contract.sh +++ b/packages/mosaic/framework/tools/git/test-issue-comment-usage-contract.sh @@ -42,6 +42,10 @@ cleanup() { trap cleanup EXIT mkdir -p "$BIN_DIR" +# Keep sandbox parse-acceptance arms from discovering a surrounding checkout. +# This owned empty repository has no origin; the deliberate GitHub arm below +# still uses its own explicit remote. Production detection remains real. +git -C "$WORK_DIR" init -q : > "$PROBE_LOG" # Provider shims: any invocation is recorded and fails the run at the end. @@ -89,6 +93,10 @@ fail() { exit 1 } +if git -C "$WORK_DIR" remote get-url origin >/dev/null 2>&1; then + fail "owned sandbox unexpectedly has an origin" +fi + expect_rc() { # expect_rc local want="$1" desc="$2" rc=0 shift 2 diff --git a/packages/mosaic/framework/tools/git/test-issue-reopen-usage-contract.sh b/packages/mosaic/framework/tools/git/test-issue-reopen-usage-contract.sh index ff7e73439..41e30e55f 100755 --- a/packages/mosaic/framework/tools/git/test-issue-reopen-usage-contract.sh +++ b/packages/mosaic/framework/tools/git/test-issue-reopen-usage-contract.sh @@ -32,6 +32,8 @@ cleanup() { trap cleanup EXIT mkdir -p "$BIN_DIR" +# Owned no-origin Git boundary prevents discovery of an enclosing checkout. +git -C "$WORK_DIR" init -q : > "$PROBE_LOG" for tool in gh tea curl; do @@ -67,6 +69,10 @@ fail() { exit 1 } +if git -C "$WORK_DIR" remote get-url origin >/dev/null 2>&1; then + fail "owned sandbox unexpectedly has an origin" +fi + expect_rc() { # expect_rc local want="$1" desc="$2" rc=0 shift 2 -- 2.54.0 From de99f7263f41247406953f71240dfb93fd19f44c Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Wed, 4 Nov 2026 19:41:12 -0600 Subject: [PATCH 22/26] fix(framework): validate milestone title before provider reads (#1841) Refuse missing or empty creation titles before provider detection while preserving title-free listing. Prove zero-provider usage failures and unchanged synthetic list and creation payload behavior. Co-Authored-By: GPT-6.1-Sol (Codex) --- .../framework/tools/git/milestone-create.sh | 10 +++-- .../test-milestone-create-usage-contract.sh | 39 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/framework/tools/git/milestone-create.sh b/packages/mosaic/framework/tools/git/milestone-create.sh index c21acfce8..51884fb91 100755 --- a/packages/mosaic/framework/tools/git/milestone-create.sh +++ b/packages/mosaic/framework/tools/git/milestone-create.sh @@ -80,6 +80,12 @@ while [[ $# -gt 0 ]]; do esac done +# Creation usage errors must stop before provider detection or credentials. +# Listing does not require a title. +if [[ "$LIST_ONLY" != true && -z "$TITLE" ]]; then + usage_error "Title is required (-t) for creating milestones" +fi + PLATFORM=$(detect_platform) if [[ "$LIST_ONLY" == true ]]; then @@ -106,10 +112,6 @@ if [[ "$LIST_ONLY" == true ]]; then exit 0 fi -if [[ -z "$TITLE" ]]; then - usage_error "Title is required (-t) for creating milestones" -fi - case "$PLATFORM" in github) # GitHub uses the API for milestone creation diff --git a/packages/mosaic/framework/tools/git/test-milestone-create-usage-contract.sh b/packages/mosaic/framework/tools/git/test-milestone-create-usage-contract.sh index e267f9dc0..c49a89dd4 100755 --- a/packages/mosaic/framework/tools/git/test-milestone-create-usage-contract.sh +++ b/packages/mosaic/framework/tools/git/test-milestone-create-usage-contract.sh @@ -90,6 +90,7 @@ expect_stderr "[Uu]nknown option" "unknown option names itself on stderr" # 3. Missing required PR number: rc 2, stderr. expect_rc 2 "missing -t exits 2" +expect_rc 2 "empty title exits 2" -t "" expect_stderr "Title is required" "missing -t message on stderr" @@ -129,4 +130,42 @@ done # the arm above proves only parse acceptance and non-usage classification. # Parser-failure arms (1-4) remain zero-contact (asserted at 4b). +# Positive provider paths remain real wrapper dispatch with fixture-only gh. +# --list remains legal without a title; ordinary creation retains its JSON body. +GH_REPO="$WORK_DIR/github-fixture" +mkdir -p "$GH_REPO" +git -C "$GH_REPO" init -q +git -C "$GH_REPO" remote add origin https://github.com/fixture/widgets.git +: > "$PROBE_LOG" +PAYLOAD_LOG="$WORK_DIR/payload.json" +export PAYLOAD_LOG +cat > "$BIN_DIR/gh" <> "$PROBE_LOG" +if [[ "\$*" == "api repos/:owner/:repo/milestones --jq "* ]]; then + printf '7\tfixture-list\topen\t0/0\n' + exit 0 +fi +if [[ "\$*" == "api repos/:owner/:repo/milestones --method POST --input -" ]]; then + cat > "\$PAYLOAD_LOG" + printf '{"id":8}\n' + exit 0 +fi +exit 99 +STUB +chmod +x "$BIN_DIR/gh" +( + cd "$GH_REPO" + PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/milestone-create.sh" --list +) > "$OUT_FILE" 2> "$ERR_FILE" || fail "list without title failed" +grep -q 'fixture-list' "$OUT_FILE" || fail "list result missing" +( + cd "$GH_REPO" + PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/milestone-create.sh" -t 'fixture "title"' -d description --due 2030-01-02 +) > "$OUT_FILE" 2> "$ERR_FILE" || fail "valid creation failed" +grep -q 'created successfully' "$OUT_FILE" || fail "creation success missing" +jq -e '.title == "fixture \"title\"" and .description == "description" and .due_on == "2030-01-02T00:00:00Z"' "$PAYLOAD_LOG" >/dev/null || fail "creation payload changed" +[[ "$(wc -l < "$PROBE_LOG")" -eq 2 ]] || fail "unexpected positive-path provider contact" +[[ "$(grep -c '^gh api .* --method POST --input -$' "$PROBE_LOG")" -eq 1 ]] || fail "creation did not invoke expected provider method" + echo "milestone-create.sh usage-contract regression passed (R1/R4)" -- 2.54.0 From abab3a67e265c4a1997c5f1f0c578600a4a60fd8 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 11:47:16 -0600 Subject: [PATCH 23/26] fix(framework): enforce pinned forge merge policy --- .../framework/guides/TOOLS-REFERENCE.md | 4 +- packages/mosaic/framework/tools/git/README.md | 154 ++++-------------- .../mosaic/framework/tools/git/forge-api.py | 28 +++- .../framework/tools/git/merge-policy.py | 12 +- .../mosaic/framework/tools/git/pr-merge.sh | 88 ++++++---- .../mosaic/framework/tools/git/pr-metadata.sh | 24 ++- .../mosaic/framework/tools/git/repo-decl.sh | 18 +- .../framework/tools/git/test-forge-cli.py | 9 +- .../tools/git/test-forge-contract.py | 97 +++++++++++ .../framework/tools/git/test-merge-policy.py | 153 ++++++++++++++++- .../tools/git/test-pr-merge-baseline.sh | 2 +- .../tools/git/test-pr-merge-fork-ci-status.sh | 2 +- .../git/test-pr-merge-gitea-empty-uid.sh | 2 +- .../tools/git/test-pr-merge-head-pin.sh | 2 +- .../tools/git/test-pr-merge-message-field.sh | 2 +- .../tools/git/test-pr-merge-no-ci-expected.sh | 2 +- .../tools/git/test-pr-merge-queue-branch.sh | 2 +- .../tools/git/test-pr-metadata-gitea.sh | 4 +- .../tools/git/test-repo-decl-consumption.sh | 8 +- 19 files changed, 427 insertions(+), 186 deletions(-) diff --git a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md index 7c446bda8..75312f006 100644 --- a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md +++ b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md @@ -124,7 +124,9 @@ approve path carries the trap.) `pr-review.sh` sends the correct token for the d Whatever you use, re-read `GET /pulls/{n}/reviews` and assert the state before reporting a verdict placed. -The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. For a repository with no CI configured at all, `pr-merge.sh --no-ci-expected` is the sanctioned merge path: it forwards to `ci-queue-wait.sh --no-ci-expected`, which reclassifies a zero-context merge head as queue-clear only when the acting token holds repository admin and `MOSAIC_GIT_IDENTITY` names the asserting identity (a caller without one is refused with exit 78 before the admin lookup), and records the assertion (or its refusal) in the same JSONL audit log. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head ` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`. +The guard exits nonzero for non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT`; merge holds until evidence is available. The legacy `pr-merge.sh --no-ci-expected` path forwards that assertion to the queue guard, which requires repository admin and `MOSAIC_GIT_IDENTITY`. The legacy merge interface retains its options and consults the declaration at the PR's base commit. Pass `--expect-head ` to bind a verdict; the `--json` adapter requires it. Gitea uses an atomic head pin and GitHub passes the expected SHA. + +REST forge wrappers require `MOSAIC_GIT_IDENTITY` and an operator-provisioned token in the identity's host-specific store. For hosts other than `git.uscllc.com` and `git.mosaicstack.dev`, that store is `$MOSAIC_BRAIN_HOME/credentials/forge//.token` (brain defaults to `~/.mosaic`). The two fleet hosts use their per-seat or per-service token paths; see [API wrapper identity and credentials](../tools/git/README.md#api-wrapper-identity-and-credentials). API token resolution no longer falls back to git-config identity, shared `credentials.sh` tokens, `GITEA_TOKEN`, or `~/.git-credentials`. The separate tea login resolver retains its legacy git-config and host-default behavior; set the environment identity to align both paths. ### Code Review (Codex) diff --git a/packages/mosaic/framework/tools/git/README.md b/packages/mosaic/framework/tools/git/README.md index b25628f9c..5057f976a 100644 --- a/packages/mosaic/framework/tools/git/README.md +++ b/packages/mosaic/framework/tools/git/README.md @@ -40,109 +40,39 @@ Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login ` f 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. -## Per-agent Gitea identity (Gate-16 author≠reviewer) +## API wrapper identity and credentials -By default, git push/fetch (via `git-credential-mosaic`) and the API wrappers above (via -`detect-platform.sh`'s `get_gitea_token`) all authenticate as the single shared Gitea -account/token configured through `tools/_lib/credentials.sh`. That means every agent in a -fleet commits, pushes, and opens PRs under one identity — with no cryptographic -separation between an author and a reviewer. +The REST wrappers require `MOSAIC_GIT_IDENTITY` to name the acting account. They +select exactly one host-bound token file, fail closed when it is missing, and +verify `/user` against that identity before a write. Operators provision these +files; the wrappers do not create them. `` is `MOSAIC_BRAIN_HOME` when +set, otherwise `~/.mosaic`. -`git-credential-mosaic`, `get_gitea_token()`, and the `gitea-mosaicstack` / -`gitea-usc` arms of `load_credentials` resolve an optional **per-agent identity**: +| Host and identity | Token file | +| --- | --- | +| `git.mosaicstack.dev` or `git.uscllc.com`, with `/fleet/agents//` present | `/fleet/agents//secrets/gitea-{mosaicstack,usc}-.token` | +| Those hosts, other identities | `~/.config/mosaic/secrets/gitea-tokens/gitea-{mosaicstack,usc}-.token` | +| Any other host, including github.com | `/credentials/forge//.token` | -1. `MOSAIC_GIT_IDENTITY` environment variable, or -2. `git config --get mosaic.gitIdentity` (set per-worktree; persists on disk across - non-persistent shells — `git config mosaic.gitIdentity `), or -3. (git-credential-mosaic only) the username git itself supplies for the credential - request. +Use the actual hostname (and port when specified) as `` and the matching +instance name in the two fleet-host paths. Keep tokens private and out of git. +There is no fallback to another identity or store. -### Which store a credential is read from +**Migration:** set `MOSAIC_GIT_IDENTITY` explicitly and provision the appropriate +file before upgrading. API token resolution no longer falls back to git config +`mosaic.gitIdentity`, a shared `credentials.sh` token, `GITEA_TOKEN`, or +`~/.git-credentials`. The git credential helper and other service credential +loaders have their own contracts and are not configured by this change. -The store is chosen by what the identity **is**, not by which file happens to exist first: +The separate tea-based read paths resolve a tea login rather than a REST token; +their legacy resolver still accepts `git config mosaic.gitIdentity` or a host +default when the environment identity is absent. Set `MOSAIC_GIT_IDENTITY` for +both paths to avoid selecting different actors. Explicit `--login` overrides +remain host-bound as described above. -| The identity | Its credential is read from | -| ------------------------------------------------------------------ | -------------------------------------------------------------------------- | -| has a directory at `/fleet/agents//` — it is a **seat** | `/fleet/agents//secrets/gitea-{usc,mosaicstack}-.token` | -| does not — it is a **service identity** | `~/.config/mosaic/secrets/gitea-tokens/gitea-{usc,mosaicstack}-.token` | +### Enabling the Git credential helper for a clone -`` is `MOSAIC_BRAIN_HOME` if set, else `~/.mosaic` — the same resolution -`packages/mosaic/src/fleet/brain-home.ts` performs. - -The Gitea arms of `load_credentials` obtain a seat token through the production -`git-credential-mosaic` entrypoint, rather than reading the slot directly. That retains -the entrypoint's clean-environment and process-ancestry fence. The Gitea URL remains -provider configuration from the service store. A caller-supplied `GITEA_TOKEN` retains -its established environment precedence. - -**There is no precedence between the two stores and no fallback from one to the other.** -A seat whose slot is empty is refused even when a same-named token sits in the framework -store. One credential lives in exactly one location: a second copy is drift rather than -redundancy, and the way drift surfaces is a stale copy returning 401, which reads as a -revoked token and sends whoever debugs it to the wrong place. - -### What happens when nothing resolves - -| identity resolves | token in its store | host runs a fleet | result | -| ----------------- | ------------------ | ----------------- | ------------------------- | -| yes | yes | — | that identity + token | -| yes | no | — | **fail closed** | -| no | — | yes | **fail closed** | -| no | — | no | shared account, unchanged | - -A host "runs a fleet" when `/fleet/agents` exists — the same signal `brain-home.ts` -uses to decide a brain is active. - -Failing closed means: nothing is emitted, the exit status is nonzero, a stderr diagnostic -names the identity, its source, the store it resolved to and the path that was expected, -and `git-credential-mosaic` additionally appends a record (identity, host, reason, cwd — -never a token value) to `${MOSAIC_CREDENTIAL_SPOOL:-~/.local/state/mosaic-credential-escalations}`. -The git operation fails; nothing is attributed to anyone. - -The shared-account fallback that used to cover these two cases is why a PR could be -authored, commented and merged under an account whose owner did not open it — every seat -shared one identity, so the record could not be traced back afterwards. An -under-provisioned agent is refused rather than handed the most privileged account -available. - -**On a host with no fleet, nothing changes**: no `fleet/agents` directory means the shared -account still answers, so this is a no-op for an operator who has not provisioned per-slot -tokens. On a host that does run a fleet, a human doing manual git work needs an identity -of their own — `MOSAIC_GIT_IDENTITY=` with a provisioned slot. There is deliberately no -environment variable that restores the fallback; one would reintroduce exactly the -substitution this removes. - -### The tea path: login resolution (#1356) - -The wrappers that go through `tea` (`issue-list.sh`, `pr-list.sh`, `pr-view.sh`, -`lane-brief.sh`, and the tea half of `issue-close.sh`) cannot use a token directly: tea -0.14 only acts as a **login** already stored in `~/.config/tea/config.yml`. Those wrappers -therefore resolve a login name, not a token, and the resolution follows the same identity -as above: - -1. Resolve the identity (`MOSAIC_GIT_IDENTITY`, then `git config mosaic.gitIdentity`). -2. Derive the Gitea instance from the repo host (`git.mosaicstack.dev` → `mosaicstack`, - `git.uscllc.com` → `usc`), or from the owner when `--repo owner/name` is given. -3. The canonical login is `-`. If tea has it, that login acts. -4. If the identity is set but that login is missing, the wrapper **fails closed**: nonzero - exit, empty stdout, and a stderr line naming the login it wanted and the source of the - identity. When `tea` itself is not installed the message says so instead, since "no such - login" would send the reader to create a login they cannot create. -5. With **no identity set**, the old host-default behaviour is unchanged (first login - configured for that host, else the API fallback). - -Step 4 replaced a fallback that picked any login configured for the host, which meant a -seat with no login of its own silently acted as whichever seat had configured one. That -satisfied the author≠reviewer gate on paper while one actor held both names. - -**Provisioning the logins.** `tools/fleet/seat-logins.sh` projects each seat's token from -its secrets store into tea's config under the canonical name. tea's config is a derived -cache of the secrets store: regenerate it with the script, never hand-edit it. Run it with -`--seat ` for one seat (all seats when omitted), dry-run by default, `--apply` to write. A hand-made -alias holding a seat's token blocks its canonical name (tea refuses one token under two -names); `--adopt` renames it. - -### Enabling it for a clone +This section covers Git authentication, not the REST wrapper identity above. The framework installer syncs `git-credential-mosaic` to `~/.config/mosaic/tools/git/git-credential-mosaic` (executable) on every install/update, @@ -167,32 +97,6 @@ otherwise careful never to touch. Because identity is already resolved per-workt (`mosaic.gitIdentity`), the correct granularity for registering the helper is per-clone too, so a documented manual step is the right shape here, not a global auto-write. -### Running these tests - -`MOSAIC_GIT_IDENTITY` is inherited into each test's sandbox `HOME`, and **the tests disagree -about which value they need**, so no single ambient value passes all 29. Measured on `next` at -`a480ee83`, two full passes differing only in that variable: - -| tests | identity exported | identity unset | -| ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | -------------- | -| `gitea-login-resolution`, `issue-comment-readback`, `issue-create-interactive-auth`, `pr-edit`, `pr-merge-gitea-empty-uid`, `pr-metadata-gitea` | **fail** | pass | -| `issue-close-fail-closed` | pass | **fail** | -| remaining 22 | pass | pass | - -The six fail because inside a sandbox `HOME` the identity has no `fleet/agents//` -directory, so it is classified as a **service identity**, its store is unpopulated, and the -resolver correctly refuses with `Refusing to borrow another slot's token`. That is the -documented fail-closed behaviour above, reached from a state the test never intended. -`issue-close-fail-closed` is the mirror image: it asserts that no comment POST is attempted, so -it needs an identity resolving to an empty slot, and with the variable unset the shared account -answers and the POST goes through. - -These read as wrapper regressions rather than as environment. Two seats independently -misdiagnosed them as a patch defect while reviewing #1352. Until each test controls its own -value (#1353), `env -u MOSAIC_GIT_IDENTITY` is the closest thing to a clean run at 28/29, with -`issue-close-fail-closed` the expected failure — and **"the suite passes" is not a statement -anyone can make here without naming the ambient value that produced it.** - ### PowerShell parity `detect-platform.ps1`'s Gitea wrappers authenticate through `tea` CLI logins @@ -315,7 +219,7 @@ scheduling may merge immediately and returns observed merged state rather than q Schema 1/2 remain readable. Schema 3 adds optional `merge`, with strict key/value validation through the shared declaration validator and repo-decl helper. Consumers apply the existing -branch gates to both versions 2 and 3. Absent merge policy means allowed=[squash], default=squash. +branch gates to both versions 2 and 3. Absent merge policy means allowed=[squash], default=squash. The legacy merge interface retains its options and reads a declaration from the PR base commit; a missing or unreadable declaration uses the legacy path. It refuses when that policy selects a non-squash default, unless an explicit squash is allowed. Explicit `--json` calls use the adapter and require `--expect-head`. ```json { @@ -334,8 +238,8 @@ branch gates to both versions 2 and 3. Absent merge policy means allowed=[squash ``` `allowed_methods` is non-empty and unique; default and shape methods must belong to it. -Unknown keys/shape names fail validation. Promotion is declared trunk→release; absorb is -release→trunk and requires its explicit shape mapping; other allowed contributions are task. +Unknown keys/shape names fail validation. Promotion is declared trunk→release within the same repository; absorb is +release→trunk within the same repository and requires its explicit shape mapping; other allowed contributions are task. Other targets still fail transition validation. Method requests cannot override a shape mapping. Invalid declarations never fall back to a wider merge policy. diff --git a/packages/mosaic/framework/tools/git/forge-api.py b/packages/mosaic/framework/tools/git/forge-api.py index b66b269d6..c8e1e50b1 100644 --- a/packages/mosaic/framework/tools/git/forge-api.py +++ b/packages/mosaic/framework/tools/git/forge-api.py @@ -126,15 +126,20 @@ def checks(statuses, runs, required, allow_none=False): return dict(state=state, required=required, observed=observed, complete=True) -def reviews(items, head, author, required_count=1): +def reviews(items, head, author, required_count=1, provider='github', official_required=False): latest = {} for r in sorted(items, key=lambda r: r.get('id', 0)): login = (r.get('user') or {}).get('login') state = str(r.get('state', '')).upper() - if login and state not in ('COMMENTED', 'PENDING'): + if provider != 'github': + if r.get('dismissed') is True or state not in ('APPROVED', 'REQUEST_CHANGES'): + continue + elif state in ('COMMENTED', 'PENDING'): + continue + if login: latest[login] = r rejected = any(str(r.get('state')).upper() in ('REQUEST_CHANGES', 'CHANGES_REQUESTED') for r in latest.values()) - approved = sum(login != author and str(r.get('state')).upper() == 'APPROVED' and r.get('commit_id') == head for login, r in latest.items()) >= max(1, required_count) and not rejected + approved = sum(login != author and str(r.get('state')).upper() == 'APPROVED' and r.get('commit_id') == head and (not official_required or r.get('official') is True) for login, r in latest.items()) >= max(1, required_count) and not rejected return dict(decision='changes_requested' if rejected else 'approved' if approved else 'missing', reviewed_sha=head if approved else None, complete=True, approved=approved) @@ -276,7 +281,7 @@ class Client: raise Failure(75, 'transient', 'Provider server error', outcome='unknown' if writing else 'none') if not 200 <= status < 300: raise Failure(3 if writing else 6, 'gate' if writing else 'malformed', 'Provider refused request (HTTP %s)' % status) - if not raw.strip() and status == 204: + if not raw.strip() and (status == 204 or writing): return None try: result = json.loads(raw) @@ -309,7 +314,7 @@ class Client: if not isinstance(value, list): raise Failure(6, 'malformed', 'Provider collection is not a list') result.extend(value) - if len(value) < LIMIT: + if not value or self.provider == 'github' and len(value) < LIMIT: if len(result) < expected_total: raise Failure(6,'incomplete','Provider returned fewer records than total_count') return result @@ -465,6 +470,7 @@ def protection(client, base): return None, False r = matching[0] client.review_count = r.get('required_approvals',1) + client.review_official_required = r.get('enable_approvals_whitelist') is True return (r.get('status_check_contexts') if r.get('enable_status_check') is True else None), False @@ -482,7 +488,7 @@ def snapshot(client, number, expected): check = checks(statuses, runs, required) comments, threads, integration = review_comments(client, number) issue_comments = client.pages(root + '/issues/' + str(number) + '/comments') - review = reviews(comments['reviews'], head, (p.get('user') or {}).get('login'), getattr(client,'review_count',1)) + review = reviews(comments['reviews'], head, (p.get('user') or {}).get('login'), getattr(client,'review_count',1), client.provider, getattr(client,'review_official_required',False)) if getattr(client,'codeowners_required',False) and (integration or {}).get('review_decision') != 'APPROVED': review.update(approved=False,decision='codeowner_approval_unknown',reviewed_sha=None) unresolved = sum(t['isResolved'] is False for t in threads) if client.provider == 'github' else sum(c['resolved'] is False for c in comments['comments']) if all(c['resolved'] is not None for c in comments['comments']) else None @@ -651,7 +657,7 @@ def declared_method(client, p, requested): origin_normal = run([str(HERE.parent/'structure/validate-repo-json.sh'), '--normalize-remote', origin]) if normal.returncode or origin_normal.returncode or normal.stdout != origin_normal.stdout: raise Failure(3, 'policy', 'POLICY-REFUSED: target declaration canonical remote differs from origin') - args = ['python3', '-I', str(HERE/'merge-policy.py'), str(declaration), '--head', p['head']['ref'], '--base', base['ref']] + args = ['python3', '-I', str(HERE/'merge-policy.py'), str(declaration), '--head', p['head']['ref'], '--base', base['ref'], '--head-repo', (p['head'].get('repo') or {}).get('full_name') or '', '--base-repo', client.repo] if requested: args += ['--method', requested] result = run(args) @@ -692,8 +698,12 @@ def merge(client, number, requested, expected): key = 'do' if client.version == '28.2.0' else 'Do' client.http(client.root+'/pulls/'+str(number)+'/merge','POST',{key:method,'head_commit_id':expected}) try: - after = same_base(same_head(client,number,expected), p) - if after.get('merged') is not True or not after.get('merge_commit_sha'): + after = same_head(client,number,expected) + original_identity, after_identity = base_identity(p), base_identity(after) + if original_identity[:1] + original_identity[2:] != after_identity[:1] + after_identity[2:]: + raise Failure(75, 'base_moved', 'PR identity changed after merge', outcome='unknown') + merge_sha = after.get('merge_commit_sha') + if after.get('merged') is not True or not isinstance(merge_sha, str) or not re.fullmatch(r'[0-9a-fA-F]{40}', merge_sha): raise Failure(6,'verification','Merge not proven by PR readback',outcome='unknown') except Failure as e: e.outcome='unknown' diff --git a/packages/mosaic/framework/tools/git/merge-policy.py b/packages/mosaic/framework/tools/git/merge-policy.py index 3a6634a4c..18863a3da 100644 --- a/packages/mosaic/framework/tools/git/merge-policy.py +++ b/packages/mosaic/framework/tools/git/merge-policy.py @@ -28,12 +28,16 @@ def policy(doc): return dict(p, shapes=shapes) -def resolve(doc, head, base, request): +def resolve(doc, head, base, request, head_repo=None, base_repo=None): p = policy(doc) if not head or not base or head == base: return 3, {'error':'POLICY-REFUSED: head/base must identify a non-self transition'} trunk, release = doc.get('integration_trunk'), doc.get('release_branch') - shape = 'promotion' if head == trunk and base == release and trunk != release else 'absorb' if head == release and base == trunk and trunk != release else 'task' + special_pair = trunk != release and ((head == trunk and base == release) or (head == release and base == trunk)) + if special_pair and not (head_repo and base_repo): + return 3, {'error':'POLICY-REFUSED: promotion or absorb requires both repository identities'} + same_repo = bool(head_repo and base_repo and head_repo == base_repo) + shape = 'promotion' if same_repo and head == trunk and base == release and trunk != release else 'absorb' if same_repo and head == release and base == trunk and trunk != release else 'task' mapped = p['shapes'].get(shape) selected = request or mapped or p['default_method'] if selected not in p['allowed_methods'] or mapped and selected != mapped: @@ -52,13 +56,15 @@ def main(): parser.add_argument('--head') parser.add_argument('--base') parser.add_argument('--method') + parser.add_argument('--head-repo') + parser.add_argument('--base-repo') a = parser.parse_args() try: doc = json.loads(pathlib.Path(a.file).read_text()) if not isinstance(doc,dict): raise ValueError('declaration must be an object') p = policy(doc) - code, value = (0,p) if a.validate else resolve(doc,a.head,a.base,a.method) + code, value = (0,p) if a.validate else resolve(doc,a.head,a.base,a.method,a.head_repo,a.base_repo) except (OSError, ValueError, TypeError) as e: code, value = 6, {'error':'VALIDATION_ERROR merge: ' + str(e)} print(json.dumps(value, sort_keys=True)) diff --git a/packages/mosaic/framework/tools/git/pr-merge.sh b/packages/mosaic/framework/tools/git/pr-merge.sh index 19c2c8a72..afaa8faec 100755 --- a/packages/mosaic/framework/tools/git/pr-merge.sh +++ b/packages/mosaic/framework/tools/git/pr-merge.sh @@ -20,19 +20,23 @@ for ((arg_i=1; arg_i<=$#; arg_i++)); do exec python3 -I "$SCRIPT_DIR/forge-api.py" merge "$@" fi done -# A declared merge policy also applies to callers that have not adopted --json. -declaration_root=$(git rev-parse --show-toplevel 2>/dev/null) || declaration_root="" -if [[ -n "$declaration_root" && -f "$declaration_root/.mosaic/repo.json" ]] && python3 -I -c 'import json,sys -try: - d=json.load(open(sys.argv[1])); sys.exit(0 if isinstance(d,dict) and "merge" in d else 1) -except (ValueError,OSError): - sys.exit(0)' "$declaration_root/.mosaic/repo.json"; then - exec python3 -I "$SCRIPT_DIR/forge-api.py" merge "$@" -fi +# Preserve the original argument vector if pinned policy selects the adapter. +ORIGINAL_ARGS=("$@") +MERGE_TEMP_DIRS=() +cleanup_merge_temp_dirs() { + local path + for path in "${MERGE_TEMP_DIRS[@]}"; do + [[ -n "$path" ]] && rm -rf -- "$path" + done +} +trap cleanup_merge_temp_dirs EXIT +trap 'exit 130' INT +trap 'exit 143' TERM # Default values PR_NUMBER="" MERGE_METHOD="squash" +METHOD_EXPLICIT=false DELETE_BRANCH=false DRY_RUN=false EXPECT_HEAD="" @@ -83,6 +87,7 @@ while [[ $# -gt 0 ]]; do ;; -m|--method) MERGE_METHOD="$2" + METHOD_EXPLICIT=true shift 2 ;; -d|--delete-branch) @@ -142,10 +147,6 @@ if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then exit 1 fi -if [[ "$MERGE_METHOD" != "squash" ]]; then - echo "POLICY-REFUSED: absent merge policy permits squash only." >&2 - exit 3 -fi if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2 exit 1 @@ -165,20 +166,33 @@ fi PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")" BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')" +BASE_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefOid") or "").strip())')" +if [[ ! "$BASE_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "POLICY-REFUSED: PR base commit SHA is missing or invalid." >&2 + exit 3 +fi HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')" HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')" -HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')" -BASE_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("baseRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')" +HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; d=json.load(sys.stdin); value=d.get("headRepository") or ""; owner=d.get("headRepositoryOwner") or {}; name=value.get("name") or "" if isinstance(value,dict) else ""; login=owner.get("login") or "" if isinstance(owner,dict) else ""; print((value.get("nameWithOwner") or value.get("full_name") or (login+"/"+name if login and name else "")) if isinstance(value,dict) else str(value).strip())')" +BASE_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; d=json.load(sys.stdin); value=d.get("baseRepository") or ""; owner=d.get("baseRepositoryOwner") or {}; name=value.get("name") or "" if isinstance(value,dict) else ""; login=owner.get("login") or "" if isinstance(owner,dict) else ""; print((value.get("nameWithOwner") or value.get("full_name") or (login+"/"+name if login and name else "")) if isinstance(value,dict) else str(value).strip())')" PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')" PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')" # T51 WP5b: transition validation against the declaration (spec 4.1/4.2). # Target branches are validated against the declaration, NEVER hardcoded; # the legacy main/next check survives only for undeclared repos during the # rollout window (4.3 irreversible class, loud warning). +# A missing or unreadable declaration at the observed base keeps legacy behavior. +# Never load merge policy or transition data from the caller's working tree. +pinned_decl="" +pinned_dir=$(mktemp -d "${TMPDIR:-/tmp}/mosaic-merge-policy.XXXXXX") +MERGE_TEMP_DIRS+=("$pinned_dir") +if git show "$BASE_SHA:.mosaic/repo.json" > "$pinned_dir/repo.json" 2>/dev/null; then + pinned_decl="$pinned_dir/repo.json" +fi # shellcheck source=packages/mosaic/framework/tools/git/repo-decl.sh if [ -f "$SCRIPT_DIR/repo-decl.sh" ]; then source "$SCRIPT_DIR/repo-decl.sh" - repo_decl_load + repo_decl_load "$pinned_decl" else DECL_STATE=absent; DECL_SCHEMA="" # Compatibility callbacks are invoked by declaration checks below. @@ -191,6 +205,35 @@ else repo_decl_remote_matches() { return 0; } repo_decl_check_transition() { return 2; } fi +# A base declaration with a merge key governs both paths. Validate it before +# allowing legacy squash; non-squash requests go through the normalized adapter. +has_merge_policy=false +if [[ -n "${pinned_decl:-}" ]] && python3 -I -c 'import json,sys +try: + d=json.load(open(sys.argv[1])); sys.exit(0 if isinstance(d,dict) and "merge" in d else 1) +except (ValueError,OSError): sys.exit(1)' "$pinned_decl"; then + has_merge_policy=true +fi +if [[ "$has_merge_policy" == true ]]; then + if [[ "$DECL_STATE" != valid ]]; then + echo "POLICY-REFUSED: invalid pinned base merge declaration." >&2 + exit 3 + fi + if [[ "$MERGE_METHOD" != squash ]]; then + cleanup_merge_temp_dirs + exec python3 -I "$SCRIPT_DIR/forge-api.py" merge "${ORIGINAL_ARGS[@]}" + fi + policy_args=(--head-repo "$HEAD_REPO" --base-repo "$BASE_REPO") + [[ "$METHOD_EXPLICIT" == true ]] && policy_args+=(--method squash) + if ! policy_result=$(repo_decl_merge_method "$HEAD_BRANCH" "$BASE_BRANCH" "${policy_args[@]}") || + ! python3 -I -c 'import json,sys; sys.exit(0 if json.loads(sys.argv[1]).get("method") == "squash" else 1)' "$policy_result"; then + echo "POLICY-REFUSED: pinned base merge policy does not select legacy squash." >&2 + exit 3 + fi +elif [[ "$MERGE_METHOD" != squash ]]; then + echo "POLICY-REFUSED: absent merge policy permits squash only." >&2 + exit 3 +fi if [[ "$DECL_STATE" == invalid ]]; then repo_decl_report_invalid fi @@ -279,7 +322,6 @@ else: LAST_GITEA_HTTP_CODE="000" LAST_GITEA_ERROR="" -MERGE_TEMP_DIRS=() GITEA_CURL_MAX_BYTES="${MOSAIC_GITEA_CURL_MAX_BYTES:-1048576}" GITEA_CURL_MAX_TIME="${MOSAIC_GITEA_CURL_MAX_TIME_SEC:-30}" GITEA_CURL_CONNECT_TIMEOUT="${MOSAIC_GITEA_CURL_CONNECT_TIMEOUT_SEC:-10}" @@ -323,18 +365,6 @@ print(ascii(message)) PY } -# EXIT invokes this cleanup callback. -# shellcheck disable=SC2317 -cleanup_merge_temp_dirs() { - local path - for path in "${MERGE_TEMP_DIRS[@]}"; do - [[ -n "$path" ]] && rm -rf -- "$path" - done -} -trap cleanup_merge_temp_dirs EXIT -trap 'exit 130' INT -trap 'exit 143' TERM - fetch_gitea_pr_head() { local host="$1" auth_mode="$2" credential="$3" work_root="$4" local response_file raw_code api_url auth_config curl_rc diff --git a/packages/mosaic/framework/tools/git/pr-metadata.sh b/packages/mosaic/framework/tools/git/pr-metadata.sh index 8c8bdf97a..73f2b2503 100755 --- a/packages/mosaic/framework/tools/git/pr-metadata.sh +++ b/packages/mosaic/framework/tools/git/pr-metadata.sh @@ -111,7 +111,28 @@ PY detect_platform > /dev/null if [[ "$PLATFORM" == "github" ]]; then - METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft) + METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,headRepositoryOwner,baseRefName,baseRefOid,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft) + # gh pr view does not expose the base repository. Bind both repositories, + # refs and SHAs to the same pull-request REST observation. + OWNER=$(get_repo_owner) + REPO=$(get_repo_name) + HOST=$(get_remote_host) + RAW=$(gh api --hostname "$HOST" "/repos/$OWNER/$REPO/pulls/$PR_NUMBER") + METADATA=$(PR_VIEW_JSON="$METADATA" PR_REST_JSON="$RAW" python3 -I - "$PR_NUMBER" <<'PY_GITHUB' +import json, os, sys +view = json.loads(os.environ['PR_VIEW_JSON']) +pr = json.loads(os.environ['PR_REST_JSON']) +if not isinstance(view, dict) or not isinstance(pr, dict) or pr.get('number') != int(sys.argv[1]): + raise SystemExit('Error: GitHub PR metadata identity mismatch') +for side in ('head', 'base'): + item = pr.get(side) or {} + repo = item.get('repo') or {} + view[side + 'RefName'] = item.get('ref') or '' + view[side + 'RefOid'] = item.get('sha') or '' + view[side + 'Repository'] = repo.get('full_name') or '' +print(json.dumps(view)) +PY_GITHUB +) write_metadata "$METADATA" elif [[ "$PLATFORM" == "gitea" ]]; then OWNER=$(get_repo_owner) @@ -235,6 +256,7 @@ normalized = { 'headRefOid': head_sha, 'headRepository': head_repo, 'baseRefName': base_ref, + 'baseRefOid': nested(data, 'base', 'sha') or '', 'baseRepository': base_repo, 'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)], 'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)], diff --git a/packages/mosaic/framework/tools/git/repo-decl.sh b/packages/mosaic/framework/tools/git/repo-decl.sh index df0dbcc0e..0e913d780 100755 --- a/packages/mosaic/framework/tools/git/repo-decl.sh +++ b/packages/mosaic/framework/tools/git/repo-decl.sh @@ -48,11 +48,15 @@ repo_decl_load() { DECL_ORIGIN_N="" local root - root="$(git rev-parse --show-toplevel 2>/dev/null)" || { - repo_decl_warn "no git repository — declaration consumption skipped" - return 0 - } - DECL_FILE="$root/.mosaic/repo.json" + if [ "$#" -gt 0 ]; then + DECL_FILE="$1" + else + root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + repo_decl_warn "no git repository — declaration consumption skipped" + return 0 + } + DECL_FILE="$root/.mosaic/repo.json" + fi [ -f "$DECL_FILE" ] || return 0 if [ ! -x "$REPO_DECL_VALIDATOR" ] && [ ! -f "$REPO_DECL_VALIDATOR" ]; then @@ -179,7 +183,9 @@ repo_decl_check_transition() { # $1 head, $2 base trunk-release) if [ "$head" = "$DECL_RELEASE" ] && [ "$base" = "$DECL_TRUNK" ]; then # Absorb is opt-in through a declared shape mapping, never a CLI exception. - if python3 -I "$REPO_DECL_SCRIPT_DIR/merge-policy.py" "$DECL_FILE" --head "$head" --base "$base" >/dev/null; then + local merge_policy + if merge_policy=$(python3 -I "$REPO_DECL_SCRIPT_DIR/merge-policy.py" "$DECL_FILE" --validate) && + python3 -I -c 'import json,sys; sys.exit(0 if json.loads(sys.argv[1]).get("shapes", {}).get("absorb") else 1)' "$merge_policy"; then return 0 fi repo_decl_warn "absorb transition requires explicit merge policy" diff --git a/packages/mosaic/framework/tools/git/test-forge-cli.py b/packages/mosaic/framework/tools/git/test-forge-cli.py index 771dc362e..3652b66b1 100644 --- a/packages/mosaic/framework/tools/git/test-forge-cli.py +++ b/packages/mosaic/framework/tools/git/test-forge-cli.py @@ -74,6 +74,8 @@ class CLI(unittest.TestCase): if '?' not in path and path!='graphql' and not path.startswith('/swagger.v1.json'): pagination='per_page' if github else 'limit' expected[method+' '+url+'?'+pagination+'=100&page=1']=entry + if not github and isinstance(response.get('data'),list): + expected[method+' '+url+'?limit=100&page=2']={'path':path,'response':{'data':[]}} (self.root/'responses.json').write_text(json.dumps(expected)) def run_wrapper(self,name,*args,host='github.com'): @@ -271,6 +273,11 @@ class Redirects(unittest.TestCase): if self.headers.get('Content-Length'): self.rfile.read(int(self.headers['Content-Length'])) route=(self.command,urlsplit(self.path).path) status,headers,data=(200,[],{'ok':True}) if trap else owner.routes.get(route,(418,[],{'unexpected':True})) + query=urlsplit(self.path).query + if route[1] in ('/relative-statuses','/moved-statuses') or route[1].endswith('/statuses'): + if query and status in (301,302,303,307,308): + headers=[(k,v+'?'+query if k.lower()=='location' and '?' not in v else v) for k,v in headers] + if query and 'page=2' in query and isinstance(data,list): data=[] if isinstance(data,bytes): self.connection.sendall(data);self.close_connection=True;return self.send_response(status) @@ -448,7 +455,7 @@ class Redirects(unittest.TestCase): p=subprocess.run(['bash','-x',str(HERE/'pr-ci-wait.sh'),'--host',self.host,'--repo','owner/repo','--json','-n','1','-t','1','-i','1','--expect-head',self.head],cwd=self.root,env=self.env,capture_output=True,text=True,timeout=20) self.assertEqual(p.returncode,0,p.stderr);data=json.loads(p.stdout) self.assertEqual(data['head_sha'],self.head);self.assertEqual(data['data']['head_repository'],'fork/repo');self.assertEqual(data['data']['checks']['state'],'success') - self.assertTrue(any(c['path'].startswith(key[1]) for c in self.calls));self.assertTrue(any(c['path']=='/moved-statuses' for c in self.calls)) + self.assertTrue(any(c['path'].startswith(key[1]) for c in self.calls));self.assertTrue(any(c['path'].split('?',1)[0]=='/moved-statuses' for c in self.calls)) self.assertEqual(self.trap_calls,[]) self.assertTrue(all(c['authenticated'] for c in self.calls if not c['path'].startswith('/swagger.v1.json'))) self.assertNotIn('fixture-token',p.stdout+p.stderr) diff --git a/packages/mosaic/framework/tools/git/test-forge-contract.py b/packages/mosaic/framework/tools/git/test-forge-contract.py index 8c4b389ae..2c9436787 100644 --- a/packages/mosaic/framework/tools/git/test-forge-contract.py +++ b/packages/mosaic/framework/tools/git/test-forge-contract.py @@ -63,6 +63,103 @@ class Contract(unittest.TestCase): self.assertFalse(self.api.reviews(reviews, 'new', 'author')['approved']) + def test_gitea_non_decisive_and_dismissed_reviews(self): + from unittest.mock import patch + fixture = SnapshotContract('test_ready_needs_all_evidence') + fixture.api = self.api + c = fixture.fake() + c.provider, c.version = 'gitea', '28.2.0' + def check(rows, official_required=False): + c.review_official_required = official_required + data = {'complete':True, 'reviews':rows, 'comments':[]} + with patch.object(self.api,'protection',return_value=(['build'],False)), patch.object(self.api,'review_comments',return_value=(data,[],None)): + return self.api.snapshot(c,1,None)['reviews'] + for event in ('COMMENT', 'REQUEST_REVIEW', 'PENDING'): + with self.subTest(event=event): + rows = [dict(id=1, state='REQUEST_CHANGES', user={'login':'bob'}, commit_id='a'*40), + dict(id=2, state=event, user={'login':'bob'}, commit_id='a'*40), + dict(id=3, state='APPROVED', user={'login':'alice'}, commit_id='a'*40)] + self.assertEqual(check(rows)['decision'], 'changes_requested') + rows = [dict(id=1, state='APPROVED', user={'login':'alice'}, commit_id='a'*40, dismissed=True)] + self.assertFalse(check(rows)['approved']) + rows[0]['dismissed'] = False + rows[0]['official'] = False + self.assertFalse(check(rows,True)['approved']) + rows[0]['official'] = True + self.assertTrue(check(rows,True)['approved']) + + def test_github_comment_does_not_clear_changes_requested(self): + rows=[dict(id=1,state='CHANGES_REQUESTED',user={'login':'bob'},commit_id='a'), + dict(id=2,state='COMMENTED',user={'login':'bob'},commit_id='a'), + dict(id=3,state='APPROVED',user={'login':'alice'},commit_id='a')] + self.assertEqual(self.api.reviews(rows,'a','author',provider='github')['decision'],'changes_requested') + + def test_gitea_capped_pages_include_late_rejection(self): + import urllib.parse + c = self.api.Client('git.example.test', 'owner/repo'); c.provider = 'gitea' + rows = [dict(id=i, state='APPROVED', user={'login':'alice'}, commit_id='a') for i in range(1, 120)] + rows.append(dict(id=120, state='REQUEST_CHANGES', user={'login':'bob'}, commit_id='a')) + def http(path): + page = int(urllib.parse.parse_qs(urllib.parse.urlsplit(path).query)['page'][0]) + return rows[(page-1)*50:page*50] + c.http = http + result = c.pages('/reviews') + self.assertEqual(len(result), 120) + self.assertEqual(self.api.reviews(result, 'a', 'author')['decision'], 'changes_requested') + + def test_gitea_empty_write_and_post_merge_moved_base(self): + import os, types + from unittest.mock import patch + c = self.api.Client('git.example.test', 'owner/repo'); c.provider='gitea'; c.version='28.2.0' + c.token='synthetic'; c.actor_verified=True + c.transport=lambda *args: types.SimpleNamespace(returncode=0,stdout=b'HTTP/1.1 200 OK\r\n\r\n') + original = {'head':{'sha':'a'*40,'ref':'topic','repo':{'full_name':'owner/repo'}}, 'base':{'sha':'b'*40,'ref':'main','repo':{'full_name':'owner/repo'}}} + after = {'head':dict(original['head']), 'base':dict(original['base'],sha='d'*40), 'merged':True, 'merge_commit_sha':'d'*40} + reads=iter([original, original, after]) + c.pr=lambda number: next(reads) + status=dict(base_ref='main',base_sha='b'*40,base_repository='owner/repo',head_ref='topic',head_sha='a'*40,head_repository='owner/repo',ready=True,blockers=[]) + with patch.dict(os.environ,MOSAIC_GIT_IDENTITY='fixture'), patch.object(self.api,'declared_method',return_value='squash'), patch.object(self.api,'forge_method'), patch.object(self.api,'snapshot',return_value=status), patch.object(self.api,'queue_guard'): + self.assertEqual(self.api.merge(c,1,None,'a'*40)['merge_sha'],'d'*40) + + def test_gitea_post_merge_base_tip_can_move(self): + from unittest.mock import patch + c=self.api.Client('git.example.test','owner/repo'); c.provider='gitea'; c.version='28.2.0' + original={'head':{'sha':'a'*40,'ref':'topic','repo':{'full_name':'owner/repo'}}, 'base':{'sha':'b'*40,'ref':'main','repo':{'full_name':'owner/repo'}}} + after={'head':dict(original['head']),'base':dict(original['base'],sha='d'*40),'merged':True,'merge_commit_sha':'d'*40} + reads=iter([original,original,after]); c.pr=lambda number:next(reads) + c.http=lambda *args,**kwargs: None + status=dict(base_ref='main',base_sha='b'*40,base_repository='owner/repo',head_ref='topic',head_sha='a'*40,head_repository='owner/repo',ready=True,blockers=[]) + with patch.object(self.api,'declared_method',return_value='squash'), patch.object(self.api,'forge_method'), patch.object(self.api,'snapshot',return_value=status), patch.object(self.api,'queue_guard'): + self.assertEqual(self.api.merge(c,1,None,'a'*40)['merge_sha'],'d'*40) + + def test_gitea_protection_whitelist_requires_official_approval(self): + c = self.api.Client('git.example.test','owner/repo'); c.provider='gitea' + c.pages = lambda path: [dict(branch_name='main', required_approvals=1, enable_status_check=True, status_check_contexts=['build'], enable_approvals_whitelist=True)] + self.api.protection(c,'main') + rows=[dict(id=1,state='APPROVED',user={'login':'alice'},commit_id='a',official=False)] + self.assertFalse(self.api.reviews(rows,'a','author',1,c.provider,c.review_official_required)['approved']) + + def test_github_merge_json_and_invalid_readbacks(self): + import copy + from unittest.mock import patch + original={'head':{'sha':'a'*40,'ref':'topic','repo':{'full_name':'owner/repo'}}, 'base':{'sha':'b'*40,'ref':'main','repo':{'full_name':'owner/repo'}}} + status=dict(base_ref='main',base_sha='b'*40,base_repository='owner/repo',head_ref='topic',head_sha='a'*40,head_repository='owner/repo',ready=True,blockers=[]) + for mutation in ('valid','bad_sha','bad_sha_numeric','retarget'): + with self.subTest(mutation=mutation): + c=self.api.Client('github.com','owner/repo'); c.provider='github'; c.version='documented' + after=copy.deepcopy(original); after.update(merged=True,merge_commit_sha='d'*40) + if mutation=='bad_sha': after['merge_commit_sha']='bad' + if mutation=='bad_sha_numeric': after['merge_commit_sha']=123 + if mutation=='retarget': after['base']['ref']='other' + reads=iter([original,original,after]); c.pr=lambda number:next(reads) + c.http=lambda path,method='GET',body=None: {'merged':True,'sha':'d'*40} + with patch.object(self.api,'declared_method',return_value='squash'), patch.object(self.api,'forge_method'), patch.object(self.api,'snapshot',return_value=status), patch.object(self.api,'queue_guard'): + if mutation=='valid': self.assertEqual(self.api.merge(c,1,None,'a'*40)['merge_sha'],'d'*40) + else: + with self.assertRaises(self.api.Failure) as caught: self.api.merge(c,1,None,'a'*40) + self.assertEqual(caught.exception.outcome,'unknown') + + class SnapshotContract(Contract): def fake(self, race=False, missing=False): api = self.api diff --git a/packages/mosaic/framework/tools/git/test-merge-policy.py b/packages/mosaic/framework/tools/git/test-merge-policy.py index 8dcba6078..f945a732e 100644 --- a/packages/mosaic/framework/tools/git/test-merge-policy.py +++ b/packages/mosaic/framework/tools/git/test-merge-policy.py @@ -14,7 +14,7 @@ class Policy(unittest.TestCase): if policy is not None: doc['merge'] = policy path.write_text(json.dumps(doc)) - args = ['python3','-I',str(HERE/'merge-policy.py'),str(path),'--head',head,'--base',base] + args = ['python3','-I',str(HERE/'merge-policy.py'),str(path),'--head',head,'--base',base,'--head-repo','owner/repo','--base-repo','owner/repo'] if method: args += ['--method',method] p = subprocess.run(args,capture_output=True,text=True) @@ -26,6 +26,157 @@ class Policy(unittest.TestCase): policy = dict(allowed_methods=['squash','merge'],default_method='squash',shapes={'absorb':'merge'}) self.assertEqual(self.resolve(policy,head='stable')[1].get('method'),'merge') self.assertEqual(self.resolve(policy,method='squash',head='stable')[0],3) + def test_fork_branch_named_trunk_cannot_promote(self): + with tempfile.TemporaryDirectory() as directory: + path=pathlib.Path(directory)/'repo.json' + path.write_text(json.dumps(dict(schema_version=3,integration_trunk='next',release_branch='main',flow='trunk-release'))) + args=['python3','-I',str(HERE/'merge-policy.py'),str(path),'--head','next','--base','main','--head-repo','alice/stack','--base-repo','owner/stack'] + result=subprocess.run(args,capture_output=True,text=True) + self.assertEqual(result.returncode,3,result.stdout+result.stderr) + args[-3]='owner/stack' + result=subprocess.run(args,capture_output=True,text=True) + self.assertEqual(result.returncode,0,result.stdout+result.stderr) + + def test_fork_release_branch_is_task_not_absorb(self): + with tempfile.TemporaryDirectory() as directory: + path=pathlib.Path(directory)/'repo.json' + path.write_text(json.dumps(dict(schema_version=3,integration_trunk='next',release_branch='main',flow='trunk-release',merge=dict(allowed_methods=['merge','squash'],default_method='squash',shapes={'absorb':'merge'})))) + args=['python3','-I',str(HERE/'merge-policy.py'),str(path),'--head','main','--base','next','--head-repo','alice/stack','--base-repo','owner/stack'] + result=subprocess.run(args,capture_output=True,text=True) + self.assertEqual(result.returncode,0,result.stderr) + self.assertEqual(json.loads(result.stdout)['shape'],'task') + self.assertEqual(json.loads(result.stdout)['method'],'squash') + + def test_adapter_rejects_fork_promotion(self): + import os, importlib.util + spec=importlib.util.spec_from_file_location('fork_policy',HERE/'forge-api.py') + api=importlib.util.module_from_spec(spec); spec.loader.exec_module(api) + with tempfile.TemporaryDirectory() as directory: + root=pathlib.Path(directory); (root/'.mosaic').mkdir() + def git(*args): + return subprocess.run(['git',*args],cwd=root,capture_output=True,text=True,check=True).stdout.strip() + git('init','-q'); git('remote','add','origin','https://git.example.test/owner/stack.git') + doc=dict(schema_version=3,integration_trunk='next',release_branch='main',flow='trunk-release',canonical_remote='https://git.example.test/owner/stack',canonical_clone='host:/src/project') + (root/'.mosaic/repo.json').write_text(json.dumps(doc)); git('add','.mosaic/repo.json') + git('-c','user.name=Fixture','-c','user.email=fixture@example.test','commit','-qm','policy') + p={'head':{'ref':'next','repo':{'full_name':'alice/stack'}},'base':{'ref':'main','sha':git('rev-parse','HEAD'),'repo':{'full_name':'owner/stack'}}} + c=api.Client('git.example.test','owner/stack'); old=os.getcwd() + try: + os.chdir(root) + with self.assertRaises(api.Failure) as caught: api.declared_method(c,p,None) + self.assertEqual(caught.exception.code,3) + p['head']['repo']['full_name']='owner/stack' + self.assertEqual(api.declared_method(c,p,None),'squash') + finally: os.chdir(old) + + def test_branch_precheck_still_requires_absorb_mapping(self): + with tempfile.TemporaryDirectory() as directory: + path=pathlib.Path(directory)/'repo.json' + doc=dict(schema_version=3,integration_trunk='integration',release_branch='stable',flow='trunk-release') + def check(): + return subprocess.run(['bash','-c','source "$1/repo-decl.sh"; DECL_STATE=valid; DECL_FLOW=trunk-release; DECL_TRUNK=integration; DECL_RELEASE=stable; DECL_FILE="$2"; repo_decl_check_transition stable integration','fixture',str(HERE),str(path)],capture_output=True,text=True) + path.write_text(json.dumps(doc)) + self.assertNotEqual(check().returncode,0) + doc['merge']=dict(allowed_methods=['merge'],default_method='merge',shapes={'absorb':'merge'}) + path.write_text(json.dumps(doc)) + self.assertEqual(check().returncode,0) + + def test_missing_repository_identity_refuses_promotion_and_absorb(self): + with tempfile.TemporaryDirectory() as directory: + path=pathlib.Path(directory)/'repo.json' + doc=dict(schema_version=3,integration_trunk='next',release_branch='main',flow='trunk-release',merge=dict(allowed_methods=['merge'],default_method='merge',shapes={'absorb':'merge'})) + path.write_text(json.dumps(doc)) + for head,base in [('next','main'),('main','next')]: + for head_repo,base_repo in [('', 'owner/repo'),('owner/repo',''),('','')]: + with self.subTest(head=head,head_repo=head_repo,base_repo=base_repo): + args=['python3','-I',str(HERE/'merge-policy.py'),str(path),'--head',head,'--base',base,'--head-repo',head_repo,'--base-repo',base_repo] + result=subprocess.run(args,capture_output=True,text=True) + self.assertEqual(result.returncode,3,result.stdout+result.stderr) + + def test_legacy_wrapper_uses_pinned_base_not_worktree_merge_policy(self): + import os, shutil + with tempfile.TemporaryDirectory() as directory: + root=pathlib.Path(directory); repo=root/'repo'; repo.mkdir() + git_tools=root/'tools/git'; git_tools.mkdir(parents=True) + structure=root/'tools/structure'; structure.mkdir() + for name in ['pr-merge.sh','repo-decl.sh','merge-policy.py']: + shutil.copy(HERE/name,git_tools/name) + shutil.copy(HERE.parent/'structure/validate-repo-json.sh',structure/'validate-repo-json.sh') + (git_tools/'detect-platform.sh').write_text('detect_platform() { echo github; }\nget_repo_owner() { echo owner; }\nget_repo_name() { echo repo; }\n') + def git(*args): + return subprocess.run(['git',*args],cwd=repo,capture_output=True,text=True,check=True).stdout.strip() + git('init','-q'); git('remote','add','origin','https://github.com/owner/repo') + (repo/'.mosaic').mkdir() + doc=dict(schema_version=3,integration_trunk='next',release_branch='main',flow='trunk-release',canonical_remote='https://github.com/owner/repo',canonical_clone='host:/src/repo',merge=dict(allowed_methods=['merge'],default_method='merge')) + path=repo/'.mosaic/repo.json'; path.write_text(json.dumps(doc)); git('add','.mosaic/repo.json') + git('-c','user.name=Fixture','-c','user.email=fixture@example.test','commit','-qm','base policy') + base=git('rev-parse','HEAD'); del doc['merge']; path.write_text(json.dumps(doc)) + metadata=dict(baseRefName='next',baseRefOid=base,baseRepository={'name':'repo'},baseRepositoryOwner={'login':'owner'},headRefName='task',headRefOid='a'*40,headRepository={'name':'repo'},headRepositoryOwner={'login':'owner'}) + script=git_tools/'pr-metadata.sh'; script.write_text("#!/bin/sh\nprintf '%s\\n' "+repr(json.dumps(metadata))+"\n"); script.chmod(0o755) + env=dict(os.environ,HOME=str(root),GIT_CONFIG_GLOBAL='/dev/null',GIT_CONFIG_NOSYSTEM='1') + result=subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run'],cwd=repo,env=env,capture_output=True,text=True) + self.assertEqual(result.returncode,3,result.stdout+result.stderr) + self.assertIn('POLICY-REFUSED',result.stderr) + # A missing declaration helper must not bypass a base merge policy. + (git_tools/'repo-decl.sh').unlink() + result=subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run'],cwd=repo,env=env,capture_output=True,text=True) + self.assertNotEqual(result.returncode,0,result.stdout+result.stderr) + shutil.copy(HERE/'repo-decl.sh',git_tools/'repo-decl.sh') + # An uncommitted local merge policy cannot override a base without one. + git('add','.mosaic/repo.json') + git('-c','user.name=Fixture','-c','user.email=fixture@example.test','commit','-qm','base without merge policy') + metadata['baseRefOid']=git('rev-parse','HEAD') + doc['merge']=dict(allowed_methods=['merge'],default_method='merge') + path.write_text(json.dumps(doc)) + script.write_text("#!/bin/sh\nprintf '%s\\n' "+repr(json.dumps(metadata))+"\n") + result=subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run'],cwd=repo,env=env,capture_output=True,text=True) + self.assertEqual(result.returncode,0,result.stdout+result.stderr) + self.assertIn('Dry run:',result.stdout) + # A merge default cannot silently become squash, while an explicit + # squash remains allowed when the base policy permits it. + doc['merge']=dict(allowed_methods=['squash','merge'],default_method='merge') + path.write_text(json.dumps(doc)); git('add','.mosaic/repo.json') + git('-c','user.name=Fixture','-c','user.email=fixture@example.test','commit','-qm','base merge default') + metadata['baseRefOid']=git('rev-parse','HEAD') + del doc['merge']; path.write_text(json.dumps(doc)) + script.write_text("#!/bin/sh\nprintf '%s\\n' "+repr(json.dumps(metadata))+"\n") + result=subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run'],cwd=repo,env=env,capture_output=True,text=True) + self.assertNotEqual(result.returncode,0,result.stdout+result.stderr) + result=subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run','-m','squash'],cwd=repo,env=env,capture_output=True,text=True) + self.assertEqual(result.returncode,0,result.stdout+result.stderr) + # Without an observed base SHA there is no revision to inspect. + metadata.pop('baseRefOid') + script.write_text("#!/bin/sh\nprintf '%s\\n' "+repr(json.dumps(metadata))+"\n") + result=subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run'],cwd=repo,env=env,capture_output=True,text=True) + self.assertNotEqual(result.returncode,0,result.stdout+result.stderr) + + def test_github_metadata_reads_pr_repository_identities(self): + import os, shutil + with tempfile.TemporaryDirectory() as directory: + root=pathlib.Path(directory); tools=root/'tools'; tools.mkdir() + shutil.copy(HERE/'pr-metadata.sh',tools/'pr-metadata.sh') + (tools/'detect-platform.sh').write_text('detect_platform() { PLATFORM=github; }\nget_repo_owner() { echo owner; }\nget_repo_name() { echo repo; }\nget_remote_host() { echo github.com; }\n') + gh=root/'gh' + gh.write_text("""#!/usr/bin/env python3 +import json,sys +args=sys.argv[1:] +if args[:2]==['pr','view']: + requested=args[args.index('--json')+1].split(',') + if 'baseRepository' in requested or 'baseRepositoryOwner' in requested: sys.exit(2) + print(json.dumps({'number':1,'baseRefOid':'b'*40})) +elif args[0]=='api' and '/repos/owner/repo/pulls/1' in args: + print(json.dumps({'number':1,'base':{'sha':'b'*40,'ref':'next','repo':{'full_name':'owner/repo'}},'head':{'sha':'a'*40,'ref':'task','repo':{'full_name':'fork/repo'}}})) +else: sys.exit(3) +""") + gh.chmod(0o755) + env=dict(os.environ,PATH=str(root)+os.pathsep+os.environ['PATH'],HOME=str(root)) + result=subprocess.run(['bash',str(tools/'pr-metadata.sh'),'-n','1'],env=env,capture_output=True,text=True) + self.assertEqual(result.returncode,0,result.stderr) + data=json.loads(result.stdout) + self.assertEqual(data['baseRefOid'],'b'*40) + self.assertEqual(data['baseRepository'],'owner/repo') + self.assertEqual(data['headRepository'],'fork/repo') + def test_invalid_does_not_fall_back(self): for policy in [{}, {'allowed_methods':['force_merge'],'default_method':'force_merge'}, {'allowed_methods':['squash'],'default_method':'merge'}, {'allowed_methods':['squash'],'default_method':'squash','unexpected':True}]: self.assertEqual(self.resolve(policy)[0],6) diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-baseline.sh b/packages/mosaic/framework/tools/git/test-pr-merge-baseline.sh index d59aa3442..77c06731c 100755 --- a/packages/mosaic/framework/tools/git/test-pr-merge-baseline.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-baseline.sh @@ -23,7 +23,7 @@ cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh" cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH' #!/usr/bin/env bash -printf '%s\n' '{"baseRefName":"mosaic-cli-p0","baseRepository":"mosaicstack/stack","headRefName":"mosaic-cli-p2-socket-res","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}' +printf '%s\n' '{"baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"mosaic-cli-p0","baseRepository":"mosaicstack/stack","headRefName":"mosaic-cli-p2-socket-res","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}' SH cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH' diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-fork-ci-status.sh b/packages/mosaic/framework/tools/git/test-pr-merge-fork-ci-status.sh index cf4b94650..fd0896a4f 100755 --- a/packages/mosaic/framework/tools/git/test-pr-merge-fork-ci-status.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-fork-ci-status.sh @@ -21,7 +21,7 @@ cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh" cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH' #!/usr/bin/env bash -printf '%s\n' '{"baseRefName":"next","headRefName":"fix/b1-fork-branch","headRefOid":"fedcba9876543210fedcba9876543210fedcba98","headRepository":"stack-mos-dt-0/stack"}' +printf '%s\n' '{"baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"next","headRefName":"fix/b1-fork-branch","headRefOid":"fedcba9876543210fedcba9876543210fedcba98","headRepository":"stack-mos-dt-0/stack"}' SH cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH' diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh b/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh index 7ba7a859b..fd9841b33 100755 --- a/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh @@ -93,7 +93,7 @@ if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/commits/0123456789abcdef01234 exit 0 fi if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then - emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}' + emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main","sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}' exit 0 fi if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh b/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh index 299b04f37..a08b484ae 100644 --- a/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh @@ -21,7 +21,7 @@ make_fixture() { local base_branch="${3:-main}" cat > "$tools/pr-metadata.sh" < "$tools/ci-queue-wait.sh" <<'SH' #!/usr/bin/env bash diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-message-field.sh b/packages/mosaic/framework/tools/git/test-pr-merge-message-field.sh index 75dfa16c3..326cea32d 100755 --- a/packages/mosaic/framework/tools/git/test-pr-merge-message-field.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-message-field.sh @@ -62,7 +62,7 @@ case "${MOSAIC_TEST_COMMITS_MODE:?}" in single) head_sha=1111111111111111111111111111111111111111 ;; *) echo "unknown commits mode" >&2; exit 2 ;; esac -printf '{"number":42,"title":"%s","author":"poster","baseRefName":"main","headRefName":"feature/fixture","headRefOid":"%s","headRepository":"acme/widgets"}\n' "$title" "$head_sha" +printf '{"number":42,"title":"%s","author":"poster","baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"main","headRefName":"feature/fixture","headRefOid":"%s","headRepository":"acme/widgets"}\n' "$title" "$head_sha" SH cat > "$case_dir/ci-queue-wait.sh" <<'SH' diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-no-ci-expected.sh b/packages/mosaic/framework/tools/git/test-pr-merge-no-ci-expected.sh index 7d44f392a..48ff882b9 100755 --- a/packages/mosaic/framework/tools/git/test-pr-merge-no-ci-expected.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-no-ci-expected.sh @@ -19,7 +19,7 @@ cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh" cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH' #!/usr/bin/env bash -printf '%s\n' '{"baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/no-ci-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}' +printf '%s\n' '{"baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/no-ci-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}' SH cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH' diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh b/packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh index 405011e5f..41da2cc14 100644 --- a/packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh @@ -15,7 +15,7 @@ cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh" cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH' #!/usr/bin/env bash -printf '%s\n' '{"baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}' +printf '%s\n' '{"baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}' SH cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH' diff --git a/packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh b/packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh index 93c0af205..b1bebaf47 100755 --- a/packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh +++ b/packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh @@ -86,7 +86,7 @@ cat > "$FIXTURE_DIR/gitea-standard.json" <<'JSON' "state": "open", "user": {"login": "edith"}, "head": {"ref": "edith/t_39ce717c-authentik-smoke-gate"}, - "base": {"ref": "main"}, + "base": {"ref": "main", "sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, "labels": [{"name": "ci"}], "assignees": [{"login": "edith"}], "html_url": "https://git.uscllc.com/USC/uconnect/pulls/1905" @@ -291,6 +291,8 @@ expected_head = sys.argv[2] assert data["number"] == expected_number, data assert data["baseRefName"] == "main", data assert data["headRefName"] == expected_head, data +if expected_number == 1905: + assert data["baseRefOid"] == "b" * 40, data PY } diff --git a/packages/mosaic/framework/tools/git/test-repo-decl-consumption.sh b/packages/mosaic/framework/tools/git/test-repo-decl-consumption.sh index 7f0544728..89f71c2ac 100755 --- a/packages/mosaic/framework/tools/git/test-repo-decl-consumption.sh +++ b/packages/mosaic/framework/tools/git/test-repo-decl-consumption.sh @@ -259,9 +259,13 @@ STUB echo "== C. pr-merge transition validation (no hardcoded targets) ==" mkmerge() { # $1 dir, $2 base, $3 head -> stubs pr-metadata; runs pr-merge; sets M_RC/M_OUT/M_ERR cp "$SCRIPT_DIR/pr-merge.sh" "$1/tools/git/pr-merge.sh" 2>/dev/null || true + git -C "$1" add -A -- .mosaic + git -C "$1" diff --cached --quiet || git -C "$1" commit -q -m 'pinned base declaration' + local base_sha + base_sha=$(git -C "$1" rev-parse HEAD) cat > "$1/tools/git/pr-metadata.sh" < "$1/tools/git/ci-queue-wait.sh" <<'EOF' @@ -272,7 +276,7 @@ EOF mkdir -p "$1/stub" mkmetadata "$1" M_RC=0 - M_OUT="$(cd "$1" && env -i PATH="$1/stub:/usr/bin:/bin" HOME="$1/home" GITEA_TOKEN=stub-token \ + M_OUT="$(cd "$1" && env -i PATH="$1/stub:/usr/bin:/bin" HOME="$1/home" TMPDIR="$SB" GITEA_TOKEN=stub-token \ bash "$1/tools/git/pr-merge.sh" -n 7 --dry-run < /dev/null 2>"$1/merr.txt")" || M_RC=$? M_ERR="$(cat "$1/merr.txt")" } -- 2.54.0 From ed5332b73bd4e4c60490aee811b64a1211b17113 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 12:21:58 -0600 Subject: [PATCH 24/26] fix(framework): fail closed on missing base and overlapping pages --- packages/mosaic/framework/tools/git/README.md | 2 +- .../mosaic/framework/tools/git/forge-api.py | 12 +++ .../mosaic/framework/tools/git/pr-merge.sh | 17 ++++- .../framework/tools/git/test-forge-auth.py | 4 +- .../framework/tools/git/test-forge-cli.py | 39 ++++++++++ .../tools/git/test-forge-contract.py | 74 +++++++++++++++++++ .../framework/tools/git/test-merge-policy.py | 52 +++++++++++++ .../tools/git/test-pr-merge-baseline.sh | 5 +- .../tools/git/test-pr-merge-fork-ci-status.sh | 4 +- .../git/test-pr-merge-gitea-empty-uid.sh | 4 +- .../tools/git/test-pr-merge-head-pin.sh | 4 +- .../tools/git/test-pr-merge-message-field.sh | 6 +- .../tools/git/test-pr-merge-no-ci-expected.sh | 5 +- .../tools/git/test-pr-merge-queue-branch.sh | 5 +- 14 files changed, 222 insertions(+), 11 deletions(-) diff --git a/packages/mosaic/framework/tools/git/README.md b/packages/mosaic/framework/tools/git/README.md index 5057f976a..b1b188dde 100644 --- a/packages/mosaic/framework/tools/git/README.md +++ b/packages/mosaic/framework/tools/git/README.md @@ -219,7 +219,7 @@ scheduling may merge immediately and returns observed merged state rather than q Schema 1/2 remain readable. Schema 3 adds optional `merge`, with strict key/value validation through the shared declaration validator and repo-decl helper. Consumers apply the existing -branch gates to both versions 2 and 3. Absent merge policy means allowed=[squash], default=squash. The legacy merge interface retains its options and reads a declaration from the PR base commit; a missing or unreadable declaration uses the legacy path. It refuses when that policy selects a non-squash default, unless an explicit squash is allowed. Explicit `--json` calls use the adapter and require `--expect-head`. +branch gates to both versions 2 and 3. Absent merge policy means allowed=[squash], default=squash. The legacy merge interface retains its options and reads a declaration from the PR base commit; an absent declaration uses the legacy path; an unavailable base or a declaration that cannot be read or parsed refuses. It refuses when that policy selects a non-squash default, unless an explicit squash is allowed. Explicit `--json` calls use the adapter and require `--expect-head`. ```json { diff --git a/packages/mosaic/framework/tools/git/forge-api.py b/packages/mosaic/framework/tools/git/forge-api.py index c8e1e50b1..a7716efe2 100644 --- a/packages/mosaic/framework/tools/git/forge-api.py +++ b/packages/mosaic/framework/tools/git/forge-api.py @@ -302,6 +302,7 @@ class Client: def pages(self, path, key=None): result = [] expected_total = 0 + previous = None for page in range(1, MAX_PAGES + 1): query = ('&' if '?' in path else '?') + ('per_page' if self.provider == 'github' else 'limit') + '=100&page=' + str(page) value = self.http(path + query) @@ -313,7 +314,18 @@ class Client: value = value.get(key) if not isinstance(value, list): raise Failure(6, 'malformed', 'Provider collection is not a list') + if self.provider in ('gitea', 'forgejo') and previous and value: + by_id = all(isinstance(row, dict) and row.get('id') is not None for row in previous + value) + before = [row['id'] for row in previous] if by_id else previous + current = [row['id'] for row in value] if by_id else value + if current == before: + if len(result) < expected_total: + raise Failure(6, 'incomplete', 'Provider returned fewer records than total_count') + return result + if any(row == old for row in current for old in before): + raise Failure(6, 'incomplete', 'Provider pages overlap; collection incomplete') result.extend(value) + previous = value if not value or self.provider == 'github' and len(value) < LIMIT: if len(result) < expected_total: raise Failure(6,'incomplete','Provider returned fewer records than total_count') diff --git a/packages/mosaic/framework/tools/git/pr-merge.sh b/packages/mosaic/framework/tools/git/pr-merge.sh index afaa8faec..aa153350c 100755 --- a/packages/mosaic/framework/tools/git/pr-merge.sh +++ b/packages/mosaic/framework/tools/git/pr-merge.sh @@ -181,12 +181,25 @@ PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=js # Target branches are validated against the declaration, NEVER hardcoded; # the legacy main/next check survives only for undeclared repos during the # rollout window (4.3 irreversible class, loud warning). -# A missing or unreadable declaration at the observed base keeps legacy behavior. +# An absent declaration at the observed base keeps legacy behavior. # Never load merge policy or transition data from the caller's working tree. pinned_decl="" pinned_dir=$(mktemp -d "${TMPDIR:-/tmp}/mosaic-merge-policy.XXXXXX") MERGE_TEMP_DIRS+=("$pinned_dir") -if git show "$BASE_SHA:.mosaic/repo.json" > "$pinned_dir/repo.json" 2>/dev/null; then +if ! git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then + echo "POLICY-REFUSED: pinned base commit is not available locally; fetch it before merging." >&2 + exit 3 +fi +if ! git ls-tree "$BASE_SHA" -- .mosaic/repo.json > "$pinned_dir/listing" 2>/dev/null; then + echo "POLICY-REFUSED: cannot inspect pinned base declaration." >&2 + exit 3 +fi +if [[ -s "$pinned_dir/listing" ]]; then + if ! git show "$BASE_SHA:.mosaic/repo.json" > "$pinned_dir/repo.json" 2>/dev/null || + ! python3 -I -c 'import json,sys; d=json.load(open(sys.argv[1])); assert isinstance(d,dict)' "$pinned_dir/repo.json" 2>/dev/null; then + echo "POLICY-REFUSED: pinned base declaration cannot be read or parsed." >&2 + exit 3 + fi pinned_decl="$pinned_dir/repo.json" fi # shellcheck source=packages/mosaic/framework/tools/git/repo-decl.sh diff --git a/packages/mosaic/framework/tools/git/test-forge-auth.py b/packages/mosaic/framework/tools/git/test-forge-auth.py index 5e6cf8322..d65282154 100644 --- a/packages/mosaic/framework/tools/git/test-forge-auth.py +++ b/packages/mosaic/framework/tools/git/test-forge-auth.py @@ -30,7 +30,7 @@ for flag in ['--config','-K']: observed={'path':u.path,'method':method,'argv_leak':any(secret in x for x in a),'authenticated':config=='header = '+json.dumps('Authorization: token '+secret)+chr(10),'anonymous':anonymous} with open(os.environ['TEST_AUTH_OBSERVATIONS'],'a') as f: f.write(json.dumps(observed)+chr(10)) if observed['argv_leak']: sys.exit(97) -head='a'*40; base='b'*40 +head='a'*40; base=os.environ['TEST_AUTH_BASE'] pull=dict(number=1,state='open',draft=False,merged=False,mergeable=True,title='Title',head={'sha':head,'ref':'task','repo':{'full_name':'owner/repo'}},base={'sha':base,'ref':'main','repo':{'full_name':'owner/repo'}},user={'login':'author'}) if u.path=='/swagger.v1.json': body={'info':{'title':'Gitea API','version':'1.24.7'}} elif u.path.endswith('/user'): body={'login':'fixture-user'} @@ -111,6 +111,8 @@ class Auth(unittest.TestCase): repo=self.root/'repo'; repo.mkdir() subprocess.run(['git','init','-q','-b','task',str(repo)],check=True,env=self.env) subprocess.run(['git','-C',str(repo),'remote','add','origin','https://forge.example.test/owner/repo'],check=True,env=self.env) + subprocess.run(['git','-C',str(repo),'-c','user.name=Fixture','-c','user.email=fixture@example.test','commit','--allow-empty','-qm','base'],check=True,env=self.env) + self.env['TEST_AUTH_BASE']=subprocess.run(['git','-C',str(repo),'rev-parse','HEAD'],check=True,env=self.env,capture_output=True,text=True).stdout.strip() bin_dir=self.root/'bin';bin_dir.mkdir() (bin_dir/'curl').write_text(LEGACY_CURL);(bin_dir/'curl').chmod(0o755) (bin_dir/'tea').write_text('#!/bin/sh\nexit 1\n');(bin_dir/'tea').chmod(0o755) diff --git a/packages/mosaic/framework/tools/git/test-forge-cli.py b/packages/mosaic/framework/tools/git/test-forge-cli.py index 3652b66b1..6faaf06e2 100644 --- a/packages/mosaic/framework/tools/git/test-forge-cli.py +++ b/packages/mosaic/framework/tools/git/test-forge-cli.py @@ -30,6 +30,9 @@ entry=fixtures.get(method+' '+url) if entry is None: print('unexpected provider URL '+url,file=sys.stderr); sys.exit(98) response=entry['response']; path=entry['path'] +log=pathlib.Path(os.environ['FAKE_LOG']) +if response.get('after_merge') and log.exists() and any(json.loads(line)['method']=='POST' and json.loads(line)['path'].endswith('/pulls/1/merge') for line in log.read_text().splitlines()): + response=dict(response,data=response['after_merge']) query=urllib.parse.urlsplit(url).query if '--input' in args or '--data-binary' in args: body=json.load(sys.stdin) @@ -224,6 +227,42 @@ class CLI(unittest.TestCase): + def test_gitea_status_with_unpaginated_comments_and_protections(self): + self.gitea(); head='a'*40 + self.fixtures['GET /repos/owner/repo/pulls/1']={'data':{'number':1,'state':'open','draft':False,'merged':False,'mergeable':True,'head':{'sha':head,'ref':'task','repo':{'full_name':'owner/repo'}},'base':{'sha':'b'*40,'ref':'next','repo':{'full_name':'owner/repo'}},'user':{'login':'author'}}} + values={'/repos/owner/repo/branch_protections':[{'branch_name':'next','enable_status_check':True,'status_check_contexts':['build'],'required_approvals':1}],'/repos/owner/repo/issues/1/comments':[{'id':11,'body':'issue'}],'/repos/owner/repo/pulls/1/reviews/1/comments':[{'id':22,'resolver':{'login':'reviewer'},'user':{'login':'reviewer'}}]} + for path,rows in values.items(): + self.fixtures['GET '+path]={'data':rows} + self.fixtures['GET '+path+'?limit=100&page=2']={'data':rows} + self.fixtures['GET /repos/owner/repo/commits/'+head+'/statuses']={'data':[{'id':1,'context':'build','state':'success'}]} + self.fixtures['GET /repos/owner/repo/pulls/1/reviews']={'data':[{'id':1,'state':'APPROVED','commit_id':head,'user':{'login':'reviewer'}}]} + p,v=self.run_wrapper('pr-status','-n','1',host='git.example.test') + self.assertEqual(p.returncode,0,p.stdout+p.stderr) + self.assertTrue(v['data']['ready']); self.assertEqual(len(v['data']['issue_comments']),1) + self.assertEqual(len(v['data']['comments']),1) + # Exercise the actual JSON merge wrapper with a local pinned base and a + # fake queue guard. Provider reads and mutation remain fixture-only. + import shutil + repo=self.root/'repo'; repo.mkdir() + def git(*args): + return subprocess.run(['git','-C',str(repo),*args],env=self.env,capture_output=True,text=True,check=True).stdout.strip() + git('init','-q'); git('remote','add','origin','https://git.example.test/owner/repo') + git('-c','user.name=Fixture','-c','user.email=fixture@example.test','commit','--allow-empty','-qm','base') + pr=self.fixtures['GET /repos/owner/repo/pulls/1']['data'] + pr['base'].update(sha=git('rev-parse','HEAD'),ref='main') + values['/repos/owner/repo/branch_protections'][0]['branch_name']='main' + self.fixtures['GET /repos/owner/repo']={'data':{'default_branch':'main','allow_squash_merge':True}} + self.fixtures['GET /user']={'data':{'login':'fixture-user'}} + self.fixtures['POST /repos/owner/repo/pulls/1/merge']={'data':None} + self.fixtures['GET /repos/owner/repo/pulls/1']['after_merge']=dict(pr,merged=True,merge_commit_sha='d'*40) + tools=self.root/'tools/git'; shutil.copytree(HERE,tools) + guard=tools/'ci-queue-wait.sh'; guard.write_text('#!/bin/sh\nexit 0\n'); guard.chmod(0o755) + self.write_responses('git.example.test') + p=subprocess.run(['bash',str(tools/'pr-merge.sh'),'--host','git.example.test','--repo','owner/repo','--json','-n','1','--expect-head',head],cwd=repo,env=self.env,capture_output=True,text=True) + self.assertEqual(p.returncode,0,p.stdout+p.stderr) + self.assertTrue(json.loads(p.stdout)['data']['merged']) + self.assertEqual(sum(call['method']=='POST' and call['path'].endswith('/pulls/1/merge') for call in self.calls()),1) + def test_watch_predicate_preserves_baseline_and_read_errors(self): self.test_gitea_status_and_ci_wait_use_exact_head() key='GET /repos/owner/repo/commits/'+'a'*40+'/statuses' diff --git a/packages/mosaic/framework/tools/git/test-forge-contract.py b/packages/mosaic/framework/tools/git/test-forge-contract.py index 2c9436787..4666610fd 100644 --- a/packages/mosaic/framework/tools/git/test-forge-contract.py +++ b/packages/mosaic/framework/tools/git/test-forge-contract.py @@ -107,6 +107,80 @@ class Contract(unittest.TestCase): self.assertEqual(len(result), 120) self.assertEqual(self.api.reviews(result, 'a', 'author')['decision'], 'changes_requested') + def test_forge_duplicate_and_partial_pages(self): + import urllib.parse + for provider in ('gitea','forgejo'): + for first,second in [([{'id':1,'v':'old'},{'id':2}],[{'id':1,'v':'new'},{'id':2}]), ([{'branch_name':'next'}],[{'branch_name':'next'}])]: + c=self.api.Client('git.example.test','owner/repo'); c.provider=provider + calls=[] + def http(path): + page=int(urllib.parse.parse_qs(urllib.parse.urlsplit(path).query)['page'][0]); calls.append(page) + return first if page == 1 else second + c.http=http + self.assertEqual(c.pages('/collection'),first) + self.assertEqual(calls,[1,2]) + for first,second in [([{'id':1},{'id':2}],[{'id':2},{'id':3}]), ([{'name':'one'},{'name':'two'}],[{'name':'two'},{'name':'three'}])]: + c=self.api.Client('git.example.test','owner/repo'); c.provider=provider + c.http=lambda path: first if 'page=1' in path else second + with self.assertRaises(self.api.Failure) as caught: c.pages('/collection') + self.assertEqual((caught.exception.code,caught.exception.kind),(6,'incomplete')) + + def test_forge_partial_overlap_fails_closed(self): + for provider in ('gitea','forgejo'): + for first,second in [([{'id':1},{'id':2}],[{'id':2},{'id':3}]), ([{'name':'one'},{'name':'two'}],[{'name':'two'},{'name':'three'}])]: + with self.subTest(provider=provider,first=first): + c=self.api.Client('git.example.test','owner/repo'); c.provider=provider + c.http=lambda path: first if 'page=1' in path else second if 'page=2' in path else [] + with self.assertRaises(self.api.Failure) as caught: c.pages('/collection') + self.assertEqual((caught.exception.code,caught.exception.kind),(6,'incomplete')) + + def test_forge_unpaginated_collections_status_and_merge_json(self): + import contextlib, copy, io, json, os, sys, urllib.parse + from unittest.mock import patch + api=self.api + for provider,version in [('gitea','1.24.7'),('forgejo','16.0.0-dev')]: + with self.subTest(provider=provider): + c=api.Client('git.example.test','owner/repo'); c.provider=provider; c.version=version + c.detect=lambda: None + head='a'*40 + pr={'number':1,'state':'open','draft':False,'merged':False,'mergeable':True,'head':{'sha':head,'ref':'task','repo':{'full_name':'owner/repo'}},'base':{'sha':'b'*40,'ref':'next','repo':{'full_name':'owner/repo'}},'user':{'login':'author'}} + rules=[{'branch_name':'next','enable_status_check':True,'status_check_contexts':['build'],'required_approvals':1}] + issue=[{'id':11,'body':'issue'}] + comment=[{'id':22,'body':'review','resolver':{'login':'reviewer'},'user':{'login':'reviewer'}}] + state={'merged':False,'calls':[]} + def http(path,method='GET',body=None,public=False): + clean=path.split('?',1)[0]; state['calls'].append((clean,method,path)) + if method=='POST': state['merged']=True; return None + if clean.endswith('/pulls/1'): + data=copy.deepcopy(pr) + if state['merged']: data.update(merged=True,merge_commit_sha='d'*40) + return data + if clean.endswith('/branch_protections'): return rules + if clean.endswith('/issues/1/comments'): return issue + if clean.endswith('/reviews/1/comments'): return comment + page=int(urllib.parse.parse_qs(urllib.parse.urlsplit(path).query).get('page',['1'])[0]) + if clean.endswith('/statuses'): return [{'id':2,'context':'build','state':'success'}] if page==1 else [] + if clean.endswith('/reviews'): return [{'id':1,'state':'APPROVED','commit_id':head,'user':{'login':'reviewer'}}] if page==1 else [] + if clean==c.root: return {'allow_squash_merge':True} + raise AssertionError(path) + c.http=http + def run_main(operation): + out=io.StringIO() + args=['forge-api.py',operation,'--host','git.example.test','--repo','owner/repo','-n','1','--json','--expect-head',head] + with patch.object(sys,'argv',args), patch.dict(os.environ,{'MOSAIC_GIT_IDENTITY':'fixture'}), patch.object(api,'Client',return_value=c), patch.object(api,'declared_method',return_value='squash'), patch.object(api,'queue_guard'), contextlib.redirect_stdout(out): + code=api.main() + return code,json.loads(out.getvalue()) + with self.subTest(operation='status'): + code,data=run_main('status'); self.assertEqual(code,0,data) + self.assertTrue(data['data']['ready']); self.assertEqual(data['data']['issue_comments'],issue) + self.assertEqual(len(data['data']['comments']),1) + with self.subTest(operation='merge'): + code,data=run_main('merge'); self.assertEqual(code,0,data) + self.assertTrue(data['data']['merged']) + self.assertEqual(sum(method=='POST' for _,method,_ in state['calls']),1) + for suffix in ('branch_protections','issues/1/comments','reviews/1/comments'): + self.assertTrue(any(path.endswith(suffix) and 'page=2' in url for path,_,url in state['calls'])) + def test_gitea_empty_write_and_post_merge_moved_base(self): import os, types from unittest.mock import patch diff --git a/packages/mosaic/framework/tools/git/test-merge-policy.py b/packages/mosaic/framework/tools/git/test-merge-policy.py index f945a732e..34c55b8a2 100644 --- a/packages/mosaic/framework/tools/git/test-merge-policy.py +++ b/packages/mosaic/framework/tools/git/test-merge-policy.py @@ -150,6 +150,58 @@ class Policy(unittest.TestCase): result=subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run'],cwd=repo,env=env,capture_output=True,text=True) self.assertNotEqual(result.returncode,0,result.stdout+result.stderr) + def test_legacy_pinned_base_availability_and_declaration(self): + import os, shutil + with tempfile.TemporaryDirectory() as directory: + root=pathlib.Path(directory); repo=root/'repo'; repo.mkdir() + git_tools=root/'tools/git'; git_tools.mkdir(parents=True) + structure=root/'tools/structure'; structure.mkdir() + for name in ['pr-merge.sh','repo-decl.sh','merge-policy.py']: + shutil.copy(HERE/name,git_tools/name) + shutil.copy(HERE.parent/'structure/validate-repo-json.sh',structure/'validate-repo-json.sh') + (git_tools/'detect-platform.sh').write_text('detect_platform() { echo github; }\nget_repo_owner() { echo owner; }\nget_repo_name() { echo repo; }\n') + def git(*args): + return subprocess.run(['git',*args],cwd=repo,capture_output=True,text=True,check=True).stdout.strip() + git('init','-q'); git('remote','add','origin','https://github.com/owner/repo') + git('-c','user.name=Fixture','-c','user.email=fixture@example.test','commit','--allow-empty','-qm','absent') + absent=git('rev-parse','HEAD') + (repo/'.mosaic').mkdir() + doc=dict(schema_version=3,integration_trunk='next',release_branch='main',flow='trunk-release',canonical_remote='https://github.com/owner/repo',canonical_clone='host:/src/repo',merge=dict(allowed_methods=['merge'],default_method='merge',shapes={'promotion':'merge'})) + path=repo/'.mosaic/repo.json'; path.write_text(json.dumps(doc)); git('add','.mosaic/repo.json') + git('-c','user.name=Fixture','-c','user.email=fixture@example.test','commit','-qm','declared') + declared=git('rev-parse','HEAD') + script=git_tools/'pr-metadata.sh' + metadata=dict(baseRefName='main',baseRefOid='f'*40,baseRepository='owner/repo',headRefName='feature',headRefOid='a'*40,headRepository='owner/repo') + env=dict(os.environ,HOME=str(root),GIT_CONFIG_GLOBAL='/dev/null',GIT_CONFIG_NOSYSTEM='1') + def invoke(): + script.write_text("#!/bin/sh\nprintf '%s\\n' "+repr(json.dumps(metadata))+"\n"); script.chmod(0o755) + return subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run'],cwd=repo,env=env,capture_output=True,text=True) + for head in ['feature','next']: + with self.subTest(head=head): + metadata['headRefName']=head + result=invoke() + self.assertEqual(result.returncode,3,result.stdout+result.stderr) + self.assertIn('fetch',result.stderr.lower()) + self.assertIn('not available locally',result.stderr.lower()) + metadata.update(headRefName='feature',baseRefName='next',baseRefOid=absent) + result=invoke(); self.assertEqual(result.returncode,0,result.stdout+result.stderr) + self.assertIn('Dry run:',result.stdout) + metadata['baseRefOid']=declared + bindir=root/'bin'; bindir.mkdir() + realgit=shutil.which('git') + fake=bindir/'git'; fake.write_text('#!/bin/sh\ncase "$1" in show) exit 1;; esac\nexec '+realgit+' "$@"\n'); fake.chmod(0o755) + env['PATH']=str(bindir)+os.pathsep+os.environ['PATH'] + result=invoke(); self.assertEqual(result.returncode,3,result.stdout+result.stderr) + self.assertIn('declaration',result.stderr.lower()) + fake.write_text('#!/bin/sh\ncase "$1" in ls-tree) exit 1;; esac\nexec '+realgit+' "$@"\n') + result=invoke(); self.assertEqual(result.returncode,3,result.stdout+result.stderr) + self.assertIn('inspect',result.stderr.lower()) + env['PATH']=os.environ['PATH'] + path.write_text('{invalid'); git('add','.mosaic/repo.json') + git('-c','user.name=Fixture','-c','user.email=fixture@example.test','commit','-qm','invalid') + metadata['baseRefOid']=git('rev-parse','HEAD') + result=invoke(); self.assertEqual(result.returncode,3,result.stdout+result.stderr) + def test_github_metadata_reads_pr_repository_identities(self): import os, shutil with tempfile.TemporaryDirectory() as directory: diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-baseline.sh b/packages/mosaic/framework/tools/git/test-pr-merge-baseline.sh index 77c06731c..387cb6841 100755 --- a/packages/mosaic/framework/tools/git/test-pr-merge-baseline.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-baseline.sh @@ -18,12 +18,15 @@ OUT_LOG="$WORK_DIR/out.log" rm -rf "$WORK_DIR" mkdir -p "$FIXTURE_DIR" +git -C "$WORK_DIR" init -q +git -C "$WORK_DIR" -c user.name=Fixture -c user.email=fixture@example.test commit --allow-empty -qm base +export MOSAIC_TEST_BASE_SHA="$(git -C "$WORK_DIR" rev-parse HEAD)" cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh" cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh" cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH' #!/usr/bin/env bash -printf '%s\n' '{"baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"mosaic-cli-p0","baseRepository":"mosaicstack/stack","headRefName":"mosaic-cli-p2-socket-res","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}' +printf '%s\n' '{"baseRefOid":"'"$MOSAIC_TEST_BASE_SHA"'","baseRefName":"mosaic-cli-p0","baseRepository":"mosaicstack/stack","headRefName":"mosaic-cli-p2-socket-res","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}' SH cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH' diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-fork-ci-status.sh b/packages/mosaic/framework/tools/git/test-pr-merge-fork-ci-status.sh index fd0896a4f..84fe72db3 100755 --- a/packages/mosaic/framework/tools/git/test-pr-merge-fork-ci-status.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-fork-ci-status.sh @@ -21,7 +21,7 @@ cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh" cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH' #!/usr/bin/env bash -printf '%s\n' '{"baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"next","headRefName":"fix/b1-fork-branch","headRefOid":"fedcba9876543210fedcba9876543210fedcba98","headRepository":"stack-mos-dt-0/stack"}' +printf '%s\n' '{"baseRefOid":"'"$MOSAIC_TEST_BASE_SHA"'","baseRefName":"next","headRefName":"fix/b1-fork-branch","headRefOid":"fedcba9876543210fedcba9876543210fedcba98","headRepository":"stack-mos-dt-0/stack"}' SH cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH' @@ -33,6 +33,8 @@ chmod +x "$FIXTURE_DIR"/*.sh # A git repo with an origin remote, so the origin fallback resolves. git init -q "$WORK_DIR/upstream" +git -C "$WORK_DIR/upstream" -c user.name=Fixture -c user.email=fixture@example.test commit --allow-empty -qm base +export MOSAIC_TEST_BASE_SHA="$(git -C "$WORK_DIR/upstream" rev-parse HEAD)" git -C "$WORK_DIR/upstream" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git set +e diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh b/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh index fd9841b33..80ba784d2 100755 --- a/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh @@ -93,7 +93,7 @@ if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/commits/0123456789abcdef01234 exit 0 fi if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then - emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main","sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}' + emit_response "$(printf '%s' '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main","sha":"BASE_SHA_FIXTURE"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}' | sed "s/BASE_SHA_FIXTURE/$MOSAIC_TEST_BASE_SHA/")" exit 0 fi if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then @@ -116,6 +116,8 @@ chmod +x "$MOCK_BIN/curl" cd "$REPO_DIR" git init -q +git -c user.name=Fixture -c user.email=fixture@example.test commit --allow-empty -qm base +export MOSAIC_TEST_BASE_SHA="$(git rev-parse HEAD)" git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git # HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them. # diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh b/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh index a08b484ae..546f3d99d 100644 --- a/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh @@ -17,11 +17,13 @@ make_fixture() { cp "$SCRIPT_DIR/pr-merge.sh" "$tools/pr-merge.sh" cp "$SCRIPT_DIR/detect-platform.sh" "$tools/detect-platform.sh" git -C "$root/repo" init -q + git -C "$root/repo" -c user.name=Fixture -c user.email=fixture@example.test commit --allow-empty -qm base + local base_sha; base_sha=$(git -C "$root/repo" rev-parse HEAD) git -C "$root/repo" remote add origin "$remote" local base_branch="${3:-main}" cat > "$tools/pr-metadata.sh" < "$tools/ci-queue-wait.sh" <<'SH' #!/usr/bin/env bash diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-message-field.sh b/packages/mosaic/framework/tools/git/test-pr-merge-message-field.sh index 326cea32d..d13d4160c 100755 --- a/packages/mosaic/framework/tools/git/test-pr-merge-message-field.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-message-field.sh @@ -12,6 +12,9 @@ failures=0 rm -rf "$WORK_DIR" mkdir -p "$WORK_DIR" +git -C "$WORK_DIR" init -q +git -C "$WORK_DIR" -c user.name=Fixture -c user.email=fixture@example.test commit --allow-empty -qm base +export MOSAIC_TEST_BASE_SHA="$(git -C "$WORK_DIR" rev-parse HEAD)" fail() { echo "FAIL $1" >&2 @@ -62,7 +65,7 @@ case "${MOSAIC_TEST_COMMITS_MODE:?}" in single) head_sha=1111111111111111111111111111111111111111 ;; *) echo "unknown commits mode" >&2; exit 2 ;; esac -printf '{"number":42,"title":"%s","author":"poster","baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"main","headRefName":"feature/fixture","headRefOid":"%s","headRepository":"acme/widgets"}\n' "$title" "$head_sha" +printf '{"number":42,"title":"%s","author":"poster","baseRefOid":"%s","baseRefName":"main","headRefName":"feature/fixture","headRefOid":"%s","headRepository":"acme/widgets"}\n' "$title" "$MOSAIC_TEST_BASE_SHA" "$head_sha" SH cat > "$case_dir/ci-queue-wait.sh" <<'SH' @@ -269,6 +272,7 @@ SH run_case() { local case_dir="$1" mode="$2" shift 2 + cd "$WORK_DIR" || return MOSAIC_TEST_COMMITS_MODE="$mode" \ MOSAIC_TEST_CURL_LOG="$case_dir/curl.log" \ MOSAIC_TEST_CURL_BOUNDS_LOG="$case_dir/curl-bounds.log" \ diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-no-ci-expected.sh b/packages/mosaic/framework/tools/git/test-pr-merge-no-ci-expected.sh index 48ff882b9..e500215c8 100755 --- a/packages/mosaic/framework/tools/git/test-pr-merge-no-ci-expected.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-no-ci-expected.sh @@ -14,12 +14,15 @@ CALL_LOG="$WORK_DIR/queue-call.log" rm -rf "$WORK_DIR" mkdir -p "$FIXTURE_DIR" +git -C "$WORK_DIR" init -q +git -C "$WORK_DIR" -c user.name=Fixture -c user.email=fixture@example.test commit --allow-empty -qm base +export MOSAIC_TEST_BASE_SHA="$(git -C "$WORK_DIR" rev-parse HEAD)" cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh" cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh" cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH' #!/usr/bin/env bash -printf '%s\n' '{"baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/no-ci-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}' +printf '%s\n' '{"baseRefOid":"'"$MOSAIC_TEST_BASE_SHA"'","baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/no-ci-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}' SH cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH' diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh b/packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh index 41da2cc14..51a6636a9 100644 --- a/packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh @@ -10,12 +10,15 @@ CALL_LOG="$WORK_DIR/queue-call.log" rm -rf "$WORK_DIR" mkdir -p "$FIXTURE_DIR" +git -C "$WORK_DIR" init -q +git -C "$WORK_DIR" -c user.name=Fixture -c user.email=fixture@example.test commit --allow-empty -qm base +export MOSAIC_TEST_BASE_SHA="$(git -C "$WORK_DIR" rev-parse HEAD)" cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh" cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh" cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH' #!/usr/bin/env bash -printf '%s\n' '{"baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}' +printf '%s\n' '{"baseRefOid":"'"$MOSAIC_TEST_BASE_SHA"'","baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}' SH cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH' -- 2.54.0 From 01662b281f833ee1958f1b860e189ac6522adae5 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 5 Nov 2026 13:16:32 -0600 Subject: [PATCH 25/26] fix(framework): read pinned policy from repo root and compare pages unordered --- .../mosaic/framework/tools/git/forge-api.py | 8 +++- .../mosaic/framework/tools/git/pr-merge.sh | 2 +- .../tools/git/test-forge-contract.py | 24 ++++++++++ .../framework/tools/git/test-merge-policy.py | 48 +++++++++++++++++-- 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/packages/mosaic/framework/tools/git/forge-api.py b/packages/mosaic/framework/tools/git/forge-api.py index a7716efe2..a234fa523 100644 --- a/packages/mosaic/framework/tools/git/forge-api.py +++ b/packages/mosaic/framework/tools/git/forge-api.py @@ -318,7 +318,11 @@ class Client: by_id = all(isinstance(row, dict) and row.get('id') is not None for row in previous + value) before = [row['id'] for row in previous] if by_id else previous current = [row['id'] for row in value] if by_id else value - if current == before: + if by_id: + repeated = sorted(current, key=lambda item: json.dumps(item, sort_keys=True)) == sorted(before, key=lambda item: json.dumps(item, sort_keys=True)) + else: + repeated = sorted(json.dumps(row, sort_keys=True) for row in current) == sorted(json.dumps(row, sort_keys=True) for row in before) + if repeated: if len(result) < expected_total: raise Failure(6, 'incomplete', 'Provider returned fewer records than total_count') return result @@ -644,7 +648,7 @@ def declared_method(client, p, requested): revision = base['sha'] if run(['git', 'cat-file', '-e', revision + '^{commit}']).returncode: raise Failure(3, 'policy', 'POLICY-REFUSED: fetch the pinned target revision before merging') - listing = run(['git', 'ls-tree', revision, '--', '.mosaic/repo.json']) + listing = run(['git', 'ls-tree', '--full-tree', revision, '--', '.mosaic/repo.json']) if listing.returncode: raise Failure(6, 'verification', 'Cannot inspect pinned target declaration') if not listing.stdout.strip(): diff --git a/packages/mosaic/framework/tools/git/pr-merge.sh b/packages/mosaic/framework/tools/git/pr-merge.sh index aa153350c..ce012af56 100755 --- a/packages/mosaic/framework/tools/git/pr-merge.sh +++ b/packages/mosaic/framework/tools/git/pr-merge.sh @@ -190,7 +190,7 @@ if ! git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then echo "POLICY-REFUSED: pinned base commit is not available locally; fetch it before merging." >&2 exit 3 fi -if ! git ls-tree "$BASE_SHA" -- .mosaic/repo.json > "$pinned_dir/listing" 2>/dev/null; then +if ! git ls-tree --full-tree "$BASE_SHA" -- .mosaic/repo.json > "$pinned_dir/listing" 2>/dev/null; then echo "POLICY-REFUSED: cannot inspect pinned base declaration." >&2 exit 3 fi diff --git a/packages/mosaic/framework/tools/git/test-forge-contract.py b/packages/mosaic/framework/tools/git/test-forge-contract.py index 4666610fd..601fe4141 100644 --- a/packages/mosaic/framework/tools/git/test-forge-contract.py +++ b/packages/mosaic/framework/tools/git/test-forge-contract.py @@ -125,6 +125,30 @@ class Contract(unittest.TestCase): with self.assertRaises(self.api.Failure) as caught: c.pages('/collection') self.assertEqual((caught.exception.code,caught.exception.kind),(6,'incomplete')) + def test_forge_reordered_repeated_pages_preserve_multiplicity(self): + import urllib.parse + cases=[ + ([{'id':21},{'id':'22'}],[{'id':'22'},{'id':21}]), + ([{'name':'one','detail':{'a':1,'b':2}},{'name':'two'}],[{'name':'two'},{'detail':{'b':2,'a':1},'name':'one'}]), + ] + for provider in ('gitea','forgejo'): + for first,second in cases: + with self.subTest(provider=provider,first=first): + c=self.api.Client('git.example.test','owner/repo'); c.provider=provider + calls=[] + def http(path): + page=int(urllib.parse.parse_qs(urllib.parse.urlsplit(path).query)['page'][0]); calls.append(page) + return first if page==1 else second + c.http=http + self.assertEqual(c.pages('/reviews/1/comments'),first) + self.assertEqual(calls,[1,2]) + for first,second in [([{'id':1},{'id':1},{'id':2}],[{'id':1},{'id':2},{'id':2}]), ([{'name':'a'},{'name':'a'},{'name':'b'}],[{'name':'a'},{'name':'b'},{'name':'b'}])]: + with self.subTest(multiplicity=first): + c=self.api.Client('git.example.test','owner/repo'); c.provider='gitea' + c.http=lambda path: first if 'page=1' in path else second + with self.assertRaises(self.api.Failure) as caught: c.pages('/collection') + self.assertEqual(caught.exception.code,6) + def test_forge_partial_overlap_fails_closed(self): for provider in ('gitea','forgejo'): for first,second in [([{'id':1},{'id':2}],[{'id':2},{'id':3}]), ([{'name':'one'},{'name':'two'}],[{'name':'two'},{'name':'three'}])]: diff --git a/packages/mosaic/framework/tools/git/test-merge-policy.py b/packages/mosaic/framework/tools/git/test-merge-policy.py index 34c55b8a2..ce586f313 100644 --- a/packages/mosaic/framework/tools/git/test-merge-policy.py +++ b/packages/mosaic/framework/tools/git/test-merge-policy.py @@ -69,6 +69,28 @@ class Policy(unittest.TestCase): self.assertEqual(api.declared_method(c,p,None),'squash') finally: os.chdir(old) + def test_adapter_reads_pinned_declaration_from_subdirectory(self): + import os, importlib.util + spec=importlib.util.spec_from_file_location('subdir_policy',HERE/'forge-api.py') + api=importlib.util.module_from_spec(spec); spec.loader.exec_module(api) + with tempfile.TemporaryDirectory() as directory: + root=pathlib.Path(directory); (root/'.mosaic').mkdir(); (root/'subdir').mkdir() + def git(*args): + return subprocess.run(['git',*args],cwd=root,capture_output=True,text=True,check=True).stdout.strip() + git('init','-q'); git('remote','add','origin','https://git.example.test/owner/stack.git') + doc=dict(schema_version=3,integration_trunk='next',release_branch='main',flow='trunk-release',canonical_remote='https://git.example.test/owner/stack',canonical_clone='host:/src/project',merge=dict(allowed_methods=['merge'],default_method='merge',shapes={'promotion':'merge'})) + (root/'.mosaic/repo.json').write_text(json.dumps(doc)); git('add','.mosaic/repo.json') + git('-c','user.name=Fixture','-c','user.email=fixture@example.test','commit','-qm','policy') + p={'head':{'ref':'next','repo':{'full_name':'owner/stack'}},'base':{'ref':'main','sha':git('rev-parse','HEAD'),'repo':{'full_name':'owner/stack'}}} + c=api.Client('git.example.test','owner/stack'); c.http=lambda path: {'default_branch':'main'} + old=os.getcwd() + try: + for cwd in (root,root/'subdir'): + with self.subTest(cwd=cwd): + os.chdir(cwd) + self.assertEqual(api.declared_method(c,p,None),'merge') + finally: os.chdir(old) + def test_branch_precheck_still_requires_absorb_mapping(self): with tempfile.TemporaryDirectory() as directory: path=pathlib.Path(directory)/'repo.json' @@ -166,16 +188,34 @@ class Policy(unittest.TestCase): git('-c','user.name=Fixture','-c','user.email=fixture@example.test','commit','--allow-empty','-qm','absent') absent=git('rev-parse','HEAD') (repo/'.mosaic').mkdir() - doc=dict(schema_version=3,integration_trunk='next',release_branch='main',flow='trunk-release',canonical_remote='https://github.com/owner/repo',canonical_clone='host:/src/repo',merge=dict(allowed_methods=['merge'],default_method='merge',shapes={'promotion':'merge'})) - path=repo/'.mosaic/repo.json'; path.write_text(json.dumps(doc)); git('add','.mosaic/repo.json') + doc=dict(schema_version=3,integration_trunk='next',release_branch='main',flow='trunk-release',canonical_remote='https://github.com/owner/repo',canonical_clone='host:/src/repo',merge=dict(allowed_methods=['squash','merge'],default_method='squash',shapes={'promotion':'merge'})) + path=repo/'.mosaic/repo.json' + merge=doc.pop('merge'); path.write_text(json.dumps(doc)); git('add','.mosaic/repo.json') + git('-c','user.name=Fixture','-c','user.email=fixture@example.test','commit','-qm','declared transition') + transition=git('rev-parse','HEAD') + doc['merge']=merge; path.write_text(json.dumps(doc)); git('add','.mosaic/repo.json') git('-c','user.name=Fixture','-c','user.email=fixture@example.test','commit','-qm','declared') declared=git('rev-parse','HEAD') script=git_tools/'pr-metadata.sh' metadata=dict(baseRefName='main',baseRefOid='f'*40,baseRepository='owner/repo',headRefName='feature',headRefOid='a'*40,headRepository='owner/repo') env=dict(os.environ,HOME=str(root),GIT_CONFIG_GLOBAL='/dev/null',GIT_CONFIG_NOSYSTEM='1') - def invoke(): + def invoke(cwd=repo): script.write_text("#!/bin/sh\nprintf '%s\\n' "+repr(json.dumps(metadata))+"\n"); script.chmod(0o755) - return subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run'],cwd=repo,env=env,capture_output=True,text=True) + return subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run'],cwd=cwd,env=env,capture_output=True,text=True) + subdir=repo/'subdir'; subdir.mkdir() + metadata['baseRefOid']=declared + for head, code, message in [('feature',1,'transition'),('next',3,'does not select legacy squash')]: + with self.subTest(cwd='subdir', head=head): + metadata['headRefName']=head + metadata['baseRefOid']=transition if head=='feature' else declared + root_result=invoke() + sub_result=invoke(subdir) + self.assertEqual(root_result.returncode,code,root_result.stdout+root_result.stderr) + self.assertEqual(sub_result.returncode,code,sub_result.stdout+sub_result.stderr) + self.assertIn(message,root_result.stderr.lower()) + self.assertIn(message,sub_result.stderr.lower()) + self.assertEqual(sub_result.stderr,root_result.stderr) + metadata['baseRefOid']='f'*40 for head in ['feature','next']: with self.subTest(head=head): metadata['headRefName']=head -- 2.54.0 From faf3f90e3add80264d758ee85a43b541860744cc Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 13:44:57 -0600 Subject: [PATCH 26/26] style(framework): format forge wrapper README table --- packages/mosaic/framework/tools/git/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/mosaic/framework/tools/git/README.md b/packages/mosaic/framework/tools/git/README.md index b1b188dde..f09987be2 100644 --- a/packages/mosaic/framework/tools/git/README.md +++ b/packages/mosaic/framework/tools/git/README.md @@ -48,11 +48,11 @@ verify `/user` against that identity before a write. Operators provision these files; the wrappers do not create them. `` is `MOSAIC_BRAIN_HOME` when set, otherwise `~/.mosaic`. -| Host and identity | Token file | -| --- | --- | -| `git.mosaicstack.dev` or `git.uscllc.com`, with `/fleet/agents//` present | `/fleet/agents//secrets/gitea-{mosaicstack,usc}-.token` | -| Those hosts, other identities | `~/.config/mosaic/secrets/gitea-tokens/gitea-{mosaicstack,usc}-.token` | -| Any other host, including github.com | `/credentials/forge//.token` | +| Host and identity | Token file | +| ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | +| `git.mosaicstack.dev` or `git.uscllc.com`, with `/fleet/agents//` present | `/fleet/agents//secrets/gitea-{mosaicstack,usc}-.token` | +| Those hosts, other identities | `~/.config/mosaic/secrets/gitea-tokens/gitea-{mosaicstack,usc}-.token` | +| Any other host, including github.com | `/credentials/forge//.token` | Use the actual hostname (and port when specified) as `` and the matching instance name in the two fleet-host paths. Keep tokens private and out of git. -- 2.54.0