meshcode 2.11.145__tar.gz → 2.11.148__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 (107) hide show
  1. {meshcode-2.11.145 → meshcode-2.11.148}/PKG-INFO +1 -1
  2. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/__init__.py +1 -1
  3. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/hostd.py +222 -12
  4. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/meshcode_mcp/server.py +38 -6
  5. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/up.py +15 -1
  6. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode.egg-info/PKG-INFO +1 -1
  7. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode.egg-info/SOURCES.txt +2 -0
  8. {meshcode-2.11.145 → meshcode-2.11.148}/pyproject.toml +1 -1
  9. meshcode-2.11.148/tests/test_hostd_launch_pinned_env.py +127 -0
  10. meshcode-2.11.148/tests/test_hostd_serve_discovery_split.py +149 -0
  11. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_security_regressions.py +54 -0
  12. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_terminal_lifecycle.py +41 -0
  13. {meshcode-2.11.145 → meshcode-2.11.148}/README.md +0 -0
  14. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/__main__.py +0 -0
  15. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/_session_handoff_template.py +0 -0
  16. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/_stop_hook_template.py +0 -0
  17. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/ascii_art.py +0 -0
  18. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/atomic_push.py +0 -0
  19. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/claude_update.py +0 -0
  20. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/cli.py +0 -0
  21. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/comms_v4.py +0 -0
  22. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/compat.py +0 -0
  23. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/daemon.py +0 -0
  24. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/date_parse.py +0 -0
  25. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/doctor.py +0 -0
  26. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/error_hints.py +0 -0
  27. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/exceptions.py +0 -0
  28. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/helper_visuals.py +0 -0
  29. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/hooks/__init__.py +0 -0
  30. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/hooks/repo_path_lock.py +0 -0
  31. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/invites.py +0 -0
  32. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/launcher.py +0 -0
  33. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/launcher_install.py +0 -0
  34. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/meshcode_mcp/__init__.py +0 -0
  35. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/meshcode_mcp/__main__.py +0 -0
  36. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/meshcode_mcp/backend.py +0 -0
  37. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/meshcode_mcp/realtime.py +0 -0
  38. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/meshcode_mcp/sleep_signals.py +0 -0
  39. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/meshcode_mcp/swarm.py +0 -0
  40. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/meshcode_mcp/test_backend.py +0 -0
  41. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/meshcode_mcp/test_boot_timing.py +0 -0
  42. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/meshcode_mcp/test_install_guard.py +0 -0
  43. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/meshcode_mcp/test_prefs_claude_version.py +0 -0
  44. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  45. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
  46. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/meshcode_mcp/test_swarm.py +0 -0
  47. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/preferences.py +0 -0
  48. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/protocol_handler.py +0 -0
  49. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/protocol_v2.py +0 -0
  50. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/quickstart.py +0 -0
  51. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/rpc_allowlist.py +0 -0
  52. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/run_agent.py +0 -0
  53. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/scripts/check_secrets.py +0 -0
  54. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/scripts/race_rate_harness.py +0 -0
  55. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/secrets.py +0 -0
  56. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/self_update.py +0 -0
  57. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/setup_clients.py +0 -0
  58. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/supervisor.py +0 -0
  59. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode/upload.py +0 -0
  60. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode.egg-info/dependency_links.txt +0 -0
  61. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode.egg-info/entry_points.txt +0 -0
  62. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode.egg-info/requires.txt +0 -0
  63. {meshcode-2.11.145 → meshcode-2.11.148}/meshcode.egg-info/top_level.txt +0 -0
  64. {meshcode-2.11.145 → meshcode-2.11.148}/setup.cfg +0 -0
  65. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_auto_update_hardening.py +0 -0
  66. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_autonomous_closegap_1.py +0 -0
  67. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_autonomous_closegap_2.py +0 -0
  68. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_autonomous_closegap_3.py +0 -0
  69. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_autonomous_prompt_inject.py +0 -0
  70. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_boot_bug_regression.py +0 -0
  71. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_color_truecolor.py +0 -0
  72. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_core.py +0 -0
  73. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_cross_agent_messaging.py +0 -0
  74. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_date_parse.py +0 -0
  75. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_doctor.py +0 -0
  76. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_epistemic_v1_python_sdk.py +0 -0
  77. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_epistemic_v1_stop_conditions.py +0 -0
  78. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_esc_deaf_state.py +0 -0
  79. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_exceptions.py +0 -0
  80. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_file_upload.py +0 -0
  81. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_helper_visuals.py +0 -0
  82. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_hostd_zombie_sessions.py +0 -0
  83. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_init_device_code.py +0 -0
  84. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_install_guard.py +0 -0
  85. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_lease_sigterm_release.py +0 -0
  86. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_live_mesh_guard.py +0 -0
  87. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_mark_read_batch.py +0 -0
  88. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_marketplace_ratings.py +0 -0
  89. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_migration_integrity.py +0 -0
  90. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_pretrust_claude.py +0 -0
  91. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_realtime_event_freshness.py +0 -0
  92. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_rls_cross_tenant.py +0 -0
  93. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_rpc_grants.py +0 -0
  94. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_rpc_migrations.py +0 -0
  95. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_run_agent_dry_run.py +0 -0
  96. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_run_agent_no_server_import.py +0 -0
  97. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_self_update_user_site.py +0 -0
  98. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_sentinel.py +0 -0
  99. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_session_replay_gate.py +0 -0
  100. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_setup_path.py +0 -0
  101. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_sleep_signals.py +0 -0
  102. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_status_enum_coverage.py +0 -0
  103. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_stay_on_loop_hook.py +0 -0
  104. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_stop_ghost_terminal.py +0 -0
  105. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_swarm_events.py +0 -0
  106. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_task_progress.py +0 -0
  107. {meshcode-2.11.145 → meshcode-2.11.148}/tests/test_wait_open_tasks_contradiction.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 2.11.145
3
+ Version: 2.11.148
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -1,5 +1,5 @@
1
1
  """MeshCode — Real-time communication between AI agents."""
2
- __version__ = "2.11.145"
2
+ __version__ = "2.11.148"
3
3
 
4
4
  # Exception hierarchy — eagerly imported (lightweight, no deps)
