Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6c7d1491e | ||
|
|
9d96cf4cfa | ||
|
|
7978008709 | ||
|
|
6a53308473 | ||
|
|
626be8b178 |
@@ -132,6 +132,7 @@ def assess_canonical_repository_root(
|
|||||||
process_project_root: str,
|
process_project_root: str,
|
||||||
remote: str | None = None,
|
remote: str | None = None,
|
||||||
require_binding: bool = False,
|
require_binding: bool = False,
|
||||||
|
mode: str = "validation",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Validate the canonical repository root binding, failing closed on forgery.
|
"""Validate the canonical repository root binding, failing closed on forgery.
|
||||||
|
|
||||||
@@ -140,14 +141,13 @@ def assess_canonical_repository_root(
|
|||||||
``configured`` (whether a cross-repo binding was declared),
|
``configured`` (whether a cross-repo binding was declared),
|
||||||
``resolved_slug`` and ``source``.
|
``resolved_slug`` and ``source``.
|
||||||
|
|
||||||
Without a configured binding the single-repo default is preserved: the
|
*mode*:
|
||||||
canonical root is derived from *process_project_root* and never blocks
|
- ``"validation"`` (default): an independently trusted expected repository
|
||||||
(unless *require_binding* explicitly demands one).
|
slug is known (or required). Candidate root's observed identity must match.
|
||||||
|
Unprovable or missing expected identity fails closed when *require_binding* is True.
|
||||||
With a configured binding the path must exist, be a git repository, and —
|
- ``"derivation"``: caller is deriving the canonical repository identity.
|
||||||
when *expected_slug* is known — carry a matching repository identity. A
|
No expected slug exists yet by design. Derivation succeeds if the configured
|
||||||
mismatched or (when *require_binding*) unprovable identity is a forged or
|
root exists, is a git repository, and carries a resolvable git remote identity.
|
||||||
conflicting binding and fails closed.
|
|
||||||
"""
|
"""
|
||||||
process_root = os.path.realpath(process_project_root)
|
process_root = os.path.realpath(process_project_root)
|
||||||
declared = (configured_value or "").strip()
|
declared = (configured_value or "").strip()
|
||||||
@@ -168,12 +168,28 @@ def assess_canonical_repository_root(
|
|||||||
)
|
)
|
||||||
# Single-repo default: canonical root follows the install checkout.
|
# Single-repo default: canonical root follows the install checkout.
|
||||||
derived = resolve_repo_toplevel(process_root) or process_root
|
derived = resolve_repo_toplevel(process_root) or process_root
|
||||||
|
resolved_slug = repository_identity_slug(derived, remote=remote)
|
||||||
|
reasons: list[str] = []
|
||||||
|
if mode == "validation" and expected_slug:
|
||||||
|
expected = expected_slug.strip()
|
||||||
|
if resolved_slug and resolved_slug.lower() != expected.lower():
|
||||||
|
reasons.append(
|
||||||
|
f"canonical repository root identity mismatch: '{derived}' resolves "
|
||||||
|
f"to repository '{resolved_slug}' but expected repository identity "
|
||||||
|
f"is '{expected}' (forged or conflicting binding, fail closed)"
|
||||||
|
)
|
||||||
|
elif not resolved_slug and require_binding:
|
||||||
|
reasons.append(
|
||||||
|
f"canonical repository root '{derived}' has no resolvable git "
|
||||||
|
f"remote identity to confirm authorization for '{expected}' "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
return _assessment(
|
return _assessment(
|
||||||
proven=True,
|
proven=not reasons,
|
||||||
reasons=[],
|
reasons=reasons,
|
||||||
configured=False,
|
configured=False,
|
||||||
canonical_repo_root=derived,
|
canonical_repo_root=derived,
|
||||||
resolved_slug=None,
|
resolved_slug=resolved_slug,
|
||||||
source=None,
|
source=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -207,19 +223,33 @@ def assess_canonical_repository_root(
|
|||||||
|
|
||||||
resolved_slug = repository_identity_slug(toplevel, remote=remote)
|
resolved_slug = repository_identity_slug(toplevel, remote=remote)
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
expected = (expected_slug or "").strip() or None
|
|
||||||
if expected:
|
if mode == "derivation":
|
||||||
if resolved_slug and resolved_slug.lower() != expected.lower():
|
if not resolved_slug:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
f"canonical repository root identity mismatch: '{toplevel}' resolves "
|
f"configured canonical repository root '{toplevel}' has no resolvable "
|
||||||
f"to repository '{resolved_slug}' but the session is authorized for "
|
"git remote identity (fail closed)"
|
||||||
f"'{expected}' (forged or conflicting binding, fail closed)"
|
|
||||||
)
|
)
|
||||||
elif not resolved_slug and require_binding:
|
else:
|
||||||
|
expected = (expected_slug or "").strip() or None
|
||||||
|
if expected:
|
||||||
|
if resolved_slug and resolved_slug.lower() != expected.lower():
|
||||||
|
reasons.append(
|
||||||
|
f"canonical repository root identity mismatch: '{toplevel}' resolves "
|
||||||
|
f"to repository '{resolved_slug}' but the session is authorized for "
|
||||||
|
f"'{expected}' (forged or conflicting binding, fail closed)"
|
||||||
|
)
|
||||||
|
elif not resolved_slug and require_binding:
|
||||||
|
reasons.append(
|
||||||
|
f"canonical repository root '{toplevel}' has no resolvable git "
|
||||||
|
f"remote identity to confirm authorization for '{expected}' "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
elif require_binding:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
f"canonical repository root '{toplevel}' has no resolvable git "
|
f"canonical repository root '{toplevel}' has configured value "
|
||||||
f"remote identity to confirm authorization for '{expected}' "
|
f"'{configured_value}' but authoritative expected repository identity "
|
||||||
"(fail closed)"
|
"is unprovable or missing (fail closed)"
|
||||||
)
|
)
|
||||||
|
|
||||||
return _assessment(
|
return _assessment(
|
||||||
|
|||||||
@@ -1180,6 +1180,10 @@ RECOGNIZED_GITEA_ENV_KEYS = frozenset({
|
|||||||
"GITEA_SERVER_PROVENANCE",
|
"GITEA_SERVER_PROVENANCE",
|
||||||
"GITEA_AUTHOR_WORKTREE",
|
"GITEA_AUTHOR_WORKTREE",
|
||||||
"GITEA_ACTIVE_WORKTREE",
|
"GITEA_ACTIVE_WORKTREE",
|
||||||
|
"GITEA_REVIEWER_WORKTREE",
|
||||||
|
"GITEA_MERGER_WORKTREE",
|
||||||
|
"GITEA_CANONICAL_REPOSITORY_ROOT",
|
||||||
|
"GITEA_MCP_SESSION_STATE_TTL_HOURS",
|
||||||
"GITEA_DISABLE_KEYCHAIN",
|
"GITEA_DISABLE_KEYCHAIN",
|
||||||
"GITEA_CONTROL_PLANE_DB",
|
"GITEA_CONTROL_PLANE_DB",
|
||||||
"GITEA_DB_PATH",
|
"GITEA_DB_PATH",
|
||||||
|
|||||||
+81
-15
@@ -500,10 +500,66 @@ def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str:
|
|||||||
return workspace
|
return workspace
|
||||||
|
|
||||||
|
|
||||||
def _resolve_namespace_mutation_context(worktree_path: str | None = None) -> dict:
|
def _process_root_git_remote_url(remote_name: str) -> str | None:
|
||||||
|
"""Best-effort local ``git remote get-url`` strictly inside ``PROJECT_ROOT``.
|
||||||
|
|
||||||
|
#973 (B8): Must derive expected repository identity from an authority
|
||||||
|
independent of the candidate configured canonical root.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["git", "remote", "get-url", remote_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=PROJECT_ROOT,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return None
|
||||||
|
url = (proc.stdout or "").strip()
|
||||||
|
return url or None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_expected_repository_slug(
|
||||||
|
remote: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
repo: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Resolve expected repository slug from session context, parameters, or process root.
|
||||||
|
|
||||||
|
Must derive expected repository identity ONLY from trusted sources independent
|
||||||
|
of the candidate configured canonical root (#973 B8).
|
||||||
|
|
||||||
|
Bound session context takes precedence over request parameters so request-supplied
|
||||||
|
coordinates cannot replace a bound canonical root (#741 / #973 B9).
|
||||||
|
"""
|
||||||
|
bound = session_ctx.get_session_context() or {}
|
||||||
|
b_org = bound.get("org")
|
||||||
|
b_repo = bound.get("repository")
|
||||||
|
if b_org and b_repo:
|
||||||
|
return session_ctx.format_repository_slug(b_org, b_repo)
|
||||||
|
if org and repo:
|
||||||
|
return session_ctx.format_repository_slug(org, repo)
|
||||||
|
eff_remote = remote or bound.get("remote") or _effective_remote()
|
||||||
|
parsed = remote_repo_guard.parse_org_repo_from_remote_url(
|
||||||
|
_process_root_git_remote_url(eff_remote)
|
||||||
|
)
|
||||||
|
if parsed:
|
||||||
|
return session_ctx.format_repository_slug(parsed[0], parsed[1])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_namespace_mutation_context(
|
||||||
|
worktree_path: str | None = None,
|
||||||
|
remote: str | None = None,
|
||||||
|
) -> dict:
|
||||||
"""Canonical namespace workspace + repository root for guards (#460/#510/#706/#618)."""
|
"""Canonical namespace workspace + repository root for guards (#460/#510/#706/#618)."""
|
||||||
role = _effective_workspace_role()
|
role = _effective_workspace_role()
|
||||||
configured_root, _source = _configured_canonical_root()
|
configured_root, _source = _configured_canonical_root()
|
||||||
|
bound = session_ctx.get_session_context() or {}
|
||||||
|
eff_remote = remote or bound.get("remote") or _effective_remote()
|
||||||
|
expected_slug = _resolve_expected_repository_slug(eff_remote)
|
||||||
return nwb.resolve_namespace_mutation_context(
|
return nwb.resolve_namespace_mutation_context(
|
||||||
role_kind=role,
|
role_kind=role,
|
||||||
worktree_path=worktree_path,
|
worktree_path=worktree_path,
|
||||||
@@ -516,9 +572,12 @@ def _resolve_namespace_mutation_context(worktree_path: str | None = None) -> dic
|
|||||||
),
|
),
|
||||||
profile_name=get_profile().get("profile_name"),
|
profile_name=get_profile().get("profile_name"),
|
||||||
configured_canonical_root=configured_root,
|
configured_canonical_root=configured_root,
|
||||||
|
expected_slug=expected_slug,
|
||||||
|
remote=eff_remote,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_author_mutation_context(worktree_path: str | None = None) -> dict:
|
def _resolve_author_mutation_context(worktree_path: str | None = None) -> dict:
|
||||||
"""Backward-compatible alias for namespace workspace context."""
|
"""Backward-compatible alias for namespace workspace context."""
|
||||||
return _resolve_namespace_mutation_context(worktree_path)
|
return _resolve_namespace_mutation_context(worktree_path)
|
||||||
@@ -620,6 +679,9 @@ def _preflight_workspace_details(worktree_path: str | None, dirty_files: list[st
|
|||||||
inspected_root = _get_git_root(workspace)
|
inspected_root = _get_git_root(workspace)
|
||||||
process_root = ctx["process_project_root"]
|
process_root = ctx["process_project_root"]
|
||||||
canonical_root = ctx["canonical_repo_root"]
|
canonical_root = ctx["canonical_repo_root"]
|
||||||
|
crr_assessment = ctx.get("canonical_root_assessment") or {}
|
||||||
|
resolved_slug = crr_assessment.get("resolved_slug")
|
||||||
|
expected_slug = ctx.get("expected_slug")
|
||||||
active_root = os.path.realpath(inspected_root or workspace)
|
active_root = os.path.realpath(inspected_root or workspace)
|
||||||
if active_root == canonical_root:
|
if active_root == canonical_root:
|
||||||
dirty_scope = "control checkout"
|
dirty_scope = "control checkout"
|
||||||
@@ -649,6 +711,10 @@ def _preflight_workspace_details(worktree_path: str | None, dirty_files: list[st
|
|||||||
"workspace_healthy": not bool(
|
"workspace_healthy": not bool(
|
||||||
ctx.get("bound_worktree_missing") or ctx.get("author_worktree_block")
|
ctx.get("bound_worktree_missing") or ctx.get("author_worktree_block")
|
||||||
),
|
),
|
||||||
|
"canonical_root_assessment": crr_assessment,
|
||||||
|
"expected_repository_slug": expected_slug,
|
||||||
|
"observed_repository_identity": resolved_slug,
|
||||||
|
"worktree_registration_result": ctx.get("in_git_worktree_list"),
|
||||||
}
|
}
|
||||||
if not ctx["roots_aligned"]:
|
if not ctx["roots_aligned"]:
|
||||||
details["workspace_root_mismatch"] = (
|
details["workspace_root_mismatch"] = (
|
||||||
@@ -658,6 +724,7 @@ def _preflight_workspace_details(worktree_path: str | None, dirty_files: list[st
|
|||||||
return details
|
return details
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _format_preflight_workspace_details(details: dict) -> str:
|
def _format_preflight_workspace_details(details: dict) -> str:
|
||||||
parts = [
|
parts = [
|
||||||
f"MCP server process root: {details.get('mcp_server_process_root')}",
|
f"MCP server process root: {details.get('mcp_server_process_root')}",
|
||||||
@@ -930,10 +997,7 @@ def _enforce_canonical_repository_root(
|
|||||||
if not configured_value:
|
if not configured_value:
|
||||||
return
|
return
|
||||||
|
|
||||||
bound = session_ctx.get_session_context() or {}
|
expected_slug = _resolve_expected_repository_slug(remote)
|
||||||
expected_slug = session_ctx.format_repository_slug(
|
|
||||||
bound.get("org"), bound.get("repository")
|
|
||||||
)
|
|
||||||
assessment = crr.assess_canonical_repository_root(
|
assessment = crr.assess_canonical_repository_root(
|
||||||
configured_value=configured_value,
|
configured_value=configured_value,
|
||||||
source=source,
|
source=source,
|
||||||
@@ -1866,6 +1930,9 @@ def _verify_role_mutation_workspace(
|
|||||||
# back to the install checkout and validated Gitea-Tools/branches/ instead
|
# back to the install checkout and validated Gitea-Tools/branches/ instead
|
||||||
# of the target repository the namespace is actually bound to.
|
# of the target repository the namespace is actually bound to.
|
||||||
_configured_root, _configured_source = _configured_canonical_root()
|
_configured_root, _configured_source = _configured_canonical_root()
|
||||||
|
bound = session_ctx.get_session_context() or {}
|
||||||
|
eff_remote = remote or bound.get("remote") or _effective_remote()
|
||||||
|
expected_slug = _resolve_expected_repository_slug(eff_remote, org=org, repo=repo)
|
||||||
assessment = nwb.assess_namespace_mutation_workspace(
|
assessment = nwb.assess_namespace_mutation_workspace(
|
||||||
role_kind=role,
|
role_kind=role,
|
||||||
worktree_path=worktree_path,
|
worktree_path=worktree_path,
|
||||||
@@ -1880,6 +1947,8 @@ def _verify_role_mutation_workspace(
|
|||||||
profile_name=get_profile().get("profile_name"),
|
profile_name=get_profile().get("profile_name"),
|
||||||
current_branch=git_state.get("current_branch"),
|
current_branch=git_state.get("current_branch"),
|
||||||
configured_canonical_root=_configured_root,
|
configured_canonical_root=_configured_root,
|
||||||
|
expected_slug=expected_slug,
|
||||||
|
remote=eff_remote,
|
||||||
)
|
)
|
||||||
if assessment["block"]:
|
if assessment["block"]:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -2184,6 +2253,7 @@ def _canonical_repository_slug(
|
|||||||
process_project_root=PROJECT_ROOT,
|
process_project_root=PROJECT_ROOT,
|
||||||
remote=remote,
|
remote=remote,
|
||||||
require_binding=True,
|
require_binding=True,
|
||||||
|
mode="derivation",
|
||||||
)
|
)
|
||||||
slug = assessment.get("resolved_slug")
|
slug = assessment.get("resolved_slug")
|
||||||
if assessment.get("block") or not slug:
|
if assessment.get("block") or not slug:
|
||||||
@@ -3414,7 +3484,7 @@ def cleanup_in_progress_for_pr(
|
|||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _effective_remote(remote: str) -> str:
|
def _effective_remote(remote: str = "dadeschools") -> str:
|
||||||
"""If remote is the default ('dadeschools') but the active profile base_url maps to a known remote, use that remote instead."""
|
"""If remote is the default ('dadeschools') but the active profile base_url maps to a known remote, use that remote instead."""
|
||||||
try:
|
try:
|
||||||
profile = get_profile()
|
profile = get_profile()
|
||||||
@@ -15277,22 +15347,17 @@ def _current_runtime_mode_report(refresh: bool = False) -> dict:
|
|||||||
workspace_root = None
|
workspace_root = None
|
||||||
aligned = None
|
aligned = None
|
||||||
canonical_root = None
|
canonical_root = None
|
||||||
|
resolved_slug = None
|
||||||
try:
|
try:
|
||||||
ctx = _resolve_namespace_mutation_context(None)
|
ctx = _resolve_namespace_mutation_context(None)
|
||||||
workspace_root = ctx.get("workspace_path")
|
workspace_root = ctx.get("workspace_path")
|
||||||
canonical_root = ctx.get("canonical_repo_root")
|
canonical_root = ctx.get("canonical_repo_root")
|
||||||
# Alignment keeps its established repository-level meaning (#615 F1):
|
|
||||||
# does this namespace target the repository the process is installed in,
|
|
||||||
# i.e. canonical_repo_root == process_project_root. It is deliberately
|
|
||||||
# NOT path equality between the task workspace and the process root --
|
|
||||||
# the global worktree rule requires task work to live in a branches/
|
|
||||||
# worktree, so that comparison would classify every correctly bound
|
|
||||||
# author, reviewer, and merger session as unsafe.
|
|
||||||
aligned = ctx.get("roots_aligned")
|
aligned = ctx.get("roots_aligned")
|
||||||
|
crr_assessment = ctx.get("canonical_root_assessment") or {}
|
||||||
|
resolved_slug = crr_assessment.get("resolved_slug")
|
||||||
except Exception:
|
except Exception:
|
||||||
# An unresolvable binding is reported as unknown alignment, never as
|
|
||||||
# proof of alignment.
|
|
||||||
aligned = None
|
aligned = None
|
||||||
|
resolved_slug = None
|
||||||
try:
|
try:
|
||||||
profile_name = get_profile()["profile_name"]
|
profile_name = get_profile()["profile_name"]
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -15305,6 +15370,7 @@ def _current_runtime_mode_report(refresh: bool = False) -> dict:
|
|||||||
dirty_files=dirty_files,
|
dirty_files=dirty_files,
|
||||||
active_task_workspace=workspace_root,
|
active_task_workspace=workspace_root,
|
||||||
canonical_repository_root=canonical_root,
|
canonical_repository_root=canonical_root,
|
||||||
|
repository_slug=resolved_slug,
|
||||||
workspace_roots_aligned=aligned,
|
workspace_roots_aligned=aligned,
|
||||||
profile=profile_name,
|
profile=profile_name,
|
||||||
declared_mode=stable_control_runtime.declared_runtime_mode(),
|
declared_mode=stable_control_runtime.declared_runtime_mode(),
|
||||||
|
|||||||
+143
-27
@@ -8,8 +8,10 @@ poison workspace purity checks in another namespace.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
import author_mutation_worktree as amw
|
import author_mutation_worktree as amw
|
||||||
|
import canonical_repository_root as crr
|
||||||
|
|
||||||
ACTIVE_WORKTREE_ENV = amw.ACTIVE_WORKTREE_ENV
|
ACTIVE_WORKTREE_ENV = amw.ACTIVE_WORKTREE_ENV
|
||||||
AUTHOR_WORKTREE_ENV = amw.AUTHOR_WORKTREE_ENV
|
AUTHOR_WORKTREE_ENV = amw.AUTHOR_WORKTREE_ENV
|
||||||
@@ -152,6 +154,60 @@ def resolve_namespace_workspace(
|
|||||||
return os.path.realpath(process_project_root), "MCP server process root (default)"
|
return os.path.realpath(process_project_root), "MCP server process root (default)"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_git_common_directory_membership(
|
||||||
|
workspace_path: str,
|
||||||
|
canonical_repo_root: str,
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
|
"""Verify that workspace_path belongs to canonical_repo_root via git common-dir or branches containment."""
|
||||||
|
ws = (workspace_path or "").strip()
|
||||||
|
root = (canonical_repo_root or "").strip()
|
||||||
|
if not ws or not root:
|
||||||
|
return False, "empty workspace or canonical root path"
|
||||||
|
|
||||||
|
try:
|
||||||
|
real_ws = os.path.realpath(os.path.abspath(ws))
|
||||||
|
real_root = os.path.realpath(os.path.abspath(root))
|
||||||
|
except Exception as exc:
|
||||||
|
return False, f"invalid workspace or root path: {exc}"
|
||||||
|
|
||||||
|
if real_ws == real_root:
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
if not os.path.isdir(real_ws):
|
||||||
|
return False, f"workspace directory '{real_ws}' does not exist"
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", real_ws, "rev-parse", "--git-common-dir"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if res.returncode == 0:
|
||||||
|
common_raw = (res.stdout or "").strip()
|
||||||
|
common_dir = amw._realpath_git_common_dir(real_ws, common_raw)
|
||||||
|
real_common = os.path.realpath(common_dir)
|
||||||
|
canonical_git = os.path.realpath(os.path.join(real_root, ".git"))
|
||||||
|
|
||||||
|
if real_common in (canonical_git, real_root):
|
||||||
|
return True, None
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"workspace '{real_ws}' git common directory '{real_common}' does not match "
|
||||||
|
f"canonical repository root '{real_root}' (.git at '{canonical_git}')"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"workspace '{real_ws}' is not a valid git repository or git rev-parse failed"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"failed to inspect git common directory for workspace '{real_ws}': {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def resolve_namespace_mutation_context(
|
def resolve_namespace_mutation_context(
|
||||||
*,
|
*,
|
||||||
role_kind: str,
|
role_kind: str,
|
||||||
@@ -163,6 +219,8 @@ def resolve_namespace_mutation_context(
|
|||||||
worktree: str | None = None,
|
worktree: str | None = None,
|
||||||
profile_name: str | None = None,
|
profile_name: str | None = None,
|
||||||
configured_canonical_root: str | None = None,
|
configured_canonical_root: str | None = None,
|
||||||
|
expected_slug: str | None = None,
|
||||||
|
remote: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Shared workspace resolution for runtime_context and mutation guards.
|
"""Shared workspace resolution for runtime_context and mutation guards.
|
||||||
|
|
||||||
@@ -180,11 +238,31 @@ def resolve_namespace_mutation_context(
|
|||||||
env_map = env if env is not None else os.environ
|
env_map = env if env is not None else os.environ
|
||||||
process_root = os.path.realpath(process_project_root)
|
process_root = os.path.realpath(process_project_root)
|
||||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||||
configured = (configured_canonical_root or "").strip()
|
|
||||||
if configured:
|
configured_val = (configured_canonical_root or "").strip()
|
||||||
canonical_root = os.path.realpath(configured)
|
if configured_val:
|
||||||
|
crr_assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=configured_val,
|
||||||
|
source="configured_canonical_root",
|
||||||
|
expected_slug=expected_slug,
|
||||||
|
process_project_root=process_root,
|
||||||
|
remote=remote,
|
||||||
|
require_binding=True,
|
||||||
|
)
|
||||||
|
canonical_root = crr_assessment["canonical_repo_root"]
|
||||||
|
roots_aligned = crr_assessment["proven"]
|
||||||
else:
|
else:
|
||||||
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
crr_assessment = {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"reasons": [],
|
||||||
|
"configured": False,
|
||||||
|
"canonical_repo_root": amw.resolve_canonical_repo_root(process_root, process_root),
|
||||||
|
"resolved_slug": None,
|
||||||
|
"source": None,
|
||||||
|
}
|
||||||
|
canonical_root = crr_assessment["canonical_repo_root"]
|
||||||
|
roots_aligned = (canonical_root == process_root)
|
||||||
|
|
||||||
durable: dict | None = None
|
durable: dict | None = None
|
||||||
if role == "author":
|
if role == "author":
|
||||||
@@ -234,7 +312,10 @@ def resolve_namespace_mutation_context(
|
|||||||
"ignored_bindings": demotions + (pollution.get("ignored_bindings") or []),
|
"ignored_bindings": demotions + (pollution.get("ignored_bindings") or []),
|
||||||
"process_project_root": process_root,
|
"process_project_root": process_root,
|
||||||
"canonical_repo_root": canonical_root,
|
"canonical_repo_root": canonical_root,
|
||||||
"roots_aligned": canonical_root == process_root,
|
"roots_aligned": roots_aligned,
|
||||||
|
"canonical_root_assessment": crr_assessment,
|
||||||
|
"expected_slug": expected_slug,
|
||||||
|
"remote": remote,
|
||||||
}
|
}
|
||||||
if durable is not None:
|
if durable is not None:
|
||||||
result["author_worktree_resolution"] = durable
|
result["author_worktree_resolution"] = durable
|
||||||
@@ -246,6 +327,15 @@ def resolve_namespace_mutation_context(
|
|||||||
result["author_worktree_reasons"] = list(durable.get("reasons") or [])
|
result["author_worktree_reasons"] = list(durable.get("reasons") or [])
|
||||||
result["author_worktree_blocker_kind"] = durable.get("blocker_kind")
|
result["author_worktree_blocker_kind"] = durable.get("blocker_kind")
|
||||||
result["operator_recovery"] = durable.get("operator_recovery")
|
result["operator_recovery"] = durable.get("operator_recovery")
|
||||||
|
else:
|
||||||
|
path_exists = os.path.exists(workspace)
|
||||||
|
result["path_exists"] = path_exists
|
||||||
|
result["in_git_worktree_list"] = (
|
||||||
|
amw.path_in_git_worktree_list(workspace, canonical_root)
|
||||||
|
if path_exists
|
||||||
|
else False
|
||||||
|
)
|
||||||
|
result["bound_worktree_missing"] = not path_exists
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -378,8 +468,8 @@ def format_namespace_workspace_binding_error(
|
|||||||
def assess_namespace_mutation_workspace(
|
def assess_namespace_mutation_workspace(
|
||||||
*,
|
*,
|
||||||
role_kind: str,
|
role_kind: str,
|
||||||
worktree_path: str | None,
|
worktree_path: str | None = None,
|
||||||
worktree: str | None,
|
worktree: str | None = None,
|
||||||
process_project_root: str,
|
process_project_root: str,
|
||||||
env: dict[str, str] | os._Environ | None = None,
|
env: dict[str, str] | os._Environ | None = None,
|
||||||
session_lease_worktree: str | None = None,
|
session_lease_worktree: str | None = None,
|
||||||
@@ -387,6 +477,8 @@ def assess_namespace_mutation_workspace(
|
|||||||
profile_name: str | None = None,
|
profile_name: str | None = None,
|
||||||
current_branch: str | None = None,
|
current_branch: str | None = None,
|
||||||
configured_canonical_root: str | None = None,
|
configured_canonical_root: str | None = None,
|
||||||
|
expected_slug: str | None = None,
|
||||||
|
remote: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Evaluate namespace workspace binding before preflight/mutation."""
|
"""Evaluate namespace workspace binding before preflight/mutation."""
|
||||||
ctx = resolve_namespace_mutation_context(
|
ctx = resolve_namespace_mutation_context(
|
||||||
@@ -399,6 +491,8 @@ def assess_namespace_mutation_workspace(
|
|||||||
session_lock_worktree=session_lock_worktree,
|
session_lock_worktree=session_lock_worktree,
|
||||||
profile_name=profile_name,
|
profile_name=profile_name,
|
||||||
configured_canonical_root=configured_canonical_root,
|
configured_canonical_root=configured_canonical_root,
|
||||||
|
expected_slug=expected_slug,
|
||||||
|
remote=remote,
|
||||||
)
|
)
|
||||||
mutation_workspace = ctx["workspace_path"]
|
mutation_workspace = ctx["workspace_path"]
|
||||||
binding_source = ctx["workspace_binding_source"]
|
binding_source = ctx["workspace_binding_source"]
|
||||||
@@ -422,6 +516,27 @@ def assess_namespace_mutation_workspace(
|
|||||||
|
|
||||||
reasons = list(metadata.get("reasons") or [])
|
reasons = list(metadata.get("reasons") or [])
|
||||||
operator_recovery = ctx.get("operator_recovery")
|
operator_recovery = ctx.get("operator_recovery")
|
||||||
|
|
||||||
|
crr_reasons = list(ctx.get("canonical_root_assessment", {}).get("reasons") or [])
|
||||||
|
if crr_reasons:
|
||||||
|
reasons.extend(crr_reasons)
|
||||||
|
|
||||||
|
path_exists = ctx.get("path_exists")
|
||||||
|
if path_exists is None:
|
||||||
|
path_exists = os.path.exists(mutation_workspace)
|
||||||
|
|
||||||
|
if not path_exists:
|
||||||
|
if role != "author":
|
||||||
|
reasons.append(
|
||||||
|
f"{role} mutation blocked: configured workspace directory '{mutation_workspace}' does not exist (nonexistent worktree)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
valid_common, common_err = verify_git_common_directory_membership(
|
||||||
|
mutation_workspace, ctx["canonical_repo_root"]
|
||||||
|
)
|
||||||
|
if not valid_common and common_err:
|
||||||
|
reasons.append(common_err)
|
||||||
|
|
||||||
if role == "author":
|
if role == "author":
|
||||||
# #618 durable resolution already validated existence, membership,
|
# #618 durable resolution already validated existence, membership,
|
||||||
# branches/, lock ownership, and traversal safety when present.
|
# branches/, lock ownership, and traversal safety when present.
|
||||||
@@ -438,26 +553,27 @@ def assess_namespace_mutation_workspace(
|
|||||||
)
|
)
|
||||||
if branches["block"]:
|
if branches["block"]:
|
||||||
reasons.extend(branches["reasons"])
|
reasons.extend(branches["reasons"])
|
||||||
elif (
|
elif role in {"reviewer", "merger"}:
|
||||||
role == "reviewer"
|
if mutation_workspace == process_root and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"]):
|
||||||
and mutation_workspace == process_root
|
reasons.append(
|
||||||
and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"])
|
f"{role} mutation blocked: workspace is the stable control checkout; "
|
||||||
):
|
f"create or reconnect to a session-owned worktree under branches/ "
|
||||||
reasons.append(
|
f"or set {ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} / "
|
||||||
f"{role} mutation blocked: workspace is the stable control checkout; "
|
f"{ACTIVE_WORKTREE_ENV}"
|
||||||
f"create or reconnect to a session-owned worktree under branches/ "
|
)
|
||||||
f"or set {ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} / "
|
elif mutation_workspace != process_root and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"]):
|
||||||
f"{ACTIVE_WORKTREE_ENV}"
|
reasons.append(
|
||||||
)
|
f"{role} mutation blocked: workspace '{mutation_workspace}' is not under "
|
||||||
elif (
|
f"'{ctx['canonical_repo_root']}/branches/'"
|
||||||
role in {"reviewer", "merger"}
|
)
|
||||||
and mutation_workspace != process_root
|
|
||||||
and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"])
|
if path_exists and amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"]):
|
||||||
):
|
in_list = ctx.get("in_git_worktree_list")
|
||||||
reasons.append(
|
if in_list is False:
|
||||||
f"{role} mutation blocked: workspace '{mutation_workspace}' is not under "
|
reasons.append(
|
||||||
f"'{ctx['canonical_repo_root']}/branches/'"
|
f"{role} mutation blocked: workspace '{mutation_workspace}' is under branches/ "
|
||||||
)
|
f"but is not registered in git worktree list for '{ctx['canonical_repo_root']}'"
|
||||||
|
)
|
||||||
|
|
||||||
block = bool(reasons)
|
block = bool(reasons)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ class TestCanonicalRootGuardBinding(_ServerHarness):
|
|||||||
process_project_root=self.install_root,
|
process_project_root=self.install_root,
|
||||||
remote="prgs",
|
remote="prgs",
|
||||||
require_binding=True,
|
require_binding=True,
|
||||||
|
mode="derivation",
|
||||||
)
|
)
|
||||||
self.assertFalse(got.get("block"), got.get("reasons"))
|
self.assertFalse(got.get("block"), got.get("reasons"))
|
||||||
self.assertEqual(got["resolved_slug"], TARGET_SLUG)
|
self.assertEqual(got["resolved_slug"], TARGET_SLUG)
|
||||||
|
|||||||
@@ -241,9 +241,10 @@ class TestNamespaceContextUsesConfiguredRoot(unittest.TestCase):
|
|||||||
process_project_root=self.install,
|
process_project_root=self.install,
|
||||||
env={},
|
env={},
|
||||||
configured_canonical_root=self.target,
|
configured_canonical_root=self.target,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
)
|
)
|
||||||
self.assertEqual(ctx["canonical_repo_root"], self.target)
|
self.assertEqual(ctx["canonical_repo_root"], self.target)
|
||||||
self.assertFalse(ctx["roots_aligned"])
|
self.assertTrue(ctx["roots_aligned"])
|
||||||
|
|
||||||
def test_target_worktree_is_member_of_target_root(self):
|
def test_target_worktree_is_member_of_target_root(self):
|
||||||
got = nwb.amw.assess_workspace_repo_membership(
|
got = nwb.amw.assess_workspace_repo_membership(
|
||||||
@@ -268,6 +269,7 @@ class TestNamespaceContextUsesConfiguredRoot(unittest.TestCase):
|
|||||||
env={},
|
env={},
|
||||||
current_branch="feat/issue-1",
|
current_branch="feat/issue-1",
|
||||||
configured_canonical_root=self.target,
|
configured_canonical_root=self.target,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
)
|
)
|
||||||
self.assertFalse(assessment["block"], assessment.get("reasons"))
|
self.assertFalse(assessment["block"], assessment.get("reasons"))
|
||||||
self.assertEqual(assessment["canonical_repo_root"], self.target)
|
self.assertEqual(assessment["canonical_repo_root"], self.target)
|
||||||
|
|||||||
@@ -0,0 +1,546 @@
|
|||||||
|
"""Regression tests for Issue #973: validated cross-repository canonical roots."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import gitea_config
|
||||||
|
import namespace_workspace_binding as nwb
|
||||||
|
import canonical_repository_root as crr
|
||||||
|
import stable_control_runtime
|
||||||
|
import gitea_mcp_server as mcp_server
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue973RecognizedEnvKeys(unittest.TestCase):
|
||||||
|
"""Test recognized environment variable keys under #973."""
|
||||||
|
|
||||||
|
def test_recognized_gitea_env_keys(self):
|
||||||
|
for key in (
|
||||||
|
"GITEA_CANONICAL_REPOSITORY_ROOT",
|
||||||
|
"GITEA_REVIEWER_WORKTREE",
|
||||||
|
"GITEA_MERGER_WORKTREE",
|
||||||
|
"GITEA_MCP_SESSION_STATE_TTL_HOURS",
|
||||||
|
):
|
||||||
|
self.assertIn(key, gitea_config.RECOGNIZED_GITEA_ENV_KEYS)
|
||||||
|
|
||||||
|
def test_get_unconsumed_gitea_env_overrides_ignores_recognized(self):
|
||||||
|
env = {
|
||||||
|
"GITEA_CANONICAL_REPOSITORY_ROOT": "/some/path",
|
||||||
|
"GITEA_REVIEWER_WORKTREE": "/some/reviewer/path",
|
||||||
|
"GITEA_MERGER_WORKTREE": "/some/merger/path",
|
||||||
|
"GITEA_MCP_SESSION_STATE_TTL_HOURS": "24",
|
||||||
|
"GITEA_UNRECOGNIZED_FOO_VAR": "bar",
|
||||||
|
}
|
||||||
|
unconsumed = gitea_config.get_unconsumed_gitea_env_overrides(env)
|
||||||
|
self.assertNotIn("GITEA_CANONICAL_REPOSITORY_ROOT", unconsumed)
|
||||||
|
self.assertNotIn("GITEA_REVIEWER_WORKTREE", unconsumed)
|
||||||
|
self.assertNotIn("GITEA_MERGER_WORKTREE", unconsumed)
|
||||||
|
self.assertNotIn("GITEA_MCP_SESSION_STATE_TTL_HOURS", unconsumed)
|
||||||
|
self.assertIn("GITEA_UNRECOGNIZED_FOO_VAR", unconsumed)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue973CrossRepoCanonicalRoots(unittest.TestCase):
|
||||||
|
"""Test workspace binding and canonical root validation for cross-repo namespaces."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.tmp_dir = os.path.realpath(self._tmp.name)
|
||||||
|
|
||||||
|
# Create simulated installation root
|
||||||
|
self.install_root = os.path.join(self.tmp_dir, "Gitea-Tools")
|
||||||
|
os.makedirs(self.install_root)
|
||||||
|
subprocess.run(["git", "init", "-b", "master"], cwd=self.install_root, check=True)
|
||||||
|
subprocess.run(["git", "config", "user.email", "[email protected]"], cwd=self.install_root, check=True)
|
||||||
|
subprocess.run(["git", "config", "user.name", "Test User"], cwd=self.install_root, check=True)
|
||||||
|
with open(os.path.join(self.install_root, "README.md"), "w") as f:
|
||||||
|
f.write("install\n")
|
||||||
|
subprocess.run(["git", "add", "README.md"], cwd=self.install_root, check=True)
|
||||||
|
subprocess.run(["git", "commit", "-m", "initial"], cwd=self.install_root, check=True)
|
||||||
|
|
||||||
|
# Create simulated target repository root
|
||||||
|
self.target_root = os.path.join(self.tmp_dir, "mcp-control-plane")
|
||||||
|
os.makedirs(self.target_root)
|
||||||
|
subprocess.run(["git", "init", "-b", "master"], cwd=self.target_root, check=True)
|
||||||
|
subprocess.run(["git", "config", "user.email", "[email protected]"], cwd=self.target_root, check=True)
|
||||||
|
subprocess.run(["git", "config", "user.name", "Test User"], cwd=self.target_root, check=True)
|
||||||
|
with open(os.path.join(self.target_root, "README.md"), "w") as f:
|
||||||
|
f.write("target\n")
|
||||||
|
subprocess.run(["git", "add", "README.md"], cwd=self.target_root, check=True)
|
||||||
|
subprocess.run(["git", "commit", "-m", "initial"], cwd=self.target_root, check=True)
|
||||||
|
|
||||||
|
# Add remotes to simulate real git repositories with identities
|
||||||
|
subprocess.run(["git", "remote", "add", "prgs", "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"], cwd=self.install_root, check=True)
|
||||||
|
subprocess.run(["git", "remote", "add", "prgs", "https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane.git"], cwd=self.target_root, check=True)
|
||||||
|
|
||||||
|
# Create simulated foreign repository root
|
||||||
|
self.evil_root = os.path.join(self.tmp_dir, "Evil-Repo")
|
||||||
|
os.makedirs(self.evil_root)
|
||||||
|
subprocess.run(["git", "init", "-b", "master"], cwd=self.evil_root, check=True)
|
||||||
|
subprocess.run(["git", "config", "user.email", "[email protected]"], cwd=self.evil_root, check=True)
|
||||||
|
subprocess.run(["git", "config", "user.name", "Evil User"], cwd=self.evil_root, check=True)
|
||||||
|
with open(os.path.join(self.evil_root, "README.md"), "w") as f:
|
||||||
|
f.write("evil\n")
|
||||||
|
subprocess.run(["git", "add", "README.md"], cwd=self.evil_root, check=True)
|
||||||
|
subprocess.run(["git", "commit", "-m", "initial"], cwd=self.evil_root, check=True)
|
||||||
|
subprocess.run(["git", "remote", "add", "prgs", "https://gitea.prgs.cc/Someone-Else/Evil-Repo.git"], cwd=self.evil_root, check=True)
|
||||||
|
|
||||||
|
# Create branches/ directory and a valid registered worktree in target repository
|
||||||
|
self.target_branches = os.path.join(self.target_root, "branches")
|
||||||
|
self.target_worktree = os.path.join(self.target_branches, "rev-pr-99")
|
||||||
|
subprocess.run(["git", "worktree", "add", "-b", "rev-pr-99", self.target_worktree], cwd=self.target_root, check=True)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def test_valid_same_repository_configuration(self):
|
||||||
|
ctx = nwb.resolve_namespace_mutation_context(
|
||||||
|
role_kind="reviewer",
|
||||||
|
worktree_path=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=None,
|
||||||
|
)
|
||||||
|
self.assertEqual(ctx["canonical_repo_root"], self.install_root)
|
||||||
|
self.assertTrue(ctx["roots_aligned"])
|
||||||
|
self.assertTrue(ctx["canonical_root_assessment"]["proven"])
|
||||||
|
|
||||||
|
def test_valid_cross_repo_canonical_root(self):
|
||||||
|
ctx = nwb.resolve_namespace_mutation_context(
|
||||||
|
role_kind="reviewer",
|
||||||
|
worktree_path=self.target_worktree,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertEqual(ctx["canonical_repo_root"], self.target_root)
|
||||||
|
self.assertTrue(ctx["roots_aligned"])
|
||||||
|
self.assertTrue(ctx["canonical_root_assessment"]["proven"])
|
||||||
|
|
||||||
|
def test_expected_repository_identity_match(self):
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root,
|
||||||
|
source="test",
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["proven"])
|
||||||
|
self.assertFalse(assessment["block"])
|
||||||
|
|
||||||
|
def test_foreign_repository_identity_mismatch(self):
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.evil_root,
|
||||||
|
source="test",
|
||||||
|
expected_slug="Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["proven"])
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertTrue(any("identity mismatch" in r for r in assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_native_repository_binding_mismatch(self):
|
||||||
|
ctx = nwb.resolve_namespace_mutation_context(
|
||||||
|
role_kind="reviewer",
|
||||||
|
worktree_path=self.target_worktree,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.evil_root,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
self.assertFalse(ctx["roots_aligned"])
|
||||||
|
self.assertFalse(ctx["canonical_root_assessment"]["proven"])
|
||||||
|
self.assertTrue(any("identity mismatch" in r for r in ctx["canonical_root_assessment"]["reasons"]))
|
||||||
|
|
||||||
|
def test_unpatched_foreign_configured_root_derives_expected_from_process_root_and_blocks(self):
|
||||||
|
"""B8: Production path test where foreign configured root cannot self-authorize."""
|
||||||
|
with patch.object(mcp_server, "PROJECT_ROOT", self.install_root), \
|
||||||
|
patch.object(mcp_server, "_configured_canonical_root", return_value=(self.evil_root, "env")):
|
||||||
|
expected_slug = mcp_server._resolve_expected_repository_slug("prgs")
|
||||||
|
self.assertEqual(expected_slug, "Scaled-Tech-Consulting/Gitea-Tools")
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.evil_root,
|
||||||
|
source="env",
|
||||||
|
expected_slug=expected_slug,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["proven"])
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertEqual(assessment["resolved_slug"], "Someone-Else/Evil-Repo")
|
||||||
|
self.assertTrue(any("identity mismatch" in r for r in assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_unpatched_valid_cross_repo_matching_session_context(self):
|
||||||
|
"""B8: Valid cross-repo namespace matches when session context is bound to target repo."""
|
||||||
|
bound_ctx = {"org": "Scaled-Tech-Consulting", "repository": "mcp-control-plane", "remote": "prgs"}
|
||||||
|
with patch.object(mcp_server, "PROJECT_ROOT", self.install_root), \
|
||||||
|
patch.object(mcp_server.session_ctx, "get_session_context", return_value=bound_ctx):
|
||||||
|
expected_slug = mcp_server._resolve_expected_repository_slug("prgs")
|
||||||
|
self.assertEqual(expected_slug, "Scaled-Tech-Consulting/mcp-control-plane")
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root,
|
||||||
|
source="env",
|
||||||
|
expected_slug=expected_slug,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["proven"])
|
||||||
|
self.assertFalse(assessment["block"])
|
||||||
|
self.assertEqual(assessment["resolved_slug"], "Scaled-Tech-Consulting/mcp-control-plane")
|
||||||
|
|
||||||
|
def test_unprovable_expected_identity_fails_closed(self):
|
||||||
|
"""B8: If expected repository identity is unprovable for a configured root, fail closed."""
|
||||||
|
no_remote_root = os.path.join(self.tmp_dir, "no-remote-process-root")
|
||||||
|
os.makedirs(no_remote_root)
|
||||||
|
subprocess.run(["git", "init", "-b", "master"], cwd=no_remote_root, check=True)
|
||||||
|
with patch.object(mcp_server, "PROJECT_ROOT", no_remote_root), \
|
||||||
|
patch.object(mcp_server.session_ctx, "get_session_context", return_value=None):
|
||||||
|
expected_slug = mcp_server._resolve_expected_repository_slug("prgs")
|
||||||
|
self.assertIsNone(expected_slug)
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root,
|
||||||
|
source="env",
|
||||||
|
expected_slug=expected_slug,
|
||||||
|
process_project_root=no_remote_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["proven"])
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertTrue(any("unprovable or missing" in r for r in assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_missing_canonical_root(self):
|
||||||
|
ctx = nwb.resolve_namespace_mutation_context(
|
||||||
|
role_kind="author",
|
||||||
|
worktree_path=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root="",
|
||||||
|
)
|
||||||
|
self.assertEqual(ctx["canonical_repo_root"], self.install_root)
|
||||||
|
self.assertTrue(ctx["roots_aligned"])
|
||||||
|
|
||||||
|
def test_nonexistent_configured_canonical_root(self):
|
||||||
|
nonexistent = os.path.join(self.tmp_dir, "nonexistent-repo")
|
||||||
|
ctx = nwb.resolve_namespace_mutation_context(
|
||||||
|
role_kind="reviewer",
|
||||||
|
worktree_path=self.target_worktree,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=nonexistent,
|
||||||
|
)
|
||||||
|
self.assertFalse(ctx["roots_aligned"])
|
||||||
|
self.assertFalse(ctx["canonical_root_assessment"]["proven"])
|
||||||
|
self.assertTrue(any("does not exist" in r for r in ctx["canonical_root_assessment"]["reasons"]))
|
||||||
|
|
||||||
|
def test_non_git_configured_canonical_root(self):
|
||||||
|
non_git = os.path.join(self.tmp_dir, "non-git-dir")
|
||||||
|
os.makedirs(non_git)
|
||||||
|
ctx = nwb.resolve_namespace_mutation_context(
|
||||||
|
role_kind="reviewer",
|
||||||
|
worktree_path=self.target_worktree,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=non_git,
|
||||||
|
)
|
||||||
|
self.assertFalse(ctx["roots_aligned"])
|
||||||
|
self.assertFalse(ctx["canonical_root_assessment"]["proven"])
|
||||||
|
self.assertTrue(any("not a git repository" in r for r in ctx["canonical_root_assessment"]["reasons"]))
|
||||||
|
|
||||||
|
def test_git_common_directory_membership_matching(self):
|
||||||
|
valid, err = nwb.verify_git_common_directory_membership(
|
||||||
|
self.target_worktree, self.target_root
|
||||||
|
)
|
||||||
|
self.assertTrue(valid, err)
|
||||||
|
self.assertIsNone(err)
|
||||||
|
|
||||||
|
def test_foreign_git_common_directory(self):
|
||||||
|
# Foreign worktree created under install_root
|
||||||
|
install_branches = os.path.join(self.install_root, "branches")
|
||||||
|
foreign_wt = os.path.join(install_branches, "foreign-wt")
|
||||||
|
subprocess.run(["git", "worktree", "add", "-b", "foreign-wt", foreign_wt], cwd=self.install_root, check=True)
|
||||||
|
|
||||||
|
valid, err = nwb.verify_git_common_directory_membership(
|
||||||
|
foreign_wt, self.target_root
|
||||||
|
)
|
||||||
|
self.assertFalse(valid)
|
||||||
|
self.assertIn("does not match", err)
|
||||||
|
|
||||||
|
def test_normalized_path_aliases(self):
|
||||||
|
alias_path = self.target_worktree + "/../rev-pr-99/./"
|
||||||
|
valid, err = nwb.verify_git_common_directory_membership(
|
||||||
|
alias_path, self.target_root
|
||||||
|
)
|
||||||
|
self.assertTrue(valid, err)
|
||||||
|
|
||||||
|
def test_safe_symlink_identity(self):
|
||||||
|
link_path = os.path.join(self.target_branches, "symlink-rev-99")
|
||||||
|
try:
|
||||||
|
os.symlink(self.target_worktree, link_path)
|
||||||
|
valid, err = nwb.verify_git_common_directory_membership(
|
||||||
|
link_path, self.target_root
|
||||||
|
)
|
||||||
|
self.assertTrue(valid, err)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(link_path):
|
||||||
|
os.unlink(link_path)
|
||||||
|
|
||||||
|
def test_symlink_escape_or_foreign_alias(self):
|
||||||
|
outside_dir = os.path.join(self.tmp_dir, "outside-target")
|
||||||
|
os.makedirs(outside_dir)
|
||||||
|
link_escape = os.path.join(self.target_branches, "escape-link")
|
||||||
|
try:
|
||||||
|
os.symlink(outside_dir, link_escape)
|
||||||
|
assessment = nwb.assess_namespace_mutation_workspace(
|
||||||
|
role_kind="reviewer",
|
||||||
|
worktree_path=link_escape,
|
||||||
|
worktree=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
finally:
|
||||||
|
if os.path.exists(link_escape):
|
||||||
|
os.unlink(link_escape)
|
||||||
|
|
||||||
|
def test_reviewer_worktree_registered_and_valid(self):
|
||||||
|
assessment = nwb.assess_namespace_mutation_workspace(
|
||||||
|
role_kind="reviewer",
|
||||||
|
worktree_path=self.target_worktree,
|
||||||
|
worktree=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["block"])
|
||||||
|
|
||||||
|
def test_reviewer_worktree_unregistered_blocks(self):
|
||||||
|
unreg_wt = os.path.join(self.target_branches, "unregistered-reviewer")
|
||||||
|
os.makedirs(unreg_wt)
|
||||||
|
assessment = nwb.assess_namespace_mutation_workspace(
|
||||||
|
role_kind="reviewer",
|
||||||
|
worktree_path=unreg_wt,
|
||||||
|
worktree=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertTrue(any("is not registered in git worktree list" in r for r in assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_merger_worktree_registered_and_valid(self):
|
||||||
|
assessment = nwb.assess_namespace_mutation_workspace(
|
||||||
|
role_kind="merger",
|
||||||
|
worktree_path=self.target_worktree,
|
||||||
|
worktree=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["block"])
|
||||||
|
|
||||||
|
def test_merger_worktree_unregistered_blocks(self):
|
||||||
|
unreg_wt = os.path.join(self.target_branches, "unregistered-merger")
|
||||||
|
os.makedirs(unreg_wt)
|
||||||
|
assessment = nwb.assess_namespace_mutation_workspace(
|
||||||
|
role_kind="merger",
|
||||||
|
worktree_path=unreg_wt,
|
||||||
|
worktree=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertTrue(any("is not registered in git worktree list" in r for r in assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_reviewer_or_merger_worktree_outside_branches_blocks(self):
|
||||||
|
outside_wt = os.path.join(self.target_root, "outside_branches_wt")
|
||||||
|
os.makedirs(outside_wt)
|
||||||
|
for r_kind in ("reviewer", "merger"):
|
||||||
|
assessment = nwb.assess_namespace_mutation_workspace(
|
||||||
|
role_kind=r_kind,
|
||||||
|
worktree_path=outside_wt,
|
||||||
|
worktree=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertTrue(any("is not under" in r for r in assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_nonexistent_reviewer_or_merger_worktree_blocks(self):
|
||||||
|
nonexistent_wt = os.path.join(self.target_branches, "nonexistent-wt")
|
||||||
|
for r_kind in ("reviewer", "merger"):
|
||||||
|
assessment = nwb.assess_namespace_mutation_workspace(
|
||||||
|
role_kind=r_kind,
|
||||||
|
worktree_path=nonexistent_wt,
|
||||||
|
worktree=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target_root,
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertTrue(any("does not exist" in r for r in assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_safe_and_unsafe_mutation_alignment_outcomes(self):
|
||||||
|
# Safe alignment (same repo)
|
||||||
|
report_safe = stable_control_runtime.build_runtime_report(
|
||||||
|
process_root=self.install_root,
|
||||||
|
checkout_branch="master",
|
||||||
|
runtime_head="abcdef123456",
|
||||||
|
active_task_workspace=self.install_root,
|
||||||
|
canonical_repository_root=self.install_root,
|
||||||
|
workspace_roots_aligned=True,
|
||||||
|
)
|
||||||
|
gate_safe = stable_control_runtime.assess_runtime_mutation_gate(report_safe)
|
||||||
|
self.assertFalse(gate_safe["block"])
|
||||||
|
|
||||||
|
# Unsafe alignment
|
||||||
|
report_unsafe = stable_control_runtime.build_runtime_report(
|
||||||
|
process_root=self.install_root,
|
||||||
|
checkout_branch="master",
|
||||||
|
runtime_head="abcdef123456",
|
||||||
|
active_task_workspace=self.target_worktree,
|
||||||
|
canonical_repository_root=self.target_root,
|
||||||
|
workspace_roots_aligned=False,
|
||||||
|
)
|
||||||
|
gate_unsafe = stable_control_runtime.assess_runtime_mutation_gate(report_unsafe)
|
||||||
|
self.assertTrue(gate_unsafe["block"])
|
||||||
|
|
||||||
|
def test_reviewer_lease_lifecycle_production_path(self):
|
||||||
|
"""B5: Automated regression for reviewer lease acquire and release through production path."""
|
||||||
|
mock_whoami = {
|
||||||
|
"authenticated": True,
|
||||||
|
"username": "sysadmin",
|
||||||
|
"remote": "prgs",
|
||||||
|
"profile": {
|
||||||
|
"profile_name": "prgs-reviewer",
|
||||||
|
"role": "reviewer",
|
||||||
|
"role_kind": "reviewer",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.pr.comment", "gitea.pr.approve", "gitea.pr.request_changes"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def mock_api_request(method, url, auth=None, json_data=None):
|
||||||
|
if method == "GET":
|
||||||
|
return {"number": 99, "head": {"sha": "abc1234"}, "state": "open", "merged": False, "merged_at": None}
|
||||||
|
elif method == "POST":
|
||||||
|
return {"id": 9999, "body": (json_data or {}).get("body", "")}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with patch.object(mcp_server, "gitea_whoami", return_value=mock_whoami), \
|
||||||
|
patch.object(mcp_server, "get_profile", return_value=mock_whoami["profile"]), \
|
||||||
|
patch.object(mcp_server, "_effective_workspace_role", return_value="reviewer"), \
|
||||||
|
patch.object(mcp_server, "_configured_canonical_root", return_value=(self.target_root, "env")), \
|
||||||
|
patch.object(mcp_server, "_reviewer_session_worktree", return_value=self.target_worktree), \
|
||||||
|
patch.object(mcp_server, "_auth", return_value="token mock-token"), \
|
||||||
|
patch.object(mcp_server, "_fetch_pr_comments", return_value=[]), \
|
||||||
|
patch.object(mcp_server, "api_request", side_effect=mock_api_request), \
|
||||||
|
patch("reviewer_pr_lease.assess_acquire_lease", return_value={"acquire_allowed": True, "reasons": [], "lease_body": "<!-- LEASE -->"}), \
|
||||||
|
patch("reviewer_pr_lease.find_active_reviewer_lease", return_value={"session_id": "sid-123", "reviewer": "sysadmin"}), \
|
||||||
|
patch("reviewer_pr_lease.get_session_lease", return_value={"session_id": "sid-123", "reviewer": "sysadmin"}), \
|
||||||
|
patch("reviewer_pr_lease.clear_session_lease") as mock_clear:
|
||||||
|
|
||||||
|
acq_res = mcp_server.gitea_acquire_reviewer_pr_lease(
|
||||||
|
pr_number=99,
|
||||||
|
remote="prgs",
|
||||||
|
worktree=self.target_worktree,
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertTrue(acq_res.get("success"), acq_res)
|
||||||
|
|
||||||
|
rel_res = mcp_server.gitea_release_reviewer_pr_lease(
|
||||||
|
pr_number=99,
|
||||||
|
worktree=self.target_worktree,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertTrue(rel_res.get("success"), rel_res)
|
||||||
|
mock_clear.assert_called_once()
|
||||||
|
|
||||||
|
def test_unbound_session_derives_and_retains_configured_target_repository(self):
|
||||||
|
"""B9: Unbound session with a legitimate configured target root derives and retains target repository."""
|
||||||
|
prof = {
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/mcp-control-plane"],
|
||||||
|
"canonical_repository_root": self.target_root,
|
||||||
|
}
|
||||||
|
with patch.object(mcp_server, "PROJECT_ROOT", self.install_root), \
|
||||||
|
patch.object(mcp_server.session_ctx, "get_session_context", return_value=None):
|
||||||
|
slug, reasons = mcp_server._canonical_repository_slug(prof, "prgs")
|
||||||
|
self.assertEqual(slug, "Scaled-Tech-Consulting/mcp-control-plane", f"reasons: {reasons}")
|
||||||
|
self.assertEqual(reasons, [])
|
||||||
|
res = mcp_server._trusted_session_repository(prof, "prgs")
|
||||||
|
self.assertEqual(res["org"], "Scaled-Tech-Consulting")
|
||||||
|
self.assertEqual(res["repository"], "mcp-control-plane")
|
||||||
|
|
||||||
|
def test_process_root_a_configured_target_b_retains_b(self):
|
||||||
|
"""B9: Process root A (Gitea-Tools) and configured target B (mcp-control-plane) keep B as expected identity when bound."""
|
||||||
|
bound_ctx = {"org": "Scaled-Tech-Consulting", "repository": "mcp-control-plane", "remote": "prgs"}
|
||||||
|
with patch.object(mcp_server, "PROJECT_ROOT", self.install_root), \
|
||||||
|
patch.object(mcp_server.session_ctx, "get_session_context", return_value=bound_ctx):
|
||||||
|
expected = mcp_server._resolve_expected_repository_slug("prgs")
|
||||||
|
self.assertEqual(expected, "Scaled-Tech-Consulting/mcp-control-plane")
|
||||||
|
|
||||||
|
def test_request_supplied_repository_c_cannot_replace_derived_or_bound_repository_b(self):
|
||||||
|
"""B9: Request-supplied repository C (Timesheet) cannot replace bound repository B (mcp-control-plane)."""
|
||||||
|
bound_ctx = {"org": "Scaled-Tech-Consulting", "repository": "mcp-control-plane", "remote": "prgs"}
|
||||||
|
with patch.object(mcp_server, "PROJECT_ROOT", self.install_root), \
|
||||||
|
patch.object(mcp_server.session_ctx, "get_session_context", return_value=bound_ctx):
|
||||||
|
expected = mcp_server._resolve_expected_repository_slug("prgs", org="Scaled-Tech-Consulting", repo="Timesheet")
|
||||||
|
self.assertEqual(expected, "Scaled-Tech-Consulting/mcp-control-plane")
|
||||||
|
|
||||||
|
def test_known_expected_repo_a_plus_candidate_root_b_fails_closed(self):
|
||||||
|
"""B9: Known expected repository A plus candidate root B fails closed in validation mode."""
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root,
|
||||||
|
source="env",
|
||||||
|
expected_slug="Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
mode="validation",
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["proven"])
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertTrue(any("identity mismatch" in r for r in assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_validation_mode_with_unprovable_expected_identity_fails_closed(self):
|
||||||
|
"""B9: Validation mode with expected_slug=None and require_binding=True fails closed."""
|
||||||
|
assessment = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target_root,
|
||||||
|
source="env",
|
||||||
|
expected_slug=None,
|
||||||
|
process_project_root=self.install_root,
|
||||||
|
remote="prgs",
|
||||||
|
require_binding=True,
|
||||||
|
mode="validation",
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["proven"])
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertTrue(any("unprovable or missing" in r for r in assessment["reasons"]))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -245,10 +245,15 @@ class TestNamespaceWorkspaceIntegration(unittest.TestCase):
|
|||||||
def test_pr487_style_merge_binds_clean_merger_workspace(
|
def test_pr487_style_merge_binds_clean_merger_workspace(
|
||||||
self, _exists, _isdir, mock_run
|
self, _exists, _isdir, mock_run
|
||||||
):
|
):
|
||||||
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
|
def mock_git(cmd, *args, **kwargs):
|
||||||
|
if "rev-parse" in cmd:
|
||||||
|
return MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
|
||||||
|
return MagicMock(returncode=0, stdout=f"worktree {CONTROL_ROOT}\nworktree {MERGER_CLEAN}\n")
|
||||||
|
mock_run.side_effect = mock_git
|
||||||
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
|
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
|
||||||
|
os.environ[nwb.MERGER_WORKTREE_ENV] = MERGER_CLEAN
|
||||||
srv._preflight_resolved_role = "reviewer"
|
srv._preflight_resolved_role = "reviewer"
|
||||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||||
with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()):
|
with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()):
|
||||||
resolved = srv._verify_role_mutation_workspace("prgs")
|
resolved = srv._verify_role_mutation_workspace("prgs")
|
||||||
self.assertEqual(resolved, os.path.realpath(MCP_PROCESS_ROOT))
|
self.assertEqual(resolved, os.path.realpath(MERGER_CLEAN))
|
||||||
Reference in New Issue
Block a user