fix: isolate immutable session context tests (#714)

This commit is contained in:
2026-07-15 02:26:27 -04:00
parent 632c568865
commit 29ffe1407f
7 changed files with 303 additions and 126 deletions
+117 -56
View File
@@ -11,24 +11,70 @@ substitution is forbidden.
from __future__ import annotations
import os
import threading
import urllib.parse
from typing import Any
from dataclasses import dataclass
from typing import Any, Mapping
@dataclass(frozen=True, slots=True)
class _SessionContext:
"""One atomic, immutable process-session binding."""
profile_name: str | None
remote: str | None
host: str | None
identity: str | None
repository: str | None
org: str | None
role_kind: str | None
expected_username: str | None
source: str
pid: int
def as_dict(self) -> dict[str, Any]:
return {
"profile_name": self.profile_name,
"remote": self.remote,
"host": self.host,
"identity": self.identity,
"repository": self.repository,
"org": self.org,
"role_kind": self.role_kind,
"expected_username": self.expected_username,
"source": self.source,
"pid": self.pid,
}
# Process-local only — never a shared file (same rationale as mutation authority).
_SESSION_CONTEXT: dict[str, Any] | None = None
# The frozen value prevents partial mutation, while the lock makes first-bind and
# sanctioned rebind atomic across concurrent MCP calls.
_SESSION_CONTEXT: _SessionContext | None = None
_SESSION_CONTEXT_LOCK = threading.RLock()
def clear_session_context() -> None:
"""Reset session context (tests / process rebind)."""
def _reset_session_context_for_testing() -> None:
"""Reset at a pytest test boundary; unavailable to production callers.
Production sessions transition only through process startup/PID change or
the explicit profile-activation rebind. Keeping this helper private and
requiring pytest's per-test marker prevents it from becoming an MCP/runtime
bypass.
"""
if "PYTEST_CURRENT_TEST" not in os.environ:
raise RuntimeError("session context reset is restricted to pytest boundaries")
global _SESSION_CONTEXT
_SESSION_CONTEXT = None
with _SESSION_CONTEXT_LOCK:
_SESSION_CONTEXT = None
def get_session_context() -> dict[str, Any] | None:
"""Return a shallow copy of the bound context, or None if unbound."""
if _SESSION_CONTEXT is None:
return None
return dict(_SESSION_CONTEXT)
"""Return a detached snapshot of the bound context, or None if unbound."""
with _SESSION_CONTEXT_LOCK:
if _SESSION_CONTEXT is None:
return None
return _SESSION_CONTEXT.as_dict()
def profile_host(profile: dict | None) -> str | None:
@@ -97,21 +143,48 @@ def bind_session_context(
expected_username: str | None = None,
source: str = "bind",
) -> dict[str, Any]:
"""Bind or re-bind the immutable session context (explicit activate path)."""
"""Atomically bind/re-bind context (the explicit activation path)."""
with _SESSION_CONTEXT_LOCK:
return _bind_session_context_unlocked(
profile_name=profile_name,
remote=remote,
host=host,
identity=identity,
repository=repository,
org=org,
role_kind=role_kind,
expected_username=expected_username,
source=source,
)
def _bind_session_context_unlocked(
*,
profile_name: str,
remote: str | None,
host: str | None,
identity: str | None,
repository: str | None,
org: str | None,
role_kind: str | None,
expected_username: str | None,
source: str,
) -> dict[str, Any]:
"""Store a complete immutable context while the caller holds the lock."""
global _SESSION_CONTEXT
_SESSION_CONTEXT = {
"profile_name": (profile_name or "").strip() or None,
"remote": (remote or "").strip() or None,
"host": (host or "").strip().lower() or None,
"identity": (identity or "").strip() or None,
"repository": (repository or "").strip() or None,
"org": (org or "").strip() or None,
"role_kind": (role_kind or "").strip() or None,
"expected_username": (expected_username or "").strip() or None,
"source": source,
"pid": os.getpid(),
}
return get_session_context() # type: ignore[return-value]
_SESSION_CONTEXT = _SessionContext(
profile_name=(profile_name or "").strip() or None,
remote=(remote or "").strip() or None,
host=(host or "").strip().lower() or None,
identity=(identity or "").strip() or None,
repository=(repository or "").strip() or None,
org=(org or "").strip() or None,
role_kind=(role_kind or "").strip() or None,
expected_username=(expected_username or "").strip() or None,
source=source,
pid=os.getpid(),
)
return _SESSION_CONTEXT.as_dict()
def seed_session_context_if_unbound(
@@ -125,37 +198,17 @@ def seed_session_context_if_unbound(
role_kind: str | None = None,
expected_username: str | None = None,
source: str = "seed",
active_profile_override: str | None = None,
env_profile: str | None = None,
) -> dict[str, Any]:
"""Bind when unbound, or rebind when the static env profile changes.
"""Atomically bind only when this process has no current context.
Mid-session ``_active_profile_override`` changes that did **not** go
through ``gitea_activate_profile`` keep the prior bound context so
assess/mutation gates can detect drift and fail closed.
A changed environment or an interleaved call is not a session boundary and
therefore cannot replace an established binding. A newly started/forked
process is recognized by PID; explicit ``gitea_activate_profile`` uses
:func:`bind_session_context` as its sanctioned logical-session transition.
"""
global _SESSION_CONTEXT
live = (profile_name or "").strip() or None
if _SESSION_CONTEXT is None or _SESSION_CONTEXT.get("pid") != os.getpid():
return bind_session_context(
profile_name=profile_name,
remote=remote,
host=host,
identity=identity,
repository=repository,
org=org,
role_kind=role_kind,
expected_username=expected_username,
source=source,
)
bound_profile = _SESSION_CONTEXT.get("profile_name")
if live and bound_profile and live != bound_profile:
# Static env profile switch (tests / launcher) without override: rebind.
# Override present but bound not updated: leave bound for drift detection.
if active_profile_override is None and (
env_profile is None or env_profile == live
):
return bind_session_context(
with _SESSION_CONTEXT_LOCK:
if _SESSION_CONTEXT is None or _SESSION_CONTEXT.pid != os.getpid():
return _bind_session_context_unlocked(
profile_name=profile_name,
remote=remote,
host=host,
@@ -164,9 +217,9 @@ def seed_session_context_if_unbound(
org=org,
role_kind=role_kind,
expected_username=expected_username,
source=f"{source}-env-rebind",
source=source,
)
return get_session_context() # type: ignore[return-value]
return _SESSION_CONTEXT.as_dict()
def assess_session_context(
@@ -187,7 +240,9 @@ def assess_session_context(
unbound and ``require_bound`` is true, fails closed.
"""
reasons: list[str] = []
ctx = _SESSION_CONTEXT
with _SESSION_CONTEXT_LOCK:
bound = _SESSION_CONTEXT
ctx = bound.as_dict() if bound is not None else None
if ctx is None or ctx.get("pid") != os.getpid():
if require_bound:
reasons.append(
@@ -307,6 +362,12 @@ def profile_allowed_for_remote(
return {"proven": False, "block": True, "reasons": reasons}
if not remote:
return {"proven": True, "block": False, "reasons": reasons}
# Legacy env-only profiles have no configured base URL or v2 context. Their
# first call may establish the process remote/host pin; after that,
# assess_session_context rejects any drift. Configured v2 profiles still
# require positive host/context alignment here.
if not profile_host(profile) and not (profile.get("context") or "").strip():
return {"proven": True, "block": False, "reasons": reasons}
if not profile_matches_remote(profile, remote, remotes, contexts=contexts):
p_name = profile.get("profile_name") or profile.get("name") or "(unknown)"
p_host = profile_host(profile) or "(none)"
@@ -319,7 +380,7 @@ def profile_allowed_for_remote(
def mutation_context_audit_fields(
ctx: dict[str, Any] | None = None,
ctx: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Fields to include in pre-mutation audit records."""
data = ctx if ctx is not None else get_session_context()
@@ -347,7 +408,7 @@ def mutation_context_audit_fields(
def _assessment(
proven: bool, reasons: list[str], ctx: dict[str, Any] | None
proven: bool, reasons: list[str], ctx: Mapping[str, Any] | None
) -> dict[str, Any]:
return {
"proven": proven,