meshcode 2.11.128__tar.gz → 2.11.129__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. {meshcode-2.11.128 → meshcode-2.11.129}/PKG-INFO +2 -11
  2. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/__init__.py +1 -1
  3. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/meshcode_mcp/backend.py +11 -0
  4. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/meshcode_mcp/server.py +43 -6
  5. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/secrets.py +51 -11
  6. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode.egg-info/PKG-INFO +2 -11
  7. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode.egg-info/SOURCES.txt +1 -0
  8. {meshcode-2.11.128 → meshcode-2.11.129}/pyproject.toml +1 -1
  9. meshcode-2.11.129/tests/test_session_replay_gate.py +153 -0
  10. {meshcode-2.11.128 → meshcode-2.11.129}/README.md +0 -0
  11. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/__main__.py +0 -0
  12. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/_session_handoff_template.py +0 -0
  13. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/_stop_hook_template.py +0 -0
  14. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/ascii_art.py +0 -0
  15. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/atomic_push.py +0 -0
  16. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/claude_update.py +0 -0
  17. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/cli.py +0 -0
  18. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/comms_v4.py +0 -0
  19. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/compat.py +0 -0
  20. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/daemon.py +0 -0
  21. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/date_parse.py +0 -0
  22. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/doctor.py +0 -0
  23. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/error_hints.py +0 -0
  24. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/exceptions.py +0 -0
  25. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/hooks/__init__.py +0 -0
  26. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/hooks/repo_path_lock.py +0 -0
  27. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/hostd.py +0 -0
  28. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/invites.py +0 -0
  29. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/launcher.py +0 -0
  30. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/launcher_install.py +0 -0
  31. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/meshcode_mcp/__init__.py +0 -0
  32. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/meshcode_mcp/__main__.py +0 -0
  33. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/meshcode_mcp/realtime.py +0 -0
  34. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/meshcode_mcp/sleep_signals.py +0 -0
  35. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/meshcode_mcp/swarm.py +0 -0
  36. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/meshcode_mcp/test_backend.py +0 -0
  37. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/meshcode_mcp/test_boot_timing.py +0 -0
  38. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/meshcode_mcp/test_install_guard.py +0 -0
  39. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/meshcode_mcp/test_prefs_claude_version.py +0 -0
  40. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  41. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
  42. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/meshcode_mcp/test_swarm.py +0 -0
  43. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/preferences.py +0 -0
  44. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/protocol_handler.py +0 -0
  45. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/protocol_v2.py +0 -0
  46. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/quickstart.py +0 -0
  47. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/rpc_allowlist.py +0 -0
  48. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/run_agent.py +0 -0
  49. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/scripts/check_secrets.py +0 -0
  50. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/scripts/race_rate_harness.py +0 -0
  51. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/self_update.py +0 -0
  52. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/setup_clients.py +0 -0
  53. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/supervisor.py +0 -0
  54. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/up.py +0 -0
  55. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode/upload.py +0 -0
  56. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode.egg-info/dependency_links.txt +0 -0
  57. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode.egg-info/entry_points.txt +0 -0
  58. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode.egg-info/requires.txt +0 -0
  59. {meshcode-2.11.128 → meshcode-2.11.129}/meshcode.egg-info/top_level.txt +0 -0
  60. {meshcode-2.11.128 → meshcode-2.11.129}/setup.cfg +0 -0
  61. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_auto_update_hardening.py +0 -0
  62. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_autonomous_closegap_1.py +0 -0
  63. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_autonomous_closegap_2.py +0 -0
  64. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_autonomous_closegap_3.py +0 -0
  65. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_autonomous_prompt_inject.py +0 -0
  66. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_boot_bug_regression.py +0 -0
  67. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_color_truecolor.py +0 -0
  68. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_core.py +0 -0
  69. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_cross_agent_messaging.py +0 -0
  70. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_date_parse.py +0 -0
  71. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_doctor.py +0 -0
  72. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_epistemic_v1_python_sdk.py +0 -0
  73. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_epistemic_v1_stop_conditions.py +0 -0
  74. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_esc_deaf_state.py +0 -0
  75. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_exceptions.py +0 -0
  76. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_file_upload.py +0 -0
  77. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_init_device_code.py +0 -0
  78. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_install_guard.py +0 -0
  79. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_lease_sigterm_release.py +0 -0
  80. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_live_mesh_guard.py +0 -0
  81. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_mark_read_batch.py +0 -0
  82. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_marketplace_ratings.py +0 -0
  83. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_migration_integrity.py +0 -0
  84. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_realtime_event_freshness.py +0 -0
  85. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_rls_cross_tenant.py +0 -0
  86. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_rpc_grants.py +0 -0
  87. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_rpc_migrations.py +0 -0
  88. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_run_agent_dry_run.py +0 -0
  89. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_run_agent_no_server_import.py +0 -0
  90. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_security_regressions.py +0 -0
  91. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_self_update_user_site.py +0 -0
  92. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_sentinel.py +0 -0
  93. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_setup_path.py +0 -0
  94. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_sleep_signals.py +0 -0
  95. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_status_enum_coverage.py +0 -0
  96. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_stay_on_loop_hook.py +0 -0
  97. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_stop_ghost_terminal.py +0 -0
  98. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_swarm_events.py +0 -0
  99. {meshcode-2.11.128 → meshcode-2.11.129}/tests/test_wait_open_tasks_contradiction.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.1