5
5
  from meshcode.exceptions import ( # noqa: F401
@@ -534,6 +534,31 @@ def _host_cfg(api_key: str, host_id: str) -> Optional[dict]:
534
534
  return cfg
535
535
 
536
536
 
537
+ def _agent_desired_state_fresh(api_key: str, host_id: str, target: str) -> Optional[str]:
538
+ """UNCACHED single-roster read of one target's current desired_state, for the
539
+ F1 stop-vs-respawn pre-spawn re-check (task 8d9950a7).
540
+
541
+ mc_agents_needing_respawn is read ONCE at the top of a respawn sweep; a Stop that
542
+ commits AFTER that read (mc_agent_power -> desired_state='stopped') is invisible to
543
+ the stale candidate snapshot, so hostd would re-open a terminal the user just closed
544
+ (ghost). This deliberately BYPASSES _host_cfg's per-sweep TTL cache (which can predate
545
+ the Stop) and hits mc_host_config_get directly so the re-check sees committed Stops.
546
+ Bounded cost: only candidates that pass every other guard reach this, capped by
547
+ _MAX_SPAWNS_PER_SWEEP (~2-3) per sweep.
548
+
549
+ Returns the desired_state string, or None if the roster can't be read / the agent is
550
+ absent — callers FAIL OPEN (the reordered stop sweep + _do_stopped_ghost_sweep are the
551
+ backstop; a transient roster-read error must not freeze all respawns)."""
552
+ cfg = _rpc("mc_host_config_get", {"p_api_key": api_key, "p_host_id": host_id})
553
+ if not cfg or not cfg.get("ok"):
554
+ return None
555
+ for a in (cfg.get("agents") or []):
556
+ proj, agent = a.get("project_name"), a.get("name")
557
+ if proj and agent and f"{proj}/{agent}" == target:
558
+ return a.get("desired_state")
559
+ return None
560
+
561
+
537
562
  # ------------------------------------------------------------------
538
563
  # Respawn
539
564
  # ------------------------------------------------------------------
@@ -596,6 +621,66 @@ def _validate_repo_path(rp) -> Optional[str]:
596
621
  return p
597
622
 
598
623
 
624
+ def _default_bindir() -> str:
625
+ """bin/Scripts dir of hostd's own interpreter — the legacy PATH prepend."""
626
+ try:
627
+ return str(Path(sys.executable).resolve().parent)
628
+ except Exception:
629
+ return ""
630
+
631
+
632
+ def _resolve_launch_python() -> tuple:
633
+ """Interpreter hostd should use to launch `meshcode run` (task 07a571f8).
634
+
635
+ THE version-split root cause: hostd's own ``sys.executable`` is whatever
636
+ python started the daemon — on Samuel's box that was the USER-SITE install
637
+ (2.11.144) while every agent's .mcp.json pinned the immutable env
638
+ (2.11.145). Launching ``meshcode run`` with ``sys.executable`` therefore ran
639
+ the STALE user-site code for the entire run-wrapper + prekill/dedup path, so
640
+ a perfect fix shipped to PyPI did NOTHING until user-site was manually
641
+ upgraded — the durable "fix exists but bug persists" trap, and the source of
642
+ Samuel's double-launches (stale hostd lacks the dedup fix) + loop-drop
643
+ (runtime != MESHCODE_EXPECTED_VERSION breaks the meshcode_* tool-bind).
644
+
645
+ Fix: resolve the SAME pinned env the agent's MCP serve already uses
646
+ (self_update.resolve_target_version -> ensure_versioned_env) and launch the
647
+ run-wrapper from THERE, so the launch path can never lag user-site.
648
+
649
+ Offline-safe fallback to ``sys.executable`` (legacy behavior) ONLY when the
650
+ pin cannot be resolved or built: editable/dev install, updates disabled,
651
+ offline (no version resolved), or env-build failure. We never hard-break a
652
+ spawn, but every fallback is LOGGED loudly so a silent split is impossible.
653
+
654
+ Returns ``(python_exe, bindir, pinned_version_or_None)``.
655
+ """
656
+ try:
657
+ from meshcode import self_update as _su
658
+ # Dev / opt-out installs: never repoint, keep current interpreter.
659
+ if _su.update_disabled() or _su.is_editable_install():
660
+ return sys.executable, _default_bindir(), None
661
+ target = _su.resolve_target_version()
662
+ if not target:
663
+ _log("launch-py: no target version resolved (offline?) -> "
664
+ "sys.executable (legacy)")
665
+ return sys.executable, _default_bindir(), None
666
+ py = _su.ensure_versioned_env(target, verbose=False)
667
+ if py is None or not Path(py).exists():
668
+ _log(f"WARN: launch-py: could not build/resolve env for meshcode "
669
+ f"{target} -> falling back to sys.executable ({sys.executable}); "
670
+ f"possible version split until the env finalizes (hostd retries "
671
+ f"on the next sweep)")
672
+ return sys.executable, _default_bindir(), None
673
+ # refuse-stale-spawn: never launch from an env that is not the target.
674
+ if _su._env_version(str(py)) != target:
675
+ _log(f"WARN: launch-py: env at {py} reports != {target} -> "
676
+ f"sys.executable (legacy)")
677
+ return sys.executable, _default_bindir(), None
678
+ return str(py), str(Path(py).resolve().parent), target
679
+ except Exception as e:
680
+ _log(f"WARN: launch-py resolution failed ({e}) -> sys.executable")
681
+ return sys.executable, _default_bindir(), None
682
+
683
+
599
684
  def _spawn_agent(project: str, agent: str, repo_path=None) -> bool:
600
685
  """Relaunch `meshcode run <project>/<agent>` in a VISIBLE terminal window.
601
686
 
@@ -615,6 +700,16 @@ def _spawn_agent(project: str, agent: str, repo_path=None) -> bool:
615
700
  + the respawn RPC carry it.
616
701
  """
617
702
  target = f"{project}/{agent}"
703
+ # C3 RCE GATE (task f9f4a2d3): project/agent are interpolated into a cmd.exe '&'-chain
704
+ # (Windows) / POSIX shell below. Candidates originate from mc_agents_needing_respawn
705
+ # (DB-sourced) — a poisoned row must NEVER reach the shell. Same allowlist as the launch
706
+ # path; this is the sink-level backstop (covers every caller of _spawn_agent).
707
+ from meshcode.protocol_handler import is_valid_agent_name
708
+ if not (is_valid_agent_name(project) and is_valid_agent_name(agent)):
709
+ _log(f"WARN: refusing to spawn {target!r}: project/agent failed the "
710
+ f"is_valid_agent_name allowlist (shell-unsafe chars) — possible injection, "
711
+ f"NOT spawning. [spawn_blocked_reason=invalid_name]")
712
+ return False
618
713
  repo = _validate_repo_path(repo_path)
619
714
  # env hygiene (item1 RC): a stale CLAUDECODE aborts `meshcode run` (exit2). Build the terminal
620
715
  # command PER-PLATFORM (mesh-core FIX1, 0x80070002): cmd.exe uses & (NOT ';'), set "V=" to clear
@@ -624,12 +719,15 @@ def _spawn_agent(project: str, agent: str, repo_path=None) -> bool:
624
719
  # via `python -m meshcode` (NOT the .exe shim) so a bg `pip install -U` can replace meshcode.exe.
625
720
  # RELIABILITY (commander, REOPEN_PATH_bug): the VISIBLE spawn must also put the meshcode
626
721
  # console script on PATH (a8efddeb fixed only the headless branch). Without it the spawned
627
- # agent + its `meshcode mcp` child die with "meshcode not on PATH". Prepend sys.executable's
628
- # bin dir, mirroring the headless POSIX fix + the win32 venv-Scripts injection.
629
- try:
630
- _bindir = str(Path(sys.executable).resolve().parent)
631
- except Exception:
632
- _bindir = ""
722
+ # agent + its `meshcode mcp` child die with "meshcode not on PATH". Prepend the launch
723
+ # interpreter's bin dir, mirroring the headless POSIX fix + the win32 venv-Scripts injection.
724
+ # task 07a571f8: launch from the PINNED env python (not hostd's own sys.executable) so the
725
+ # run-wrapper can never lag user-site — _resolve_launch_python falls back to sys.executable
726
+ # (legacy) offline/dev/build-fail, always logging. _bindir follows the chosen interpreter so
727
+ # PATH + the meshcode console script match the runtime that actually serves.
728
+ _launch_py, _bindir, _pin_ver = _resolve_launch_python()
729
+ if _pin_ver:
730
+ _log(f"launch {target}: pinned env meshcode {_pin_ver} ({_launch_py})")
633
731
  if sys.platform == "win32":
634
732
  # repo already validated (no '"'/control chars, real dir) -> safe to double-quote.
635
733
  _repo_win = f' --repo "{repo}"' if repo else ''
@@ -640,14 +738,14 @@ def _spawn_agent(project: str, agent: str, repo_path=None) -> bool:
640
738
  # re-arming would override a human Stop clicked mid-spawn).
641
739
  f'set "MESHCODE_HOSTD_SPAWN=1" & '
642
740
  + (f'set "PATH={_bindir};%PATH%" & ' if _bindir else '')
643
- + f'"{sys.executable}" -m meshcode run "{target}"{_repo_win}')
741
+ + f'"{_launch_py}" -m meshcode run "{target}"{_repo_win}')
644
742
  else:
