meshcode 2.11.137__tar.gz → 2.11.138__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 (105) hide show
  1. {meshcode-2.11.137 → meshcode-2.11.138}/PKG-INFO +11 -2
  2. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/__init__.py +1 -1
  3. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/server.py +2 -1
  4. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/swarm.py +80 -15
  5. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/test_swarm.py +84 -17
  6. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/run_agent.py +24 -3
  7. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode.egg-info/PKG-INFO +11 -2
  8. {meshcode-2.11.137 → meshcode-2.11.138}/pyproject.toml +1 -1
  9. {meshcode-2.11.137 → meshcode-2.11.138}/README.md +0 -0
  10. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/__main__.py +0 -0
  11. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/_session_handoff_template.py +0 -0
  12. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/_stop_hook_template.py +0 -0
  13. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/ascii_art.py +0 -0
  14. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/atomic_push.py +0 -0
  15. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/claude_update.py +0 -0
  16. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/cli.py +0 -0
  17. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/comms_v4.py +0 -0
  18. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/compat.py +0 -0
  19. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/daemon.py +0 -0
  20. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/date_parse.py +0 -0
  21. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/doctor.py +0 -0
  22. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/error_hints.py +0 -0
  23. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/exceptions.py +0 -0
  24. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/helper_visuals.py +0 -0
  25. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/hooks/__init__.py +0 -0
  26. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/hooks/repo_path_lock.py +0 -0
  27. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/hostd.py +0 -0
  28. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/invites.py +0 -0
  29. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/launcher.py +0 -0
  30. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/launcher_install.py +0 -0
  31. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/__init__.py +0 -0
  32. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/__main__.py +0 -0
  33. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/backend.py +0 -0
  34. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/realtime.py +0 -0
  35. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/sleep_signals.py +0 -0
  36. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/test_backend.py +0 -0
  37. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/test_boot_timing.py +0 -0
  38. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/test_install_guard.py +0 -0
  39. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/test_prefs_claude_version.py +0 -0
  40. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  41. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
  42. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/preferences.py +0 -0
  43. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/protocol_handler.py +0 -0
  44. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/protocol_v2.py +0 -0
  45. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/quickstart.py +0 -0
  46. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/rpc_allowlist.py +0 -0
  47. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/scripts/check_secrets.py +0 -0
  48. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/scripts/race_rate_harness.py +0 -0
  49. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/secrets.py +0 -0
  50. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/self_update.py +0 -0
  51. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/setup_clients.py +0 -0
  52. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/supervisor.py +0 -0
  53. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/up.py +0 -0
  54. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/upload.py +0 -0
  55. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode.egg-info/SOURCES.txt +0 -0
  56. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode.egg-info/dependency_links.txt +0 -0
  57. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode.egg-info/entry_points.txt +0 -0
  58. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode.egg-info/requires.txt +0 -0
  59. {meshcode-2.11.137 → meshcode-2.11.138}/meshcode.egg-info/top_level.txt +0 -0
  60. {meshcode-2.11.137 → meshcode-2.11.138}/setup.cfg +0 -0
  61. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_auto_update_hardening.py +0 -0
  62. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_autonomous_closegap_1.py +0 -0
  63. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_autonomous_closegap_2.py +0 -0
  64. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_autonomous_closegap_3.py +0 -0
  65. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_autonomous_prompt_inject.py +0 -0
  66. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_boot_bug_regression.py +0 -0
  67. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_color_truecolor.py +0 -0
  68. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_core.py +0 -0
  69. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_cross_agent_messaging.py +0 -0
  70. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_date_parse.py +0 -0
  71. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_doctor.py +0 -0
  72. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_epistemic_v1_python_sdk.py +0 -0
  73. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_epistemic_v1_stop_conditions.py +0 -0
  74. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_esc_deaf_state.py +0 -0
  75. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_exceptions.py +0 -0
  76. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_file_upload.py +0 -0
  77. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_helper_visuals.py +0 -0
  78. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_hostd_zombie_sessions.py +0 -0
  79. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_init_device_code.py +0 -0
  80. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_install_guard.py +0 -0
  81. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_lease_sigterm_release.py +0 -0
  82. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_live_mesh_guard.py +0 -0
  83. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_mark_read_batch.py +0 -0
  84. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_marketplace_ratings.py +0 -0
  85. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_migration_integrity.py +0 -0
  86. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_pretrust_claude.py +0 -0
  87. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_realtime_event_freshness.py +0 -0
  88. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_rls_cross_tenant.py +0 -0
  89. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_rpc_grants.py +0 -0
  90. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_rpc_migrations.py +0 -0
  91. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_run_agent_dry_run.py +0 -0
  92. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_run_agent_no_server_import.py +0 -0
  93. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_security_regressions.py +0 -0
  94. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_self_update_user_site.py +0 -0
  95. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_sentinel.py +0 -0
  96. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_session_replay_gate.py +0 -0
  97. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_setup_path.py +0 -0
  98. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_sleep_signals.py +0 -0
  99. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_status_enum_coverage.py +0 -0
  100. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_stay_on_loop_hook.py +0 -0
  101. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_stop_ghost_terminal.py +0 -0
  102. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_swarm_events.py +0 -0
  103. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_task_progress.py +0 -0
  104. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_terminal_lifecycle.py +0 -0
  105. {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_wait_open_tasks_contradiction.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 2.11.137
3
+ Version: 2.11.138
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -18,8 +18,17 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Operating System :: OS Independent
19
19
  Requires-Python: >=3.9
20
20
  Description-Content-Type: text/markdown
21
+ Requires-Dist: mcp[cli]>=1.0.0
22
+ Requires-Dist: websockets>=12.0
23
+ Requires-Dist: realtime>=2.0.0
24
+ Requires-Dist: keyring>=24.0
25
+ Requires-Dist: cryptography>=41.0
21
26
  Provides-Extra: test
27
+ Requires-Dist: pytest>=8; extra == "test"
22
28
  Provides-Extra: dev
29
+ Requires-Dist: build>=1.0; extra == "dev"
30
+ Requires-Dist: twine>=4; extra == "dev"
31
+ Requires-Dist: pytest>=8; extra == "dev"
23
32
 
24
33
  # MeshCode
25
34
 
@@ -1,5 +1,5 @@
1
1
  """MeshCode — Real-time communication between AI agents."""
2
- __version__ = "2.11.137"
2
+ __version__ = "2.11.138"
3
3
 
4
4
  # Exception hierarchy — eagerly imported (lightweight, no deps)
5
5
  from meshcode.exceptions import ( # noqa: F401
@@ -6694,7 +6694,8 @@ def meshcode_helper_spawn(name: str, role: str = "helper",
6694
6694
  return _swarm.spawn_helper(_get_api_key(), _PROJECT_ID, name, role=role,
6695
6695
  swarm_id=swarm_id,
6696
6696
  spawned_for_task_id=spawned_for_task_id,
6697
- ttl_seconds=ttl_seconds, headless=headless)
6697
+ ttl_seconds=ttl_seconds, headless=headless,
6698
+ project_name=PROJECT_NAME)
6698
6699
 
6699
6700
 
6700
6701
  @mcp.tool()
@@ -72,6 +72,11 @@ log = logging.getLogger("meshcode.swarm")
72
72
  RPC_HELPER_SPAWN = "mc_helper_spawn_as_agent"
73
73
  RPC_TRAY_CLAIM = "mc_task_claim_from_tray_as_agent"
74
74
  RPC_HELPER_RETIRE = "mc_helper_retire_as_agent"
75
+ # mig 525 (task 80f62d47): parent-only mint of the helper's agent-scoped api
76
+ # key — closes the W6 gap (helpers booted with the host's DEFAULT unscoped
77
+ # key, so every identity-bound *_as_agent RPC refused agent_key_required and
78
+ # the tray_drained→retire loop was dead; helpers lived until the TTL reaper).
79
+ RPC_HELPER_KEY_MINT = "mc_helper_key_mint_as_agent"
75
80
  RPC_AGENT_POWER = "mc_agent_power_as_agent" # mig 416 + 471 G1b + 473 cap
76
81
  RPC_TASK_COMPLETE = "mc_task_complete" # live, agent-callable (rpc_allowlist)
77
82
 
@@ -134,6 +139,49 @@ def _explain_spawn_refusal(error_code: Optional[str], error: str) -> str:
134
139
  return error
135
140
 
136
141
 
142
+ def provision_helper_key(api_key: str, project_id: str, name: str,
143
+ project_name: Optional[str]) -> Dict[str, Any]:
144
+ """Mint the helper's agent-scoped api key (mig 525, parent-only) and store
145
+ it in the OS keychain under profile mesh:<project_name>:<name> — the same
146
+ convention `meshcode join` uses, which `meshcode run`'s auto-setup now
147
+ prefers over 'default' (task 80f62d47). MUST complete BEFORE power-on:
148
+ hostd materializes the helper workspace at first launch and a workspace
149
+ baked with the default (unscoped) profile reproduces the dead-retire bug.
150
+
151
+ Soft-fail: returns {ok: False, ...} but never raises — a key-less helper
152
+ still works the tray-less degraded path and the TTL reaper backstops it.
153
+ """
154
+ if not project_name:
155
+ return {"ok": False, "error": "project name unknown — cannot derive keychain profile"}
156
+ mint = be.sb_rpc(RPC_HELPER_KEY_MINT, {
157
+ "p_api_key": api_key,
158
+ "p_project_id": project_id,
159
+ "p_helper_name": name,
160
+ })
161
+ err = _err(mint)
162
+ if err:
163
+ code = mint.get("error_code") if isinstance(mint, dict) else None
164
+ return {"ok": False, "error": err, **({"error_code": code} if code else {})}
165
+ helper_key = mint.get("api_key") if isinstance(mint, dict) else None
166
+ if not helper_key:
167
+ return {"ok": False, "error": "mint returned no api_key"}
168
+ profile = f"mesh:{project_name}:{name}"
169
+ try:
170
+ import importlib
171
+ secrets_mod = importlib.import_module("meshcode.secrets")
172
+ stored = secrets_mod.set_api_key(helper_key, profile=profile, meta={
173
+ "type": "scoped", "meshwork": project_name, "agent": name,
174
+ "helper": True, "expires_at": mint.get("expires_at"),
175
+ })
176
+ except Exception as e:
177
+ return {"ok": False, "error": f"keychain store failed: {e}", "profile": profile}
178
+ if not stored:
179
+ return {"ok": False, "error": "keychain store refused", "profile": profile}
180
+ return {"ok": True, "profile": profile,
181
+ "key_prefix": mint.get("key_prefix"),
182
+ "rotated": bool(mint.get("rotated"))}
183
+
184
+
137
185
  def spawn_helper(api_key: str, project_id: str, name: str, *,
138
186
  role: str = "helper",
139
187
  swarm_id: Optional[str] = None,
@@ -141,16 +189,23 @@ def spawn_helper(api_key: str, project_id: str, name: str, *,
141
189
  ttl_seconds: int = DEFAULT_TTL_SECONDS,
142
190
  launch: bool = True,
143
191
  headless: Optional[bool] = None,
144
- precheck: bool = True) -> Dict[str, Any]:
192
+ precheck: bool = True,
193
+ project_name: Optional[str] = None) -> Dict[str, Any]:
145
194
  """Spawn an ephemeral helper. Parent = caller (server-derived, W2).
146
195
 
147
196
  swarm_id=None lets the SERVER mint the tray id and stamp it on the parent
148
197
  row too (mig 480 parent-stamp — the SDK never mints). Pass an explicit
149
- swarm_id only to join an existing tray. headless=None lets the server's
150
- power-on default apply (single RPC); an explicit True/False does spawn
151
- with p_power_on=false and a separate mc_agent_power_as_agent call carrying
152
- p_headless. The helper's workspace materializes via `meshcode run`'s
153
- auto-setup when hostd spawns it (W5: row inherits repo_path/host).
198
+ swarm_id only to join an existing tray. The helper's workspace
199
+ materializes via `meshcode run`'s auto-setup when hostd spawns it (W5:
200
+ row inherits repo_path/host).
201
+
202
+ Flow is ALWAYS two-step when launching (task 80f62d47): register with
203
+ p_power_on=false → mint + keychain-store the helper's agent-scoped key
204
+ (mig 525) → power on. Power-on flips desired_state and hostd may
205
+ materialize the workspace on its very next poll tick, so the key MUST be
206
+ in the keychain first — otherwise auto-setup bakes the default unscoped
207
+ profile and every identity-bound *_as_agent RPC (tray claim, self-retire)
208
+ refuses for the helper's whole life.
154
209
 
155
210
  precheck (scope-add 6a14336c): consult the user's Enjambre toggle BEFORE
156
211
  creating anything — enabled=False refuses early with a clear message
@@ -164,8 +219,6 @@ def spawn_helper(api_key: str, project_id: str, name: str, *,
164
219
  return {"ok": False, "stage": "precheck",
165
220
  "error_code": "swarm_disabled", "error": MSG_SWARM_DISABLED}
166
221
 
167
- single_rpc_power = launch and headless is None
168
-
169
222
  reg = be.sb_rpc(RPC_HELPER_SPAWN, {
170
223
  "p_api_key": api_key,
171
224
  "p_project_id": project_id,
@@ -175,7 +228,7 @@ def spawn_helper(api_key: str, project_id: str, name: str, *,
175
228
  "p_role": role,
176
229
  "p_auto_retire": True,
177
230
  "p_ttl_seconds": int(ttl_seconds),
178
- "p_power_on": bool(single_rpc_power),
231
+ "p_power_on": False,
179
232
  })
180
233
  err = _err(reg)
181
234
  if err:
@@ -199,18 +252,30 @@ def spawn_helper(api_key: str, project_id: str, name: str, *,
199
252
  out.update({k: reg[k] for k in
200
253
  ("agent_id", "swarm_id", "parent_agent_id", "ttl_expires_at")
201
254
  if k in reg})
202
- power = reg.get("power")
255
+
256
+ # Key provisioning BEFORE power-on (ordering is the whole fix). Soft-fail:
257
+ # surfaced in the result, never blocks the launch.
258
+ prov = provision_helper_key(api_key, project_id, name, project_name)
259
+ out["key_provisioned"] = bool(prov.get("ok"))
260
+ if prov.get("ok"):
261
+ out["keychain_profile"] = prov.get("profile")
203
262
  else:
204
- power = None
263
+ out["key_provision_error"] = prov.get("error")
264
+ log.warning("helper %s: key provisioning failed (%s) — helper will "
265
+ "boot with the default key and cannot tray-claim/retire",
266
+ name, prov.get("error"))
205
267
 
206
- if launch and not single_rpc_power:
207
- power = be.sb_rpc(RPC_AGENT_POWER, {
268
+ power = None
269
+ if launch:
270
+ power_params = {
208
271
  "p_api_key": api_key,
209
272
  "p_project_id": project_id,
210
273
  "p_agent": name,
211
274
  "p_state": "running",
212
- "p_headless": bool(headless),
213
- })
275
+ }
276
+ if headless is not None:
277
+ power_params["p_headless"] = bool(headless)
278
+ power = be.sb_rpc(RPC_AGENT_POWER, power_params)
214
279
 
215
280
  if launch:
216
281
  perr = _err(power)
@@ -10,6 +10,7 @@ helper_cap_reached in BOTH register and power paths), drain semantics,
10
10
  DAG-blocked polling, ttl expiry, error budget, and the no-recycle rule
11
11
  (no code path may ever call mc_recycle*).
12
12
  """
13
+ import sys
13
14
  import unittest
14
15
  from unittest import mock
15
16
 
@@ -47,6 +48,16 @@ class SwarmTestCase(unittest.TestCase):
47
48
  patcher.start()
48
49
  self.addCleanup(patcher.stop)
49
50
 
51
+ def patch_secrets(self, stored=True):
52
+ """Fake meshcode.secrets so provision_helper_key's keychain store is
53
+ offline. Returns the mock module (inspect set_api_key calls)."""
54
+ secrets_mod = mock.MagicMock()
55
+ secrets_mod.set_api_key.return_value = stored
56
+ patcher = mock.patch.dict(sys.modules, {"meshcode.secrets": secrets_mod})
57
+ patcher.start()
58
+ self.addCleanup(patcher.stop)
59
+ return secrets_mod
60
+
50
61
  def assert_no_recycle(self):
51
62
  """recycle_agent is DISABLED in prod — the helper lifecycle must never
52
63
  touch any mc_recycle* RPC on any path."""
@@ -55,24 +66,37 @@ class SwarmTestCase(unittest.TestCase):
55
66
 
56
67
 
57
68
  class SpawnTests(SwarmTestCase):
58
- def test_spawn_single_rpc_composes_power_on(self):
59
- """Default (headless unset): ONE RPC server composes power-on."""
69
+ def test_spawn_two_step_key_before_power(self):
70
+ """task 80f62d47: register(p_power_on=false) MINT + keychain store
71
+ power. Ordering is the fix: the key must be in the keychain BEFORE
72
+ desired_state flips, or hostd materializes the workspace with the
73
+ default unscoped profile and the helper can never tray-claim/retire."""
74
+ secrets_mod = self.patch_secrets()
60
75
  self.rpc.script(swarm.RPC_HELPER_SPAWN,
61
76
  {"ok": True, "agent_id": "h-1", "name": "helper-1",
62
77
  "swarm_id": "s-1", "parent_agent_id": "p-1",
63
- "ttl_expires_at": "2026-06-10T01:00:00Z",
64
- "power": {"ok": True, "powered_by": "parent:qa",
65
- "no_host": False}})
78
+ "ttl_expires_at": "2026-06-10T01:00:00Z"})
79
+ self.rpc.script(swarm.RPC_HELPER_KEY_MINT,
80
+ {"ok": True, "api_key": "mc_helper_secret",
81
+ "key_prefix": "mc_helper_se", "rotated": False})
82
+ self.rpc.script(swarm.RPC_AGENT_POWER,
83
+ {"ok": True, "powered_by": "parent:qa", "no_host": False})
66
84
 
