swarph-cli 0.24.0__tar.gz → 0.26.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 (156) hide show
  1. {swarph_cli-0.24.0/src/swarph_cli.egg-info → swarph_cli-0.26.0}/PKG-INFO +1 -1
  2. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/pyproject.toml +1 -1
  3. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/__init__.py +1 -1
  4. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/brain_ask.py +42 -13
  5. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/watchdog.py +69 -27
  6. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/gateway/server.py +61 -0
  7. {swarph_cli-0.24.0 → swarph_cli-0.26.0/src/swarph_cli.egg-info}/PKG-INFO +1 -1
  8. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli.egg-info/SOURCES.txt +1 -0
  9. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_brain_ask_command.py +59 -0
  10. swarph_cli-0.26.0/tests/test_gateway_brain.py +104 -0
  11. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_watchdog.py +119 -6
  12. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_watchdog_model_rung.py +1 -1
  13. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/LICENSE +0 -0
  14. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/README.md +0 -0
  15. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/setup.cfg +0 -0
  16. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/caller.py +0 -0
  17. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/capture/__init__.py +0 -0
  18. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/capture/harden.py +0 -0
  19. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/capture/lineage.py +0 -0
  20. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/capture/liveness.py +0 -0
  21. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/capture/manifest.py +0 -0
  22. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/capture/paths.py +0 -0
  23. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/capture/verify.py +0 -0
  24. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/cell.py +0 -0
  25. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/chain_token.py +0 -0
  26. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/__init__.py +0 -0
  27. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/_gateway_client.py +0 -0
  28. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/add.py +0 -0
  29. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/brain.py +0 -0
  30. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/cell.py +0 -0
  31. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/channel.py +0 -0
  32. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/chat.py +0 -0
  33. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/codegraph.py +0 -0
  34. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/compress.py +0 -0
  35. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/daemon.py +0 -0
  36. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/event.py +0 -0
  37. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/gateway.py +0 -0
  38. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/highlight.py +0 -0
  39. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/hook_output.py +0 -0
  40. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/hooks.py +0 -0
  41. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/import_session.py +0 -0
  42. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/init.py +0 -0
  43. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/install_hook.py +0 -0
  44. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/install_multiplexer.py +0 -0
  45. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/lane.py +0 -0
  46. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/mcp_server.py +0 -0
  47. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/memory_sync.py +0 -0
  48. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/mesh.py +0 -0
  49. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/onboard.py +0 -0
  50. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/protocol_handler.py +0 -0
  51. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/ratify.py +0 -0
  52. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/schedule.py +0 -0
  53. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/security.py +0 -0
  54. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/service.py +0 -0
  55. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/spawn.py +0 -0
  56. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/compress/__init__.py +0 -0
  57. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/compress/levers.py +0 -0
  58. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/compress/marker.py +0 -0
  59. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/compress/verify.py +0 -0
  60. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/gateway/__init__.py +0 -0
  61. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/gateway/feature_registry.py +0 -0
  62. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/gateway/lanes_control.py +0 -0
  63. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/gateway/schema.sql +0 -0
  64. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/gateway/services_control.py +0 -0
  65. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/main.py +0 -0
  66. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/multiplexer.py +0 -0
  67. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/parsers/__init__.py +0 -0
  68. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/parsers/claude.py +0 -0
  69. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/service/__init__.py +0 -0
  70. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/service/app.py +0 -0
  71. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/service/providers.py +0 -0
  72. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
  73. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
  74. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
  75. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
  76. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli.egg-info/entry_points.txt +0 -0
  77. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli.egg-info/requires.txt +0 -0
  78. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli.egg-info/top_level.txt +0 -0
  79. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_add_security_gate.py +0 -0
  80. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_artifact_add.py +0 -0
  81. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_artifact_hash.py +0 -0
  82. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_artifact_lib.py +0 -0
  83. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_artifact_mcp.py +0 -0
  84. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_artifact_skill.py +0 -0
  85. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_artifact_tool.py +0 -0
  86. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_artifact_uri.py +0 -0
  87. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_brain_command.py +0 -0
  88. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_caller_meta_guard.py +0 -0
  89. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_capture_lineage.py +0 -0
  90. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_capture_liveness.py +0 -0
  91. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_capture_manifest.py +0 -0
  92. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_capture_paths.py +0 -0
  93. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_capture_security.py +0 -0
  94. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_cell_command_dispatch.py +0 -0
  95. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_cell_harden.py +0 -0
  96. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_cell_loader.py +0 -0
  97. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_cell_verify.py +0 -0
  98. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_chain_token.py +0 -0
  99. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_channel_command.py +0 -0
  100. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_chat_command.py +0 -0
  101. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_claude_parser.py +0 -0
  102. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_claude_tmux_template.py +0 -0
  103. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_codegraph_command.py +0 -0
  104. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_codegraph_mcp.py +0 -0
  105. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_compress_caller_convention.py +0 -0
  106. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_compress_command.py +0 -0
  107. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_compress_levers.py +0 -0
  108. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_compress_marker.py +0 -0
  109. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_compress_verify.py +0 -0
  110. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_daemon_command.py +0 -0
  111. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_event_emit.py +0 -0
  112. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_feature_to_uri.py +0 -0
  113. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_gateway_client.py +0 -0
  114. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_gateway_command.py +0 -0
  115. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_highlight_command.py +0 -0
  116. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_hook_output.py +0 -0
  117. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_hooks_add.py +0 -0
  118. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_hooks_bundle.py +0 -0
  119. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_hooks_init.py +0 -0
  120. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_hooks_lifecycle.py +0 -0
  121. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_hooks_merge.py +0 -0
  122. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_import_command.py +0 -0
  123. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_init_command.py +0 -0
  124. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_install_hook.py +0 -0
  125. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_install_multiplexer_command.py +0 -0
  126. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_lane_command.py +0 -0
  127. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_main.py +0 -0
  128. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_mcp_server.py +0 -0
  129. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_memory_sync.py +0 -0
  130. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_mesh_command.py +0 -0
  131. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_mesh_sidecar.py +0 -0
  132. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_meta_edge_identity.py +0 -0
  133. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_multiplexer.py +0 -0
  134. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_onboard_command.py +0 -0
  135. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_protocol_handler.py +0 -0
  136. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_ratify_command.py +0 -0
  137. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_repl_caller_convention.py +0 -0
  138. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_schedule_command.py +0 -0
  139. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_security.py +0 -0
  140. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_service_app.py +0 -0
  141. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_service_command.py +0 -0
  142. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_service_providers.py +0 -0
  143. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_session_path_role_gate.py +0 -0
  144. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_smoke_chat.py +0 -0
  145. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_smoke_one_shot.py +0 -0
  146. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_smoke_phase_5_5.py +0 -0
  147. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_spawn_command.py +0 -0
  148. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_spawn_live_pin.py +0 -0
  149. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_spawn_mitosis_lineage.py +0 -0
  150. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_spawn_tmux_session.py +0 -0
  151. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_spawn_windows_relaunch.py +0 -0
  152. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_version_consistency.py +0 -0
  153. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_watchdog_dm_wake.py +0 -0
  154. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_watchdog_dm_wake_cooldown.py +0 -0
  155. {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_watchdog_dm_wake_wiring.py +0 -0
  156. {swarph_cli-0.24.0 → swarph_cli-0.26.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.24.0
3
+ Version: 0.26.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "swarph-cli"
7
- version = "0.24.0"
7
+ version = "0.26.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" }
@@ -16,6 +16,6 @@ The architecture splits CLI from substrate so:
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- __version__ = "0.24.0"
19
+ __version__ = "0.26.0"
20
20
 
21
21
  __all__ = ["__version__"]
@@ -15,6 +15,9 @@ Stdlib-only. Config from the environment, mirroring ``swarph mesh``'s token mode
15
15
  / SWARPH_BRAIN_TOKEN mesh per-peer token (~/.config/swarph/<self>.peer_token).
16
16
  / peer-token file Once gbrain accepts mesh peer tokens, the peer token IS
17
17
  the read token — no separate secret to provision.
18
+ SWARPH_BRAIN_GATEWAY when set, query the brain via the mesh gateway's
19
+ /brain/query proxy using the cell's mesh peer token
20
+ (no per-cell gbrain_ token). Unset = direct :8792.
18
21
  SWARPH_FACADE optional synthesis endpoint (claude-service chat-completions)
19
22
  SWARPH_FACADE_TOKEN bearer for the facade
20
23
  """
@@ -127,6 +130,15 @@ def _mcp_query(url: str, token: str, question: str, limit: int) -> list:
127
130
  return _parse_query_response(raw)
128
131
 
129
132
 
133
+ def _gateway_query(gw_base: str, peer_token: str, question: str, limit: int) -> list:
134
+ """Query the brain via the mesh gateway's /brain/query proxy, authenticating
135
+ with the cell's MESH peer token. The gateway holds the gbrain token; we never do."""
136
+ url = gw_base.rstrip("/") + "/brain/query"
137
+ raw = _http_post(url, {"query": question, "limit": limit}, peer_token,
138
+ accept="application/json")
139
+ return json.loads(raw).get("chunks", [])
140
+
141
+
130
142
  def _synthesize(facade_url: str, facade_token: str, question: str, chunks: list) -> str:
131
143
  """Ask the $0 facade to answer ONLY from the retrieved chunks, citing slugs."""
132
144
  context = _format_chunks(chunks)
@@ -158,19 +170,36 @@ def run_brain_ask(argv: list) -> int:
158
170
  args = parser.parse_args(argv)
159
171
  question = " ".join(args.question)
160
172
 
161
- token = _resolve_token(args.token_file, _self_name())
162
- if not token:
163
- sys.stderr.write(
164
- "swarph brain-ask: no gbrain read token "
165
- "(set GBRAIN_TOKEN / SWARPH_BRAIN_TOKEN, pass --token-file, or place a "
166
- "mesh peer token at ~/.config/swarph/<self>.peer_token)\n")
167
- return 2
168
-
169
- try:
170
- chunks = _mcp_query(args.gateway, token, question, args.limit)
171
- except Exception as exc: # noqa: BLE001 — surface any transport/parse failure cleanly
172
- sys.stderr.write(f"swarph brain-ask: gbrain query failed: {exc}\n")
173
- return 1
173
+ gw = os.environ.get("SWARPH_BRAIN_GATEWAY")
174
+ if gw:
175
+ self_name = _self_name()
176
+ try:
177
+ peer_token = _peer_token_path(self_name).read_text(encoding="utf-8").strip()
178
+ except OSError:
179
+ peer_token = ""
180
+ if not peer_token:
181
+ sys.stderr.write(
182
+ f"swarph brain-ask: SWARPH_BRAIN_GATEWAY set but no mesh peer token at "
183
+ f"~/.config/swarph/{self_name}.peer_token\n")
184
+ return 2
185
+ try:
186
+ chunks = _gateway_query(gw, peer_token, question, args.limit)
187
+ except Exception as e: # noqa: BLE001 — surface, don't swallow
188
+ sys.stderr.write(f"swarph brain-ask: gateway brain query failed: {e}\n")
189
+ return 1
190
+ else:
191
+ token = _resolve_token(args.token_file, _self_name())
192
+ if not token:
193
+ sys.stderr.write(
194
+ "swarph brain-ask: no gbrain read token "
195
+ "(set GBRAIN_TOKEN / SWARPH_BRAIN_TOKEN, pass --token-file, or place a "
196
+ "mesh peer token at ~/.config/swarph/<self>.peer_token)\n")
197
+ return 2
198
+ try:
199
+ chunks = _mcp_query(args.gateway, token, question, args.limit)
200
+ except Exception as exc: # noqa: BLE001 — surface any transport/parse failure cleanly
201
+ sys.stderr.write(f"swarph brain-ask: gbrain query failed: {exc}\n")
202
+ return 1
174
203
 
175
204
  facade = os.environ.get("SWARPH_FACADE")
176
205
  if args.no_synth or not facade:
@@ -580,14 +580,15 @@ def _pid_under(pid: int, ancestors: set, _max_depth: int = 40) -> bool:
580
580
  return cur in ancestors
581
581
 
582
582
 
583
- def _process_alive(tmux_session: str) -> bool:
584
- """Detect if a claude process is running INSIDE the named tmux session.
585
-
586
- Scopes to the session's pane PIDs (and their descendants) rather than a
587
- host-wide ``pgrep claude``: on a multi-session host, an unrelated cell's
588
- claude would otherwise mask THIS session's death and suppress the A2 alert
589
- (adversarial-sweep MED). Best-effort; falls back to True (assume alive) on
590
- detection error so a broken detector never false-fires A2.
583
+ def _process_alive(tmux_session: str, process_name: str = "claude") -> bool:
584
+ """Detect if a `process_name` process is running INSIDE the named tmux session.
585
+
586
+ `process_name` (default "claude") is the command the cell's agent runs
587
+ node/codex cells pass "node", grok cells pass "grok". Scopes to the
588
+ session's pane PIDs (and descendants) rather than a host-wide pgrep: on a
589
+ multi-session host, an unrelated cell's process would otherwise mask THIS
590
+ session's death and suppress the A2 alert. Best-effort; falls back to True
591
+ (assume alive) on detection error so a broken detector never false-fires A2.
591
592
  """
592
593
  try:
593
594
  panes = subprocess.run(
@@ -615,18 +616,34 @@ def _process_alive(tmux_session: str) -> bool:
615
616
  return True # ambiguous (session with no pane pids) → don't false-fire
616
617
 
617
618
  pg = subprocess.run(
618
- ["pgrep", "-f", "claude"],
619
+ ["pgrep", "-f", process_name],
619
620
  capture_output=True, text=True, timeout=5,
620
621
  )
621
622
  if pg.returncode != 0:
622
- return False # no claude process anywhere on the host
623
- claude_pids = [int(p) for p in pg.stdout.split() if p.strip().isdigit()]
624
- # Alive only if a claude process is a descendant of THIS session's panes.
625
- return any(_pid_under(cpid, pane_pids) for cpid in claude_pids)
623
+ return False # no matching process anywhere on the host
624
+ matched_pids = [int(p) for p in pg.stdout.split() if p.strip().isdigit()]
625
+ # Alive only if a matching process is a descendant of THIS session's panes.
626
+ return any(_pid_under(cpid, pane_pids) for cpid in matched_pids)
626
627
  except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError):
627
628
  return True # assume alive on detection error
628
629
 
629
630
 
631
+ def _liveness_via_cmd(cmd: str) -> bool:
632
+ """Escape-hatch liveness probe: run `cmd`; exit 0 = alive, non-zero = dead.
633
+
634
+ For cells whose liveness a process name can't express. Bounded timeout;
635
+ on timeout / OSError assume ALIVE — a broken or slow probe must never
636
+ false-fire the destructive A2 respawn (same fail-safe as _process_alive).
637
+ """
638
+ try:
639
+ result = subprocess.run(
640
+ cmd, shell=True, capture_output=True, timeout=5,
641
+ )
642
+ return result.returncode == 0
643
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
644
+ return True
645
+
646
+
630
647
  def _tmux_session_exists(name: str) -> bool:
631
648
  try:
632
649
  result = subprocess.run(
@@ -638,15 +655,15 @@ def _tmux_session_exists(name: str) -> bool:
638
655
  return False
639
656
 
640
657
 
641
- def _resolve_send_target(name: str) -> str:
642
- """Resolve a session name to the pane actually running the claude TUI.
658
+ def _resolve_send_target(name: str, process_name: str = "claude") -> str:
659
+ """Resolve a session name to the pane actually running the cell's agent.
643
660
 
644
661
  `send-keys -t <session>` lands on the session's ACTIVE pane — on a
645
- multi-pane cell that can be a bash/log pane, where an injected
646
- `/model ...` would execute as a SHELL command (drop seat-A N3). Prefer
647
- the pane whose current command looks like the claude CLI (node/claude);
648
- fall back to the session name unchanged when tmux is unavailable, the
649
- listing fails, or no pane matches.
662
+ multi-pane cell that can be a bash/log pane, where an injected wake would
663
+ execute as a SHELL command. Prefer the pane whose current command matches
664
+ the cell's `process_name`; then fall back to the claude-CLI heuristic
665
+ (claude runs under node); then to the session name unchanged when tmux is
666
+ unavailable, the listing fails, or no pane matches.
650
667
  """
651
668
  try:
652
669
  result = subprocess.run(
@@ -656,8 +673,11 @@ def _resolve_send_target(name: str) -> str:
656
673
  )
657
674
  if result.returncode != 0:
658
675
  return name
659
- for line in result.stdout.splitlines():
660
- parts = line.split()
676
+ panes = [ln.split() for ln in result.stdout.splitlines()]
677
+ for parts in panes: # exact process_name match wins
678
+ if len(parts) >= 2 and parts[1] == process_name:
679
+ return parts[0]
680
+ for parts in panes: # claude-CLI fallback (node)
661
681
  if len(parts) >= 2 and parts[1] in ("claude", "node"):
662
682
  return parts[0]
663
683
  except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
@@ -665,7 +685,9 @@ def _resolve_send_target(name: str) -> str:
665
685
  return name
666
686
 
667
687
 
668
- def _tmux_send_keys(name: str, text: str, clear_input: bool = False) -> bool:
688
+ def _tmux_send_keys(
689
+ name: str, text: str, clear_input: bool = False, process_name: str = "claude"
690
+ ) -> bool:
669
691
  """Send `text` + Enter to a tmux target.
670
692
 
671
693
  The target is resolved to the claude-TUI pane first (_resolve_send_target,
@@ -677,7 +699,7 @@ def _tmux_send_keys(name: str, text: str, clear_input: bool = False) -> bool:
677
699
  text is noise, but `/model ...` merging into typed text submits a
678
700
  corrupted command.
679
701
  """
680
- target = _resolve_send_target(name)
702
+ target = _resolve_send_target(name, process_name)
681
703
  keys = ["C-u", text, "Enter"] if clear_input else [text, "Enter"]
682
704
  try:
683
705
  result = subprocess.run(
@@ -1269,7 +1291,10 @@ def _run_local_check(args: argparse.Namespace) -> int:
1269
1291
  return 0
1270
1292
 
1271
1293
  # FALLBACK signal: pgrep claude (per mother #1021 AND-gate)
1272
- process_alive = _process_alive(tmux_session)
1294
+ if args.liveness_cmd:
1295
+ process_alive = _liveness_via_cmd(args.liveness_cmd)
1296
+ else:
1297
+ process_alive = _process_alive(tmux_session, args.process_name)
1273
1298
  diag["process_alive"] = process_alive
1274
1299
 
1275
1300
  # Check unread DM queue at gateway. If no unread DMs, no need to wake.
@@ -1444,7 +1469,8 @@ def _run_local_check(args: argparse.Namespace) -> int:
1444
1469
  f"{model_text} into {tmux_session}",
1445
1470
  file=sys.stderr,
1446
1471
  )
1447
- sent = _tmux_send_keys(tmux_session, model_text, clear_input=True)
1472
+ sent = _tmux_send_keys(tmux_session, model_text, clear_input=True,
1473
+ process_name=args.process_name)
1448
1474
  diag["send_keys_ok"] = sent
1449
1475
  if not sent:
1450
1476
  # BLOCK-1 fix: a FAILED inject (wedged / timing-out pane) is
@@ -1487,7 +1513,7 @@ def _run_local_check(args: argparse.Namespace) -> int:
1487
1513
  f"watchdog wake — cursor stale {cursor_age}s, "
1488
1514
  f"unread={unread}; please drain inbox"
1489
1515
  )