2
2
  Name: meshcode
3
- Version: 2.11.128
3
+ Version: 2.11.129
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -18,17 +18,8 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Operating System :: OS Independent
19
19
  Requires-Python: >=3.9
20
20
  Description-Content-Type: text/markdown
21
- Requires-Dist: mcp[cli]>=1.0.0
22
- Requires-Dist: websockets>=12.0
23
- Requires-Dist: realtime>=2.0.0
24
- Requires-Dist: keyring>=24.0
25
- Requires-Dist: cryptography>=41.0
26
21
  Provides-Extra: test
27
- Requires-Dist: pytest>=8; extra == "test"
28
22
  Provides-Extra: dev
29
- Requires-Dist: build>=1.0; extra == "dev"
30
- Requires-Dist: twine>=4; extra == "dev"
31
- Requires-Dist: pytest>=8; extra == "dev"
32
23
 
33
24
  # MeshCode
34
25
 
@@ -1,5 +1,5 @@
1
1
  """MeshCode — Real-time communication between AI agents."""
2
- __version__ = "2.11.128"
2
+ __version__ = "2.11.129"
3
3
 
4
4
  # Exception hierarchy — eagerly imported (lightweight, no deps)
5
5
  from meshcode.exceptions import ( # noqa: F401
@@ -1327,6 +1327,17 @@ def task_complete(api_key, project_id, task_id, completing_agent, summary=""):
1327
1327
  })
1328
1328
 
1329
1329
 
1330
+ def feature_flag_enabled(api_key, flag_name):
1331
+ """Agent-facing feature-flag check (mig478). The FE's mc_check_feature_flag
1332
+ is auth.uid()-only, so agents use this api-key variant. Returns the raw
1333
+ RPC dict ({"ok": true, "enabled": bool}); callers treat anything else as
1334
+ disabled."""
1335
+ return sb_rpc("mc_feature_flag_enabled", {
1336
+ "p_api_key": api_key,
1337
+ "p_flag_name": flag_name,
1338
+ })
1339
+
1340
+
1330
1341
  # Per-(project,agent,session,event_type) min interval. Same idea as task_list:
1331
1342
  # fire-and-forget pollers that hammer record_event at >2/sec are throttled
1332
1343
  # client-side. Identical events within the window are dropped (returns
@@ -609,16 +609,53 @@ import uuid as _uuid
609
609
  _SESSION_ID = str(_uuid.uuid4())[:12]
610
610
 
611
611
 