67
85
  out = swarm.spawn_helper("KEY", PROJ, "helper-1",
68
86
  swarm_id="s-1", spawned_for_task_id="t-9",
69
- ttl_seconds=120, precheck=False)
87
+ ttl_seconds=120, precheck=False,
88
+ project_name="myproj")
70
89
 
71
90
  self.assertTrue(out["ok"])
72
91
  self.assertEqual(out["agent_id"], "h-1")
73
92
  self.assertEqual(out["swarm_id"], "s-1")
74
93
  self.assertEqual(out["powered_by"], "parent:qa")
75
- self.assertEqual(self.rpc.called_fns(), [swarm.RPC_HELPER_SPAWN])
94
+ self.assertTrue(out["key_provisioned"])
95
+ self.assertEqual(out["keychain_profile"], "mesh:myproj:helper-1")
96
+ # strict order: spawn → mint → power
97
+ self.assertEqual(self.rpc.called_fns(),
98
+ [swarm.RPC_HELPER_SPAWN, swarm.RPC_HELPER_KEY_MINT,
99
+ swarm.RPC_AGENT_POWER])
76
100
 
77
101
  _, params = self.rpc.calls[0]
78
102
  self.assertEqual(params["p_project_id"], PROJ)
@@ -80,9 +104,39 @@ class SpawnTests(SwarmTestCase):
80
104
  self.assertEqual(params["p_spawned_for_task_id"], "t-9")
