meshcode 2.11.144__tar.gz → 2.11.147__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.144 → meshcode-2.11.147}/PKG-INFO +1 -1
  2. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/__init__.py +1 -1
  3. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/hostd.py +300 -18
  4. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/server.py +8 -3
  5. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/up.py +15 -1
  6. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode.egg-info/PKG-INFO +1 -1
  7. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode.egg-info/SOURCES.txt +2 -0
  8. {meshcode-2.11.144 → meshcode-2.11.147}/pyproject.toml +1 -1
  9. meshcode-2.11.147/tests/test_hostd_launch_pinned_env.py +127 -0
  10. meshcode-2.11.147/tests/test_hostd_serve_discovery_split.py +149 -0
  11. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_hostd_zombie_sessions.py +283 -0
  12. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_security_regressions.py +54 -0
  13. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_terminal_lifecycle.py +41 -0
  14. {meshcode-2.11.144 → meshcode-2.11.147}/README.md +0 -0
  15. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/__main__.py +0 -0
  16. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/_session_handoff_template.py +0 -0
  17. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/_stop_hook_template.py +0 -0
  18. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/ascii_art.py +0 -0
  19. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/atomic_push.py +0 -0
  20. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/claude_update.py +0 -0
  21. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/cli.py +0 -0
  22. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/comms_v4.py +0 -0
  23. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/compat.py +0 -0
  24. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/daemon.py +0 -0
  25. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/date_parse.py +0 -0
  26. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/doctor.py +0 -0
  27. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/error_hints.py +0 -0
  28. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/exceptions.py +0 -0
  29. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/helper_visuals.py +0 -0
  30. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/hooks/__init__.py +0 -0
  31. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/hooks/repo_path_lock.py +0 -0
  32. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/invites.py +0 -0
  33. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/launcher.py +0 -0
  34. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/launcher_install.py +0 -0
  35. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/__init__.py +0 -0
  36. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/__main__.py +0 -0
  37. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/backend.py +0 -0
  38. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/realtime.py +0 -0
  39. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/sleep_signals.py +0 -0
  40. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/swarm.py +0 -0
  41. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/test_backend.py +0 -0
  42. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/test_boot_timing.py +0 -0
  43. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/test_install_guard.py +0 -0
  44. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/test_prefs_claude_version.py +0 -0
  45. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  46. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
  47. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/test_swarm.py +0 -0
  48. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/preferences.py +0 -0
  49. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/protocol_handler.py +0 -0
  50. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/protocol_v2.py +0 -0
  51. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/quickstart.py +0 -0
  52. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/rpc_allowlist.py +0 -0
  53. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/run_agent.py +0 -0
  54. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/scripts/check_secrets.py +0 -0
  55. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/scripts/race_rate_harness.py +0 -0
  56. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/secrets.py +0 -0
  57. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/self_update.py +0 -0
  58. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/setup_clients.py +0 -0
  59. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/supervisor.py +0 -0
  60. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/upload.py +0 -0
  61. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode.egg-info/dependency_links.txt +0 -0
  62. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode.egg-info/entry_points.txt +0 -0
  63. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode.egg-info/requires.txt +0 -0
  64. {meshcode-2.11.144 → meshcode-2.11.147}/meshcode.egg-info/top_level.txt +0 -0
  65. {meshcode-2.11.144 → meshcode-2.11.147}/setup.cfg +0 -0
  66. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_auto_update_hardening.py +0 -0
  67. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_autonomous_closegap_1.py +0 -0
  68. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_autonomous_closegap_2.py +0 -0
  69. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_autonomous_closegap_3.py +0 -0
  70. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_autonomous_prompt_inject.py +0 -0
  71. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_boot_bug_regression.py +0 -0
  72. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_color_truecolor.py +0 -0
  73. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_core.py +0 -0
  74. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_cross_agent_messaging.py +0 -0
  75. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_date_parse.py +0 -0
  76. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_doctor.py +0 -0
  77. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_epistemic_v1_python_sdk.py +0 -0
  78. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_epistemic_v1_stop_conditions.py +0 -0
  79. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_esc_deaf_state.py +0 -0
  80. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_exceptions.py +0 -0
  81. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_file_upload.py +0 -0
  82. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_helper_visuals.py +0 -0
  83. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_init_device_code.py +0 -0
  84. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_install_guard.py +0 -0
  85. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_lease_sigterm_release.py +0 -0
  86. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_live_mesh_guard.py +0 -0
  87. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_mark_read_batch.py +0 -0
  88. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_marketplace_ratings.py +0 -0
  89. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_migration_integrity.py +0 -0
  90. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_pretrust_claude.py +0 -0
  91. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_realtime_event_freshness.py +0 -0
  92. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_rls_cross_tenant.py +0 -0
  93. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_rpc_grants.py +0 -0
  94. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_rpc_migrations.py +0 -0
  95. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_run_agent_dry_run.py +0 -0
  96. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_run_agent_no_server_import.py +0 -0
  97. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_self_update_user_site.py +0 -0
  98. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_sentinel.py +0 -0
  99. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_session_replay_gate.py +0 -0
  100. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_setup_path.py +0 -0
  101. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_sleep_signals.py +0 -0
  102. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_status_enum_coverage.py +0 -0
  103. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_stay_on_loop_hook.py +0 -0
  104. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_stop_ghost_terminal.py +0 -0
  105. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_swarm_events.py +0 -0
  106. {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_task_progress.py +0 -0
  107. {meshcode-2.11.144 → meshcode-2.11.147}/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.144
3
+ Version: 2.11.147
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.144"
2
+ __version__ = "2.11.147"
3
3
 
4
4
  # Exception hierarchy — eagerly imported (lightweight, no deps)
5
5
  from meshcode.exceptions import ( # noqa: F401
@@ -148,13 +148,31 @@ def _maybe_self_restart_on_version_drift() -> None:
148
148
  if _has_supervisor():
149
149
  _log("supervisor present -> clean exit; launchd/systemd/schtasks relaunches on new code")
150
150
  os._exit(0)
151
- # No supervisor (e.g. dev foreground run): execv is the only in-place restart. If it's blocked,
152
- # do NOT self-destruct (nothing would relaunch us) stay on old code, retry after the guard window.
151
+ # No supervisor (e.g. dev foreground run / sandboxed Mac terminal):
152
+ # try execv first (fastest, in-place); if blocked (macOS sandbox), spawn a
153
+ # DETACHED hostd on the new wheel and exit. The new process inherits nothing
154
+ # from us — clean slate on the on-disk version.
153
155
  try:
154
156
  os.execv(sys.executable, [sys.executable, "-m", "meshcode"] + sys.argv[1:])
155
157
  except Exception as e:
156
- _log(f"WARN: no supervisor + execv failed ({e}); staying on {_RUNNING_VERSION}, retry after guard window")
157
- return
158
+ _log(f"execv blocked ({e}); attempting detached self-relaunch via subprocess")
159
+ try:
160
+ argv = _hostd_run_argv()
161
+ if sys.platform == "win32":
162
+ # DETACHED_PROCESS: no console inheritance, survives our exit.
163
+ subprocess.Popen(argv, creationflags=subprocess.DETACHED_PROCESS,
164
+ close_fds=True, stdin=subprocess.DEVNULL,
165
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
166
+ else:
167
+ subprocess.Popen(argv, start_new_session=True,
168
+ close_fds=True, stdin=subprocess.DEVNULL,
169
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
170
+ _log(f"detached hostd spawned ({' '.join(argv)}); exiting old ({_RUNNING_VERSION})")
171
+ os._exit(0)
172
+ except Exception as e2:
173
+ _log(f"WARN: no supervisor + execv failed + detached spawn failed ({e2}); "
174
+ f"staying on {_RUNNING_VERSION}, retry after guard window")
175
+ return
158
176
 
159
177
  STATE_DIR = Path.home() / ".meshcode"
160
178
  HOST_ID_PATH = STATE_DIR / "host_id"
@@ -516,6 +534,31 @@ def _host_cfg(api_key: str, host_id: str) -> Optional[dict]:
516
534
  return cfg
517
535
 
518
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
+
519
562
  # ------------------------------------------------------------------
520
563
  # Respawn
521
564
  # ------------------------------------------------------------------
@@ -578,6 +621,66 @@ def _validate_repo_path(rp) -> Optional[str]:
578
621
  return p
579
622
 
580
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
+
581
684
  def _spawn_agent(project: str, agent: str, repo_path=None) -> bool:
582
685
  """Relaunch `meshcode run <project>/<agent>` in a VISIBLE terminal window.
583
686
 
@@ -597,6 +700,16 @@ def _spawn_agent(project: str, agent: str, repo_path=None) -> bool:
597
700
  + the respawn RPC carry it.
598
701
  """
599
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
600
713
  repo = _validate_repo_path(repo_path)
601
714
  # env hygiene (item1 RC): a stale CLAUDECODE aborts `meshcode run` (exit2). Build the terminal
602
715
  # command PER-PLATFORM (mesh-core FIX1, 0x80070002): cmd.exe uses & (NOT ';'), set "V=" to clear
@@ -606,12 +719,15 @@ def _spawn_agent(project: str, agent: str, repo_path=None) -> bool:
606
719
  # via `python -m meshcode` (NOT the .exe shim) so a bg `pip install -U` can replace meshcode.exe.
607
720
  # RELIABILITY (commander, REOPEN_PATH_bug): the VISIBLE spawn must also put the meshcode
608
721
  # console script on PATH (a8efddeb fixed only the headless branch). Without it the spawned
609
- # agent + its `meshcode mcp` child die with "meshcode not on PATH". Prepend sys.executable's
610
- # bin dir, mirroring the headless POSIX fix + the win32 venv-Scripts injection.
611
- try:
612
- _bindir = str(Path(sys.executable).resolve().parent)
613
- except Exception:
614
- _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})")
615
731
  if sys.platform == "win32":
616
732
  # repo already validated (no '"'/control chars, real dir) -> safe to double-quote.
617
733
  _repo_win = f' --repo "{repo}"' if repo else ''
@@ -622,14 +738,14 @@ def _spawn_agent(project: str, agent: str, repo_path=None) -> bool:
622
738
  # re-arming would override a human Stop clicked mid-spawn).
623
739
  f'set "MESHCODE_HOSTD_SPAWN=1" & '
624
740
  + (f'set "PATH={_bindir};%PATH%" & ' if _bindir else '')
625
- + f'"{sys.executable}" -m meshcode run "{target}"{_repo_win}')
741
+ + f'"{_launch_py}" -m meshcode run "{target}"{_repo_win}')
626
742
  else:
