swarph-cli 0.20.0__tar.gz → 0.22.0__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 (148) hide show
  1. {swarph_cli-0.20.0/src/swarph_cli.egg-info → swarph_cli-0.22.0}/PKG-INFO +2 -1
  2. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/pyproject.toml +4 -1
  3. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/__init__.py +1 -1
  4. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/spawn.py +100 -8
  5. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/gateway/schema.sql +9 -1
  6. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/gateway/server.py +193 -12
  7. {swarph_cli-0.20.0 → swarph_cli-0.22.0/src/swarph_cli.egg-info}/PKG-INFO +2 -1
  8. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli.egg-info/SOURCES.txt +1 -0
  9. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli.egg-info/requires.txt +1 -0
  10. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_caller_meta_guard.py +1 -1
  11. swarph_cli-0.22.0/tests/test_meta_edge_identity.py +333 -0
  12. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_spawn_command.py +150 -4
  13. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/LICENSE +0 -0
  14. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/README.md +0 -0
  15. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/setup.cfg +0 -0
  16. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/caller.py +0 -0
  17. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/capture/__init__.py +0 -0
  18. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/capture/harden.py +0 -0
  19. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/capture/lineage.py +0 -0
  20. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/capture/liveness.py +0 -0
  21. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/capture/manifest.py +0 -0
  22. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/capture/paths.py +0 -0
  23. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/capture/verify.py +0 -0
  24. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/cell.py +0 -0
  25. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/__init__.py +0 -0
  26. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/_gateway_client.py +0 -0
  27. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/add.py +0 -0
  28. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/brain.py +0 -0
  29. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/brain_ask.py +0 -0
  30. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/cell.py +0 -0
  31. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/channel.py +0 -0
  32. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/chat.py +0 -0
  33. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/compress.py +0 -0
  34. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/daemon.py +0 -0
  35. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/gateway.py +0 -0
  36. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/highlight.py +0 -0
  37. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/hook_output.py +0 -0
  38. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/hooks.py +0 -0
  39. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/import_session.py +0 -0
  40. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/init.py +0 -0
  41. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/install_hook.py +0 -0
  42. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/install_multiplexer.py +0 -0
  43. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/lane.py +0 -0
  44. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/mcp_server.py +0 -0
  45. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/memory_sync.py +0 -0
  46. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/mesh.py +0 -0
  47. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/onboard.py +0 -0
  48. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/protocol_handler.py +0 -0
  49. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/ratify.py +0 -0
  50. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/schedule.py +0 -0
  51. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/security.py +0 -0
  52. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/service.py +0 -0
  53. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/watchdog.py +0 -0
  54. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/compress/__init__.py +0 -0
  55. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/compress/levers.py +0 -0
  56. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/compress/marker.py +0 -0
  57. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/compress/verify.py +0 -0
  58. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/gateway/__init__.py +0 -0
  59. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/gateway/feature_registry.py +0 -0
  60. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/gateway/lanes_control.py +0 -0
  61. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/gateway/services_control.py +0 -0
  62. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/main.py +0 -0
  63. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/multiplexer.py +0 -0
  64. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/parsers/__init__.py +0 -0
  65. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/parsers/claude.py +0 -0
  66. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/service/__init__.py +0 -0
  67. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/service/app.py +0 -0
  68. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/service/providers.py +0 -0
  69. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
  70. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
  71. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
  72. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
  73. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli.egg-info/entry_points.txt +0 -0
  74. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli.egg-info/top_level.txt +0 -0
  75. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_add_security_gate.py +0 -0
  76. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_artifact_add.py +0 -0
  77. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_artifact_hash.py +0 -0
  78. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_artifact_lib.py +0 -0
  79. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_artifact_mcp.py +0 -0
  80. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_artifact_skill.py +0 -0
  81. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_artifact_tool.py +0 -0
  82. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_artifact_uri.py +0 -0
  83. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_brain_ask_command.py +0 -0
  84. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_brain_command.py +0 -0
  85. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_capture_lineage.py +0 -0
  86. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_capture_liveness.py +0 -0
  87. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_capture_manifest.py +0 -0
  88. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_capture_paths.py +0 -0
  89. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_capture_security.py +0 -0
  90. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_cell_command_dispatch.py +0 -0
  91. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_cell_harden.py +0 -0
  92. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_cell_loader.py +0 -0
  93. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_cell_verify.py +0 -0
  94. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_channel_command.py +0 -0
  95. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_chat_command.py +0 -0
  96. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_claude_parser.py +0 -0
  97. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_claude_tmux_template.py +0 -0
  98. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_compress_caller_convention.py +0 -0
  99. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_compress_command.py +0 -0
  100. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_compress_levers.py +0 -0
  101. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_compress_marker.py +0 -0
  102. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_compress_verify.py +0 -0
  103. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_daemon_command.py +0 -0
  104. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_feature_to_uri.py +0 -0
  105. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_gateway_client.py +0 -0
  106. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_gateway_command.py +0 -0
  107. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_highlight_command.py +0 -0
  108. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_hook_output.py +0 -0
  109. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_hooks_add.py +0 -0
  110. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_hooks_bundle.py +0 -0
  111. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_hooks_init.py +0 -0
  112. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_hooks_lifecycle.py +0 -0
  113. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_hooks_merge.py +0 -0
  114. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_import_command.py +0 -0
  115. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_init_command.py +0 -0
  116. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_install_hook.py +0 -0
  117. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_install_multiplexer_command.py +0 -0
  118. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_lane_command.py +0 -0
  119. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_main.py +0 -0
  120. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_mcp_server.py +0 -0
  121. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_memory_sync.py +0 -0
  122. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_mesh_command.py +0 -0
  123. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_mesh_sidecar.py +0 -0
  124. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_multiplexer.py +0 -0
  125. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_onboard_command.py +0 -0
  126. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_protocol_handler.py +0 -0
  127. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_ratify_command.py +0 -0
  128. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_repl_caller_convention.py +0 -0
  129. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_schedule_command.py +0 -0
  130. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_security.py +0 -0
  131. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_service_app.py +0 -0
  132. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_service_command.py +0 -0
  133. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_service_providers.py +0 -0
  134. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_session_path_role_gate.py +0 -0
  135. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_smoke_chat.py +0 -0
  136. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_smoke_one_shot.py +0 -0
  137. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_smoke_phase_5_5.py +0 -0
  138. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_spawn_live_pin.py +0 -0
  139. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_spawn_mitosis_lineage.py +0 -0
  140. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_spawn_tmux_session.py +0 -0
  141. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_spawn_windows_relaunch.py +0 -0
  142. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_version_consistency.py +0 -0
  143. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_watchdog.py +0 -0
  144. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_watchdog_dm_wake.py +0 -0
  145. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_watchdog_dm_wake_cooldown.py +0 -0
  146. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_watchdog_dm_wake_wiring.py +0 -0
  147. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_watchdog_model_rung.py +0 -0
  148. {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_watchdog_stale_peers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.20.0
3
+ Version: 0.22.0
4
4
  Summary: The `swarph` binary — a multi-LLM CLI and mesh-gateway client + bundled server (`swarph gateway`): one-shot prompts across providers, interactive `chat`, multi-provider `spawn` (claude/codex/antigravity via a ProviderMembrane with subprocess billing-scrub), `mesh` send/inbox/register, `brain-ask` over the swarph-brain (gbrain) semantic memory, git-backed `assisted_memory`, session `import`, and a stranded-session `watchdog`.
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
@@ -36,6 +36,7 @@ Requires-Dist: fastapi>=0.115; extra == "gateway"
36
36
  Requires-Dist: uvicorn[standard]>=0.32; extra == "gateway"
37
37
  Requires-Dist: pydantic>=2.9; extra == "gateway"
38
38
  Requires-Dist: croniter>=6.2; extra == "gateway"
39
+ Requires-Dist: PyJWT[crypto]>=2.8; extra == "gateway"
39
40
  Provides-Extra: service
40
41
  Requires-Dist: fastapi>=0.115; extra == "service"
41
42
  Requires-Dist: uvicorn[standard]>=0.32; extra == "service"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "swarph-cli"
7
- version = "0.20.0"
7
+ version = "0.22.0"
8
8
  description = "The `swarph` binary — a multi-LLM CLI and mesh-gateway client + bundled server (`swarph gateway`): one-shot prompts across providers, interactive `chat`, multi-provider `spawn` (claude/codex/antigravity via a ProviderMembrane with subprocess billing-scrub), `mesh` send/inbox/register, `brain-ask` over the swarph-brain (gbrain) semantic memory, git-backed `assisted_memory`, session `import`, and a stranded-session `watchdog`."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -69,6 +69,9 @@ gateway = [
69
69
  "uvicorn[standard]>=0.32",
70
70
  "pydantic>=2.9",
71
71
  "croniter>=6.2",
72
+ # B1 Meta-Edge identity: verify RS256 SSO JWTs (the gateway TRUSTS, never
73
+ # signs). [crypto] pulls in `cryptography` for RSA signature verification.
74
+ "PyJWT[crypto]>=2.8",
72
75
  ]
73
76
  # `swarph service serve` stands up a $0 subscription-LLM HTTP lane. Same
74
77
  # FastAPI/uvicorn stack as the gateway, behind its own extra so the core
@@ -16,6 +16,6 @@ The architecture splits CLI from substrate so:
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- __version__ = "0.20.0"
19
+ __version__ = "0.22.0"
20
20
 
21
21
  __all__ = ["__version__"]
@@ -24,6 +24,7 @@ for users (alpha #891 D2).
24
24
  from __future__ import annotations
25
25
 
26
26
  import argparse
27
+ import json
27
28
  import os
28
29
  import shutil
29
30
  import subprocess
@@ -445,7 +446,13 @@ def _build_agy_argv(
445
446
  cell: Cell, no_starter: bool, passthrough: list[str]
446
447
  ) -> list[str]:
447
448
  argv = ["agy"]
448
-
449
+
450
+ # Resume THIS cwd's conversation. agy keys sessions internally by the full
451
+ # workspace path and starts FRESH gracefully when there's none (gemini-researcher
452
+ # verified live 2026-07-01), so --continue is unconditional — no guard, and NOT
453
+ # keyed on ~/.gemini/history/ (a retired gemini-cli path antigravity never writes).
454
+ argv.append("--continue")
455
+
449
456
  # codex is adding cell.sandbox; default ON, only off on explicit falsy
450
457
  sandbox_attr = getattr(cell, "sandbox", None)
451
458
  if sandbox_attr is not None:
@@ -460,22 +467,64 @@ def _build_agy_argv(
460
467
  argv.extend(["--add-dir", str(cell.cwd)])
461
468
 
462
469
  if not no_starter and cell.starter_prompt_path:
463
- argv.extend(["--prompt-interactive", read_starter_prompt(cell)])
464
-
470
+ starter = read_starter_prompt(cell)
471
+ if starter: # skip an empty starter file (matches claude/grok membranes)
472
+ argv.extend(["--prompt-interactive", starter])
473
+
465
474
  argv.extend(passthrough)
466
475
  return argv
467
476
 
468
477
 
478
+ def _newest_codex_session_for_cwd(cwd, sessions_root=None):
479
+ """Newest codex session_id recorded for this cwd (interactive, not codex_exec), or None.
480
+ Codex `--last` is global; swarph does the per-cwd selection. Never raises."""
481
+ import glob
482
+ root = Path(sessions_root) if sessions_root else (Path.home() / ".codex" / "sessions")
483
+ try:
484
+ files = sorted(glob.glob(str(root / "**" / "rollout-*.jsonl"), recursive=True))
485
+ except OSError:
486
+ return None
487
+ # codex records os.getcwd() (realpath-resolved) while cell.cwd may be a logical
488
+ # path — accept either so a symlinked cwd still matches (never a false fresh).
489
+ try:
490
+ targets = {str(cwd), str(Path(cwd).resolve())}
491
+ except OSError:
492
+ targets = {str(cwd)}
493
+ # sorted() is ascending (zero-padded YYYY/MM/DD + ISO-ts in the path), so walk
494
+ # from the NEWEST end and return the first cwd match — no need to read older
495
+ # files. isinstance guards keep "never raises" airtight for a non-dict first line.
496
+ for f in reversed(files):
497
+ try:
498
+ with open(f) as fh:
499
+ meta = json.loads(fh.readline())
500
+ except (OSError, ValueError):
501
+ continue
502
+ if not isinstance(meta, dict):
503
+ continue
504
+ pl = meta.get("payload", meta)
505
+ if not isinstance(pl, dict):
506
+ continue
507
+ if pl.get("cwd") in targets and pl.get("originator") != "codex_exec":
508
+ sid = pl.get("session_id") or pl.get("id")
509
+ if sid: # some older sessions record a null id — skip to the next-newest match
510
+ return sid
511
+ return None
512
+
513
+
469
514
  def _build_codex_argv(cell: Cell, passthrough: list[str]) -> list[str]:
470
- argv = [
471
- "codex",
515
+ sid = _newest_codex_session_for_cwd(cell.cwd)
516
+ if sid:
517
+ argv = ["codex", "resume", sid]
518
+ else:
519
+ argv = ["codex"]
520
+ argv.extend([
472
521
  "-C",
473
522
  str(cell.cwd),
474
523
  "-s",
475
524
  _codex_sandbox(cell),
476
525
  "-a",
477
526
  _CODEX_APPROVAL,
478
- ]
527
+ ])
479
528
  argv.extend(passthrough)
480
529
  return argv
481
530
 
@@ -569,6 +618,43 @@ def _grok_env(cell: Cell) -> dict[str, str]:
569
618
  return env
570
619
 
571
620
 
621
+ def _git_identity_env(cell: Cell) -> dict[str, str]:
622
+ """Per-cell git author/committer identity for the spawned session.
623
+
624
+ So a cell's commits are attributable to IT — the RACI ownership reconcile key
625
+ — instead of folding into a shared global ``git config user.name`` (which is
626
+ why co-located cells were previously indistinguishable in git history). Every
627
+ membrane merges this into the child env before exec, so it works uniformly
628
+ across claude/codex/antigravity/grok.
629
+
630
+ Identity resolution:
631
+ * default ``GIT_AUTHOR_NAME`` = the cell's MESH name (``cell.name`` — e.g.
632
+ ``lab-ovh``/``science-claude``, NOT the spawn ``role``), email
633
+ ``<name>@brainsurfing.tech``;
634
+ * an optional ``git_identity: {name, email}`` block in the cell.yaml (carried
635
+ through ``cell.extra``, so no schema change) overrides either field.
636
+
637
+ The cell identity intentionally WINS over any inherited ``GIT_AUTHOR_*`` (the
638
+ cell is the author), so the membranes ``.update()`` the env with this last.
639
+ """
640
+ name = cell.name
641
+ email = f"{cell.name}@brainsurfing.tech"
642
+ extra = getattr(cell, "extra", None)
643
+ if isinstance(extra, dict):
644
+ gid = extra.get("git_identity")
645
+ if isinstance(gid, dict):
646
+ if gid.get("name"):
647
+ name = str(gid["name"])
648
+ if gid.get("email"):
649
+ email = str(gid["email"])
650
+ return {
651
+ "GIT_AUTHOR_NAME": name,
652
+ "GIT_AUTHOR_EMAIL": email,
653
+ "GIT_COMMITTER_NAME": name,
654
+ "GIT_COMMITTER_EMAIL": email,
655
+ }
656
+
657
+
572
658
  def _link_grok_auth(link: Path) -> None:
573
659
  """Symlink the operator's ``~/.grok/auth.json`` to ``link`` for $0 OIDC.
574
660
 
@@ -1242,6 +1328,7 @@ class ClaudeMembrane(ProviderMembrane):
1242
1328
  # was already injected via --append-system-prompt, so it skips double-
1243
1329
  # injection. The env carries to the child either way.
1244
1330
  env = _claude_env()
1331
+ env.update(_git_identity_env(cell)) # per-cell git author (RACI attribution)
1245
1332
 
1246
1333
  # Per-OS launch mechanism — the SAME split as the tmux attach, for the
1247
1334
  # SAME reason:
@@ -1294,8 +1381,10 @@ class CodexMembrane(ProviderMembrane):
1294
1381
  )
1295
1382
 
1296
1383
  def launch(self, cell: Cell, binary: str, argv: list[str]) -> int:
1384
+ env = _scrubbed_codex_env()
1385
+ env.update(_git_identity_env(cell)) # per-cell git author (RACI attribution)
1297
1386
  try:
1298
- os.execve(binary, argv, _scrubbed_codex_env())
1387
+ os.execve(binary, argv, env)
1299
1388
  except OSError as exc:
1300
1389
  print(f"swarph spawn: exec failed: {exc}", file=sys.stderr)
1301
1390
  return 1
@@ -1333,8 +1422,10 @@ class AntigravityMembrane(ProviderMembrane):
1333
1422
  def launch(self, cell: Cell, binary: str, argv: list[str]) -> int:
1334
1423
  # execve carries exactly the scrubbed env to the child without mutating
1335
1424
  # this process's os.environ first (so a failed exec leaves us intact).
1425
+ env = _agy_env()
1426
+ env.update(_git_identity_env(cell)) # per-cell git author (RACI attribution)
1336
1427
  try:
1337
- os.execve(binary, argv, _agy_env())
1428
+ os.execve(binary, argv, env)
1338
1429
  except OSError as exc:
1339
1430
  print(f"swarph spawn: exec failed: {exc}", file=sys.stderr)
1340
1431
  return 1
@@ -1413,6 +1504,7 @@ class GrokMembrane(ProviderMembrane):
1413
1504
  # Isolated-HOME + billing-scrubbed env carried to grok without mutating
1414
1505
  # this process's os.environ first (a failed exec leaves us intact).
1415
1506
  env = _grok_env(cell)
1507
+ env.update(_git_identity_env(cell)) # per-cell git author (RACI attribution)
1416
1508
  # Per-OS launch — the SAME split as claude.launch (v0.12.1 fix): on
1417
1509
  # Windows os.exec* is emulated as spawn-and-exit (not a real replace),
1418
1510
  # which collapses the tmux pane (its root command exits, orphaning grok);
@@ -20,7 +20,15 @@ CREATE TABLE IF NOT EXISTS claude_peers (
20
20
  ratified INTEGER NOT NULL DEFAULT 0, -- Phase 5.5: server-side §15 contract gate
21
21
  ratified_at TIMESTAMP,
22
22
  ratified_by TEXT, -- canonical witness peer name
23
- ratification_reason TEXT
23
+ ratification_reason TEXT,
24
+ -- B2 login-scoped registry (META_EDGE_IDENTITY_CONTRACT.md): the Meta-Edge
25
+ -- canonical user id (`sub`) that OWNS this cell — the "node-in-your-tailnet"
26
+ -- relationship. NULLABLE + additive: lab peers (shared/per-peer token
27
+ -- registration) land owner=NULL and stay globally visible; only cells
28
+ -- registered via a Meta-Edge identity JWT or cell-join key carry an owner,
29
+ -- which scopes them to that user on GET /peers. Existing DBs get this column
30
+ -- via the idempotent ALTER in server.py:_init_db (existing peers → NULL).
31
+ owner TEXT
24
32
  );
25
33
 
26
34
  -- ─── THREADS — UUID ↔ readable thread name mapping ─────────────────
@@ -47,6 +47,7 @@ from datetime import datetime, timedelta, timezone
47
47
  from pathlib import Path
48
48
  from typing import Any, NamedTuple, Optional
49
49
 
50
+ import jwt # PyJWT — B1 Meta-Edge RS256 identity-token verification (gateway extra)
50
51
  from fastapi import FastAPI, Header, HTTPException, Query, Request
51
52
  from fastapi.middleware.cors import CORSMiddleware
52
53
  from fastapi.responses import JSONResponse, Response
@@ -70,6 +71,14 @@ SCHEMA_PATH = os.environ.get(
70
71
  )
71
72
  PORT = int(os.environ.get("PORT", "8788"))
72
73
 
74
+ # B1 Meta-Edge identity (META_EDGE_IDENTITY_CONTRACT.md). Meta-Edge SSO ISSUES
75
+ # RS256 JWTs; the gateway only ever TRUSTS them with Meta-Edge's PUBLIC key (it
76
+ # can never forge one — the identity↔relay asymmetry). All three are read at
77
+ # CALL time (via os.environ, see _meta_edge_public_key) NOT cached at import, so
78
+ # the key can be rotated without a restart and monkeypatched directly in tests.
79
+ META_EDGE_ISSUER_DEFAULT = "meta-edge"
80
+ META_EDGE_AUDIENCE_DEFAULT = "swarph-gateway"
81
+
73
82
  VALID_KINDS = {"status", "question", "answer", "unblock", "fyi"}
74
83
  VALID_COUNCIL_ROLES = {
75
84
  "r1_defender", # Claude side (claude -p)
@@ -228,6 +237,25 @@ def _init_db() -> None:
228
237
  else:
229
238
  _grandfather_pending = False
230
239
 
240
+ # B2 migration (Meta-Edge login-scoped registry,
241
+ # META_EDGE_IDENTITY_CONTRACT.md). Add the nullable `owner` column to
242
+ # claude_peers for EXISTING DBs BEFORE schema.sql executescript (same
243
+ # probe+ALTER pattern as the ratification ALTER above). Additive and
244
+ # NULLABLE — existing/lab peers land owner=NULL and stay globally
245
+ # visible; only Meta-Edge-registered cells carry an owner. On a fresh
246
+ # install claude_peers doesn't exist yet — the OperationalError is
247
+ # caught and skipped (schema.sql's CREATE includes `owner` directly).
248
+ try:
249
+ c.execute("SELECT owner FROM claude_peers LIMIT 1")
250
+ except sqlite3.OperationalError as e:
251
+ if "no such column: owner" in str(e):
252
+ log.info("migrating DB: adding owner column to claude_peers (B2 login-scoped registry)")
253
+ c.execute("ALTER TABLE claude_peers ADD COLUMN owner TEXT")
254
+ # else: "no such table" = fresh install → schema.sql creates it with
255
+ # the column; any other OperationalError is a real fault → re-raise.
256
+ elif "no such table: claude_peers" not in str(e):
257
+ raise
258
+
231
259
  # v9 migration (R1 C3-A — trust-epoch stamp). Add binding_regime to
232
260
  # peer_ratifications BEFORE schema.sql executescript, mirroring the v3
233
261
  # ratification ALTER above. SQLite cannot add a CHECK constraint via
@@ -595,6 +623,79 @@ def _row_to_dict(row: Optional[sqlite3.Row]) -> Optional[dict]:
595
623
  # AUTH
596
624
  # =====================================================================
597
625
 
626
+ def _meta_edge_public_key() -> Optional[str]:
627
+ """Return the configured Meta-Edge RS256 PUBLIC key PEM, or None if unset.
628
+
629
+ Read at CALL time (not cached at import) so the key can be rotated without a
630
+ restart and monkeypatched directly in tests. ``META_EDGE_PUBLIC_KEY`` (inline
631
+ PEM) takes precedence over ``META_EDGE_PUBLIC_KEY_FILE`` (path). A None return
632
+ means Meta-Edge verification is simply NOT enabled (the gateway runs in
633
+ lab-only mode); an unreadable key FILE also returns None (and logs) rather
634
+ than raising — the JWT branch is only entered when this returns a key.
635
+ """
636
+ pem = os.environ.get("META_EDGE_PUBLIC_KEY", "").strip()
637
+ if pem:
638
+ return pem
639
+ path = os.environ.get("META_EDGE_PUBLIC_KEY_FILE", "").strip()
640
+ if path:
641
+ try:
642
+ return Path(path).read_text().strip() or None
643
+ except OSError as e:
644
+ log.warning("META_EDGE_PUBLIC_KEY_FILE unreadable (%s); Meta-Edge auth disabled", e)
645
+ return None
646
+ return None
647
+
648
+
649
+ def _looks_like_jwt(token: str) -> bool:
650
+ """A compact JWS/JWT is three non-empty '.'-separated base64url segments."""
651
+ parts = token.split(".")
652
+ return len(parts) == 3 and all(parts)
653
+
654
+
655
+ def _verify_meta_edge_token(token: str) -> Optional[dict]:
656
+ """Verify a Meta-Edge RS256 identity/join JWT — FAIL-CLOSED (B1 security core).
657
+
658
+ Returns the verified claims dict on success, or None on ANY problem (the
659
+ gateway TRUSTS Meta-Edge's tokens but never partial-trusts). Returns None when
660
+ no Meta-Edge public key is configured (verification not enabled).
661
+
662
+ The algorithm is PINNED to RS256 explicitly (``algorithms=["RS256"]``) — this
663
+ single rule blocks BOTH ``alg:none`` (unsigned forgeries) AND the HS256
664
+ algorithm-confusion attack (a token HMAC-signed with the RSA *public* key
665
+ bytes as the shared secret). The token header NEVER picks the algorithm.
666
+ iss/aud/exp and the presence of exp/iss/aud/sub are all required and verified;
667
+ a 30s leeway absorbs clock skew only.
668
+ """
669
+ pem = _meta_edge_public_key()
670
+ if not pem:
671
+ return None
672
+ issuer = os.environ.get("META_EDGE_ISSUER", META_EDGE_ISSUER_DEFAULT)
673
+ audience = os.environ.get("META_EDGE_AUDIENCE", META_EDGE_AUDIENCE_DEFAULT)
674
+ try:
675
+ return jwt.decode(
676
+ token,
677
+ pem,
678
+ algorithms=["RS256"],
679
+ audience=audience,
680
+ issuer=issuer,
681
+ leeway=30,
682
+ options={
683
+ "require": ["exp", "iss", "aud", "sub"],
684
+ "verify_signature": True,
685
+ "verify_aud": True,
686
+ "verify_iss": True,
687
+ "verify_exp": True,
688
+ },
689
+ )
690
+ except Exception as e:
691
+ # Fail-closed on EVERYTHING: bad signature, alg mismatch (none/HS256),
692
+ # expired, wrong iss/aud, missing required claim, malformed token, or any
693
+ # unexpected error. A security verifier denies on doubt and never leaks
694
+ # which check failed to the caller (only logs server-side).
695
+ log.warning("Meta-Edge token rejected: %s: %s", type(e).__name__, e)
696
+ return None
697
+
698
+
598
699
  class AuthContext(NamedTuple):
599
700
  """Resolved identity of an authenticated caller (R1 C1).
600
701
 
@@ -614,10 +715,29 @@ class AuthContext(NamedTuple):
614
715
  C3-B (peer_ratify) and C2 (caller-binding) read it. Live behavior is
615
716
  unchanged: shared tokens authenticate exactly as before; per-peer resolution
616
717
  is additive and nothing enforces caller==peer yet.
718
+
719
+ B1/B2 (META_EDGE_IDENTITY_CONTRACT.md) adds a THIRD principal kind alongside
720
+ the two lab regimes, discriminated by ``kind`` (None for the lab regimes):
721
+ - kind='user_identity' : a verified Meta-Edge SSO login. user=<sub> (the
722
+ canonical Meta-Edge user id / owner key),
723
+ provider/login informational. Scopes GET /peers to
724
+ owned cells; stamps owner=user on register.
725
+ - kind='cell_join' : a verified Meta-Edge join-key (the `tailscale up
726
+ --authkey` analog). owner=<owner claim>; a
727
+ one-purpose registration credential — stamps
728
+ owner on register, grants nothing else.
729
+ All NamedTuple fields carry defaults so the two existing keyword call sites
730
+ (shared_token / per_peer_token) are untouched and the identity branches
731
+ construct with only their own fields.
617
732
  """
618
- peer: Optional[str]
619
- regime: str
620
- key_generation: Optional[int]
733
+ peer: Optional[str] = None
734
+ regime: str = "shared_token"
735
+ key_generation: Optional[int] = None
736
+ kind: Optional[str] = None
737
+ user: Optional[str] = None
738
+ provider: Optional[str] = None
739
+ login: Optional[str] = None
740
+ owner: Optional[str] = None
621
741
 
622
742
 
623
743
  def _is_revoked(c, peer, key_generation) -> bool:
@@ -647,6 +767,40 @@ def _authorize(authorization: Optional[str]) -> AuthContext:
647
767
  if not authorization or not authorization.startswith("Bearer "):
648
768
  raise HTTPException(401, "missing bearer token")
649
769
  token = authorization.removeprefix("Bearer ").strip()
770
+ # 0. Meta-Edge identity branch (B1/B2) — FIRST, and FAIL-CLOSED with NO
771
+ # fall-through. If the bearer is JWT-shaped AND a Meta-Edge public key is
772
+ # configured, it MUST be a valid Meta-Edge token: verification failure
773
+ # raises 401 here and is NEVER re-interpreted against the shared/peer-token
774
+ # paths (re-interpreting a forged/expired JWT would be a downgrade attack).
775
+ # Non-JWT bearers (the lab's opaque tokens) skip this branch untouched.
776
+ if _looks_like_jwt(token) and _meta_edge_public_key():
777
+ claims = _verify_meta_edge_token(token)
778
+ if claims is None:
779
+ raise HTTPException(401, "invalid Meta-Edge token")
780
+ purpose = claims.get("purpose")
781
+ if purpose in (None, "identity"):
782
+ # Verified SSO login → read + owner-scope. sub is the canonical
783
+ # Meta-Edge user id (the owner key); provider/login are informational.
784
+ return AuthContext(
785
+ kind="user_identity",
786
+ user=claims.get("sub"),
787
+ provider=claims.get("provider"),
788
+ login=claims.get("login"),
789
+ )
790
+ if purpose == "cell-join":
791
+ # A one-purpose join-key (tailscale up --authkey analog). It MUST
792
+ # carry the owner it registers the cell under — a join-key without an
793
+ # owner claim is fail-closed (it would otherwise register an
794
+ # unowned/lab-visible cell, defeating the scoping it exists for).
795
+ owner = claims.get("owner")
796
+ if not owner:
797
+ log.warning("Meta-Edge cell-join token missing owner claim; rejected")
798
+ raise HTTPException(401, "invalid Meta-Edge token")
799
+ return AuthContext(kind="cell_join", owner=owner)
800
+ # A validly-signed token with an UNRECOGNIZED purpose is fail-closed —
801
+ # never default an unknown purpose into the privileged identity path.
802
+ log.warning("Meta-Edge token with unrecognized purpose=%r rejected", purpose)
803
+ raise HTTPException(401, "invalid Meta-Edge token")
650
804
  # 1. Shared-token branch FIRST — verbatim from pre-C1, constant-time
651
805
  # compare_digest preserved, and resolved before any DB hit so the common
652
806
  # path takes no extra latency. Shared tokens carry no peer identity.
@@ -875,7 +1029,19 @@ async def peers_register(req: PeerRegisterRequest,
875
1029
  Called from each peer's systemd ExecStartPost or on-demand. Upserts on
876
1030
  name. capabilities dict is the JSON snapshot from claude-service /health.
877
1031
  """
878
- _authorize(authorization)
1032
+ ctx = _authorize(authorization)
1033
+ # B2 owner-stamping (META_EDGE_IDENTITY_CONTRACT.md §Cell-join). A Meta-Edge
1034
+ # principal ties the registered cell to its owner (the node-in-your-tailnet
1035
+ # relationship); lab principals (shared/commander/per-peer token) leave owner
1036
+ # NULL — unchanged behavior:
1037
+ # - cell_join → owner = the join-key's owner claim (headless join).
1038
+ # - user_identity → owner = the verified user `sub` (interactive join: the
1039
+ # user's app registers the cell on their behalf).
1040
+ owner: Optional[str] = None
1041
+ if ctx.kind == "cell_join":
1042
+ owner = ctx.owner
1043
+ elif ctx.kind == "user_identity":
1044
+ owner = ctx.user
879
1045
  now = _utcnow_iso()
880
1046
  # R1 C1: mint-once per-peer token. Set only when a fresh token is minted
881
1047
  # on this call; a re-register of a peer whose LIVE token still exists returns
@@ -899,13 +1065,17 @@ async def peers_register(req: PeerRegisterRequest,
899
1065
  try:
900
1066
  c.execute(
901
1067
  """INSERT INTO claude_peers
902
- (name, url, capabilities, registered_at, last_seen, ratified)
903
- VALUES (?, ?, ?, ?, ?, 0)
1068
+ (name, url, capabilities, registered_at, last_seen, ratified, owner)
1069
+ VALUES (?, ?, ?, ?, ?, 0, ?)
904
1070
  ON CONFLICT(name) DO UPDATE SET
905
1071
  url = excluded.url,
906
1072
  capabilities = excluded.capabilities,
907
- last_seen = excluded.last_seen""",
908
- (req.name, req.url, json.dumps(req.capabilities), now, now),
1073
+ last_seen = excluded.last_seen,
1074
+ -- B2: a Meta-Edge register stamps/refreshes owner; a lab
1075
+ -- (owner-NULL) re-register PRESERVES an existing owner via
1076
+ -- COALESCE rather than clobbering it back to NULL.
1077
+ owner = COALESCE(excluded.owner, claude_peers.owner)""",
1078
+ (req.name, req.url, json.dumps(req.capabilities), now, now, owner),
909
1079
  )
910
1080
  # R1 C4: mint-once becomes mint-or-rotate-past-a-revocation.
911
1081
  # - NO token row → C1's gen=1 mint (first onboarding).
@@ -980,14 +1150,25 @@ async def peers_register(req: PeerRegisterRequest,
980
1150
  @app.get("/peers")
981
1151
  async def peers_list(authorization: Optional[str] = Header(None),
982
1152
  enabled_only: bool = Query(True)) -> dict:
983
- _authorize(authorization)
984
- where = "WHERE enabled = 1" if enabled_only else ""
1153
+ ctx = _authorize(authorization)
1154
+ clauses: list[str] = []
1155
+ params: list = []
1156
+ if enabled_only:
1157
+ clauses.append("enabled = 1")
1158
+ # B2 login-scoping: a Meta-Edge user_identity sees ONLY its OWN cells
1159
+ # (owner == sub). Every other principal (shared/commander/per-peer token —
1160
+ # the lab paths) is UNCHANGED and still sees all peers.
1161
+ if ctx.kind == "user_identity":
1162
+ clauses.append("owner = ?")
1163
+ params.append(ctx.user)
1164
+ where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
985
1165
  with _conn() as c:
986
1166
  rows = c.execute(
987
1167
  f"SELECT name, url, capabilities, registered_at, last_health, "
988
1168
  f"last_seen, enabled, ratified, ratified_at, ratified_by, "
989
- f"ratification_reason "
990
- f"FROM claude_peers {where} ORDER BY name"
1169
+ f"ratification_reason, owner "
1170
+ f"FROM claude_peers {where} ORDER BY name",
1171
+ params,
991
1172
  ).fetchall()
992
1173
  return {
993
1174
  "peers": [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.20.0
3
+ Version: 0.22.0
4
4
  Summary: The `swarph` binary — a multi-LLM CLI and mesh-gateway client + bundled server (`swarph gateway`): one-shot prompts across providers, interactive `chat`, multi-provider `spawn` (claude/codex/antigravity via a ProviderMembrane with subprocess billing-scrub), `mesh` send/inbox/register, `brain-ask` over the swarph-brain (gbrain) semantic memory, git-backed `assisted_memory`, session `import`, and a stranded-session `watchdog`.
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
@@ -36,6 +36,7 @@ Requires-Dist: fastapi>=0.115; extra == "gateway"
36
36
  Requires-Dist: uvicorn[standard]>=0.32; extra == "gateway"
37
37
  Requires-Dist: pydantic>=2.9; extra == "gateway"
38
38
  Requires-Dist: croniter>=6.2; extra == "gateway"
39
+ Requires-Dist: PyJWT[crypto]>=2.8; extra == "gateway"
39
40
  Provides-Extra: service
40
41
  Requires-Dist: fastapi>=0.115; extra == "service"
41
42
  Requires-Dist: uvicorn[standard]>=0.32; extra == "service"
@@ -117,6 +117,7 @@ tests/test_mcp_server.py
117
117
  tests/test_memory_sync.py
118
118
  tests/test_mesh_command.py
119
119
  tests/test_mesh_sidecar.py
120
+ tests/test_meta_edge_identity.py
120
121
  tests/test_multiplexer.py
121
122
  tests/test_onboard_command.py
122
123
  tests/test_protocol_handler.py
@@ -10,6 +10,7 @@ fastapi>=0.115
10
10
  uvicorn[standard]>=0.32
11
11
  pydantic>=2.9
12
12
  croniter>=6.2
13
+ PyJWT[crypto]>=2.8
13
14
 
14
15
  [mcp]
15
16
  mcp>=1.0
@@ -119,7 +119,7 @@ def test_no_modules_skipped_by_the_walk():
119
119
  # so it is always walked.)
120
120
  if (mod.name.startswith(("swarph_cli.gateway", "swarph_cli.service.app"))
121
121
  and isinstance(e, ModuleNotFoundError)
122
- and getattr(e, "name", None) in ("fastapi", "uvicorn", "pydantic")):
122
+ and getattr(e, "name", None) in ("fastapi", "uvicorn", "pydantic", "jwt")):
123
123
  continue
124
124
  skipped.append((mod.name, repr(e)))
125
125
  assert not skipped, (