645
743
  _repo_posix = f" --repo {shlex.quote(repo)}" if repo else ''
646
744
  cmd = (f"unset CLAUDECODE CLAUDE_CODE_SESSION MESHCODE_NO_UPDATE MESHCODE_NO_AUTO_UPDATE; "
647
745
  # task 01e2b5c1: see win32 branch — hostd spawns skip run-intent.
648
746
  + f"export MESHCODE_HOSTD_SPAWN=1; "
649
747
  + (f"export PATH={shlex.quote(_bindir)}:$PATH; " if _bindir else "")
650
- + f"exec {shlex.quote(sys.executable)} -m meshcode run {shlex.quote(target)}{_repo_posix}")
748
+ + f"exec {shlex.quote(_launch_py)} -m meshcode run {shlex.quote(target)}{_repo_posix}")
651
749
  try:
652
750
  from meshcode import protocol_handler as _ph
653
751
  ok, info = _ph._spawn_terminal(cmd)
@@ -687,6 +785,10 @@ _DISCOVERY_ERR_LOGGED: set = set()
687
785
  # so the ORPHAN-CLAIM line prints once per agent instead of every ~10s sweep.
688
786
  _ORPHAN_CLAIMED_LOGGED: set = set()
689
787
 
788
+ # C3 (task f9f4a2d3): names rejected by the is_valid_agent_name gate, logged once per
789
+ # name per daemon session so a poisoned DB row raises a visible alert without flooding.
790
+ _INVALID_NAME_LOGGED: set = set()
791
+
690
792
 
691
793
  def _do_respawns(api_key: str, host_id: str) -> int:
692
794
  """One respawn sweep. Returns number relaunched."""
@@ -740,6 +842,18 @@ def _do_respawns(api_key: str, host_id: str) -> int:
740
842
  proj, agent = c.get("project_name"), c.get("agent")
741
843
  if not proj or not agent:
742
844
  continue
845
+ # C3 RCE GATE (task f9f4a2d3): reject DB-sourced candidate names outside the strict
846
+ # allowlist BEFORE any reconcile/spawn — defense in depth with the _spawn_agent sink
847
+ # guard, and a clean once-per-name skip log so a poisoned row can't storm.
848
+ from meshcode.protocol_handler import is_valid_agent_name
849
+ if not (is_valid_agent_name(proj) and is_valid_agent_name(agent)):
850
+ _bn = f"{proj}/{agent}"
851
+ if _bn not in _INVALID_NAME_LOGGED:
852
+ _INVALID_NAME_LOGGED.add(_bn)
853
+ _log(f"SKIP respawn {proj!r}/{agent!r}: invalid project/agent name "
854
+ f"(is_valid_agent_name allowlist) — refusing a shell-unsafe candidate. "
855
+ f"[respawn_blocked_reason=invalid_name]")
856
+ continue
743
857
  if not c.get("respawn_allowed", True):
744
858
  # mig404: not allowed = rate-limited (<60s since last respawn) or at the cap.
745
859
  # mc_record_respawn v2 sets desired_state='crashed' ATOMICALLY at the cap, so we
@@ -1038,6 +1152,23 @@ def _do_respawns(api_key: str, host_id: str) -> int:
1038
1152
  # removed, task cba01de5), so this always runs for a recycle. (With the pre-spawn
1039
1153
  # reconciliation above this set is normally already empty — kept as backstop.)
1040
1154
  _old_vis_pids = _discover_agent_pids(_target) if _is_recycle else []
1155
+ # F1 STOP-VS-RESPAWN re-check (task 8d9950a7): the candidate snapshot from
1156
+ # mc_agents_needing_respawn was taken at the top of this sweep. Re-read
1157
+ # desired_state FRESH (uncached) at the last moment before opening a terminal —
1158
+ # if a Stop landed during this sweep (desired_state != 'running'), do NOT spawn:
1159
+ # the stop/ghost sweeps own the teardown. Without this, clicking Stop in the
1160
+ # respawn window re-opens the terminal the user just closed (ghost). Fail OPEN
1161
+ # (None = unreadable roster) so a transient RPC error can't freeze respawns.
1162
+ _ds_now = _agent_desired_state_fresh(api_key, host_id, _target)
1163
+ if _ds_now is not None and _ds_now != "running":
1164
+ _log(f"SKIP {'recycle-' if _is_recycle else ''}respawn {_target}: desired_state="
1165
+ f"{_ds_now} on fresh pre-spawn re-read (Stop landed during this sweep) — "
1166
+ f"NOT opening a terminal. [respawn_blocked_reason=stopped_mid_sweep]")
1167
+ _log_respawn_event(api_key, host_id, c,
1168
+ "recycle_respawn" if _is_recycle else "respawn",
1169
+ "failed", "stopped_mid_sweep",
1170
+ detail=f"desired_state={_ds_now} at pre-spawn re-read")
1171
+ continue
1041
1172
  # DEAD-WINDOW REAP (task 70ac6d25, Samuel hit it twice 2026-06-09): an
1042
1173
  # exit-for-respawn rotation takes THIS plain-respawn path, and the old
1043
1174
  # window — whose launch script predates the .119 in-script self-closer,
@@ -1772,12 +1903,66 @@ def _discover_serve_pids(target: str) -> list:
1772
1903
  return _discover_serve_pids_ex(target)["pids"]
1773
1904
 
1774
1905
 
