meshcode 2.11.137__tar.gz → 2.11.139__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.139}/PKG-INFO +11 -2
  2. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/__init__.py +1 -1
  3. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/hostd.py +6 -0
  4. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/meshcode_mcp/server.py +2 -1
  5. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/meshcode_mcp/swarm.py +80 -15
  6. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/meshcode_mcp/test_swarm.py +84 -17
  7. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/run_agent.py +108 -3
  8. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode.egg-info/PKG-INFO +11 -2
  9. {meshcode-2.11.137 → meshcode-2.11.139}/pyproject.toml +1 -1
  10. {meshcode-2.11.137 → meshcode-2.11.139}/README.md +0 -0
  11. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/__main__.py +0 -0
  12. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/_session_handoff_template.py +0 -0
  13. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/_stop_hook_template.py +0 -0
  14. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/ascii_art.py +0 -0
  15. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/atomic_push.py +0 -0
  16. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/claude_update.py +0 -0
  17. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/cli.py +0 -0
  18. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/comms_v4.py +0 -0
  19. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/compat.py +0 -0
  20. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/daemon.py +0 -0
  21. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/date_parse.py +0 -0
  22. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/doctor.py +0 -0
  23. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/error_hints.py +0 -0
  24. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/exceptions.py +0 -0
  25. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/helper_visuals.py +0 -0
  26. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/hooks/__init__.py +0 -0
  27. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/hooks/repo_path_lock.py +0 -0
  28. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/invites.py +0 -0
  29. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/launcher.py +0 -0
  30. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/launcher_install.py +0 -0
  31. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/meshcode_mcp/__init__.py +0 -0
  32. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/meshcode_mcp/__main__.py +0 -0
  33. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/meshcode_mcp/backend.py +0 -0
  34. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/meshcode_mcp/realtime.py +0 -0
  35. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/meshcode_mcp/sleep_signals.py +0 -0
  36. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/meshcode_mcp/test_backend.py +0 -0
  37. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/meshcode_mcp/test_boot_timing.py +0 -0
  38. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/meshcode_mcp/test_install_guard.py +0 -0
  39. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/meshcode_mcp/test_prefs_claude_version.py +0 -0
  40. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  41. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
  42. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/preferences.py +0 -0
  43. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/protocol_handler.py +0 -0
  44. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/protocol_v2.py +0 -0
  45. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/quickstart.py +0 -0
  46. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/rpc_allowlist.py +0 -0
  47. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/scripts/check_secrets.py +0 -0
  48. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/scripts/race_rate_harness.py +0 -0
  49. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/secrets.py +0 -0
  50. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/self_update.py +0 -0
  51. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/setup_clients.py +0 -0
  52. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/supervisor.py +0 -0
  53. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/up.py +0 -0
  54. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode/upload.py +0 -0
  55. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode.egg-info/SOURCES.txt +0 -0
  56. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode.egg-info/dependency_links.txt +0 -0
  57. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode.egg-info/entry_points.txt +0 -0
  58. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode.egg-info/requires.txt +0 -0
  59. {meshcode-2.11.137 → meshcode-2.11.139}/meshcode.egg-info/top_level.txt +0 -0
  60. {meshcode-2.11.137 → meshcode-2.11.139}/setup.cfg +0 -0
  61. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_auto_update_hardening.py +0 -0
  62. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_autonomous_closegap_1.py +0 -0
  63. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_autonomous_closegap_2.py +0 -0
  64. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_autonomous_closegap_3.py +0 -0
  65. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_autonomous_prompt_inject.py +0 -0
  66. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_boot_bug_regression.py +0 -0
  67. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_color_truecolor.py +0 -0
  68. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_core.py +0 -0
  69. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_cross_agent_messaging.py +0 -0
  70. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_date_parse.py +0 -0
  71. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_doctor.py +0 -0
  72. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_epistemic_v1_python_sdk.py +0 -0
  73. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_epistemic_v1_stop_conditions.py +0 -0
  74. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_esc_deaf_state.py +0 -0
  75. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_exceptions.py +0 -0
  76. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_file_upload.py +0 -0
  77. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_helper_visuals.py +0 -0
  78. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_hostd_zombie_sessions.py +0 -0
  79. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_init_device_code.py +0 -0
  80. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_install_guard.py +0 -0
  81. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_lease_sigterm_release.py +0 -0
  82. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_live_mesh_guard.py +0 -0
  83. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_mark_read_batch.py +0 -0
  84. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_marketplace_ratings.py +0 -0
  85. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_migration_integrity.py +0 -0
  86. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_pretrust_claude.py +0 -0
  87. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_realtime_event_freshness.py +0 -0
  88. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_rls_cross_tenant.py +0 -0
  89. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_rpc_grants.py +0 -0
  90. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_rpc_migrations.py +0 -0
  91. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_run_agent_dry_run.py +0 -0
  92. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_run_agent_no_server_import.py +0 -0
  93. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_security_regressions.py +0 -0
  94. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_self_update_user_site.py +0 -0
  95. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_sentinel.py +0 -0
  96. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_session_replay_gate.py +0 -0
  97. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_setup_path.py +0 -0
  98. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_sleep_signals.py +0 -0
  99. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_status_enum_coverage.py +0 -0
  100. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_stay_on_loop_hook.py +0 -0
  101. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_stop_ghost_terminal.py +0 -0
  102. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_swarm_events.py +0 -0
  103. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_task_progress.py +0 -0
  104. {meshcode-2.11.137 → meshcode-2.11.139}/tests/test_terminal_lifecycle.py +0 -0
  105. {meshcode-2.11.137 → meshcode-2.11.139}/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.139
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.139"
3
3
 
