From 61c3a57df57151a41dff02038acf276f62ad36af Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Sat, 18 Jul 2026 12:02:31 -0400 Subject: [PATCH] fix(mcp): consume canonical target root in cross-repository operations (Closes #741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #706 introduced the immutable `canonical_repository_root` and #739/#740 routed three consumption paths through it. The paths #740 left behind all shared one shape: the *filesystem* guards had been migrated to the canonical root, but *repository identity* still bottomed out in `_local_git_remote_url`, which ran `git remote get-url` with `cwd=PROJECT_ROOT` — always the Gitea-Tools install checkout. The two halves of a single assessment therefore described two different repositories. For a namespace bound to another repository this inverts the guards rather than merely weakening them: an operation naming the genuinely bound target is rejected, while one naming Gitea-Tools is accepted. Central helper -------------- Adds `_canonical_local_git_root()` as the single place a target-repository root is resolved: the configured canonical root when one is declared, else `PROJECT_ROOT`. A configured-but-invalid root is never silently replaced by the install checkout. Single-repository behaviour is byte-for-byte unchanged. Defects fixed ------------- * `_local_git_remote_url` now runs in the canonical target root, which corrects every downstream identity consumer at once (`_resolve`, the #530 remote/repo guard, the anti-stomp org/repo fill, `_workspace_repository_slug`). * `_verify_role_mutation_workspace` omitted `configured_canonical_root`, so the #274 branches-only / worktree-membership guards validated `Gitea-Tools/branches/` instead of the bound target. It now threads the root exactly as `_resolve_namespace_mutation_context` does. * `_resolve` derived omitted coordinates by looking a remote up *by name*. A target checkout commonly names its remote `origin` rather than `prgs`, so the lookup returned None and the coordinates fell through to the remote-wide default target — an unrelated repository. It now prefers `_canonical_repository_slug`, which probes candidate remote names. * Explicit `org`/`repo` short-circuit the #530 match check, so caller coordinates bypassed validation entirely. They may now only *confirm* a canonical binding, never override it, and fail closed in both directions. * Reconciler ancestry, fetch, worktree-inventory and cleanup call sites, the author worktree derivation in `gitea_lock_issue`/`gitea_create_pr`, and the `control_clean` porcelain probe now use the canonical target root. * `gitea_config`: v2-`environments` silently *dropped* `canonical_repository_root` during flattening, so such a namespace fell back to the install root — a fail-open. It is now validated and propagated. The v1 path validates it too, so all three loaders behave identically. Preserved as installation-scoped -------------------------------- Server parity (`master_parity_gate`), workflow/schema/skill loading, self-code hashing and stale-runtime detection, and `mirror_refs.sh` lookup remain anchored to `PROJECT_ROOT`. The `server_implementation` vs `target_repository` parity dimensions from #740 stay separately labelled, and only the server dimension gates mutations. Tests ----- `tests/test_issue_741_canonical_root_consumers.py` (51 tests, 52 subtests) drives a role-by-operation matrix over author/reviewer/merger/reconciler against: correct cross-repository target, wrong repository, wrong worktree, explicit matching and mismatched coordinates, missing/invalid canonical root, immutable first-bind, and request-override attempts — plus both cross-repository directions and all three config loaders. Real git repositories, no network, no branch deletion, no merge. The module reinstalls the genuine `_local_git_remote_url` per test: an autouse conftest fixture permanently reassigns it to a working-directory-independent stub, which is precisely the behaviour under test. Full suite: 3408 passed, 6 skipped, 2 failed. Both failures (`test_issue_702_review_findings_f1_f6`, `test_reconciler_supersession_close`) reproduce identically on clean master c908ed605089 and are pre-existing. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01AYGdWAwuA6UNDc9c3CGipU --- docs/canonical-repository-root.md | 153 ++++ gitea_config.py | 17 + gitea_mcp_server.py | 144 +++- ...test_issue_741_canonical_root_consumers.py | 768 ++++++++++++++++++ 4 files changed, 1058 insertions(+), 24 deletions(-) create mode 100644 docs/canonical-repository-root.md create mode 100644 tests/test_issue_741_canonical_root_consumers.py diff --git a/docs/canonical-repository-root.md b/docs/canonical-repository-root.md new file mode 100644 index 0000000..b1110b7 --- /dev/null +++ b/docs/canonical-repository-root.md @@ -0,0 +1,153 @@ +# Installation root vs canonical target repository root + +## Purpose + +This document (tracked as issue #741, building on #706 and #739/#740) explains +the two distinct filesystem roots the Gitea-Tools MCP server reasons about, why +conflating them silently targets the wrong repository, and which rule applies +when you add a new consumer. + +It is the repository-scope companion to +[`gitea-execution-profiles.md`](gitea-execution-profiles.md) (the profile model) +and [`gitea-dual-namespace-deployment.md`](gitea-dual-namespace-deployment.md) +(the per-role namespace model). + +## The two roots + +| | Installation root | Canonical target repository root | +|---|---|---| +| What it is | The checkout the server *code* lives in | The working root of the repository whose issues/PRs/branches the namespace *mutates* | +| How it is derived | `PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))` | Configured per namespace, then pinned immutably into the session | +| Configured by | Nothing — it follows the script | `canonical_repository_root` profile field, or the `GITEA_CANONICAL_REPOSITORY_ROOT` environment variable | +| Changes at runtime? | No | No — first bind wins for the life of the process | +| Accessor | `PROJECT_ROOT` | `_canonical_local_git_root()` (filesystem) / `_canonical_repository_slug()` (identity) | + +For a **single-repository** namespace — every Gitea-Tools namespace today — the +two roots are the same path, and nothing about the existing behaviour changes. +The distinction only becomes observable once a namespace is pointed at a +different repository. + +## Which root does my code need? + +Ask what the operation is *about*, not where the file happens to sit. + +**Use the installation root (`PROJECT_ROOT`)** when the operation concerns the +Gitea-Tools software itself: + +- server implementation / version parity (`master_parity_gate`, the + `startup_head` vs `current_head` staleness gate); +- loading the server's own workflow, schema and skill files; +- self-code hashing and stale-runtime detection; +- locating installed scripts such as `mirror_refs.sh`. + +These are intentionally install-scoped. Do not "fix" them. + +**Use the canonical target root (`_canonical_local_git_root()`)** when the +operation concerns the repository being worked on: + +- `git remote get-url` for repository identity; +- branch creation, push, and commit; +- ancestry and merge-base proofs; +- worktree inventory, cleanup, and branch deletion; +- any local git subprocess whose result feeds a mutation guard. + +**If you cannot tell, fail closed.** An ambiguous consumer that guesses the +install root is the exact defect class #741 exists to eliminate. + +## Why conflating them inverts the guards + +Before #741, `_local_git_remote_url()` ran `git remote get-url` with +`cwd=PROJECT_ROOT` unconditionally. Every consumer of repository *identity* — +`_resolve`, the #530 remote/repo guard, the anti-stomp org/repo fill, +`_workspace_repository_slug` — therefore read the Gitea-Tools remote and called +it "the workspace", no matter which repository the namespace was bound to. + +For a namespace whose canonical root points elsewhere, this **inverts** the +guard rather than merely weakening it: + +- an operation naming the genuinely bound target repository is **rejected**, + because that slug does not appear in the Gitea-Tools remote URL; +- an operation naming Gitea-Tools is **accepted**. + +The filesystem guards (#274 branches-only and worktree membership) had already +been migrated to the canonical root by #706, so the two halves of a single +assessment described two different repositories. + +A related subtlety: repository identity must not be derived by looking a remote +up by *name*. A target checkout commonly names its remote `origin` rather than +`prgs`, so a name-keyed lookup returns nothing and the omitted coordinates fall +through to the remote-wide default *target* — an unrelated repository. +`_canonical_repository_slug()` probes candidate remote names against the +canonical root instead. + +`_canonical_local_git_root()` is now the one place a target root is resolved. +Do not re-derive it; new code that needs a target root calls that helper. + +## Configuration + +Declare the binding on the profile, alongside `allowed_repositories`: + +```json +{ + "profiles": { + "example-author": { + "role": "author", + "canonical_repository_root": "/absolute/path/to/target-repo", + "allowed_repositories": ["Example-Org/target-repo"] + } + } +} +``` + +The namespace-scoped environment variable +`GITEA_CANONICAL_REPOSITORY_ROOT` overrides the profile field, and is normally +exported next to the server `cwd` in the MCP client configuration. + +Validation is layered, and each layer fails closed: + +1. **Config load.** The path must be a non-empty absolute string. All supported + loaders — v1, v2-`environments`, and v2-`contexts` — validate it identically. + (Before #741 only the v2-`contexts` loader validated it, and + v2-`environments` silently *dropped* the field during flattening, so the + namespace fell back to the install root — a fail-open.) +2. **Bind time.** The path must exist, be a git repository, and resolve to a + repository identity matching the session's authorized slug. A configured but + unresolvable root is never replaced by the install identity. +3. **Every mutation.** The pinned root is compared against the live configured + value; a mismatch is treated as a forged or conflicting binding. + +`allowed_repositories` remains a separate authorization boundary (#714): the +canonical root determines *which* repository is derived, and +`allowed_repositories` determines whether the session may act on it. A root that +resolves to a repository outside that list fails closed. + +## Explicit coordinates confirm, never override + +Explicit `org`/`repo` arguments may **confirm** an existing canonical binding. +They can never establish, complete, or replace one. A request naming a +repository that contradicts the binding fails closed, in both directions: + +- a Gitea-Tools-rooted namespace cannot mutate another repository; +- a namespace rooted at another repository cannot mutate Gitea-Tools. + +This matters because both-explicit coordinates short-circuit the #530 +remote/repo match check, so without this rule a caller could name any repository +and skip validation entirely. + +No request-supplied workspace, remote, owner, repository, or worktree can +replace the immutable root. + +## Parity is reported per dimension + +`gitea_assess_master_parity` reports two separately labelled dimensions: + +- `server_implementation` — the Gitea-Tools installation checkout. Its + `startup_head` / `current_head` / `stale` / `restart_required` fields keep + their original meaning, and **only this dimension gates mutations**: the + running process executes the code it started with, so a merged fix is not live + until the daemon restarts. +- `target_repository` — the configured canonical target checkout and its + last-known remote master. + +"In parity" is a statement about one dimension, never about the whole system. +Read the dimension you actually care about. diff --git a/gitea_config.py b/gitea_config.py index b83056c..53e6a05 100644 --- a/gitea_config.py +++ b/gitea_config.py @@ -272,6 +272,15 @@ def load_config(path=None): ) if not isinstance(data.get("profiles"), dict): raise ConfigError(f"{path} must be a JSON object with a 'profiles' object") + # #741: the v1 path returns `data` unflattened, so nothing else validates + # the cross-repository binding before gitea_auth.get_profile() reads it. + # Validate it here so every supported loader treats the field identically + # (a relative or blank path must never reach the runtime guard). + for _name, _profile in data["profiles"].items(): + if isinstance(_profile, dict): + _validate_canonical_repository_root( + _name, _profile.get("canonical_repository_root") + ) return data @@ -358,6 +367,14 @@ def _flatten_identity(env_name, svc_name, svc, ident_name, ident): for key in ("role", "username", "execution_profile", "audit_label"): if ident.get(key): profile[key] = ident[key] + # #741: the cross-repository binding must survive flattening. Previously + # this key was silently dropped here, so a v2-environments namespace that + # declared canonical_repository_root fell back to the *installation* root + # and mutated Gitea-Tools instead of its target repository — a fail-open. + # Validate it exactly as the v2-contexts loader does before propagating. + _validate_canonical_repository_root(addr, ident.get("canonical_repository_root")) + if ident.get("canonical_repository_root"): + profile["canonical_repository_root"] = ident["canonical_repository_root"] return addr, profile diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 2c008d5..456f94b 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -393,6 +393,35 @@ def _configured_canonical_root() -> tuple[str | None, str | None]: return crr.configured_canonical_root(profile, os.environ) +def _canonical_local_git_root() -> str: + """Local git working root for *target-repository* operations (#741). + + Two distinct roots exist and must never be conflated: + + * ``PROJECT_ROOT`` — the Gitea-Tools **installation** checkout, i.e. where + this server script lives. Server implementation/version parity, workflow + and skill file loading, and self-code staleness detection are anchored + here and must stay that way. + * the **canonical target repository root** — the working root of the + repository whose issues/PRs/branches the namespace actually mutates. + + Every local git invocation that reads or proves *target repository* state + (remote identity, ancestry, worktree inventory, cleanup) must run here, or + a cross-repository namespace silently operates on Gitea-Tools instead. + + Returns the resolved canonical target root when one is configured, else + ``PROJECT_ROOT`` — which preserves the single-repository default exactly. + A configured-but-invalid root is *not* silently replaced by the install + checkout: it is returned as-is so downstream git calls fail rather than + succeed against the wrong repository, and + :func:`_enforce_canonical_repository_root` fails the mutation closed first. + """ + configured, _source = _configured_canonical_root() + if not configured: + return PROJECT_ROOT + return crr.resolve_repo_toplevel(configured) or os.path.realpath(configured) + + def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str: """Resolve the namespace-scoped workspace root inspected by pre-flight guards. @@ -1448,6 +1477,12 @@ def _verify_role_mutation_workspace( git_state = issue_lock_worktree.read_worktree_git_state( _resolve_preflight_workspace_path(worktree_path) ) + # #741: thread the configured canonical root exactly as + # _resolve_namespace_mutation_context does. Omitting it here made the two + # paths disagree: the #274 branches-only / worktree-membership guards fell + # back to the install checkout and validated Gitea-Tools/branches/ instead + # of the target repository the namespace is actually bound to. + _configured_root, _configured_source = _configured_canonical_root() assessment = nwb.assess_namespace_mutation_workspace( role_kind=role, worktree_path=worktree_path, @@ -1458,6 +1493,7 @@ def _verify_role_mutation_workspace( ), profile_name=get_profile().get("profile_name"), current_branch=git_state.get("current_branch"), + configured_canonical_root=_configured_root, ) if assessment["block"]: raise RuntimeError( @@ -2403,9 +2439,24 @@ def _resolve(remote: str, host: str | None, org: str | None, repo: str | None): filled_org = False filled_repo = False if not org_explicit or not repo_explicit: - parsed = remote_repo_guard.parse_org_repo_from_remote_url( - _local_git_remote_url(remote) - ) + parsed = None + # #741: for a cross-repository namespace, prefer the canonical root's + # own repository identity. `_local_git_remote_url` looks a remote up by + # *name*, but a target checkout commonly names its remote `origin` + # rather than `prgs`; that lookup then returns None and the omitted + # coordinates silently fell through to the REMOTES default target — + # i.e. a completely unrelated repository. `_canonical_repository_slug` + # probes the candidate remote names against the canonical root. + try: + canonical_slug, _reasons = _canonical_repository_slug(get_profile(), remote) + except Exception: + canonical_slug = None + if canonical_slug: + parsed = session_ctx.parse_repository_slug(canonical_slug) + if not parsed: + parsed = remote_repo_guard.parse_org_repo_from_remote_url( + _local_git_remote_url(remote) + ) if parsed: if not org_explicit: resolved_org = parsed[0] @@ -2413,6 +2464,40 @@ def _resolve(remote: str, host: str | None, org: str | None, repo: str | None): if not repo_explicit: resolved_repo = parsed[1] filled_repo = True + # #741: explicit caller coordinates may *confirm* a cross-repository + # canonical binding but must never override it. Both-explicit coordinates + # short-circuit the #530 guard (remote_repo_guard.assess_remote_repo_match), + # so without this check a caller could name any repository and skip + # validation entirely. Unconfigured (single-repository) namespaces yield no + # canonical slug and are unaffected. An unresolvable configured root is not + # blocked here — reads must keep working; the fail-closed decision belongs + # to _enforce_canonical_repository_root at the mutation gate. + if org_explicit or repo_explicit: + try: + canonical_slug, _canonical_reasons = _canonical_repository_slug( + get_profile(), remote + ) + except Exception: + canonical_slug = None + bound = ( + session_ctx.parse_repository_slug(canonical_slug) + if canonical_slug + else None + ) + if bound: + override = session_ctx.assess_repository_override( + requested_org=org, + requested_repo=repo, + bound_org=bound[0], + bound_repo=bound[1], + ) + if override.get("block"): + raise RuntimeError( + "Canonical repository binding guard (#741): " + + "; ".join(override.get("reasons") or []) + + ". Explicit org/repo may confirm the configured canonical " + "repository binding but can never override it." + ) _enforce_remote_repo_guard( remote, resolved_org, @@ -3067,7 +3152,7 @@ def gitea_lock_issue( return blocked resolved_worktree = issue_lock_worktree.resolve_author_worktree_path( - worktree_path, PROJECT_ROOT + worktree_path, _canonical_local_git_root() ) h, o, r = _resolve(remote, host, org, repo) active_lease_block = issue_lock_store.assess_same_issue_lease_conflict( @@ -3369,7 +3454,7 @@ def gitea_create_pr( locked_worktree = lock_data.get("worktree_path") worktree_check = issue_lock_worktree.verify_pr_worktree_matches_lock( - locked_worktree, worktree_path, PROJECT_ROOT + locked_worktree, worktree_path, _canonical_local_git_root() ) if worktree_check["block"]: raise ValueError(worktree_check["reasons"][0]) @@ -8499,13 +8584,24 @@ def gitea_merge_pr( def _local_git_remote_url(remote_name: str) -> str | None: - """Best-effort local ``git remote get-url`` for trust-gate corroboration.""" + """Best-effort local ``git remote get-url`` for trust-gate corroboration. + + #741: this runs in the **canonical target repository root**, not the + Gitea-Tools installation checkout. Every consumer of this function derives + *target repository identity* from it (``_resolve``, the #530 remote/repo + guard, the anti-stomp org/repo fill, ``_workspace_repository_slug``), so + running it in ``PROJECT_ROOT`` inverted those guards for a cross-repository + namespace: the genuinely bound target was rejected while Gitea-Tools was + accepted. Single-repository namespaces are unaffected — + :func:`_canonical_local_git_root` returns ``PROJECT_ROOT`` when no + cross-repository binding is configured. + """ try: proc = subprocess.run( ["git", "remote", "get-url", remote_name], capture_output=True, text=True, - cwd=PROJECT_ROOT, + cwd=_canonical_local_git_root(), ) if proc.returncode != 0: return None @@ -9075,7 +9171,7 @@ def gitea_cleanup_merged_pr_branch( else f"origin/{target_branch}" ) head_on_target = merged_cleanup_reconcile.is_head_ancestor_of_ref( - PROJECT_ROOT, + _canonical_local_git_root(), head_sha, target_ref, ) @@ -9110,7 +9206,7 @@ def gitea_cleanup_merged_pr_branch( repo=r, branch=head_branch, pr_number=pr_number, - project_root=PROJECT_ROOT, + project_root=_canonical_local_git_root(), auth=auth, base_api=base, ) @@ -9605,7 +9701,7 @@ def gitea_capture_branches_worktree_snapshot( "reasons": read_block, "permission_report": _permission_block_report("gitea.read"), } - root = PROJECT_ROOT + root = _canonical_local_git_root() if worktree_path: root = os.path.realpath(os.path.abspath(worktree_path)) git_root = _get_git_root(root) @@ -9715,14 +9811,14 @@ def gitea_reconcile_merged_cleanups( h, o, r, auth, head_ref ) head_on_master[int(pr["number"])] = merged_cleanup_reconcile.is_head_ancestor_of_ref( - PROJECT_ROOT, head_sha, master_ref + _canonical_local_git_root(), head_sha, master_ref ) delete_capability_allowed = not _profile_operation_gate("gitea.branch.delete") # #534: discover reviewer scratch trees and active leases for those PRs. scratch_candidates = merged_cleanup_reconcile.discover_reviewer_scratch_worktrees( - PROJECT_ROOT + _canonical_local_git_root() ) active_reviewer_leases: dict[int, bool] = {} pr_states: dict[int, dict] = {} @@ -9748,7 +9844,7 @@ def gitea_reconcile_merged_cleanups( } report = merged_cleanup_reconcile.build_reconciliation_report( - project_root=PROJECT_ROOT, + project_root=_canonical_local_git_root(), closed_prs=merged_closed, open_prs=open_prs, remote_branch_exists=remote_branch_exists, @@ -9788,7 +9884,7 @@ def gitea_reconcile_merged_cleanups( repo=r, branch=head_branch, pr_number=pr_num_int, - project_root=PROJECT_ROOT, + project_root=_canonical_local_git_root(), auth=auth, base_api=base, ) @@ -9872,7 +9968,7 @@ def gitea_reconcile_merged_cleanups( if local_assessment.get("safe_to_remove_worktree"): result = merged_cleanup_reconcile.remove_local_worktree( - PROJECT_ROOT, + _canonical_local_git_root(), head_branch, worktree_path=local_assessment.get("worktree_path"), ) @@ -9882,7 +9978,7 @@ def gitea_reconcile_merged_cleanups( if not scratch.get("safe_to_remove_worktree"): continue result = merged_cleanup_reconcile.remove_reviewer_scratch_worktree( - PROJECT_ROOT, scratch.get("worktree_path") or "" + _canonical_local_git_root(), scratch.get("worktree_path") or "" ) actions.append({"action": "remove_reviewer_scratch_worktree", **result}) @@ -9980,7 +10076,7 @@ def gitea_assess_already_landed_reconciliation( assessment = reconciliation_workflow.assess_open_pr_reconciliation( pr=pr, - project_root=PROJECT_ROOT, + project_root=_canonical_local_git_root(), remote=remote, target_branch=target_branch, ) @@ -10076,14 +10172,14 @@ def gitea_scan_already_landed_open_prs( } target_fetch = reconciliation_workflow.fetch_target_branch( - PROJECT_ROOT, remote, target_branch + _canonical_local_git_root(), remote, target_branch ) candidates: list[dict] = [] for pr in open_prs: assessment = reconciliation_workflow.assess_open_pr_reconciliation( pr=pr, - project_root=PROJECT_ROOT, + project_root=_canonical_local_git_root(), remote=remote, target_branch=target_branch, target_fetch=target_fetch, @@ -10180,7 +10276,7 @@ def gitea_audit_worktree_cleanup( active_issue_branches.add(str(lock["branch_name"]).strip()) report = worktree_cleanup_audit.audit_branches_directory( - PROJECT_ROOT, + _canonical_local_git_root(), open_pr_branches=open_pr_branches, active_issue_branches=active_issue_branches, now=datetime.now(timezone.utc), @@ -10263,7 +10359,7 @@ def gitea_reconcile_already_landed_pr( assessment = already_landed_reconcile.assess_already_landed_reconciliation( pr=pr, - project_root=PROJECT_ROOT, + project_root=_canonical_local_git_root(), remote=remote, target_branch=target_branch, ) @@ -10468,7 +10564,7 @@ def gitea_reconcile_superseded_by_merged_pr( ) target_fetch = already_landed_reconcile.fetch_target_branch( - PROJECT_ROOT, remote, target_branch + _canonical_local_git_root(), remote, target_branch ) superseding_head_sha = ( (superseding_pr.get("head") or {}).get("sha") @@ -10478,7 +10574,7 @@ def gitea_reconcile_superseded_by_merged_pr( ancestor = None if target_fetch.get("success"): ancestor = already_landed_reconcile.is_head_ancestor_of_ref( - PROJECT_ROOT, + _canonical_local_git_root(), superseding_head_sha, target_fetch.get("target_ref") or f"{remote}/{target_branch}", ) @@ -15735,7 +15831,7 @@ def gitea_update_pr_branch_by_merge( control_clean = True try: - porcelain = _get_workspace_porcelain(PROJECT_ROOT) + porcelain = _get_workspace_porcelain(_canonical_local_git_root()) # Untracked-only dirt is ignored; tracked edits contaminate control. tracked_dirty = [ line for line in (porcelain or "").splitlines() diff --git a/tests/test_issue_741_canonical_root_consumers.py b/tests/test_issue_741_canonical_root_consumers.py new file mode 100644 index 0000000..0c8631e --- /dev/null +++ b/tests/test_issue_741_canonical_root_consumers.py @@ -0,0 +1,768 @@ +"""Complete PROJECT_ROOT elimination in cross-repository MCP operations (#741). + +#706 introduced the immutable ``canonical_repository_root``; #739/#740 routed +three consumption paths through it. This module covers the paths #740 left +behind, whose shape is uniform: the *filesystem* guards were migrated to the +canonical root, but *repository identity* still bottomed out in +``_local_git_remote_url``'s hardcoded ``cwd=PROJECT_ROOT`` — always the +Gitea-Tools installation checkout. + +The consequences asserted here: + +* **Identity inversion.** ``_resolve``, the #530 remote/repo guard and the + anti-stomp org/repo fill all derived the *target* repository from the + *install* checkout, so a cross-repository namespace resolved Gitea-Tools + coordinates while its branch/parity facts came from the target repo. +* **Guard disagreement.** ``_verify_role_mutation_workspace`` omitted + ``configured_canonical_root``, so the #274 branches-only / worktree-membership + guards validated ``Gitea-Tools/branches/`` rather than the bound target. +* **Explicit-coordinate override.** Both-explicit ``org``/``repo`` short-circuit + ``assess_remote_repo_match``, so caller coordinates bypassed validation + entirely rather than merely *confirming* the binding. +* **Configuration fail-open.** ``_flatten_identity`` silently dropped + ``canonical_repository_root``, so a v2-``environments`` namespace fell back to + the install root; the v1 path never validated the field at all. + +A role-by-operation matrix drives author, reviewer, merger and reconciler +against: correct cross-repository target, wrong repository, wrong worktree, +explicit matching coordinates, explicit mismatched coordinates, missing +canonical root, invalid canonical root, immutable binding after first bind, and +request-override attempts. + +Real git repositories are used throughout. No network calls are made, no branch +is deleted, and no merge is performed. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import canonical_repository_root as crr # noqa: E402 +import gitea_config # noqa: E402 +import gitea_mcp_server as srv # noqa: E402 +import mcp_server # noqa: E402 +import session_context_binding as session_ctx # noqa: E402 + +INSTALL_ORG = "Scaled-Tech-Consulting" +INSTALL_REPO = "Gitea-Tools" +INSTALL_SLUG = f"{INSTALL_ORG}/{INSTALL_REPO}" +INSTALL_URL = f"https://gitea.prgs.cc/{INSTALL_SLUG}.git" + +TARGET_ORG = "Scaled-Tech-Consulting" +TARGET_REPO = "mcp-control-plane" +TARGET_SLUG = f"{TARGET_ORG}/{TARGET_REPO}" +TARGET_URL = f"https://gitea.prgs.cc/{TARGET_SLUG}.git" + +THIRD_REPO = "Timesheet" +THIRD_SLUG = f"{INSTALL_ORG}/{THIRD_REPO}" + +# tests/conftest.py installs an autouse fixture +# (mutation_profile_fixture.install_deterministic_remote_urls) that *permanently +# reassigns* srv._local_git_remote_url to a stub mapping remote names to fixed +# URLs. That stub deliberately ignores the working directory, which is exactly +# the behaviour this module must verify — so these tests would silently assert +# against the stub rather than production code in a full-suite run. Capture the +# genuine implementation at import time (before any fixture executes) and +# reinstall it per test. +_REAL_LOCAL_GIT_REMOTE_URL = srv._local_git_remote_url + + +def _git(cwd: str, *args: str) -> str: + res = subprocess.run( + ["git", "-C", cwd, *args], capture_output=True, text=True, check=True + ) + return res.stdout.strip() + + +def _init_repo(path: Path, remote_url: str, *, remote_name: str = "origin") -> str: + path.mkdir(parents=True, exist_ok=True) + _git(str(path), "init", "-q") + _git(str(path), "config", "user.email", "t@example.com") + _git(str(path), "config", "user.name", "Test") + _git(str(path), "remote", "add", remote_name, remote_url) + # Distinct content per repo: identical seed content, author and timestamp + # otherwise produce byte-identical commits and therefore an identical SHA, + # which would make the parity-dimension assertions vacuously true. + (path / "README.md").write_text(f"seed {remote_url}\n") + _git(str(path), "add", "README.md") + _git(str(path), "commit", "-q", "-m", f"seed {remote_name}") + return os.path.realpath(str(path)) + + +_ROLE_OPS = { + "author": ( + [ + "gitea.read", + "gitea.issue.create", + "gitea.issue.comment", + "gitea.pr.create", + "gitea.pr.comment", + "gitea.branch.create", + "gitea.branch.push", + "gitea.repo.commit", + ], + ["gitea.pr.approve", "gitea.pr.merge", "gitea.pr.request_changes"], + ), + "reviewer": ( + [ + "gitea.read", + "gitea.pr.approve", + "gitea.pr.request_changes", + "gitea.pr.comment", + "gitea.issue.comment", + ], + ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"], + ), + "merger": ( + ["gitea.read", "gitea.pr.merge", "gitea.pr.comment"], + [ + "gitea.pr.create", + "gitea.branch.push", + "gitea.pr.approve", + "gitea.pr.request_changes", + ], + ), + "reconciler": ( + ["gitea.read", "gitea.pr.close", "gitea.pr.comment", "gitea.branch.delete"], + [ + "gitea.pr.create", + "gitea.branch.push", + "gitea.pr.approve", + "gitea.pr.merge", + "gitea.pr.request_changes", + ], + ), +} + +ROLES = tuple(_ROLE_OPS) + + +def _profile(role: str, *, canonical_root: str | None = None) -> dict: + allowed, forbidden = _ROLE_OPS[role] + profile = { + "enabled": True, + "context": "prgs", + "role": role, + "username": "jcwalker3", + "base_url": "https://gitea.prgs.cc", + "auth": {"type": "env", "name": f"GITEA_TOKEN_PRGS_{role.upper()}"}, + "allowed_operations": list(allowed), + "forbidden_operations": list(forbidden), + "execution_profile": f"prgs-{role}", + "allowed_repositories": [TARGET_SLUG], + } + if canonical_root is not None: + profile["canonical_repository_root"] = canonical_root + return profile + + +def _config(profiles: dict) -> dict: + return { + "version": 2, + "rules": {"allow_runtime_switching": True}, + "contexts": { + "prgs": { + "enabled": True, + "gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"}, + } + }, + "profiles": profiles, + } + + +class _CrossRepoHarness(unittest.TestCase): + """Real install + target git repos, temp profiles.json, no network.""" + + def setUp(self): + self._dir = tempfile.TemporaryDirectory() + self.tmp = self._dir.name + self.config_path = os.path.join(self.tmp, "profiles.json") + + # The real install checkout carries a remote literally named `prgs`; + # a freshly cloned target repository normally names its remote `origin`. + # Reproducing that asymmetry is the point: identity derivation must not + # depend on the remote happening to share the `remote=` argument's name. + self.install_root = _init_repo( + Path(self.tmp) / "install", INSTALL_URL, remote_name="prgs" + ) + self.target_root = _init_repo( + Path(self.tmp) / "target", TARGET_URL, remote_name="origin" + ) + self.third_root = _init_repo( + Path(self.tmp) / "third", f"https://gitea.prgs.cc/{THIRD_SLUG}.git" + ) + self.not_a_repo = os.path.join(self.tmp, "plain-dir") + os.makedirs(self.not_a_repo, exist_ok=True) + + session_ctx._reset_session_context_for_testing() + gitea_config._active_profile_override = None + mcp_server._IDENTITY_CACHE.clear() + srv._MUTATION_AUTHORITY = None + + # PROJECT_ROOT is wherever the server file physically lives; pin it to a + # real Gitea-Tools-identified checkout so "install-derived" is + # deterministic regardless of the developer's layout. + self._project_root = patch.object(srv, "PROJECT_ROOT", self.install_root) + self._project_root.start() + # Undo the autouse deterministic-remote stub for this module only. + self._real_remote_url = patch.object( + srv, "_local_git_remote_url", _REAL_LOCAL_GIT_REMOTE_URL + ) + self._real_remote_url.start() + + def tearDown(self): + self._real_remote_url.stop() + self._project_root.stop() + session_ctx._reset_session_context_for_testing() + gitea_config._active_profile_override = None + mcp_server._IDENTITY_CACHE.clear() + srv._MUTATION_AUTHORITY = None + self._dir.cleanup() + + def _write_config(self, profiles: dict) -> None: + with open(self.config_path, "w", encoding="utf-8") as fh: + fh.write(json.dumps(_config(profiles))) + + def _env(self, role: str, **extra) -> dict: + env = { + "GITEA_MCP_CONFIG": self.config_path, + "GITEA_MCP_PROFILE": f"prgs-{role}", + "GITEA_TOKEN_PRGS_AUTHOR": "t", + "GITEA_TOKEN_PRGS_REVIEWER": "t", + "GITEA_TOKEN_PRGS_MERGER": "t", + "GITEA_TOKEN_PRGS_RECONCILER": "t", + } + env.update(extra) + return env + + def _bind(self, role: str, canonical_root: str | None): + """Activate *role* with *canonical_root* and return an env patch ctx.""" + self._write_config( + {f"prgs-{role}": _profile(role, canonical_root=canonical_root)} + ) + return patch.dict(os.environ, self._env(role), clear=True) + + +# =========================================================================== +# 1. The central helper (single source of root resolution) +# =========================================================================== +class TestCanonicalLocalGitRoot(_CrossRepoHarness): + """_canonical_local_git_root is the one place a target root is derived.""" + + def test_unconfigured_namespace_uses_installation_root(self): + with self._bind("author", None): + self.assertEqual(srv._canonical_local_git_root(), self.install_root) + + def test_configured_namespace_uses_target_root(self): + with self._bind("author", self.target_root): + self.assertEqual(srv._canonical_local_git_root(), self.target_root) + + def test_configured_root_resolves_to_git_toplevel(self): + nested = os.path.join(self.target_root, "branches", "wt") + os.makedirs(nested, exist_ok=True) + with self._bind("author", nested): + # A subdirectory of the target repo still resolves to its toplevel. + self.assertEqual(srv._canonical_local_git_root(), self.target_root) + + def test_invalid_root_never_silently_becomes_installation_root(self): + """A configured-but-broken root must not fall back to Gitea-Tools.""" + with self._bind("author", self.not_a_repo): + resolved = srv._canonical_local_git_root() + self.assertNotEqual(resolved, self.install_root) + self.assertEqual(resolved, os.path.realpath(self.not_a_repo)) + + def test_env_override_beats_profile_binding(self): + self._write_config( + {"prgs-author": _profile("author", canonical_root=self.third_root)} + ) + env = self._env("author", GITEA_CANONICAL_REPOSITORY_ROOT=self.target_root) + with patch.dict(os.environ, env, clear=True): + self.assertEqual(srv._canonical_local_git_root(), self.target_root) + + +# =========================================================================== +# 2. Repository identity — the upstream inversion +# =========================================================================== +class TestRepositoryIdentityDerivation(_CrossRepoHarness): + """_local_git_remote_url and its consumers must read the target repo.""" + + def test_remote_url_reads_target_not_installation(self): + with self._bind("author", self.target_root): + self.assertEqual(srv._local_git_remote_url("origin"), TARGET_URL) + + def test_remote_url_unconfigured_still_reads_installation(self): + with self._bind("author", None): + # The install checkout's remote is named `prgs`, matching production. + self.assertEqual(srv._local_git_remote_url("prgs"), INSTALL_URL) + + def test_workspace_slug_follows_canonical_root(self): + with self._bind("author", self.target_root): + self.assertEqual(srv._workspace_repository_slug("origin"), TARGET_SLUG) + + def test_workspace_slug_unconfigured_is_installation(self): + with self._bind("author", None): + self.assertEqual(srv._workspace_repository_slug("prgs"), INSTALL_SLUG) + + def test_every_role_derives_the_same_target_identity(self): + for role in ROLES: + with self.subTest(role=role): + with self._bind(role, self.target_root): + self.assertEqual( + srv._workspace_repository_slug("origin"), TARGET_SLUG + ) + + +# =========================================================================== +# 3. _resolve — omitted and explicit coordinates +# =========================================================================== +class TestResolveTargetCoordinates(_CrossRepoHarness): + """Omitted coordinates follow the binding; explicit ones may only confirm.""" + + def test_omitted_coordinates_resolve_to_target_repository(self): + for role in ROLES: + with self.subTest(role=role): + with self._bind(role, self.target_root): + _host, org, repo = srv._resolve("prgs", None, None, None) + self.assertEqual((org, repo), (TARGET_ORG, TARGET_REPO)) + + def test_omitted_coordinates_unconfigured_resolve_to_installation(self): + with self._bind("author", None): + _host, org, repo = srv._resolve("prgs", None, None, None) + self.assertEqual((org, repo), (INSTALL_ORG, INSTALL_REPO)) + + def test_explicit_matching_coordinates_confirm_the_binding(self): + for role in ROLES: + with self.subTest(role=role): + with self._bind(role, self.target_root): + _host, org, repo = srv._resolve( + "prgs", None, TARGET_ORG, TARGET_REPO + ) + self.assertEqual((org, repo), (TARGET_ORG, TARGET_REPO)) + + def test_explicit_mismatched_repository_cannot_override_binding(self): + for role in ROLES: + with self.subTest(role=role): + with self._bind(role, self.target_root): + with self.assertRaises(RuntimeError) as ctx: + srv._resolve("prgs", None, TARGET_ORG, INSTALL_REPO) + msg = str(ctx.exception) + self.assertIn("#741", msg) + self.assertIn("fail closed", msg) + + def test_explicit_mismatched_organization_cannot_override_binding(self): + with self._bind("author", self.target_root): + with self.assertRaises(RuntimeError): + srv._resolve("prgs", None, "SomeoneElse", TARGET_REPO) + + def test_gitea_tools_rooted_namespace_cannot_target_control_plane(self): + """Direction 1: an install-rooted namespace must not reach the target.""" + with self._bind("author", self.install_root): + with self.assertRaises(RuntimeError): + srv._resolve("prgs", None, TARGET_ORG, TARGET_REPO) + + def test_control_plane_rooted_namespace_cannot_target_gitea_tools(self): + """Direction 2: a target-rooted namespace must not reach Gitea-Tools.""" + with self._bind("author", self.target_root): + with self.assertRaises(RuntimeError): + srv._resolve("prgs", None, INSTALL_ORG, INSTALL_REPO) + + def test_unconfigured_namespace_keeps_existing_explicit_behaviour(self): + """Same-repository behaviour is unchanged (no new fail-closed path).""" + with self._bind("author", None): + _host, org, repo = srv._resolve("prgs", None, INSTALL_ORG, INSTALL_REPO) + self.assertEqual((org, repo), (INSTALL_ORG, INSTALL_REPO)) + + +# =========================================================================== +# 4. #274 workspace guard — the two guard paths must agree +# =========================================================================== +class TestMutationWorkspaceGuardBinding(_CrossRepoHarness): + """Both guard paths must agree on which repository they protect.""" + + def _contexts(self, worktree: str | None = None): + return srv._resolve_namespace_mutation_context(worktree) + + def test_mutation_context_uses_target_root(self): + with self._bind("author", self.target_root): + self.assertEqual(self._contexts()["canonical_repo_root"], self.target_root) + + def test_mutation_context_unconfigured_uses_installation_root(self): + with self._bind("author", None): + self.assertEqual(self._contexts()["canonical_repo_root"], self.install_root) + + def test_role_mutation_workspace_guard_agrees_with_mutation_context(self): + """The omitted configured_canonical_root made these two disagree.""" + import namespace_workspace_binding as nwb + + for role in ROLES: + with self.subTest(role=role): + with self._bind(role, self.target_root): + configured, _src = srv._configured_canonical_root() + assessment = nwb.assess_namespace_mutation_workspace( + role_kind=role, + worktree_path=None, + worktree=None, + process_project_root=srv.PROJECT_ROOT, + profile_name=f"prgs-{role}", + configured_canonical_root=configured, + ) + self.assertEqual( + assessment["canonical_repo_root"], + self._contexts()["canonical_repo_root"], + ) + self.assertEqual( + assessment["canonical_repo_root"], self.target_root + ) + + def test_wrong_worktree_is_rejected_against_target_root(self): + """A worktree belonging to the install repo is not in the target repo.""" + import author_mutation_worktree as amw + + with self._bind("author", self.target_root): + foreign = os.path.join(self.install_root, "branches", "wt") + os.makedirs(foreign, exist_ok=True) + membership = amw.assess_workspace_repo_membership( + workspace_path=foreign, + canonical_repo_root=self.target_root, + ) + self.assertTrue(membership["block"]) + + +# =========================================================================== +# 5. Canonical-root validation — missing / invalid / mismatched +# =========================================================================== +class TestCanonicalRootFailClosed(_CrossRepoHarness): + """Missing, invalid, ambiguous and mismatched roots fail closed.""" + + def _assess(self, value, *, expected=TARGET_SLUG, require=True): + return crr.assess_canonical_repository_root( + configured_value=value, + source="test", + expected_slug=expected, + process_project_root=self.install_root, + remote="origin", + require_binding=require, + ) + + def test_missing_root_fails_closed_when_binding_required(self): + result = self._assess(None) + self.assertTrue(result["block"]) + self.assertFalse(result["configured"]) + + def test_missing_root_is_the_single_repo_default_when_not_required(self): + result = self._assess(None, expected=None, require=False) + self.assertFalse(result["block"]) + self.assertEqual(result["canonical_repo_root"], self.install_root) + + def test_nonexistent_root_fails_closed(self): + result = self._assess(os.path.join(self.tmp, "nope")) + self.assertTrue(result["block"]) + self.assertIn("does not exist", " ".join(result["reasons"])) + + def test_non_git_directory_fails_closed(self): + result = self._assess(self.not_a_repo) + self.assertTrue(result["block"]) + self.assertIn("not a git repository", " ".join(result["reasons"])) + + def test_mismatched_repository_identity_fails_closed(self): + result = self._assess(self.third_root) + self.assertTrue(result["block"]) + self.assertIn("mismatch", " ".join(result["reasons"])) + + def test_matching_repository_identity_is_proven(self): + result = self._assess(self.target_root) + self.assertFalse(result["block"]) + self.assertEqual(result["resolved_slug"], TARGET_SLUG) + self.assertEqual(result["canonical_repo_root"], self.target_root) + + def test_unresolvable_root_yields_fail_closed_reasons_not_fallback(self): + with self._bind("author", self.not_a_repo): + slug, reasons = srv._canonical_repository_slug(srv.get_profile(), "origin") + self.assertIsNone(slug) + self.assertTrue(reasons) + + +# =========================================================================== +# 6. Immutability — first bind wins, requests cannot replace the root +# =========================================================================== +class TestCanonicalRootImmutability(_CrossRepoHarness): + """No request-supplied value can establish or swap the pinned root.""" + + def test_first_bind_pins_target_root(self): + with self._bind("author", self.target_root): + with patch( + "gitea_mcp_server.api_request", + side_effect=lambda *a, **k: { + "login": "jcwalker3", + "full_name": "T", + "id": 1, + "email": "t@example.com", + }, + ): + srv.gitea_whoami(remote="prgs") + bound = session_ctx.get_session_context() + self.assertEqual(bound["canonical_repository_root"], self.target_root) + + def test_binding_is_first_write_wins(self): + with self._bind("author", self.target_root): + session_ctx.seed_session_context_if_unbound( + profile_name="prgs-author", + remote="prgs", + host="gitea.prgs.cc", + identity="jcwalker3", + org=TARGET_ORG, + repository=TARGET_REPO, + role_kind="author", + source="test", + canonical_repository_root=self.target_root, + ) + # A second, contradictory seed must not replace the pin. + session_ctx.seed_session_context_if_unbound( + profile_name="prgs-author", + remote="prgs", + host="gitea.prgs.cc", + identity="jcwalker3", + org=INSTALL_ORG, + repository=INSTALL_REPO, + role_kind="author", + source="test-2", + canonical_repository_root=self.install_root, + ) + bound = session_ctx.get_session_context() + self.assertEqual(bound["canonical_repository_root"], self.target_root) + self.assertEqual(bound["repository"], TARGET_REPO) + + def test_request_supplied_worktree_cannot_replace_the_root(self): + with self._bind("author", self.target_root): + ctx = srv._resolve_namespace_mutation_context(self.install_root) + # The workspace argument may be demoted/inspected, but the canonical + # repository root stays the configured target. + self.assertEqual(ctx["canonical_repo_root"], self.target_root) + + def test_request_supplied_coordinates_cannot_replace_the_root(self): + with self._bind("author", self.target_root): + with self.assertRaises(RuntimeError): + srv._resolve("prgs", None, INSTALL_ORG, INSTALL_REPO) + self.assertEqual(srv._canonical_local_git_root(), self.target_root) + + +# =========================================================================== +# 7. Parity dimensions stay separately labelled (#739 F3 preserved) +# =========================================================================== +class TestParityDimensionSeparation(_CrossRepoHarness): + """Server-implementation parity stays anchored to the install checkout.""" + + def test_server_dimension_is_installation_not_target(self): + import master_parity_gate + + with self._bind("author", self.target_root): + head = master_parity_gate.read_git_head(srv.PROJECT_ROOT) + self.assertEqual(head, _git(self.install_root, "rev-parse", "HEAD")) + self.assertNotEqual(head, _git(self.target_root, "rev-parse", "HEAD")) + + def test_target_dimension_reads_the_target_checkout(self): + with self._bind("author", self.target_root): + self.assertEqual( + _git(srv._canonical_local_git_root(), "rev-parse", "HEAD"), + _git(self.target_root, "rev-parse", "HEAD"), + ) + + +# =========================================================================== +# 8. Configuration loaders validate the binding consistently (AC13) +# =========================================================================== +class TestConfigurationLoaderValidation(unittest.TestCase): + """Every supported loader treats canonical_repository_root identically.""" + + def setUp(self): + self._dir = tempfile.TemporaryDirectory() + self.tmp = self._dir.name + + def tearDown(self): + self._dir.cleanup() + + def _write(self, data: dict) -> str: + path = os.path.join(self.tmp, "profiles.json") + with open(path, "w", encoding="utf-8") as fh: + fh.write(json.dumps(data)) + return path + + # --- v1 --------------------------------------------------------------- + def _v1(self, root): + return { + "version": 1, + "profiles": { + "prgs-author": { + "base_url": "https://gitea.prgs.cc", + "auth": {"type": "env", "name": "GITEA_TOKEN"}, + "canonical_repository_root": root, + } + }, + } + + def test_v1_rejects_relative_canonical_root(self): + path = self._write(self._v1("relative/path")) + with self.assertRaises(gitea_config.ConfigError) as ctx: + gitea_config.load_config(path) + self.assertIn("absolute", str(ctx.exception)) + + def test_v1_rejects_blank_canonical_root(self): + path = self._write(self._v1(" ")) + with self.assertRaises(gitea_config.ConfigError): + gitea_config.load_config(path) + + def test_v1_accepts_absolute_canonical_root(self): + path = self._write(self._v1("/abs/target")) + loaded = gitea_config.load_config(path) + self.assertEqual( + loaded["profiles"]["prgs-author"]["canonical_repository_root"], + "/abs/target", + ) + + def test_v1_without_the_field_is_unchanged(self): + data = self._v1("/abs/target") + del data["profiles"]["prgs-author"]["canonical_repository_root"] + loaded = gitea_config.load_config(self._write(data)) + self.assertNotIn("canonical_repository_root", loaded["profiles"]["prgs-author"]) + + # --- v2 environments -------------------------------------------------- + def _v2_env(self, root): + ident = { + "auth": {"type": "env", "name": "GITEA_TOKEN"}, + "base_url": "https://gitea.prgs.cc", + "username": "jcwalker3", + "allowed_operations": ["gitea.read"], + } + if root is not None: + ident["canonical_repository_root"] = root + return { + "version": 2, + "environments": { + "prgs": {"services": {"gitea": {"identities": {"author": ident}}}} + }, + } + + def test_v2_environments_propagates_canonical_root(self): + """Regression: the field was silently dropped during flattening.""" + loaded = gitea_config.load_config(self._write(self._v2_env("/abs/target"))) + profile = loaded["profiles"]["prgs.gitea.author"] + self.assertEqual(profile["canonical_repository_root"], "/abs/target") + + def test_v2_environments_rejects_relative_canonical_root(self): + with self.assertRaises(gitea_config.ConfigError) as ctx: + gitea_config.load_config(self._write(self._v2_env("relative/path"))) + self.assertIn("absolute", str(ctx.exception)) + + def test_v2_environments_without_the_field_is_unchanged(self): + loaded = gitea_config.load_config(self._write(self._v2_env(None))) + self.assertNotIn( + "canonical_repository_root", loaded["profiles"]["prgs.gitea.author"] + ) + + # --- v2 contexts (already validated; guard against regression) --------- + def test_v2_contexts_still_rejects_relative_canonical_root(self): + data = { + "version": 2, + "contexts": { + "prgs": { + "enabled": True, + "gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"}, + } + }, + "profiles": { + "prgs-author": { + "enabled": True, + "context": "prgs", + "username": "jcwalker3", + "auth": {"type": "env", "name": "GITEA_TOKEN"}, + "allowed_operations": ["gitea.read"], + "canonical_repository_root": "relative/path", + } + }, + } + with self.assertRaises(gitea_config.ConfigError): + gitea_config.load_config(self._write(data)) + + +# =========================================================================== +# 9. Role-by-operation matrix +# =========================================================================== +class TestRoleOperationMatrix(_CrossRepoHarness): + """Each role, each cross-repository condition, one assertion per cell.""" + + def test_correct_target_resolves_for_every_role(self): + for role in ROLES: + with self.subTest(role=role, case="correct-target"): + with self._bind(role, self.target_root): + _h, org, repo = srv._resolve("prgs", None, None, None) + self.assertEqual((org, repo), (TARGET_ORG, TARGET_REPO)) + + def test_wrong_repository_is_rejected_for_every_role(self): + for role in ROLES: + with self.subTest(role=role, case="wrong-repo"): + with self._bind(role, self.target_root): + with self.assertRaises(RuntimeError): + srv._resolve("prgs", None, INSTALL_ORG, INSTALL_REPO) + + def test_explicit_matching_coordinates_pass_for_every_role(self): + for role in ROLES: + with self.subTest(role=role, case="explicit-match"): + with self._bind(role, self.target_root): + _h, org, repo = srv._resolve("prgs", None, TARGET_ORG, TARGET_REPO) + self.assertEqual((org, repo), (TARGET_ORG, TARGET_REPO)) + + def test_explicit_mismatch_fails_closed_for_every_role(self): + for role in ROLES: + with self.subTest(role=role, case="explicit-mismatch"): + with self._bind(role, self.target_root): + with self.assertRaises(RuntimeError): + srv._resolve("prgs", None, INSTALL_ORG, THIRD_REPO) + + def test_missing_canonical_root_preserves_single_repo_default(self): + for role in ROLES: + with self.subTest(role=role, case="missing-root"): + with self._bind(role, None): + self.assertEqual(srv._canonical_local_git_root(), self.install_root) + + def test_invalid_canonical_root_never_becomes_install_root(self): + for role in ROLES: + with self.subTest(role=role, case="invalid-root"): + with self._bind(role, self.not_a_repo): + self.assertNotEqual( + srv._canonical_local_git_root(), self.install_root + ) + + def test_wrong_worktree_rejected_for_every_role(self): + import author_mutation_worktree as amw + + foreign = os.path.join(self.install_root, "branches", "foreign") + os.makedirs(foreign, exist_ok=True) + for role in ROLES: + with self.subTest(role=role, case="wrong-worktree"): + with self._bind(role, self.target_root): + membership = amw.assess_workspace_repo_membership( + workspace_path=foreign, + canonical_repo_root=srv._canonical_local_git_root(), + ) + self.assertTrue(membership["block"]) + + def test_request_override_attempt_rejected_for_every_role(self): + for role in ROLES: + with self.subTest(role=role, case="request-override"): + with self._bind(role, self.target_root): + with self.assertRaises(RuntimeError): + srv._resolve("prgs", None, "Attacker", "evil-repo") + self.assertEqual(srv._canonical_local_git_root(), self.target_root) + + +if __name__ == "__main__": + unittest.main()