1906
+ def _serve_attributable_via_ancestor(p, target: str) -> bool:
1907
+ """Version-AGNOSTIC serve attribution (task 07a571f8 [B]).
1908
+
1909
+ A serve's own cmdline (`python -m meshcode.meshcode_mcp serve`) carries no
1910
+ target, so discovery normally attributes it by the serve's ENVIRON
1911
+ (MESHCODE_PROJECT/MESHCODE_AGENT). But across an env/version split hostd
1912
+ frequently cannot READ that environ — psutil raises AccessDenied on a
1913
+ process running under a DIFFERENT venv python (hostd .144 vs the .145-env
1914
+ serve). The old code's `except: continue` then silently dropped the serve →
1915
+ serve_pids=0 → false crash_respawn over a still-live agent → doubles +
1916
+ loop-drop (qa verify 0a75d094).
1917
+
1918
+ Fallback that needs NO environ: the serve's ANCESTOR chain contains the
1919
+ `meshcode run <target>` wrapper, whose cmdline IS attributable via the same
1920
+ strict whole-token regex (`_run_token_rx`) + cwd foreign-guard used for
1921
+ agent-pid discovery. Split-proof, and strict enough that a sibling agent's
1922
+ serve can never match. Best-effort; never raises."""
1923
+ rx = _run_token_rx(target)
1924
+ anc = None
1925
+ try:
1926
+ anc = p.parent()
1927
+ except Exception:
1928
+ anc = None
1929
+ hops = 0
1930
+ while anc is not None and hops < 6:
1931
+ hops += 1
1932
+ try:
1933
+ acl = " ".join(anc.cmdline() or [])
1934
+ if rx.search(acl):
1935
+ try:
1936
+ acwd = anc.cwd() or ""
1937
+ except Exception:
1938
+ acwd = ""
1939
+ if not _bare_match_is_foreign(acl, acwd, target):
1940
+ return True
1941
+ except Exception:
1942
+ pass
1943
+ try:
1944
+ anc = anc.parent()
1945
+ except Exception:
1946
+ break
1947
+ return False
1948
+
1949
+
1775
1950
  def _discover_serve_pids_ex(target: str) -> dict:
1776
1951
  """Instrumented serve discovery (task 89d50a14 [B]) — same result contract as
1777
1952
  _discover_agent_pids_ex. psutil-missing is errored=False (degraded BY DESIGN,
1778
1953
  documented since 14e0760c: the session-root tree kill still covers parented
1779
1954
  serves); the enumeration itself raising is errored=True (empty result is
1780
- untrustworthy → spawn decisions fail closed)."""
1955
+ untrustworthy → spawn decisions fail closed).
1956
+
1957
+ VERSION-SPLIT HARDENING (task 07a571f8 [B]): attribution is no longer purely
1958
+ environ-based. A serve whose environ is UNREADABLE (the cross-venv
1959
+ AccessDenied that made the .145-env serve invisible to a .144 hostd) is
1960
+ attributed via its `meshcode run <target>` ancestor instead
1961
+ (_serve_attributable_via_ancestor) — version-agnostic. A real meshcode_mcp
1962
+ serve we can NEITHER confirm (env/ancestor) NOR refute (env names a
1963
+ different agent) is treated as AMBIGUOUS → errored=True so the SPAWN-AUDIT
1964
+ fails CLOSED (skips the respawn) rather than respawning over a possibly-live
1965
+ session. This is the fix for "serve_pids=0 across a split → false respawn"."""
1781
1966
  ps = _psutil()
1782
1967
  if ps is None:
1783
1968
  return {"pids": [], "errored": False, "path": "none(psutil-missing)", "error": ""}
@@ -1786,6 +1971,7 @@ def _discover_serve_pids_ex(target: str) -> dict:
1786
1971
  return {"pids": [], "errored": False, "path": "psutil", "error": ""}
1787
1972
  own = _own_pid()
1788
1973
  out = []
1974
+ ambiguous = False
1789
1975
  try:
1790
1976
  for p in ps.process_iter():
1791
1977
  try:
@@ -1794,14 +1980,38 @@ def _discover_serve_pids_ex(target: str) -> dict:
1794
1980
  cl = " ".join(p.cmdline() or [])
1795
1981
  if "meshcode" not in cl or "serve" not in cl or "meshcode_mcp" not in cl:
1796
1982
  continue
1797
- env = p.environ() or {}
1798
- if env.get("MESHCODE_AGENT") == agent and \
1799
- (not proj or env.get("MESHCODE_PROJECT") == proj):
1983
+ # Precise positive attribution by the serve's own env, when readable.
1984
+ env = None
1985
+ try:
1986
+ env = p.environ()
1987
+ except Exception:
1988
+ env = None # AccessDenied (cross-venv split) — fall through.
1989
+ if env is not None:
1990
+ if env.get("MESHCODE_AGENT") == agent and \
1991
+ (not proj or env.get("MESHCODE_PROJECT") == proj):
1992
+ out.append(p.pid)
1993
+ continue
1994
+ if env.get("MESHCODE_AGENT"):
1995
+ continue # env names a DIFFERENT agent — definitively not ours.
1996
+ # env unreadable (or present without MESHCODE_AGENT): version-agnostic
1997
+ # ancestor attribution before giving up.
1998
+ if _serve_attributable_via_ancestor(p, target):
1800
1999
  out.append(p.pid)
2000
+ continue
2001
+ # A real meshcode_mcp serve we could neither confirm nor refute —
2002
+ # only when its environ was UNREADABLE (the split signature). Never
2003
+ # treat it as silently absent: mark ambiguous → fail closed below.
2004
+ if env is None:
2005
+ ambiguous = True
1801
2006
  except Exception:
1802
2007
  continue # process vanished / access denied — never block the sweep
1803
2008
  except Exception as e:
1804
2009
  return {"pids": [], "errored": True, "path": "psutil", "error": f"psutil: {e!r}"}
2010
+ if ambiguous and not out:
2011
+ return {"pids": [], "errored": True, "path": "psutil",
2012
+ "error": ("serve matched by cmdline but environ unreadable and no "
2013
+ "attributable `meshcode run <target>` ancestor — attribution "
2014
+ "ambiguous, failing closed (task 07a571f8 [B])")}
1805
2015
  return {"pids": out, "errored": False, "path": "psutil", "error": ""}
1806
2016
 
1807
2017
 
@@ -969,6 +969,15 @@ _current_tool = ""
969
969
  _IDLE_THRESHOLD_S = 120 # seconds without tool call → IDLE
970
970
  _SLEEPING_THRESHOLD_S = 300 # seconds in waiting without activity → SLEEPING
971
971
  _WORKING_COOLDOWN_S = 60 # seconds after last tool returns before flipping to ONLINE
972
+
973
+ # ── P1 heartbeat throttle (2.11.148) ──
974
+ # When status+task haven't changed, skip the full mc_agents UPDATE and
975
+ # write only a cheap mc_heartbeats ping. Idle agents go from ~every 5s
976
+ # full-row writes to ~every 15s, staying under the 20s-live / 90s-offline
977
+ # thresholds while cutting Realtime fan-out ~3×.
978
+ _HB_THROTTLE_S = 15.0
979
+ _hb_last_state: "tuple[str, str] | None" = None
980
+ _hb_last_full_write: float = 0.0
972
981
  _working_timer: Optional[_threading.Timer] = None
973
982
 
974
983
 
@@ -2564,8 +2573,18 @@ def _heartbeat_loop_inner():
2564
2573
  # no longer wired to a status write.
2565
2574
 
2566
2575
  # Sync current state to DB (in case realtime missed it)
2576
+ # P1 throttle (2.11.148): skip full mc_agents UPDATE when state
2577
+ # hasn't changed recently — write cheap mc_heartbeats only.
2578
+ global _hb_last_state, _hb_last_full_write
2579
+ _hb_cur = (_current_state, _current_tool)
2580
+ _hb_now = _time.monotonic()
2567
2581
  try:
2568
- be.set_status(_PROJECT_ID, AGENT_NAME, _current_state, _current_tool, api_key=_get_api_key())
2582
+ if _hb_cur == _hb_last_state and (_hb_now - _hb_last_full_write) < _HB_THROTTLE_S:
2583
+ be.heartbeat(_PROJECT_ID, AGENT_NAME)
2584
+ else:
2585
+ be.set_status(_PROJECT_ID, AGENT_NAME, _current_state, _current_tool, api_key=_get_api_key())
2586
+ _hb_last_state = _hb_cur
2587
+ _hb_last_full_write = _hb_now
2569
2588
  except Exception as e:
2570
2589
  log.warning(f"status sync failed (agent may show stale status): {e}")
2571
2590
  # Realtime subscription recovery: if the WebSocket is connected but
@@ -6458,11 +6477,19 @@ def meshcode_boot() -> Dict[str, Any]:
6458
6477
  _overdue.append(_t)
6459
6478
  else:
6460
6479
  _due_today.append(_t)
6480
+ def _compact_due(_t):
6481
+ return {
6482
+ "id": str(_t.get("id", ""))[:8],
6483
+ "title": str(_t.get("title", ""))[:80],
6484
+ "due_at": _t.get("due_at"),
6485
+ "priority": _t.get("priority"),
6486
+ }
6487
+ _due_all = _due_today + _overdue
6461
6488
  calendar_block = {
6462
6489
  "pending_due_today": len(_due_today),
6463
6490
  "overdue": len(_overdue),
6464
- "next_due": (_due_today[0] if _due_today else (_overdue[0] if _overdue else None)),
6465
- "due_tasks": _due_today + _overdue,
6491
+ "next_due": (_compact_due(_due_all[0]) if _due_all else None),
6492
+ "due_tasks": [_compact_due(_t) for _t in _due_all],
6466
6493
  }
6467
6494
  resp["calendar_context"] = calendar_block
6468
6495
  except Exception:
@@ -7339,12 +7366,17 @@ def meshcode_seed_fixture(
7339
7366
  ttl_seconds: int = 3600,
7340
7367
  ) -> Dict[str, Any]:
7341
7368
  """Seed a fixture row in a whitelisted table for Playwright/verify.