81
105
  self.assertEqual(params["p_ttl_seconds"], 120)
82
106
  self.assertTrue(params["p_auto_retire"])
83
- self.assertTrue(params["p_power_on"])
107
+ self.assertFalse(params["p_power_on"]) # never single-RPC anymore
108
+ # keychain got the MINTED key under the scoped profile
109
+ secrets_mod.set_api_key.assert_called_once()
110
+ args, kwargs = secrets_mod.set_api_key.call_args
111
+ self.assertEqual(args[0], "mc_helper_secret")
112
+ self.assertEqual(kwargs["profile"], "mesh:myproj:helper-1")
84
113
  self.assert_no_recycle()
85
114
 
115
+ def test_spawn_key_mint_soft_fails_never_blocks_launch(self):
116
+ """Mint refusal (e.g. mig 525 not applied yet) degrades, not breaks:
117
+ helper still powers on; result surfaces key_provision_error."""
118
+ self.rpc.script(swarm.RPC_HELPER_SPAWN, {"ok": True, "agent_id": "h-1"})
119
+ self.rpc.script(swarm.RPC_HELPER_KEY_MINT,
120
+ {"ok": False, "error_code": "not_your_helper",
121
+ "error": "only the spawning parent can mint"})
122
+ self.rpc.script(swarm.RPC_AGENT_POWER, {"ok": True})
123
+ out = swarm.spawn_helper("KEY", PROJ, "helper-1", precheck=False,
124
+ project_name="myproj")
125
+ self.assertTrue(out["ok"])
126
+ self.assertFalse(out["key_provisioned"])
127
+ self.assertIn("parent", out["key_provision_error"])
128
+ self.assertEqual(self.rpc.called_fns()[-1], swarm.RPC_AGENT_POWER)
129
+
130
+ def test_spawn_without_project_name_skips_mint(self):
131
+ """No project name → no profile to store under → skip the mint RPC
132
+ entirely (degraded, surfaced) instead of leaking an unstorable key."""
133
+ self.rpc.script(swarm.RPC_HELPER_SPAWN, {"ok": True, "agent_id": "h-1"})
134
+ self.rpc.script(swarm.RPC_AGENT_POWER, {"ok": True})
135
+ out = swarm.spawn_helper("KEY", PROJ, "helper-1", precheck=False)
136
+ self.assertTrue(out["ok"])
137
+ self.assertFalse(out["key_provisioned"])
138
+ self.assertNotIn(swarm.RPC_HELPER_KEY_MINT, self.rpc.called_fns())
139
+
86
140
  def test_spawn_omitted_swarm_id_passes_null_for_server_mint(self):
