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