7342
- MESH-IMPROVE-7 (mig 345). Whitelist: mc_tasks, mc_messages, mc_agents.
7369
+ MESH-IMPROVE-7 (mig 345). Whitelist: mc_tasks, mc_messages, mc_agents, mc_agent_visits.
7343
7370
  Auto-swept after ttl_seconds (min 60). Pair with meshcode_release_fixture.
7344
7371
  """
7345
- if table not in ("mc_tasks", "mc_messages", "mc_agents"):
7372
+ # Single source of truth so the docstring / guard / hint can't drift apart again
7373
+ # (task 6cc2cabc — the literal was triplicated and went stale). mc_agent_visits added
7374
+ # to sync with mig588 (mc_sweep_stale_agent_visits) for the agent-visit e2e; the
7375
+ # server-side mc_seed_fixture RPC whitelist must allow it too (mesh-core task 64c73406).
7376
+ _SEEDABLE = ("mc_tasks", "mc_messages", "mc_agents", "mc_agent_visits")
7377
+ if table not in _SEEDABLE:
7346
7378
  return {"error": "table not seedable",
7347
- "hint": "whitelist: mc_tasks, mc_messages, mc_agents"}
7379
+ "hint": "whitelist: " + ", ".join(_SEEDABLE)}
7348
7380
  return be.sb_rpc("mc_seed_fixture", {
7349
7381
  "p_api_key": _get_api_key(),
7350
7382
  "p_project_id": _PROJECT_ID,
@@ -139,8 +139,10 @@ def _launch_cmd(project: str, agent: str) -> str:
139
139
  if platform.system() == "Windows":
140
140
  # `set X=` clears; `&` chains; spawner wraps with cmd /c + self-closing
141
141
  # epilogue (task a4001d59 — tab closes itself on graceful exit/Stop)
142
+ # target is allowlist-validated upstream (cmd_up); double-quote it too so a
143
+ # cmd.exe metacharacter can never chain even if a future caller skips the gate.
142
144
  return (f'set "CLAUDECODE="&set "CLAUDE_CODE_SESSION="&'
143
- f'set "MESHCODE_NO_AUTO_UPDATE=1"&{mc} run {target}')
145
+ f'set "MESHCODE_NO_AUTO_UPDATE=1"&{mc} run "{target}"')
144
146
  # POSIX: inline env assignments before the command
145
147
  return (f'CLAUDECODE= CLAUDE_CODE_SESSION= MESHCODE_NO_AUTO_UPDATE=1 '
146
148
  f'{shlex.quote(mc)} run {shlex.quote(target)}')
@@ -213,6 +215,18 @@ def cmd_up(args: List[str]) -> int:
213
215
  "error_code": "no_roster", "project": project}))
214
216
  return 1
215
217
 
218
+ # C3 RCE GATE (task f9f4a2d3 / security_vuln_map): project + agent names flow into
219
+ # _launch_cmd -> a cmd.exe '&'-chain on Windows AND are registered into mc_agents
220
+ # (where hostd later respawns them). Hard-reject anything outside the strict allowlist
221
+ # HERE, before either sink — the same boundary cmd_launch_batch + run_agent enforce.
222
+ from meshcode.protocol_handler import is_valid_agent_name
223
+ _bad = [n for n in ([project, *agents]) if not is_valid_agent_name(n)]
224
+ if _bad:
225
+ print(json.dumps({"ok": False,
226
+ "error": f"invalid project/agent name(s) rejected: {_bad}",
227
+ "error_code": "invalid_name"}))
228
+ return 1
229
+
216
230
  host_id = _host_id()
217
231
  rmode, rvalue = _recycle_to_mode_value(recycle_arg) if recycle_arg else (None, None)
218
232
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 2.11.145
3
+ Version: 2.11.148
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -73,6 +73,8 @@ tests/test_esc_deaf_state.py
73
73
  tests/test_exceptions.py
74
74
  tests/test_file_upload.py
75
75
  tests/test_helper_visuals.py
76
+ tests/test_hostd_launch_pinned_env.py
77
+ tests/test_hostd_serve_discovery_split.py
76
78
  tests/test_hostd_zombie_sessions.py
77
79
  tests/test_init_device_code.py
78
80
  tests/test_install_guard.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "meshcode"
7
- version = "2.11.145"
7
+ version = "2.11.148"
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,127 @@
1
+ """Offline unit tests for the hostd launch-path version-split fix (task 07a571f8).
2
+
3
+ Stdlib only (unittest + mock), zero network, zero real env builds. Contract:
4
+
5
+ THE BUG: hostd launches `meshcode run` via its OWN sys.executable. On
6
+ Samuel's box hostd ran the user-site install (2.11.144) while every agent's
7
+ .mcp.json pinned the immutable env (2.11.145), so the run-wrapper + the
8
+ prekill/dedup path ran STALE code — a PyPI fix did nothing until user-site
9
+ was manually upgraded (double-launches + loop-drop traced here).
10
+
11
+ THE FIX (_resolve_launch_python): resolve the SAME pinned env the agent's
12
+ MCP serve uses and launch the wrapper from there, with an OFFLINE-SAFE,
13
+ ALWAYS-LOGGED fallback to sys.executable on dev/opt-out/offline/build-fail.
14
+
15
+ Asserts the decision matrix of _resolve_launch_python AND that _spawn_agent's
16
+ emitted command actually invokes the resolved interpreter (not sys.executable)
17
+ when a pin is available.
18
+ """
19
+ import sys
20
+ import unittest
21
+ from unittest import mock
22
+
23
+ from meshcode import hostd
24
+
25
+ PINNED_PY = (r"C:\Users\Usuario\.meshcode\envs\2.11.145\Scripts\python.exe"
26
+ if sys.platform == "win32"
27
+ else "/home/u/.meshcode/envs/2.11.145/bin/python3")
28
+ TARGET = "2.11.145"
29
+
30
+
31
+ def _fake_su(**over):
32
+ """Build a fake self_update module with sane 'happy path' defaults."""
33
+ m = mock.MagicMock(name="self_update")
34
+ m.update_disabled.return_value = over.get("update_disabled", False)
35
+ m.is_editable_install.return_value = over.get("is_editable_install", False)
36
+ m.resolve_target_version.return_value = over.get("resolve_target_version", TARGET)
37
+ m.ensure_versioned_env.return_value = over.get("ensure_versioned_env", PINNED_PY)
38
+ m._env_version.return_value = over.get("_env_version", TARGET)
39
+ return m
40
+
41
+
42
+ class ResolveLaunchPython(unittest.TestCase):
43
+ def _run(self, su, py_exists=True):
44
+ # Patch the lazily-imported `from meshcode import self_update as _su`.
45
+ with mock.patch.dict(sys.modules, {"meshcode.self_update": su}), \
46
+ mock.patch.object(hostd.Path, "exists", lambda self: py_exists):
47
+ return hostd._resolve_launch_python()
48
+
49
+ def test_happy_path_uses_pinned_env(self):
50
+ py, bindir, ver = self._run(_fake_su())
51
+ self.assertEqual(py, PINNED_PY)
52
+ self.assertEqual(ver, TARGET)
53
+ self.assertTrue(bindir) # env's Scripts/bin dir, not empty
54
+
55
+ def test_editable_install_keeps_sys_executable(self):
56
+ py, _bindir, ver = self._run(_fake_su(is_editable_install=True))
57
+ self.assertEqual(py, sys.executable)
58
+ self.assertIsNone(ver)
59
+
60
+ def test_update_disabled_keeps_sys_executable(self):
61
+ py, _bindir, ver = self._run(_fake_su(update_disabled=True))
62
+ self.assertEqual(py, sys.executable)
63
+ self.assertIsNone(ver)
64
+
65
+ def test_offline_no_version_falls_back(self):
66
+ py, _bindir, ver = self._run(_fake_su(resolve_target_version=None))
67
+ self.assertEqual(py, sys.executable)
68
+ self.assertIsNone(ver)
69
+
70
+ def test_env_build_failure_falls_back(self):
71
+ py, _bindir, ver = self._run(_fake_su(ensure_versioned_env=None))
72
+ self.assertEqual(py, sys.executable)
73
+ self.assertIsNone(ver)
74
+
75
+ def test_stale_env_version_refused(self):
76
+ # env exists but reports a different version -> refuse-stale-spawn.
77
+ py, _bindir, ver = self._run(_fake_su(_env_version="2.11.144"))
78
+ self.assertEqual(py, sys.executable)
79
+ self.assertIsNone(ver)
80
+
81
+ def test_resolution_exception_falls_back(self):
82
+ broken = _fake_su()
83
+ broken.resolve_target_version.side_effect = RuntimeError("boom")
84
+ py, _bindir, ver = self._run(broken)
85
+ self.assertEqual(py, sys.executable)
86
+ self.assertIsNone(ver)
87
+
88
+
89
+ class SpawnAgentUsesResolvedInterpreter(unittest.TestCase):
90
+ """_spawn_agent must put the RESOLVED interpreter in the terminal command,
91
+ never hostd's sys.executable, when a pin is available."""
92
+
93
+ def _capture_cmd(self, resolved):
94
+ captured = {}
95
+
96
+ def fake_spawn(cmd):
97
+ captured["cmd"] = cmd
98
+ return True, "fake-terminal"
99
+
100
+ fake_ph = mock.MagicMock()
101
+ fake_ph._spawn_terminal.side_effect = fake_spawn
102
+ with mock.patch.object(hostd, "_resolve_launch_python", return_value=resolved), \
103
+ mock.patch.object(hostd, "_validate_repo_path", return_value=None), \
104
+ mock.patch.dict(sys.modules, {"meshcode.protocol_handler": fake_ph}):
105
+ ok = hostd._spawn_agent("mesh-core", "qa")
106
+ return ok, captured.get("cmd", "")
107
+
108
+ def test_pinned_interpreter_in_command(self):
109
+ ok, cmd = self._capture_cmd((PINNED_PY, "BINDIR", TARGET))
110
+ self.assertTrue(ok)
111
+ self.assertIn(PINNED_PY, cmd)
112
+ self.assertIn("-m meshcode run", cmd)
113
+ # the user-site interpreter must NOT be what launches the wrapper
114
+ # (only meaningful when they differ, which is the whole bug).
115
+ if sys.executable != PINNED_PY:
116
+ self.assertNotIn(f'"{sys.executable}" -m meshcode run', cmd)
117
+ self.assertNotIn(f"exec {sys.executable} -m meshcode run", cmd)
118
+
119
+ def test_fallback_interpreter_in_command(self):
120
+ ok, cmd = self._capture_cmd((sys.executable, "BINDIR", None))
121
+ self.assertTrue(ok)
122
+ self.assertIn("-m meshcode run", cmd)
123
+ self.assertIn(sys.executable, cmd)
124
+
125
+
126
+ if __name__ == "__main__":
127
+ unittest.main()
@@ -0,0 +1,149 @@
1
+ """Offline unit tests for the version-split serve-discovery hardening (task 07a571f8 [B]).
2
+
3
+ Stdlib only, zero network. Contract under test (qa verify 0a75d094):
4
+
5
+ Across an env/version split, hostd cannot READ the .145-env serve's environ
6
+ (psutil AccessDenied on a cross-venv process). The OLD discovery silently
7
+ dropped it → serve_pids=0 → false crash_respawn over a still-live agent →
8
+ doubles + loop-drop. The FIX makes serve attribution version-AGNOSTIC:
9
+
10
+ 1. environ readable + names THIS agent -> counted (precise, unchanged)
11
+ 2. environ readable + names ANOTHER agent -> not ours (refuted, no noise)
12
+ 3. environ UNREADABLE + `meshcode run <target>` ancestor -> counted (split-proof)
13
+ 4. environ UNREADABLE + no attributable ancestor -> AMBIGUOUS -> errored=True
14
+ (SPAWN-AUDIT fails closed; never respawns over a possibly-live session)
15
+ """
16
+ import sys
17
+ import unittest
18
+ from unittest import mock
19
+
20
+ from meshcode import hostd
21
+
22
+ HOSTD_PID = 10
23
+ TARGET = "mesh-core/qa"
24
+ SERVE_CMD = "python -m meshcode.meshcode_mcp serve"
25
+ WRAP_CMD = 'python -m meshcode run "mesh-core/qa"'
26
+
27
+
28
+ class _Proc:
29
+ def __init__(self, table, pid, ppid, cmdline, env=None, cwd="", env_raises=False):
30
+ self._t = table
31
+ self.pid = pid
32
+ self._ppid = ppid
33
+ self._cmdline = cmdline
34
+ self._env = env or {}
35
+ self._cwd = cwd
36
+ self._env_raises = env_raises
37
+
38
+ def cmdline(self):
39
+ return self._cmdline.split() if self._cmdline else []
40
+
41
+ def cwd(self):
42
+ return self._cwd
43
+
44
+ def environ(self):
45
+ if self._env_raises:
46
+ raise PermissionError("AccessDenied (cross-venv)")
47
+ return dict(self._env)
48
+
49
+ def parent(self):
50
+ p = self._t.procs.get(self._ppid)
51
+ return p if (p and p.pid in self._t.live) else None
52
+
53
+
54
+ class _Psutil:
55
+ def __init__(self):
56
+ self.procs = {}
57
+ self.live = set()
58
+
59
+ def add(self, pid, ppid, cmdline, **kw):
60
+ self.procs[pid] = _Proc(self, pid, ppid, cmdline, **kw)
61
+ self.live.add(pid)
62
+ return self.procs[pid]
63
+
64
+ def process_iter(self):
65
+ return [p for p in self.procs.values() if p.pid in self.live]
66
+
67
+
68
+ class ServeDiscoverySplit(unittest.TestCase):
69
+ def setUp(self):
70
+ self.ps = _Psutil()
71
+ self._patches = [
72
+ mock.patch.object(hostd, "_psutil", lambda: self.ps),
73
+ mock.patch.object(hostd, "_own_pid", lambda: HOSTD_PID),
74
+ ]
75
+ for p in self._patches:
76
+ p.start()
77
+
78
+ def tearDown(self):
79
+ for p in self._patches:
80
+ p.stop()
81
+
82
+ def test_env_readable_matching_agent_counted(self):
83
+ self.ps.add(100, HOSTD_PID, SERVE_CMD,
84
+ env={"MESHCODE_PROJECT": "mesh-core", "MESHCODE_AGENT": "qa"})
85
+ d = hostd._discover_serve_pids_ex(TARGET)
86
+ self.assertEqual(d["pids"], [100])
87
+ self.assertFalse(d["errored"])
88
+
89
+ def test_env_readable_other_agent_refuted(self):
90
+ self.ps.add(101, HOSTD_PID, SERVE_CMD,
91
+ env={"MESHCODE_PROJECT": "mesh-core", "MESHCODE_AGENT": "frontend"})
92
+ d = hostd._discover_serve_pids_ex(TARGET)
93
+ self.assertEqual(d["pids"], [])
94
+ self.assertFalse(d["errored"]) # cleanly not-ours, no ambiguity
95
+
96
+ def test_split_env_unreadable_attributed_via_ancestor(self):
97
+ # The .145-env serve: environ AccessDenied, but its parent is the
98
+ # `meshcode run mesh-core/qa` wrapper -> split-proof attribution.
99
+ self.ps.add(200, HOSTD_PID, WRAP_CMD, cwd="/home/u/meshcode/mesh-core-qa")
100
+ self.ps.add(201, 200, SERVE_CMD, env_raises=True)
101
+ d = hostd._discover_serve_pids_ex(TARGET)
102
+ self.assertEqual(d["pids"], [201])
103
+ self.assertFalse(d["errored"])
104
+
105
+ def test_split_env_unreadable_no_ancestor_fails_closed(self):
106
+ # Orphaned serve, environ unreadable, no `run <target>` ancestor:
107
+ # ambiguous -> errored=True so SPAWN-AUDIT skips the respawn.
108
+ self.ps.add(300, HOSTD_PID, SERVE_CMD, env_raises=True)
109
+ d = hostd._discover_serve_pids_ex(TARGET)
110
+ self.assertEqual(d["pids"], [])
111
+ self.assertTrue(d["errored"])
112
+ self.assertIn("ambiguous", d["error"])
113
+
114
+ def test_ambiguous_with_a_confirmed_pid_does_not_error(self):
115
+ # A confirmed serve already blocks the spawn (still_alive non-empty),
116
+ # so an additional ambiguous serve must NOT discard the good pid.
117
+ self.ps.add(400, HOSTD_PID, SERVE_CMD,
118
+ env={"MESHCODE_PROJECT": "mesh-core", "MESHCODE_AGENT": "qa"})
119
+ self.ps.add(401, HOSTD_PID, SERVE_CMD, env_raises=True)
120
+ d = hostd._discover_serve_pids_ex(TARGET)
121
+ self.assertEqual(d["pids"], [400])
122
+ self.assertFalse(d["errored"])
123
+
124
+ def test_sibling_wrapper_ancestor_not_matched(self):
125
+ # serve whose only ancestor runs a DIFFERENT agent must not be attributed
126
+ # to qa (strict whole-token regex) -> ambiguous (env unreadable) -> errored.
127
+ self.ps.add(500, HOSTD_PID, 'python -m meshcode run "mesh-core/frontend"',
128
+ cwd="/home/u/meshcode/mesh-core-frontend")
129
+ self.ps.add(501, 500, SERVE_CMD, env_raises=True)
130
+ d = hostd._discover_serve_pids_ex(TARGET)
131
+ self.assertEqual(d["pids"], [])
132
+ self.assertTrue(d["errored"])
133
+
134
+ def test_non_serve_processes_ignored(self):
135
+ self.ps.add(600, HOSTD_PID, 'python -m meshcode run "mesh-core/qa"') # wrapper, not a serve
136
+ self.ps.add(601, HOSTD_PID, "claude --foo")
137
+ d = hostd._discover_serve_pids_ex(TARGET)
138
+ self.assertEqual(d["pids"], [])
139
+ self.assertFalse(d["errored"])
140
+
141
+ def test_psutil_missing_degraded_not_errored(self):
142
+ with mock.patch.object(hostd, "_psutil", lambda: None):
143
+ d = hostd._discover_serve_pids_ex(TARGET)
144
+ self.assertEqual(d["pids"], [])
145
+ self.assertFalse(d["errored"])
146
+
147
+
148
+ if __name__ == "__main__":
149
+ unittest.main()
@@ -226,3 +226,57 @@ class TestP1_4_LaunchBatchAgentNameRCE:
226
226
  assert sink is not None, "cmd_launch_batch shell-interpolation sink not found (test stale?)"