1490
- sent = _tmux_send_keys(tmux_session, wake_text)
1516
+ sent = _tmux_send_keys(tmux_session, wake_text, process_name=args.process_name)
1491
1517
  diag["send_keys_ok"] = sent
1492
1518
  if sent:
1493
1519
  _record_a1_fired(marker, cursor_mtime)
@@ -1722,6 +1748,22 @@ def _build_parser() -> argparse.ArgumentParser:
1722
1748
  p.add_argument("--gateway", default=_DEFAULT_GATEWAY_URL)
1723
1749
  p.add_argument("--tmux-session", default=None)
1724
1750
  p.add_argument("--peer", default=None)
1751
+ liveness_group = p.add_mutually_exclusive_group()
1752
+ liveness_group.add_argument(
1753
+ "--process-name", default="claude",
1754
+ help="Process the cell's agent runs, used by the liveness gate's "
1755
+ "`pgrep -f` (scoped to the session's panes). Default 'claude'; "
1756
+ "pass 'node' for a codex cell, 'grok' for a grok cell so a "
1757
+ "non-Claude cell isn't mis-read as dead. Mutually exclusive with "
1758
+ "--liveness-cmd.",
1759
+ )
1760
+ liveness_group.add_argument(
1761
+ "--liveness-cmd", default=None,
1762
+ help="Escape hatch: shell command whose exit status is the liveness "
1763
+ "verdict (0 = alive, non-zero = dead) instead of the pgrep gate. "
1764
+ "On timeout/error the cell is assumed ALIVE (never false-fire the "
1765
+ "destructive A2 respawn). Mutually exclusive with --process-name.",
1766
+ )
1725
1767
  p.add_argument("--no-respawn", action="store_true")
