meshcode 1.4.0__tar.gz → 1.5.1__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-1.4.0 → meshcode-1.5.1}/PKG-INFO +2 -1
- {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/__init__.py +1 -1
- {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/comms_v4.py +117 -15
- meshcode-1.5.1/meshcode/invites.py +363 -0
- {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/meshcode_mcp/server.py +43 -5
- meshcode-1.5.1/meshcode/secrets.py +337 -0
- {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/setup_clients.py +102 -18
- {meshcode-1.4.0 → meshcode-1.5.1}/meshcode.egg-info/PKG-INFO +2 -1
- {meshcode-1.4.0 → meshcode-1.5.1}/meshcode.egg-info/SOURCES.txt +2 -0
- {meshcode-1.4.0 → meshcode-1.5.1}/meshcode.egg-info/requires.txt +1 -0
- {meshcode-1.4.0 → meshcode-1.5.1}/pyproject.toml +2 -1
- {meshcode-1.4.0 → meshcode-1.5.1}/README.md +0 -0
- {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/cli.py +0 -0
- {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/launcher.py +0 -0
- {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/launcher_install.py +0 -0
- {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/meshcode_mcp/__init__.py +0 -0
- {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/meshcode_mcp/__main__.py +0 -0
- {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/meshcode_mcp/backend.py +0 -0
- {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/meshcode_mcp/realtime.py +0 -0
- {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/meshcode_mcp/test_backend.py +0 -0
- {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/meshcode_mcp/test_realtime.py +0 -0
- {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/protocol_v2.py +0 -0
- {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/run_agent.py +0 -0
- {meshcode-1.4.0 → meshcode-1.5.1}/meshcode.egg-info/dependency_links.txt +0 -0
- {meshcode-1.4.0 → meshcode-1.5.1}/meshcode.egg-info/entry_points.txt +0 -0
- {meshcode-1.4.0 → meshcode-1.5.1}/meshcode.egg-info/top_level.txt +0 -0
- {meshcode-1.4.0 → meshcode-1.5.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meshcode
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.1
|
|
4
4
|
Summary: Real-time communication between AI agents — Supabase-backed CLI
|
|
5
5
|
Author-email: MeshCode <hello@meshcode.io>
|
|
6
6
|
License: MIT
|
|
@@ -21,6 +21,7 @@ Description-Content-Type: text/markdown
|
|
|
21
21
|
Requires-Dist: mcp[cli]>=1.0.0
|
|
22
22
|
Requires-Dist: websockets>=12.0
|
|
23
23
|
Requires-Dist: realtime>=2.0.0
|
|
24
|
+
Requires-Dist: keyring>=24.0
|
|
24
25
|
|
|
25
26
|
# MeshCode
|
|
26
27
|
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""MeshCode — Real-time communication between AI agents."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.5.1"
|
|
@@ -1396,13 +1396,13 @@ def connect(project, name, hook_target="claude", role=""):
|
|
|
1396
1396
|
print(f"[MESHCODE] Plataforma detectada: {os_name}")
|
|
1397
1397
|
print(f"[MESHCODE] Conectando a meshwork '{project}' como '{name}'...")
|
|
1398
1398
|
|
|
1399
|
-
#
|
|
1400
|
-
|
|
1401
|
-
if
|
|
1399
|
+
# Show logged-in user (from non-secret metadata, NOT the api key file)
|
|
1400
|
+
meta_path = Path.home() / ".meshcode" / "profile_meta.json"
|
|
1401
|
+
if meta_path.exists():
|
|
1402
1402
|
try:
|
|
1403
|
-
|
|
1404
|
-
print(f"[MESHCODE] Autenticado como {
|
|
1405
|
-
except:
|
|
1403
|
+
meta = json.loads(meta_path.read_text())
|
|
1404
|
+
print(f"[MESHCODE] Autenticado como {meta.get('display_name', meta.get('email', '?'))}")
|
|
1405
|
+
except Exception:
|
|
1406
1406
|
pass
|
|
1407
1407
|
|
|
1408
1408
|
# Register agent
|
|
@@ -1453,23 +1453,57 @@ def connect(project, name, hook_target="claude", role=""):
|
|
|
1453
1453
|
|
|
1454
1454
|
|
|
1455
1455
|
def login(api_key):
|
|
1456
|
-
"""Authenticate with API key and save
|
|
1456
|
+
"""Authenticate with API key and save it to the OS keychain.
|
|
1457
|
+
|
|
1458
|
+
1.4.1+ stores api keys in the OS keychain (macOS Keychain, Linux
|
|
1459
|
+
libsecret, Windows Credential Manager) instead of plain text on disk.
|
|
1460
|
+
A small metadata file at ~/.meshcode/profile_meta.json holds the
|
|
1461
|
+
user_id / email / display_name (no secrets) so the rest of the CLI
|
|
1462
|
+
can show "Logged in as ..." without hitting Supabase every call.
|
|
1463
|
+
"""
|
|
1457
1464
|
result = sb_rpc("mc_validate_api_key", {"p_api_key": api_key})
|
|
1458
1465
|
if not result or not result.get("valid"):
|
|
1459
1466
|
print("[MESHCODE] API key inválida o expirada")
|
|
1460
1467
|
return False
|
|
1461
1468
|
|
|
1462
|
-
#
|
|
1469
|
+
# Lazy import to avoid pulling keyring at every CLI startup if not needed
|
|
1470
|
+
try:
|
|
1471
|
+
import importlib
|
|
1472
|
+
secrets_mod = importlib.import_module("meshcode.secrets")
|
|
1473
|
+
except Exception as e:
|
|
1474
|
+
print(f"[MESHCODE] ERROR: cannot load secrets module: {e}")
|
|
1475
|
+
return False
|
|
1476
|
+
|
|
1477
|
+
meta = {
|
|
1478
|
+
"user_id": result.get("user_id"),
|
|
1479
|
+
"email": result.get("email"),
|
|
1480
|
+
"display_name": result.get("display_name"),
|
|
1481
|
+
}
|
|
1482
|
+
if not secrets_mod.set_api_key(api_key, profile=secrets_mod.DEFAULT_PROFILE, meta=meta):
|
|
1483
|
+
print("[MESHCODE] ERROR: could not store api key in keychain")
|
|
1484
|
+
return False
|
|
1485
|
+
|
|
1486
|
+
# Also persist non-secret metadata so we can show the logged-in user
|
|
1463
1487
|
config_dir = Path.home() / ".meshcode"
|
|
1464
1488
|
config_dir.mkdir(exist_ok=True)
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1489
|
+
meta_path = config_dir / "profile_meta.json"
|
|
1490
|
+
meta_path.write_text(json.dumps(meta, indent=2))
|
|
1491
|
+
try:
|
|
1492
|
+
os.chmod(meta_path, 0o600)
|
|
1493
|
+
except Exception:
|
|
1494
|
+
pass
|
|
1495
|
+
|
|
1496
|
+
# Drop the migration flag so we don't try to re-migrate next run
|
|
1497
|
+
flag = config_dir / ".migrated_to_keychain"
|
|
1498
|
+
if not flag.exists():
|
|
1499
|
+
try:
|
|
1500
|
+
flag.write_text("login")
|
|
1501
|
+
except Exception:
|
|
1502
|
+
pass
|
|
1503
|
+
|
|
1504
|
+
backend = secrets_mod.keyring_status().get("backend", "?")
|
|
1472
1505
|
print(f"[MESHCODE] Autenticado como {result['display_name']} ({result['email']})")
|
|
1506
|
+
print(f"[MESHCODE] API key stored in OS keychain ({backend})")
|
|
1473
1507
|
return True
|
|
1474
1508
|
|
|
1475
1509
|
|
|
@@ -1658,6 +1692,14 @@ def parse_flags(argv):
|
|
|
1658
1692
|
|
|
1659
1693
|
|
|
1660
1694
|
if __name__ == "__main__":
|
|
1695
|
+
# One-shot migration: pre-1.4.1 plain-text credentials.json → OS keychain.
|
|
1696
|
+
# Idempotent and safe; runs once per machine then drops a flag file.
|
|
1697
|
+
try:
|
|
1698
|
+
import importlib as _imp
|
|
1699
|
+
_imp.import_module("meshcode.secrets").migrate_legacy_credentials()
|
|
1700
|
+
except Exception:
|
|
1701
|
+
pass
|
|
1702
|
+
|
|
1661
1703
|
if len(sys.argv) < 2:
|
|
1662
1704
|
show_help()
|
|
1663
1705
|
sys.exit(0)
|
|
@@ -1908,6 +1950,66 @@ if __name__ == "__main__":
|
|
|
1908
1950
|
_run = importlib.import_module("meshcode.run_agent").run
|
|
1909
1951
|
sys.exit(_run(agent, project=proj_override, editor_override=editor_override))
|
|
1910
1952
|
|
|
1953
|
+
elif cmd == "invite":
|
|
1954
|
+
# meshcode invite <project> <agent> [--role "..."] [--days N]
|
|
1955
|
+
if len(pos) < 2:
|
|
1956
|
+
print("Usage: meshcode invite <project> <agent> [--role \"...\"] [--days 7]")
|
|
1957
|
+
sys.exit(1)
|
|
1958
|
+
proj = pos[0]
|
|
1959
|
+
agent = pos[1]
|
|
1960
|
+
role = flags.get("role", "")
|
|
1961
|
+
days = int(flags.get("days", "7") or 7)
|
|
1962
|
+
import importlib
|
|
1963
|
+
_inv = importlib.import_module("meshcode.invites")
|
|
1964
|
+
sys.exit(_inv.cmd_invite(proj, agent, role=role, days=days))
|
|
1965
|
+
|
|
1966
|
+
elif cmd == "join":
|
|
1967
|
+
# meshcode join <invite-token> [--display-name "alice"]
|
|
1968
|
+
if len(sys.argv) < 3:
|
|
1969
|
+
print("Usage: meshcode join <invite-token> [--display-name \"your name\"]")
|
|
1970
|
+
sys.exit(1)
|
|
1971
|
+
token = sys.argv[2] if not sys.argv[2].startswith("--") else (pos[0] if pos else "")
|
|
1972
|
+
display = flags.get("display-name") or flags.get("name")
|
|
1973
|
+
import importlib
|
|
1974
|
+
_inv = importlib.import_module("meshcode.invites")
|
|
1975
|
+
sys.exit(_inv.cmd_join(token, display_name=display))
|
|
1976
|
+
|
|
1977
|
+
elif cmd == "invites":
|
|
1978
|
+
# meshcode invites <project> — list outstanding invites
|
|
1979
|
+
if len(pos) < 1:
|
|
1980
|
+
print("Usage: meshcode invites <project>")
|
|
1981
|
+
sys.exit(1)
|
|
1982
|
+
import importlib
|
|
1983
|
+
_inv = importlib.import_module("meshcode.invites")
|
|
1984
|
+
sys.exit(_inv.cmd_list_invites(pos[0]))
|
|
1985
|
+
|
|
1986
|
+
elif cmd == "members":
|
|
1987
|
+
# meshcode members <project> — list members
|
|
1988
|
+
if len(pos) < 1:
|
|
1989
|
+
print("Usage: meshcode members <project>")
|
|
1990
|
+
sys.exit(1)
|
|
1991
|
+
import importlib
|
|
1992
|
+
_inv = importlib.import_module("meshcode.invites")
|
|
1993
|
+
sys.exit(_inv.cmd_list_members(pos[0]))
|
|
1994
|
+
|
|
1995
|
+
elif cmd == "revoke-invite":
|
|
1996
|
+
# meshcode revoke-invite <invite-id>
|
|
1997
|
+
if len(pos) < 1:
|
|
1998
|
+
print("Usage: meshcode revoke-invite <invite-id>")
|
|
1999
|
+
sys.exit(1)
|
|
2000
|
+
import importlib
|
|
2001
|
+
_inv = importlib.import_module("meshcode.invites")
|
|
2002
|
+
sys.exit(_inv.cmd_revoke_invite(pos[0]))
|
|
2003
|
+
|
|
2004
|
+
elif cmd == "revoke-member":
|
|
2005
|
+
# meshcode revoke-member <project> <member-user-id>
|
|
2006
|
+
if len(pos) < 2:
|
|
2007
|
+
print("Usage: meshcode revoke-member <project> <member-user-id>")
|
|
2008
|
+
sys.exit(1)
|
|
2009
|
+
import importlib
|
|
2010
|
+
_inv = importlib.import_module("meshcode.invites")
|
|
2011
|
+
sys.exit(_inv.cmd_revoke_member(pos[0], pos[1]))
|
|
2012
|
+
|
|
1911
2013
|
elif cmd == "login":
|
|
1912
2014
|
key = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
1913
2015
|
if not key:
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Invite + join CLI helpers (Phase 7B).
|
|
2
|
+
|
|
3
|
+
Lets users:
|
|
4
|
+
meshcode invite <project> <agent> [--role "..."] [--days 7]
|
|
5
|
+
→ owner generates an invite token + share URL for one specific
|
|
6
|
+
agent slot in their meshwork
|
|
7
|
+
|
|
8
|
+
meshcode join <token> [--display-name "alice"]
|
|
9
|
+
→ friend redeems the invite, gets a scoped api key tied to one
|
|
10
|
+
meshwork + one agent name, stores it in the OS keychain under
|
|
11
|
+
a per-mesh profile, and creates a workspace ready to launch
|
|
12
|
+
|
|
13
|
+
meshcode invites <project>
|
|
14
|
+
→ list outstanding + redeemed invites for a meshwork
|
|
15
|
+
|
|
16
|
+
meshcode members <project>
|
|
17
|
+
→ list members of a meshwork
|
|
18
|
+
|
|
19
|
+
meshcode revoke-invite <invite-id>
|
|
20
|
+
→ cancel an outstanding invite (or kick the redeemer if already in)
|
|
21
|
+
|
|
22
|
+
meshcode revoke-member <project> <member-user-id>
|
|
23
|
+
→ kick a member out of a meshwork
|
|
24
|
+
|
|
25
|
+
All RPCs run via the publishable Supabase key + the user's own api_key
|
|
26
|
+
for ownership validation. Scoped guest keys never see the owner's main
|
|
27
|
+
key — they get a narrow key that only works on their one agent slot.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Dict, Optional
|
|
36
|
+
from urllib.error import HTTPError, URLError
|
|
37
|
+
from urllib.request import Request, urlopen
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ============================================================
|
|
41
|
+
# Supabase RPC helpers
|
|
42
|
+
# ============================================================
|
|
43
|
+
|
|
44
|
+
_DEFAULT_SUPABASE_URL = "https://wwgzzmydrwrjgaebspdo.supabase.co"
|
|
45
|
+
_DEFAULT_SUPABASE_KEY = "sb_publishable_0qf0U1GURopPIxLR8Vu7eQ_5grflPP4"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _sb() -> Dict[str, str]:
|
|
49
|
+
url = os.environ.get("SUPABASE_URL", "") or _DEFAULT_SUPABASE_URL
|
|
50
|
+
key = os.environ.get("SUPABASE_KEY", "") or _DEFAULT_SUPABASE_KEY
|
|
51
|
+
return {"url": url, "key": key}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _rpc(name: str, body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
55
|
+
sb = _sb()
|
|
56
|
+
req = Request(
|
|
57
|
+
f"{sb['url']}/rest/v1/rpc/{name}",
|
|
58
|
+
data=json.dumps(body).encode(),
|
|
59
|
+
method="POST",
|
|
60
|
+
headers={
|
|
61
|
+
"apikey": sb["key"],
|
|
62
|
+
"Authorization": f"Bearer {sb['key']}",
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
with urlopen(req, timeout=15) as resp:
|
|
68
|
+
return json.loads(resp.read().decode())
|
|
69
|
+
except HTTPError as e:
|
|
70
|
+
try:
|
|
71
|
+
err_body = e.read().decode()
|
|
72
|
+
return json.loads(err_body)
|
|
73
|
+
except Exception:
|
|
74
|
+
print(f"[meshcode] HTTP {e.code} from {name}: {e.reason}", file=sys.stderr)
|
|
75
|
+
return None
|
|
76
|
+
except URLError as e:
|
|
77
|
+
print(f"[meshcode] network error calling {name}: {e.reason}", file=sys.stderr)
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _get_default_api_key() -> Optional[str]:
|
|
82
|
+
try:
|
|
83
|
+
import importlib
|
|
84
|
+
secrets_mod = importlib.import_module("meshcode.secrets")
|
|
85
|
+
return secrets_mod.get_api_key(profile="default")
|
|
86
|
+
except Exception:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ============================================================
|
|
91
|
+
# meshcode invite — owner creates an invite
|
|
92
|
+
# ============================================================
|
|
93
|
+
|
|
94
|
+
def cmd_invite(project: str, agent: str, role: str = "", days: int = 7) -> int:
|
|
95
|
+
api_key = _get_default_api_key()
|
|
96
|
+
if not api_key:
|
|
97
|
+
print("[meshcode] ERROR: not logged in. Run `meshcode login <api_key>` first.", file=sys.stderr)
|
|
98
|
+
return 2
|
|
99
|
+
|
|
100
|
+
result = _rpc("mc_create_invite", {
|
|
101
|
+
"p_api_key": api_key,
|
|
102
|
+
"p_meshwork_name": project,
|
|
103
|
+
"p_agent_name": agent,
|
|
104
|
+
"p_role_description": role,
|
|
105
|
+
"p_expires_in_days": int(days),
|
|
106
|
+
})
|
|
107
|
+
if not isinstance(result, dict):
|
|
108
|
+
print("[meshcode] ERROR: invite RPC returned no data", file=sys.stderr)
|
|
109
|
+
return 1
|
|
110
|
+
if result.get("error"):
|
|
111
|
+
print(f"[meshcode] ERROR: {result['error']}", file=sys.stderr)
|
|
112
|
+
return 1
|
|
113
|
+
|
|
114
|
+
print()
|
|
115
|
+
print(f"[meshcode] ✓ Invite created for agent '{agent}' in meshwork '{project}'")
|
|
116
|
+
print(f"[meshcode]")
|
|
117
|
+
print(f"[meshcode] Role: {result.get('role') or '(unset)'}")
|
|
118
|
+
print(f"[meshcode] Expires: {result.get('expires_at')}")
|
|
119
|
+
print(f"[meshcode]")
|
|
120
|
+
print(f"[meshcode] Share this URL with your teammate:")
|
|
121
|
+
print(f"[meshcode] {result.get('join_url')}")
|
|
122
|
+
print(f"[meshcode]")
|
|
123
|
+
print(f"[meshcode] Or share the CLI command:")
|
|
124
|
+
print(f"[meshcode] {result.get('cli_command')}")
|
|
125
|
+
print(f"[meshcode]")
|
|
126
|
+
print(f"[meshcode] The token is single-use. After they redeem it, they'll have a")
|
|
127
|
+
print(f"[meshcode] scoped api key that only works for the '{agent}' agent in this")
|
|
128
|
+
print(f"[meshcode] meshwork. You can revoke them anytime from the dashboard.")
|
|
129
|
+
print()
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ============================================================
|
|
134
|
+
# meshcode join — friend redeems an invite
|
|
135
|
+
# ============================================================
|
|
136
|
+
|
|
137
|
+
def cmd_join(token: str, display_name: Optional[str] = None) -> int:
|
|
138
|
+
if not token or not token.startswith("mc_inv_"):
|
|
139
|
+
print(f"[meshcode] ERROR: that doesn't look like an invite token (expected mc_inv_...)", file=sys.stderr)
|
|
140
|
+
return 2
|
|
141
|
+
|
|
142
|
+
# 1. Inspect first so the user sees what they're joining BEFORE we mint a key
|
|
143
|
+
inspect = _rpc("mc_inspect_invite", {"p_token": token})
|
|
144
|
+
if not isinstance(inspect, dict):
|
|
145
|
+
print("[meshcode] ERROR: could not reach meshcode.io", file=sys.stderr)
|
|
146
|
+
return 1
|
|
147
|
+
if inspect.get("error"):
|
|
148
|
+
print(f"[meshcode] ERROR: {inspect['error']}", file=sys.stderr)
|
|
149
|
+
return 1
|
|
150
|
+
|
|
151
|
+
print()
|
|
152
|
+
print(f"[meshcode] You're about to join:")
|
|
153
|
+
print(f"[meshcode] Meshwork: {inspect.get('meshwork')}")
|
|
154
|
+
print(f"[meshcode] Agent: {inspect.get('agent_name')}")
|
|
155
|
+
print(f"[meshcode] Role: {inspect.get('role') or '(unset)'}")
|
|
156
|
+
print(f"[meshcode] Invited by: {inspect.get('invited_by')}")
|
|
157
|
+
print(f"[meshcode] Expires: {inspect.get('expires_at')}")
|
|
158
|
+
print()
|
|
159
|
+
|
|
160
|
+
# 2. Redeem (anonymous — creates a guest user under the hood)
|
|
161
|
+
redeem = _rpc("mc_redeem_invite", {
|
|
162
|
+
"p_token": token,
|
|
163
|
+
"p_display_name": display_name or "",
|
|
164
|
+
})
|
|
165
|
+
if not isinstance(redeem, dict):
|
|
166
|
+
print("[meshcode] ERROR: could not redeem invite", file=sys.stderr)
|
|
167
|
+
return 1
|
|
168
|
+
if redeem.get("error"):
|
|
169
|
+
print(f"[meshcode] ERROR: {redeem['error']}", file=sys.stderr)
|
|
170
|
+
return 1
|
|
171
|
+
|
|
172
|
+
scoped_key = redeem.get("api_key")
|
|
173
|
+
project = redeem.get("meshwork")
|
|
174
|
+
agent = redeem.get("agent_name")
|
|
175
|
+
if not scoped_key or not project or not agent:
|
|
176
|
+
print("[meshcode] ERROR: redeem succeeded but response was incomplete", file=sys.stderr)
|
|
177
|
+
return 1
|
|
178
|
+
|
|
179
|
+
# 3. Store scoped key in OS keychain under a per-mesh profile
|
|
180
|
+
profile_name = f"mesh:{project}:{agent}"
|
|
181
|
+
try:
|
|
182
|
+
import importlib
|
|
183
|
+
secrets_mod = importlib.import_module("meshcode.secrets")
|
|
184
|
+
except Exception as e:
|
|
185
|
+
print(f"[meshcode] ERROR: cannot load secrets module: {e}", file=sys.stderr)
|
|
186
|
+
return 1
|
|
187
|
+
|
|
188
|
+
if not secrets_mod.set_api_key(scoped_key, profile=profile_name, meta={
|
|
189
|
+
"type": "scoped",
|
|
190
|
+
"meshwork": project,
|
|
191
|
+
"agent": agent,
|
|
192
|
+
"role": redeem.get("role") or "",
|
|
193
|
+
"expires_at": redeem.get("expires_at"),
|
|
194
|
+
"display_name": redeem.get("display_name"),
|
|
195
|
+
"guest_user_id": redeem.get("guest_user_id"),
|
|
196
|
+
}):
|
|
197
|
+
print(f"[meshcode] ERROR: could not store scoped api key in keychain", file=sys.stderr)
|
|
198
|
+
return 1
|
|
199
|
+
|
|
200
|
+
# 4. Create the workspace dir with this profile baked in
|
|
201
|
+
try:
|
|
202
|
+
sc = importlib.import_module("meshcode.setup_clients")
|
|
203
|
+
rc = sc.setup_workspace(
|
|
204
|
+
project, agent,
|
|
205
|
+
role=redeem.get("role") or "",
|
|
206
|
+
keychain_profile=profile_name,
|
|
207
|
+
)
|
|
208
|
+
if rc != 0:
|
|
209
|
+
return rc
|
|
210
|
+
except Exception as e:
|
|
211
|
+
print(f"[meshcode] WARNING: workspace creation failed ({e}); manual setup needed", file=sys.stderr)
|
|
212
|
+
|
|
213
|
+
print()
|
|
214
|
+
print(f"[meshcode] 🎉 You've joined '{project}' as '{agent}'")
|
|
215
|
+
print(f"[meshcode]")
|
|
216
|
+
print(f"[meshcode] To launch your agent now:")
|
|
217
|
+
print(f"[meshcode] meshcode run {agent}")
|
|
218
|
+
print(f"[meshcode]")
|
|
219
|
+
print(f"[meshcode] Your scoped api key is in the OS keychain under profile:")
|
|
220
|
+
print(f"[meshcode] {profile_name}")
|
|
221
|
+
print(f"[meshcode]")
|
|
222
|
+
print(f"[meshcode] This key only works for the '{agent}' agent in '{project}'.")
|
|
223
|
+
print(f"[meshcode] It cannot create new meshworks, see other agents' messages,")
|
|
224
|
+
print(f"[meshcode] or affect the inviter's billing.")
|
|
225
|
+
print()
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ============================================================
|
|
230
|
+
# meshcode invites <project> — list outstanding invites
|
|
231
|
+
# ============================================================
|
|
232
|
+
|
|
233
|
+
def cmd_list_invites(project: str) -> int:
|
|
234
|
+
api_key = _get_default_api_key()
|
|
235
|
+
if not api_key:
|
|
236
|
+
print("[meshcode] ERROR: not logged in", file=sys.stderr)
|
|
237
|
+
return 2
|
|
238
|
+
|
|
239
|
+
# Resolve project_id by name first
|
|
240
|
+
pid = _rpc("mc_resolve_project", {
|
|
241
|
+
"p_api_key": api_key, "p_project_name": project,
|
|
242
|
+
})
|
|
243
|
+
if not isinstance(pid, dict) or not pid.get("project_id"):
|
|
244
|
+
print(f"[meshcode] ERROR: meshwork '{project}' not found or not yours", file=sys.stderr)
|
|
245
|
+
return 1
|
|
246
|
+
|
|
247
|
+
result = _rpc("mc_list_invites", {
|
|
248
|
+
"p_api_key": api_key,
|
|
249
|
+
"p_meshwork_id": pid["project_id"],
|
|
250
|
+
})
|
|
251
|
+
if not isinstance(result, dict) or result.get("error"):
|
|
252
|
+
print(f"[meshcode] ERROR: {(result or {}).get('error', 'rpc failed')}", file=sys.stderr)
|
|
253
|
+
return 1
|
|
254
|
+
|
|
255
|
+
invites = result.get("invites") or []
|
|
256
|
+
if not invites:
|
|
257
|
+
print(f"[meshcode] No invites for '{project}' yet.")
|
|
258
|
+
print(f"[meshcode] Create one with: meshcode invite {project} <agent>")
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
print()
|
|
262
|
+
print(f"[meshcode] Invites for '{project}':")
|
|
263
|
+
print()
|
|
264
|
+
for inv in invites:
|
|
265
|
+
status = "REDEEMED" if inv.get("redeemed") else ("REVOKED" if inv.get("revoked") else "OPEN")
|
|
266
|
+
print(f" [{status}] {inv.get('token_prefix')}… agent={inv.get('agent_name')} expires={inv.get('expires_at')}")
|
|
267
|
+
if inv.get("role"):
|
|
268
|
+
print(f" role: {inv['role']}")
|
|
269
|
+
print()
|
|
270
|
+
return 0
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ============================================================
|
|
274
|
+
# meshcode members <project> — list members
|
|
275
|
+
# ============================================================
|
|
276
|
+
|
|
277
|
+
def cmd_list_members(project: str) -> int:
|
|
278
|
+
api_key = _get_default_api_key()
|
|
279
|
+
if not api_key:
|
|
280
|
+
print("[meshcode] ERROR: not logged in", file=sys.stderr)
|
|
281
|
+
return 2
|
|
282
|
+
|
|
283
|
+
pid = _rpc("mc_resolve_project", {
|
|
284
|
+
"p_api_key": api_key, "p_project_name": project,
|
|
285
|
+
})
|
|
286
|
+
if not isinstance(pid, dict) or not pid.get("project_id"):
|
|
287
|
+
print(f"[meshcode] ERROR: meshwork '{project}' not found or not yours", file=sys.stderr)
|
|
288
|
+
return 1
|
|
289
|
+
|
|
290
|
+
result = _rpc("mc_list_members", {
|
|
291
|
+
"p_api_key": api_key,
|
|
292
|
+
"p_meshwork_id": pid["project_id"],
|
|
293
|
+
})
|
|
294
|
+
if not isinstance(result, dict) or result.get("error"):
|
|
295
|
+
print(f"[meshcode] ERROR: {(result or {}).get('error', 'rpc failed')}", file=sys.stderr)
|
|
296
|
+
return 1
|
|
297
|
+
|
|
298
|
+
members = result.get("members") or []
|
|
299
|
+
if not members:
|
|
300
|
+
print(f"[meshcode] No members in '{project}' yet (only you).")
|
|
301
|
+
return 0
|
|
302
|
+
|
|
303
|
+
print()
|
|
304
|
+
print(f"[meshcode] Members of '{project}':")
|
|
305
|
+
print()
|
|
306
|
+
for m in members:
|
|
307
|
+
guest_tag = " (guest)" if m.get("is_guest") else ""
|
|
308
|
+
agent_tag = f" [{m.get('agent_name')}]" if m.get("agent_name") else ""
|
|
309
|
+
print(f" • {m.get('display_name')}{guest_tag} — {m.get('role')}{agent_tag}")
|
|
310
|
+
print(f" user_id: {m.get('user_id')}")
|
|
311
|
+
print(f" joined: {m.get('joined_at')}")
|
|
312
|
+
print()
|
|
313
|
+
return 0
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ============================================================
|
|
317
|
+
# meshcode revoke-invite <invite-id>
|
|
318
|
+
# ============================================================
|
|
319
|
+
|
|
320
|
+
def cmd_revoke_invite(invite_id: str) -> int:
|
|
321
|
+
api_key = _get_default_api_key()
|
|
322
|
+
if not api_key:
|
|
323
|
+
print("[meshcode] ERROR: not logged in", file=sys.stderr)
|
|
324
|
+
return 2
|
|
325
|
+
|
|
326
|
+
result = _rpc("mc_revoke_invite", {
|
|
327
|
+
"p_api_key": api_key,
|
|
328
|
+
"p_invite_id": invite_id,
|
|
329
|
+
})
|
|
330
|
+
if not isinstance(result, dict) or result.get("error"):
|
|
331
|
+
print(f"[meshcode] ERROR: {(result or {}).get('error', 'rpc failed')}", file=sys.stderr)
|
|
332
|
+
return 1
|
|
333
|
+
print(f"[meshcode] ✓ Invite {invite_id} revoked")
|
|
334
|
+
return 0
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ============================================================
|
|
338
|
+
# meshcode revoke-member <project> <member-user-id>
|
|
339
|
+
# ============================================================
|
|
340
|
+
|
|
341
|
+
def cmd_revoke_member(project: str, member_user_id: str) -> int:
|
|
342
|
+
api_key = _get_default_api_key()
|
|
343
|
+
if not api_key:
|
|
344
|
+
print("[meshcode] ERROR: not logged in", file=sys.stderr)
|
|
345
|
+
return 2
|
|
346
|
+
|
|
347
|
+
pid = _rpc("mc_resolve_project", {
|
|
348
|
+
"p_api_key": api_key, "p_project_name": project,
|
|
349
|
+
})
|
|
350
|
+
if not isinstance(pid, dict) or not pid.get("project_id"):
|
|
351
|
+
print(f"[meshcode] ERROR: meshwork '{project}' not found or not yours", file=sys.stderr)
|
|
352
|
+
return 1
|
|
353
|
+
|
|
354
|
+
result = _rpc("mc_revoke_member", {
|
|
355
|
+
"p_api_key": api_key,
|
|
356
|
+
"p_meshwork_id": pid["project_id"],
|
|
357
|
+
"p_member_user_id": member_user_id,
|
|
358
|
+
})
|
|
359
|
+
if not isinstance(result, dict) or result.get("error"):
|
|
360
|
+
print(f"[meshcode] ERROR: {(result or {}).get('error', 'rpc failed')}", file=sys.stderr)
|
|
361
|
+
return 1
|
|
362
|
+
print(f"[meshcode] ✓ Member {member_user_id} removed from '{project}'")
|
|
363
|
+
return 0
|
|
@@ -115,13 +115,51 @@ if not PROJECT_NAME or not AGENT_NAME:
|
|
|
115
115
|
sys.exit(2)
|
|
116
116
|
|
|
117
117
|
|
|
118
|
+
# ============================================================
|
|
119
|
+
# API key resolution — keychain first, env var fallback
|
|
120
|
+
# ============================================================
|
|
121
|
+
#
|
|
122
|
+
# 1.4.1+ stores api keys in the OS keychain. The setup_clients writer
|
|
123
|
+
# bakes only MESHCODE_KEYCHAIN_PROFILE into the .mcp.json env block, NOT
|
|
124
|
+
# the api key itself. The MCP server resolves the key at boot via the
|
|
125
|
+
# secrets module. The env-var path is preserved as a fallback for users
|
|
126
|
+
# whose OS doesn't have a keychain backend.
|
|
127
|
+
|
|
128
|
+
_API_KEY_CACHE: Optional[str] = None
|
|
129
|
+
|
|
130
|
+
def _get_api_key() -> str:
|
|
131
|
+
"""Resolve the api_key for this MCP server process. Cached after first call."""
|
|
132
|
+
global _API_KEY_CACHE
|
|
133
|
+
if _API_KEY_CACHE is not None:
|
|
134
|
+
return _API_KEY_CACHE
|
|
135
|
+
# 1. Direct env var (legacy / fallback path)
|
|
136
|
+
val = os.environ.get("MESHCODE_API_KEY", "").strip()
|
|
137
|
+
if val:
|
|
138
|
+
_API_KEY_CACHE = val
|
|
139
|
+
return val
|
|
140
|
+
# 2. Keychain via profile name in env
|
|
141
|
+
profile = os.environ.get("MESHCODE_KEYCHAIN_PROFILE", "").strip()
|
|
142
|
+
if profile:
|
|
143
|
+
try:
|
|
144
|
+
import importlib
|
|
145
|
+
secrets_mod = importlib.import_module("meshcode.secrets")
|
|
146
|
+
kc_val = secrets_mod.get_api_key(profile=profile)
|
|
147
|
+
if kc_val:
|
|
148
|
+
_API_KEY_CACHE = kc_val
|
|
149
|
+
return kc_val
|
|
150
|
+
except Exception as e:
|
|
151
|
+
print(f"[meshcode-mcp] WARNING: keychain lookup failed for profile '{profile}': {e}", file=sys.stderr)
|
|
152
|
+
_API_KEY_CACHE = ""
|
|
153
|
+
return ""
|
|
154
|
+
|
|
155
|
+
|
|
118
156
|
# Resolve project_id at startup. Try in order:
|
|
119
157
|
# 1. MESHCODE_PROJECT_ID env var (baked by `meshcode setup`, fastest)
|
|
120
158
|
# 2. mc_resolve_project RPC with the user's api_key (security definer, bypasses RLS)
|
|
121
159
|
# 3. Direct SELECT via get_project_id (only works if RLS is open / user is admin)
|
|
122
160
|
_PROJECT_ID: Optional[str] = os.environ.get("MESHCODE_PROJECT_ID") or None
|
|
123
161
|
if not _PROJECT_ID:
|
|
124
|
-
_api_key =
|
|
162
|
+
_api_key = _get_api_key()
|
|
125
163
|
if _api_key:
|
|
126
164
|
try:
|
|
127
165
|
_r = be.sb_rpc("mc_resolve_project", {
|
|
@@ -137,7 +175,7 @@ if not _PROJECT_ID:
|
|
|
137
175
|
if not _PROJECT_ID:
|
|
138
176
|
_PROJECT_ID = be.get_project_id(PROJECT_NAME)
|
|
139
177
|
if not _PROJECT_ID:
|
|
140
|
-
print(f"[meshcode-mcp] ERROR: project '{PROJECT_NAME}' not found (check
|
|
178
|
+
print(f"[meshcode-mcp] ERROR: project '{PROJECT_NAME}' not found (check MESHCODE_KEYCHAIN_PROFILE / MESHCODE_API_KEY)", file=sys.stderr)
|
|
141
179
|
sys.exit(2)
|
|
142
180
|
|
|
143
181
|
_register_result = be.register_agent(PROJECT_NAME, AGENT_NAME, AGENT_ROLE or "MCP-connected agent")
|
|
@@ -149,7 +187,7 @@ if isinstance(_register_result, dict) and _register_result.get("error"):
|
|
|
149
187
|
# bypass RLS — the publishable key has no JWT context and cannot UPDATE
|
|
150
188
|
# mc_agents directly. The RPC validates ownership via api_key.
|
|
151
189
|
def _flip_status(status: str, task: str = "") -> bool:
|
|
152
|
-
api_key =
|
|
190
|
+
api_key = _get_api_key()
|
|
153
191
|
if not api_key:
|
|
154
192
|
# Last-resort fallback: try the direct PATCH (may be denied by RLS)
|
|
155
193
|
try:
|
|
@@ -186,7 +224,7 @@ import uuid as _uuid
|
|
|
186
224
|
_INSTANCE_ID = f"mcp-{_uuid.uuid4().hex[:12]}"
|
|
187
225
|
|
|
188
226
|
def _acquire_lease() -> bool:
|
|
189
|
-
api_key =
|
|
227
|
+
api_key = _get_api_key()
|
|
190
228
|
if not api_key:
|
|
191
229
|
return True # legacy clients without api_key skip lease check
|
|
192
230
|
try:
|
|
@@ -212,7 +250,7 @@ if not _acquire_lease():
|
|
|
212
250
|
|
|
213
251
|
|
|
214
252
|
def _release_lease() -> None:
|
|
215
|
-
api_key =
|
|
253
|
+
api_key = _get_api_key()
|
|
216
254
|
if not api_key:
|
|
217
255
|
return
|
|
218
256
|
try:
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""OS keychain wrapper for MeshCode credentials.
|
|
2
|
+
|
|
3
|
+
The user's MeshCode api key (mc_xxx) is sensitive and used to be stored
|
|
4
|
+
in plain text at ~/.meshcode/credentials.json AND baked into every
|
|
5
|
+
~/meshcode/<workspace>/.mcp.json AND passed through env vars to the
|
|
6
|
+
MCP server subprocess (visible in `ps eauxww`). That was the equivalent
|
|
7
|
+
of leaving your password in ~/Desktop/pass.txt + photocopying it on
|
|
8
|
+
every wall.
|
|
9
|
+
|
|
10
|
+
1.4.1+ stores api keys in the OS keychain via the `keyring` library:
|
|
11
|
+
|
|
12
|
+
macOS → /usr/bin/security (Keychain Access)
|
|
13
|
+
Linux → libsecret / kwallet (SecretService)
|
|
14
|
+
Windows → Credential Manager (wincred)
|
|
15
|
+
Fallback → encrypted file via keyrings.cryptfile if no native backend
|
|
16
|
+
|
|
17
|
+
The MCP server config files (.mcp.json) no longer contain the api key —
|
|
18
|
+
they only contain a `MESHCODE_KEYCHAIN_PROFILE` env var that names the
|
|
19
|
+
keychain entry to look up at boot time. The api key never appears in
|
|
20
|
+
the filesystem, never in process env, never in `ps e`.
|
|
21
|
+
|
|
22
|
+
Profile naming convention:
|
|
23
|
+
"default" — the user's primary api key (used for owned meshworks)
|
|
24
|
+
"mesh:<project>:<agent>" — a per-agent scoped key (used for friend invites)
|
|
25
|
+
|
|
26
|
+
The "default" profile is the one set by `meshcode login`. The
|
|
27
|
+
per-agent profiles are set by `meshcode join <invite-token>` (Phase 7B).
|
|
28
|
+
|
|
29
|
+
Backwards compatibility:
|
|
30
|
+
- migrate_legacy_credentials() reads ~/.meshcode/credentials.json on
|
|
31
|
+
first 1.4.1 startup and moves it to keychain, then shreds the file.
|
|
32
|
+
- get_api_key() falls back to credentials.json if keychain is unavailable
|
|
33
|
+
AND the file still exists. This keeps existing installs from breaking
|
|
34
|
+
if their OS doesn't have a usable keyring backend.
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import secrets as _stdlib_secrets
|
|
41
|
+
import sys
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Optional, Dict, Any
|
|
44
|
+
|
|
45
|
+
SERVICE_NAME = "meshcode"
|
|
46
|
+
DEFAULT_PROFILE = "default"
|
|
47
|
+
LEGACY_CREDS_FILE = Path.home() / ".meshcode" / "credentials.json"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ============================================================
|
|
51
|
+
# Keyring backend probe
|
|
52
|
+
# ============================================================
|
|
53
|
+
|
|
54
|
+
_KEYRING_AVAILABLE: Optional[bool] = None
|
|
55
|
+
_KEYRING_BACKEND_NAME: Optional[str] = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _probe_keyring() -> bool:
|
|
59
|
+
"""Check whether the keyring library has a usable backend on this OS.
|
|
60
|
+
|
|
61
|
+
Returns True if we can read+write keychain entries. Caches the result.
|
|
62
|
+
"""
|
|
63
|
+
global _KEYRING_AVAILABLE, _KEYRING_BACKEND_NAME
|
|
64
|
+
if _KEYRING_AVAILABLE is not None:
|
|
65
|
+
return _KEYRING_AVAILABLE
|
|
66
|
+
try:
|
|
67
|
+
import keyring # type: ignore
|
|
68
|
+
from keyring.errors import KeyringError # type: ignore
|
|
69
|
+
backend = keyring.get_keyring()
|
|
70
|
+
_KEYRING_BACKEND_NAME = type(backend).__name__
|
|
71
|
+
# Some installs report a "fail" backend on headless Linux. Probe with a
|
|
72
|
+
# write+read+delete round trip on a sentinel key.
|
|
73
|
+
sentinel_key = "meshcode_probe_sentinel"
|
|
74
|
+
sentinel_val = "ok"
|
|
75
|
+
try:
|
|
76
|
+
keyring.set_password(SERVICE_NAME, sentinel_key, sentinel_val)
|
|
77
|
+
got = keyring.get_password(SERVICE_NAME, sentinel_key)
|
|
78
|
+
keyring.delete_password(SERVICE_NAME, sentinel_key)
|
|
79
|
+
_KEYRING_AVAILABLE = (got == sentinel_val)
|
|
80
|
+
except KeyringError:
|
|
81
|
+
_KEYRING_AVAILABLE = False
|
|
82
|
+
except Exception:
|
|
83
|
+
_KEYRING_AVAILABLE = False
|
|
84
|
+
except ImportError:
|
|
85
|
+
_KEYRING_AVAILABLE = False
|
|
86
|
+
_KEYRING_BACKEND_NAME = "(keyring not installed)"
|
|
87
|
+
return _KEYRING_AVAILABLE
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def keyring_status() -> Dict[str, Any]:
|
|
91
|
+
"""Return a small status dict for diagnostics."""
|
|
92
|
+
available = _probe_keyring()
|
|
93
|
+
return {
|
|
94
|
+
"available": available,
|
|
95
|
+
"backend": _KEYRING_BACKEND_NAME or "(unknown)",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ============================================================
|
|
100
|
+
# Profile index — what profiles exist in keychain
|
|
101
|
+
# ============================================================
|
|
102
|
+
#
|
|
103
|
+
# OS keychains let us SET and GET by (service, username) but listing all
|
|
104
|
+
# usernames under a service is backend-specific (and often unavailable).
|
|
105
|
+
# We keep a small JSON index at ~/.meshcode/profiles.json that lists which
|
|
106
|
+
# profile names exist. This is metadata only — the index never contains
|
|
107
|
+
# any secret values, just names. Mode 600.
|
|
108
|
+
|
|
109
|
+
PROFILE_INDEX = Path.home() / ".meshcode" / "profiles.json"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _load_index() -> Dict[str, Any]:
|
|
113
|
+
if not PROFILE_INDEX.exists():
|
|
114
|
+
return {"profiles": {}}
|
|
115
|
+
try:
|
|
116
|
+
return json.loads(PROFILE_INDEX.read_text())
|
|
117
|
+
except Exception:
|
|
118
|
+
return {"profiles": {}}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _save_index(idx: Dict[str, Any]) -> None:
|
|
122
|
+
PROFILE_INDEX.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
tmp = PROFILE_INDEX.with_suffix(".tmp")
|
|
124
|
+
tmp.write_text(json.dumps(idx, indent=2))
|
|
125
|
+
try:
|
|
126
|
+
os.chmod(tmp, 0o600)
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
tmp.replace(PROFILE_INDEX)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _index_set(profile: str, meta: Dict[str, Any]) -> None:
|
|
133
|
+
idx = _load_index()
|
|
134
|
+
idx.setdefault("profiles", {})[profile] = meta
|
|
135
|
+
_save_index(idx)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _index_remove(profile: str) -> None:
|
|
139
|
+
idx = _load_index()
|
|
140
|
+
if profile in idx.get("profiles", {}):
|
|
141
|
+
del idx["profiles"][profile]
|
|
142
|
+
_save_index(idx)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def list_profiles() -> Dict[str, Dict[str, Any]]:
|
|
146
|
+
"""Return all known keychain profiles with their metadata."""
|
|
147
|
+
return _load_index().get("profiles", {})
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ============================================================
|
|
151
|
+
# Get / set / delete api key
|
|
152
|
+
# ============================================================
|
|
153
|
+
|
|
154
|
+
def set_api_key(api_key: str, profile: str = DEFAULT_PROFILE,
|
|
155
|
+
meta: Optional[Dict[str, Any]] = None) -> bool:
|
|
156
|
+
"""Store api_key under (SERVICE_NAME, profile) in the OS keychain.
|
|
157
|
+
|
|
158
|
+
Returns True on success. On failure, falls back to writing a mode-600
|
|
159
|
+
file at ~/.meshcode/credentials.<profile>.json (only as a last resort
|
|
160
|
+
when no keychain is available — prints a warning).
|
|
161
|
+
"""
|
|
162
|
+
if not api_key or not isinstance(api_key, str):
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
if _probe_keyring():
|
|
166
|
+
try:
|
|
167
|
+
import keyring # type: ignore
|
|
168
|
+
keyring.set_password(SERVICE_NAME, profile, api_key)
|
|
169
|
+
_index_set(profile, {"backend": _KEYRING_BACKEND_NAME, **(meta or {})})
|
|
170
|
+
return True
|
|
171
|
+
except Exception as e:
|
|
172
|
+
print(f"[meshcode] WARNING: keychain write failed ({e}); falling back to file", file=sys.stderr)
|
|
173
|
+
|
|
174
|
+
# Fallback: mode-600 JSON file (legacy path, secured)
|
|
175
|
+
fallback_path = Path.home() / ".meshcode" / f"credentials.{profile}.json"
|
|
176
|
+
fallback_path.parent.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
fallback_path.write_text(json.dumps({"api_key": api_key, **(meta or {})}, indent=2))
|
|
178
|
+
try:
|
|
179
|
+
os.chmod(fallback_path, 0o600)
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
_index_set(profile, {"backend": "file-fallback", **(meta or {})})
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def get_api_key(profile: str = DEFAULT_PROFILE) -> Optional[str]:
|
|
187
|
+
"""Read api_key from the OS keychain (or fallback file).
|
|
188
|
+
|
|
189
|
+
Resolution order:
|
|
190
|
+
1. Keychain entry (SERVICE_NAME, profile)
|
|
191
|
+
2. ~/.meshcode/credentials.<profile>.json (fallback file from set_api_key)
|
|
192
|
+
3. ~/.meshcode/credentials.json (legacy pre-1.4.1 file, default profile only)
|
|
193
|
+
"""
|
|
194
|
+
if _probe_keyring():
|
|
195
|
+
try:
|
|
196
|
+
import keyring # type: ignore
|
|
197
|
+
val = keyring.get_password(SERVICE_NAME, profile)
|
|
198
|
+
if val:
|
|
199
|
+
return val
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
# Fallback file for this profile
|
|
204
|
+
fallback_path = Path.home() / ".meshcode" / f"credentials.{profile}.json"
|
|
205
|
+
if fallback_path.exists():
|
|
206
|
+
try:
|
|
207
|
+
data = json.loads(fallback_path.read_text())
|
|
208
|
+
if isinstance(data, dict) and data.get("api_key"):
|
|
209
|
+
return data["api_key"]
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
# Legacy pre-1.4.1 path: only valid for the default profile
|
|
214
|
+
if profile == DEFAULT_PROFILE and LEGACY_CREDS_FILE.exists():
|
|
215
|
+
try:
|
|
216
|
+
data = json.loads(LEGACY_CREDS_FILE.read_text())
|
|
217
|
+
if isinstance(data, dict) and data.get("api_key"):
|
|
218
|
+
return data["api_key"]
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def delete_api_key(profile: str = DEFAULT_PROFILE) -> bool:
|
|
226
|
+
"""Remove the keychain entry + any fallback file for this profile."""
|
|
227
|
+
ok = False
|
|
228
|
+
if _probe_keyring():
|
|
229
|
+
try:
|
|
230
|
+
import keyring # type: ignore
|
|
231
|
+
from keyring.errors import PasswordDeleteError # type: ignore
|
|
232
|
+
try:
|
|
233
|
+
keyring.delete_password(SERVICE_NAME, profile)
|
|
234
|
+
ok = True
|
|
235
|
+
except PasswordDeleteError:
|
|
236
|
+
pass
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
fallback_path = Path.home() / ".meshcode" / f"credentials.{profile}.json"
|
|
241
|
+
if fallback_path.exists():
|
|
242
|
+
try:
|
|
243
|
+
_shred_file(fallback_path)
|
|
244
|
+
ok = True
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
_index_remove(profile)
|
|
249
|
+
return ok
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ============================================================
|
|
253
|
+
# Shred (overwrite + unlink) — for migration of plain text files
|
|
254
|
+
# ============================================================
|
|
255
|
+
|
|
256
|
+
def _shred_file(path: Path, passes: int = 3) -> None:
|
|
257
|
+
"""Overwrite the file with random bytes then unlink it.
|
|
258
|
+
|
|
259
|
+
Not a guarantee against forensic recovery on SSDs (TRIM, wear leveling),
|
|
260
|
+
but raises the bar significantly compared to a plain unlink.
|
|
261
|
+
"""
|
|
262
|
+
if not path.exists():
|
|
263
|
+
return
|
|
264
|
+
try:
|
|
265
|
+
size = path.stat().st_size
|
|
266
|
+
if size > 0:
|
|
267
|
+
with open(path, "r+b") as f:
|
|
268
|
+
for _ in range(passes):
|
|
269
|
+
f.seek(0)
|
|
270
|
+
f.write(_stdlib_secrets.token_bytes(size))
|
|
271
|
+
f.flush()
|
|
272
|
+
try:
|
|
273
|
+
os.fsync(f.fileno())
|
|
274
|
+
except Exception:
|
|
275
|
+
pass
|
|
276
|
+
finally:
|
|
277
|
+
try:
|
|
278
|
+
path.unlink()
|
|
279
|
+
except Exception:
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ============================================================
|
|
284
|
+
# Migration: pre-1.4.1 ~/.meshcode/credentials.json → keychain
|
|
285
|
+
# ============================================================
|
|
286
|
+
|
|
287
|
+
MIGRATION_FLAG = Path.home() / ".meshcode" / ".migrated_to_keychain"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def migrate_legacy_credentials() -> bool:
|
|
291
|
+
"""One-shot migration: move plain-text credentials.json to the OS keychain
|
|
292
|
+
and shred the original file. Idempotent — safe to call on every CLI start.
|
|
293
|
+
|
|
294
|
+
Returns True if a migration ran this call, False if nothing to do.
|
|
295
|
+
"""
|
|
296
|
+
if MIGRATION_FLAG.exists():
|
|
297
|
+
return False
|
|
298
|
+
if not LEGACY_CREDS_FILE.exists():
|
|
299
|
+
# Nothing to migrate, but still drop the flag so we don't re-check forever
|
|
300
|
+
try:
|
|
301
|
+
MIGRATION_FLAG.parent.mkdir(parents=True, exist_ok=True)
|
|
302
|
+
MIGRATION_FLAG.write_text("no legacy file")
|
|
303
|
+
except Exception:
|
|
304
|
+
pass
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
data = json.loads(LEGACY_CREDS_FILE.read_text())
|
|
309
|
+
except Exception:
|
|
310
|
+
return False
|
|
311
|
+
api_key = (data or {}).get("api_key") if isinstance(data, dict) else None
|
|
312
|
+
if not api_key:
|
|
313
|
+
return False
|
|
314
|
+
|
|
315
|
+
meta = {k: v for k, v in data.items() if k != "api_key"} if isinstance(data, dict) else {}
|
|
316
|
+
ok = set_api_key(api_key, profile=DEFAULT_PROFILE, meta=meta)
|
|
317
|
+
if not ok:
|
|
318
|
+
# Don't shred the legacy file if the migration failed — user would
|
|
319
|
+
# be locked out. Leave it in place and try again next run.
|
|
320
|
+
return False
|
|
321
|
+
|
|
322
|
+
# Migration succeeded — shred the plain-text file
|
|
323
|
+
_shred_file(LEGACY_CREDS_FILE)
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
MIGRATION_FLAG.parent.mkdir(parents=True, exist_ok=True)
|
|
327
|
+
MIGRATION_FLAG.write_text(json.dumps({
|
|
328
|
+
"migrated_at_unix": int(__import__("time").time()),
|
|
329
|
+
"backend": _KEYRING_BACKEND_NAME or "(unknown)",
|
|
330
|
+
}))
|
|
331
|
+
except Exception:
|
|
332
|
+
pass
|
|
333
|
+
|
|
334
|
+
print("[meshcode] ✓ Migrated your api key from plain text to OS keychain.", file=sys.stderr)
|
|
335
|
+
print(f"[meshcode] Backend: {_KEYRING_BACKEND_NAME or '(file fallback)'}", file=sys.stderr)
|
|
336
|
+
print("[meshcode] The old ~/.meshcode/credentials.json has been shredded.", file=sys.stderr)
|
|
337
|
+
return True
|
|
@@ -23,12 +23,50 @@ from pathlib import Path
|
|
|
23
23
|
from typing import Dict, Any, Optional
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def _load_credentials() -> Dict[str, str]:
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
def _load_credentials(profile: str = "default") -> Dict[str, str]:
|
|
27
|
+
"""Load the api key + non-secret metadata for the given keychain profile.
|
|
28
|
+
|
|
29
|
+
1.4.1+ stores api keys in the OS keychain. The api key is read from there
|
|
30
|
+
via the secrets module. Non-secret metadata (user_id / email / display_name)
|
|
31
|
+
lives in ~/.meshcode/profile_meta.json. Falls back to the legacy
|
|
32
|
+
credentials.json file if the migration hasn't run yet (e.g. first run after
|
|
33
|
+
upgrade).
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
import importlib
|
|
37
|
+
secrets_mod = importlib.import_module("meshcode.secrets")
|
|
38
|
+
except Exception as e:
|
|
39
|
+
print(f"[meshcode] ERROR: cannot load secrets module: {e}", file=sys.stderr)
|
|
40
|
+
sys.exit(2)
|
|
41
|
+
|
|
42
|
+
api_key = secrets_mod.get_api_key(profile=profile)
|
|
43
|
+
if not api_key:
|
|
29
44
|
print("[meshcode] ERROR: No credentials found. Run `meshcode login <api_key>` first.", file=sys.stderr)
|
|
30
45
|
sys.exit(2)
|
|
31
|
-
|
|
46
|
+
|
|
47
|
+
meta_path = Path.home() / ".meshcode" / "profile_meta.json"
|
|
48
|
+
meta: Dict[str, Any] = {"api_key": api_key}
|
|
49
|
+
if meta_path.exists():
|
|
50
|
+
try:
|
|
51
|
+
extra = json.loads(meta_path.read_text())
|
|
52
|
+
if isinstance(extra, dict):
|
|
53
|
+
meta.update({k: v for k, v in extra.items() if v is not None})
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
# Legacy fallback (pre-1.4.1): if profile_meta.json doesn't exist, try the
|
|
57
|
+
# old credentials.json for non-secret fields. The api key itself is already
|
|
58
|
+
# in keychain by now (via migrate_legacy_credentials).
|
|
59
|
+
legacy_path = Path.home() / ".meshcode" / "credentials.json"
|
|
60
|
+
if legacy_path.exists() and not meta_path.exists():
|
|
61
|
+
try:
|
|
62
|
+
legacy = json.loads(legacy_path.read_text())
|
|
63
|
+
if isinstance(legacy, dict):
|
|
64
|
+
for k in ("user_id", "email", "display_name"):
|
|
65
|
+
if legacy.get(k) and not meta.get(k):
|
|
66
|
+
meta[k] = legacy[k]
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
return meta
|
|
32
70
|
|
|
33
71
|
|
|
34
72
|
def _load_supabase_env() -> Dict[str, str]:
|
|
@@ -84,19 +122,41 @@ def _resolve_project_id(api_key: str, project: str, sb: Dict[str, str]) -> str:
|
|
|
84
122
|
|
|
85
123
|
|
|
86
124
|
def _build_server_block(project: str, project_id: str, agent: str, role: str,
|
|
87
|
-
api_key: str, sb: Dict[str, str]
|
|
125
|
+
api_key: str, sb: Dict[str, str],
|
|
126
|
+
keychain_profile: str = "default") -> Dict[str, Any]:
|
|
127
|
+
"""Build the MCP server config block for an agent.
|
|
128
|
+
|
|
129
|
+
1.4.1+ does NOT bake the api key into the env. Instead it bakes a
|
|
130
|
+
`MESHCODE_KEYCHAIN_PROFILE` env var that names the keychain entry the
|
|
131
|
+
MCP server should look up at boot. The api key never appears in any
|
|
132
|
+
file on disk and never in process env / `ps eauxww`.
|
|
133
|
+
|
|
134
|
+
For backwards compatibility, if the keychain isn't available on this
|
|
135
|
+
OS, the api key falls back to the env var (legacy path).
|
|
136
|
+
"""
|
|
137
|
+
env: Dict[str, str] = {
|
|
138
|
+
"MESHCODE_PROJECT": project,
|
|
139
|
+
"MESHCODE_PROJECT_ID": project_id,
|
|
140
|
+
"MESHCODE_AGENT": agent,
|
|
141
|
+
"MESHCODE_ROLE": role or "MCP-connected agent",
|
|
142
|
+
"MESHCODE_KEYCHAIN_PROFILE": keychain_profile,
|
|
143
|
+
"SUPABASE_URL": sb["SUPABASE_URL"],
|
|
144
|
+
"SUPABASE_KEY": sb["SUPABASE_KEY"],
|
|
145
|
+
}
|
|
146
|
+
# Belt-and-suspenders fallback: if the OS doesn't have a keychain backend
|
|
147
|
+
# the MCP server can't read the key from there, so we still need to pass
|
|
148
|
+
# it via env. Probe and only include it as a fallback path.
|
|
149
|
+
try:
|
|
150
|
+
import importlib
|
|
151
|
+
secrets_mod = importlib.import_module("meshcode.secrets")
|
|
152
|
+
if not secrets_mod.keyring_status().get("available", False):
|
|
153
|
+
env["MESHCODE_API_KEY"] = api_key
|
|
154
|
+
except Exception:
|
|
155
|
+
env["MESHCODE_API_KEY"] = api_key
|
|
88
156
|
return {
|
|
89
157
|
"command": sys.executable or "python3",
|
|
90
158
|
"args": ["-m", "meshcode.meshcode_mcp", "serve"],
|
|
91
|
-
"env":
|
|
92
|
-
"MESHCODE_PROJECT": project,
|
|
93
|
-
"MESHCODE_PROJECT_ID": project_id,
|
|
94
|
-
"MESHCODE_AGENT": agent,
|
|
95
|
-
"MESHCODE_ROLE": role or "MCP-connected agent",
|
|
96
|
-
"MESHCODE_API_KEY": api_key,
|
|
97
|
-
"SUPABASE_URL": sb["SUPABASE_URL"],
|
|
98
|
-
"SUPABASE_KEY": sb["SUPABASE_KEY"],
|
|
99
|
-
},
|
|
159
|
+
"env": env,
|
|
100
160
|
}
|
|
101
161
|
|
|
102
162
|
|
|
@@ -118,19 +178,43 @@ def _workspace_dir(project: str, agent: str) -> Path:
|
|
|
118
178
|
return WORKSPACES_ROOT / f"{project}-{agent}"
|
|
119
179
|
|
|
120
180
|
|
|
121
|
-
def setup_workspace(project: str, agent: str, role: str = ""
|
|
181
|
+
def setup_workspace(project: str, agent: str, role: str = "",
|
|
182
|
+
keychain_profile: str = "default") -> int:
|
|
122
183
|
"""Create an isolated workspace dir at ~/meshcode/<project>-<agent>/ with
|
|
123
184
|
.mcp.json containing ONLY this agent's server entry. Universal for any
|
|
124
185
|
MCP-compatible editor that supports per-directory configs (Claude Code via
|
|
125
186
|
--mcp-config flag, Cursor + Cline via .cursor/mcp.json + .vscode/mcp.json).
|
|
187
|
+
|
|
188
|
+
keychain_profile: which entry in the OS keychain holds the api key for
|
|
189
|
+
this agent. Defaults to "default" (the user's main login). For a
|
|
190
|
+
friend who joined via `meshcode join <token>`, this is set to
|
|
191
|
+
"mesh:<project>:<agent>" so the workspace uses the scoped guest key
|
|
192
|
+
instead of the inviter's main key.
|
|
126
193
|
"""
|
|
127
|
-
creds = _load_credentials()
|
|
128
194
|
sb = _load_supabase_env()
|
|
129
|
-
|
|
195
|
+
|
|
196
|
+
# Resolve api_key from the named keychain profile (NOT always 'default')
|
|
197
|
+
try:
|
|
198
|
+
import importlib
|
|
199
|
+
secrets_mod = importlib.import_module("meshcode.secrets")
|
|
200
|
+
api_key = secrets_mod.get_api_key(profile=keychain_profile) or ""
|
|
201
|
+
except Exception as e:
|
|
202
|
+
print(f"[meshcode] ERROR: cannot load secrets module: {e}", file=sys.stderr)
|
|
203
|
+
return 2
|
|
204
|
+
|
|
205
|
+
if not api_key:
|
|
206
|
+
print(f"[meshcode] ERROR: no api key found in keychain profile '{keychain_profile}'", file=sys.stderr)
|
|
207
|
+
if keychain_profile == "default":
|
|
208
|
+
print("[meshcode] Run `meshcode login <api_key>` first.", file=sys.stderr)
|
|
209
|
+
else:
|
|
210
|
+
print(f"[meshcode] This profile is created by `meshcode join <token>`.", file=sys.stderr)
|
|
211
|
+
return 2
|
|
212
|
+
|
|
130
213
|
project_id = _resolve_project_id(api_key, project, sb)
|
|
131
214
|
|
|
132
215
|
server_id = f"meshcode-{project}-{agent}"
|
|
133
|
-
server_block = _build_server_block(project, project_id, agent, role, api_key, sb
|
|
216
|
+
server_block = _build_server_block(project, project_id, agent, role, api_key, sb,
|
|
217
|
+
keychain_profile=keychain_profile)
|
|
134
218
|
|
|
135
219
|
ws = _workspace_dir(project, agent)
|
|
136
220
|
ws.mkdir(parents=True, exist_ok=True)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meshcode
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.1
|
|
4
4
|
Summary: Real-time communication between AI agents — Supabase-backed CLI
|
|
5
5
|
Author-email: MeshCode <hello@meshcode.io>
|
|
6
6
|
License: MIT
|
|
@@ -21,6 +21,7 @@ Description-Content-Type: text/markdown
|
|
|
21
21
|
Requires-Dist: mcp[cli]>=1.0.0
|
|
22
22
|
Requires-Dist: websockets>=12.0
|
|
23
23
|
Requires-Dist: realtime>=2.0.0
|
|
24
|
+
Requires-Dist: keyring>=24.0
|
|
24
25
|
|
|
25
26
|
# MeshCode
|
|
26
27
|
|
|
@@ -3,10 +3,12 @@ pyproject.toml
|
|
|
3
3
|
meshcode/__init__.py
|
|
4
4
|
meshcode/cli.py
|
|
5
5
|
meshcode/comms_v4.py
|
|
6
|
+
meshcode/invites.py
|
|
6
7
|
meshcode/launcher.py
|
|
7
8
|
meshcode/launcher_install.py
|
|
8
9
|
meshcode/protocol_v2.py
|
|
9
10
|
meshcode/run_agent.py
|
|
11
|
+
meshcode/secrets.py
|
|
10
12
|
meshcode/setup_clients.py
|
|
11
13
|
meshcode.egg-info/PKG-INFO
|
|
12
14
|
meshcode.egg-info/SOURCES.txt
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "meshcode"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.5.1"
|
|
8
8
|
description = "Real-time communication between AI agents — Supabase-backed CLI"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -15,6 +15,7 @@ dependencies = [
|
|
|
15
15
|
"mcp[cli]>=1.0.0",
|
|
16
16
|
"websockets>=12.0",
|
|
17
17
|
"realtime>=2.0.0",
|
|
18
|
+
"keyring>=24.0",
|
|
18
19
|
]
|
|
19
20
|
classifiers = [
|
|
20
21
|
"Development Status :: 4 - Beta",
|
|
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
|