627
743
  _repo_posix = f" --repo {shlex.quote(repo)}" if repo else ''
628
744
  cmd = (f"unset CLAUDECODE CLAUDE_CODE_SESSION MESHCODE_NO_UPDATE MESHCODE_NO_AUTO_UPDATE; "
629
745
  # task 01e2b5c1: see win32 branch — hostd spawns skip run-intent.
630
746
  + f"export MESHCODE_HOSTD_SPAWN=1; "
631
747
  + (f"export PATH={shlex.quote(_bindir)}:$PATH; " if _bindir else "")
632
- + 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}")
633
749
  try:
634
750
  from meshcode import protocol_handler as _ph
635
751
  ok, info = _ph._spawn_terminal(cmd)
@@ -669,6 +785,10 @@ _DISCOVERY_ERR_LOGGED: set = set()
669
785
  # so the ORPHAN-CLAIM line prints once per agent instead of every ~10s sweep.
670
786
  _ORPHAN_CLAIMED_LOGGED: set = set()
671
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
+
672
792
 
673
793
  def _do_respawns(api_key: str, host_id: str) -> int:
674
794
  """One respawn sweep. Returns number relaunched."""
@@ -722,6 +842,18 @@ def _do_respawns(api_key: str, host_id: str) -> int:
722
842
  proj, agent = c.get("project_name"), c.get("agent")