1726
1768
  p.add_argument(
1727
1769
  "--a15-max-swaps", type=int, default=_DEFAULT_A15_MAX_SWAPS,
@@ -41,6 +41,7 @@ import sqlite3
41
41
  import sys
42
42
  import time
43
43
  import unicodedata
44
+ import urllib.request
44
45
  import uuid
45
46
  from contextlib import contextmanager
46
47
  from datetime import datetime, timedelta, timezone
@@ -52,6 +53,7 @@ from fastapi import FastAPI, Header, HTTPException, Query, Request
52
53
  from fastapi.middleware.cors import CORSMiddleware
53
54
  from fastapi.responses import JSONResponse, Response
54
55
  from pydantic import BaseModel, Field
56
+ from starlette.concurrency import run_in_threadpool
55
57
 
56
58
  from .feature_registry import aggregate_features, apply_allowlist, apply_caps
57
59
 
@@ -71,6 +73,12 @@ SCHEMA_PATH = os.environ.get(
71
73
  )
72
74
  PORT = int(os.environ.get("PORT", "8788"))
73
75
 
76
+ # Gateway-held gbrain proxy token (POST /brain/query). Cells authenticate to
77
+ # THIS gateway with their mesh token; the gateway then presents ITS OWN
78
+ # gbrain token upstream so cells never need a separate gbrain_ credential.
79
+ GATEWAY_GBRAIN_URL = os.environ.get("GATEWAY_GBRAIN_URL", "http://100.107.222.72:8792/mcp")
80
+ GATEWAY_GBRAIN_TOKEN = os.environ.get("GATEWAY_GBRAIN_TOKEN", "")
81
+
74
82
  # B1 Meta-Edge identity (META_EDGE_IDENTITY_CONTRACT.md). Meta-Edge SSO ISSUES
75
83
  # RS256 JWTs; the gateway only ever TRUSTS them with Meta-Edge's PUBLIC key (it
76
84
  # can never forge one — the identity↔relay asymmetry). All three are read at
@@ -4196,6 +4204,59 @@ async def council_reclaim_stuck(req: CouncilReclaimStuckRequest,
4196
4204
  }
4197
4205
 
4198
4206
 
4207
+ # =====================================================================
4208
+ # BRAIN PROXY (POST /brain/query)
4209
+ # =====================================================================
4210
+
4211
+ def _brain_query_upstream(question: str, limit: int) -> list:
4212
+ """Proxy a READ-ONLY gbrain query. Builds the MCP `query` call itself (never
4213
+ write/admin), POSTs to GATEWAY_GBRAIN_URL with the gateway-held token, returns
4214
+ the chunk array. Raises on any upstream/parse failure (caller maps to 502)."""
4215
+ body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
4216
+ "params": {"name": "query",
4217
+ "arguments": {"query": question, "limit": limit, "expand": False}}}
4218
+ req = urllib.request.Request(
4219
+ GATEWAY_GBRAIN_URL, data=json.dumps(body).encode("utf-8"), method="POST")
4220
+ req.add_header("Content-Type", "application/json")
4221
+ req.add_header("Accept", "application/json, text/event-stream")
4222
+ req.add_header("Authorization", f"Bearer {GATEWAY_GBRAIN_TOKEN}")
4223
+ with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 — fixed tailnet URL
4224
+ raw = resp.read().decode("utf-8")
4225
+ payload = raw
4226
+ if "data:" in raw:
4227
+ for line in raw.splitlines():
4228
+ s = line.strip()
4229
+ if s.startswith("data:"):
4230
+ payload = s[len("data:"):].strip()
4231
+ break
4232
+ doc = json.loads(payload)
4233
+ parsed = json.loads(doc["result"]["content"][0]["text"])
4234
+ return parsed if isinstance(parsed, list) else []
4235
+
4236
+
4237
+ class BrainQueryRequest(BaseModel):
4238
+ query: str
4239
+ limit: int = 8
4240
+
4241
+
4242
+ @app.post("/brain/query")
4243
+ async def brain_query(req: BrainQueryRequest,
4244
+ authorization: Optional[str] = Header(None)) -> dict:
4245
+ auth = _authorize(authorization) # 401 on bad mesh token
4246
+ if not GATEWAY_GBRAIN_TOKEN:
4247
+ raise HTTPException(503, "brain proxy not configured")
4248
+ if not req.query.strip():
4249
+ raise HTTPException(400, "query is required")
4250
+ try:
4251
+ chunks = await run_in_threadpool(_brain_query_upstream, req.query, req.limit)
4252
+ except Exception as e: # fail loud — never a swallowed empty result
4253
+ log.warning("brain proxy upstream error for peer=%s: %s", auth.peer, e)
4254
+ raise HTTPException(502, "brain upstream error")
4255
+ log.info("brain query peer=%s regime=%s limit=%d chunks=%d",
4256
+ auth.peer, auth.regime, req.limit, len(chunks))
4257
+ return {"chunks": chunks}
4258
+
4259
+
4199
4260
  # =====================================================================
