meshcode 2.11.126__tar.gz → 2.11.128__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.126 → meshcode-2.11.128}/PKG-INFO +1 -1
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/__init__.py +1 -1
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/hostd.py +157 -2
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/protocol_handler.py +8 -5
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode.egg-info/PKG-INFO +1 -1
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode.egg-info/SOURCES.txt +2 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/pyproject.toml +9 -1
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_boot_bug_regression.py +1 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_cross_agent_messaging.py +34 -10
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_epistemic_v1_stop_conditions.py +1 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_esc_deaf_state.py +7 -1
- meshcode-2.11.128/tests/test_live_mesh_guard.py +100 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_mark_read_batch.py +34 -11
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_rls_cross_tenant.py +19 -5
- meshcode-2.11.128/tests/test_stop_ghost_terminal.py +211 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/README.md +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/__main__.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/_session_handoff_template.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/_stop_hook_template.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/ascii_art.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/atomic_push.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/claude_update.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/cli.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/comms_v4.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/compat.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/daemon.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/date_parse.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/doctor.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/error_hints.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/exceptions.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/hooks/__init__.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/hooks/repo_path_lock.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/invites.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/launcher.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/launcher_install.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/meshcode_mcp/__init__.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/meshcode_mcp/__main__.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/meshcode_mcp/backend.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/meshcode_mcp/realtime.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/meshcode_mcp/server.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/meshcode_mcp/sleep_signals.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/meshcode_mcp/swarm.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/meshcode_mcp/test_backend.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/meshcode_mcp/test_boot_timing.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/meshcode_mcp/test_install_guard.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/meshcode_mcp/test_prefs_claude_version.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/meshcode_mcp/test_realtime.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/meshcode_mcp/test_swarm.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/preferences.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/protocol_v2.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/quickstart.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/rpc_allowlist.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/run_agent.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/scripts/check_secrets.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/scripts/race_rate_harness.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/secrets.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/self_update.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/setup_clients.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/supervisor.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/up.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode/upload.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode.egg-info/dependency_links.txt +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode.egg-info/entry_points.txt +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode.egg-info/requires.txt +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/meshcode.egg-info/top_level.txt +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/setup.cfg +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_auto_update_hardening.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_autonomous_closegap_1.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_autonomous_closegap_2.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_autonomous_closegap_3.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_autonomous_prompt_inject.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_color_truecolor.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_core.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_date_parse.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_doctor.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_epistemic_v1_python_sdk.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_exceptions.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_file_upload.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_init_device_code.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_install_guard.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_lease_sigterm_release.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_marketplace_ratings.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_migration_integrity.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_realtime_event_freshness.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_rpc_grants.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_rpc_migrations.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_run_agent_dry_run.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_run_agent_no_server_import.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_security_regressions.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_self_update_user_site.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_sentinel.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_setup_path.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_sleep_signals.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_status_enum_coverage.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_stay_on_loop_hook.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_swarm_events.py +0 -0
- {meshcode-2.11.126 → meshcode-2.11.128}/tests/test_wait_open_tasks_contradiction.py +0 -0
|
@@ -1051,9 +1051,63 @@ def _discover_agent_pids(target: str) -> list:
|
|
|
1051
1051
|
pids.append(p)
|
|
1052
1052
|
except Exception:
|
|
1053
1053
|
pass
|
|
1054
|
+
# GHOST-TERMINAL fix (task 91201315): on POSIX `meshcode run` EXECVP's claude, so the
|
|
1055
|
+
# live agent's cmdline is `claude …` — no 'meshcode run <target>' survives and the
|
|
1056
|
+
# pgrep above sees NOTHING for a visible macOS agent (the '0 stopped forever' hole).
|
|
1057
|
+
# Its launcher bash (~/.meshcode/launchers/<label>.command) DOES keep the target in
|
|
1058
|
+
# its cmdline for the window's whole life — so also return that bash's direct agent
|
|
1059
|
+
# children. Launcher children are hostd-spawned BY CONSTRUCTION: a session the human
|
|
1060
|
+
# opened by hand (`claude --resume` in a plain tab) can NEVER match.
|
|
1061
|
+
if sys.platform != "win32":
|
|
1062
|
+
try:
|
|
1063
|
+
pids += [p for p in _discover_launcher_child_pids(target) if p not in pids]
|
|
1064
|
+
except Exception:
|
|
1065
|
+
pass
|
|
1054
1066
|
return pids
|
|
1055
1067
|
|
|
1056
1068
|
|
|
1069
|
+
def _discover_launcher_child_pids(target: str) -> list:
|
|
1070
|
+
"""PIDs of `target`'s agent process(es) running UNDER its launcher bash
|
|
1071
|
+
(~/.meshcode/launchers/<label>.command — the macOS visible spawn path).
|
|
1072
|
+
|
|
1073
|
+
One ps snapshot: find the bash whose cmdline ends in the target's launcher
|
|
1074
|
+
file, then return its DIRECT children whose cmdline looks like the agent
|
|
1075
|
+
(claude/meshcode). The marker is re.escape'd and anchored (whitespace/EOL
|
|
1076
|
+
lookahead) so 'mesh-dev_back.command' can never prefix-match
|
|
1077
|
+
'mesh-dev_back-2.command' (same WHOLE-token rule as _discover_agent_pids).
|
|
1078
|
+
Best-effort; [] on non-POSIX or any failure."""
|
|
1079
|
+
if sys.platform == "win32":
|
|
1080
|
+
return []
|
|
1081
|
+
marker = _terminal_window_marker(target) # '<sanitized label>.command'
|
|
1082
|
+
if not marker:
|
|
1083
|
+
return []
|
|
1084
|
+
rx = re.compile(r"launchers/" + re.escape(marker) + r"(?=\s|$)")
|
|
1085
|
+
kids = []
|
|
1086
|
+
try:
|
|
1087
|
+
out = subprocess.run(["ps", "-axo", "pid=,ppid=,args="],
|
|
1088
|
+
capture_output=True, text=True, timeout=8).stdout
|
|
1089
|
+
rows, bash_pids = [], set()
|
|
1090
|
+
for ln in out.splitlines():
|
|
1091
|
+
parts = ln.split(None, 2)
|
|
1092
|
+
if len(parts) < 3:
|
|
1093
|
+
continue
|
|
1094
|
+
try:
|
|
1095
|
+
pid, ppid = int(parts[0]), int(parts[1])
|
|
1096
|
+
except Exception:
|
|
1097
|
+
continue
|
|
1098
|
+
rows.append((pid, ppid, parts[2]))
|
|
1099
|
+
if rx.search(parts[2]):
|
|
1100
|
+
bash_pids.add(pid)
|
|
1101
|
+
for pid, ppid, args in rows:
|
|
1102
|
+
if ppid in bash_pids and pid not in bash_pids:
|
|
1103
|
+
low = args.lower()
|
|
1104
|
+
if "claude" in low or "meshcode" in low:
|
|
1105
|
+
kids.append(pid)
|
|
1106
|
+
except Exception:
|
|
1107
|
+
pass
|
|
1108
|
+
return kids
|
|
1109
|
+
|
|
1110
|
+
|
|
1057
1111
|
def _kill_heartbeat_fork(target: str) -> None:
|
|
1058
1112
|
"""Kill the agent's heartbeat daemon fork (Fix B, task 24e3dd44). The fork is detached into its
|
|
1059
1113
|
OWN session, so os.killpg(getpgid(agent_pid)) in _kill_headless_pid does NOT take it down — it
|
|
@@ -1165,6 +1219,104 @@ def _do_stops(api_key: str, host_id: str) -> int:
|
|
|
1165
1219
|
return n
|
|
1166
1220
|
|
|
1167
1221
|
|
|
1222
|
+
def _term_ghost_pid(target: str, pid: int) -> bool:
|
|
1223
|
+
"""Kill a stop-ghost agent process WITHOUT killpg (task 91201315). The launcher
|
|
1224
|
+
bash shares the pgid — it must SURVIVE the kill so it sees rc=143 and the
|
|
1225
|
+
wrapper's clean-close path (protocol_handler) closes its own Terminal window.
|
|
1226
|
+
SIGTERM the pid only, escalate to SIGKILL (pid only) after a short grace.
|
|
1227
|
+
Cmdline reuse-guard is STRICTER than _kill_headless_pid's: an EMPTY cmdline is
|
|
1228
|
+
a skip too (we only ever kill a process we can positively read as the agent).
|
|
1229
|
+
Windows has no .command wrapper — delegate to _kill_headless_pid (tree kill).
|
|
1230
|
+
Returns True if the ghost was killed."""
|
|
1231
|
+
if not pid:
|
|
1232
|
+
return False
|
|
1233
|
+
if sys.platform == "win32":
|
|
1234
|
+
return _kill_headless_pid(target, pid)
|
|
1235
|
+
cl = _pid_cmdline(pid).lower()
|
|
1236
|
+
if "meshcode" not in cl and "claude" not in cl:
|
|
1237
|
+
_log(f"GHOST {target}: pid {pid} cmdline unreadable/mismatch — skip kill")
|
|
1238
|
+
return False
|
|
1239
|
+
try:
|
|
1240
|
+
os.kill(pid, _signal.SIGTERM)
|
|
1241
|
+
except ProcessLookupError:
|
|
1242
|
+
return False
|
|
1243
|
+
except Exception as e:
|
|
1244
|
+
_log(f"WARN: GHOST {target}: SIGTERM pid {pid} failed: {e}")
|
|
1245
|
+
return False
|
|
1246
|
+
time.sleep(2.0)
|
|
1247
|
+
try:
|
|
1248
|
+
os.kill(pid, 0) # still alive?
|
|
1249
|
+
os.kill(pid, _signal.SIGKILL) # didn't honor SIGTERM
|
|
1250
|
+
_log(f"GHOST {target}: pid {pid} ignored SIGTERM — SIGKILLed")
|
|
1251
|
+
except ProcessLookupError:
|
|
1252
|
+
pass
|
|
1253
|
+
except Exception:
|
|
1254
|
+
pass
|
|
1255
|
+
_kill_heartbeat_fork(target) # its detached heartbeat fork won't die with the pid
|
|
1256
|
+
_log(f"GHOST {target}: killed stop-ghost pid {pid} (desired=stopped, heartbeat stale)")
|
|
1257
|
+
return True
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def _do_stopped_ghost_sweep(api_key: str, host_id: str) -> int:
|
|
1261
|
+
"""GHOST-TERMINAL kill sweep (task 91201315; Ian's live repro: back-2 pid 26078,
|
|
1262
|
+
hostd logging '0 stopped' forever). The hole: on Stop the agent exits its loop
|
|
1263
|
+
cleanly, clears its instance_id and STOPS heartbeating — and BOTH kill RPCs
|
|
1264
|
+
(mc_agents_to_stop, mc_agents_to_force_kill) gate on last_heartbeat < 90s, so
|
|
1265
|
+
once the heartbeat goes stale the still-alive claude process is INVISIBLE to
|
|
1266
|
+
every RPC sweep and its terminal window stays open with claude running.
|
|
1267
|
+
|
|
1268
|
+
Sweep the ROSTER instead (mc_host_config_get — no fresh-heartbeat gate): every
|
|
1269
|
+
desired_state='stopped' agent whose heartbeat is STALE or absent (>=90s; fresh
|
|
1270
|
+
ones stay with the RPC sweeps so the cooperative must_exit gets first crack)
|
|
1271
|
+
gets PID discovery; any live process found IS a ghost — desired says stopped,
|
|
1272
|
+
nothing legit can be running. Kill via _term_ghost_pid (SIGTERM, no killpg) so
|
|
1273
|
+
the launcher bash survives, sees rc=143 and closes its own window; then the
|
|
1274
|
+
dead-window reap below mops up any '[Process completed]' leftovers (SIGKILL
|
|
1275
|
+
path / wrappers generated before the rc=143 fix).
|
|
1276
|
+
|
|
1277
|
+
NEVER touches a hand-opened session: discovery matches ONLY 'meshcode run
|
|
1278
|
+
<target>' cmdlines (token-safe) or direct children of the target's own
|
|
1279
|
+
hostd launcher .command — a human `claude --resume` in a plain tab can't
|
|
1280
|
+
match either. Returns number of ghosts killed."""
|
|
1281
|
+
cfg = _rpc("mc_host_config_get", {"p_api_key": api_key, "p_host_id": host_id})
|
|
1282
|
+
if not cfg or not cfg.get("ok"):
|
|
1283
|
+
return 0
|
|
1284
|
+
st = _load_state()
|
|
1285
|
+
pids = st.get("headless_pids") or {}
|
|
1286
|
+
n = 0
|
|
1287
|
+
for a in (cfg.get("agents") or []):
|
|
1288
|
+
if a.get("desired_state") != "stopped":
|
|
1289
|
+
continue
|
|
1290
|
+
hb = a.get("heartbeat_age_s")
|
|
1291
|
+
if hb is not None and hb < 90:
|
|
1292
|
+
continue # fresh heartbeat -> mc_agents_to_stop/_force_kill own this window
|
|
1293
|
+
proj, agent = a.get("project_name"), a.get("name")
|
|
1294
|
+
if not proj or not agent:
|
|
1295
|
+
continue
|
|
1296
|
+
target = f"{proj}/{agent}"
|
|
1297
|
+
killed_here = 0
|
|
1298
|
+
for pid in _discover_agent_pids(target):
|
|
1299
|
+
if _term_ghost_pid(target, pid):
|
|
1300
|
+
killed_here += 1
|
|
1301
|
+
if not killed_here:
|
|
1302
|
+
continue
|
|
1303
|
+
n += killed_here
|
|
1304
|
+
pids.pop(target, None)
|
|
1305
|
+
# The human pressed Stop — finish the job like _do_force_kills does: brief
|
|
1306
|
+
# settle, then close any window left dead (SIGKILL fallback / old wrapper).
|
|
1307
|
+
try:
|
|
1308
|
+
time.sleep(1.5)
|
|
1309
|
+
_wins = _list_dead_terminal_windows(target)
|
|
1310
|
+
if _wins:
|
|
1311
|
+
_close_dead_terminal_windows(target, _wins)
|
|
1312
|
+
except Exception:
|
|
1313
|
+
pass
|
|
1314
|
+
if n:
|
|
1315
|
+
st["headless_pids"] = pids
|
|
1316
|
+
_save_state(st)
|
|
1317
|
+
return n
|
|
1318
|
+
|
|
1319
|
+
|
|
1168
1320
|
# 38523a98 Gap 1: explicit human force-kill of VISIBLE agents. ENABLED in 2.11.112 (task fa11ff48):
|
|
1169
1321
|
# Samuel-blessed + 0 false-positives verified (backend2 pre-check + empty owner-scoped queue on our box).
|
|
1170
1322
|
# _REAP_DRYRUN (autonomous reaper, below) stays True — SEPARATE gate, needs its own 0-FP + Samuel OK.
|
|
@@ -2017,11 +2169,14 @@ def cmd_hostd(args: list) -> int:
|
|
|
2017
2169
|
ver_recycled = _do_version_recycles(api_key, host_id)
|
|
2018
2170
|
stopped = _do_stops(api_key, host_id)
|
|
2019
2171
|
force_killed = _do_force_kills(api_key, host_id) # 38523a98 Gap1: visible explicit human stop
|
|
2172
|
+
# 91201315: stopped agents whose instance/heartbeat already cleared —
|
|
2173
|
+
# invisible to both RPC sweeps above (their <90s heartbeat gate).
|
|
2174
|
+
ghost_killed = _do_stopped_ghost_sweep(api_key, host_id)
|
|
2020
2175
|
reaped = _do_reap(api_key, host_id) # 38523a98: kill ghosts/dup-PIDs/crashed-orphans
|
|
2021
2176
|
_gc_headless_pids() # cb90b058: drop dead PIDs (stale entry can't mask a live agent)
|
|
2022
2177
|
_up = int(time.monotonic() - _spawn_mono)
|
|
2023
|
-
if relaunched or recycled or ver_recycled or stopped or enforced or reaped or force_killed:
|
|
2024
|
-
_log(f"sweep done (uptime={_up}s) — {relaunched} respawned, {recycled} recycled, {ver_recycled} version-recycled, {stopped} stopped, {force_killed} force-killed, {enforced} recycle-enforced, {reaped} reaped")
|
|
2178
|
+
if relaunched or recycled or ver_recycled or stopped or enforced or reaped or force_killed or ghost_killed:
|
|
2179
|
+
_log(f"sweep done (uptime={_up}s) — {relaunched} respawned, {recycled} recycled, {ver_recycled} version-recycled, {stopped} stopped, {force_killed} force-killed, {ghost_killed} ghost-killed, {enforced} recycle-enforced, {reaped} reaped")
|
|
2025
2180
|
elif time.monotonic() - _last_alive_log >= 60:
|
|
2026
2181
|
_log(f"alive — uptime={_up}s")
|
|
2027
2182
|
_last_alive_log = time.monotonic()
|
|
@@ -224,11 +224,14 @@ def _spawn_terminal_macos(cmd: str) -> tuple[bool, str]:
|
|
|
224
224
|
_run = cmd
|
|
225
225
|
lines.append(_run)
|
|
226
226
|
lines.append('MC_RC=$?')
|
|
227
|
-
# Close THIS Terminal window ONLY on a clean exit (recycle/stop
|
|
228
|
-
# (
|
|
229
|
-
#
|
|
230
|
-
#
|
|
231
|
-
|
|
227
|
+
# Close THIS Terminal window ONLY on a clean exit: rc=0 (recycle/stop self-exit)
|
|
228
|
+
# OR rc=143 (SIGTERM — hostd's stop/ghost kill sweep ended the agent because the
|
|
229
|
+
# human pressed Stop, task 91201315; that's the job FINISHING, not a crash). Any
|
|
230
|
+
# OTHER rc is a crash -> leave the window OPEN so the scrollback is available for
|
|
231
|
+
# debugging (commander condition: debugging > clean window). The own-$MC_TTY
|
|
232
|
+
# filter closes ONLY this window — never another agent's. saving no => no
|
|
233
|
+
# "close?" prompt. macOS-only path.
|
|
234
|
+
lines.append('if { [ "$MC_RC" = "0" ] || [ "$MC_RC" = "143" ]; } && [ -n "$MC_TTY" ]; then')
|
|
232
235
|
lines.append(
|
|
233
236
|
" /usr/bin/osascript"
|
|
234
237
|
" -e 'tell application \"Terminal\"'"
|
|
@@ -74,6 +74,7 @@ tests/test_file_upload.py
|
|
|
74
74
|
tests/test_init_device_code.py
|
|
75
75
|
tests/test_install_guard.py
|
|
76
76
|
tests/test_lease_sigterm_release.py
|
|
77
|
+
tests/test_live_mesh_guard.py
|
|
77
78
|
tests/test_mark_read_batch.py
|
|
78
79
|
tests/test_marketplace_ratings.py
|
|
79
80
|
tests/test_migration_integrity.py
|
|
@@ -90,5 +91,6 @@ tests/test_setup_path.py
|
|
|
90
91
|
tests/test_sleep_signals.py
|
|
91
92
|
tests/test_status_enum_coverage.py
|
|
92
93
|
tests/test_stay_on_loop_hook.py
|
|
94
|
+
tests/test_stop_ghost_terminal.py
|
|
93
95
|
tests/test_swarm_events.py
|
|
94
96
|
tests/test_wait_open_tasks_contradiction.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "meshcode"
|
|
7
|
-
version = "2.11.
|
|
7
|
+
version = "2.11.128"
|
|
8
8
|
description = "Real-time communication between AI agents — Supabase-backed CLI"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -45,6 +45,14 @@ Repository = "https://github.com/rf2f7f7sg4-dev/meshcode"
|
|
|
45
45
|
[tool.pytest.ini_options]
|
|
46
46
|
testpaths = ["tests"]
|
|
47
47
|
addopts = "--durations=10 --durations-min=0.5 -v --tb=short"
|
|
48
|
+
markers = [
|
|
49
|
+
# live_mesh: the test talks to the REAL Supabase backend (registers agents,
|
|
50
|
+
# sends messages, reads the live roster). Opt-in via MESHCODE_TEST_PROJECT;
|
|
51
|
+
# CI/smoke must deselect with: -m "not live_mesh"
|
|
52
|
+
# (incident 2026-06-10: suite runs leaked 30 fixture agents into the live
|
|
53
|
+
# mesh-dev roster on Ian's dashboard).
|
|
54
|
+
"live_mesh: hits the real Supabase backend/live mesh; opt-in, deselect with -m 'not live_mesh'",
|
|
55
|
+
]
|
|
48
56
|
|
|
49
57
|
[tool.setuptools.packages.find]
|
|
50
58
|
include = ["meshcode", "meshcode.*"]
|
|
@@ -21,6 +21,14 @@ import time
|
|
|
21
21
|
import unittest
|
|
22
22
|
from pathlib import Path
|
|
23
23
|
|
|
24
|
+
import pytest
|
|
25
|
+
|
|
26
|
+
# These tests register agents and send messages on the REAL backend — every
|
|
27
|
+
# run is visible on the owner's dashboard. Opt-in only (incident 2026-06-10:
|
|
28
|
+
# fixture agents leaked into the live mesh-dev roster). Deselect in CI/smoke
|
|
29
|
+
# with: -m "not live_mesh"
|
|
30
|
+
pytestmark = pytest.mark.live_mesh
|
|
31
|
+
|
|
24
32
|
# Add SDK to path
|
|
25
33
|
SDK_PATH = Path(__file__).parent.parent / "meshcode" / "meshcode_mcp"
|
|
26
34
|
if SDK_PATH.exists():
|
|
@@ -39,7 +47,10 @@ from meshcode.meshcode_mcp import backend as be
|
|
|
39
47
|
# Agents are cleaned up in tearDown. No project creation needed.
|
|
40
48
|
#
|
|
41
49
|
|
|
42
|
-
|
|
50
|
+
# NEVER default to a live mesh. Unset MESHCODE_TEST_PROJECT = SKIP everything:
|
|
51
|
+
# the old default ("mesh-dev") put every fixture agent and broadcast of this
|
|
52
|
+
# suite straight onto the production dashboard (incident 2026-06-10).
|
|
53
|
+
TEST_PROJECT = os.environ.get("MESHCODE_TEST_PROJECT")
|
|
43
54
|
AGENT_A = f"e2e-alice-{int(time.time()) % 10000}"
|
|
44
55
|
AGENT_B = f"e2e-bob-{int(time.time()) % 10000}"
|
|
45
56
|
AGENT_C = f"e2e-carol-{int(time.time()) % 10000}"
|
|
@@ -61,6 +72,10 @@ def _get_api_key():
|
|
|
61
72
|
API_KEY = _get_api_key()
|
|
62
73
|
|
|
63
74
|
|
|
75
|
+
@unittest.skipUnless(
|
|
76
|
+
TEST_PROJECT,
|
|
77
|
+
"MESHCODE_TEST_PROJECT not set — live-mesh e2e tests are opt-in and must "
|
|
78
|
+
"point at a dedicated test meshwork, never a live one (incident 2026-06-10)")
|
|
64
79
|
class CrossAgentMessagingTests(unittest.TestCase):
|
|
65
80
|
"""E2E tests for cross-agent message delivery."""
|
|
66
81
|
|
|
@@ -106,16 +121,25 @@ class CrossAgentMessagingTests(unittest.TestCase):
|
|
|
106
121
|
|
|
107
122
|
@classmethod
|
|
108
123
|
def tearDownClass(cls):
|
|
109
|
-
"""Cleanup: unregister ephemeral test agents.
|
|
124
|
+
"""Cleanup: unregister ephemeral test agents. LOUD on failure — the old
|
|
125
|
+
except:pass (plus never checking sb_rpc's returned error dict) let a
|
|
126
|
+
404 on mc_unregister_agent leak fixture agents into the roster forever
|
|
127
|
+
(incident 2026-06-10)."""
|
|
128
|
+
failures = []
|
|
110
129
|
for agent in [AGENT_A, AGENT_B, AGENT_C]:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
130
|
+
result = be.sb_rpc("mc_unregister_agent", {
|
|
131
|
+
"p_api_key": API_KEY,
|
|
132
|
+
"p_project_id": cls.project_id,
|
|
133
|
+
"p_agent_name": agent,
|
|
134
|
+
})
|
|
135
|
+
# sb_rpc reports errors as a return VALUE, not an exception.
|
|
136
|
+
if isinstance(result, dict) and (result.get("error") or result.get("_error")):
|
|
137
|
+
failures.append(f"{agent}: {result}")
|
|
138
|
+
if failures:
|
|
139
|
+
raise AssertionError(
|
|
140
|
+
"tearDown could not unregister test agents — they are now "
|
|
141
|
+
f"ORPHANED in '{TEST_PROJECT}' and must be removed by hand:\n "
|
|
142
|
+
+ "\n ".join(failures))
|
|
119
143
|
print(f"\n Cleaned up test agents from {TEST_PROJECT}")
|
|
120
144
|
|
|
121
145
|
# ── Basic Delivery ─────────────────────────────────────────────────────
|
|
@@ -33,6 +33,7 @@ def _live_or_skip():
|
|
|
33
33
|
pytest.skip("MESHCODE_SUPABASE_URL not set; epistemic V1 health is live-only.")
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
@pytest.mark.live_mesh
|
|
36
37
|
def test_epistemic_v1_health_all_conditions_healthy():
|
|
37
38
|
"""Live check: call mc_epistemic_v1_health() and assert each is_healthy."""
|
|
38
39
|
_live_or_skip()
|
|
@@ -20,6 +20,8 @@ import os
|
|
|
20
20
|
import sys
|
|
21
21
|
import time
|
|
22
22
|
import unittest
|
|
23
|
+
|
|
24
|
+
import pytest
|
|
23
25
|
from pathlib import Path
|
|
24
26
|
from unittest.mock import patch, MagicMock, AsyncMock
|
|
25
27
|
|
|
@@ -196,8 +198,12 @@ class TestCircuitBreakerAfterCancel(unittest.TestCase):
|
|
|
196
198
|
# Not a hard failure — pool may recover via other means
|
|
197
199
|
|
|
198
200
|
|
|
201
|
+
@pytest.mark.live_mesh
|
|
199
202
|
class TestBackendOperationsAfterCancel(unittest.TestCase):
|
|
200
|
-
"""Test that backend send/read work after simulated interrupt state.
|
|
203
|
+
"""Test that backend send/read work after simulated interrupt state.
|
|
204
|
+
|
|
205
|
+
live_mesh: test_06 reads count_pending against the REAL backend with the
|
|
206
|
+
keyring credential (read-only, but still a live-DB dependency)."""
|
|
201
207
|
|
|
202
208
|
def test_06_send_message_after_cb_reset(self):
|
|
203
209
|
"""send_message should work even if circuit breaker was previously open."""
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Live-Mesh Guard Regression Tests
|
|
3
|
+
=================================
|
|
4
|
+
Enforces the suite-wide rules from incident 2026-06-10 (30 fixture agents +
|
|
5
|
+
broadcast/impersonation spam leaked into the LIVE mesh-dev roster on the
|
|
6
|
+
owner's dashboard because e2e tests defaulted TEST_PROJECT to "mesh-dev" and
|
|
7
|
+
their teardown swallowed a 404 on a nonexistent RPC):
|
|
8
|
+
|
|
9
|
+
1. No test may DEFAULT its target project to a live mesh — the project must
|
|
10
|
+
come from MESHCODE_TEST_PROJECT with no fallback value.
|
|
11
|
+
2. Any test file that registers agents on the real backend must carry the
|
|
12
|
+
`live_mesh` pytest marker so CI/smoke can deselect with -m "not live_mesh".
|
|
13
|
+
3. mc_unregister_agent teardowns must CHECK the sb_rpc result (errors come
|
|
14
|
+
back as a return value, not an exception) — no silent leaks.
|
|
15
|
+
4. The live_mesh marker stays registered in pyproject.toml.
|
|
16
|
+
|
|
17
|
+
Source-parsing only (pattern of test_security_regressions.py) — no DB needed.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
pytest tests/test_live_mesh_guard.py -v
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
TESTS_DIR = Path(__file__).parent
|
|
27
|
+
PYPROJECT = TESTS_DIR.parent / "pyproject.toml"
|
|
28
|
+
THIS_FILE = Path(__file__).name
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _test_sources():
|
|
32
|
+
for f in sorted(TESTS_DIR.glob("test_*.py")):
|
|
33
|
+
if f.name == THIS_FILE:
|
|
34
|
+
continue
|
|
35
|
+
yield f.name, f.read_text(errors="replace")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _code_lines(src: str):
|
|
39
|
+
"""Lines with #-comments stripped (docstring prose is still included, so
|
|
40
|
+
the patterns below target call/assignment syntax, not bare words)."""
|
|
41
|
+
return [ln.split("#", 1)[0] for ln in src.splitlines()]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestNoLiveMeshDefaults:
|
|
45
|
+
def test_no_default_value_for_test_project_env(self):
|
|
46
|
+
"""os.environ.get("MESHCODE_TEST_PROJECT", <default>) is forbidden —
|
|
47
|
+
unset must mean SKIP, never a fallback mesh."""
|
|
48
|
+
rx = re.compile(r"""MESHCODE_TEST_PROJECT['"]\s*,""")
|
|
49
|
+
offenders = [name for name, src in _test_sources()
|
|
50
|
+
for ln in _code_lines(src) if rx.search(ln)]
|
|
51
|
+
assert not offenders, (
|
|
52
|
+
f"MESHCODE_TEST_PROJECT must have NO default value (incident "
|
|
53
|
+
f"2026-06-10) — offenders: {offenders}")
|
|
54
|
+
|
|
55
|
+
def test_no_hardcoded_live_project_assignment(self):
|
|
56
|
+
"""A module-level <X>PROJECT = "mesh-dev" (or any literal) hardcodes a
|
|
57
|
+
live mesh as the test target."""
|
|
58
|
+
rx = re.compile(r"""^\s*\w*PROJECT\s*=\s*['"][a-zA-Z]""")
|
|
59
|
+
offenders = [name for name, src in _test_sources()
|
|
60
|
+
for ln in _code_lines(src) if rx.search(ln)]
|
|
61
|
+
assert not offenders, (
|
|
62
|
+
f"Test project names must come from MESHCODE_TEST_PROJECT, never "
|
|
63
|
+
f"a literal — offenders: {offenders}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestLiveMeshMarker:
|
|
67
|
+
def test_agent_registering_files_carry_marker(self):
|
|
68
|
+
"""Every file that calls mc_register_agent against the real backend
|
|
69
|
+
(i.e. without mocking sb_rpc) must be deselectable via the marker."""
|
|
70
|
+
for name, src in _test_sources():
|
|
71
|
+
code = "\n".join(_code_lines(src))
|
|
72
|
+
registers = re.search(r"""sb_rpc\(\s*['"]mc_register_agent['"]""", code)
|
|
73
|
+
mocked = re.search(r"patch.*sb_rpc|sb_rpc\s*=|monkeypatch", code)
|
|
74
|
+
if registers and not mocked:
|
|
75
|
+
assert re.search(r"pytestmark\s*=\s*pytest\.mark\.live_mesh", code), (
|
|
76
|
+
f"{name} registers agents on the live backend but has no "
|
|
77
|
+
f"module-level `pytestmark = pytest.mark.live_mesh`")
|
|
78
|
+
|
|
79
|
+
def test_marker_registered_in_pyproject(self):
|
|
80
|
+
cfg = PYPROJECT.read_text(errors="replace")
|
|
81
|
+
assert re.search(r"live_mesh\s*:", cfg), (
|
|
82
|
+
"live_mesh marker must stay registered under "
|
|
83
|
+
"[tool.pytest.ini_options] markers in pyproject.toml")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TestLoudTeardown:
|
|
87
|
+
def test_unregister_results_are_checked(self):
|
|
88
|
+
"""sb_rpc returns errors as a dict, not an exception — every
|
|
89
|
+
mc_unregister_agent caller must inspect the result."""
|
|
90
|
+
for name, src in _test_sources():
|
|
91
|
+
code = "\n".join(_code_lines(src))
|
|
92
|
+
if "mc_unregister_agent" not in code:
|
|
93
|
+
continue
|
|
94
|
+
# The call's result must be bound and error-checked.
|
|
95
|
+
assert re.search(r"=\s*be\.sb_rpc\(\s*['\"]mc_unregister_agent", code), (
|
|
96
|
+
f"{name}: mc_unregister_agent result is discarded — a 404 "
|
|
97
|
+
f"would silently leak fixture agents (incident 2026-06-10)")
|
|
98
|
+
assert re.search(r"""\.get\(\s*['"](_)?error['"]""", code), (
|
|
99
|
+
f"{name}: mc_unregister_agent result is never checked for an "
|
|
100
|
+
f"error key")
|
|
@@ -18,10 +18,17 @@ import time
|
|
|
18
18
|
import unittest
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
|
|
21
|
+
import pytest
|
|
22
|
+
|
|
21
23
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
22
24
|
|
|
23
25
|
os.environ.setdefault("SUPABASE_URL", "https://gjinagyyjttyxnaoavnz.supabase.co")
|
|
24
26
|
|
|
27
|
+
# Registers agents and sends messages on the REAL backend — opt-in only
|
|
28
|
+
# (incident 2026-06-10: mread-* fixtures leaked into the live mesh-dev
|
|
29
|
+
# roster). Deselect in CI/smoke with: -m "not live_mesh"
|
|
30
|
+
pytestmark = pytest.mark.live_mesh
|
|
31
|
+
|
|
25
32
|
|
|
26
33
|
def _get_api_key():
|
|
27
34
|
key = os.environ.get("MESHCODE_API_KEY")
|
|
@@ -36,9 +43,16 @@ def _get_api_key():
|
|
|
36
43
|
|
|
37
44
|
|
|
38
45
|
API_KEY = _get_api_key()
|
|
39
|
-
|
|
46
|
+
# NEVER default to a live mesh (the old hardcoded "mesh-dev" put mread-*
|
|
47
|
+
# fixtures on the production dashboard). Unset = tests SKIP.
|
|
48
|
+
PROJECT = os.environ.get("MESHCODE_TEST_PROJECT")
|
|
40
49
|
PROJECT_ID = None
|
|
41
50
|
|
|
51
|
+
_LIVE_GATE = unittest.skipUnless(
|
|
52
|
+
API_KEY and PROJECT,
|
|
53
|
+
"MESHCODE_TEST_PROJECT (and an API key) required — live-mesh tests are "
|
|
54
|
+
"opt-in and must point at a dedicated test meshwork (incident 2026-06-10)")
|
|
55
|
+
|
|
42
56
|
|
|
43
57
|
def _resolve():
|
|
44
58
|
global PROJECT_ID
|
|
@@ -57,7 +71,7 @@ AGENT_A = f"mread-a-{int(time.time()) % 10000}"
|
|
|
57
71
|
AGENT_B = f"mread-b-{int(time.time()) % 10000}"
|
|
58
72
|
|
|
59
73
|
|
|
60
|
-
@
|
|
74
|
+
@_LIVE_GATE
|
|
61
75
|
class TestBatchMarkRead(unittest.TestCase):
|
|
62
76
|
|
|
63
77
|
@classmethod
|
|
@@ -77,16 +91,25 @@ class TestBatchMarkRead(unittest.TestCase):
|
|
|
77
91
|
|
|
78
92
|
@classmethod
|
|
79
93
|
def tearDownClass(cls):
|
|
94
|
+
# LOUD on failure — the old except:pass (plus never checking sb_rpc's
|
|
95
|
+
# returned error dict) let a 404 on mc_unregister_agent leak mread-*
|
|
96
|
+
# fixtures into the roster forever (incident 2026-06-10).
|
|
80
97
|
from meshcode.meshcode_mcp import backend as be
|
|
98
|
+
failures = []
|
|
81
99
|
for name in [AGENT_A, AGENT_B]:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
100
|
+
result = be.sb_rpc("mc_unregister_agent", {
|
|
101
|
+
"p_api_key": API_KEY,
|
|
102
|
+
"p_project_id": cls.pid,
|
|
103
|
+
"p_agent_name": name,
|
|
104
|
+
})
|
|
105
|
+
# sb_rpc reports errors as a return VALUE, not an exception.
|
|
106
|
+
if isinstance(result, dict) and (result.get("error") or result.get("_error")):
|
|
107
|
+
failures.append(f"{name}: {result}")
|
|
108
|
+
if failures:
|
|
109
|
+
raise AssertionError(
|
|
110
|
+
"tearDown could not unregister test agents — they are now "
|
|
111
|
+
f"ORPHANED in '{PROJECT}' and must be removed by hand:\n "
|
|
112
|
+
+ "\n ".join(failures))
|
|
90
113
|
|
|
91
114
|
def test_01_batch_mark_read(self):
|
|
92
115
|
"""Send 5 messages, read_inbox marks all read in one call."""
|
|
@@ -129,7 +152,7 @@ class TestBatchMarkRead(unittest.TestCase):
|
|
|
129
152
|
self.assertEqual(count, 0, f"Pending should be 0 after mark_read, got {count}")
|
|
130
153
|
|
|
131
154
|
|
|
132
|
-
@
|
|
155
|
+
@_LIVE_GATE
|
|
133
156
|
class TestEffectiveStatusIntegration(unittest.TestCase):
|
|
134
157
|
"""Live integration test for mc_agent_effective_status."""
|
|
135
158
|
|
|
@@ -17,10 +17,17 @@ import time
|
|
|
17
17
|
import unittest
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
20
22
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
21
23
|
|
|
22
24
|
os.environ.setdefault("SUPABASE_URL", "https://gjinagyyjttyxnaoavnz.supabase.co")
|
|
23
25
|
|
|
26
|
+
# Sends real messages (incl. impersonation probes a human can see on the
|
|
27
|
+
# dashboard) against the REAL backend — opt-in only (incident 2026-06-10).
|
|
28
|
+
# Deselect in CI/smoke with: -m "not live_mesh"
|
|
29
|
+
pytestmark = pytest.mark.live_mesh
|
|
30
|
+
|
|
24
31
|
|
|
25
32
|
def _get_api_key():
|
|
26
33
|
key = os.environ.get("MESHCODE_API_KEY")
|
|
@@ -35,11 +42,18 @@ def _get_api_key():
|
|
|
35
42
|
|
|
36
43
|
|
|
37
44
|
API_KEY = _get_api_key()
|
|
38
|
-
#
|
|
39
|
-
|
|
45
|
+
# NEVER default to a live mesh (the old hardcoded "mesh-dev" sent the
|
|
46
|
+
# impersonation probes of this file to the production dashboard). Unset =
|
|
47
|
+
# tests SKIP.
|
|
48
|
+
OWN_PROJECT = os.environ.get("MESHCODE_TEST_PROJECT")
|
|
40
49
|
OWN_PROJECT_ID = None
|
|
41
50
|
FAKE_PROJECT_ID = "00000000-0000-0000-0000-000000000000"
|
|
42
51
|
|
|
52
|
+
_LIVE_GATE = unittest.skipUnless(
|
|
53
|
+
API_KEY and OWN_PROJECT,
|
|
54
|
+
"MESHCODE_TEST_PROJECT (and an API key) required — live-mesh tests are "
|
|
55
|
+
"opt-in and must point at a dedicated test meshwork (incident 2026-06-10)")
|
|
56
|
+
|
|
43
57
|
|
|
44
58
|
def _resolve_project():
|
|
45
59
|
global OWN_PROJECT_ID
|
|
@@ -57,7 +71,7 @@ def _resolve_project():
|
|
|
57
71
|
return OWN_PROJECT_ID
|
|
58
72
|
|
|
59
73
|
|
|
60
|
-
@
|
|
74
|
+
@_LIVE_GATE
|
|
61
75
|
class TestCrossTenantIsolation(unittest.TestCase):
|
|
62
76
|
"""Agent in project X cannot access project Y data."""
|
|
63
77
|
|
|
@@ -135,7 +149,7 @@ class TestCrossTenantIsolation(unittest.TestCase):
|
|
|
135
149
|
self.assertTrue(is_safe, f"Unexpected result: {result}")
|
|
136
150
|
|
|
137
151
|
|
|
138
|
-
@
|
|
152
|
+
@_LIVE_GATE
|
|
139
153
|
class TestImpersonation(unittest.TestCase):
|
|
140
154
|
"""Cannot impersonate another agent or user."""
|
|
141
155
|
|
|
@@ -202,7 +216,7 @@ class TestImpersonation(unittest.TestCase):
|
|
|
202
216
|
f"Empty API key should be rejected: {result}")
|
|
203
217
|
|
|
204
218
|
|
|
205
|
-
@
|
|
219
|
+
@_LIVE_GATE
|
|
206
220
|
class TestRPCErrorConsistency(unittest.TestCase):
|
|
207
221
|
"""All RPCs should return consistent error format."""
|
|
208
222
|
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Stop-Ghost-Terminal Regression Tests (task 91201315)
|
|
3
|
+
=====================================================
|
|
4
|
+
Bug (Ian live repro 2026-06-10, back-2 pid 26078): dashboard Stop -> agent
|
|
5
|
+
exits its loop cleanly, clears instance_id and stops heartbeating BEFORE the
|
|
6
|
+
hostd sweep -> mc_agents_to_stop / mc_agents_to_force_kill (both gate on
|
|
7
|
+
last_heartbeat < 90s) never return it -> hostd logs '0 stopped' forever and
|
|
8
|
+
the Terminal window stays open with a live claude.
|
|
9
|
+
|
|
10
|
+
Fix under test:
|
|
11
|
+
1. hostd._do_stopped_ghost_sweep — roster-based sweep (mc_host_config_get)
|
|
12
|
+
for desired_state='stopped' + stale heartbeat, wired into the poll loop.
|
|
13
|
+
2. hostd._discover_launcher_child_pids — finds the agent process under its
|
|
14
|
+
launcher bash (on POSIX `meshcode run` execvp's claude, so the old
|
|
15
|
+
`pgrep -f "meshcode run <target>"` sees nothing for visible agents).
|
|
16
|
+
3. hostd._term_ghost_pid — SIGTERM the agent pid WITHOUT killpg so the
|
|
17
|
+
launcher bash survives to see rc=143.
|
|
18
|
+
4. protocol_handler wrapper — rc=143 (SIGTERM) closes the window like a
|
|
19
|
+
clean exit; any other non-zero rc still leaves it open for debugging.
|
|
20
|
+
|
|
21
|
+
Safety invariants (commander conditions):
|
|
22
|
+
- NEVER kill a session the human opened by hand (`claude --resume` in a
|
|
23
|
+
plain tab): discovery only matches `meshcode run <target>` cmdlines or
|
|
24
|
+
direct children of the target's own launcher .command.
|
|
25
|
+
- Token-safe target matching: 'back' must never match 'back-2'.
|
|
26
|
+
|
|
27
|
+
No live DB or processes needed: pure-function tests with subprocess mocked,
|
|
28
|
+
plus source-pattern checks (pattern of tests/test_security_regressions.py).
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
pytest tests/test_stop_ghost_terminal.py -v
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import inspect
|
|
35
|
+
import re
|
|
36
|
+
import sys
|
|
37
|
+
import types
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
41
|
+
|
|
42
|
+
from meshcode import hostd, protocol_handler # noqa: E402
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _code_only(func) -> str:
|
|
46
|
+
"""Source of `func` with the docstring and #-comments stripped, so the
|
|
47
|
+
assertions below test the CODE, not prose that mentions the same names."""
|
|
48
|
+
src = inspect.getsource(func)
|
|
49
|
+
src = re.sub(r'""".*?"""', "", src, count=1, flags=re.DOTALL)
|
|
50
|
+
return "\n".join(ln.split("#", 1)[0] for ln in src.splitlines())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# 1. Launcher-child discovery (the execvp'd-claude hole)
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
# Shaped like real `ps -axo pid=,ppid=,args=` output from the live repro box:
|
|
58
|
+
# back-2 ghost tree + a SIBLING agent 'back' launcher (prefix trap) + a claude
|
|
59
|
+
# the human opened by hand in a plain zsh tab (must NEVER be discovered).
|
|
60
|
+
_FAKE_PS = """\
|
|
61
|
+
26061 26060 login -pf user
|
|
62
|
+
26075 26061 /bin/bash /Users/u/.meshcode/launchers/mesh-dev_back-2.command
|
|
63
|
+
26078 26075 claude --dangerously-skip-permissions -- boot
|
|
64
|
+
26140 26078 /usr/bin/python3 -m meshcode.meshcode_mcp serve
|
|
65
|
+
30001 26061 /bin/bash /Users/u/.meshcode/launchers/mesh-dev_back.command
|
|
66
|
+
30002 30001 claude --dangerously-skip-permissions -- boot
|
|
67
|
+
40001 40000 -zsh
|
|
68
|
+
40002 40001 claude --resume abc123
|
|
69
|
+
50001 50000 vim notes.txt
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _patch_ps(monkeypatch, stdout=_FAKE_PS):
|
|
74
|
+
def fake_run(cmd, **kw):
|
|
75
|
+
assert cmd[0] == "ps", f"unexpected subprocess in discovery: {cmd}"
|
|
76
|
+
return types.SimpleNamespace(stdout=stdout, returncode=0)
|
|
77
|
+
monkeypatch.setattr(hostd.subprocess, "run", fake_run)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestLauncherChildDiscovery:
|
|
81
|
+
def test_finds_claude_child_of_target_launcher(self, monkeypatch):
|
|
82
|
+
_patch_ps(monkeypatch)
|
|
83
|
+
assert hostd._discover_launcher_child_pids("mesh-dev/back-2") == [26078]
|
|
84
|
+
|
|
85
|
+
def test_token_safe_back_does_not_match_back2(self, monkeypatch):
|
|
86
|
+
_patch_ps(monkeypatch)
|
|
87
|
+
# 'mesh-dev/back' must resolve to ITS OWN launcher child (30002),
|
|
88
|
+
# never to back-2's tree — the 24e3dd44 prefix-kill class of bug.
|
|
89
|
+
assert hostd._discover_launcher_child_pids("mesh-dev/back") == [30002]
|
|
90
|
+
|
|
91
|
+
def test_never_matches_hand_opened_claude_resume(self, monkeypatch):
|
|
92
|
+
_patch_ps(monkeypatch)
|
|
93
|
+
for target in ("mesh-dev/back-2", "mesh-dev/back"):
|
|
94
|
+
kids = hostd._discover_launcher_child_pids(target)
|
|
95
|
+
assert 40002 not in kids, "human `claude --resume` session would be killed!"
|
|
96
|
+
|
|
97
|
+
def test_grandchild_mcp_serve_not_returned(self, monkeypatch):
|
|
98
|
+
# SIGTERM goes to the claude child only; the mcp-serve grandchild exits
|
|
99
|
+
# with its stdio. Returning it would double-kill into the wrong layer.
|
|
100
|
+
_patch_ps(monkeypatch)
|
|
101
|
+
assert 26140 not in hostd._discover_launcher_child_pids("mesh-dev/back-2")
|
|
102
|
+
|
|
103
|
+
def test_empty_on_ps_failure(self, monkeypatch):
|
|
104
|
+
def boom(cmd, **kw):
|
|
105
|
+
raise OSError("no ps")
|
|
106
|
+
monkeypatch.setattr(hostd.subprocess, "run", boom)
|
|
107
|
+
assert hostd._discover_launcher_child_pids("mesh-dev/back-2") == []
|
|
108
|
+
|
|
109
|
+
def test_discover_agent_pids_includes_launcher_children(self, monkeypatch):
|
|
110
|
+
"""_discover_agent_pids (used by _do_stops/_do_force_kills/_do_reap too)
|
|
111
|
+
must now see the execvp'd claude via the launcher path."""
|
|
112
|
+
def fake_run(cmd, **kw):
|
|
113
|
+
if cmd[0] == "pgrep": # old path: nothing post-execvp
|
|
114
|
+
return types.SimpleNamespace(stdout="", returncode=1)
|
|
115
|
+
return types.SimpleNamespace(stdout=_FAKE_PS, returncode=0)
|
|
116
|
+
monkeypatch.setattr(hostd.subprocess, "run", fake_run)
|
|
117
|
+
assert 26078 in hostd._discover_agent_pids("mesh-dev/back-2")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# 2. _term_ghost_pid kill semantics
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
class TestTermGhostPid:
|
|
125
|
+
def test_no_killpg_ever(self):
|
|
126
|
+
"""killpg would take the launcher bash down with the agent — the bash must
|
|
127
|
+
survive to see rc=143 and close its own window."""
|
|
128
|
+
assert "killpg" not in _code_only(hostd._term_ghost_pid)
|
|
129
|
+
|
|
130
|
+
def test_skips_unreadable_or_mismatched_cmdline(self, monkeypatch):
|
|
131
|
+
monkeypatch.setattr(hostd, "_pid_cmdline", lambda p: "")
|
|
132
|
+
sent = []
|
|
133
|
+
monkeypatch.setattr(hostd.os, "kill", lambda *a: sent.append(a))
|
|
134
|
+
monkeypatch.setattr(hostd.sys, "platform", "darwin")
|
|
135
|
+
assert hostd._term_ghost_pid("mesh-dev/back-2", 12345) is False
|
|
136
|
+
assert sent == [], "killed a pid whose cmdline could not be verified"
|
|
137
|
+
|
|
138
|
+
def test_sigterm_then_done_when_process_exits(self, monkeypatch):
|
|
139
|
+
monkeypatch.setattr(hostd.sys, "platform", "darwin")
|
|
140
|
+
monkeypatch.setattr(hostd, "_pid_cmdline",
|
|
141
|
+
lambda p: "claude --dangerously-skip-permissions -- boot")
|
|
142
|
+
monkeypatch.setattr(hostd.time, "sleep", lambda s: None)
|
|
143
|
+
monkeypatch.setattr(hostd, "_kill_heartbeat_fork", lambda t: None)
|
|
144
|
+
calls = []
|
|
145
|
+
|
|
146
|
+
def fake_kill(pid, sig):
|
|
147
|
+
calls.append((pid, sig))
|
|
148
|
+
if sig == 0: # liveness probe: already gone
|
|
149
|
+
raise ProcessLookupError()
|
|
150
|
+
monkeypatch.setattr(hostd.os, "kill", fake_kill)
|
|
151
|
+
assert hostd._term_ghost_pid("mesh-dev/back-2", 26078) is True
|
|
152
|
+
assert (26078, hostd._signal.SIGTERM) in calls
|
|
153
|
+
assert (26078, hostd._signal.SIGKILL) not in calls
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# 3. Ghost sweep gating + loop wiring (source checks)
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
class TestGhostSweep:
|
|
161
|
+
SRC = None
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def setup_class(cls):
|
|
165
|
+
cls.SRC = _code_only(hostd._do_stopped_ghost_sweep)
|
|
166
|
+
|
|
167
|
+
def test_only_desired_state_stopped(self):
|
|
168
|
+
assert '"stopped"' in self.SRC and "desired_state" in self.SRC
|
|
169
|
+
|
|
170
|
+
def test_fresh_heartbeat_left_to_rpc_sweeps(self):
|
|
171
|
+
# < 90s heartbeat = the cooperative must_exit / RPC kill paths own it.
|
|
172
|
+
assert re.search(r"heartbeat_age_s", self.SRC) and "90" in self.SRC
|
|
173
|
+
|
|
174
|
+
def test_uses_roster_not_heartbeat_gated_rpc(self):
|
|
175
|
+
assert "mc_host_config_get" in self.SRC
|
|
176
|
+
assert "mc_agents_to_stop" not in self.SRC
|
|
177
|
+
|
|
178
|
+
def test_wired_into_poll_loop(self):
|
|
179
|
+
full = Path(hostd.__file__).read_text(encoding="utf-8", errors="replace")
|
|
180
|
+
m = re.search(r"_do_stopped_ghost_sweep\(api_key,\s*host_id\)", full)
|
|
181
|
+
assert m, "_do_stopped_ghost_sweep is defined but never called in the poll loop"
|
|
182
|
+
|
|
183
|
+
def test_sweep_runs_dead_window_reap(self):
|
|
184
|
+
assert "_close_dead_terminal_windows" in self.SRC
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# 4. Wrapper rc=143 clean-close
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
class TestWrapperRc143:
|
|
192
|
+
SRC = None
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def setup_class(cls):
|
|
196
|
+
cls.SRC = Path(protocol_handler.__file__).read_text(encoding="utf-8",
|
|
197
|
+
errors="replace")
|
|
198
|
+
|
|
199
|
+
def test_close_condition_accepts_0_and_143(self):
|
|
200
|
+
m = re.search(r"lines\.append\('if (.+?)'\)", self.SRC)
|
|
201
|
+
assert m, "wrapper close-condition line not found"
|
|
202
|
+
cond = m.group(1)
|
|
203
|
+
assert '"$MC_RC" = "0"' in cond
|
|
204
|
+
assert '"$MC_RC" = "143"' in cond
|
|
205
|
+
|
|
206
|
+
def test_other_nonzero_rcs_stay_open(self):
|
|
207
|
+
m = re.search(r"lines\.append\('if (.+?)'\)", self.SRC)
|
|
208
|
+
cond = m.group(1)
|
|
209
|
+
# Only the two literal values may close the window — no wildcard/negation.
|
|
210
|
+
assert "!=" not in cond and "-ne" not in cond
|
|
211
|
+
assert re.findall(r'"\$MC_RC" = "(\d+)"', cond) == ["0", "143"]
|
|
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
|