723
843
  if not proj or not agent:
724
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
725
857
  if not c.get("respawn_allowed", True):
726
858
  # mig404: not allowed = rate-limited (<60s since last respawn) or at the cap.
727
859
  # mc_record_respawn v2 sets desired_state='crashed' ATOMICALLY at the cap, so we
@@ -1020,6 +1152,23 @@ def _do_respawns(api_key: str, host_id: str) -> int:
1020
1152
  # removed, task cba01de5), so this always runs for a recycle. (With the pre-spawn
1021
1153
  # reconciliation above this set is normally already empty — kept as backstop.)
1022
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
1023
1172
  # DEAD-WINDOW REAP (task 70ac6d25, Samuel hit it twice 2026-06-09): an
1024
1173
  # exit-for-respawn rotation takes THIS plain-respawn path, and the old
1025
1174
  # window — whose launch script predates the .119 in-script self-closer,
@@ -1754,12 +1903,66 @@ def _discover_serve_pids(target: str) -> list:
1754
1903
  return _discover_serve_pids_ex(target)["pids"]
1755
1904
 
1756
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
+
1757
1950
  def _discover_serve_pids_ex(target: str) -> dict:
1758
1951
  """Instrumented serve discovery (task 89d50a14 [B]) — same result contract as
1759
1952
  _discover_agent_pids_ex. psutil-missing is errored=False (degraded BY DESIGN,
1760
1953
  documented since 14e0760c: the session-root tree kill still covers parented
1761
1954
  serves); the enumeration itself raising is errored=True (empty result is
1762
- 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"."""
1763
1966
  ps = _psutil()