87
141
  """mig 480: p_swarm_id DEFAULT NULL — the SERVER mints the tray id and
88
142
  parent-stamps it; the SDK must NOT mint client-side."""
@@ -93,7 +147,7 @@ class SpawnTests(SwarmTestCase):
93
147
  self.assertIsNone(params["p_swarm_id"]) # server mints, never the SDK
94
148
  self.assertEqual(out["swarm_id"], "srv-minted")
95
149
 
96
- def test_spawn_explicit_headless_uses_two_rpcs(self):
150
+ def test_spawn_explicit_headless_carries_p_headless(self):
97
151
  self.rpc.script(swarm.RPC_HELPER_SPAWN, {"ok": True, "agent_id": "h-1"})
98
152
  self.rpc.script(swarm.RPC_AGENT_POWER,
99
153
  {"ok": True, "powered_by": "parent:qa"})
@@ -102,11 +156,21 @@ class SpawnTests(SwarmTestCase):
102
156
  self.assertTrue(out["ok"])
103
157
  _, spawn_params = self.rpc.calls[0]
104
158
  self.assertFalse(spawn_params["p_power_on"])
105
- fn, power_params = self.rpc.calls[1]
159
+ fn, power_params = self.rpc.calls[-1]
106
160
  self.assertEqual(fn, swarm.RPC_AGENT_POWER)
