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
+58 -5
View File
@@ -95,6 +95,15 @@ SAFE_ENV_KEYS = (
"GITEA_MCP_CONFIG",
)
# #948: provenance used to be derived from the summary this allowlist produces.
# The allowlist never carried a provenance key, so that derivation could only
# ever evaluate to ``manual_launch`` — whatever the process actually was — while
# ``gitea_get_runtime_context`` read the live environment and reported
# ``client_managed`` for the same process. Provenance is no longer derived here.
# It comes from ``mcp_worker_identity.assess_provenance``, the single authority
# every surface shares. This allowlist keeps its original and only job: deciding
# which env values are safe to echo back in diagnostics.
def assess_connected_namespace_attachment(
*,
@@ -443,12 +452,23 @@ def classify_namespace_probe(
profile: str | None = None,
configured: bool = True,
probe_source: str | None = None,
worker_identity: str | None = None,
generation_id: str | None = None,
registry: Any | None = None,
pid_alive_probe: Any | None = None,
) -> dict[str, Any]:
"""Classify whether a required tool is callable through a live namespace.
``registered_tools`` is static/server-side evidence. ``probe_result`` is
live invocation evidence. Only ``probe_source=client_namespace`` proves the
IDE-managed path; ``offline_spawn`` is an offline subprocess check only.
#948: ``worker_identity``/``generation_id``/``registry`` carry the
client/session ownership evidence. Provenance is resolved by
``mcp_worker_identity.assess_provenance`` — the same call
``gitea_get_runtime_context`` makes — so the two surfaces cannot report
different provenance for one process. Omitting them yields the fail-closed
``unproven`` verdict, never a fabricated ``client_managed``.
"""
ns = (namespace or "").strip()
tool = required_tool or REQUIRED_NAMESPACE_TOOLS.get(ns) or "gitea_whoami"
@@ -565,14 +585,31 @@ def classify_namespace_probe(
blocks = namespace_health_blocks_task("merge_pr", healthy)
import gitea_config
import mcp_worker_identity
raw_env = process.get("env") if isinstance(process, dict) else None
unconsumed_env = gitea_config.get_unconsumed_gitea_env_overrides(raw_env)
is_client_managed = bool(
env_summary.get("GITEA_CLIENT_MANAGED") in ("1", "true", "yes", "client_managed")
or env_summary.get("GITEA_MCP_CLIENT_MANAGED") in ("1", "true", "yes", "client_managed")
or env_summary.get("GITEA_SERVER_PROVENANCE") == "client_managed"
# #948: one authority, shared with gitea_get_runtime_context. The env is
# passed whole rather than through SAFE_ENV_KEYS — the allowlist exists to
# decide what may be *echoed*, and using it to decide what may be *believed*
# is what made this surface structurally unable to report client_managed.
# ``declared_only``: ``process`` describes an observed peer, not this
# interpreter. Its stdin is unavailable and its launcher-config env is
# inherited from whatever shell started it, so only an explicit declaration
# is evidence. Absence of one is ``unproven``, not an asserted manual launch.
provenance_verdict = mcp_worker_identity.assess_provenance(
registry=registry,
worker_identity=worker_identity,
generation_id=generation_id,
env=raw_env if isinstance(raw_env, dict) else {},
namespace=ns,
profile=profile_name,
pid_alive_probe=pid_alive_probe,
declared_only=True,
)
provenance = "client_managed" if is_client_managed else "manual_launch"
provenance = provenance_verdict["provenance"]
is_client_managed = provenance_verdict["is_client_managed"]
return {
"success": healthy,
@@ -591,6 +628,15 @@ def classify_namespace_probe(
"remediation": remediation,
"provenance": provenance,
"is_client_managed": is_client_managed,
# Every non-client-session verdict fails closed. Consumers that only
# need "may this mutate?" read this and stay correct across the #948
# vocabulary split between ``manual_launch`` and ``unproven``.
"provenance_fail_closed": provenance_verdict["fail_closed"],
"provenance_assessment": provenance_verdict,
"worker_identity": provenance_verdict["worker_identity"],
"session_id": provenance_verdict["session_id"],
"generation_id": provenance_verdict["generation_id"],
"client_name": provenance_verdict["client_name"],
"unconsumed_gitea_env": unconsumed_env,
"diagnostics": {
"namespace": ns,
@@ -602,6 +648,13 @@ def classify_namespace_probe(
"probe_source": source,
"provenance": provenance,
"is_client_managed": is_client_managed,
"provenance_fail_closed": provenance_verdict["fail_closed"],
"provenance_blocker_kind": provenance_verdict["blocker_kind"],
"provenance_scope": provenance_verdict["scope"],
"worker_identity": provenance_verdict["worker_identity"],
"session_id": provenance_verdict["session_id"],
"generation_id": provenance_verdict["generation_id"],
"client_name": provenance_verdict["client_name"],
"unconsumed_gitea_env": unconsumed_env,
},
"blocks_merge_workflow": blocks,