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
154 lines
7.0 KiB
Python
154 lines
7.0 KiB
Python
"""Tests for Issue #686: Detect and reject manually launched duplicate MCP role servers."""
|
|
import os
|
|
import unittest
|
|
from unittest.mock import patch, MagicMock
|
|
from datetime import datetime
|
|
|
|
import gitea_config
|
|
import gitea_mcp_server
|
|
import mcp_namespace_health
|
|
|
|
|
|
class TestIssue686ManualMcpProvenance(unittest.TestCase):
|
|
|
|
def test_client_managed_process_detection(self):
|
|
"""Test _is_client_managed_process correctly detects provenance markers."""
|
|
with patch.dict(os.environ, {"GITEA_CLIENT_MANAGED": "1"}, clear=True):
|
|
self.assertTrue(gitea_mcp_server._is_client_managed_process())
|
|
|
|
with patch.dict(os.environ, {"GITEA_MCP_CLIENT_MANAGED": "true"}, clear=True):
|
|
self.assertTrue(gitea_mcp_server._is_client_managed_process())
|
|
|
|
with patch.dict(os.environ, {"GITEA_SERVER_PROVENANCE": "client_managed"}, clear=True):
|
|
self.assertTrue(gitea_mcp_server._is_client_managed_process())
|
|
|
|
with patch.dict(os.environ, {"GITEA_CLIENT_MANAGED": "0"}, clear=True):
|
|
self.assertFalse(gitea_mcp_server._is_client_managed_process())
|
|
|
|
def test_unconsumed_gitea_env_overrides(self):
|
|
"""Test surfacing of unsupported GITEA_* env overrides (e.g. GITEA_DUMMY)."""
|
|
env = {
|
|
"GITEA_MCP_PROFILE": "prgs-author",
|
|
"GITEA_CLIENT_MANAGED": "1",
|
|
"GITEA_DUMMY": "2",
|
|
"GITEA_UNKNOWN_FLAG": "abc",
|
|
}
|
|
unconsumed = gitea_config.get_unconsumed_gitea_env_overrides(env)
|
|
self.assertIn("GITEA_DUMMY", unconsumed)
|
|
self.assertEqual(unconsumed["GITEA_DUMMY"], "2")
|
|
self.assertIn("GITEA_UNKNOWN_FLAG", unconsumed)
|
|
self.assertNotIn("GITEA_MCP_PROFILE", unconsumed)
|
|
self.assertNotIn("GITEA_CLIENT_MANAGED", unconsumed)
|
|
|
|
def test_manual_server_mutation_fail_closed(self):
|
|
"""AC 2: Mutating tools on a server without client-managed provenance fail closed with a typed blocker."""
|
|
with patch.dict(os.environ, {"GITEA_CLIENT_MANAGED": "0"}, clear=True):
|
|
block = gitea_mcp_server._provenance_mutation_block(task="create_issue")
|
|
self.assertIsNotNone(block)
|
|
self.assertFalse(block["success"])
|
|
self.assertFalse(block["performed"])
|
|
self.assertEqual(block["blocker_kind"], "unsupported_manual_launch")
|
|
self.assertEqual(block["provenance"], "manual_launch")
|
|
self.assertTrue(any("mutation denied: server process was launched manually" in r for r in block["reasons"]))
|
|
self.assertIn("BLOCKED + RECONNECT", block["exact_next_action"])
|
|
|
|
def test_client_managed_server_mutation_passes_provenance_gate(self):
|
|
"""AC 3: Clean client-managed baseline passes the provenance gate."""
|
|
with patch.dict(os.environ, {"GITEA_CLIENT_MANAGED": "1"}, clear=True):
|
|
block = gitea_mcp_server._provenance_mutation_block(task="create_issue")
|
|
self.assertIsNone(block)
|
|
|
|
@patch("subprocess.run")
|
|
@patch("os.path.getmtime")
|
|
@patch("os.path.exists")
|
|
@patch("os.getpid")
|
|
def test_manual_duplicate_does_not_mask_stale_runtime(
|
|
self, mock_getpid, mock_exists, mock_getmtime, mock_run
|
|
):
|
|
"""AC 1 & AC 3: Staleness detection ignores manual duplicates and reports stale supported runtimes."""
|
|
mock_getpid.return_value = 12345
|
|
mock_exists.return_value = True
|
|
|
|
code_time = datetime(2026, 7, 8, 14, 0, 0)
|
|
mock_getmtime.return_value = code_time.timestamp()
|
|
|
|
# PID 12345: stale client-managed process (started at 13:00)
|
|
# PID 99999: fresh manual duplicate process (started at 15:00, no GITEA_CLIENT_MANAGED)
|
|
ps_output = (
|
|
" PID LSTART COMMAND\n"
|
|
"12345 Wed Jul 8 13:00:00 2026 /path/to/python mcp_server.py\n"
|
|
"99999 Wed Jul 8 15:00:00 2026 /path/to/python mcp_server.py\n"
|
|
)
|
|
|
|
mock_run_ps = MagicMock()
|
|
mock_run_ps.stdout = ps_output
|
|
|
|
mock_env_12345 = MagicMock()
|
|
mock_env_12345.stdout = "GITEA_MCP_PROFILE=prgs-author GITEA_CLIENT_MANAGED=1"
|
|
|
|
mock_env_99999 = MagicMock()
|
|
mock_env_99999.stdout = "GITEA_MCP_PROFILE=prgs-author GITEA_DUMMY=2"
|
|
|
|
def side_effect(args, **kwargs):
|
|
if args[0] == "ps" and "eww" in args:
|
|
pid = args[2]
|
|
if pid == "12345":
|
|
return mock_env_12345
|
|
elif pid == "99999":
|
|
return mock_env_99999
|
|
elif args[0] == "ps":
|
|
return mock_run_ps
|
|
raise ValueError(f"Unexpected args: {args}")
|
|
|
|
mock_run.side_effect = side_effect
|
|
|
|
reasons = gitea_mcp_server._check_mcp_runtimes_diagnostics("create_issue", ["prgs-author"])
|
|
|
|
# Manual duplicate process must be flagged
|
|
self.assertTrue(any("Duplicate MCP server process(es) detected" in r for r in reasons))
|
|
# Unsupported env override (GITEA_DUMMY=2) must be flagged
|
|
self.assertTrue(any("unsupported-env: Unsupported GITEA_* environment variable override(s) detected: GITEA_DUMMY=2" in r for r in reasons))
|
|
# Stale runtime must NOT be masked by fresh manual process 99999!
|
|
self.assertTrue(any("All matching profiles for task 'create_issue' (['prgs-author']) are running but stale" in r for r in reasons))
|
|
|
|
def test_namespace_health_classification_includes_provenance(self):
|
|
"""AC 1 & 4: mcp_namespace_health diagnostics include provenance and unconsumed_gitea_env.
|
|
|
|
#948 narrowed the vocabulary here. This process carries no client-managed
|
|
declaration, so the old code labelled it ``manual_launch`` — asserting a
|
|
hand-launched terminal process it had no evidence for, and contradicting
|
|
``gitea_get_runtime_context``, which read the same process and reported
|
|
``client_managed``. Absence of proof is now reported as ``unproven``.
|
|
|
|
The #686 wall itself is unchanged and still asserted below:
|
|
``is_client_managed`` stays False, so nothing previously refused is now
|
|
permitted. Only the label on the *reason* changed, so remediation names
|
|
the proof that is actually missing.
|
|
"""
|
|
process = {
|
|
"pid": 5555,
|
|
"profile": "prgs-author",
|
|
"env": {
|
|
"GITEA_MCP_PROFILE": "prgs-author",
|
|
"GITEA_DUMMY": "99",
|
|
},
|
|
}
|
|
res = mcp_namespace_health.classify_namespace_probe(
|
|
"gitea-author",
|
|
configured=True,
|
|
registered_tools=["gitea_whoami"],
|
|
probe_result={"success": True},
|
|
process=process,
|
|
probe_source="client_namespace",
|
|
)
|
|
self.assertEqual(res["provenance"], "unproven")
|
|
self.assertFalse(res["is_client_managed"])
|
|
# The wall is intact: no client-managed proof still fails closed.
|
|
self.assertTrue(res["provenance_fail_closed"])
|
|
self.assertEqual(res["unconsumed_gitea_env"], {"GITEA_DUMMY": "99"})
|
|
self.assertEqual(res["diagnostics"]["provenance"], "unproven")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|