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.
Files changed (27) hide show
  1. {meshcode-1.4.0 → meshcode-1.5.1}/PKG-INFO +2 -1
  2. {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/__init__.py +1 -1
  3. {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/comms_v4.py +117 -15
  4. meshcode-1.5.1/meshcode/invites.py +363 -0
  5. {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/meshcode_mcp/server.py +43 -5
  6. meshcode-1.5.1/meshcode/secrets.py +337 -0
  7. {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/setup_clients.py +102 -18
  8. {meshcode-1.4.0 → meshcode-1.5.1}/meshcode.egg-info/PKG-INFO +2 -1
  9. {meshcode-1.4.0 → meshcode-1.5.1}/meshcode.egg-info/SOURCES.txt +2 -0
  10. {meshcode-1.4.0 → meshcode-1.5.1}/meshcode.egg-info/requires.txt +1 -0
  11. {meshcode-1.4.0 → meshcode-1.5.1}/pyproject.toml +2 -1
  12. {meshcode-1.4.0 → meshcode-1.5.1}/README.md +0 -0
  13. {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/cli.py +0 -0
  14. {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/launcher.py +0 -0
  15. {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/launcher_install.py +0 -0
  16. {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/meshcode_mcp/__init__.py +0 -0
  17. {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/meshcode_mcp/__main__.py +0 -0
  18. {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/meshcode_mcp/backend.py +0 -0
  19. {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/meshcode_mcp/realtime.py +0 -0
  20. {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/meshcode_mcp/test_backend.py +0 -0
  21. {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  22. {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/protocol_v2.py +0 -0
  23. {meshcode-1.4.0 → meshcode-1.5.1}/meshcode/run_agent.py +0 -0
  24. {meshcode-1.4.0 → meshcode-1.5.1}/meshcode.egg-info/dependency_links.txt +0 -0
  25. {meshcode-1.4.0 → meshcode-1.5.1}/meshcode.egg-info/entry_points.txt +0 -0
  26. {meshcode-1.4.0 → meshcode-1.5.1}/meshcode.egg-info/top_level.txt +0 -0
  27. {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.4.0
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.4.0"
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
- # Check for saved credentials
1400
- creds_path = Path.home() / ".meshcode" / "credentials.json"
1401
- if creds_path.exists():
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
- creds = json.loads(creds_path.read_text())
1404
- print(f"[MESHCODE] Autenticado como {creds.get('display_name', creds.get('email', '?'))}")
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 credentials locally."""
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
- # Save credentials
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
- creds = {
1466
- "api_key": api_key,
1467
- "user_id": result["user_id"],
1468
- "email": result["email"],
1469
- "display_name": result["display_name"]
1470
- }
1471
- (config_dir / "credentials.json").write_text(json.dumps(creds, indent=2))
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 = os.environ.get("MESHCODE_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 MESHCODE_API_KEY env var)", file=sys.stderr)
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 = os.environ.get("MESHCODE_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 = os.environ.get("MESHCODE_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 = os.environ.get("MESHCODE_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
- creds_path = Path.home() / ".meshcode" / "credentials.json"
28
- if not creds_path.exists():
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
- return json.loads(creds_path.read_text())
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]) -> Dict[str, Any]:
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 = "") -> int:
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
- api_key = creds.get("api_key", "")
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.4.0
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
@@ -1,3 +1,4 @@
1
1
  mcp[cli]>=1.0.0
2
2
  websockets>=12.0
3
3
  realtime>=2.0.0
4
+ keyring>=24.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "meshcode"
7
- version = "1.4.0"
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