612
+ # DB F2.1 (task 7f016375): tool_call events are 82% of mc_session_events
613
+ # storage and their only dashboard consumer (session replay / timeline) is
614
+ # gated behind the ff_session_replay feature flag — so the WRITE is gated by
615
+ # the same flag. Feature-flag gate, NOT sampling (a partial replay lies when
616
+ # debugging). ONLY 'tool_call' is gated; lifecycle events (boot/shutdown/
617
+ # error/status_change) always record. ORTHOGONAL to the 2.11.33 M3a throttle
618
+ # in backend.record_event: that one limits the RATE of whatever is enabled,
619
+ # this one decides IF tool_call records at all. The flag is also enforced
620
+ # server-side inside mc_record_event (mig488) — that covers old clients; this
621
+ # client cache just saves the network call. Fail-closed: unknown flag state
622
+ # records nothing (storage emergency beats replay completeness).
623
+ _FF_REPLAY_TTL_SEC = 600.0 # re-check the flag every 10 min per process
624
+ _ff_replay_cache = {"enabled": None, "checked_mono": 0.0}
625
+
626
+
627
+ def _session_replay_enabled() -> bool:
628
+ """Cached ff_session_replay lookup. Runs INSIDE the bg record thread, so a
629
+ slow/failed flag fetch never adds latency to a tool call."""
630
+ now = _time.monotonic()
631
+ cache = _ff_replay_cache
632
+ if cache["enabled"] is None or now - cache["checked_mono"] >= _FF_REPLAY_TTL_SEC:
633
+ try:
634
+ res = be.feature_flag_enabled(_get_api_key(), "ff_session_replay")
635
+ cache["enabled"] = bool(isinstance(res, dict) and res.get("enabled"))
636
+ except Exception:
637
+ # Keep the last known answer; with no answer yet, fail closed.
638
+ if cache["enabled"] is None:
639
+ cache["enabled"] = False
640
+ cache["checked_mono"] = now
641
+ return cache["enabled"]
642
+
643
+
612
644
  def _record_event_bg(event_type: str, payload: dict = None) -> None:
613
- """Fire-and-forget: record a session event in background thread."""
645
+ """Fire-and-forget: record a session event in background thread.
646
+ 'tool_call' events are dropped while ff_session_replay is off (task
647
+ 7f016375); every other event type records unconditionally."""
614
648
  try:
615
649
  api_key = _get_api_key()
616
650
  import threading
617
- threading.Thread(
618
- target=be.record_event,
619
- args=(api_key, _PROJECT_ID, AGENT_NAME, _SESSION_ID, event_type, payload or {}),
620
- daemon=True,
621
- ).start()
651
+
652
+ def _record():
653
+ if event_type == "tool_call" and not _session_replay_enabled():
654
+ return
655
+ be.record_event(api_key, _PROJECT_ID, AGENT_NAME, _SESSION_ID,
656
+ event_type, payload or {})
657
+
658
+ threading.Thread(target=_record, daemon=True).start()
622
659
  except Exception:
623
660
  pass
624
661
 
@@ -97,12 +97,45 @@ def _probe_keyring() -> bool:
97
97
  _KEYRING_BACKEND_NAME = type(backend).__name__
98
98
  # Some installs report a "fail" backend on headless Linux. Probe with a
99
99
  # write+read+delete round trip on a sentinel key.
100
- sentinel_key = "meshcode_probe_sentinel"
100
+ # BUG 6b28a32f: the sentinel MUST be unique per process. With a fixed
101
+ # name, N concurrent CLIs (hostd respawn sweeps spawn up to 3 in the
102
+ # same second) race set/get/delete on the SAME wincred entry — the
103
+ # loser reads None (or its delete raises because a sibling already
104
+ # deleted it) and wrongly concludes the keychain is unusable, which
105
+ # cascades into "[meshcode] Not logged in" + a dead terminal tab.
106
+ # Fast path (race-immune): if we can READ an existing entry, the
107
+ # backend is proven usable — no write probe needed. The write probe
108
+ # below is only for fresh installs with no entry yet (interactive
109
+ # `meshcode login`, single process, no concurrency).
110
+ try:
111
+ if keyring.get_password(SERVICE_NAME, DEFAULT_PROFILE):
112
+ _KEYRING_AVAILABLE = True
113
+ return True
114
+ except Exception:
115
+ pass
116
+ sentinel_key = f"meshcode_probe_{os.getpid()}_{_stdlib_secrets.token_hex(4)}"
101
117
  sentinel_val = "ok"
