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:
+257
-34
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user