107
161
  self.assertEqual(power_params["p_state"], "running")
108
162
  self.assertTrue(power_params["p_headless"])
109
163
 
164
+ def test_spawn_default_headless_omits_p_headless(self):
165
+ """headless=None → server's power default applies (no p_headless key)."""
166
+ self.rpc.script(swarm.RPC_HELPER_SPAWN, {"ok": True, "agent_id": "h-1"})
167
+ self.rpc.script(swarm.RPC_AGENT_POWER, {"ok": True})
168
+ out = swarm.spawn_helper("KEY", PROJ, "helper-1", precheck=False)
169
+ self.assertTrue(out["ok"])
170
+ fn, power_params = self.rpc.calls[-1]
171
+ self.assertEqual(fn, swarm.RPC_AGENT_POWER)
172
+ self.assertNotIn("p_headless", power_params)
173
+
110
174
  def test_spawn_tier_limit_surfaces_register_stage(self):
111
175
  self.rpc.script(swarm.RPC_HELPER_SPAWN,
112
176
  {"ok": False, "error_code": "tier_limit",
@@ -121,9 +185,10 @@ class SpawnTests(SwarmTestCase):
121
185
  """mig 473 concurrent cap: row created, power refused — message maps to
122
186
  the user-facing UX copy (Samuel scope-add); caller can stagger/re-power."""
123
187
  self.rpc.script(swarm.RPC_HELPER_SPAWN,
124
- {"ok": True, "agent_id": "h-1", "swarm_id": "s-1",
125
- "power": {"ok": False, "error_code": "helper_cap_reached",
126
- "error": "too many running helpers"}})
188
+ {"ok": True, "agent_id": "h-1", "swarm_id": "s-1"})
189
+ self.rpc.script(swarm.RPC_AGENT_POWER,
190
+ {"ok": False, "error_code": "helper_cap_reached",
191
+ "error": "too many running helpers"})
127
192
  out = swarm.spawn_helper("KEY", PROJ, "helper-1", swarm_id="s-1",
128
193
  precheck=False)
129
194
  self.assertFalse(out["ok"])
@@ -163,7 +228,8 @@ class UxControlsTests(SwarmTestCase):
163
228
  {"ok": True, "enabled": True, "max_helpers": 5,
164
229
  "running_helpers": 1})