102
118
  try:
119
+ import time as _time
103
120
  keyring.set_password(SERVICE_NAME, sentinel_key, sentinel_val)
104
- got = keyring.get_password(SERVICE_NAME, sentinel_key)
105
- keyring.delete_password(SERVICE_NAME, sentinel_key)
121
+ # Windows CredMan has read-after-write visibility lag under
122
+ # concurrent vault access (verified 6b28a32f: CredWrite succeeds,
123
+ # immediate CredRead misses, the entry IS there later). Retry the
124
+ # read-back briefly before declaring the backend unusable.
125
+ got = None
126
+ for _attempt in range(4):
127
+ got = keyring.get_password(SERVICE_NAME, sentinel_key)
128
+ if got == sentinel_val:
129
+ break
130
+ _time.sleep(0.05 * (_attempt + 1))
131
+ for _attempt in range(2):
132
+ try:
133
+ keyring.delete_password(SERVICE_NAME, sentinel_key)
134
+ break
135
+ except Exception:
136
+ # cleanup failure does not mean the backend is unusable;
137
+ # one delayed retry keeps orphan sentinels out of the vault
138
+ _time.sleep(0.05)
106
139
  _KEYRING_AVAILABLE = (got == sentinel_val)
107
140
  except KeyringError:
108
141
  _KEYRING_AVAILABLE = False
@@ -218,15 +251,22 @@ def get_api_key(profile: str = DEFAULT_PROFILE) -> Optional[str]:
218
251
  1. Keychain entry (SERVICE_NAME, profile)
219
252
  2. ~/.meshcode/credentials.<profile>.json (fallback file from set_api_key)
220
253
  3. ~/.meshcode/credentials.json (legacy pre-1.4.1 file, default profile only)