1764
1967
  if ps is None:
1765
1968
  return {"pids": [], "errored": False, "path": "none(psutil-missing)", "error": ""}
@@ -1768,6 +1971,7 @@ def _discover_serve_pids_ex(target: str) -> dict:
1768
1971
  return {"pids": [], "errored": False, "path": "psutil", "error": ""}
1769
1972
  own = _own_pid()
1770
1973
  out = []
1974
+ ambiguous = False
1771
1975
  try:
1772
1976
  for p in ps.process_iter():
1773
1977
  try:
@@ -1776,14 +1980,38 @@ def _discover_serve_pids_ex(target: str) -> dict:
1776
1980
  cl = " ".join(p.cmdline() or [])
1777
1981
  if "meshcode" not in cl or "serve" not in cl or "meshcode_mcp" not in cl:
1778
1982
  continue
1779
- env = p.environ() or {}
1780
- if env.get("MESHCODE_AGENT") == agent and \
1781
- (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):
1782
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
1783
2006
  except Exception:
1784
2007
  continue # process vanished / access denied — never block the sweep
1785
2008
  except Exception as e:
1786
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])")}
1787
2015
  return {"pids": out, "errored": False, "path": "psutil", "error": ""}
1788
2016
 
1789
2017
 
@@ -2243,8 +2471,6 @@ def _do_reap(api_key: str, host_id: str) -> int:
2243
2471
  if not cfg or not cfg.get("ok"):
2244
2472
  return 0
2245
2473
  agents = cfg.get("agents") or []
2246
- if not agents:
2247
- return 0
2248
2474
 
2249
2475
  def _alive(p) -> bool:
2250
2476
  if not p:
@@ -2332,6 +2558,62 @@ def _do_reap(api_key: str, host_id: str) -> int:
2332
2558
  elif now - float(first) >= _REAP_ORPHAN_GRACE_SEC:
2333
2559
  n += _reap(target, pid, f"deleted-agent orphan; alive {int(now-float(first))}s after roster removal")
2334
2560
  seen.pop(target, None)
