meshcode 2.10.41__tar.gz → 2.10.43__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.10.41 → meshcode-2.10.43}/PKG-INFO +1 -1
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/__init__.py +1 -1
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/comms_v4.py +71 -13
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/meshcode_mcp/backend.py +144 -20
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/meshcode_mcp/server.py +147 -33
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/preferences.py +22 -5
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/run_agent.py +187 -16
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/secrets.py +29 -8
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/setup_clients.py +20 -0
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode.egg-info/PKG-INFO +1 -1
- {meshcode-2.10.41 → meshcode-2.10.43}/pyproject.toml +1 -1
- {meshcode-2.10.41 → meshcode-2.10.43}/README.md +0 -0
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/ascii_art.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/cli.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/invites.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/launcher.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/launcher_install.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/meshcode_mcp/__init__.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/meshcode_mcp/__main__.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/meshcode_mcp/realtime.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/meshcode_mcp/test_backend.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/meshcode_mcp/test_realtime.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/protocol_v2.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/self_update.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode.egg-info/SOURCES.txt +0 -0
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode.egg-info/dependency_links.txt +0 -0
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode.egg-info/entry_points.txt +0 -0
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode.egg-info/requires.txt +0 -0
- {meshcode-2.10.41 → meshcode-2.10.43}/meshcode.egg-info/top_level.txt +0 -0
- {meshcode-2.10.41 → meshcode-2.10.43}/setup.cfg +0 -0
- {meshcode-2.10.41 → meshcode-2.10.43}/tests/test_status_enum_coverage.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""MeshCode — Real-time communication between AI agents."""
|
|
2
|
-
__version__ = "2.10.
|
|
2
|
+
__version__ = "2.10.43"
|
|
@@ -74,7 +74,11 @@ SUPABASE_KEY = os.environ.get("SUPABASE_KEY") or _env_file.get("SUPABASE_KEY") o
|
|
|
74
74
|
SCHEMA = "meshcode"
|
|
75
75
|
|
|
76
76
|
# Local paths for session/TTY tracking (still needed for nudge)
|
|
77
|
-
|
|
77
|
+
# NEVER use Path(__file__).parent — writing into the package directory
|
|
78
|
+
# creates residual files that turn site-packages/meshcode/ into a
|
|
79
|
+
# namespace package, breaking editable installs and __version__ imports.
|
|
80
|
+
COMMS_DIR = Path.home() / ".meshcode"
|
|
81
|
+
COMMS_DIR.mkdir(parents=True, exist_ok=True)
|
|
78
82
|
SESSIONS_DIR = COMMS_DIR / "sessions"
|
|
79
83
|
LOG_FILE = COMMS_DIR / "comms.log"
|
|
80
84
|
|
|
@@ -310,34 +314,86 @@ def mark_nudged(project, name):
|
|
|
310
314
|
nf.write_text(str(time.time()), encoding="utf-8")
|
|
311
315
|
|
|
312
316
|
|
|
317
|
+
def _sanitize_notification_text(text: str) -> str:
|
|
318
|
+
"""Strip characters that could escape shell/PS quoting contexts."""
|
|
319
|
+
# Remove quotes, backticks, dollar signs, semicolons, pipes, and backslashes
|
|
320
|
+
# to prevent injection in osascript, PowerShell, and notify-send.
|
|
321
|
+
return "".join(c for c in text if c not in '"\'`$;|\\<>(){}')
|
|
322
|
+
|
|
323
|
+
|
|
313
324
|
def send_notification(project, name, from_agent, pending=1):
|
|
314
325
|
"""Cross-platform notification: macOS (osascript), Windows (PowerShell toast), Linux (notify-send)."""
|
|
315
|
-
title = f"MeshCode [{project}] → {name}"
|
|
316
|
-
body = f"{pending} mensaje(s) pendiente(s) de {from_agent}"
|
|
326
|
+
title = _sanitize_notification_text(f"MeshCode [{project}] → {name}")
|
|
327
|
+
body = _sanitize_notification_text(f"{pending} mensaje(s) pendiente(s) de {from_agent}")
|
|
317
328
|
try:
|
|
318
329
|
if sys.platform == "darwin":
|
|
330
|
+
# osascript: pass as -e argument, quotes are stripped by sanitizer
|
|
319
331
|
subprocess.run(['osascript', '-e',
|
|
320
332
|
f'display notification "{body}" with title "{title}" sound name "Ping"'],
|
|
321
333
|
capture_output=True, timeout=3)
|
|
322
334
|
elif sys.platform == "win32":
|
|
335
|
+
# PowerShell: use single-quoted strings (no variable expansion)
|
|
336
|
+
# and pass title/body via -replace to avoid any interpolation
|
|
323
337
|
ps_script = (
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
f
|
|
328
|
-
f
|
|
329
|
-
|
|
330
|
-
|
|
338
|
+
'[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null; '
|
|
339
|
+
'$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(0); '
|
|
340
|
+
'$text = $xml.GetElementsByTagName("text"); '
|
|
341
|
+
f"$text[0].AppendChild($xml.CreateTextNode('{title}')); "
|
|
342
|
+
f"$text[1].AppendChild($xml.CreateTextNode('{body}')); "
|
|
343
|
+
'$toast = [Windows.UI.Notifications.ToastNotification]::new($xml); '
|
|
344
|
+
'[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("MeshCode").Show($toast)'
|
|
331
345
|
)
|
|
332
|
-
subprocess.run(['powershell', '-Command', ps_script],
|
|
346
|
+
subprocess.run(['powershell', '-NoProfile', '-Command', ps_script],
|
|
333
347
|
capture_output=True, timeout=5)
|
|
334
348
|
else:
|
|
335
|
-
# Linux
|
|
349
|
+
# Linux: notify-send takes title and body as separate args (no shell)
|
|
336
350
|
subprocess.run(['notify-send', title, body], capture_output=True, timeout=3)
|
|
337
351
|
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
|
338
352
|
pass
|
|
339
353
|
|
|
340
354
|
|
|
355
|
+
def _nudge_windows_terminal(name, pending, tty, pid):
|
|
356
|
+
"""Activate the agent's terminal window on Windows.
|
|
357
|
+
|
|
358
|
+
Uses PowerShell + kernel32 to find the console window by process ID and
|
|
359
|
+
bring it to the foreground. Does NOT send keystrokes — that would risk
|
|
360
|
+
confirming prompts or interrupting bypass mode. The window activation
|
|
361
|
+
combined with the desktop notification is enough to alert the user.
|
|
362
|
+
"""
|
|
363
|
+
if not pid:
|
|
364
|
+
return
|
|
365
|
+
try:
|
|
366
|
+
pid = int(pid)
|
|
367
|
+
except (ValueError, TypeError):
|
|
368
|
+
return
|
|
369
|
+
try:
|
|
370
|
+
ps_script = f'''
|
|
371
|
+
$proc = Get-Process -Id {pid} -ErrorAction SilentlyContinue
|
|
372
|
+
if ($proc -and $proc.MainWindowHandle -ne 0) {{
|
|
373
|
+
Add-Type @"
|
|
374
|
+
using System;
|
|
375
|
+
using System.Runtime.InteropServices;
|
|
376
|
+
public class WinApi {{
|
|
377
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
|
|
378
|
+
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
|
379
|
+
}}
|
|
380
|
+
"@
|
|
381
|
+
[WinApi]::ShowWindow($proc.MainWindowHandle, 9)
|
|
382
|
+
[WinApi]::SetForegroundWindow($proc.MainWindowHandle)
|
|
383
|
+
}}
|
|
384
|
+
'''
|
|
385
|
+
subprocess.run(
|
|
386
|
+
["powershell", "-NoProfile", "-Command", ps_script],
|
|
387
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
388
|
+
timeout=10,
|
|
389
|
+
)
|
|
390
|
+
log_msg(f"[nudge] Windows: activated terminal for {name} (pid={pid})")
|
|
391
|
+
except subprocess.TimeoutExpired:
|
|
392
|
+
log_msg(f"[nudge] Windows: PowerShell timed out for {name}")
|
|
393
|
+
except Exception as e:
|
|
394
|
+
log_msg(f"[nudge] Windows: failed for {name}: {e}")
|
|
395
|
+
|
|
396
|
+
|
|
341
397
|
def _is_pid_alive(pid):
|
|
342
398
|
"""Return True iff the given pid corresponds to a live process."""
|
|
343
399
|
if not pid:
|
|
@@ -782,8 +838,10 @@ def nudge_agent(project, name, from_agent=""):
|
|
|
782
838
|
message = f"Tienes {pending} mensaje(s). Revisa: meshcode read {project} {name}"
|
|
783
839
|
escaped_message = message.replace('\\', '\\\\').replace('"', '\\"')
|
|
784
840
|
|
|
785
|
-
#
|
|
841
|
+
# Non-macOS: use platform-specific terminal nudge
|
|
786
842
|
if sys.platform != "darwin":
|
|
843
|
+
if sys.platform == "win32":
|
|
844
|
+
_nudge_windows_terminal(name, pending, tty, cached_pid)
|
|
787
845
|
send_notification(project, name, from_agent, pending)
|
|
788
846
|
mark_nudged(project, name)
|
|
789
847
|
return True
|
|
@@ -23,49 +23,114 @@ from urllib.request import Request, urlopen
|
|
|
23
23
|
# ── Circuit Breaker ──────────────────────────────────────────────
|
|
24
24
|
# Protects against cascading failures when Supabase is down.
|
|
25
25
|
# States: CLOSED (normal) → OPEN (reject fast) → HALF_OPEN (probe)
|
|
26
|
+
#
|
|
27
|
+
# v2 fixes (2026-04-18):
|
|
28
|
+
# 1. Rolling failure window — only recent failures count toward threshold
|
|
29
|
+
# 2. Exponential backoff — half-open probe failure doubles recovery timeout
|
|
30
|
+
# 3. Max open duration — force-resets after 5 min regardless of probe results
|
|
31
|
+
# 4. Pool flush on recovery — clears stale sockets before half-open probe
|
|
32
|
+
# 5. sb_rpc records ONE failure per call, not one per retry attempt
|
|
26
33
|
class _CircuitBreaker:
|
|
27
34
|
CLOSED = "closed"
|
|
28
35
|
OPEN = "open"
|
|
29
36
|
HALF_OPEN = "half_open"
|
|
30
37
|
|
|
31
|
-
def __init__(self, failure_threshold: int = 5, recovery_timeout: float =
|
|
38
|
+
def __init__(self, failure_threshold: int = 5, recovery_timeout: float = 10.0,
|
|
39
|
+
max_recovery_timeout: float = 60.0, window_seconds: float = 60.0,
|
|
40
|
+
max_open_seconds: float = 300.0):
|
|
32
41
|
self.failure_threshold = failure_threshold
|
|
33
|
-
self.
|
|
42
|
+
self._base_recovery_timeout = recovery_timeout
|
|
43
|
+
self._max_recovery_timeout = max_recovery_timeout
|
|
44
|
+
self._window_seconds = window_seconds
|
|
45
|
+
self._max_open_seconds = max_open_seconds
|
|
34
46
|
self.state = self.CLOSED
|
|
35
|
-
self.
|
|
47
|
+
self._failures: list = []
|
|
48
|
+
self._current_recovery_timeout = recovery_timeout
|
|
49
|
+
self._opened_at = 0.0
|
|
36
50
|
self.last_failure_time = 0.0
|
|
37
51
|
self._lock = _threading.Lock()
|
|
52
|
+
self._pool_ref = None
|
|
53
|
+
|
|
54
|
+
def set_pool(self, pool):
|
|
55
|
+
self._pool_ref = pool
|
|
56
|
+
|
|
57
|
+
def _prune_old_failures(self):
|
|
58
|
+
cutoff = _time.monotonic() - self._window_seconds
|
|
59
|
+
self._failures = [t for t in self._failures if t > cutoff]
|
|
60
|
+
|
|
61
|
+
def _flush_pool(self):
|
|
62
|
+
if self._pool_ref:
|
|
63
|
+
try:
|
|
64
|
+
self._pool_ref.close()
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
38
67
|
|
|
39
68
|
def can_execute(self) -> bool:
|
|
40
69
|
with self._lock:
|
|
41
70
|
if self.state == self.CLOSED:
|
|
42
71
|
return True
|
|
72
|
+
now = _time.monotonic()
|
|
43
73
|
if self.state == self.OPEN:
|
|
44
|
-
if
|
|
74
|
+
if now - self._opened_at >= self._max_open_seconds:
|
|
75
|
+
log.warning("circuit breaker: max open duration (%ds) reached — force half-open",
|
|
76
|
+
int(self._max_open_seconds))
|
|
45
77
|
self.state = self.HALF_OPEN
|
|
78
|
+
self._failures.clear()
|
|
79
|
+
self._current_recovery_timeout = self._base_recovery_timeout
|
|
80
|
+
self._flush_pool()
|
|
81
|
+
return True
|
|
82
|
+
if now - self.last_failure_time >= self._current_recovery_timeout:
|
|
83
|
+
log.info("circuit breaker: recovery timeout elapsed — half-open probe")
|
|
84
|
+
self.state = self.HALF_OPEN
|
|
85
|
+
self._flush_pool()
|
|
46
86
|
return True
|
|
47
87
|
return False
|
|
48
|
-
# HALF_OPEN: allow one probe
|
|
49
88
|
return True
|
|
50
89
|
|
|
51
90
|
def record_success(self) -> None:
|
|
52
91
|
with self._lock:
|
|
53
|
-
|
|
92
|
+
prev = self.state
|
|
93
|
+
self._failures.clear()
|
|
54
94
|
self.state = self.CLOSED
|
|
95
|
+
self._current_recovery_timeout = self._base_recovery_timeout
|
|
96
|
+
if prev != self.CLOSED:
|
|
97
|
+
log.info("circuit breaker: recovered — back to CLOSED")
|
|
55
98
|
|
|
56
99
|
def record_failure(self) -> None:
|
|
57
100
|
with self._lock:
|
|
58
|
-
|
|
59
|
-
self.
|
|
60
|
-
|
|
101
|
+
now = _time.monotonic()
|
|
102
|
+
self._failures.append(now)
|
|
103
|
+
self.last_failure_time = now
|
|
104
|
+
self._prune_old_failures()
|
|
105
|
+
if self.state == self.HALF_OPEN:
|
|
106
|
+
self.state = self.OPEN
|
|
107
|
+
self._opened_at = now
|
|
108
|
+
self._current_recovery_timeout = min(
|
|
109
|
+
self._current_recovery_timeout * 2,
|
|
110
|
+
self._max_recovery_timeout
|
|
111
|
+
)
|
|
112
|
+
log.warning("circuit breaker: half-open probe failed — OPEN (next probe in %ds)",
|
|
113
|
+
int(self._current_recovery_timeout))
|
|
114
|
+
elif len(self._failures) >= self.failure_threshold:
|
|
61
115
|
self.state = self.OPEN
|
|
116
|
+
self._opened_at = now
|
|
117
|
+
log.warning("circuit breaker: %d failures in %ds window — OPEN",
|
|
118
|
+
len(self._failures), int(self._window_seconds))
|
|
62
119
|
|
|
63
120
|
@property
|
|
64
121
|
def is_open(self) -> bool:
|
|
65
122
|
return self.state == self.OPEN
|
|
66
123
|
|
|
124
|
+
@property
|
|
125
|
+
def failure_count(self) -> int:
|
|
126
|
+
with self._lock:
|
|
127
|
+
self._prune_old_failures()
|
|
128
|
+
return len(self._failures)
|
|
129
|
+
|
|
67
130
|
|
|
68
|
-
_circuit = _CircuitBreaker(failure_threshold=5, recovery_timeout=10.0
|
|
131
|
+
_circuit = _CircuitBreaker(failure_threshold=5, recovery_timeout=10.0,
|
|
132
|
+
max_recovery_timeout=60.0, window_seconds=60.0,
|
|
133
|
+
max_open_seconds=300.0)
|
|
69
134
|
|
|
70
135
|
# Bake in production defaults — RLS-protected publishable key, safe to ship.
|
|
71
136
|
_DEFAULT_SUPABASE_URL = "https://gjinagyyjttyxnaoavnz.supabase.co"
|
|
@@ -180,6 +245,7 @@ class _ConnectionPool:
|
|
|
180
245
|
|
|
181
246
|
|
|
182
247
|
_pool = _ConnectionPool(SUPABASE_URL, pool_size=3)
|
|
248
|
+
_circuit.set_pool(_pool)
|
|
183
249
|
|
|
184
250
|
|
|
185
251
|
def _now_iso() -> str:
|
|
@@ -309,16 +375,15 @@ def sb_rpc(fn_name: str, params: Dict, *, _max_retries: int = 3) -> Any:
|
|
|
309
375
|
_bg_record("error", {"rpc": fn_name, "error": raw[:200]})
|
|
310
376
|
return result
|
|
311
377
|
else:
|
|
312
|
-
_circuit.record_failure()
|
|
313
378
|
last_err = raw[:200]
|
|
314
379
|
except (URLError, OSError, TimeoutError, http.client.HTTPException) as e:
|
|
315
|
-
_circuit.record_failure()
|
|
316
380
|
last_err = str(getattr(e, 'reason', e))
|
|
317
381
|
# Retry with jitter for transient errors (5xx, network)
|
|
318
382
|
if attempt < _max_retries - 1:
|
|
319
383
|
delay = (2 ** attempt) + _random.uniform(0, 1)
|
|
320
384
|
_time.sleep(delay)
|
|
321
|
-
# All retries exhausted
|
|
385
|
+
# All retries exhausted — record ONE failure for the entire call
|
|
386
|
+
_circuit.record_failure()
|
|
322
387
|
if _recording_enabled and fn_name not in _SKIP_RECORDING:
|
|
323
388
|
_bg_record("error", {"rpc": fn_name, "error": str(last_err)[:200], "retries_exhausted": True})
|
|
324
389
|
return {"_error": str(last_err)[:200] if last_err else "request failed after retries"}
|
|
@@ -409,34 +474,75 @@ def register_agent(project: str, name: str, role: str = "", api_key: Optional[st
|
|
|
409
474
|
}
|
|
410
475
|
|
|
411
476
|
|
|
477
|
+
# TTL cache for resolve_agent_name — avoids N+1 queries on every send().
|
|
478
|
+
# Key: (project_id, name) → (resolved_name, expiry_ts). Max 256 entries.
|
|
479
|
+
_AGENT_NAME_CACHE: Dict[tuple, tuple] = {}
|
|
480
|
+
_AGENT_NAME_CACHE_LOCK = _threading.Lock()
|
|
481
|
+
_AGENT_NAME_CACHE_TTL = 300 # 5 minutes
|
|
482
|
+
_AGENT_NAME_CACHE_MAX = 256
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _agent_name_cache_get(project_id: str, name: str) -> Optional[str]:
|
|
486
|
+
key = (project_id, name)
|
|
487
|
+
with _AGENT_NAME_CACHE_LOCK:
|
|
488
|
+
entry = _AGENT_NAME_CACHE.get(key)
|
|
489
|
+
if entry and entry[1] > _time.time():
|
|
490
|
+
return entry[0]
|
|
491
|
+
_AGENT_NAME_CACHE.pop(key, None)
|
|
492
|
+
return None
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _agent_name_cache_set(project_id: str, name: str, resolved: str) -> None:
|
|
496
|
+
key = (project_id, name)
|
|
497
|
+
with _AGENT_NAME_CACHE_LOCK:
|
|
498
|
+
if len(_AGENT_NAME_CACHE) >= _AGENT_NAME_CACHE_MAX:
|
|
499
|
+
# Evict oldest entries
|
|
500
|
+
now = _time.time()
|
|
501
|
+
expired = [k for k, v in _AGENT_NAME_CACHE.items() if v[1] <= now]
|
|
502
|
+
for k in expired:
|
|
503
|
+
del _AGENT_NAME_CACHE[k]
|
|
504
|
+
if len(_AGENT_NAME_CACHE) >= _AGENT_NAME_CACHE_MAX:
|
|
505
|
+
# Still full — evict first quarter by insertion order
|
|
506
|
+
to_evict = list(_AGENT_NAME_CACHE.keys())[:_AGENT_NAME_CACHE_MAX // 4]
|
|
507
|
+
for k in to_evict:
|
|
508
|
+
del _AGENT_NAME_CACHE[k]
|
|
509
|
+
_AGENT_NAME_CACHE[key] = (resolved, _time.time() + _AGENT_NAME_CACHE_TTL)
|
|
510
|
+
|
|
511
|
+
|
|
412
512
|
def resolve_agent_name(project_id: str, name: str) -> str:
|
|
413
513
|
"""Resolve a partial or exact agent name to the full registered name.
|
|
414
514
|
|
|
415
515
|
Returns the exact match if found, or a unique prefix/substring match.
|
|
416
516
|
Returns the original name unchanged if no match or ambiguous.
|
|
417
517
|
Broadcast target '*' is passed through unchanged.
|
|
518
|
+
Uses a TTL cache to avoid repeated DB lookups.
|
|
418
519
|
"""
|
|
419
520
|
if not name or name == "*":
|
|
420
521
|
return name
|
|
522
|
+
cached = _agent_name_cache_get(project_id, name)
|
|
523
|
+
if cached is not None:
|
|
524
|
+
return cached
|
|
421
525
|
# Exact match — fast path
|
|
422
526
|
exact = sb_select("mc_agents",
|
|
423
527
|
f"project_id=eq.{project_id}&name=eq.{quote(name)}",
|
|
424
528
|
limit=1)
|
|
425
529
|
if exact:
|
|
530
|
+
_agent_name_cache_set(project_id, name, name)
|
|
426
531
|
return name
|
|
427
532
|
# Fuzzy: prefix or substring match among registered agents
|
|
428
|
-
agents = sb_select("mc_agents", f"project_id=eq.{project_id}"
|
|
429
|
-
columns="name")
|
|
533
|
+
agents = sb_select("mc_agents", f"project_id=eq.{project_id}&select=name")
|
|
430
534
|
if not agents:
|
|
431
535
|
return name
|
|
432
536
|
all_names = [a["name"] for a in agents]
|
|
433
537
|
# Try prefix match first
|
|
434
538
|
prefix_matches = [n for n in all_names if n.startswith(name)]
|
|
435
539
|
if len(prefix_matches) == 1:
|
|
540
|
+
_agent_name_cache_set(project_id, name, prefix_matches[0])
|
|
436
541
|
return prefix_matches[0]
|
|
437
542
|
# Try substring match
|
|
438
543
|
sub_matches = [n for n in all_names if name in n]
|
|
439
544
|
if len(sub_matches) == 1:
|
|
545
|
+
_agent_name_cache_set(project_id, name, sub_matches[0])
|
|
440
546
|
return sub_matches[0]
|
|
441
547
|
# Ambiguous or no match — return original
|
|
442
548
|
return name
|
|
@@ -651,20 +757,34 @@ def count_pending(project_id: str, agent: str, api_key: Optional[str] = None) ->
|
|
|
651
757
|
# Encryption helpers for sensitive mesh messages
|
|
652
758
|
# ============================================================
|
|
653
759
|
|
|
654
|
-
|
|
760
|
+
# TTL + max-size cache for mesh encryption keys. Previous version was unbounded.
|
|
761
|
+
_mesh_key_cache: Dict[str, tuple] = {} # project_id -> (hex_key, expiry_ts)
|
|
762
|
+
_MESH_KEY_CACHE_TTL = 600 # 10 minutes
|
|
763
|
+
_MESH_KEY_CACHE_MAX = 64
|
|
655
764
|
|
|
656
765
|
|
|
657
766
|
def get_mesh_key(api_key: str, project_id: str) -> Optional[str]:
|
|
658
767
|
"""Retrieve the per-meshwork encryption key (hex-encoded AES-256 key)."""
|
|
659
|
-
|
|
660
|
-
|
|
768
|
+
entry = _mesh_key_cache.get(project_id)
|
|
769
|
+
if entry and entry[1] > _time.time():
|
|
770
|
+
return entry[0]
|
|
771
|
+
_mesh_key_cache.pop(project_id, None)
|
|
661
772
|
result = sb_rpc("mc_get_mesh_key", {
|
|
662
773
|
"p_api_key": api_key,
|
|
663
774
|
"p_project_id": project_id,
|
|
664
775
|
})
|
|
665
776
|
if isinstance(result, dict) and result.get("ok"):
|
|
666
777
|
key = result["key"]
|
|
667
|
-
_mesh_key_cache
|
|
778
|
+
if len(_mesh_key_cache) >= _MESH_KEY_CACHE_MAX:
|
|
779
|
+
# Evict expired, then oldest if still full
|
|
780
|
+
now = _time.time()
|
|
781
|
+
expired = [k for k, v in _mesh_key_cache.items() if v[1] <= now]
|
|
782
|
+
for k in expired:
|
|
783
|
+
del _mesh_key_cache[k]
|
|
784
|
+
if len(_mesh_key_cache) >= _MESH_KEY_CACHE_MAX:
|
|
785
|
+
oldest = min(_mesh_key_cache, key=lambda k: _mesh_key_cache[k][1])
|
|
786
|
+
del _mesh_key_cache[oldest]
|
|
787
|
+
_mesh_key_cache[project_id] = (key, _time.time() + _MESH_KEY_CACHE_TTL)
|
|
668
788
|
return key
|
|
669
789
|
return None
|
|
670
790
|
|
|
@@ -726,7 +846,11 @@ def decrypt_payload(encrypted_b64: str, hex_key: str, aad: Optional[str] = None)
|
|
|
726
846
|
|
|
727
847
|
|
|
728
848
|
def get_board(project_id: str) -> List[Dict]:
|
|
729
|
-
return sb_select(
|
|
849
|
+
return sb_select(
|
|
850
|
+
"mc_agents",
|
|
851
|
+
f"project_id=eq.{project_id}&select=name,role,status,task,last_heartbeat,registered_at",
|
|
852
|
+
order="registered_at.asc",
|
|
853
|
+
)
|
|
730
854
|
|
|
731
855
|
|
|
732
856
|
def heartbeat(project_id: str, agent: str) -> Dict:
|
|
@@ -108,14 +108,14 @@ _SEEN_TTL = 300.0 # 5 minutes
|
|
|
108
108
|
# ============================================================
|
|
109
109
|
# Auto-wake: when agent is NOT in meshcode_wait and a message
|
|
110
110
|
# arrives, inject text into the terminal to wake the agent.
|
|
111
|
-
# DEFAULT: ON. Disable with MESHCODE_AUTO_WAKE=0 if you don't want it.
|
|
112
111
|
# ============================================================
|
|
113
112
|
_IN_WAIT = False # True while meshcode_wait is blocking
|
|
114
|
-
# Default OFF
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
# MESHCODE_AUTO_WAKE=1
|
|
113
|
+
# Default OFF — keystroke injection can corrupt stdin on some terminals.
|
|
114
|
+
# The primary nudge path is now in comms_v4.nudge_agent() which uses
|
|
115
|
+
# platform-specific window activation (SetForegroundWindow on Windows,
|
|
116
|
+
# AppleScript on macOS) + desktop notification.
|
|
117
|
+
# Set MESHCODE_AUTO_WAKE=1 to also inject text into the terminal from
|
|
118
|
+
# the MCP server side (useful if comms_v4 nudge doesn't reach the agent).
|
|
119
119
|
_AUTO_WAKE = os.environ.get("MESHCODE_AUTO_WAKE", "0").lower() in ("1", "true", "yes")
|
|
120
120
|
|
|
121
121
|
|
|
@@ -197,8 +197,10 @@ def _try_auto_wake(from_agent: str, preview: str) -> None:
|
|
|
197
197
|
Add-Type -AssemblyName System.Windows.Forms
|
|
198
198
|
[System.Windows.Forms.SendKeys]::SendWait("{sk_safe}{{ENTER}}")
|
|
199
199
|
'''
|
|
200
|
-
subprocess.
|
|
201
|
-
|
|
200
|
+
# Use subprocess.run with timeout to prevent orphaned PowerShell processes
|
|
201
|
+
subprocess.run(["powershell", "-NoProfile", "-Command", ps_script],
|
|
202
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
203
|
+
timeout=10)
|
|
202
204
|
log.info("auto-wake: injected nudge via PowerShell SendKeys")
|
|
203
205
|
else:
|
|
204
206
|
# Linux — xdotool takes args directly, not shell-interpolated (safe)
|
|
@@ -382,6 +384,30 @@ if not PROJECT_NAME or not AGENT_NAME:
|
|
|
382
384
|
)
|
|
383
385
|
sys.exit(2)
|
|
384
386
|
|
|
387
|
+
# ============================================================
|
|
388
|
+
# CWD check — warn if user opened Claude from the wrong directory
|
|
389
|
+
# ============================================================
|
|
390
|
+
# On Windows (and sometimes macOS), users open Claude Code from their home
|
|
391
|
+
# directory instead of the workspace where .mcp.json lives. The MCP server
|
|
392
|
+
# still loads (env vars come from .mcp.json), but the agent can't access
|
|
393
|
+
# workspace files. Warn early so the user knows to use `meshcode run`.
|
|
394
|
+
|
|
395
|
+
_cwd = os.getcwd()
|
|
396
|
+
_cwd_has_mcp = os.path.exists(os.path.join(_cwd, ".mcp.json"))
|
|
397
|
+
_home = os.path.expanduser("~")
|
|
398
|
+
if not _cwd_has_mcp and os.path.normpath(_cwd) == os.path.normpath(_home):
|
|
399
|
+
_mc_log(
|
|
400
|
+
f"Current directory is your home folder ({_cwd}). "
|
|
401
|
+
f"This agent's workspace may be elsewhere. "
|
|
402
|
+
f"For best results, use: meshcode run {AGENT_NAME}",
|
|
403
|
+
"warn",
|
|
404
|
+
)
|
|
405
|
+
elif not _cwd_has_mcp:
|
|
406
|
+
_mc_log(
|
|
407
|
+
f"No .mcp.json found in {_cwd}. "
|
|
408
|
+
f"If tools can't find your files, try: meshcode run {AGENT_NAME}",
|
|
409
|
+
"warn",
|
|
410
|
+
)
|
|
385
411
|
|
|
386
412
|
# ============================================================
|
|
387
413
|
# API key resolution — keychain first, env var fallback
|
|
@@ -1120,24 +1146,69 @@ async def _on_new_message(msg: Dict[str, Any]) -> None:
|
|
|
1120
1146
|
# Stashed MCP session for silent auto-wake (works when agent is idle, no active tool call)
|
|
1121
1147
|
_STASHED_SESSION = None
|
|
1122
1148
|
|
|
1149
|
+
# Store the main asyncio event loop reference for use in the heartbeat daemon
|
|
1150
|
+
# thread. On Windows (ProactorEventLoop), asyncio.get_event_loop() inside a
|
|
1151
|
+
# non-main thread returns a NEW, non-running loop — so is_running() is always
|
|
1152
|
+
# False and run_coroutine_threadsafe silently does nothing. By capturing the
|
|
1153
|
+
# loop at lifespan start we guarantee the heartbeat thread can schedule
|
|
1154
|
+
# Realtime restarts on the correct loop regardless of platform.
|
|
1155
|
+
_MAIN_LOOP: asyncio.AbstractEventLoop | None = None
|
|
1156
|
+
|
|
1123
1157
|
_heartbeat_stop = _threading.Event()
|
|
1124
1158
|
|
|
1159
|
+
# Windows CPU tracking: (Get-Process).CPU returns cumulative seconds, not a
|
|
1160
|
+
# real-time percentage like Unix `ps -o %cpu`. We track the previous reading
|
|
1161
|
+
# and timestamp so we can derive Δcpu/Δtime as a percentage.
|
|
1162
|
+
_win_prev_cpu: float | None = None
|
|
1163
|
+
_win_prev_ts: float | None = None
|
|
1164
|
+
|
|
1125
1165
|
|
|
1126
1166
|
def _get_parent_cpu() -> float:
|
|
1127
|
-
"""
|
|
1167
|
+
"""Return approximate real-time CPU usage (%) of the parent process.
|
|
1168
|
+
|
|
1169
|
+
On Unix this delegates to ``ps -o %cpu`` which returns a percentage
|
|
1170
|
+
directly. On Windows, ``(Get-Process).CPU`` returns *total CPU seconds*
|
|
1171
|
+
since process start — a monotonically increasing number. To get a
|
|
1172
|
+
meaningful percentage we compute ``(Δcpu / Δtime) * 100`` between two
|
|
1173
|
+
consecutive heartbeat calls.
|
|
1174
|
+
"""
|
|
1175
|
+
global _win_prev_cpu, _win_prev_ts
|
|
1128
1176
|
try:
|
|
1129
1177
|
import subprocess as _sp, platform as _pl
|
|
1130
1178
|
ppid = os.getppid()
|
|
1131
1179
|
if _pl.system() == "Windows":
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1180
|
+
now = _time.time()
|
|
1181
|
+
r = _sp.run(
|
|
1182
|
+
["powershell", "-NoProfile", "-Command",
|
|
1183
|
+
f"(Get-Process -Id {ppid} -ErrorAction SilentlyContinue).CPU"],
|
|
1184
|
+
capture_output=True, text=True, timeout=5,
|
|
1185
|
+
)
|
|
1186
|
+
val = r.stdout.strip()
|
|
1187
|
+
if not val:
|
|
1188
|
+
log.debug(f"Windows CPU: empty output for ppid={ppid}")
|
|
1189
|
+
return 0.0
|
|
1190
|
+
cpu_total = float(val)
|
|
1191
|
+
if _win_prev_cpu is None or _win_prev_ts is None:
|
|
1192
|
+
# First reading — store baseline, return 0
|
|
1193
|
+
_win_prev_cpu = cpu_total
|
|
1194
|
+
_win_prev_ts = now
|
|
1195
|
+
log.debug(f"Windows CPU baseline: {cpu_total:.2f}s (ppid={ppid})")
|
|
1196
|
+
return 0.0
|
|
1197
|
+
dt = now - _win_prev_ts
|
|
1198
|
+
dcpu = cpu_total - _win_prev_cpu
|
|
1199
|
+
_win_prev_cpu = cpu_total
|
|
1200
|
+
_win_prev_ts = now
|
|
1201
|
+
if dt <= 0:
|
|
1202
|
+
return 0.0
|
|
1203
|
+
pct = (dcpu / dt) * 100.0
|
|
1204
|
+
log.debug(f"Windows CPU: Δcpu={dcpu:.3f}s Δt={dt:.1f}s → {pct:.1f}%")
|
|
1205
|
+
return max(pct, 0.0)
|
|
1136
1206
|
else:
|
|
1137
1207
|
r = _sp.run(["ps", "-p", str(ppid), "-o", "%cpu"], capture_output=True, text=True, timeout=5)
|
|
1138
1208
|
lines = [l.strip() for l in r.stdout.strip().split("\n") if l.strip()]
|
|
1139
1209
|
return float(lines[-1]) if len(lines) > 1 else 0.0
|
|
1140
|
-
except Exception:
|
|
1210
|
+
except Exception as e:
|
|
1211
|
+
log.debug(f"CPU detection failed: {e}")
|
|
1141
1212
|
return 0.0
|
|
1142
1213
|
|
|
1143
1214
|
|
|
@@ -1171,6 +1242,15 @@ def _heartbeat_thread_fn():
|
|
|
1171
1242
|
def _heartbeat_loop_inner():
|
|
1172
1243
|
"""Single iteration of the heartbeat loop. Separated so the outer
|
|
1173
1244
|
wrapper can catch any exception and restart cleanly."""
|
|
1245
|
+
import platform as _pl
|
|
1246
|
+
_is_windows = _pl.system() == "Windows"
|
|
1247
|
+
if _is_windows:
|
|
1248
|
+
log.info(
|
|
1249
|
+
f"[WIN-DEBUG] heartbeat thread started on Windows — "
|
|
1250
|
+
f"agent={AGENT_NAME} ppid={os.getppid()} "
|
|
1251
|
+
f"loop_captured={_MAIN_LOOP is not None} "
|
|
1252
|
+
f"realtime={'connected' if _REALTIME and _REALTIME.is_connected else 'none/disconnected'}"
|
|
1253
|
+
)
|
|
1174
1254
|
lease_counter = 0
|
|
1175
1255
|
while not _heartbeat_stop.is_set():
|
|
1176
1256
|
try:
|
|
@@ -1182,6 +1262,16 @@ def _heartbeat_loop_inner():
|
|
|
1182
1262
|
in_wait = _IN_WAIT
|
|
1183
1263
|
idle_secs = _time.time() - _last_tool_at
|
|
1184
1264
|
|
|
1265
|
+
if _is_windows and lease_counter % 12 == 0:
|
|
1266
|
+
# Periodic Windows debug dump (every ~60s on pro, ~180s on free)
|
|
1267
|
+
log.info(
|
|
1268
|
+
f"[WIN-DEBUG] cpu={parent_cpu:.1f}% state={cur_state} "
|
|
1269
|
+
f"in_wait={in_wait} idle={idle_secs:.0f}s "
|
|
1270
|
+
f"rt_conn={_REALTIME.is_connected if _REALTIME else 'N/A'} "
|
|
1271
|
+
f"rt_sub={_REALTIME.is_subscribed if _REALTIME else 'N/A'} "
|
|
1272
|
+
f"loop_running={_MAIN_LOOP.is_running() if _MAIN_LOOP else False}"
|
|
1273
|
+
)
|
|
1274
|
+
|
|
1185
1275
|
if in_wait:
|
|
1186
1276
|
# Actually in meshcode_wait right now — listening for messages
|
|
1187
1277
|
if cur_state != "waiting":
|
|
@@ -1206,26 +1296,29 @@ def _heartbeat_loop_inner():
|
|
|
1206
1296
|
try:
|
|
1207
1297
|
be.set_status(_PROJECT_ID, AGENT_NAME, _current_state, _current_tool, api_key=_get_api_key())
|
|
1208
1298
|
except Exception as e:
|
|
1209
|
-
log.
|
|
1299
|
+
log.warning(f"status sync failed (agent may show stale status): {e}")
|
|
1210
1300
|
# Realtime subscription recovery: if the WebSocket is connected but
|
|
1211
1301
|
# the channel subscription dropped (e.g. Supabase maintenance), or
|
|
1212
1302
|
# the WebSocket itself disconnected, trigger a restart so agents
|
|
1213
1303
|
# don't stay stuck in slow DB polling for the entire session.
|
|
1304
|
+
#
|
|
1305
|
+
# Use _MAIN_LOOP (captured at lifespan start) instead of
|
|
1306
|
+
# asyncio.get_event_loop() — on Windows the latter returns a new,
|
|
1307
|
+
# non-running loop inside daemon threads, silently preventing
|
|
1308
|
+
# Realtime restarts.
|
|
1214
1309
|
if _REALTIME:
|
|
1215
1310
|
if not _REALTIME.is_connected:
|
|
1216
1311
|
log.warning("heartbeat ok (HTTP) but WebSocket disconnected — scheduling Realtime restart")
|
|
1217
1312
|
try:
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
asyncio.run_coroutine_threadsafe(_REALTIME.restart(), loop)
|
|
1313
|
+
if _MAIN_LOOP and _MAIN_LOOP.is_running():
|
|
1314
|
+
asyncio.run_coroutine_threadsafe(_REALTIME.restart(), _MAIN_LOOP)
|
|
1221
1315
|
except Exception as e:
|
|
1222
1316
|
log.debug(f"Realtime restart scheduling failed: {e}")
|
|
1223
1317
|
elif not _REALTIME.is_subscribed:
|
|
1224
1318
|
log.warning("WebSocket connected but subscription lost — scheduling Realtime restart")
|
|
1225
1319
|
try:
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
asyncio.run_coroutine_threadsafe(_REALTIME.restart(), loop)
|
|
1320
|
+
if _MAIN_LOOP and _MAIN_LOOP.is_running():
|
|
1321
|
+
asyncio.run_coroutine_threadsafe(_REALTIME.restart(), _MAIN_LOOP)
|
|
1229
1322
|
except Exception as e:
|
|
1230
1323
|
log.debug(f"Realtime restart scheduling failed: {e}")
|
|
1231
1324
|
else:
|
|
@@ -1258,7 +1351,17 @@ def _heartbeat_loop_inner():
|
|
|
1258
1351
|
@asynccontextmanager
|
|
1259
1352
|
async def lifespan(_app):
|
|
1260
1353
|
"""Start the Realtime listener when the MCP server boots; stop on shutdown."""
|
|
1261
|
-
global _REALTIME
|
|
1354
|
+
global _REALTIME, _MAIN_LOOP
|
|
1355
|
+
_MAIN_LOOP = asyncio.get_running_loop()
|
|
1356
|
+
|
|
1357
|
+
import platform as _pl
|
|
1358
|
+
if _pl.system() == "Windows":
|
|
1359
|
+
log.info(
|
|
1360
|
+
f"[WIN-DEBUG] lifespan start — loop={type(_MAIN_LOOP).__name__} "
|
|
1361
|
+
f"policy={type(asyncio.get_event_loop_policy()).__name__} "
|
|
1362
|
+
f"agent={AGENT_NAME}"
|
|
1363
|
+
)
|
|
1364
|
+
|
|
1262
1365
|
_REALTIME = RealtimeListener(
|
|
1263
1366
|
supabase_url=be.SUPABASE_URL,
|
|
1264
1367
|
supabase_key=be.SUPABASE_KEY,
|
|
@@ -1914,7 +2017,6 @@ async def _meshcode_wait_inner(actual_timeout: int, include_acks: bool) -> Dict[
|
|
|
1914
2017
|
_mark_realtime_msgs_read_in_db(deduped)
|
|
1915
2018
|
out: Dict[str, Any] = {
|
|
1916
2019
|
"got_message": True,
|
|
1917
|
-
"source": "realtime",
|
|
1918
2020
|
**split,
|
|
1919
2021
|
}
|
|
1920
2022
|
done = _detect_global_done(deduped)
|
|
@@ -1971,7 +2073,7 @@ async def _meshcode_wait_inner(actual_timeout: int, include_acks: bool) -> Dict[
|
|
|
1971
2073
|
if not include_acks:
|
|
1972
2074
|
split["acks"] = []
|
|
1973
2075
|
if split["messages"] or split["done_signals"]:
|
|
1974
|
-
return {"got_message": True,
|
|
2076
|
+
return {"got_message": True, **split}
|
|
1975
2077
|
except Exception as e:
|
|
1976
2078
|
log.debug(f"DB poll fallback error: {e}")
|
|
1977
2079
|
|
|
@@ -1995,7 +2097,7 @@ async def _meshcode_wait_inner(actual_timeout: int, include_acks: bool) -> Dict[
|
|
|
1995
2097
|
if not include_acks:
|
|
1996
2098
|
split["acks"] = []
|
|
1997
2099
|
if split["messages"] or split["done_signals"]:
|
|
1998
|
-
return {"got_message": True,
|
|
2100
|
+
return {"got_message": True, **split}
|
|
1999
2101
|
except Exception as e:
|
|
2000
2102
|
log.debug(f"final DB fallback error: {e}")
|
|
2001
2103
|
|
|
@@ -2081,10 +2183,6 @@ def meshcode_check(include_acks: bool = False, since: Optional[str] = None) -> D
|
|
|
2081
2183
|
split["acks"] = []
|
|
2082
2184
|
result = {
|
|
2083
2185
|
"pending": pending if not effective_since else len(split.get("messages", [])),
|
|
2084
|
-
"agent": AGENT_NAME,
|
|
2085
|
-
"project": PROJECT_NAME,
|
|
2086
|
-
"realtime_connected": _REALTIME.is_connected if _REALTIME else False,
|
|
2087
|
-
"realtime_subscribed": _REALTIME.is_subscribed if _REALTIME else False,
|
|
2088
2186
|
**split,
|
|
2089
2187
|
}
|
|
2090
2188
|
# Auto-inject pending tasks
|
|
@@ -2100,7 +2198,6 @@ def meshcode_status() -> Dict[str, Any]:
|
|
|
2100
2198
|
"""List all agents with status, role, and current task."""
|
|
2101
2199
|
agents = be.get_board(_PROJECT_ID)
|
|
2102
2200
|
return {
|
|
2103
|
-
"project": PROJECT_NAME,
|
|
2104
2201
|
"agents": [
|
|
2105
2202
|
{
|
|
2106
2203
|
"name": a["name"],
|
|
@@ -2871,7 +2968,7 @@ def meshcode_recall(key: Optional[str] = None) -> Dict[str, Any]:
|
|
|
2871
2968
|
if isinstance(reference, dict) and reference.get("ok"):
|
|
2872
2969
|
ref_keys = [m.get("key") for m in reference.get("memories", [])]
|
|
2873
2970
|
critical["reference_keys"] = ref_keys
|
|
2874
|
-
|
|
2971
|
+
# tip removed — already in system prompt, saves ~20 tokens per call
|
|
2875
2972
|
return critical
|
|
2876
2973
|
|
|
2877
2974
|
|
|
@@ -2931,6 +3028,25 @@ def meshcode_recall_search(query: str) -> Dict[str, Any]:
|
|
|
2931
3028
|
})
|
|
2932
3029
|
|
|
2933
3030
|
|
|
3031
|
+
# ----------------- BUG REPORTING -----------------
|
|
3032
|
+
|
|
3033
|
+
@mcp.tool()
|
|
3034
|
+
@with_working_status
|
|
3035
|
+
def meshcode_report_bug(description: str) -> Dict[str, Any]:
|
|
3036
|
+
"""Report a bug from within the mesh. Visible in admin panel.
|
|
3037
|
+
|
|
3038
|
+
Args:
|
|
3039
|
+
description: What went wrong — include steps to reproduce if possible.
|
|
3040
|
+
"""
|
|
3041
|
+
api_key = _get_api_key()
|
|
3042
|
+
return be.sb_rpc("mc_report_bug", {
|
|
3043
|
+
"p_api_key": api_key,
|
|
3044
|
+
"p_description": description,
|
|
3045
|
+
"p_meshwork_name": PROJECT_NAME,
|
|
3046
|
+
"p_agent_name": AGENT_NAME,
|
|
3047
|
+
})
|
|
3048
|
+
|
|
3049
|
+
|
|
2934
3050
|
# ----------------- HEALTH CHECK -----------------
|
|
2935
3051
|
|
|
2936
3052
|
@mcp.tool()
|
|
@@ -2938,8 +3054,6 @@ def meshcode_health() -> Dict[str, Any]:
|
|
|
2938
3054
|
"""Check MCP server health: DB connectivity, Realtime status, circuit breaker state, uptime."""
|
|
2939
3055
|
import time as _t
|
|
2940
3056
|
health: Dict[str, Any] = {
|
|
2941
|
-
"agent": AGENT_NAME,
|
|
2942
|
-
"project": PROJECT_NAME,
|
|
2943
3057
|
"instance_id": _INSTANCE_ID,
|
|
2944
3058
|
"sdk_version": _SDK_VERSION,
|
|
2945
3059
|
}
|
|
@@ -44,17 +44,34 @@ def load_prefs() -> Dict[str, Any]:
|
|
|
44
44
|
return {}
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def _restrict_file_permissions(path: "Path") -> None:
|
|
48
|
+
"""Restrict file to owner-only access on all platforms."""
|
|
49
|
+
try:
|
|
50
|
+
if sys.platform == "win32":
|
|
51
|
+
import subprocess
|
|
52
|
+
# icacls: remove inherited ACLs, grant only current user Full Control
|
|
53
|
+
username = os.environ.get("USERNAME", os.environ.get("USER", ""))
|
|
54
|
+
if username:
|
|
55
|
+
subprocess.run(
|
|
56
|
+
["icacls", str(path), "/inheritance:r",
|
|
57
|
+
"/grant:r", f"{username}:(F)"],
|
|
58
|
+
capture_output=True, timeout=5,
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
os.chmod(path, 0o600)
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
47
66
|
def save_prefs(prefs: Dict[str, Any]) -> bool:
|
|
48
|
-
"""Atomic write of the prefs file with
|
|
67
|
+
"""Atomic write of the prefs file with restricted permissions."""
|
|
49
68
|
try:
|
|
50
69
|
PREFS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
51
70
|
tmp = PREFS_PATH.with_suffix(".tmp")
|
|
52
71
|
tmp.write_text(json.dumps(prefs, indent=2))
|
|
53
|
-
|
|
54
|
-
os.chmod(tmp, 0o600)
|
|
55
|
-
except Exception:
|
|
56
|
-
pass
|
|
72
|
+
_restrict_file_permissions(tmp)
|
|
57
73
|
tmp.replace(PREFS_PATH)
|
|
74
|
+
_restrict_file_permissions(PREFS_PATH)
|
|
58
75
|
return True
|
|
59
76
|
except Exception as e:
|
|
60
77
|
print(f"[meshcode] WARNING: could not save prefs: {e}", file=sys.stderr)
|
|
@@ -351,21 +351,65 @@ def _list_local_projects_for_agent(agent: str) -> list:
|
|
|
351
351
|
return out
|
|
352
352
|
|
|
353
353
|
|
|
354
|
+
def _claude_supports_bypass(editor_cmd: str) -> bool:
|
|
355
|
+
"""Check if the Claude Code binary supports --dangerously-skip-permissions.
|
|
356
|
+
|
|
357
|
+
Runs ``claude --help`` and looks for the flag. Returns False on any
|
|
358
|
+
error (timeout, not found, old version) so the caller can degrade
|
|
359
|
+
gracefully instead of crashing.
|
|
360
|
+
|
|
361
|
+
On Windows, .cmd/.bat wrappers need shell=True to execute correctly —
|
|
362
|
+
without it the subprocess silently fails and bypass never activates.
|
|
363
|
+
"""
|
|
364
|
+
try:
|
|
365
|
+
use_shell = sys.platform == "win32" and editor_cmd.lower().endswith((".cmd", ".bat"))
|
|
366
|
+
print(f"[meshcode] bypass-detect: checking '{editor_cmd}' (shell={use_shell})", file=sys.stderr)
|
|
367
|
+
r = subprocess.run(
|
|
368
|
+
[editor_cmd, "--help"],
|
|
369
|
+
capture_output=True, text=True, timeout=10,
|
|
370
|
+
shell=use_shell,
|
|
371
|
+
)
|
|
372
|
+
found = "--dangerously-skip-permissions" in r.stdout
|
|
373
|
+
if found:
|
|
374
|
+
print(f"[meshcode] bypass-detect: ✓ flag found — bypass mode available", file=sys.stderr)
|
|
375
|
+
else:
|
|
376
|
+
stdout_len = len(r.stdout)
|
|
377
|
+
stderr_preview = (r.stderr or "")[:200]
|
|
378
|
+
print(f"[meshcode] bypass-detect: ✗ flag NOT found in --help output ({stdout_len} chars, exit={r.returncode})", file=sys.stderr)
|
|
379
|
+
if stderr_preview:
|
|
380
|
+
print(f"[meshcode] bypass-detect: stderr: {stderr_preview}", file=sys.stderr)
|
|
381
|
+
return found
|
|
382
|
+
except subprocess.TimeoutExpired:
|
|
383
|
+
print(f"[meshcode] bypass-detect: ✗ '{editor_cmd} --help' timed out after 10s", file=sys.stderr)
|
|
384
|
+
return False
|
|
385
|
+
except FileNotFoundError:
|
|
386
|
+
print(f"[meshcode] bypass-detect: ✗ '{editor_cmd}' not found in PATH", file=sys.stderr)
|
|
387
|
+
return False
|
|
388
|
+
except Exception as e:
|
|
389
|
+
print(f"[meshcode] bypass-detect: ✗ unexpected error: {e}", file=sys.stderr)
|
|
390
|
+
return False
|
|
391
|
+
|
|
392
|
+
|
|
354
393
|
def _detect_editor() -> Optional[str]:
|
|
355
394
|
"""Pick the user's preferred MCP-aware editor."""
|
|
356
395
|
override = os.environ.get("MESHCODE_EDITOR", "").strip().lower()
|
|
357
396
|
if override:
|
|
358
|
-
|
|
359
|
-
|
|
397
|
+
resolved = shutil.which(override)
|
|
398
|
+
if resolved:
|
|
399
|
+
# On Windows, return resolved path to preserve .cmd/.bat extension
|
|
400
|
+
return resolved if sys.platform == "win32" else override
|
|
360
401
|
print(f"[meshcode] WARNING: MESHCODE_EDITOR='{override}' not found in PATH", file=sys.stderr)
|
|
361
402
|
|
|
362
403
|
# Detection order: most likely to be installed + best per-workspace MCP
|
|
363
404
|
# support first. codex is last because its MCP config is GLOBAL only —
|
|
364
405
|
# launching it doesn't take a workspace-scoped .mcp.json, so it should
|
|
365
406
|
# only be picked if nothing else is available.
|
|
407
|
+
# On Windows, return the fully-resolved path so .cmd/.bat extension is
|
|
408
|
+
# preserved — needed for shell=True detection in bypass and launch paths.
|
|
366
409
|
for cmd in ("claude", "cursor", "code", "windsurf", "codex"):
|
|
367
|
-
|
|
368
|
-
|
|
410
|
+
resolved = shutil.which(cmd)
|
|
411
|
+
if resolved:
|
|
412
|
+
return resolved if sys.platform == "win32" else cmd
|
|
369
413
|
|
|
370
414
|
# Windows fallback: check common install locations not in PATH
|
|
371
415
|
if sys.platform == "win32":
|
|
@@ -397,6 +441,77 @@ def _detect_editor() -> Optional[str]:
|
|
|
397
441
|
return None
|
|
398
442
|
|
|
399
443
|
|
|
444
|
+
def _preflight_heartbeat(agent: str, project: str) -> None:
|
|
445
|
+
"""Send a heartbeat + set status before the editor launches.
|
|
446
|
+
|
|
447
|
+
On Windows without bypass mode, Claude Code may delay spawning the
|
|
448
|
+
MCP server until the user approves the first tool call. During that
|
|
449
|
+
gap the agent shows as 'offline' in the dashboard. This direct RPC
|
|
450
|
+
call ensures the agent appears online immediately.
|
|
451
|
+
|
|
452
|
+
Best-effort: any failure is logged and swallowed — it must never
|
|
453
|
+
block or crash the launch.
|
|
454
|
+
"""
|
|
455
|
+
try:
|
|
456
|
+
from .setup_clients import _load_supabase_env
|
|
457
|
+
import importlib
|
|
458
|
+
secrets_mod = importlib.import_module("meshcode.secrets")
|
|
459
|
+
from urllib.request import Request, urlopen
|
|
460
|
+
|
|
461
|
+
profile = os.environ.get("MESHCODE_KEYCHAIN_PROFILE") or "default"
|
|
462
|
+
api_key = secrets_mod.get_api_key(profile=profile)
|
|
463
|
+
if not api_key:
|
|
464
|
+
return
|
|
465
|
+
|
|
466
|
+
sb = _load_supabase_env()
|
|
467
|
+
headers = {
|
|
468
|
+
"apikey": sb["SUPABASE_KEY"],
|
|
469
|
+
"Authorization": f"Bearer {sb['SUPABASE_KEY']}",
|
|
470
|
+
"Content-Type": "application/json",
|
|
471
|
+
"Content-Profile": "meshcode",
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
# Resolve project_id
|
|
475
|
+
resolve_body = json.dumps({"p_api_key": api_key, "p_project_name": project})
|
|
476
|
+
req = Request(
|
|
477
|
+
f"{sb['SUPABASE_URL']}/rest/v1/rpc/mc_resolve_project",
|
|
478
|
+
data=resolve_body.encode(), method="POST", headers=headers,
|
|
479
|
+
)
|
|
480
|
+
with urlopen(req, timeout=5) as resp:
|
|
481
|
+
proj_data = json.loads(resp.read().decode())
|
|
482
|
+
project_id = proj_data.get("project_id") if proj_data else None
|
|
483
|
+
if not project_id:
|
|
484
|
+
return
|
|
485
|
+
|
|
486
|
+
# Send heartbeat
|
|
487
|
+
hb_body = json.dumps({"p_project_id": project_id, "p_agent_name": agent})
|
|
488
|
+
hb_req = Request(
|
|
489
|
+
f"{sb['SUPABASE_URL']}/rest/v1/rpc/mc_heartbeat",
|
|
490
|
+
data=hb_body.encode(), method="POST", headers=headers,
|
|
491
|
+
)
|
|
492
|
+
with urlopen(hb_req, timeout=5) as resp:
|
|
493
|
+
resp.read()
|
|
494
|
+
|
|
495
|
+
# Set status to 'online'
|
|
496
|
+
status_body = json.dumps({
|
|
497
|
+
"p_api_key": api_key,
|
|
498
|
+
"p_project_id": project_id,
|
|
499
|
+
"p_agent_name": agent,
|
|
500
|
+
"p_status": "online",
|
|
501
|
+
"p_task": "launching editor",
|
|
502
|
+
})
|
|
503
|
+
status_req = Request(
|
|
504
|
+
f"{sb['SUPABASE_URL']}/rest/v1/rpc/mc_agent_set_status_by_api_key",
|
|
505
|
+
data=status_body.encode(), method="POST", headers=headers,
|
|
506
|
+
)
|
|
507
|
+
with urlopen(status_req, timeout=5) as resp:
|
|
508
|
+
resp.read()
|
|
509
|
+
|
|
510
|
+
print(f"[meshcode] Pre-flight heartbeat sent — agent visible in dashboard")
|
|
511
|
+
except Exception as e:
|
|
512
|
+
print(f"[meshcode] Pre-flight heartbeat skipped: {e}", file=sys.stderr)
|
|
513
|
+
|
|
514
|
+
|
|
400
515
|
def run(agent: str, project: Optional[str] = None, editor_override: Optional[str] = None, permission_override: Optional[str] = None) -> int:
|
|
401
516
|
"""Launch the user's editor with ONLY the named agent's MCP server loaded."""
|
|
402
517
|
# Non-blocking self-update check (consumes prior result, may spawn bg pip)
|
|
@@ -509,7 +624,22 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
|
|
|
509
624
|
# ── Welcome banner with unique ASCII art + personality ──────────
|
|
510
625
|
try:
|
|
511
626
|
from .ascii_art import generate_art, render_welcome
|
|
512
|
-
|
|
627
|
+
cli_version = ""
|
|
628
|
+
try:
|
|
629
|
+
from . import __version__ as cli_version
|
|
630
|
+
except ImportError:
|
|
631
|
+
try:
|
|
632
|
+
from meshcode import __version__ as cli_version
|
|
633
|
+
except ImportError:
|
|
634
|
+
# Last resort: read __version__ directly from __init__.py
|
|
635
|
+
try:
|
|
636
|
+
_init_path = Path(__file__).resolve().parent / "__init__.py"
|
|
637
|
+
for _line in _init_path.read_text(encoding="utf-8").splitlines():
|
|
638
|
+
if _line.startswith("__version__"):
|
|
639
|
+
cli_version = _line.split("=", 1)[1].strip().strip('"').strip("'")
|
|
640
|
+
break
|
|
641
|
+
except Exception:
|
|
642
|
+
pass
|
|
513
643
|
ascii_art, agent_role, profile_color = _fetch_or_generate_art(agent, resolved_project)
|
|
514
644
|
_leader_haystack = (agent + ' ' + agent_role).lower()
|
|
515
645
|
_LEADER_KW = ('commander', 'lead', 'orchestrat', 'coordinator', 'coordinat',
|
|
@@ -521,9 +651,11 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
|
|
|
521
651
|
agent, resolved_project, ascii_art, cli_version,
|
|
522
652
|
is_commander=is_cmd, role=agent_role, stats=agent_stats,
|
|
523
653
|
profile_color=profile_color,
|
|
524
|
-
))
|
|
525
|
-
except Exception:
|
|
526
|
-
|
|
654
|
+
), file=sys.stderr, flush=True)
|
|
655
|
+
except Exception as _banner_err:
|
|
656
|
+
import traceback as _tb
|
|
657
|
+
print(f"[meshcode] WARN: banner failed: {_banner_err}", file=sys.stderr)
|
|
658
|
+
_tb.print_exc(file=sys.stderr)
|
|
527
659
|
|
|
528
660
|
print(f"[meshcode] Launching {editor} for agent '{agent}' (project: {resolved_project})")
|
|
529
661
|
print(f"[meshcode] Workspace: {ws}")
|
|
@@ -545,10 +677,17 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
|
|
|
545
677
|
# MCP" bucket and survive ESC. Verified with a minimal FastMCP
|
|
546
678
|
# repro side-by-side: Built-in dies, Local survives, same SDK code.
|
|
547
679
|
mode = resolve_permission_mode(permission_override)
|
|
680
|
+
print(f"[meshcode] Editor resolved: {editor}", file=sys.stderr)
|
|
681
|
+
print(f"[meshcode] Permission mode resolved: {mode}", file=sys.stderr)
|
|
548
682
|
cmd = [editor]
|
|
549
683
|
if mode == "bypass":
|
|
550
|
-
|
|
551
|
-
|
|
684
|
+
if _claude_supports_bypass(editor):
|
|
685
|
+
cmd.append("--dangerously-skip-permissions")
|
|
686
|
+
print(f"[meshcode] Permission mode: bypass (autonomous loop, no prompts)")
|
|
687
|
+
else:
|
|
688
|
+
print(f"[meshcode] Permission mode: bypass requested but --dangerously-skip-permissions")
|
|
689
|
+
print(f"[meshcode] not supported by this Claude Code version. Agent will prompt for tools.")
|
|
690
|
+
print(f"[meshcode] Upgrade Claude Code: npm install -g @anthropic-ai/claude-code@latest")
|
|
552
691
|
else:
|
|
553
692
|
print(f"[meshcode] Permission mode: safe (Claude will prompt for every tool call)")
|
|
554
693
|
print(f"[meshcode] Tip: change with `meshcode prefs permission-mode bypass`")
|
|
@@ -557,10 +696,12 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
|
|
|
557
696
|
# messages, claim tasks, greet peers) without waiting for user input.
|
|
558
697
|
# Uses -- to separate options from the positional prompt argument.
|
|
559
698
|
cmd.extend(["--", "boot"])
|
|
699
|
+
if not os.path.isdir(ws):
|
|
700
|
+
print(f"[meshcode] WARNING: workspace dir does not exist: {ws}", file=sys.stderr)
|
|
560
701
|
try:
|
|
561
702
|
os.chdir(ws)
|
|
562
|
-
except Exception:
|
|
563
|
-
|
|
703
|
+
except Exception as e:
|
|
704
|
+
print(f"[meshcode] WARNING: chdir to workspace failed: {e}", file=sys.stderr)
|
|
564
705
|
elif editor == "cursor":
|
|
565
706
|
# Cursor reads .cursor/mcp.json from the workspace cwd.
|
|
566
707
|
cmd = [editor, str(ws)]
|
|
@@ -575,10 +716,12 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
|
|
|
575
716
|
# Codex CLI reads ~/.codex/config.toml globally. There's no per-
|
|
576
717
|
# workspace flag, so launching is just `codex` with cwd set to the
|
|
577
718
|
# workspace dir so any file ops the agent does land there.
|
|
719
|
+
if not os.path.isdir(ws):
|
|
720
|
+
print(f"[meshcode] WARNING: workspace dir does not exist: {ws}", file=sys.stderr)
|
|
578
721
|
try:
|
|
579
722
|
os.chdir(ws)
|
|
580
|
-
except Exception:
|
|
581
|
-
|
|
723
|
+
except Exception as e:
|
|
724
|
+
print(f"[meshcode] WARNING: chdir to workspace failed: {e}", file=sys.stderr)
|
|
582
725
|
cmd = [editor]
|
|
583
726
|
else:
|
|
584
727
|
cmd = [editor, str(ws)]
|
|
@@ -598,11 +741,39 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
|
|
|
598
741
|
except OSError:
|
|
599
742
|
pass # No devnull (unlikely) — let the editor handle it
|
|
600
743
|
|
|
744
|
+
# Pre-flight heartbeat: mark the agent as online BEFORE the editor
|
|
745
|
+
# subprocess spawns. On Windows without bypass mode, Claude Code may
|
|
746
|
+
# delay starting the MCP server until the user interacts — leaving the
|
|
747
|
+
# agent stuck as 'offline' in the dashboard. This direct RPC call
|
|
748
|
+
# ensures immediate visibility regardless of MCP spawn timing.
|
|
749
|
+
_preflight_heartbeat(agent, resolved_project)
|
|
750
|
+
|
|
751
|
+
# Flush all output before exec replaces this process — execvp does NOT
|
|
752
|
+
# flush Python file buffers, so any buffered stdout (e.g. the banner)
|
|
753
|
+
# would be silently lost.
|
|
754
|
+
sys.stdout.flush()
|
|
755
|
+
sys.stderr.flush()
|
|
756
|
+
|
|
757
|
+
# Effective cwd for the editor — log it for debugging (especially
|
|
758
|
+
# Windows where shell=True + os.chdir may not propagate).
|
|
759
|
+
effective_cwd = os.getcwd()
|
|
760
|
+
print(f"[meshcode] Effective cwd: {effective_cwd}")
|
|
761
|
+
|
|
601
762
|
try:
|
|
602
763
|
if sys.platform == "win32":
|
|
603
|
-
# Windows: no execvp, use subprocess and wait
|
|
764
|
+
# Windows: no execvp, use subprocess and wait.
|
|
765
|
+
# .cmd/.bat files need shell=True for proper argument passing —
|
|
766
|
+
# without it, flags like --dangerously-skip-permissions can be
|
|
767
|
+
# mangled by Windows' CreateProcess argument splitting.
|
|
768
|
+
# Explicit cwd=str(ws) ensures the child process starts in the
|
|
769
|
+
# workspace even if os.chdir() didn't stick (shell=True can
|
|
770
|
+
# reset cwd via cmd.exe).
|
|
604
771
|
import subprocess as _sp
|
|
605
|
-
|
|
772
|
+
use_shell = cmd[0].lower().endswith((".cmd", ".bat"))
|
|
773
|
+
if use_shell:
|
|
774
|
+
print(f"[meshcode] (Windows: launching via shell for .cmd wrapper)")
|
|
775
|
+
launch_cwd = str(ws) if os.path.isdir(ws) else None
|
|
776
|
+
result = _sp.run(cmd, shell=use_shell, cwd=launch_cwd)
|
|
606
777
|
sys.exit(result.returncode)
|
|
607
778
|
else:
|
|
608
779
|
# Unix: replace this process with the editor
|
|
@@ -47,6 +47,33 @@ DEFAULT_PROFILE = "default"
|
|
|
47
47
|
LEGACY_CREDS_FILE = Path.home() / ".meshcode" / "credentials.json"
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
# ============================================================
|
|
51
|
+
# File permission helpers
|
|
52
|
+
# ============================================================
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _restrict_perms(path: "Path") -> None:
|
|
56
|
+
"""Restrict file to owner-only access on all platforms.
|
|
57
|
+
|
|
58
|
+
On Windows, os.chmod(0o600) is a no-op — must use icacls to remove
|
|
59
|
+
inherited ACLs and grant only the current user Full Control.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
if sys.platform == "win32":
|
|
63
|
+
import subprocess
|
|
64
|
+
username = os.environ.get("USERNAME", os.environ.get("USER", ""))
|
|
65
|
+
if username:
|
|
66
|
+
subprocess.run(
|
|
67
|
+
["icacls", str(path), "/inheritance:r",
|
|
68
|
+
"/grant:r", f"{username}:(F)"],
|
|
69
|
+
capture_output=True, timeout=5,
|
|
70
|
+
)
|
|
71
|
+
else:
|
|
72
|
+
os.chmod(path, 0o600)
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
50
77
|
# ============================================================
|
|
51
78
|
# Keyring backend probe
|
|
52
79
|
# ============================================================
|
|
@@ -129,10 +156,7 @@ def _save_index(idx: Dict[str, Any]) -> None:
|
|
|
129
156
|
PROFILE_INDEX.parent.mkdir(parents=True, exist_ok=True)
|
|
130
157
|
tmp = PROFILE_INDEX.with_suffix(".tmp")
|
|
131
158
|
tmp.write_text(json.dumps(idx, indent=2), encoding="utf-8")
|
|
132
|
-
|
|
133
|
-
os.chmod(tmp, 0o600)
|
|
134
|
-
except Exception:
|
|
135
|
-
pass
|
|
159
|
+
_restrict_perms(tmp)
|
|
136
160
|
tmp.replace(PROFILE_INDEX)
|
|
137
161
|
|
|
138
162
|
|
|
@@ -182,10 +206,7 @@ def set_api_key(api_key: str, profile: str = DEFAULT_PROFILE,
|
|
|
182
206
|
fallback_path = Path.home() / ".meshcode" / f"credentials.{profile}.json"
|
|
183
207
|
fallback_path.parent.mkdir(parents=True, exist_ok=True)
|
|
184
208
|
fallback_path.write_text(json.dumps({"api_key": api_key, **(meta or {})}, indent=2), encoding="utf-8")
|
|
185
|
-
|
|
186
|
-
os.chmod(fallback_path, 0o600)
|
|
187
|
-
except Exception:
|
|
188
|
-
pass
|
|
209
|
+
_restrict_perms(fallback_path)
|
|
189
210
|
_index_set(profile, {"backend": "file-fallback", **(meta or {})})
|
|
190
211
|
return True
|
|
191
212
|
|
|
@@ -204,11 +204,31 @@ def _build_server_block(project: str, project_id: str, agent: str, role: str,
|
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
|
|
207
|
+
def _restrict_perms(path: Path) -> None:
|
|
208
|
+
"""Restrict file to owner-only access (Windows-aware)."""
|
|
209
|
+
try:
|
|
210
|
+
if sys.platform == "win32":
|
|
211
|
+
import subprocess
|
|
212
|
+
username = os.environ.get("USERNAME", os.environ.get("USER", ""))
|
|
213
|
+
if username:
|
|
214
|
+
subprocess.run(
|
|
215
|
+
["icacls", str(path), "/inheritance:r",
|
|
216
|
+
"/grant:r", f"{username}:(F)"],
|
|
217
|
+
capture_output=True, timeout=5,
|
|
218
|
+
)
|
|
219
|
+
else:
|
|
220
|
+
os.chmod(path, 0o600)
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
|
|
207
225
|
def _atomic_write_json(path: Path, data: dict) -> None:
|
|
208
226
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
209
227
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
210
228
|
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
229
|
+
_restrict_perms(tmp)
|
|
211
230
|
tmp.replace(path)
|
|
231
|
+
_restrict_perms(path)
|
|
212
232
|
|
|
213
233
|
|
|
214
234
|
def _toml_escape(s: str) -> str:
|
|
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
|