254
+
255
+ BUG 6b28a32f: READS are no longer gated on _probe_keyring(). The probe
256
+ does a write+read+delete round trip, which is only meaningful before a
257
+ WRITE (set_api_key) — gating reads on it meant any transient probe
258
+ failure (concurrent-CLI sentinel race, vault hiccup) skipped a perfectly
259
+ healthy keychain entry and fell through to fallback files that were
260
+ migrated-and-shredded, producing a false "Not logged in". Just try the
261
+ read; it is its own probe.
221
262
  """
222
- if _probe_keyring():
223
- try:
224
- import keyring # type: ignore
225
- val = keyring.get_password(SERVICE_NAME, profile)
226
- if val:
227
- return val
228
- except Exception:
229
- pass
263
+ try:
264
+ import keyring # type: ignore
265
+ val = keyring.get_password(SERVICE_NAME, profile)
266
+ if val:
267
+ return val
268
+ except Exception:
269
+ pass
230
270
 
231
271
  # Fallback file for this profile
232
272
  fallback_path = Path.home() / ".meshcode" / f"credentials.{profile}.json"
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.1
2
2
  Name: meshcode
3
- Version: 2.11.128
3
+ Version: 2.11.129
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -18,17 +18,8 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Operating System :: OS Independent
19
19
  Requires-Python: >=3.9
20
20
  Description-Content-Type: text/markdown
21
- Requires-Dist: mcp[cli]>=1.0.0
22
- Requires-Dist: websockets>=12.0
23
- Requires-Dist: realtime>=2.0.0
24
- Requires-Dist: keyring>=24.0
25
- Requires-Dist: cryptography>=41.0
26
21
  Provides-Extra: test
27
- Requires-Dist: pytest>=8; extra == "test"
28
22
  Provides-Extra: dev
29
- Requires-Dist: build>=1.0; extra == "dev"
30
- Requires-Dist: twine>=4; extra == "dev"
31
- Requires-Dist: pytest>=8; extra == "dev"
32
23
 
33
24
  # MeshCode
34
25
 
@@ -87,6 +87,7 @@ tests/test_run_agent_no_server_import.py
87
87
  tests/test_security_regressions.py
88
88
  tests/test_self_update_user_site.py
89
89
  tests/test_sentinel.py
90
+ tests/test_session_replay_gate.py
90
91
  tests/test_setup_path.py
91
92
  tests/test_sleep_signals.py
92
93
  tests/test_status_enum_coverage.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "meshcode"
7
- version = "2.11.128"
7
+ version = "2.11.129"
8
8
  description = "Real-time communication between AI agents — Supabase-backed CLI"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -0,0 +1,153 @@
1
+ """
2
+ Session-Replay tool_call Gate Tests (task 7f016375, DB F2.1)
3
+ =============================================================
4
+ tool_call events are 82% of mc_session_events storage; their only consumer
5
+ (session replay / timeline) is feature-flagged. These tests pin the gate:
6
+
7
+ 1. server.py: _record_event_bg drops 'tool_call' while ff_session_replay
8
+ is off, records it when on, and NEVER gates lifecycle events.
9
+ 2. The flag lookup is cached (TTL) and fail-closed on first error.
10
+ 3. mig478 SQL: mc_record_event gates ONLY tool_call; the flag seeds
11
+ disabled; the agent-facing flag RPC exists with grants.
12
+ 4. The gate is a FLAG, not sampling — no random() anywhere in the path.
13
+
14
+ Unit tests run fully offline (backend mocked). SQL checks parse migrations
15
+ (pattern of test_security_regressions.py).
16
+
17
+ Usage:
18
+ pytest tests/test_session_replay_gate.py -v
19
+ """
20
+
21
+ import re
22
+ import sys
23
+ import time
24
+ from pathlib import Path
25
+ from unittest.mock import patch
26
+
27
+ sys.path.insert(0, str(Path(__file__).parent.parent))
28
+
29
+ MIGRATIONS_DIR = Path(__file__).parent.parent / "supabase" / "migrations"
30
+ MIG478 = MIGRATIONS_DIR / "20260610_478_tool_call_replay_gate.sql"
31
+
32
+
33
+ def _fresh_server():
34
+ """Import server with a clean flag cache. server.py is import-heavy but
35
+ side-effect-free at module level (asserted by test_boot_bug_regression)."""
36
+ from meshcode.meshcode_mcp import server
37
+ server._ff_replay_cache["enabled"] = None
38
+ server._ff_replay_cache["checked_mono"] = 0.0
39
+ return server
40
+
41
+
42
+ def _run_record(server, event_type, flag_response):
43
+ """Call _record_event_bg with backend mocked; run the bg thread inline by
44
+ joining all threads it spawns. Returns the list of record_event calls."""
45
+ calls = []
46
+ import threading
47
+ started = []
48
+ orig_start = threading.Thread.start
49
+
50
+ def capture_start(self):
51
+ started.append(self)
52
+ orig_start(self)
53
+
54
+ with patch.object(server.be, "record_event",
55
+ side_effect=lambda *a, **k: calls.append(a)), \
56
+ patch.object(server.be, "feature_flag_enabled",
57
+ side_effect=flag_response), \
58
+ patch.object(server, "_get_api_key", return_value="mc_test_key"), \
59
+ patch.object(threading.Thread, "start", capture_start):
60
+ server._record_event_bg(event_type, {"tool": "x"})
61
+ for t in started:
62
+ t.join(timeout=5)
63
+ return calls
64
+
65
+
66
+ class TestClientGate:
67
+ def test_tool_call_dropped_when_flag_off(self):
68
+ server = _fresh_server()
69
+ calls = _run_record(server, "tool_call",
70
+ lambda *a: {"ok": True, "enabled": False})
71
+ assert calls == [], "tool_call recorded despite ff_session_replay off"
72
+
73
+ def test_tool_call_recorded_when_flag_on(self):
74
+ server = _fresh_server()
75
+ calls = _run_record(server, "tool_call",
76
+ lambda *a: {"ok": True, "enabled": True})
77
+ assert len(calls) == 1 and calls[0][4] == "tool_call"
78
+
79
+ def test_lifecycle_events_never_gated(self):
80
+ for ev in ("boot", "shutdown", "error", "status_change"):
81
+ server = _fresh_server()
82
+
83
+ def explode(*a):
84
+ raise AssertionError(f"flag checked for lifecycle event {ev!r}")
85
+ calls = _run_record(server, ev, explode)
86
+ assert len(calls) == 1 and calls[0][4] == ev, (
87
+ f"lifecycle event {ev!r} must record unconditionally")
88
+
89
+ def test_fail_closed_on_first_flag_error(self):
90
+ server = _fresh_server()
91
+
92
+ def boom(*a):
93
+ raise OSError("network down")
94
+ calls = _run_record(server, "tool_call", boom)
95
+ assert calls == [], "flag fetch failure must fail CLOSED (no record)"
96
+
97
+ def test_flag_cached_within_ttl(self):
98
+ server = _fresh_server()
99
+ fetches = []
100
+
101
+ def counting(*a):
102
+ fetches.append(1)
103
+ return {"ok": True, "enabled": True}
104
+ _run_record(server, "tool_call", counting)
105
+ _run_record(server, "tool_call", counting)
106
+ assert len(fetches) == 1, (
107
+ f"flag fetched {len(fetches)}x within TTL — cache not working")
108
+
109
+ def test_no_sampling_anywhere(self):
110
+ """Commander condition 1: flag gate, NOT sampling."""
111
+ import inspect
112
+ from meshcode.meshcode_mcp import server
113
+ src = inspect.getsource(server._record_event_bg) + \
114
+ inspect.getsource(server._session_replay_enabled)
115
+ assert "random" not in src
116
+
117
+
118
+ class TestMig478Sql:
119
+ SQL = None
120
+
121
+ @classmethod
122
+ def setup_class(cls):
123
+ cls.SQL = MIG478.read_text(errors="replace")
124
+
125
+ def test_gate_only_wraps_tool_call(self):
126
+ body = re.search(r"FUNCTION public\.mc_record_event.*?\$\$;", self.SQL,
127
+ re.DOTALL).group(0)
128
+ gate = re.search(r"IF p_event_type = 'tool_call' THEN(.*?)END IF;",
129
+ body, re.DOTALL)
130
+ assert gate, "tool_call gate missing from mc_record_event"
131
+ assert "ff_session_replay" in gate.group(1)
132
+ # The INSERT must be OUTSIDE the gate so other event types still write.
133
+ after_gate = body[body.index(gate.group(0)) + len(gate.group(0)):]
134
+ assert "INSERT INTO meshcode.mc_session_events" in after_gate
135
+
136
+ def test_flag_seeds_disabled(self):
137
+ seed = re.search(r"INSERT INTO meshcode\.mc_global_config.*?ON CONFLICT",
138
+ self.SQL, re.DOTALL).group(0)
139
+ assert "'ff_session_replay'" in seed
140
+ assert '"enabled": false' in seed
141
+ assert "DO NOTHING" in self.SQL.split("ON CONFLICT", 1)[1][:30]
142
+
143
+ def test_flag_rpc_exists_with_grants(self):
144
+ assert "FUNCTION public.mc_feature_flag_enabled" in self.SQL
145
+ assert re.search(
146
+ r"GRANT EXECUTE ON FUNCTION public\.mc_feature_flag_enabled.*?TO anon",
147
+ self.SQL)
148
+
149
+ def test_backend_wrapper_targets_new_rpc(self):
150
+ from meshcode.meshcode_mcp import backend as be
151
+ import inspect
152
+ src = inspect.getsource(be.feature_flag_enabled)
153
+ assert "mc_feature_flag_enabled" in src
File without changes
File without changes
File without changes
File without changes