meshcode 2.10.42__tar.gz → 2.10.45__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.42 → meshcode-2.10.45}/PKG-INFO +1 -1
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/__init__.py +1 -1
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/comms_v4.py +82 -10
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/meshcode_mcp/server.py +37 -14
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/run_agent.py +162 -15
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/secrets.py +29 -8
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/setup_clients.py +20 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode.egg-info/PKG-INFO +1 -1
- {meshcode-2.10.42 → meshcode-2.10.45}/pyproject.toml +1 -1
- {meshcode-2.10.42 → meshcode-2.10.45}/README.md +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/ascii_art.py +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/cli.py +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/invites.py +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/launcher.py +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/launcher_install.py +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/meshcode_mcp/__init__.py +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/meshcode_mcp/__main__.py +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/meshcode_mcp/backend.py +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/meshcode_mcp/realtime.py +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/meshcode_mcp/test_backend.py +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/meshcode_mcp/test_realtime.py +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/preferences.py +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/protocol_v2.py +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/self_update.py +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode.egg-info/SOURCES.txt +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode.egg-info/dependency_links.txt +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode.egg-info/entry_points.txt +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode.egg-info/requires.txt +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/meshcode.egg-info/top_level.txt +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/setup.cfg +0 -0
- {meshcode-2.10.42 → meshcode-2.10.45}/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.45"
|
|
@@ -315,10 +315,14 @@ def mark_nudged(project, name):
|
|
|
315
315
|
|
|
316
316
|
|
|
317
317
|
def _sanitize_notification_text(text: str) -> str:
|
|
318
|
-
"""
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
318
|
+
"""Whitelist-sanitize text for safe use in shell notification commands.
|
|
319
|
+
|
|
320
|
+
Only allows alphanumeric, spaces, and safe punctuation. This is a
|
|
321
|
+
whitelist (not blocklist) to prevent injection in osascript, PowerShell,
|
|
322
|
+
and notify-send even if new shell metacharacters are discovered.
|
|
323
|
+
"""
|
|
324
|
+
import re
|
|
325
|
+
return re.sub(r'[^a-zA-Z0-9 _\-.,!?:@#/\u00c0-\u024f\u2190-\u21ff]', '', text)[:200]
|
|
322
326
|
|
|
323
327
|
|
|
324
328
|
def send_notification(project, name, from_agent, pending=1):
|
|
@@ -332,19 +336,20 @@ def send_notification(project, name, from_agent, pending=1):
|
|
|
332
336
|
f'display notification "{body}" with title "{title}" sound name "Ping"'],
|
|
333
337
|
capture_output=True, timeout=3)
|
|
334
338
|
elif sys.platform == "win32":
|
|
335
|
-
# PowerShell:
|
|
336
|
-
#
|
|
339
|
+
# PowerShell: read title/body from environment variables instead of
|
|
340
|
+
# interpolating into the script string — prevents PS injection.
|
|
337
341
|
ps_script = (
|
|
338
342
|
'[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null; '
|
|
339
343
|
'$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(0); '
|
|
340
344
|
'$text = $xml.GetElementsByTagName("text"); '
|
|
341
|
-
|
|
342
|
-
|
|
345
|
+
'$text[0].AppendChild($xml.CreateTextNode($env:MC_NOTIFY_TITLE)); '
|
|
346
|
+
'$text[1].AppendChild($xml.CreateTextNode($env:MC_NOTIFY_BODY)); '
|
|
343
347
|
'$toast = [Windows.UI.Notifications.ToastNotification]::new($xml); '
|
|
344
348
|
'[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("MeshCode").Show($toast)'
|
|
345
349
|
)
|
|
350
|
+
env = {**os.environ, "MC_NOTIFY_TITLE": title, "MC_NOTIFY_BODY": body}
|
|
346
351
|
subprocess.run(['powershell', '-NoProfile', '-Command', ps_script],
|
|
347
|
-
capture_output=True, timeout=5)
|
|
352
|
+
capture_output=True, timeout=5, env=env)
|
|
348
353
|
else:
|
|
349
354
|
# Linux: notify-send takes title and body as separate args (no shell)
|
|
350
355
|
subprocess.run(['notify-send', title, body], capture_output=True, timeout=3)
|
|
@@ -352,6 +357,48 @@ def send_notification(project, name, from_agent, pending=1):
|
|
|
352
357
|
pass
|
|
353
358
|
|
|
354
359
|
|
|
360
|
+
def _nudge_windows_terminal(name, pending, tty, pid):
|
|
361
|
+
"""Activate the agent's terminal window on Windows.
|
|
362
|
+
|
|
363
|
+
Uses PowerShell + kernel32 to find the console window by process ID and
|
|
364
|
+
bring it to the foreground. Does NOT send keystrokes — that would risk
|
|
365
|
+
confirming prompts or interrupting bypass mode. The window activation
|
|
366
|
+
combined with the desktop notification is enough to alert the user.
|
|
367
|
+
"""
|
|
368
|
+
if not pid:
|
|
369
|
+
return
|
|
370
|
+
try:
|
|
371
|
+
pid = int(pid)
|
|
372
|
+
except (ValueError, TypeError):
|
|
373
|
+
return
|
|
374
|
+
try:
|
|
375
|
+
ps_script = f'''
|
|
376
|
+
$proc = Get-Process -Id {pid} -ErrorAction SilentlyContinue
|
|
377
|
+
if ($proc -and $proc.MainWindowHandle -ne 0) {{
|
|
378
|
+
Add-Type @"
|
|
379
|
+
using System;
|
|
380
|
+
using System.Runtime.InteropServices;
|
|
381
|
+
public class WinApi {{
|
|
382
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
|
|
383
|
+
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
|
384
|
+
}}
|
|
385
|
+
"@
|
|
386
|
+
[WinApi]::ShowWindow($proc.MainWindowHandle, 9)
|
|
387
|
+
[WinApi]::SetForegroundWindow($proc.MainWindowHandle)
|
|
388
|
+
}}
|
|
389
|
+
'''
|
|
390
|
+
subprocess.run(
|
|
391
|
+
["powershell", "-NoProfile", "-Command", ps_script],
|
|
392
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
393
|
+
timeout=10,
|
|
394
|
+
)
|
|
395
|
+
log_msg(f"[nudge] Windows: activated terminal for {name} (pid={pid})")
|
|
396
|
+
except subprocess.TimeoutExpired:
|
|
397
|
+
log_msg(f"[nudge] Windows: PowerShell timed out for {name}")
|
|
398
|
+
except Exception as e:
|
|
399
|
+
log_msg(f"[nudge] Windows: failed for {name}: {e}")
|
|
400
|
+
|
|
401
|
+
|
|
355
402
|
def _is_pid_alive(pid):
|
|
356
403
|
"""Return True iff the given pid corresponds to a live process."""
|
|
357
404
|
if not pid:
|
|
@@ -796,8 +843,10 @@ def nudge_agent(project, name, from_agent=""):
|
|
|
796
843
|
message = f"Tienes {pending} mensaje(s). Revisa: meshcode read {project} {name}"
|
|
797
844
|
escaped_message = message.replace('\\', '\\\\').replace('"', '\\"')
|
|
798
845
|
|
|
799
|
-
#
|
|
846
|
+
# Non-macOS: use platform-specific terminal nudge
|
|
800
847
|
if sys.platform != "darwin":
|
|
848
|
+
if sys.platform == "win32":
|
|
849
|
+
_nudge_windows_terminal(name, pending, tty, cached_pid)
|
|
801
850
|
send_notification(project, name, from_agent, pending)
|
|
802
851
|
mark_nudged(project, name)
|
|
803
852
|
return True
|
|
@@ -2691,12 +2740,35 @@ if __name__ == "__main__":
|
|
|
2691
2740
|
cur = get_permission_mode()
|
|
2692
2741
|
print(f"permission_mode: {cur or '(unset — will prompt on next run)'}")
|
|
2693
2742
|
sys.exit(0)
|
|
2743
|
+
elif sub == "claude-version":
|
|
2744
|
+
from meshcode.preferences import load_prefs, save_prefs
|
|
2745
|
+
if len(sys.argv) > 3:
|
|
2746
|
+
ver = sys.argv[3].strip()
|
|
2747
|
+
if ver in ("reset", "unpin", "latest"):
|
|
2748
|
+
prefs = load_prefs()
|
|
2749
|
+
prefs.pop("claude_version", None)
|
|
2750
|
+
save_prefs(prefs)
|
|
2751
|
+
print("[meshcode] Claude Code version pin removed — will use installed version")
|
|
2752
|
+
else:
|
|
2753
|
+
prefs = load_prefs()
|
|
2754
|
+
prefs["claude_version"] = ver
|
|
2755
|
+
save_prefs(prefs)
|
|
2756
|
+
print(f"[meshcode] Claude Code version pinned to: {ver}")
|
|
2757
|
+
print(f"[meshcode] meshcode run will use npx @anthropic-ai/claude-code@{ver}")
|
|
2758
|
+
sys.exit(0)
|
|
2759
|
+
else:
|
|
2760
|
+
from meshcode.preferences import load_prefs
|
|
2761
|
+
cur = load_prefs().get("claude_version")
|
|
2762
|
+
print(f"claude_version: {cur or '(not pinned — using installed version)'}")
|
|
2763
|
+
sys.exit(0)
|
|
2694
2764
|
elif sub == "reset":
|
|
2695
2765
|
reset_permission_mode()
|
|
2696
2766
|
print("[meshcode] preferences reset")
|
|
2697
2767
|
sys.exit(0)
|
|
2698
2768
|
else:
|
|
2699
2769
|
print("Usage: meshcode prefs permission-mode [bypass|safe|ask]")
|
|
2770
|
+
print(" meshcode prefs claude-version [version|reset]")
|
|
2771
|
+
print(" meshcode prefs auto-update [on|off|reset]")
|
|
2700
2772
|
print(" meshcode prefs reset")
|
|
2701
2773
|
sys.exit(1)
|
|
2702
2774
|
|
|
@@ -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
|
|
|
@@ -174,10 +174,12 @@ def _try_auto_wake(from_agent: str, preview: str) -> None:
|
|
|
174
174
|
system = platform.system()
|
|
175
175
|
try:
|
|
176
176
|
if system == "Darwin":
|
|
177
|
-
#
|
|
177
|
+
# Sanitize app_name — TERM_PROGRAM is an env var, not DB-sourced,
|
|
178
|
+
# but defense-in-depth: only allow known terminal app names.
|
|
178
179
|
parent_app = os.environ.get("TERM_PROGRAM", "Terminal")
|
|
179
180
|
app_name = "iTerm" if "iTerm" in parent_app else "Terminal"
|
|
180
|
-
# Escape for AppleScript string: replace backslash and double-quote
|
|
181
|
+
# Escape for AppleScript string: replace backslash and double-quote.
|
|
182
|
+
# nudge is already whitelist-sanitized (alphanum + _-.,!? space only).
|
|
181
183
|
as_safe = nudge.replace("\\", "\\\\").replace('"', '\\"')
|
|
182
184
|
script = f'''
|
|
183
185
|
tell application "{app_name}"
|
|
@@ -191,14 +193,16 @@ def _try_auto_wake(from_agent: str, preview: str) -> None:
|
|
|
191
193
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
192
194
|
log.info(f"auto-wake: injected nudge via {app_name} AppleScript")
|
|
193
195
|
elif system == "Windows":
|
|
194
|
-
#
|
|
195
|
-
|
|
196
|
-
ps_script =
|
|
196
|
+
# Pass nudge text via environment variable instead of interpolating
|
|
197
|
+
# into a PowerShell string — prevents any PS injection.
|
|
198
|
+
ps_script = '''
|
|
197
199
|
Add-Type -AssemblyName System.Windows.Forms
|
|
198
|
-
[System.Windows.Forms.SendKeys]::SendWait("{
|
|
200
|
+
[System.Windows.Forms.SendKeys]::SendWait($env:MC_NUDGE_TEXT + "{ENTER}")
|
|
199
201
|
'''
|
|
200
|
-
|
|
201
|
-
|
|
202
|
+
env = {**os.environ, "MC_NUDGE_TEXT": nudge}
|
|
203
|
+
subprocess.run(["powershell", "-NoProfile", "-Command", ps_script],
|
|
204
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
205
|
+
timeout=10, env=env)
|
|
202
206
|
log.info("auto-wake: injected nudge via PowerShell SendKeys")
|
|
203
207
|
else:
|
|
204
208
|
# Linux — xdotool takes args directly, not shell-interpolated (safe)
|
|
@@ -3026,6 +3030,25 @@ def meshcode_recall_search(query: str) -> Dict[str, Any]:
|
|
|
3026
3030
|
})
|
|
3027
3031
|
|
|
3028
3032
|
|
|
3033
|
+
# ----------------- BUG REPORTING -----------------
|
|
3034
|
+
|
|
3035
|
+
@mcp.tool()
|
|
3036
|
+
@with_working_status
|
|
3037
|
+
def meshcode_report_bug(description: str) -> Dict[str, Any]:
|
|
3038
|
+
"""Report a bug from within the mesh. Visible in admin panel.
|
|
3039
|
+
|
|
3040
|
+
Args:
|
|
3041
|
+
description: What went wrong — include steps to reproduce if possible.
|
|
3042
|
+
"""
|
|
3043
|
+
api_key = _get_api_key()
|
|
3044
|
+
return be.sb_rpc("mc_report_bug", {
|
|
3045
|
+
"p_api_key": api_key,
|
|
3046
|
+
"p_description": description,
|
|
3047
|
+
"p_meshwork_name": PROJECT_NAME,
|
|
3048
|
+
"p_agent_name": AGENT_NAME,
|
|
3049
|
+
})
|
|
3050
|
+
|
|
3051
|
+
|
|
3029
3052
|
# ----------------- HEALTH CHECK -----------------
|
|
3030
3053
|
|
|
3031
3054
|
@mcp.tool()
|
|
@@ -351,6 +351,106 @@ def _list_local_projects_for_agent(agent: str) -> list:
|
|
|
351
351
|
return out
|
|
352
352
|
|
|
353
353
|
|
|
354
|
+
STABLE_CLAUDE_VERSION = "2.1.104" # Known-good version: ESC does NOT kill MCP
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _get_claude_version(editor_cmd: str) -> Optional[str]:
|
|
358
|
+
"""Get the installed Claude Code version. Returns None on failure."""
|
|
359
|
+
try:
|
|
360
|
+
use_shell = sys.platform == "win32" and editor_cmd.lower().endswith((".cmd", ".bat"))
|
|
361
|
+
r = subprocess.run(
|
|
362
|
+
[editor_cmd, "--version"],
|
|
363
|
+
capture_output=True, text=True, timeout=10,
|
|
364
|
+
shell=use_shell,
|
|
365
|
+
)
|
|
366
|
+
# Output is typically just the version string, e.g. "2.1.104"
|
|
367
|
+
version = r.stdout.strip().split('\n')[0].strip()
|
|
368
|
+
if version:
|
|
369
|
+
return version
|
|
370
|
+
except Exception:
|
|
371
|
+
pass
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _fetch_global_claude_version() -> Optional[str]:
|
|
376
|
+
"""Fetch the admin-pinned Claude Code version from mc_global_config.
|
|
377
|
+
|
|
378
|
+
Uses the user's API key to authenticate the RPC call. Returns None
|
|
379
|
+
on any failure (no network, no key, etc.) — caller falls back to
|
|
380
|
+
local preferences or installed version.
|
|
381
|
+
"""
|
|
382
|
+
try:
|
|
383
|
+
from .secrets import get_api_key
|
|
384
|
+
api_key = get_api_key()
|
|
385
|
+
if not api_key:
|
|
386
|
+
return None
|
|
387
|
+
from .setup_clients import _load_supabase_env
|
|
388
|
+
env = _load_supabase_env()
|
|
389
|
+
url = env.get("SUPABASE_URL", "")
|
|
390
|
+
anon_key = env.get("SUPABASE_KEY", "")
|
|
391
|
+
if not url or not anon_key:
|
|
392
|
+
return None
|
|
393
|
+
from urllib.request import Request, urlopen
|
|
394
|
+
import json as _json
|
|
395
|
+
req = Request(
|
|
396
|
+
f"{url}/rest/v1/rpc/mc_get_global_config_by_key",
|
|
397
|
+
data=_json.dumps({"p_api_key": api_key, "p_config_key": "claude_version"}).encode(),
|
|
398
|
+
headers={
|
|
399
|
+
"Content-Type": "application/json",
|
|
400
|
+
"apikey": anon_key,
|
|
401
|
+
"Authorization": f"Bearer {anon_key}",
|
|
402
|
+
},
|
|
403
|
+
)
|
|
404
|
+
with urlopen(req, timeout=5) as resp:
|
|
405
|
+
raw = _json.loads(resp.read())
|
|
406
|
+
# RPC returns the JSONB value directly — could be a string or object
|
|
407
|
+
if isinstance(raw, str) and raw not in ("latest", ""):
|
|
408
|
+
return raw
|
|
409
|
+
except Exception:
|
|
410
|
+
pass
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _resolve_pinned_claude(editor_cmd: str) -> str:
|
|
415
|
+
"""Check if a specific Claude Code version is pinned and resolve the command.
|
|
416
|
+
|
|
417
|
+
Version pin sources (first wins):
|
|
418
|
+
1. MESHCODE_CLAUDE_VERSION env var (per-launch override)
|
|
419
|
+
2. mc_global_config 'claude_version' from DB (admin-controlled)
|
|
420
|
+
3. claude_version in ~/.meshcode/preferences.json (local fallback)
|
|
421
|
+
4. No pin — use whatever is installed
|
|
422
|
+
|
|
423
|
+
If a pin is set and the installed version doesn't match, uses npx to
|
|
424
|
+
run the exact pinned version.
|
|
425
|
+
"""
|
|
426
|
+
from .preferences import load_prefs
|
|
427
|
+
|
|
428
|
+
pinned = os.environ.get("MESHCODE_CLAUDE_VERSION", "").strip()
|
|
429
|
+
if not pinned:
|
|
430
|
+
# Check global admin config from DB
|
|
431
|
+
global_ver = _fetch_global_claude_version()
|
|
432
|
+
if global_ver:
|
|
433
|
+
pinned = global_ver
|
|
434
|
+
print(f"[meshcode] Global admin pin: Claude Code v{pinned}", file=sys.stderr)
|
|
435
|
+
if not pinned:
|
|
436
|
+
pinned = load_prefs().get("claude_version", "")
|
|
437
|
+
if not pinned:
|
|
438
|
+
return editor_cmd # No pin — use installed version
|
|
439
|
+
|
|
440
|
+
current = _get_claude_version(editor_cmd)
|
|
441
|
+
if current == pinned:
|
|
442
|
+
print(f"[meshcode] Claude Code version {current} matches pin ✓", file=sys.stderr)
|
|
443
|
+
return editor_cmd
|
|
444
|
+
|
|
445
|
+
print(f"[meshcode] Version pin: {pinned} (installed: {current or 'unknown'})", file=sys.stderr)
|
|
446
|
+
print(f"[meshcode] Using npx to run pinned version...", file=sys.stderr)
|
|
447
|
+
|
|
448
|
+
# Use npx to run the exact pinned version. npx will download it if needed.
|
|
449
|
+
# We set an env var so the launch path knows to prepend npx args.
|
|
450
|
+
os.environ["_MESHCODE_NPX_CLAUDE_VERSION"] = pinned
|
|
451
|
+
return editor_cmd # Still return the editor — launch path handles npx
|
|
452
|
+
|
|
453
|
+
|
|
354
454
|
def _claude_supports_bypass(editor_cmd: str) -> bool:
|
|
355
455
|
"""Check if the Claude Code binary supports --dangerously-skip-permissions.
|
|
356
456
|
|
|
@@ -363,13 +463,30 @@ def _claude_supports_bypass(editor_cmd: str) -> bool:
|
|
|
363
463
|
"""
|
|
364
464
|
try:
|
|
365
465
|
use_shell = sys.platform == "win32" and editor_cmd.lower().endswith((".cmd", ".bat"))
|
|
466
|
+
print(f"[meshcode] bypass-detect: checking '{editor_cmd}' (shell={use_shell})", file=sys.stderr)
|
|
366
467
|
r = subprocess.run(
|
|
367
468
|
[editor_cmd, "--help"],
|
|
368
469
|
capture_output=True, text=True, timeout=10,
|
|
369
470
|
shell=use_shell,
|
|
370
471
|
)
|
|
371
|
-
|
|
372
|
-
|
|
472
|
+
found = "--dangerously-skip-permissions" in r.stdout
|
|
473
|
+
if found:
|
|
474
|
+
print(f"[meshcode] bypass-detect: ✓ flag found — bypass mode available", file=sys.stderr)
|
|
475
|
+
else:
|
|
476
|
+
stdout_len = len(r.stdout)
|
|
477
|
+
stderr_preview = (r.stderr or "")[:200]
|
|
478
|
+
print(f"[meshcode] bypass-detect: ✗ flag NOT found in --help output ({stdout_len} chars, exit={r.returncode})", file=sys.stderr)
|
|
479
|
+
if stderr_preview:
|
|
480
|
+
print(f"[meshcode] bypass-detect: stderr: {stderr_preview}", file=sys.stderr)
|
|
481
|
+
return found
|
|
482
|
+
except subprocess.TimeoutExpired:
|
|
483
|
+
print(f"[meshcode] bypass-detect: ✗ '{editor_cmd} --help' timed out after 10s", file=sys.stderr)
|
|
484
|
+
return False
|
|
485
|
+
except FileNotFoundError:
|
|
486
|
+
print(f"[meshcode] bypass-detect: ✗ '{editor_cmd}' not found in PATH", file=sys.stderr)
|
|
487
|
+
return False
|
|
488
|
+
except Exception as e:
|
|
489
|
+
print(f"[meshcode] bypass-detect: ✗ unexpected error: {e}", file=sys.stderr)
|
|
373
490
|
return False
|
|
374
491
|
|
|
375
492
|
|
|
@@ -377,17 +494,22 @@ def _detect_editor() -> Optional[str]:
|
|
|
377
494
|
"""Pick the user's preferred MCP-aware editor."""
|
|
378
495
|
override = os.environ.get("MESHCODE_EDITOR", "").strip().lower()
|
|
379
496
|
if override:
|
|
380
|
-
|
|
381
|
-
|
|
497
|
+
resolved = shutil.which(override)
|
|
498
|
+
if resolved:
|
|
499
|
+
# On Windows, return resolved path to preserve .cmd/.bat extension
|
|
500
|
+
return resolved if sys.platform == "win32" else override
|
|
382
501
|
print(f"[meshcode] WARNING: MESHCODE_EDITOR='{override}' not found in PATH", file=sys.stderr)
|
|
383
502
|
|
|
384
503
|
# Detection order: most likely to be installed + best per-workspace MCP
|
|
385
504
|
# support first. codex is last because its MCP config is GLOBAL only —
|
|
386
505
|
# launching it doesn't take a workspace-scoped .mcp.json, so it should
|
|
387
506
|
# only be picked if nothing else is available.
|
|
507
|
+
# On Windows, return the fully-resolved path so .cmd/.bat extension is
|
|
508
|
+
# preserved — needed for shell=True detection in bypass and launch paths.
|
|
388
509
|
for cmd in ("claude", "cursor", "code", "windsurf", "codex"):
|
|
389
|
-
|
|
390
|
-
|
|
510
|
+
resolved = shutil.which(cmd)
|
|
511
|
+
if resolved:
|
|
512
|
+
return resolved if sys.platform == "win32" else cmd
|
|
391
513
|
|
|
392
514
|
# Windows fallback: check common install locations not in PATH
|
|
393
515
|
if sys.platform == "win32":
|
|
@@ -641,10 +763,14 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
|
|
|
641
763
|
print(f"[meshcode] Closing the editor will flip this agent offline.")
|
|
642
764
|
print()
|
|
643
765
|
|
|
766
|
+
# On Windows, editor is the fully-resolved path (e.g. 'c:\...\claude.cmd')
|
|
767
|
+
# so we compare against the stem (filename without extension).
|
|
768
|
+
editor_stem = Path(editor).stem.lower() if sys.platform == "win32" else editor
|
|
769
|
+
|
|
644
770
|
# Set editor type so MCP server can report it to the dashboard
|
|
645
|
-
os.environ["MESHCODE_EDITOR_TYPE"] =
|
|
771
|
+
os.environ["MESHCODE_EDITOR_TYPE"] = editor_stem
|
|
646
772
|
|
|
647
|
-
if
|
|
773
|
+
if editor_stem == "claude":
|
|
648
774
|
# Claude Code: run from inside the workspace and let Claude Code
|
|
649
775
|
# auto-discover .mcp.json as a PROJECT-scoped MCP. We used to pass
|
|
650
776
|
# --mcp-config + --strict-mcp-config here, but that categorises the
|
|
@@ -654,10 +780,24 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
|
|
|
654
780
|
# scoped MCPs (loaded from ./.mcp.json at cwd) live in the "Local
|
|
655
781
|
# MCP" bucket and survive ESC. Verified with a minimal FastMCP
|
|
656
782
|
# repro side-by-side: Built-in dies, Local survives, same SDK code.
|
|
783
|
+
# Check for version pin before building launch command
|
|
784
|
+
effective_editor = _resolve_pinned_claude(editor)
|
|
785
|
+
pinned_version = os.environ.get("_MESHCODE_NPX_CLAUDE_VERSION", "")
|
|
786
|
+
|
|
657
787
|
mode = resolve_permission_mode(permission_override)
|
|
658
|
-
|
|
788
|
+
print(f"[meshcode] Editor resolved: {editor}", file=sys.stderr)
|
|
789
|
+
print(f"[meshcode] Permission mode resolved: {mode}", file=sys.stderr)
|
|
790
|
+
|
|
791
|
+
if pinned_version:
|
|
792
|
+
# Use npx to run the exact pinned version
|
|
793
|
+
cmd = ["npx", f"@anthropic-ai/claude-code@{pinned_version}"]
|
|
794
|
+
print(f"[meshcode] Using pinned version via npx: {pinned_version}")
|
|
795
|
+
else:
|
|
796
|
+
cmd = [effective_editor]
|
|
797
|
+
|
|
659
798
|
if mode == "bypass":
|
|
660
|
-
if
|
|
799
|
+
bypass_check_cmd = effective_editor if not pinned_version else "claude"
|
|
800
|
+
if pinned_version or _claude_supports_bypass(bypass_check_cmd):
|
|
661
801
|
cmd.append("--dangerously-skip-permissions")
|
|
662
802
|
print(f"[meshcode] Permission mode: bypass (autonomous loop, no prompts)")
|
|
663
803
|
else:
|
|
@@ -678,17 +818,17 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
|
|
|
678
818
|
os.chdir(ws)
|
|
679
819
|
except Exception as e:
|
|
680
820
|
print(f"[meshcode] WARNING: chdir to workspace failed: {e}", file=sys.stderr)
|
|
681
|
-
elif
|
|
821
|
+
elif editor_stem == "cursor":
|
|
682
822
|
# Cursor reads .cursor/mcp.json from the workspace cwd.
|
|
683
823
|
cmd = [editor, str(ws)]
|
|
684
|
-
elif
|
|
824
|
+
elif editor_stem == "code":
|
|
685
825
|
# VS Code (Cline reads .vscode/mcp.json from workspace cwd).
|
|
686
826
|
cmd = [editor, str(ws)]
|
|
687
|
-
elif
|
|
827
|
+
elif editor_stem == "windsurf":
|
|
688
828
|
# Windsurf (Codeium fork of VS Code) — reads workspace cwd the same
|
|
689
829
|
# way VS Code does; global MCP config at ~/.codeium/windsurf/mcp_config.json.
|
|
690
830
|
cmd = [editor, str(ws)]
|
|
691
|
-
elif
|
|
831
|
+
elif editor_stem == "codex":
|
|
692
832
|
# Codex CLI reads ~/.codex/config.toml globally. There's no per-
|
|
693
833
|
# workspace flag, so launching is just `codex` with cwd set to the
|
|
694
834
|
# workspace dir so any file ops the agent does land there.
|
|
@@ -745,7 +885,14 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
|
|
|
745
885
|
# workspace even if os.chdir() didn't stick (shell=True can
|
|
746
886
|
# reset cwd via cmd.exe).
|
|
747
887
|
import subprocess as _sp
|
|
748
|
-
|
|
888
|
+
# .cmd/.bat files need shell=True. Also check if the resolved
|
|
889
|
+
# path of cmd[0] is a .cmd (e.g., "npx" resolves to "npx.cmd").
|
|
890
|
+
cmd0 = cmd[0].lower()
|
|
891
|
+
use_shell = cmd0.endswith((".cmd", ".bat"))
|
|
892
|
+
if not use_shell:
|
|
893
|
+
resolved_cmd0 = shutil.which(cmd[0])
|
|
894
|
+
if resolved_cmd0 and resolved_cmd0.lower().endswith((".cmd", ".bat")):
|
|
895
|
+
use_shell = True
|
|
749
896
|
if use_shell:
|
|
750
897
|
print(f"[meshcode] (Windows: launching via shell for .cmd wrapper)")
|
|
751
898
|
launch_cwd = str(ws) if os.path.isdir(ws) else None
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|