feat(mcp): client/session-aware runtime ownership and provenance (#948)

Two surfaces reported different provenance for one process.
`gitea_get_runtime_context` read the live environment and reported
`client_managed`; `mcp_namespace_health.classify_namespace_probe` derived
provenance from `_safe_env_summary()`, whose `SAFE_ENV_KEYS` allowlist never
contained `GITEA_CLIENT_MANAGED`, `GITEA_MCP_CLIENT_MANAGED`, or
`GITEA_SERVER_PROVENANCE`. That lookup could only ever miss, so the health
surface was structurally incapable of returning anything but `manual_launch`.

Neither model could name which client or which session owned a runtime, so a
healthy daemon serving a second client was indistinguishable from a duplicate,
and the profile-wide duplicate scan walled the whole fleet.

Introduce `mcp_worker_identity` as the one authority, splitting two claims the
old code ran together:

* launch provenance — was this hand-launched from a terminal? Answered from the
  environment, which is legitimate because the launcher sets it. Preserves the
  #686 wall unchanged.
* session ownership — which live client session owns this runtime now? Answered
  only from a live attachment record; no environment flag can establish it.

The module provides collision-resistant worker identities
(`<llm-name>-<UTC-timestamp>-<short-sha>`), an atomic SQLite registry with
fencing epochs, heartbeat-based liveness, generation takeover that supersedes
only a non-live claimant, cohort classification, and failure scoping.

Behaviour changes:

* Registering an existing worker identity fails closed; it is never replaced,
  adopted, or merged with. The caller mints a different identity instead.
* A generation held by a live session cannot be claimed by a second one. A
  generation whose claimant is not live is taken over with a higher fencing
  epoch, so stale ownership cannot permanently strand a healthy daemon.
* A superseded session presenting an old epoch is refused and performs no write.
* Liveness comes from heartbeat freshness; a live PID cannot resurrect an
  expired record, and a dead PID withdraws liveness.
* Workers sharing a role or profile no longer trigger a profile-wide duplicate
  block, provided each carries a distinct identity. Processes with no identity
  evidence remain classified as duplicates, so the #686 wall still holds.
* Runtime failures are scoped to a worker identity or generation, never to a
  profile or the fleet.
* Reconnect guidance no longer defaults to Codex. An unidentified client gets
  host-agnostic steps; `gitea_request_mcp_reconnect(client=...)` defaults to
  resolving the client from the live attachment record.
* `resolve_bound_remote` keeps a bound namespace on its remote instead of
  falling through to the `dadeschools` library default.

Absence of proof is now reported as `unproven` rather than asserted as
`manual_launch`. Both still fail closed — `is_client_managed` is unchanged, so
nothing previously refused is now permitted — but remediation names the proof
that is actually missing instead of describing a terminal launch it cannot
evidence. The #686 test is updated for that vocabulary and keeps every
wall-preserving assertion.

Threat-model anchors and their citations in docs/remote-mcp/threat-model.md are
restamped for the line movement in gitea_mcp_server.py.

Tests: tests/test_issue_948_client_session_provenance.py adds 43 cases covering
Codex/Gemini/Antigravity/Claude attachment, same-client new session, cross-client
takeover after a session ends, two live conflicting sessions, stale records,
missing attachment proof, environment flags without attachment, mixed
generations, duplicate cohorts, the hardcoded-client regression, explicit PRGS
selection, default-remote host drift, cross-surface agreement, and fail-closed
handling without false reconnect loops. Synthetic identifiers throughout.

Full suite from a branches/ worktree: 28F/5953P/6S at head vs 28F/5910P/6S at
merge base 8eada1fb, identical failing ID sets.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01F6Vomtndpq2gSBa88Tfcwy
This commit is contained in:
2026-07-29 02:52:18 -04:00
co-authored by Claude Opus 5
parent 8eada1fbe4
commit 1dd30ecb15
8 changed files with 2491 additions and 95 deletions
+257 -34
View File
@@ -199,6 +199,7 @@ RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE"
import namespace_workspace_binding as nwb # noqa: E402
import canonical_repository_root as crr # noqa: E402 # #706 cross-repo canonical root
import mcp_namespace_health # noqa: E402
import mcp_worker_identity # noqa: E402 # #948 single client/session provenance authority
import stale_binding_recovery # noqa: E402
# Worktree env bindings inherited from the parent environment at daemon boot
@@ -14916,7 +14917,7 @@ def _stale_runtime_reconnect_action() -> str:
return (
"blocker_kind=runtime_reconnect_required: call "
"gitea_request_mcp_reconnect(namespace=<active gitea-* namespace>, "
"reason='stale-runtime', client='codex') for a typed operator "
"reason='stale-runtime') for a typed operator "
"reconnect blocker with exact UI steps, then reconnect the IDE/client "
"MCP session so the server reloads at the current master head. Do not "
"call gitea_activate_profile, pkill, touch configs, or switch MCP role "
@@ -15478,34 +15479,166 @@ def _session_context_mutation_block(
return blocked
def _is_client_managed_process() -> bool:
"""Check whether the current MCP server process has client-managed launch provenance (#686)."""
val = (
os.environ.get("GITEA_CLIENT_MANAGED")
or os.environ.get("GITEA_MCP_CLIENT_MANAGED")
or os.environ.get("GITEA_SERVER_PROVENANCE")
or os.environ.get("GITEA_FORCE_CLIENT_MANAGED")
or ""
).strip().lower()
# --- #948 client/session runtime ownership -------------------------------
#
# One daemon launch is one *generation*. The session that owns it registers a
# unique worker identity against it, so a second healthy client attaching its
# own generation is no longer indistinguishable from a duplicate process.
# Everything here is process-local cache plus a local SQLite registry: no Gitea
# call, no network, no config write, and every failure degrades to "unproven"
# rather than raising into a tool call.
if val in ("0", "false", "no", "manual", "manual_launch"):
_WORKER_REGISTRY = None
_WORKER_IDENTITY: str | None = None
_WORKER_GENERATION: str | None = None
_WORKER_REGISTRATION_ATTEMPTED = False
#: Env a client launcher may set to name itself and its session. Absent values
#: are reported as unknown; they are never guessed at, because guessing is what
#: produced Codex reconnect steps for a Gemini operator.
CLIENT_NAME_ENV = "GITEA_MCP_CLIENT"
CLIENT_INSTANCE_ENV = "GITEA_MCP_CLIENT_INSTANCE"
CLIENT_SESSION_ENV = "GITEA_MCP_CLIENT_SESSION"
def _worker_registry():
"""Local worker registry, or ``None`` when it cannot be opened.
Under pytest the registry is only opened when a test has pinned a path, so
a test run never writes into the operator's real registry.
"""
global _WORKER_REGISTRY
if _WORKER_REGISTRY is not None:
return _WORKER_REGISTRY
if mcp_daemon_guard.is_pytest_runtime() and not (
os.environ.get(mcp_worker_identity.REGISTRY_PATH_ENV) or ""
).strip():
return None
try:
_WORKER_REGISTRY = mcp_worker_identity.WorkerRegistry()
except Exception:
return None
return _WORKER_REGISTRY
def _client_identity_hints() -> dict:
"""What the launcher told us about itself. Unset fields stay unset."""
return {
"client_name": (os.environ.get(CLIENT_NAME_ENV) or "").strip() or None,
"client_instance_id": (os.environ.get(CLIENT_INSTANCE_ENV) or "").strip()
or f"pid-{os.getpid()}",
"session_id": (os.environ.get(CLIENT_SESSION_ENV) or "").strip()
or f"proc-{os.getpid()}-{_process_boot_head_sha or 'nohead'}",
}
def _active_worker_identity() -> str | None:
"""This runtime's worker identity, registering it once per process.
A collision does not adopt the existing registration: a fresh identity is
minted and registered instead, which is the #948 AC32 requirement and also
the only safe response adopting would silently transfer another worker's
leases and fencing tokens.
"""
global _WORKER_IDENTITY, _WORKER_GENERATION, _WORKER_REGISTRATION_ATTEMPTED
if _WORKER_IDENTITY is not None:
return _WORKER_IDENTITY
if _WORKER_REGISTRATION_ATTEMPTED:
return None
_WORKER_REGISTRATION_ATTEMPTED = True
registry = _worker_registry()
if registry is None:
return None
hints = _client_identity_hints()
generation = _WORKER_GENERATION or mcp_worker_identity.new_generation_id(os.getpid())
native = mcp_daemon_guard.native_runtime_status()
try:
for _attempt in range(3):
identity = mcp_worker_identity.generate_worker_identity(
hints["client_name"], hints["session_id"]
)
outcome = registry.register(
worker_identity=identity,
client_name=hints["client_name"],
client_instance_id=hints["client_instance_id"],
session_id=hints["session_id"],
generation_id=generation,
role=_active_role_kind_safe(),
profile=(os.environ.get(gitea_config.ENV_PROFILE) or "").strip() or None,
remote=(os.environ.get("GITEA_MCP_REMOTE") or "").strip() or None,
repository_binding=PROJECT_ROOT,
pid=os.getpid(),
transport=native.get("bound_transport"),
token_fingerprint=native.get("token_fingerprint"),
pid_alive_probe=issue_lock_store.is_process_alive,
)
if outcome.get("registered"):
_WORKER_IDENTITY = identity
_WORKER_GENERATION = generation
return identity
if not outcome.get("collision"):
return None
# Collided: loop mints a different identity rather than reusing this
# one. The existing registration is left exactly as it was.
except Exception:
return None
return None
def _active_role_kind_safe() -> str | None:
"""Best-effort role for the registry record; never raises into a tool call.
The role is recorded for diagnostics only. It is deliberately not part of
the identity: role is a reusable capability definition that many live
workers may share (#948 AC37).
"""
try:
profile = get_profile() or {}
return _role_kind(
profile.get("allowed_operations") or [],
profile.get("forbidden_operations") or [],
)
except Exception:
return None
def _stdin_is_tty() -> bool:
"""Terminal evidence for launch provenance, tolerant of a detached stdin."""
try:
return bool(sys.stdin and sys.stdin.isatty())
except Exception:
return False
if val in ("1", "true", "yes", "client_managed"):
return True
# A terminal launch has an active TTY on stdin
try:
if sys.stdin and sys.stdin.isatty():
return False
except Exception:
pass
def _launch_provenance() -> dict:
"""This process's launch provenance, from the one shared authority (#948).
# Standard client launch or test runner with stdio pipe and profile env
if "GITEA_MCP_CONFIG" in os.environ or "GITEA_MCP_PROFILE" in os.environ or "GITEA_PROFILE_NAME" in os.environ:
return True
Previously each surface reimplemented this. ``gitea_get_runtime_context``
read the live environment while ``mcp_namespace_health`` read a filtered
summary that structurally could not see the provenance keys, so the two
reported different provenance for the same process. Both now call
``mcp_worker_identity``.
"""
return mcp_worker_identity.assess_launch_provenance(
dict(os.environ), stdin_is_tty=_stdin_is_tty()
)
return False
def _is_client_managed_process() -> bool:
"""Check whether the current MCP server process has client-managed launch provenance (#686).
#948: the decision logic moved to
``mcp_worker_identity.assess_launch_provenance`` unchanged same env keys,
same precedence, same TTY fallback so this keeps returning exactly what it
always returned while no longer being a second, divergent implementation.
Launch provenance answers "was this hand-launched from a terminal", which is
the #686 question. It does *not* answer which live session owns this
runtime; that is ``session_ownership`` and needs an attachment record.
"""
return bool(_launch_provenance()["client_managed"])
def _provenance_mutation_block(**extra_fields) -> dict | None:
@@ -19048,7 +19181,21 @@ def gitea_get_runtime_context(
source="gitea_get_runtime_context",
)
is_client_managed = _is_client_managed_process()
# #948: one assessment, shared with mcp_namespace_health. Reporting both
# dimensions from a single call is what makes the two surfaces agree — the
# contradiction they used to produce came from two implementations, not from
# two genuinely different observations.
provenance_assessment = mcp_worker_identity.assess_provenance(
registry=_worker_registry(),
worker_identity=_active_worker_identity(),
env=dict(os.environ),
native_transport_bound=mcp_daemon_guard.bound_transport() is not None,
profile=profile.get("profile_name"),
role=_role_kind(allowed, forbidden),
stdin_is_tty=_stdin_is_tty(),
pid_alive_probe=issue_lock_store.is_process_alive,
)
is_client_managed = provenance_assessment["is_client_managed"]
unconsumed_env = gitea_config.get_unconsumed_gitea_env_overrides()
result = {
@@ -19067,8 +19214,21 @@ def gitea_get_runtime_context(
"review_merge_blocked_reasons": blocked_reasons,
"suggested_fix": suggested_fix,
"safe_next_action": safe_next_action,
"server_provenance": "client_managed" if is_client_managed else "manual_launch",
"server_provenance": provenance_assessment["launch_provenance"],
"is_client_managed": is_client_managed,
# #948: the ownership dimension, which the environment cannot establish.
# A surface that needs "may this session mutate on behalf of its client"
# reads these, not server_provenance.
"session_ownership": provenance_assessment["session_ownership"],
"session_owned": provenance_assessment["session_owned"],
"worker_identity": provenance_assessment["worker_identity"],
"session_id": provenance_assessment["session_id"],
"client_instance_id": provenance_assessment["client_instance_id"],
"generation_id": provenance_assessment["generation_id"],
"client_name": provenance_assessment["client_name"],
"fencing_epoch": provenance_assessment["fencing_epoch"],
"conflicting_live_sessions": provenance_assessment["conflicting_live_sessions"],
"provenance_assessment": provenance_assessment,
"unconsumed_gitea_env": unconsumed_env,
"preflight_ready": preflight["preflight_ready"],
"preflight_block_reasons": preflight["preflight_block_reasons"],
@@ -21654,11 +21814,23 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) ->
if match:
profile = match.group(1)
# #948: provenance for a scanned peer comes from the shared authority,
# fed by that peer's own environment, so the fleet scan cannot disagree
# with what that peer reports about itself.
peer_env = {
m.group(1): m.group(2)
for m in re.finditer(r'\b(GITEA_[A-Z0-9_]+)=([^\s]+)', env_out)
}
# declared_only: this is a peer process. Its stdin is not ours to
# inspect, and it inherits GITEA_MCP_PROFILE from any shell that
# exported it, so only an explicit declaration counts as evidence here.
is_client_managed = bool(
re.search(r'\bGITEA_CLIENT_MANAGED=(1|true|yes|client_managed)\b', env_out, re.IGNORECASE)
or re.search(r'\bGITEA_MCP_CLIENT_MANAGED=(1|true|yes|client_managed)\b', env_out, re.IGNORECASE)
or re.search(r'\bGITEA_SERVER_PROVENANCE=client_managed\b', env_out, re.IGNORECASE)
mcp_worker_identity.assess_launch_provenance(
peer_env, declared_only=True
)["client_managed"]
)
peer_worker_identity = peer_env.get("GITEA_MCP_WORKER_IDENTITY")
peer_generation = peer_env.get("GITEA_MCP_GENERATION_ID")
for env_match in re.finditer(r'\b(GITEA_[A-Z0-9_]+)=([^\s]+)', env_out):
k, v = env_match.group(1), env_match.group(2)
@@ -21674,6 +21846,8 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) ->
"start_time": start_time,
"is_stale": is_stale,
"is_client_managed": is_client_managed,
"worker_identity": peer_worker_identity,
"generation_id": peer_generation,
}
if profile not in all_profile_procs:
all_profile_procs[profile] = []
@@ -21681,12 +21855,44 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) ->
running_profiles = {}
for profile, procs in all_profile_procs.items():
if len(procs) > 1:
# #948 AC40/AC43: sharing a profile is legitimate — profile is a
# reusable capability definition, not a worker identity. What is *not*
# legitimate is reusing one worker identity, or two live sessions
# claiming one generation. Distinctness has to be proven, though:
# processes carrying no identity evidence are indistinguishable, so
# they stay classified as duplicates and keep the #686 wall intact.
identified = [p for p in procs if p.get("worker_identity")]
distinct_identities = {p["worker_identity"] for p in identified}
all_identified = len(identified) == len(procs)
duplicate_identity = len(identified) != len(distinct_identities)
contested_generation = any(
len({p["worker_identity"] for p in identified if p.get("generation_id") == gen}) > 1
for gen in {p.get("generation_id") for p in identified if p.get("generation_id")}
)
if len(procs) > 1 and (
not all_identified or duplicate_identity or contested_generation
):
pids_str = ", ".join(str(p["pid"]) for p in procs)
if duplicate_identity or contested_generation:
detail = (
"The same worker identity or generation is claimed more than once, "
"so these are genuine duplicates rather than independent workers."
)
else:
detail = (
"Manual or duplicate launches defeat staleness detection and cannot "
"receive client stdio."
)
reasons.append(
f"stale-runtime: Duplicate MCP server process(es) detected for profile '{profile}' (PIDs: {pids_str}). "
"Manual or duplicate launches defeat staleness detection and cannot receive client stdio."
+ detail
)
# Otherwise: several independently identified workers share one profile.
# No reason is appended, deliberately. Every reason this function
# returns is raised as a hard RuntimeError by its callers, so recording
# legitimate concurrency here as "informational" would block exactly the
# case #948 exists to permit.
client_procs = [p for p in procs if p["is_client_managed"]]
if client_procs:
client_procs.sort(key=lambda p: p["start_time"], reverse=True)
@@ -22095,7 +22301,7 @@ def gitea_resolve_task_capability(
next_safe_action = (
"blocker_kind=runtime_reconnect_required: call "
"gitea_request_mcp_reconnect(namespace=<active gitea-* namespace>, "
"reason='stale-runtime', client='codex') for a typed operator "
"reason='stale-runtime') for a typed operator "
"blocker with exact UI steps, then reconnect/reload the IDE-managed "
"Gitea MCP server for this profile so it reloads current master. "
"Do not edit mcp_config.json by hand, pkill, or touch configs; the "
@@ -23663,7 +23869,7 @@ def gitea_workflow_dashboard(
def gitea_request_mcp_reconnect(
namespace: str | None = None,
reason: str | None = None,
client: str = "codex",
client: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
session_id: str | None = None,
@@ -23693,8 +23899,12 @@ def gitea_request_mcp_reconnect(
to the active profile's inferred namespace.
reason: Why reconnect is requested: ``stale-runtime``, ``transport_eof``,
``missing_namespace``, ``not_required``, or free-form (normalized).
client: Operator UI surface ``codex`` (default), ``claude_code``, or
``generic``.
client: Operator UI surface ``codex``, ``claude_code``, or
``generic``. #948: omitting it resolves the client from this
runtime's live attachment record rather than assuming one vendor,
and falls back to ``generic`` when nothing identifies the client.
Emitting Codex panel steps to a Gemini/Antigravity operator left
them with no reachable recovery path.
remote: Known instance ``dadeschools`` or ``prgs`` (parity context).
host: Optional host override for parity context.
session_id: Optional session id to echo in the report.
@@ -23754,6 +23964,19 @@ def gitea_request_mcp_reconnect(
else:
effective_reason = mcp_client_reconnect.REASON_UNSPECIFIED
# #948: an omitted client is resolved from the live attachment record, so
# the steps describe the UI the operator is actually in front of.
if not (client or "").strip():
client = mcp_worker_identity.reconnect_client_for(
mcp_worker_identity.assess_provenance(
registry=_worker_registry(),
worker_identity=_active_worker_identity(),
env=dict(os.environ),
stdin_is_tty=_stdin_is_tty(),
pid_alive_probe=issue_lock_store.is_process_alive,
)
)
payload = mcp_client_reconnect.build_reconnect_request(
namespace=ns,
profile=profile_name,