2561
+ # (D) STALE-ENV SERVE ORPHAN (version-split fix): an MCP serve process whose interpreter
2562
+ # lives in ~/.meshcode/envs/<old_version>/ while the active installed version is different.
2563
+ # These are leftovers from a version split: the agent relaunched on the new env but the old
2564
+ # serve (child of the old claude session) keeps running, potentially with stale bindings.
2565
+ # Reap them. DRY-RUN gated like everything else.
2566
+ # Cross-platform: posix = .../envs/<ver>/bin/python3, win = ...\envs\<ver>\Scripts\python.exe.
2567
+ # Use os.path.normcase for case-insensitive matching on Windows.
2568
+ try:
2569
+ import importlib.metadata as _ilmd
2570
+ _active_ver = _ilmd.version("meshcode")
2571
+ except Exception:
2572
+ _active_ver = None
2573
+ if _active_ver:
2574
+ _envs_prefix = os.path.normcase(str(STATE_DIR / "envs"))
2575
+ ps = _psutil()
2576
+ if ps:
2577
+ try:
2578
+ for p in ps.process_iter():
2579
+ try:
2580
+ cl = " ".join(p.cmdline() or [])
2581
+ if "meshcode" not in cl or "serve" not in cl or "meshcode_mcp" not in cl:
2582
+ continue
2583
+ if p.pid == _own_pid():
2584
+ continue
2585
+ # Check if the interpreter (argv[0]) is from an old env
2586
+ _argv = p.cmdline() or []
2587
+ if not _argv:
2588
+ continue
2589
+ _interp = os.path.normcase(_argv[0])
2590
+ if _envs_prefix not in _interp:
2591
+ continue # not running from a versioned env — skip (e.g. anaconda)
2592
+ # Extract version from path (separator-agnostic):
2593
+ # posix: .../envs/2.11.140/bin/python3
2594
+ # win: ...\envs\2.11.140\Scripts\python.exe
2595
+ _env_ver = None
2596
+ try:
2597
+ _rest = _interp[len(_envs_prefix):]
2598
+ # Strip leading separators (/ or \)
2599
+ _rest = _rest.lstrip("/").lstrip("\\")
2600
+ # Split on either separator to get the version component
2601
+ import re as _re
2602
+ _parts = _re.split(r"[/\\]", _rest)
2603
+ _env_ver = _parts[0] if _parts and _parts[0] else None
2604
+ except Exception:
2605
+ pass
2606
+ if _env_ver and _env_ver != _active_ver:
2607
+ _env_info = p.environ() or {}
2608
+ _s_agent = _env_info.get("MESHCODE_AGENT", "?")
2609
+ _s_proj = _env_info.get("MESHCODE_PROJECT", "?")
2610
+ _s_target = f"{_s_proj}/{_s_agent}"
2611
+ n += _reap(_s_target, p.pid,
2612
+ f"stale-env serve: interp={_env_ver}, active={_active_ver}")
2613
+ except Exception:
2614
+ continue
2615
+ except Exception:
2616
+ pass
2335
2617
  # prune grace clocks only for targets that are BOTH gone from the roster AND have no alive recorded PID
2336
2618
  for t in list(seen.keys()):
2337
2619
  if t not in live_targets and not _alive(pids.get(t)):
@@ -7339,12 +7339,17 @@ def meshcode_seed_fixture(
7339
7339
  ttl_seconds: int = 3600,
7340
7340
  ) -> Dict[str, Any]:
7341
7341
  """Seed a fixture row in a whitelisted table for Playwright/verify.
7342
- MESH-IMPROVE-7 (mig 345). Whitelist: mc_tasks, mc_messages, mc_agents.
7342
+ MESH-IMPROVE-7 (mig 345). Whitelist: mc_tasks, mc_messages, mc_agents, mc_agent_visits.
7343
7343
  Auto-swept after ttl_seconds (min 60). Pair with meshcode_release_fixture.
7344
7344
  """
7345
- if table not in ("mc_tasks", "mc_messages", "mc_agents"):
7345
+ # Single source of truth so the docstring / guard / hint can't drift apart again
7346
+ # (task 6cc2cabc — the literal was triplicated and went stale). mc_agent_visits added
7347
+ # to sync with mig588 (mc_sweep_stale_agent_visits) for the agent-visit e2e; the
7348
+ # server-side mc_seed_fixture RPC whitelist must allow it too (mesh-core task 64c73406).
7349
+ _SEEDABLE = ("mc_tasks", "mc_messages", "mc_agents", "mc_agent_visits")
7350
+ if table not in _SEEDABLE:
7346
7351
  return {"error": "table not seedable",
7347
- "hint": "whitelist: mc_tasks, mc_messages, mc_agents"}
7352
+ "hint": "whitelist: " + ", ".join(_SEEDABLE)}
7348
7353
  return be.sb_rpc("mc_seed_fixture", {
7349
7354
  "p_api_key": _get_api_key(),
7350
7355
  "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.144
3
+ Version: 2.11.147
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.144"
7
+ version = "2.11.147"
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()