4200
4261
  # STARTUP
4201
4262
  # =====================================================================
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.24.0
3
+ Version: 0.26.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
@@ -105,6 +105,7 @@ tests/test_compress_verify.py
105
105
  tests/test_daemon_command.py
106
106
  tests/test_event_emit.py
107
107
  tests/test_feature_to_uri.py
108
+ tests/test_gateway_brain.py
108
109
  tests/test_gateway_client.py
109
110
  tests/test_gateway_command.py
110
111
  tests/test_highlight_command.py
@@ -13,6 +13,13 @@ from unittest.mock import patch
13
13
  from swarph_cli.commands import brain_ask as ba
14
14
 
15
15
 
16
+ # --- version ---------------------------------------------------------------
17
+
18
+ def test_version_is_0_26_0():
19
+ import swarph_cli
20
+ assert swarph_cli.__version__ == "0.26.0"
21
+
22
+
16
23
  # --- request / response plumbing -------------------------------------------
17
24
 
18
25
  def test_build_query_request_shape():
@@ -122,3 +129,55 @@ def test_resolve_endpoint_default_is_localhost(monkeypatch):
122
129
  monkeypatch.delenv("GBRAIN_MCP_URL", raising=False)
123
130
  monkeypatch.delenv("SWARPH_BRAIN_MCP", raising=False)