165
230
  self.rpc.script(swarm.RPC_HELPER_SPAWN,
166
- {"ok": True, "agent_id": "h-1", "power": {"ok": True}})
231
+ {"ok": True, "agent_id": "h-1"})
232
+ self.rpc.script(swarm.RPC_AGENT_POWER, {"ok": True})
167
233
  out = swarm.spawn_helper("KEY", PROJ, "helper-1")
168
234
  self.assertTrue(out["ok"])
169
235
  self.assertIn(swarm.RPC_HELPER_SPAWN, self.rpc.called_fns())
@@ -203,9 +269,10 @@ class UxControlsTests(SwarmTestCase):
203
269
  path — a row created while the toggle flips off arrives stopped with
204
270
  the refusal inside `power`; same clear UX copy."""
205
271
  self.rpc.script(swarm.RPC_HELPER_SPAWN,
206
- {"ok": True, "agent_id": "h-1", "swarm_id": "s-1",
207
- "power": {"ok": False, "error_code": "swarm_disabled",
208
- "error": "swarm_disabled"}})
272
+ {"ok": True, "agent_id": "h-1", "swarm_id": "s-1"})
273
+ self.rpc.script(swarm.RPC_AGENT_POWER,
274
+ {"ok": False, "error_code": "swarm_disabled",
275
+ "error": "swarm_disabled"})
209
276
  out = swarm.spawn_helper("KEY", PROJ, "helper-1", swarm_id="s-1",
210
277
  precheck=False)
211
278
  self.assertFalse(out["ok"])
@@ -352,6 +352,24 @@ def _resolve_user_projects_for_agent(agent: str) -> Optional[list]:
352
352
  return projects if isinstance(projects, list) else []
353
353
 
354
354
 
355
+ def _preferred_keychain_profile(project: str, agent: str) -> str:
356
+ """Prefer the agent-scoped keychain profile mesh:<project>:<agent> when it
357
+ holds a key — invite guests AND enjambre helpers (task 80f62d47: helper
358
+ keys are minted by the parent via mc_helper_key_mint_as_agent and stored
359
+ under this profile BEFORE power-on). Falling back to 'default' here is
360
+ what used to bake the owner's unscoped key into helper workspaces, which
361
+ made every identity-bound *_as_agent RPC refuse (dead retire loop)."""
362
+ try:
363
+ import importlib
364
+ secrets_mod = importlib.import_module("meshcode.secrets")
365
+ scoped = f"mesh:{project}:{agent}"
366
+ if secrets_mod.get_api_key(profile=scoped):
367
+ return scoped
368
+ except Exception:
369
+ pass
370
+ return os.environ.get("MESHCODE_KEYCHAIN_PROFILE") or "default"
371
+
372
+
355
373
  def _try_auto_setup(agent: str, project: str) -> Optional[Tuple[Path, str]]:
356
374
  """If agent exists on the server but has no local workspace, auto-create it.