227
227
  assert guard_pos < sink.start(), \
228
228
  "SECURITY: is_valid_agent_name is checked AFTER the shell interpolation — RCE boundary broken"
229
+
230
+
231
+ class TestC3_UpAndHostdSpawnRCE:
232
+ """C3 (task f9f4a2d3): two MORE sinks interpolate names into a cmd.exe '&'-chain and
233
+ were ungated (only cmd_launch_batch + run_agent validated):
234
+ - `meshcode up` -> up.cmd_up -> _launch_cmd (PoC: `meshcode up p --agents 'a&calc'`)
235
+ - hostd._do_respawns -> _spawn_agent (DB-sourced candidates from mc_agents_needing_respawn)
236
+ Lock the is_valid_agent_name gate at each sink + the Windows quoting so the fix can't regress."""
237
+
238
+ _UP = Path(__file__).parent.parent / "meshcode" / "up.py"
239
+ _HOSTD = Path(__file__).parent.parent / "meshcode" / "hostd.py"
240
+
241
+ @staticmethod
242
+ def _func_body(path: Path, name: str) -> str:
243
+ src = path.read_text(errors="replace")
244
+ start = src.index(f"def {name}")
245
+ nxt = src.find("\ndef ", start + 1)
246
+ return src[start:nxt if nxt > 0 else len(src)]
247
+
248
+ def test_cmd_up_validates_before_launch_and_db(self):
249
+ body = self._func_body(self._UP, "cmd_up")
250
+ assert "is_valid_agent_name" in body, \
251
+ "SECURITY: cmd_up does not gate names with is_valid_agent_name"
252
+ guard = body.index("is_valid_agent_name")
253
+ # must precede BOTH sinks: the shell launch AND the DB register (hostd respawns from it).
254
+ assert guard < body.index("_launch_cmd("), \
255
+ "SECURITY: cmd_up validates AFTER _launch_cmd — shell sink unprotected"
256
+ db = body.find("mc_host_set_agents")
257
+ if db != -1:
258
+ assert guard < db, \
259
+ "SECURITY: cmd_up validates AFTER registering names to mc_agents — respawn sink unprotected"
260
+
261
+ def test_launch_cmd_quotes_windows_target(self):
262
+ body = self._func_body(self._UP, "_launch_cmd")
263
+ assert 'run "{target}"' in body, \
264
+ "SECURITY: _launch_cmd Windows branch does not quote target — cmd.exe '&'-chain breakout"
265
+
266
+ def test_hostd_spawn_agent_validates_before_shell(self):
267
+ body = self._func_body(self._HOSTD, "_spawn_agent")
268
+ assert "is_valid_agent_name" in body, \
269
+ "SECURITY: _spawn_agent does not gate project/agent with is_valid_agent_name"
270
+ assert body.index("is_valid_agent_name") < body.index("-m meshcode run"), \
271
+ "SECURITY: _spawn_agent validates AFTER building the run command — RCE boundary broken"
272
+
273
+ def test_hostd_do_respawns_validates_candidates(self):
274
+ body = self._func_body(self._HOSTD, "_do_respawns")
275
+ assert "is_valid_agent_name" in body, \
276
+ "SECURITY: _do_respawns does not gate DB-sourced candidate names"
277
+
278
+ def test_c3_payloads_rejected_by_shared_gate(self):
279
+ from meshcode.protocol_handler import is_valid_agent_name
280
+ for payload in ('a&calc', 'x"&calc&', 'a&calc&', '-rf', '$(id)', 'a;b', 'a b'):
281
+ assert not is_valid_agent_name(payload), \
282
+ f"SECURITY: C3 payload not rejected by the shared gate: {payload!r}"
@@ -144,3 +144,44 @@ class TestGap3BootGrace:
144
144
  raise OSError("no ps")