124
131
  assert ba._resolve_endpoint() == "http://127.0.0.1:8792/mcp"
132
+
133
+
134
+ # --- SWARPH_BRAIN_GATEWAY client path (Task 2) ------------------------------
135
+
136
+ def test_gateway_query_posts_brain_query_with_peer_token(monkeypatch, tmp_path):
137
+ from swarph_cli.commands import brain_ask
138
+ captured = {}
139
+ def fake_http_post(url, body, token, accept="application/json, text/event-stream", timeout=30):
140
+ captured.update(url=url, body=body, token=token)
141
+ import json as _j
142
+ return _j.dumps({"chunks": [{"slug": "s1", "chunk_text": "x"}]})
143
+ monkeypatch.setattr(brain_ask, "_http_post", fake_http_post)
144
+ out = brain_ask._gateway_query("http://gw:8788", "peer_tok_123", "hello", 5)
145
+ assert out == [{"slug": "s1", "chunk_text": "x"}]
146
+ assert captured["url"] == "http://gw:8788/brain/query"
147
+ assert captured["body"] == {"query": "hello", "limit": 5}
148
+ assert captured["token"] == "peer_tok_123"
149
+
150
+
151
+ def test_run_brain_ask_uses_gateway_when_env_set(monkeypatch, tmp_path, capsys):
152
+ from swarph_cli.commands import brain_ask
153
+ # peer token on disk for _self_name()
154
+ cfg = tmp_path / ".config" / "swarph"
155
+ cfg.mkdir(parents=True)
156
+ (cfg / "wlc.peer_token").write_text("peer_tok_xyz", encoding="utf-8")
157
+ monkeypatch.setattr(brain_ask.Path, "home", classmethod(lambda cls: tmp_path))
158
+ monkeypatch.setenv("SWARPH_SELF", "wlc")
159
+ monkeypatch.setenv("SWARPH_BRAIN_GATEWAY", "http://gw:8788")
160
+ seen = {}
161
+ def fake_gateway_query(gw, tok, q, n):
162
+ seen.update(gw=gw, tok=tok, q=q)
163
+ return [{"slug": "s1", "chunk_text": "hit"}]
164
+ monkeypatch.setattr(brain_ask, "_gateway_query", fake_gateway_query)
165
+ rc = brain_ask.run_brain_ask(["--no-synth", "what", "is", "up"])
166
+ assert rc == 0
167
+ assert seen["gw"] == "http://gw:8788" and seen["tok"] == "peer_tok_xyz"
168
+ assert "s1" in capsys.readouterr().out
169
+
170
+
171
+ def test_run_brain_ask_direct_path_unchanged_when_gateway_unset(monkeypatch, capsys):
172
+ from swarph_cli.commands import brain_ask
173
+ monkeypatch.delenv("SWARPH_BRAIN_GATEWAY", raising=False)
174
+ monkeypatch.setenv("GBRAIN_TOKEN", "gbrain_direct")
175
+ called = {}
176
+ def fake_mcp(url, tok, q, n):
177
+ called["hit"] = (url, tok)
178
+ return [{"slug": "d"}]
179
+ monkeypatch.setattr(brain_ask, "_mcp_query", fake_mcp)
180
+ rc = brain_ask.run_brain_ask(["--no-synth", "hello"])
181
+ assert rc == 0
182
+ assert called["hit"][1] == "gbrain_direct" # direct path used the gbrain token
183
+ assert "d" in capsys.readouterr().out
@@ -0,0 +1,104 @@
1
+ import json
2
+ import os
3
+ import importlib
4
+ import tempfile
5
+ import pytest
6
+ from types import SimpleNamespace
7
+
8
+ # The gateway server stack is an optional extra ([gateway]); its module top
9
+ # does `import jwt` (PyJWT) + fastapi. Skip the whole file when those aren't
10
+ # installed (e.g. Windows CI, which doesn't install the gateway extra) — same
11
+ # guard the sibling gateway test tests/test_meta_edge_identity.py uses.
12
+ pytest.importorskip("fastapi")
13
+ pytest.importorskip("jwt")
14
+
15
+
16
+ def _load_app(monkeypatch, *, gbrain_token="gbrain_hostheld", auth="tok_shared"):
17
+ monkeypatch.setenv("MESH_GATEWAY_TOKEN", auth)
18
+ monkeypatch.setenv("GATEWAY_GBRAIN_TOKEN", gbrain_token)
19
+ monkeypatch.setenv("GATEWAY_GBRAIN_URL", "http://127.0.0.1:8792/mcp")
20
+ # NOTE: ":memory:" (as written in the task brief) is incompatible with this
21
+ # module's _conn()/_init_db(), which open a FRESH sqlite3.connect() per call
22
+ # with no shared cache — each ":memory:" connection is an independent,
23
+ # isolated DB, so the schema built by _init_db()'s first connection is gone
24
+ # by its second. That fails at import time, before any endpoint code runs,
25
+ # for every one of the 6 tests here — not the brief's expected 404/
26
+ # AttributeError RED. Using a real temp-file path (matching the existing
27
+ # pattern in tests/test_meta_edge_identity.py) fixes the scaffold without
28
+ # touching production DB code, and restores the brief's intended RED.
29
+ monkeypatch.setenv("MESH_DB_PATH", os.path.join(tempfile.mkdtemp(), "mesh.db"))
30
+ from swarph_cli.gateway import server
31
+ importlib.reload(server) # re-read module-top env config
32
+ return server
33
+
34
+
35
+ def _sse(chunks):
36
+ inner = json.dumps(chunks)
37
+ env = {"jsonrpc": "2.0", "id": 1, "result": {"content": [{"type": "text", "text": inner}]}}
38
+ return "event: message\ndata: " + json.dumps(env) + "\n\n"
39
+
40
+
41
+ def _client(server):
42
+ from fastapi.testclient import TestClient
43
+ return TestClient(server.app)
44
+
45
+
46
+ def test_brain_query_authenticates_and_returns_chunks(monkeypatch):
47
+ server = _load_app(monkeypatch)
48
+ chunks = [{"slug": "s1", "title": "T", "chunk_text": "hello", "score": 0.9}]
49
+ monkeypatch.setattr(server, "_brain_query_upstream", lambda q, n: chunks)
50
+ r = _client(server).post("/brain/query", json={"query": "hi", "limit": 5},
51
+ headers={"Authorization": "Bearer tok_shared"})
52
+ assert r.status_code == 200
53
+ assert r.json() == {"chunks": chunks}
54
+
55
+
56
+ def test_brain_query_bad_token_401(monkeypatch):
57
+ server = _load_app(monkeypatch)
58
+ monkeypatch.setattr(server, "_brain_query_upstream", lambda q, n: [])
59
+ r = _client(server).post("/brain/query", json={"query": "hi"},
60
+ headers={"Authorization": "Bearer wrong"})
61
+ assert r.status_code == 401
62
+
63
+
64
+ def test_brain_query_unconfigured_503(monkeypatch):
65
+ server = _load_app(monkeypatch, gbrain_token="") # GATEWAY_GBRAIN_TOKEN unset
66
+ r = _client(server).post("/brain/query", json={"query": "hi"},
67
+ headers={"Authorization": "Bearer tok_shared"})
68
+ assert r.status_code == 503
69
+
70
+
71
+ def test_brain_query_empty_query_400(monkeypatch):
72
+ server = _load_app(monkeypatch)
73
+ monkeypatch.setattr(server, "_brain_query_upstream", lambda q, n: [])
74
+ r = _client(server).post("/brain/query", json={"query": " "},
75
+ headers={"Authorization": "Bearer tok_shared"})
76
+ assert r.status_code == 400
77
+
78
+
79
+ def test_brain_query_upstream_error_502(monkeypatch):
80
+ server = _load_app(monkeypatch)
81
+ def boom(q, n): raise RuntimeError("gbrain down")
82
+ monkeypatch.setattr(server, "_brain_query_upstream", boom)
83
+ r = _client(server).post("/brain/query", json={"query": "hi"},
84
+ headers={"Authorization": "Bearer tok_shared"})
85
+ assert r.status_code == 502
86
+
87
+
88
+ def test_upstream_helper_is_read_only_by_construction(monkeypatch):
89
+ """The MCP body the proxy sends upstream ALWAYS has params.name == 'query'."""
90
+ server = _load_app(monkeypatch)
91
+ captured = {}
92
+ class FakeResp:
93
+ def read(self): return _sse([{"slug": "s"}]).encode()
94
+ def __enter__(self): return self
95
+ def __exit__(self, *a): return False
96
+ def fake_urlopen(req, timeout=None):
97
+ captured["body"] = json.loads(req.data.decode())
98
+ captured["auth"] = req.get_header("Authorization")
99
+ return FakeResp()
100
+ monkeypatch.setattr(server.urllib.request, "urlopen", fake_urlopen)
101
+ out = server._brain_query_upstream("anything", 8)
102
+ assert out == [{"slug": "s"}]
103
+ assert captured["body"]["params"]["name"] == "query" # read-only lock
104
+ assert captured["auth"] == "Bearer gbrain_hostheld" # gateway's held token