357
375
 
@@ -379,7 +397,8 @@ def _try_auto_setup(agent: str, project: str) -> Optional[Tuple[Path, str]]:
379
397
  role = match[0].get("role", "")
380
398
 
381
399
  print(f"[meshcode] Workspace recreated automatically for agent '{agent}' (project: {resolved_project})")
382
- rc = setup_workspace(resolved_project, agent, role)
400
+ rc = setup_workspace(resolved_project, agent, role,
401
+ keychain_profile=_preferred_keychain_profile(resolved_project, agent))
383
402
  if rc != 0:
384
403
  return None
385
404
 
@@ -928,7 +947,8 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
928
947
  print(f"[meshcode] .mcp.json missing from workspace — regenerating...", file=sys.stderr)
929
948
  try:
930
949
  from .setup_clients import setup_workspace
931
- rc = setup_workspace(resolved_project, agent)
950
+ rc = setup_workspace(resolved_project, agent,
951
+ keychain_profile=_preferred_keychain_profile(resolved_project, agent))
932
952
  if rc == 0 and mcp_json_path.exists():
933
953
  print(f"[meshcode] .mcp.json regenerated successfully.", file=sys.stderr)
934
954
  else:
@@ -1003,7 +1023,8 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
1003
1023
  print(f"[meshcode] stop hook missing from workspace — regenerating...", file=sys.stderr)
