fix(mcp): complete canonical-root consumption for cross-repo namespaces (Closes #739)
#706 routed the #274 filesystem guards and the session repository slug through
the configured canonical_repository_root. Three consumption paths were left
deriving from the Gitea-Tools installation checkout.
F1 — gitea_get_runtime_context did not normalize its `remote` argument, while
gitea_whoami did. On a prgs-hosted namespace whose first native call was the
runtime-context path, the 'dadeschools' argument default was pinned: identity
resolved against the wrong host, and because first-bind is first-write-wins a
later correct gitea_whoami(remote="prgs") could not repair the binding. It now
calls _effective_remote before any host lookup, identity resolution, or session
seeding. Explicit remotes pass through untouched and a dadeschools-hosted
profile still resolves to dadeschools.
F2 — _delete_branch_repository_binding_block derived its expected slug from
_workspace_repository_slug, which reads _local_git_remote_url in PROJECT_ROOT
(always Gitea-Tools). For a cross-repository namespace this inverted the guard:
the genuinely bound target was rejected and Gitea-Tools was accepted. The
canonical-root branch already present in _trusted_session_repository is
extracted as _canonical_repository_slug and shared by both call sites. A
configured-but-unresolvable root now fails closed instead of falling back to
the installation identity; unconfigured namespaces keep the install-derived
default unchanged.
F3 — gitea_assess_master_parity measures Gitea-Tools server implementation
parity only. That is intentional and is preserved: startup_head, current_head,
in_parity, stale, and restart_required keep their existing meaning and values,
and only the server dimension gates mutations. Two separately labelled
dimensions are added — server_implementation (installation root and commit) and
target_repository (canonical root, repository slug, checkout head, last-known
remote master head, staleness) — so cross-repository commissioning evidence can
distinguish them. The target assessment makes no network call: the remote side
is read from the existing remote-tracking ref, and an unfetched target is
reported indeterminate rather than guessed at.
Verified intentional and left unchanged: the #274 workspace-membership,
author-mutation-worktree, and branches-only guards already validate against the
configured canonical root; explicit org/repo may confirm but never authorize a
binding; and the four-role repository-specific configuration surface already
passes audit and bind-time validation with a wrong root failing closed.
Tests: 31 new cases in tests/test_cross_repo_canonical_consumption.py using
real git repositories and no network. Full suite 3298 passed / 6 skipped
against a clean-baseline 3267; the 2 failures
(test_issue_702_review_findings_f1_f6, test_reconciler_supersession_close)
reproduce identically on unmodified master a8d2087 and are pre-existing.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013jUxaLVLkPHuTQwAzFzztW
This commit is contained in:
+97
-29
@@ -1648,6 +1648,45 @@ def _workspace_repository_slug(remote: str | None) -> str | None:
|
||||
return session_ctx.format_repository_slug(parsed[0], parsed[1])
|
||||
|
||||
|
||||
def _canonical_repository_slug(
|
||||
profile: dict,
|
||||
remote: str | None,
|
||||
) -> tuple[str | None, list[str]]:
|
||||
"""Repository identity of the *configured* canonical root, if any (#739 F2).
|
||||
|
||||
Returns ``(slug, reasons)``. A namespace with no configured
|
||||
``canonical_repository_root`` yields ``(None, [])`` so callers keep the
|
||||
install-derived single-repository default. A configured root that cannot be
|
||||
resolved to a repository identity yields ``(None, reasons)`` — a fail-closed
|
||||
signal, never a silent fallback to the installation checkout, because that
|
||||
fallback is exactly what lets a cross-repository namespace validate against
|
||||
the wrong repository.
|
||||
|
||||
Env-over-profile precedence and the existence / git-toplevel / resolvable
|
||||
remote-identity validation all live in ``crr``.
|
||||
"""
|
||||
configured_value, configured_source = crr.configured_canonical_root(
|
||||
profile, os.environ
|
||||
)
|
||||
if not configured_value:
|
||||
return None, []
|
||||
assessment = crr.assess_canonical_repository_root(
|
||||
configured_value=configured_value,
|
||||
source=configured_source,
|
||||
expected_slug=None,
|
||||
process_project_root=PROJECT_ROOT,
|
||||
remote=remote,
|
||||
require_binding=True,
|
||||
)
|
||||
slug = assessment.get("resolved_slug")
|
||||
if assessment.get("block") or not slug:
|
||||
return None, list(assessment.get("reasons") or [
|
||||
"configured canonical repository root has no resolvable "
|
||||
"repository identity (fail closed)"
|
||||
])
|
||||
return slug, []
|
||||
|
||||
|
||||
def _trusted_session_repository(
|
||||
profile: dict,
|
||||
remote: str | None,
|
||||
@@ -1686,32 +1725,10 @@ def _trusted_session_repository(
|
||||
# self-authorizing). Env-over-profile precedence and fail-closed validation
|
||||
# (existence, git toplevel, resolvable remote identity) are handled by
|
||||
# ``crr``; unconfigured single-repo namespaces keep the install-derived slug.
|
||||
configured_value, configured_source = crr.configured_canonical_root(
|
||||
profile, os.environ
|
||||
)
|
||||
if configured_value:
|
||||
assessment = crr.assess_canonical_repository_root(
|
||||
configured_value=configured_value,
|
||||
source=configured_source,
|
||||
expected_slug=None,
|
||||
process_project_root=PROJECT_ROOT,
|
||||
remote=remote,
|
||||
require_binding=True,
|
||||
)
|
||||
slug = assessment.get("resolved_slug")
|
||||
if assessment.get("block") or not slug:
|
||||
return {
|
||||
"org": None,
|
||||
"repository": None,
|
||||
"reasons": assessment.get("reasons")
|
||||
or [
|
||||
"configured canonical repository root has no resolvable "
|
||||
"repository identity; session repository cannot be "
|
||||
"authorized (fail closed)"
|
||||
],
|
||||
}
|
||||
else:
|
||||
slug = _workspace_repository_slug(remote)
|
||||
canonical_slug, canonical_reasons = _canonical_repository_slug(profile, remote)
|
||||
if canonical_reasons:
|
||||
return {"org": None, "repository": None, "reasons": canonical_reasons}
|
||||
slug = canonical_slug or _workspace_repository_slug(remote)
|
||||
scope = session_ctx.assess_repository_scope(
|
||||
workspace_slug=slug,
|
||||
allowed=allowed,
|
||||
@@ -8768,8 +8785,28 @@ def _delete_branch_repository_binding_block(
|
||||
(unproven target).
|
||||
|
||||
Returns a structured block dict, or ``None`` when the target is authorized.
|
||||
|
||||
#739 F2: the trusted identity is the session's configured
|
||||
``canonical_repository_root`` when the namespace declares one. Deriving it
|
||||
from ``_workspace_repository_slug`` alone reads the *installation* checkout
|
||||
(``_local_git_remote_url`` runs in ``PROJECT_ROOT``, always Gitea-Tools),
|
||||
which inverts the guard for a cross-repository namespace: the genuinely
|
||||
bound target is rejected and Gitea-Tools is accepted. A configured root that
|
||||
cannot be resolved fails closed rather than falling back to the install
|
||||
identity; unconfigured namespaces keep the install-derived default.
|
||||
"""
|
||||
workspace_slug = _workspace_repository_slug(remote)
|
||||
canonical_slug, canonical_reasons = _canonical_repository_slug(
|
||||
get_profile(), remote
|
||||
)
|
||||
if canonical_reasons:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"reasons": canonical_reasons,
|
||||
"blocker_kind": "repository_binding",
|
||||
}
|
||||
workspace_slug = canonical_slug or _workspace_repository_slug(remote)
|
||||
workspace_parts = (
|
||||
session_ctx.parse_repository_slug(workspace_slug) if workspace_slug else None
|
||||
)
|
||||
@@ -14036,6 +14073,16 @@ def gitea_get_runtime_context(
|
||||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||||
host: Override the Gitea host.
|
||||
"""
|
||||
# #739 F1: normalize the remote *before* any host lookup, identity
|
||||
# resolution, or session seeding. ``remote`` defaults to 'dadeschools'
|
||||
# across the whole tool surface, so a caller that omits it on a prgs-hosted
|
||||
# namespace would otherwise pin the argument default: the identity lookup
|
||||
# below would authenticate against the wrong host, and because first-bind is
|
||||
# first-write-wins a later, correct ``gitea_whoami(remote="prgs")`` could not
|
||||
# repair the binding. ``gitea_whoami`` has always normalized here; this is
|
||||
# the same call, not a new policy. Explicit non-default remotes pass through
|
||||
# untouched, and a dadeschools-hosted profile still resolves to dadeschools.
|
||||
remote = _effective_remote(remote)
|
||||
profile = get_profile()
|
||||
config = gitea_config.load_config()
|
||||
reveal = _reveal_endpoints()
|
||||
@@ -14213,13 +14260,23 @@ def gitea_assess_master_parity(
|
||||
Never mutates and makes no network calls. Read-only operations are never
|
||||
blocked by staleness; only mutating operations fail closed while stale.
|
||||
|
||||
The top-level ``startup_head``/``current_head``/``in_parity`` fields keep
|
||||
their original meaning: the *Gitea-Tools server implementation* only. #739
|
||||
F3 adds two separately labelled dimensions so a cross-repository namespace
|
||||
can tell them apart — ``server_implementation`` (this installation, restated
|
||||
under an explicit name) and ``target_repository`` (the configured canonical
|
||||
target checkout and its last-known remote master). Only the server dimension
|
||||
gates mutations.
|
||||
|
||||
Returns:
|
||||
dict with 'in_parity', 'stale', 'restart_required', 'startup_head',
|
||||
'current_head', 'mutation_gate_enforced', 'summary', 'reasons', and a
|
||||
'report' recovery payload when stale.
|
||||
'current_head', 'mutation_gate_enforced', 'summary', 'reasons',
|
||||
'server_implementation', 'target_repository', and a 'report' recovery
|
||||
payload when stale.
|
||||
"""
|
||||
parity = _current_master_parity()
|
||||
enforced = not master_parity_gate.gate_disabled()
|
||||
canonical_root, canonical_source = _configured_canonical_root()
|
||||
out = {
|
||||
"in_parity": parity["in_parity"],
|
||||
"stale": parity["stale"],
|
||||
@@ -14231,6 +14288,17 @@ def gitea_assess_master_parity(
|
||||
"summary": master_parity_gate.format_parity(parity),
|
||||
"reasons": parity["reasons"],
|
||||
"process_root": PROJECT_ROOT,
|
||||
"server_implementation": {
|
||||
"installation_root": PROJECT_ROOT,
|
||||
"startup_head": parity["startup_head"],
|
||||
"current_head": parity["current_head"],
|
||||
"stale": parity["stale"],
|
||||
"determinable": parity["determinable"],
|
||||
},
|
||||
"target_repository": master_parity_gate.assess_target_repository_parity(
|
||||
canonical_root=canonical_root,
|
||||
source=canonical_source,
|
||||
),
|
||||
}
|
||||
if parity["stale"] and enforced:
|
||||
out["report"] = master_parity_gate.parity_report(parity)
|
||||
|
||||
Reference in New Issue
Block a user