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.
- {swarph_cli-0.20.0/src/swarph_cli.egg-info → swarph_cli-0.22.0}/PKG-INFO +2 -1
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/pyproject.toml +4 -1
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/__init__.py +1 -1
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/spawn.py +100 -8
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/gateway/schema.sql +9 -1
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/gateway/server.py +193 -12
- {swarph_cli-0.20.0 → swarph_cli-0.22.0/src/swarph_cli.egg-info}/PKG-INFO +2 -1
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli.egg-info/SOURCES.txt +1 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli.egg-info/requires.txt +1 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_caller_meta_guard.py +1 -1
- swarph_cli-0.22.0/tests/test_meta_edge_identity.py +333 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_spawn_command.py +150 -4
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/LICENSE +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/README.md +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/setup.cfg +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/caller.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/capture/__init__.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/capture/harden.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/capture/lineage.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/capture/liveness.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/capture/manifest.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/capture/paths.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/capture/verify.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/cell.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/__init__.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/_gateway_client.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/add.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/brain.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/brain_ask.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/cell.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/channel.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/chat.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/compress.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/daemon.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/gateway.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/highlight.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/hook_output.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/hooks.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/import_session.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/init.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/install_hook.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/install_multiplexer.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/lane.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/mcp_server.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/memory_sync.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/mesh.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/onboard.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/protocol_handler.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/ratify.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/schedule.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/security.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/service.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/commands/watchdog.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/compress/__init__.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/compress/levers.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/compress/marker.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/compress/verify.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/gateway/__init__.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/gateway/feature_registry.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/gateway/lanes_control.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/gateway/services_control.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/main.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/multiplexer.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/parsers/__init__.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/parsers/claude.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/service/__init__.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/service/app.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/service/providers.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli.egg-info/entry_points.txt +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/src/swarph_cli.egg-info/top_level.txt +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_add_security_gate.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_artifact_add.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_artifact_hash.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_artifact_lib.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_artifact_mcp.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_artifact_skill.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_artifact_tool.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_artifact_uri.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_brain_ask_command.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_brain_command.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_capture_lineage.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_capture_liveness.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_capture_manifest.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_capture_paths.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_capture_security.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_cell_command_dispatch.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_cell_harden.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_cell_loader.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_cell_verify.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_channel_command.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_chat_command.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_claude_parser.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_claude_tmux_template.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_compress_caller_convention.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_compress_command.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_compress_levers.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_compress_marker.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_compress_verify.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_daemon_command.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_feature_to_uri.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_gateway_client.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_gateway_command.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_highlight_command.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_hook_output.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_hooks_add.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_hooks_bundle.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_hooks_init.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_hooks_lifecycle.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_hooks_merge.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_import_command.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_init_command.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_install_hook.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_install_multiplexer_command.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_lane_command.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_main.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_mcp_server.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_memory_sync.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_mesh_command.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_mesh_sidecar.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_multiplexer.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_onboard_command.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_protocol_handler.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_ratify_command.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_repl_caller_convention.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_schedule_command.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_security.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_service_app.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_service_command.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_service_providers.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_session_path_role_gate.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_smoke_chat.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_smoke_one_shot.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_smoke_phase_5_5.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_spawn_live_pin.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_spawn_mitosis_lineage.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_spawn_tmux_session.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_spawn_windows_relaunch.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_version_consistency.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_watchdog.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_watchdog_dm_wake.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_watchdog_dm_wake_cooldown.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_watchdog_dm_wake_wiring.py +0 -0
- {swarph_cli-0.20.0 → swarph_cli-0.22.0}/tests/test_watchdog_model_rung.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
471
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|
|
@@ -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, (
|