145
145
  monkeypatch.setattr(hostd.subprocess, "run", boom)
146
146
  assert hostd._pid_age_s(123) >= 1e9
147
+
148
+
149
+ class TestF1StopVsRespawn:
150
+ """Stop-vs-respawn race (task 8d9950a7): mc_agents_needing_respawn is read once
151
+ per sweep; a Stop committing during the sweep must NOT be re-opened (ghost)."""
152
+
153
+ def test_helper_reads_desired_state_uncached(self):
154
+ import inspect
155
+ src = inspect.getsource(hostd._agent_desired_state_fresh)
156
+ code = re.sub(r'""".*?"""', "", src, count=1, flags=re.DOTALL)
157
+ assert "mc_host_config_get" in code, "fresh re-read must hit the roster RPC"
158
+ assert "_host_cfg" not in code, (
159
+ "must BYPASS the per-sweep TTL cache (a Stop can post-date the cached roster)")
160
+ assert "desired_state" in code
161
+
162
+ def test_helper_returns_state_and_fails_open(self, monkeypatch):
163
+ roster = {"ok": True, "agents": [
164
+ {"project_name": "mesh-dev", "name": "back", "desired_state": "stopped"},
165
+ {"project_name": "mesh-dev", "name": "front", "desired_state": "running"}]}
166
+ monkeypatch.setattr(hostd, "_rpc", lambda fn, args=None: roster)
167
+ assert hostd._agent_desired_state_fresh("k", "h", "mesh-dev/back") == "stopped"
168
+ assert hostd._agent_desired_state_fresh("k", "h", "mesh-dev/front") == "running"
169
+ # absent agent -> None (caller fails open)
170
+ assert hostd._agent_desired_state_fresh("k", "h", "mesh-dev/ghost") is None
171
+ # errored / not-ok roster -> None (caller fails open, never freezes respawns)
172
+ monkeypatch.setattr(hostd, "_rpc", lambda fn, args=None: {"ok": False})
173
+ assert hostd._agent_desired_state_fresh("k", "h", "mesh-dev/back") is None
174
+
175
+ def test_respawn_rechecks_desired_state_before_spawn(self):
176
+ import inspect
177
+ src = inspect.getsource(hostd._do_respawns)
178
+ assert "_agent_desired_state_fresh" in src, (
179
+ "_do_respawns must re-read desired_state before opening a terminal")
180
+ recheck = src.index("_agent_desired_state_fresh")
181
+ spawn = src.index("_spawn_agent(")
182
+ assert recheck < spawn, "the fresh re-read must come BEFORE the spawn call"
183
+ # the guard must SKIP (continue) when no longer desired-running
184
+ guard = src[recheck:spawn]
185
+ assert '!= "running"' in guard and "continue" in guard, (
186
+ "a non-running fresh state must skip the spawn (no ghost terminal)")
187
+ assert "stopped_mid_sweep" in guard, "skip must be traceable in hostd.log"
File without changes
File without changes
File without changes