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.
- {meshcode-2.11.137 → meshcode-2.11.138}/PKG-INFO +11 -2
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/__init__.py +1 -1
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/server.py +2 -1
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/swarm.py +80 -15
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/test_swarm.py +84 -17
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/run_agent.py +24 -3
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode.egg-info/PKG-INFO +11 -2
- {meshcode-2.11.137 → meshcode-2.11.138}/pyproject.toml +1 -1
- {meshcode-2.11.137 → meshcode-2.11.138}/README.md +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/__main__.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/_session_handoff_template.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/_stop_hook_template.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/ascii_art.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/atomic_push.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/claude_update.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/cli.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/comms_v4.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/compat.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/daemon.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/date_parse.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/doctor.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/error_hints.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/exceptions.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/helper_visuals.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/hooks/__init__.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/hooks/repo_path_lock.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/hostd.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/invites.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/launcher.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/launcher_install.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/__init__.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/__main__.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/backend.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/realtime.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/sleep_signals.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/test_backend.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/test_boot_timing.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/test_install_guard.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/test_prefs_claude_version.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/test_realtime.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/preferences.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/protocol_handler.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/protocol_v2.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/quickstart.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/rpc_allowlist.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/scripts/check_secrets.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/scripts/race_rate_harness.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/secrets.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/self_update.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/setup_clients.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/supervisor.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/up.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode/upload.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode.egg-info/SOURCES.txt +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode.egg-info/dependency_links.txt +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode.egg-info/entry_points.txt +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode.egg-info/requires.txt +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/meshcode.egg-info/top_level.txt +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/setup.cfg +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_auto_update_hardening.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_autonomous_closegap_1.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_autonomous_closegap_2.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_autonomous_closegap_3.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_autonomous_prompt_inject.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_boot_bug_regression.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_color_truecolor.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_core.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_cross_agent_messaging.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_date_parse.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_doctor.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_epistemic_v1_python_sdk.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_epistemic_v1_stop_conditions.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_esc_deaf_state.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_exceptions.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_file_upload.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_helper_visuals.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_hostd_zombie_sessions.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_init_device_code.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_install_guard.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_lease_sigterm_release.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_live_mesh_guard.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_mark_read_batch.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_marketplace_ratings.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_migration_integrity.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_pretrust_claude.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_realtime_event_freshness.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_rls_cross_tenant.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_rpc_grants.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_rpc_migrations.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_run_agent_dry_run.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_run_agent_no_server_import.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_security_regressions.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_self_update_user_site.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_sentinel.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_session_replay_gate.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_setup_path.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_sleep_signals.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_status_enum_coverage.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_stay_on_loop_hook.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_stop_ghost_terminal.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_swarm_events.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_task_progress.py +0 -0
- {meshcode-2.11.137 → meshcode-2.11.138}/tests/test_terminal_lifecycle.py +0 -0
- {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
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: meshcode
|
|
3
|
-
Version: 2.11.
|
|
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
|
|
|
@@ -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
|
|
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.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
|
59
|
-
"""
|
|
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
|
-
|
|
65
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
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"
|
|
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
|
-
|
|
208
|
-
|
|
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
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: meshcode
|
|
3
|
-
Version: 2.11.
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|