4
4
  # Exception hierarchy — eagerly imported (lightweight, no deps)
5
5
  from meshcode.exceptions import ( # noqa: F401
@@ -617,11 +617,17 @@ def _spawn_agent(project: str, agent: str, repo_path=None) -> bool:
617
617
  _repo_win = f' --repo "{repo}"' if repo else ''
618
618
  cmd = (f'set "CLAUDECODE=" & set "CLAUDE_CODE_SESSION=" & '
619
619
  f'set "MESHCODE_NO_UPDATE=" & set "MESHCODE_NO_AUTO_UPDATE=" & '
620
+ # task 01e2b5c1: mark hostd spawns so `meshcode run` skips the
621
+ # human run-intent RPC (hostd only spawns desired_state=running;
622
+ # re-arming would override a human Stop clicked mid-spawn).
623
+ f'set "MESHCODE_HOSTD_SPAWN=1" & '
620
624
  + (f'set "PATH={_bindir};%PATH%" & ' if _bindir else '')
621
625
  + f'"{sys.executable}" -m meshcode run "{target}"{_repo_win}')
622
626
  else:
623
627
  _repo_posix = f" --repo {shlex.quote(repo)}" if repo else ''
624
628
  cmd = (f"unset CLAUDECODE CLAUDE_CODE_SESSION MESHCODE_NO_UPDATE MESHCODE_NO_AUTO_UPDATE; "
629
+ # task 01e2b5c1: see win32 branch — hostd spawns skip run-intent.
630
+ + f"export MESHCODE_HOSTD_SPAWN=1; "
625
631
  + (f"export PATH={shlex.quote(_bindir)}:$PATH; " if _bindir else "")
626
632
  + f"exec {shlex.quote(sys.executable)} -m meshcode run {shlex.quote(target)}{_repo_posix}")
627
633
  try:
@@ -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,78 @@ 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 _declare_run_intent(agent: str, project: str) -> dict:
356
+ """Server-side 'human wants this agent ON' (task 01e2b5c1, mig 526).
357
+
358
+ A hand-typed `meshcode run` on a desired_state='stopped' agent used to
359
+ boot and die seconds later: hostd enforces 'stopped' against any session
360
+ it didn't bless (ghost sweep on stale heartbeat, cooperative must_exit on
361
+ fresh). mc_run_intent_by_api_key flips desired_state='running' and clears
362
+ stop_source + stale recycle/respawn flags ATOMICALLY server-side BEFORE
363
+ the editor launches, so hostd can never win the race.
364
+
365
+ Soft-fail: returns {ok: False, error} — caller warns loudly but never
366
+ blocks the launch (guests on their own box don't fight a hostd anyway).
367
+ """
368
+ try:
369
+ from .setup_clients import _load_supabase_env
370
+ import importlib
371
+ secrets_mod = importlib.import_module("meshcode.secrets")
372
+ except Exception as e:
373
+ return {"ok": False, "error": f"cannot load auth modules: {e}"}
374
+
375
+ scoped_profile = f"mesh:{project}:{agent}"
376
+ api_key = secrets_mod.get_api_key(profile=scoped_profile)
377
+ if not api_key:
378
+ profile = os.environ.get("MESHCODE_KEYCHAIN_PROFILE") or "default"
379
+ api_key = secrets_mod.get_api_key(profile=profile)
380
+ if not api_key:
381
+ return {"ok": False, "error": "not logged in"}
382
+
383
+ sb = _load_supabase_env()
384
+ try:
385
+ from urllib.request import Request, urlopen
386
+ body = json.dumps({
387
+ "p_api_key": api_key,
388
+ "p_project_name": project,
389
+ "p_agent_name": agent,
390
+ }).encode()
391
+ req = Request(
392
+ f"{sb['SUPABASE_URL']}/rest/v1/rpc/mc_run_intent_by_api_key",
393
+ data=body, method="POST",
394
+ headers={
395
+ "apikey": sb["SUPABASE_KEY"],
396
+ "Authorization": f"Bearer {sb['SUPABASE_KEY']}",
397
+ "Content-Type": "application/json",
398
+ },
399
+ )
400
+ with urlopen(req, timeout=10) as resp:
401
+ data = json.loads(resp.read().decode())
402
+ except Exception as e:
403
+ return {"ok": False, "error": f"run-intent RPC unreachable: {e}"}
404
+ if isinstance(data, dict):
405
+ return data
406
+ return {"ok": False, "error": "unexpected run-intent response shape"}
407
+
408
+
409
+ def _preferred_keychain_profile(project: str, agent: str) -> str:
410
+ """Prefer the agent-scoped keychain profile mesh:<project>:<agent> when it
411
+ holds a key — invite guests AND enjambre helpers (task 80f62d47: helper
412
+ keys are minted by the parent via mc_helper_key_mint_as_agent and stored
413
+ under this profile BEFORE power-on). Falling back to 'default' here is
414
+ what used to bake the owner's unscoped key into helper workspaces, which
415
+ made every identity-bound *_as_agent RPC refuse (dead retire loop)."""
416
+ try:
417
+ import importlib
418
+ secrets_mod = importlib.import_module("meshcode.secrets")
419
+ scoped = f"mesh:{project}:{agent}"
420
+ if secrets_mod.get_api_key(profile=scoped):
421
+ return scoped
422
+ except Exception:
423
+ pass
424
+ return os.environ.get("MESHCODE_KEYCHAIN_PROFILE") or "default"
425
+
426
+
355
427
  def _try_auto_setup(agent: str, project: str) -> Optional[Tuple[Path, str]]:
356
428
  """If agent exists on the server but has no local workspace, auto-create it.
357
429
 
@@ -379,7 +451,8 @@ def _try_auto_setup(agent: str, project: str) -> Optional[Tuple[Path, str]]:
379
451
  role = match[0].get("role", "")
380
452
 
381
453
  print(f"[meshcode] Workspace recreated automatically for agent '{agent}' (project: {resolved_project})")
382
- rc = setup_workspace(resolved_project, agent, role)
454
+ rc = setup_workspace(resolved_project, agent, role,
455
+ keychain_profile=_preferred_keychain_profile(resolved_project, agent))
383
456
  if rc != 0:
384
457
  return None
385
458
 
@@ -928,7 +1001,8 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
928
1001
  print(f"[meshcode] .mcp.json missing from workspace — regenerating...", file=sys.stderr)
929
1002
  try:
930
1003
  from .setup_clients import setup_workspace
931
- rc = setup_workspace(resolved_project, agent)
1004
+ rc = setup_workspace(resolved_project, agent,
1005
+ keychain_profile=_preferred_keychain_profile(resolved_project, agent))
932
1006
  if rc == 0 and mcp_json_path.exists():
933
1007
  print(f"[meshcode] .mcp.json regenerated successfully.", file=sys.stderr)
934
1008
  else:
@@ -1003,7 +1077,8 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
1003
1077
  print(f"[meshcode] stop hook missing from workspace — regenerating...", file=sys.stderr)
1004
1078
  try:
1005
1079
  from .setup_clients import setup_workspace
1006
- rc = setup_workspace(resolved_project, agent)
1080
+ rc = setup_workspace(resolved_project, agent,
1081
+ keychain_profile=_preferred_keychain_profile(resolved_project, agent))
1007
1082
  if rc == 0 and hook_path.exists():
1008
1083
  print(f"[meshcode] stop hook regenerated successfully.", file=sys.stderr)
1009
1084
  else:
@@ -1023,6 +1098,36 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
1023
1098
  if ownership_err:
1024
1099
  print(f"[meshcode] ERROR: {ownership_err}", file=sys.stderr)
1025
1100
  return 2
1101
+
1102
+ # ── Run-intent: explicit human launch UN-PARKS the agent (task 01e2b5c1) ──
1103
+ # Without this, `meshcode run` on a desired_state='stopped' agent boots and
1104
+ # hostd kills the session seconds later ("se apagaban automáticamente").
1105
+ # Server-side BEFORE the editor launches so hostd never wins the race.
1106
+ # Gated OFF for hostd-spawned terminals (MESHCODE_HOSTD_SPAWN=1): hostd only
1107
+ # spawns agents already 'running'; re-arming here would override a human
1108
+ # Stop clicked mid-spawn (the inverse race).
1109
+ if not dry_run and os.environ.get("MESHCODE_HOSTD_SPAWN") != "1":
1110
+ _ri = _declare_run_intent(agent, resolved_project)
1111
+ if _ri.get("ok"):
1112
+ if _ri.get("unparked"):
1113
+ print(f"[meshcode] Agent was OFF (desired_state="
1114
+ f"{_ri.get('prev_desired_state')}"
1115
+ + (f", stopped by {_ri.get('prev_stop_source')}" if _ri.get('prev_stop_source') else "")
1116
+ + ") — re-armed to running for this launch.", file=sys.stderr)
1117
+ if _ri.get("cleared_stale_recycle"):
1118
+ print("[meshcode] Cleared a stale pre-boot recycle flag "
1119
+ "(would have force-exited this session ~1min after boot).", file=sys.stderr)
1120
+ elif _ri.get("error_code") == "helper_not_manual":
1121
+ print(f"[meshcode] ERROR: {_ri.get('error')}", file=sys.stderr)
1122
+ return 2
1123
+ else:
1124
+ # Soft-fail, but tell the user the REAL error + the consequence
1125
+ # instead of a silent boot that dies seconds later.
1126
+ print(f"[meshcode] WARNING: could not declare run intent: "
1127
+ f"{_ri.get('error') or _ri}", file=sys.stderr)
1128
+ print("[meshcode] If this agent is marked stopped on the server, "
1129
+ "the host daemon may close this session within seconds. "
1130
+ "Use the dashboard Start button or retry.", file=sys.stderr)
1026
1131
  server_id = f"meshcode-{resolved_project}-{agent}"
1027
1132
 
1028
1133
  editor = editor_override or _detect_editor()
@@ -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.139
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.139"
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