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.
- {meshcode-2.11.144 → meshcode-2.11.147}/PKG-INFO +1 -1
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/__init__.py +1 -1
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/hostd.py +300 -18
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/server.py +8 -3
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/up.py +15 -1
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode.egg-info/PKG-INFO +1 -1
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode.egg-info/SOURCES.txt +2 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/pyproject.toml +1 -1
- meshcode-2.11.147/tests/test_hostd_launch_pinned_env.py +127 -0
- meshcode-2.11.147/tests/test_hostd_serve_discovery_split.py +149 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_hostd_zombie_sessions.py +283 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_security_regressions.py +54 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_terminal_lifecycle.py +41 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/README.md +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/__main__.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/_session_handoff_template.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/_stop_hook_template.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/ascii_art.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/atomic_push.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/claude_update.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/cli.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/comms_v4.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/compat.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/daemon.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/date_parse.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/doctor.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/error_hints.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/exceptions.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/helper_visuals.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/hooks/__init__.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/hooks/repo_path_lock.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/invites.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/launcher.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/launcher_install.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/__init__.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/__main__.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/backend.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/realtime.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/sleep_signals.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/swarm.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/test_backend.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/test_boot_timing.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/test_install_guard.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/test_prefs_claude_version.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/test_realtime.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/meshcode_mcp/test_swarm.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/preferences.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/protocol_handler.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/protocol_v2.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/quickstart.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/rpc_allowlist.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/run_agent.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/scripts/check_secrets.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/scripts/race_rate_harness.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/secrets.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/self_update.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/setup_clients.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/supervisor.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode/upload.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode.egg-info/dependency_links.txt +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode.egg-info/entry_points.txt +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode.egg-info/requires.txt +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/meshcode.egg-info/top_level.txt +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/setup.cfg +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_auto_update_hardening.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_autonomous_closegap_1.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_autonomous_closegap_2.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_autonomous_closegap_3.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_autonomous_prompt_inject.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_boot_bug_regression.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_color_truecolor.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_core.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_cross_agent_messaging.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_date_parse.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_doctor.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_epistemic_v1_python_sdk.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_epistemic_v1_stop_conditions.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_esc_deaf_state.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_exceptions.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_file_upload.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_helper_visuals.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_init_device_code.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_install_guard.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_lease_sigterm_release.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_live_mesh_guard.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_mark_read_batch.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_marketplace_ratings.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_migration_integrity.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_pretrust_claude.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_realtime_event_freshness.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_rls_cross_tenant.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_rpc_grants.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_rpc_migrations.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_run_agent_dry_run.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_run_agent_no_server_import.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_self_update_user_site.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_sentinel.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_session_replay_gate.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_setup_path.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_sleep_signals.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_status_enum_coverage.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_stay_on_loop_hook.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_stop_ghost_terminal.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_swarm_events.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_task_progress.py +0 -0
- {meshcode-2.11.144 → meshcode-2.11.147}/tests/test_wait_open_tasks_contradiction.py +0 -0
|
@@ -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
|
|
152
|
-
#
|
|
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"
|
|
157
|
-
|
|
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
|
|
610
|
-
# bin dir, mirroring the headless POSIX fix + the win32 venv-Scripts injection.
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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'"{
|
|
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(
|
|
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
|
|
1780
|
-
|
|
1781
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
|
@@ -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
|
|
@@ -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()
|