1004
1024
  try:
1005
1025
  from .setup_clients import setup_workspace
1006
- rc = setup_workspace(resolved_project, agent)
1026
+ rc = setup_workspace(resolved_project, agent,
1027
+ keychain_profile=_preferred_keychain_profile(resolved_project, agent))
1007
1028
  if rc == 0 and hook_path.exists():
1008
1029
  print(f"[meshcode] stop hook regenerated successfully.", file=sys.stderr)
1009
1030
  else:
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 2.11.137
3
+ Version: 2.11.138
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -18,8 +18,17 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Operating System :: OS Independent
19
19
  Requires-Python: >=3.9
20
20
  Description-Content-Type: text/markdown
21
+ Requires-Dist: mcp[cli]>=1.0.0
22
+ Requires-Dist: websockets>=12.0
23
+ Requires-Dist: realtime>=2.0.0
24
+ Requires-Dist: keyring>=24.0
25
+ Requires-Dist: cryptography>=41.0
21
26
  Provides-Extra: test
27
+ Requires-Dist: pytest>=8; extra == "test"
22
28
  Provides-Extra: dev
29
+ Requires-Dist: build>=1.0; extra == "dev"
30
+ Requires-Dist: twine>=4; extra == "dev"
31
+ Requires-Dist: pytest>=8; extra == "dev"
23
32
 
24
33
  # MeshCode
25
34
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "meshcode"
7
- version = "2.11.137"
7
+ version = "2.11.138"
8
8
  description = "Real-time communication between AI agents — Supabase-backed CLI"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
File without changes
File without changes
File without changes
File without changes