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.
- {swarph_cli-0.24.0/src/swarph_cli.egg-info → swarph_cli-0.26.0}/PKG-INFO +1 -1
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/pyproject.toml +1 -1
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/__init__.py +1 -1
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/brain_ask.py +42 -13
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/watchdog.py +69 -27
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/gateway/server.py +61 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0/src/swarph_cli.egg-info}/PKG-INFO +1 -1
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli.egg-info/SOURCES.txt +1 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_brain_ask_command.py +59 -0
- swarph_cli-0.26.0/tests/test_gateway_brain.py +104 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_watchdog.py +119 -6
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_watchdog_model_rung.py +1 -1
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/LICENSE +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/README.md +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/setup.cfg +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/caller.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/capture/__init__.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/capture/harden.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/capture/lineage.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/capture/liveness.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/capture/manifest.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/capture/paths.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/capture/verify.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/cell.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/chain_token.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/__init__.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/_gateway_client.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/add.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/brain.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/cell.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/channel.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/chat.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/codegraph.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/compress.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/daemon.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/event.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/gateway.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/highlight.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/hook_output.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/hooks.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/import_session.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/init.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/install_hook.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/install_multiplexer.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/lane.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/mcp_server.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/memory_sync.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/mesh.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/onboard.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/protocol_handler.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/ratify.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/schedule.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/security.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/service.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/commands/spawn.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/compress/__init__.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/compress/levers.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/compress/marker.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/compress/verify.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/gateway/__init__.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/gateway/feature_registry.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/gateway/lanes_control.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/gateway/schema.sql +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/gateway/services_control.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/main.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/multiplexer.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/parsers/__init__.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/parsers/claude.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/service/__init__.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/service/app.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/service/providers.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli.egg-info/entry_points.txt +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli.egg-info/requires.txt +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/src/swarph_cli.egg-info/top_level.txt +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_add_security_gate.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_artifact_add.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_artifact_hash.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_artifact_lib.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_artifact_mcp.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_artifact_skill.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_artifact_tool.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_artifact_uri.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_brain_command.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_caller_meta_guard.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_capture_lineage.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_capture_liveness.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_capture_manifest.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_capture_paths.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_capture_security.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_cell_command_dispatch.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_cell_harden.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_cell_loader.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_cell_verify.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_chain_token.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_channel_command.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_chat_command.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_claude_parser.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_claude_tmux_template.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_codegraph_command.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_codegraph_mcp.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_compress_caller_convention.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_compress_command.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_compress_levers.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_compress_marker.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_compress_verify.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_daemon_command.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_event_emit.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_feature_to_uri.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_gateway_client.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_gateway_command.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_highlight_command.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_hook_output.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_hooks_add.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_hooks_bundle.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_hooks_init.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_hooks_lifecycle.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_hooks_merge.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_import_command.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_init_command.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_install_hook.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_install_multiplexer_command.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_lane_command.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_main.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_mcp_server.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_memory_sync.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_mesh_command.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_mesh_sidecar.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_meta_edge_identity.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_multiplexer.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_onboard_command.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_protocol_handler.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_ratify_command.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_repl_caller_convention.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_schedule_command.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_security.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_service_app.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_service_command.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_service_providers.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_session_path_role_gate.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_smoke_chat.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_smoke_one_shot.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_smoke_phase_5_5.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_spawn_command.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_spawn_live_pin.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_spawn_mitosis_lineage.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_spawn_tmux_session.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_spawn_windows_relaunch.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_version_consistency.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_watchdog_dm_wake.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_watchdog_dm_wake_cooldown.py +0 -0
- {swarph_cli-0.24.0 → swarph_cli-0.26.0}/tests/test_watchdog_dm_wake_wiring.py +0 -0
- {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.
|
|
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.
|
|
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" }
|
|
@@ -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
|
-
|
|
162
|
-
if
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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",
|
|
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
|
|
623
|
-
|
|
624
|
-
# Alive only if a
|
|
625
|
-
return any(_pid_under(cpid, pane_pids) for cpid in
|
|
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
|
|
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
|
-
|
|
647
|
-
the
|
|
648
|
-
|
|
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
|
|
660
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|