mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-17 11:30:13 +02:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ede669a12 | |||
| 8fcb01276c | |||
| 10dc9450be | |||
| bef462cdcf | |||
| 5135b771f5 | |||
| 7151563a41 | |||
| 52a28967a0 | |||
| 96182fe66d | |||
| 174031479c | |||
| f1cd9eb4b9 | |||
| c266c5ff5e | |||
| 52a0968092 | |||
| 89d6bb8fb9 | |||
| d48a0cdace | |||
| df76f6f147 | |||
| 776c89bfcf | |||
| d3006df57a | |||
| e78e4d186d | |||
| d1e1be4016 | |||
| 0afb85e241 | |||
| 039a0f9d0c | |||
| b9b99c1fa8 | |||
| a8fd33a758 | |||
| 7346129d0e | |||
| eb8f39f84e |
@@ -177,6 +177,8 @@ frontend/eslint-report.json
|
||||
.git_backup/
|
||||
local-artifacts/
|
||||
release-secrets/
|
||||
release-staging/
|
||||
.tmp-release-inspect/
|
||||
shadowbroker_repo/
|
||||
frontend/src/components.bak/
|
||||
frontend/src/components/map/icons/backups/
|
||||
@@ -261,6 +263,11 @@ frontend/.desktop-export-stash-*/
|
||||
backend/data/wormhole_stderr.log
|
||||
backend/data/wormhole_stdout.log
|
||||
|
||||
# Hermes Agent (operator-local runtime install — not project source)
|
||||
.hermes/
|
||||
**/.hermes/
|
||||
hermes-agent/
|
||||
|
||||
# Runtime caches that already slip through the backend/data/* blanket
|
||||
# (these are caught by the wildcard but listing for clarity)
|
||||
|
||||
|
||||
@@ -91,6 +91,8 @@ Both paths produce identical containers — same source, same CI, same images by
|
||||
|
||||
Open `http://localhost:3000` to view the dashboard! *(Requires [Docker Desktop](https://www.docker.com/products/docker-desktop/) or Docker Engine)*
|
||||
|
||||
> **Join the private InfoNet swarm (sb-testnet-0):** Click **NODE** in the dashboard, or run `./meshnode.sh` for a headless participant. No manual peer list — fleet defaults discover the seed and pull the signed manifest automatically. Set `MESH_INFONET_FLEET_JOIN=false` in `.env` for a private solo node.
|
||||
|
||||
> **Backend port already in use?** The browser only needs port `3000`, but the backend API is also published on host port `8000` for local diagnostics. If another app already uses `8000`, create or edit `.env` next to `docker-compose.yml` and set `BACKEND_PORT=8001`, then run `docker compose up -d`.
|
||||
|
||||
> **Blank news/UAP/bases/wastewater after several minutes?** Check for backend OOM restarts with `docker events --since 30m --filter container=shadowbroker-backend --filter event=oom`. The default compose file gives the backend 4GB; if your host has less memory, reduce enabled feeds or set `BACKEND_MEMORY_LIMIT=3G` and expect slower/heavier layers to warm more gradually.
|
||||
|
||||
+17
-1
@@ -227,7 +227,23 @@ AIS_API_KEY= # https://aisstream.io/ — free tier WebSocket key
|
||||
# MESH_GATE_SESSION_STREAM_MAX_GATES=16
|
||||
# MESH_BOOTSTRAP_DISABLED=false
|
||||
# MESH_BOOTSTRAP_MANIFEST_PATH=data/bootstrap_peers.json
|
||||
# MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY=
|
||||
# Swarm discovery (signed peer manifest). Participants need only the public key;
|
||||
# the seed operator sets MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY (never commit it).
|
||||
# Generate a fleet keypair: uv run python backend/scripts/bootstrap_manifest_helper.py generate-keypair
|
||||
# Public sb-testnet fleet defaults (auto-used when MESH_INFONET_FLEET_JOIN=true).
|
||||
# MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY=ul1d0kj/ODPIp0OhHzX8eLAVXzJ3CVvzW1vn2IC6q3I=
|
||||
# MESH_INFONET_FLEET_JOIN=true
|
||||
# MESH_INFONET_FLEET_JOIN_DISABLED=false
|
||||
# MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY= # seed only
|
||||
# MESH_BOOTSTRAP_SIGNER_ID=shadowbroker-seed
|
||||
# MESH_PEER_REGISTRY_ENABLED=true # seed only (auto-enabled when private key is set)
|
||||
# Headless relay compose sets MESH_INFONET_RELAY_AUTO_WORMHOLE=true; seed nodes with
|
||||
# MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY also auto-enable Tor wormhole on startup.
|
||||
# MESH_INFONET_RELAY_AUTO_WORMHOLE=false
|
||||
# MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED=false
|
||||
# MESH_SWARM_MANIFEST_TTL_S=14400
|
||||
# MESH_SWARM_MANIFEST_PULL_INTERVAL_S=300
|
||||
# MESH_PEER_REGISTRY_STALE_S=604800
|
||||
# Infonet/Wormhole fails closed to onion/RNS by default. Only enable clearnet
|
||||
# sync for local relay development or an explicitly public testnet.
|
||||
# MESH_INFONET_ALLOW_CLEARNET_SYNC=false
|
||||
|
||||
+2
-1
@@ -27,6 +27,7 @@ WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
tor \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
@@ -72,7 +73,7 @@ ENV PRIVACY_CORE_LIB=/app/libprivacy_core.so
|
||||
# Create a non-root user for security
|
||||
# Grant write access to /app so the auto-updater can extract files
|
||||
# Pre-create /app/data so mounted volumes inherit correct ownership
|
||||
RUN adduser --system --uid 1001 backenduser \
|
||||
RUN adduser --system --uid 1001 --home /app backenduser \
|
||||
&& mkdir -p /app/data \
|
||||
&& chown -R backenduser /app \
|
||||
&& chmod -R u+w /app
|
||||
|
||||
@@ -862,7 +862,9 @@ _ROUTE_TRANSPORT_POLICY: dict[tuple[str, str], RouteTransportPolicy] = {
|
||||
("POST", "/api/wormhole/gate/messages/decrypt"): _local_only_route_policy("private_control_only"),
|
||||
# ── Wormhole DM (strong) ──────────────────────────────────────────
|
||||
("POST", "/api/wormhole/dm/compose"): _local_only_route_policy("private_control_only"),
|
||||
("POST", "/api/wormhole/dm/connect-contact"): _local_only_route_policy("private_control_only"),
|
||||
("POST", "/api/wormhole/dm/decrypt"): _local_only_route_policy("private_control_only"),
|
||||
("POST", "/api/wormhole/dm/mls-key-package"): _local_only_route_policy("private_control_only"),
|
||||
("POST", "/api/wormhole/dm/register-key"): _local_only_route_policy("private_control_only"),
|
||||
("POST", "/api/wormhole/dm/prekey/register"): _local_only_route_policy("private_control_only"),
|
||||
("POST", "/api/wormhole/dm/bootstrap-encrypt"): _local_only_route_policy("private_control_only"),
|
||||
@@ -1404,6 +1406,27 @@ def _peer_hmac_url_from_request(request: Request) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _verify_peer_transport_hmac(request: Request, body_bytes: bytes) -> bool:
|
||||
"""Verify HMAC-SHA256 peer authentication without an allowlist check."""
|
||||
provided = str(request.headers.get("x-peer-hmac", "") or "").strip()
|
||||
if not provided:
|
||||
return False
|
||||
|
||||
peer_url = _peer_hmac_url_from_request(request)
|
||||
if not peer_url:
|
||||
return False
|
||||
peer_key = resolve_peer_key_for_url(peer_url)
|
||||
if not peer_key:
|
||||
return False
|
||||
|
||||
expected = _hmac_mod.new(
|
||||
peer_key,
|
||||
body_bytes,
|
||||
_hashlib_mod.sha256,
|
||||
).hexdigest()
|
||||
return _hmac_mod.compare_digest(provided.lower(), expected.lower())
|
||||
|
||||
|
||||
def _verify_peer_push_hmac(request: Request, body_bytes: bytes) -> bool:
|
||||
"""Verify HMAC-SHA256 peer authentication on push requests.
|
||||
|
||||
|
||||
@@ -51,5 +51,10 @@
|
||||
"ShadowBroker_v0.9.82.zip": "202ab043465741dcc06de57c19ec8314904332f8e818b891d7174655719d084c",
|
||||
"ShadowBroker_0.9.82_x64-setup.exe": "0eb9f2bda02ab691b39687641abc97e6bfb507b42f48de21970ad7dfb4ea15fc",
|
||||
"ShadowBroker_0.9.82_x64_en-US.msi": "ced08f930171c0c08009a958cc30b0171a09f982230fc217c6808c2ed7ab2e30"
|
||||
},
|
||||
"v0.9.83": {
|
||||
"ShadowBroker_v0.9.83.zip": "53f56631731ad3cdc7be68df09bedd6570ed91ecda6fa57c39651098e15666c7",
|
||||
"ShadowBroker_0.9.83_x64-setup.exe": "d62170af4b9df0b190832b7bb3ad6bfe8a7ac01472f2c7b39cf2a1b61edc7492",
|
||||
"ShadowBroker_0.9.83_x64_en-US.msi": "b664cc0003a29f7ce88b04c2b425643dbe7ed897342fc6e9a2378bc1910c6850"
|
||||
}
|
||||
}
|
||||
|
||||
+408
-37
@@ -244,6 +244,7 @@ from services.mesh.mesh_protocol import (
|
||||
PROTOCOL_VERSION,
|
||||
normalize_payload,
|
||||
)
|
||||
from services.mesh.mesh_hashchain import GENESIS_HASH
|
||||
from services.mesh.mesh_signed_events import (
|
||||
MeshWriteExemption,
|
||||
SignedWriteKind,
|
||||
@@ -324,6 +325,7 @@ from auth import (
|
||||
_validate_insecure_admin_startup,
|
||||
_validate_peer_push_secret,
|
||||
_verify_peer_push_hmac,
|
||||
_verify_peer_transport_hmac,
|
||||
)
|
||||
from node_state import (
|
||||
_NODE_BOOTSTRAP_STATE,
|
||||
@@ -370,6 +372,7 @@ osint_router = _load_optional_router("routers.osint")
|
||||
scm_router = _load_optional_router("routers.scm")
|
||||
entity_graph_router = _load_optional_router("routers.entity_graph")
|
||||
intel_feeds_router = _load_optional_router("routers.intel_feeds")
|
||||
agent_shell_router = _load_optional_router("routers.agent_shell")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1069,6 +1072,10 @@ def _release_gate_status(
|
||||
|
||||
|
||||
def _validate_privacy_core_startup() -> None:
|
||||
# The wormhole child agent reuses this app on WORMHOLE_PORT; the parent
|
||||
# backend already validated privacy-core before spawning it.
|
||||
if os.environ.get("WORMHOLE_PORT"):
|
||||
return
|
||||
from services.privacy_core_attestation import validate_privacy_core_startup
|
||||
|
||||
validate_privacy_core_startup()
|
||||
@@ -1240,6 +1247,26 @@ def _local_infonet_peer_url() -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _clear_stale_arti_sync_backoff() -> None:
|
||||
"""Drop cached Arti warmup errors once SOCKS transport is actually ready."""
|
||||
from dataclasses import replace
|
||||
|
||||
with _NODE_RUNTIME_LOCK:
|
||||
current = get_sync_state()
|
||||
error_lower = str(current.last_error or "").lower()
|
||||
if "arti" not in error_lower and "onion sync requires" not in error_lower:
|
||||
return
|
||||
set_sync_state(
|
||||
replace(
|
||||
current,
|
||||
last_error="",
|
||||
consecutive_failures=0,
|
||||
next_sync_due_at=int(time.time()),
|
||||
last_outcome="idle" if current.last_outcome == "error" else current.last_outcome,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _ensure_infonet_private_transport_ready(reason: str = "") -> bool:
|
||||
"""Warm the local onion transport before private Infonet sync.
|
||||
|
||||
@@ -1268,14 +1295,36 @@ def _ensure_infonet_private_transport_ready(reason: str = "") -> bool:
|
||||
|
||||
label = f" ({reason})" if reason else ""
|
||||
logger.info("Infonet private transport warmup starting%s", label)
|
||||
tor_result = tor_service.start(target_port=8000)
|
||||
if tor_result.get("ok"):
|
||||
from services.wormhole_supervisor import invalidate_arti_ready_cache
|
||||
|
||||
for attempt in range(3):
|
||||
tor_result = tor_service.start(target_port=8000)
|
||||
if not tor_result.get("ok"):
|
||||
logger.warning(
|
||||
"Infonet private transport warmup incomplete%s: %s",
|
||||
label,
|
||||
tor_result,
|
||||
)
|
||||
continue
|
||||
_write_env_value("MESH_ARTI_ENABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
if _check_arti_ready():
|
||||
logger.info("Infonet private transport ready%s", label)
|
||||
return True
|
||||
logger.warning("Infonet private transport warmup incomplete%s: %s", label, tor_result)
|
||||
invalidate_arti_ready_cache()
|
||||
deadline = time.monotonic() + 30.0
|
||||
while time.monotonic() < deadline:
|
||||
if _check_arti_ready(force=True):
|
||||
logger.info("Infonet private transport ready%s", label)
|
||||
_clear_stale_arti_sync_backoff()
|
||||
threading.Thread(target=_swarm_bootstrap_after_transport_ready, daemon=True).start()
|
||||
_kick_public_sync_background(f"transport_ready{label}")
|
||||
return True
|
||||
time.sleep(1.0)
|
||||
logger.warning(
|
||||
"Infonet private transport SOCKS not ready after Tor start (attempt %d/3)%s",
|
||||
attempt + 1,
|
||||
label,
|
||||
)
|
||||
tor_service.stop()
|
||||
logger.warning("Infonet private transport warmup incomplete%s", label)
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.warning("Infonet private transport warmup failed: %s", exc)
|
||||
@@ -1285,10 +1334,14 @@ def _ensure_infonet_private_transport_ready(reason: str = "") -> bool:
|
||||
|
||||
|
||||
def _configured_bootstrap_seed_peer_urls() -> list[str]:
|
||||
from services.mesh.mesh_fleet_defaults import configured_bootstrap_seed_peers_with_fleet_default
|
||||
|
||||
settings = get_settings()
|
||||
primary = str(getattr(settings, "MESH_BOOTSTRAP_SEED_PEERS", "") or "").strip()
|
||||
legacy = str(getattr(settings, "MESH_DEFAULT_SYNC_PEERS", "") or "").strip()
|
||||
return parse_configured_relay_peers(primary or legacy)
|
||||
return configured_bootstrap_seed_peers_with_fleet_default(
|
||||
parse_configured_relay_peers(primary or legacy)
|
||||
)
|
||||
|
||||
|
||||
def _refresh_node_peer_store(*, now: float | None = None) -> dict[str, Any]:
|
||||
@@ -1415,6 +1468,16 @@ def _refresh_node_peer_store(*, now: float | None = None) -> dict[str, Any]:
|
||||
if private_transport_required and skipped_clearnet_peers and not bootstrap_error:
|
||||
bootstrap_error = _infonet_private_transport_error()
|
||||
|
||||
swarm_pull: dict[str, Any] = {}
|
||||
try:
|
||||
from services.mesh.mesh_swarm_runtime import refresh_swarm_manifest_from_seeds
|
||||
|
||||
swarm_pull = refresh_swarm_manifest_from_seeds(now=timestamp)
|
||||
if swarm_pull.get("ok") and not swarm_pull.get("skipped"):
|
||||
store.load()
|
||||
except Exception as exc:
|
||||
swarm_pull = {"ok": False, "detail": str(exc or type(exc).__name__)}
|
||||
|
||||
store.save()
|
||||
bootstrap_records = store.records_for_bucket("bootstrap")
|
||||
sync_records = store.records_for_bucket("sync")
|
||||
@@ -1423,6 +1486,8 @@ def _refresh_node_peer_store(*, now: float | None = None) -> dict[str, Any]:
|
||||
bootstrap_records = [record for record in bootstrap_records if _is_private_infonet_transport(record.transport)]
|
||||
sync_records = [record for record in sync_records if _is_private_infonet_transport(record.transport)]
|
||||
push_records = [record for record in push_records if _is_private_infonet_transport(record.transport)]
|
||||
swarm_sync_peer_count = len([record for record in sync_records if str(record.source or "") == "swarm"])
|
||||
swarm_push_peer_count = len([record for record in push_records if str(record.source or "") == "swarm"])
|
||||
snapshot = {
|
||||
"node_mode": mode,
|
||||
"private_transport_required": private_transport_required,
|
||||
@@ -1434,16 +1499,29 @@ def _refresh_node_peer_store(*, now: float | None = None) -> dict[str, Any]:
|
||||
"bootstrap_peer_count": len(bootstrap_records),
|
||||
"sync_peer_count": len(sync_records),
|
||||
"push_peer_count": len(push_records),
|
||||
"swarm_sync_peer_count": swarm_sync_peer_count,
|
||||
"swarm_push_peer_count": swarm_push_peer_count,
|
||||
"operator_peer_count": len(operator_peers),
|
||||
"bootstrap_seed_peer_count": len(bootstrap_seed_peers),
|
||||
"default_sync_peer_count": len(bootstrap_seed_peers),
|
||||
"last_bootstrap_error": bootstrap_error,
|
||||
"swarm_manifest_pull": swarm_pull,
|
||||
}
|
||||
with _NODE_RUNTIME_LOCK:
|
||||
_NODE_BOOTSTRAP_STATE.update(snapshot)
|
||||
return snapshot
|
||||
|
||||
|
||||
def _swarm_bootstrap_after_transport_ready() -> None:
|
||||
try:
|
||||
from services.mesh.mesh_swarm_runtime import join_swarm_with_retries
|
||||
|
||||
join_swarm_with_retries(attempts=4, delay_s=15.0, force=True)
|
||||
_refresh_node_peer_store()
|
||||
except Exception:
|
||||
logger.warning("swarm bootstrap after transport ready failed", exc_info=True)
|
||||
|
||||
|
||||
def _materialize_local_infonet_state() -> None:
|
||||
from services.mesh.mesh_hashchain import infonet
|
||||
|
||||
@@ -1591,6 +1669,12 @@ def _hydrate_dm_relay_from_chain(events: list[dict]) -> int:
|
||||
sender_token_hash = hashlib.sha256(
|
||||
f"hashchain-dm-sender|{event_id}|{canonical.get('node_id', '')}".encode("utf-8")
|
||||
).hexdigest()
|
||||
try:
|
||||
from services.mesh.mesh_dm_connect_delivery import relay_push_peer_urls_for_payload
|
||||
|
||||
replication_urls = relay_push_peer_urls_for_payload(dict(payload))
|
||||
except Exception:
|
||||
replication_urls = []
|
||||
try:
|
||||
result = dm_relay.deposit(
|
||||
sender_id=str(canonical.get("node_id", "") or ""),
|
||||
@@ -1604,6 +1688,7 @@ def _hydrate_dm_relay_from_chain(events: list[dict]) -> int:
|
||||
sender_token_hash=sender_token_hash,
|
||||
payload_format=str(payload.get("format", "dm1") or "dm1"),
|
||||
session_welcome=str(payload.get("session_welcome", "") or ""),
|
||||
replication_peer_urls=replication_urls,
|
||||
)
|
||||
if result.get("ok"):
|
||||
count += 1
|
||||
@@ -1668,7 +1753,29 @@ def _sync_from_peer(
|
||||
_hydrate_dm_relay_from_chain(events)
|
||||
rejected = list(result.get("rejected", []) or [])
|
||||
if rejected:
|
||||
return False, f"sync ingest rejected {len(rejected)} event(s)", False, 0
|
||||
reasons = [
|
||||
str((item or {}).get("reason", "") or "").strip()
|
||||
for item in rejected
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
reason_summary = ", ".join(reason for reason in reasons if reason)
|
||||
detail = f"sync ingest rejected {len(rejected)} event(s)"
|
||||
if reason_summary:
|
||||
detail = f"{detail}: {reason_summary}"
|
||||
local_empty = len(infonet.events) == 0
|
||||
stale_genesis = (
|
||||
local_empty
|
||||
and bool(events)
|
||||
and str((events[0] or {}).get("prev_hash", "") or "") == GENESIS_HASH
|
||||
and any("timestamp outside freshness window" in reason.lower() for reason in reasons)
|
||||
)
|
||||
if stale_genesis:
|
||||
detail = (
|
||||
f"{detail}; peer appears to be serving an expired genesis chain. "
|
||||
"Refresh or reset the peer chain, or perform an explicit one-time migration "
|
||||
"with MESH_INGEST_EVENT_MAX_AGE_S=0."
|
||||
)
|
||||
return False, detail, False, 0
|
||||
if int(result.get("accepted", 0) or 0) == 0 and int(result.get("duplicates", 0) or 0) >= len(events):
|
||||
return True, "", False, 0
|
||||
if len(events) < page_limit:
|
||||
@@ -1921,9 +2028,22 @@ def _propagate_public_event_to_peers(event_dict: dict[str, Any]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _propagate_ledger_event_to_peers(event_dict: dict[str, Any]) -> None:
|
||||
if not _participant_node_enabled():
|
||||
return
|
||||
event_type = str(event_dict.get("event_type") or "")
|
||||
if event_type in {"gate_message", "dm_message"}:
|
||||
from services.mesh.mesh_swarm_runtime import push_infonet_events_to_http_peers
|
||||
|
||||
push_infonet_events_to_http_peers([event_dict])
|
||||
_kick_public_sync_background("ledger_event")
|
||||
return
|
||||
_propagate_public_event_to_peers(event_dict)
|
||||
|
||||
|
||||
def _schedule_public_event_propagation(event_dict: dict[str, Any]) -> None:
|
||||
threading.Thread(
|
||||
target=_propagate_public_event_to_peers,
|
||||
target=_propagate_ledger_event_to_peers,
|
||||
args=(dict(event_dict),),
|
||||
daemon=True,
|
||||
).start()
|
||||
@@ -1959,6 +2079,7 @@ def _start_infonet_node_runtime(reason: str = "startup") -> None:
|
||||
threading.Thread(target=_http_peer_push_loop, daemon=True).start()
|
||||
threading.Thread(target=_http_gate_push_loop, daemon=True).start()
|
||||
threading.Thread(target=_http_gate_pull_loop, daemon=True).start()
|
||||
threading.Thread(target=_swarm_manifest_pull_loop, daemon=True).start()
|
||||
_NODE_RUNTIME_THREADS_STARTED = True
|
||||
_kick_public_sync_background(reason)
|
||||
if not _NODE_PUBLIC_EVENT_HOOK_REGISTERED:
|
||||
@@ -2066,6 +2187,22 @@ def _http_peer_push_loop() -> None:
|
||||
_NODE_SYNC_STOP.wait(_PEER_PUSH_INTERVAL_S)
|
||||
|
||||
|
||||
def _swarm_manifest_pull_loop() -> None:
|
||||
"""Background thread: pull signed peer manifests from bootstrap seeds."""
|
||||
while not _NODE_SYNC_STOP.is_set():
|
||||
try:
|
||||
if _participant_node_enabled():
|
||||
from services.mesh.mesh_swarm_runtime import refresh_swarm_manifest_from_seeds
|
||||
|
||||
result = refresh_swarm_manifest_from_seeds()
|
||||
if result.get("ok") and not result.get("skipped"):
|
||||
_refresh_node_peer_store()
|
||||
except Exception:
|
||||
logger.exception("swarm manifest pull loop error")
|
||||
interval_s = int(getattr(get_settings(), "MESH_SWARM_MANIFEST_PULL_INTERVAL_S", 0) or 300)
|
||||
_NODE_SYNC_STOP.wait(max(30, interval_s))
|
||||
|
||||
|
||||
# ─── Background Gate Message Pull Worker ─────────────────────────────────
|
||||
# Periodically pulls gate events from relay peers that this node is missing.
|
||||
# Complements the push loop: push sends OUR events to peers, pull fetches
|
||||
@@ -2610,8 +2747,10 @@ async def lifespan(app: FastAPI):
|
||||
if not _MESH_ONLY:
|
||||
def _startup_wormhole_runtime():
|
||||
try:
|
||||
from services.mesh.mesh_infonet_relay_bootstrap import ensure_infonet_relay_wormhole_ready
|
||||
from services.wormhole_supervisor import get_wormhole_state, sync_wormhole_with_settings
|
||||
|
||||
ensure_infonet_relay_wormhole_ready(reason="startup_relay")
|
||||
sync_wormhole_with_settings()
|
||||
_resume_private_delivery_background_work(
|
||||
current_tier=_current_private_lane_tier(get_wormhole_state()),
|
||||
@@ -3386,7 +3525,10 @@ def _request_private_surface_warmup(*, path: str, method: str, current_tier: str
|
||||
|
||||
|
||||
def _is_invite_scoped_prekey_bundle_lookup(request: Request, path: str) -> bool:
|
||||
if request.method.upper() != "GET" or str(path or "").strip() != "/api/mesh/dm/prekey-bundle":
|
||||
if request.method.upper() != "GET":
|
||||
return False
|
||||
normalized_path = str(path or "").strip()
|
||||
if normalized_path not in {"/api/mesh/dm/prekey-bundle", "/api/mesh/dm/pubkey"}:
|
||||
return False
|
||||
try:
|
||||
lookup_token = str(request.query_params.get("lookup_token", "") or "").strip()
|
||||
@@ -3487,6 +3629,14 @@ async def enforce_high_privacy_mesh(request: Request, call_next):
|
||||
except Exception:
|
||||
logger.debug("Private surface warm-up request failed", exc_info=True)
|
||||
required_tier = _minimum_transport_tier(path, request.method)
|
||||
if required_tier:
|
||||
from services.mesh.mesh_privacy_policy import runtime_route_enforcement_tier
|
||||
|
||||
required_tier = runtime_route_enforcement_tier(
|
||||
path,
|
||||
request.method,
|
||||
static_tier=required_tier,
|
||||
)
|
||||
if required_tier:
|
||||
if not _transport_tier_is_sufficient(current_tier, required_tier):
|
||||
if request.method.upper() == "POST" and path == "/api/mesh/dm/send":
|
||||
@@ -3651,6 +3801,7 @@ app.include_router(osint_router)
|
||||
app.include_router(scm_router)
|
||||
app.include_router(entity_graph_router)
|
||||
app.include_router(intel_feeds_router)
|
||||
app.include_router(agent_shell_router)
|
||||
|
||||
from services.data_fetcher import update_all_data
|
||||
|
||||
@@ -5495,6 +5646,65 @@ async def infonet_ingest(request: Request):
|
||||
return {"ok": True, **result}
|
||||
|
||||
|
||||
@app.get("/api/mesh/infonet/peer-registry", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("30/minute")
|
||||
async def infonet_peer_registry(request: Request):
|
||||
"""Operator view of the live swarm peer registry (seed nodes only)."""
|
||||
from services.mesh.mesh_peer_registry import DEFAULT_PEER_REGISTRY_PATH, PeerRegistry
|
||||
from services.mesh.mesh_swarm_runtime import peer_registry_enabled
|
||||
|
||||
if not peer_registry_enabled():
|
||||
return {"ok": False, "detail": "peer registry is not enabled on this node"}
|
||||
registry = PeerRegistry(DEFAULT_PEER_REGISTRY_PATH)
|
||||
try:
|
||||
peers = registry.load()
|
||||
except Exception as exc:
|
||||
return {"ok": False, "detail": str(exc or type(exc).__name__)}
|
||||
return {
|
||||
"ok": True,
|
||||
"peer_count": len(peers),
|
||||
"peers": [peer.to_dict() for peer in peers],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/mesh/infonet/bootstrap-manifest")
|
||||
@limiter.limit(_INFONET_SYNC_RATE_LIMIT)
|
||||
async def infonet_bootstrap_manifest(request: Request):
|
||||
"""Return the current signed bootstrap/swarm peer manifest."""
|
||||
from services.mesh.mesh_swarm_runtime import load_live_bootstrap_manifest
|
||||
|
||||
manifest = load_live_bootstrap_manifest()
|
||||
if manifest is None:
|
||||
return {"ok": False, "detail": "bootstrap manifest unavailable"}
|
||||
return {"ok": True, "manifest": manifest.to_dict()}
|
||||
|
||||
|
||||
@app.post("/api/mesh/infonet/peer-announce")
|
||||
@limiter.limit("30/minute")
|
||||
@mesh_write_exempt(MeshWriteExemption.PEER_GOSSIP)
|
||||
async def infonet_peer_announce(request: Request):
|
||||
"""Register a participant onion peer in the swarm registry (HMAC-authenticated)."""
|
||||
from auth import _peer_hmac_url_from_request
|
||||
from services.mesh.mesh_swarm_runtime import peer_registry_enabled, record_peer_announcement
|
||||
|
||||
body_bytes = await request.body()
|
||||
if not _verify_peer_transport_hmac(request, body_bytes):
|
||||
return Response(
|
||||
content='{"ok":false,"detail":"Invalid or missing peer HMAC"}',
|
||||
status_code=403,
|
||||
media_type="application/json",
|
||||
)
|
||||
if not peer_registry_enabled():
|
||||
return {"ok": False, "detail": "peer registry is not enabled on this node"}
|
||||
body = json_mod.loads(body_bytes or b"{}")
|
||||
announced_url = normalize_peer_url(str(body.get("peer_url", "") or ""))
|
||||
header_url = _peer_hmac_url_from_request(request)
|
||||
if not announced_url or announced_url != header_url:
|
||||
return {"ok": False, "detail": "peer_url must match X-Peer-Url"}
|
||||
peer = record_peer_announcement(body)
|
||||
return {"ok": True, "peer_url": peer.peer_url, "role": peer.role, "transport": peer.transport}
|
||||
|
||||
|
||||
@app.post("/api/mesh/infonet/peer-push")
|
||||
@limiter.limit("30/minute")
|
||||
@mesh_write_exempt(MeshWriteExemption.PEER_GOSSIP)
|
||||
@@ -5533,6 +5743,8 @@ async def infonet_peer_push(request: Request):
|
||||
result = infonet.ingest_events(events)
|
||||
_hydrate_gate_store_from_chain(events)
|
||||
_hydrate_dm_relay_from_chain(events)
|
||||
if any(str(event.get("event_type") or "") in {"gate_message", "dm_message"} for event in events):
|
||||
_kick_public_sync_background("peer_push_ingest")
|
||||
return {"ok": True, **result}
|
||||
|
||||
|
||||
@@ -6717,12 +6929,22 @@ def _queue_dm_release(*, current_tier: str, payload: dict[str, Any]) -> dict[str
|
||||
required_tier=release_lane_required_tier("dm"),
|
||||
)
|
||||
_wake_private_release_worker()
|
||||
outbox_id = str(item.get("id", "") or "")
|
||||
auto_release: dict[str, Any] = {"ok": True, "skipped": True}
|
||||
if outbox_id:
|
||||
try:
|
||||
from services.mesh.mesh_dm_connect_delivery import auto_release_connect_dm_outbox
|
||||
|
||||
auto_release = auto_release_connect_dm_outbox(outbox_id=outbox_id, payload=payload)
|
||||
except Exception as exc:
|
||||
auto_release = {"ok": False, "detail": str(exc) or type(exc).__name__}
|
||||
return {
|
||||
"ok": True,
|
||||
"msg_id": str(payload.get("msg_id", "") or ""),
|
||||
"outbox_id": str(item.get("id", "") or ""),
|
||||
"outbox_id": outbox_id,
|
||||
"queued": True,
|
||||
"detail": str((item.get("status") or {}).get("label", "") or "Queued for private delivery"),
|
||||
"auto_release": auto_release,
|
||||
"delivery": {
|
||||
"state": canonical_release_state(str(item.get("release_state", "") or "queued")),
|
||||
"internal_state": str(item.get("release_state", "") or "queued"),
|
||||
@@ -6895,7 +7117,8 @@ async def _dm_send_from_signed_request(request: Request):
|
||||
return {"ok": False, "detail": "DM timestamp is too far from current time"}
|
||||
if delivery_class not in ("request", "shared"):
|
||||
return {"ok": False, "detail": "delivery_class must be request or shared"}
|
||||
if delivery_class == "request":
|
||||
# Contact requests are the first-contact handshake — do not require prior verification.
|
||||
if delivery_class == "shared":
|
||||
try:
|
||||
from services.mesh.mesh_wormhole_contacts import verified_first_contact_requirement
|
||||
|
||||
@@ -6979,6 +7202,8 @@ async def _dm_send_from_signed_request(request: Request):
|
||||
|
||||
relay_salt_hex = _os.urandom(16).hex()
|
||||
|
||||
connect_intent = str(body.get("connect_intent", "") or "").strip().lower()
|
||||
lookup_peer_url = str(body.get("lookup_peer_url", "") or "").strip().rstrip("/")
|
||||
release_payload = {
|
||||
"sender_id": sender_id,
|
||||
"sender_token_hash": sender_token_hash,
|
||||
@@ -6993,6 +7218,16 @@ async def _dm_send_from_signed_request(request: Request):
|
||||
"sender_seal": sender_seal,
|
||||
"relay_salt": relay_salt_hex,
|
||||
}
|
||||
if connect_intent:
|
||||
release_payload["connect_intent"] = connect_intent
|
||||
if lookup_peer_url:
|
||||
release_payload["lookup_peer_url"] = lookup_peer_url
|
||||
try:
|
||||
from services.mesh.mesh_dm_connect_delivery import enrich_connect_release_payload
|
||||
|
||||
release_payload = enrich_connect_release_payload(release_payload)
|
||||
except Exception:
|
||||
pass
|
||||
hashchain_spool: dict[str, Any] = {"ok": False, "detail": "not attempted"}
|
||||
try:
|
||||
from services.mesh.mesh_hashchain import infonet
|
||||
@@ -7009,6 +7244,10 @@ async def _dm_send_from_signed_request(request: Request):
|
||||
"format": payload_format,
|
||||
}
|
||||
chain_payload["transport_lock"] = "private_strong"
|
||||
if connect_intent:
|
||||
chain_payload["connect_intent"] = connect_intent
|
||||
if lookup_peer_url:
|
||||
chain_payload["lookup_peer_url"] = lookup_peer_url
|
||||
chain_event = infonet.append_private_dm_message(
|
||||
node_id=sender_id,
|
||||
payload=chain_payload,
|
||||
@@ -7024,7 +7263,8 @@ async def _dm_send_from_signed_request(request: Request):
|
||||
or PROTOCOL_VERSION,
|
||||
timestamp=float(timestamp or time.time()),
|
||||
)
|
||||
_hydrate_dm_relay_from_chain([chain_event])
|
||||
# Relay deposit is deferred to the private release worker so scoped
|
||||
# connect traffic can synchronously replicate to lookup_peer_url once.
|
||||
hashchain_spool = {
|
||||
"ok": True,
|
||||
"event_id": str(chain_event.get("event_id", "") or ""),
|
||||
@@ -7279,7 +7519,12 @@ async def dm_register_key(request: Request):
|
||||
|
||||
@app.get("/api/mesh/dm/pubkey")
|
||||
@limiter.limit("30/minute")
|
||||
async def dm_get_pubkey(request: Request, agent_id: str = "", lookup_token: str = ""):
|
||||
async def dm_get_pubkey(
|
||||
request: Request,
|
||||
agent_id: str = "",
|
||||
lookup_token: str = "",
|
||||
lookup_peer_url: str = "",
|
||||
):
|
||||
"""Fetch an agent's DH public key for key exchange."""
|
||||
exposure = metadata_exposure_for_request(
|
||||
request,
|
||||
@@ -7299,11 +7544,49 @@ async def dm_get_pubkey(request: Request, agent_id: str = "", lookup_token: str
|
||||
if resolved_lookup:
|
||||
key_bundle, resolved_id = dm_relay.get_dh_key_by_lookup(resolved_lookup)
|
||||
if key_bundle is None:
|
||||
return dm_lookup_response_view(
|
||||
{"ok": False, "detail": "Agent not found or has no DH key", "lookup_mode": "invite_lookup_handle"},
|
||||
exposure=exposure,
|
||||
lookup_token_present=True,
|
||||
# Invite handles are minted on the owner's node. When a remote peer
|
||||
# pastes a short address, resolve it across the private fleet before
|
||||
# failing — same path as prekey-bundle import.
|
||||
from services.mesh.mesh_wormhole_prekey import fetch_dm_prekey_bundle
|
||||
|
||||
preferred_lookup_peer = str(lookup_peer_url or "").strip().rstrip("/")
|
||||
remote_bundle = fetch_dm_prekey_bundle(
|
||||
agent_id="",
|
||||
lookup_token=resolved_lookup,
|
||||
lookup_peer_urls=[preferred_lookup_peer] if preferred_lookup_peer else None,
|
||||
)
|
||||
if remote_bundle.get("ok"):
|
||||
bundle = dict(remote_bundle.get("bundle") or remote_bundle)
|
||||
dh_pub = str(
|
||||
bundle.get("identity_dh_pub_key", "")
|
||||
or remote_bundle.get("identity_dh_pub_key", "")
|
||||
or ""
|
||||
).strip()
|
||||
if dh_pub:
|
||||
resolved_id = str(remote_bundle.get("agent_id", "") or resolved_id or "").strip()
|
||||
key_bundle = {
|
||||
"dh_pub_key": dh_pub,
|
||||
"dh_algo": str(remote_bundle.get("dh_algo", "X25519") or "X25519"),
|
||||
"timestamp": int(remote_bundle.get("timestamp", 0) or 0),
|
||||
"public_key": str(remote_bundle.get("public_key", "") or ""),
|
||||
"public_key_algo": str(remote_bundle.get("public_key_algo", "") or ""),
|
||||
"signature": str(remote_bundle.get("signature", "") or ""),
|
||||
"sequence": int(remote_bundle.get("sequence", 0) or 0),
|
||||
"prekey_transparency_head": str(
|
||||
remote_bundle.get("prekey_transparency_head", "") or ""
|
||||
),
|
||||
"prekey_transparency_size": int(
|
||||
remote_bundle.get("prekey_transparency_size", 0) or 0
|
||||
),
|
||||
"witness_count": int(remote_bundle.get("witness_count", 0) or 0),
|
||||
"witness_latest_at": int(remote_bundle.get("witness_latest_at", 0) or 0),
|
||||
}
|
||||
if key_bundle is None:
|
||||
return dm_lookup_response_view(
|
||||
{"ok": False, "detail": "Agent not found or has no DH key", "lookup_mode": "invite_lookup_handle"},
|
||||
exposure=exposure,
|
||||
lookup_token_present=True,
|
||||
)
|
||||
lookup_mode = "invite_lookup_handle"
|
||||
if key_bundle is None and resolved_id:
|
||||
blocked = legacy_agent_id_lookup_blocked()
|
||||
@@ -7339,7 +7622,12 @@ async def dm_get_pubkey(request: Request, agent_id: str = "", lookup_token: str
|
||||
|
||||
@app.get("/api/mesh/dm/prekey-bundle")
|
||||
@limiter.limit("30/minute")
|
||||
async def dm_get_prekey_bundle(request: Request, agent_id: str = "", lookup_token: str = ""):
|
||||
async def dm_get_prekey_bundle(
|
||||
request: Request,
|
||||
agent_id: str = "",
|
||||
lookup_token: str = "",
|
||||
lookup_peer_url: str = "",
|
||||
):
|
||||
exposure = metadata_exposure_for_request(
|
||||
request,
|
||||
authenticated=_scoped_view_authenticated(request, "mesh"),
|
||||
@@ -7351,7 +7639,12 @@ async def dm_get_prekey_bundle(request: Request, agent_id: str = "", lookup_toke
|
||||
lookup_token_present=bool(lookup_token),
|
||||
)
|
||||
resolved_id, resolved_lookup = _preferred_dm_lookup_target(agent_id, lookup_token)
|
||||
result = fetch_dm_prekey_bundle(agent_id=resolved_id, lookup_token=resolved_lookup)
|
||||
preferred_lookup_peer = str(lookup_peer_url or "").strip().rstrip("/")
|
||||
result = fetch_dm_prekey_bundle(
|
||||
agent_id=resolved_id,
|
||||
lookup_token=resolved_lookup,
|
||||
lookup_peer_urls=[preferred_lookup_peer] if preferred_lookup_peer else None,
|
||||
)
|
||||
return dm_lookup_response_view(
|
||||
result,
|
||||
exposure=exposure,
|
||||
@@ -9094,9 +9387,35 @@ async def api_set_node_settings(request: Request, body: NodeSettingsUpdate):
|
||||
if bool(body.enabled):
|
||||
_start_infonet_node_runtime("operator_enable")
|
||||
_kick_public_sync_background("operator_enable")
|
||||
threading.Thread(target=_swarm_bootstrap_after_transport_ready, daemon=True).start()
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/mesh/infonet/swarm/join")
|
||||
@limiter.limit("10/minute")
|
||||
async def infonet_swarm_join(request: Request):
|
||||
"""Announce this node to the fleet seed and pull the signed peer manifest."""
|
||||
if not _participant_node_enabled():
|
||||
return {"ok": False, "detail": "participant node is disabled"}
|
||||
if _infonet_private_transport_required() and not _ensure_infonet_private_transport_ready("swarm_join"):
|
||||
return JSONResponse(
|
||||
{"ok": False, "detail": _infonet_private_transport_error()},
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
from services.mesh.mesh_swarm_runtime import announce_local_peer_to_seeds, refresh_swarm_manifest_from_seeds
|
||||
|
||||
announce = await asyncio.to_thread(announce_local_peer_to_seeds, force=True)
|
||||
manifest = await asyncio.to_thread(refresh_swarm_manifest_from_seeds, force=True)
|
||||
if manifest.get("ok"):
|
||||
await asyncio.to_thread(_refresh_node_peer_store)
|
||||
return {
|
||||
"ok": bool(announce.get("ok")) or bool(manifest.get("ok")),
|
||||
"announce": announce,
|
||||
"manifest_pull": manifest,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/settings/wormhole")
|
||||
@limiter.limit("30/minute")
|
||||
async def api_get_wormhole_settings(request: Request):
|
||||
@@ -9175,7 +9494,8 @@ class WormholeDmResetRequest(BaseModel):
|
||||
|
||||
|
||||
class WormholeDmBootstrapEncryptRequest(BaseModel):
|
||||
peer_id: str
|
||||
peer_id: str = ""
|
||||
lookup_token: str = ""
|
||||
plaintext: str
|
||||
|
||||
|
||||
@@ -9400,6 +9720,43 @@ def _get_contact_trust_level(peer_id: str) -> str:
|
||||
return "unpinned"
|
||||
|
||||
|
||||
def _compose_bundle_matches_invite_pin(peer_id: str, bundle: dict[str, Any]) -> bool:
|
||||
"""True when an invite-pinned contact already matches the supplied bundle."""
|
||||
try:
|
||||
from services.mesh.mesh_wormhole_contacts import list_wormhole_dm_contacts
|
||||
from services.mesh.mesh_wormhole_prekey import trust_fingerprint_for_bundle_record
|
||||
|
||||
contact = dict(list_wormhole_dm_contacts().get(str(peer_id or "").strip()) or {})
|
||||
if str(contact.get("trust_level", "") or "") != "invite_pinned":
|
||||
return False
|
||||
pinned = str(
|
||||
contact.get("remotePrekeyFingerprint", "")
|
||||
or contact.get("invitePinnedTrustFingerprint", "")
|
||||
or ""
|
||||
).strip().lower()
|
||||
if not pinned:
|
||||
return False
|
||||
bundle_record = dict(bundle or {})
|
||||
bundle_payload = dict(bundle_record.get("bundle") or bundle_record)
|
||||
candidate = str(bundle_record.get("trust_fingerprint", "") or "").strip().lower()
|
||||
if not candidate:
|
||||
candidate = str(
|
||||
trust_fingerprint_for_bundle_record(
|
||||
{
|
||||
"agent_id": str(peer_id or "").strip(),
|
||||
"bundle": bundle_payload,
|
||||
"public_key": str(bundle_record.get("public_key", "") or ""),
|
||||
"public_key_algo": str(bundle_record.get("public_key_algo", "") or "Ed25519"),
|
||||
"protocol_version": str(bundle_record.get("protocol_version", "") or ""),
|
||||
}
|
||||
)
|
||||
or ""
|
||||
).strip().lower()
|
||||
return bool(candidate and pinned == candidate)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def compose_wormhole_dm(
|
||||
*,
|
||||
peer_id: str,
|
||||
@@ -9464,8 +9821,11 @@ def compose_wormhole_dm(
|
||||
bundle = fetched_bundle
|
||||
if bundle and str(peer_id or "").strip():
|
||||
try:
|
||||
trust_state = observe_remote_prekey_bundle(str(peer_id or "").strip(), bundle)
|
||||
_compose_trust_level = str(trust_state.get("trust_level", "") or "")
|
||||
if _compose_bundle_matches_invite_pin(str(peer_id or "").strip(), bundle):
|
||||
_compose_trust_level = "invite_pinned"
|
||||
else:
|
||||
trust_state = observe_remote_prekey_bundle(str(peer_id or "").strip(), bundle)
|
||||
_compose_trust_level = str(trust_state.get("trust_level", "") or "")
|
||||
from services.mesh.mesh_wormhole_contacts import verified_first_contact_requirement
|
||||
|
||||
verified_first_contact = verified_first_contact_requirement(
|
||||
@@ -9646,21 +10006,11 @@ def decrypt_wormhole_dm_envelope(
|
||||
if not has_session.get("ok"):
|
||||
return has_session
|
||||
if not has_session.get("exists"):
|
||||
local_dh_secret = ""
|
||||
local_identity_alias = ""
|
||||
try:
|
||||
local_identity = read_wormhole_identity()
|
||||
local_dh_secret = str(local_identity.get("dh_private_key", "") or "")
|
||||
local_identity_alias = str(local_identity.get("node_id", "") or "")
|
||||
except Exception:
|
||||
local_dh_secret = ""
|
||||
local_identity_alias = ""
|
||||
ensured = ensure_mls_dm_session(
|
||||
resolved_local,
|
||||
resolved_remote,
|
||||
str(session_welcome or ""),
|
||||
local_dh_secret=local_dh_secret,
|
||||
identity_alias=local_identity_alias,
|
||||
identity_alias=resolved_local,
|
||||
)
|
||||
if not ensured.get("ok"):
|
||||
return ensured
|
||||
@@ -11137,9 +11487,12 @@ async def api_wormhole_dm_bootstrap_encrypt(request: Request, body: WormholeDmBo
|
||||
result = bootstrap_encrypt_for_peer(
|
||||
peer_id=str(body.peer_id or ""),
|
||||
plaintext=str(body.plaintext or ""),
|
||||
lookup_token=str(body.lookup_token or ""),
|
||||
)
|
||||
if isinstance(result, dict) and "trust_level" not in result:
|
||||
result["trust_level"] = _get_contact_trust_level(str(body.peer_id or ""))
|
||||
result["trust_level"] = _get_contact_trust_level(
|
||||
str(result.get("peer_id", "") or body.peer_id or "")
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -11155,7 +11508,7 @@ async def api_wormhole_dm_bootstrap_decrypt(request: Request, body: WormholeDmBo
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/wormhole/dm/sender-token", dependencies=[Depends(require_admin)])
|
||||
@app.post("/api/wormhole/dm/sender-token", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("60/minute")
|
||||
async def api_wormhole_dm_sender_token(request: Request, body: WormholeDmSenderTokenRequest):
|
||||
if _safe_int(body.count or 1, 1) > 1:
|
||||
@@ -11374,7 +11727,25 @@ async def api_wormhole_dm_contact_delete(request: Request, peer_id: str):
|
||||
return {"ok": True, "peer_id": peer_id, "deleted": deleted}
|
||||
|
||||
|
||||
_WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready"}
|
||||
@app.post("/api/wormhole/dm/contact/{peer_id}/sever", dependencies=[Depends(require_admin)])
|
||||
@limiter.limit("60/minute")
|
||||
async def api_wormhole_dm_contact_sever(request: Request, peer_id: str):
|
||||
from services.mesh.mesh_wormhole_contacts import sever_wormhole_dm_contact
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
if not isinstance(body, dict):
|
||||
body = {}
|
||||
block = bool(body.get("block", False))
|
||||
try:
|
||||
return sever_wormhole_dm_contact(peer_id, block=block)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "detail": str(exc)}
|
||||
|
||||
|
||||
_WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready", "arti_ready"}
|
||||
|
||||
|
||||
def _redact_wormhole_status(state: dict[str, Any], authenticated: bool) -> dict[str, Any]:
|
||||
|
||||
@@ -7,7 +7,7 @@ py-modules = []
|
||||
|
||||
[project]
|
||||
name = "backend"
|
||||
version = "0.9.82"
|
||||
version = "0.9.83"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"apscheduler==3.10.3",
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Local-operator PTY WebSocket for the Mesh Chat agent shell."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import fcntl
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import signal
|
||||
import struct
|
||||
import sys
|
||||
import termios
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from auth import (
|
||||
_current_admin_key,
|
||||
_debug_mode_enabled,
|
||||
_is_trusted_local_runtime_host,
|
||||
require_local_operator,
|
||||
)
|
||||
from services.agent_shell_settings import (
|
||||
get_agent_shell_settings,
|
||||
set_agent_shell_working_directory,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["agent-shell"])
|
||||
|
||||
|
||||
class AgentShellSettingsUpdate(BaseModel):
|
||||
working_directory: str = Field(min_length=1)
|
||||
|
||||
|
||||
def _set_winsize(fd: int, rows: int, cols: int) -> None:
|
||||
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
||||
|
||||
|
||||
def _published_local_dashboard_ws(ws: WebSocket) -> bool:
|
||||
"""Browser → published Docker port appears as a bridge IP, not loopback.
|
||||
|
||||
For the operator shell only, also accept when the upgrade request clearly
|
||||
targets the local dashboard (Host/Origin on localhost).
|
||||
"""
|
||||
host_header = str(ws.headers.get("host") or "").strip().lower()
|
||||
host_name = host_header.split(":", 1)[0]
|
||||
if host_name in {"127.0.0.1", "localhost", "::1"}:
|
||||
return True
|
||||
|
||||
origin = str(ws.headers.get("origin") or "").strip().lower()
|
||||
if origin.startswith("http://127.0.0.1:") or origin.startswith("http://localhost:"):
|
||||
return True
|
||||
if origin.startswith("https://127.0.0.1:") or origin.startswith("https://localhost:"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _authorize_agent_shell_ws(ws: WebSocket, admin_key_query: str = "") -> None:
|
||||
host = (ws.client.host or "").lower() if ws.client else ""
|
||||
if (
|
||||
_is_trusted_local_runtime_host(host)
|
||||
or _published_local_dashboard_ws(ws)
|
||||
or (_debug_mode_enabled() and host == "test")
|
||||
):
|
||||
return
|
||||
admin_key = _current_admin_key()
|
||||
presented = str(admin_key_query or ws.headers.get("x-admin-key", "") or "").strip()
|
||||
if admin_key and presented and hmac.compare_digest(presented.encode(), admin_key.encode()):
|
||||
return
|
||||
await ws.close(code=4403, reason="local operator access only")
|
||||
raise WebSocketDisconnect()
|
||||
|
||||
|
||||
def _resolve_shell_cwd(requested: str) -> str:
|
||||
requested = str(requested or "").strip()
|
||||
if requested:
|
||||
resolved = os.path.abspath(os.path.expanduser(requested))
|
||||
if os.path.isdir(resolved):
|
||||
return resolved
|
||||
return get_agent_shell_settings()["working_directory"]
|
||||
|
||||
|
||||
def _default_shell() -> str:
|
||||
if sys.platform == "win32":
|
||||
return os.environ.get("COMSPEC", "cmd.exe")
|
||||
return os.environ.get("SHELL", "/bin/bash")
|
||||
|
||||
|
||||
async def _relay_pty(master_fd: int, proc: asyncio.subprocess.Process, ws: WebSocket) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
while True:
|
||||
if proc.returncode is not None:
|
||||
break
|
||||
try:
|
||||
readable, _, _ = await loop.run_in_executor(
|
||||
None, lambda: select.select([master_fd], [], [], 0.05)
|
||||
)
|
||||
except Exception:
|
||||
break
|
||||
if master_fd in readable:
|
||||
try:
|
||||
chunk = os.read(master_fd, 4096)
|
||||
except OSError:
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
await ws.send_bytes(chunk)
|
||||
try:
|
||||
message = await asyncio.wait_for(ws.receive(), timeout=0.05)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
if message.get("type") == "websocket.disconnect":
|
||||
break
|
||||
if message.get("type") != "websocket.receive":
|
||||
continue
|
||||
if message.get("bytes"):
|
||||
os.write(master_fd, message["bytes"])
|
||||
continue
|
||||
text = message.get("text")
|
||||
if not text:
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
os.write(master_fd, text.encode("utf-8", errors="replace"))
|
||||
continue
|
||||
if payload.get("type") == "resize":
|
||||
rows = int(payload.get("rows") or 24)
|
||||
cols = int(payload.get("cols") or 80)
|
||||
_set_winsize(master_fd, max(rows, 2), max(cols, 2))
|
||||
|
||||
|
||||
@router.get("/api/agent-shell/settings", dependencies=[Depends(require_local_operator)])
|
||||
async def read_agent_shell_settings() -> dict[str, Any]:
|
||||
return get_agent_shell_settings()
|
||||
|
||||
|
||||
@router.put("/api/agent-shell/settings", dependencies=[Depends(require_local_operator)])
|
||||
async def write_agent_shell_settings(body: AgentShellSettingsUpdate) -> dict[str, Any]:
|
||||
try:
|
||||
return set_agent_shell_working_directory(body.working_directory)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "working_directory_not_found":
|
||||
raise HTTPException(status_code=400, detail="Working directory does not exist") from exc
|
||||
raise HTTPException(status_code=400, detail="Working directory is required") from exc
|
||||
|
||||
|
||||
@router.websocket("/api/agent-shell/ws")
|
||||
async def agent_shell_websocket(
|
||||
ws: WebSocket,
|
||||
cwd: str = Query(default=""),
|
||||
cols: int = Query(default=80),
|
||||
rows: int = Query(default=24),
|
||||
admin_key: str = Query(default=""),
|
||||
) -> None:
|
||||
await ws.accept()
|
||||
try:
|
||||
await _authorize_agent_shell_ws(ws, admin_key)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
|
||||
if sys.platform == "win32":
|
||||
await ws.send_text(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "error",
|
||||
"message": "Host PTY is not available on Windows backend builds yet. Use the ShadowBroker desktop app or run the backend in Docker/Linux for an embedded shell.",
|
||||
}
|
||||
)
|
||||
)
|
||||
await ws.close(code=1011)
|
||||
return
|
||||
|
||||
shell_cwd = _resolve_shell_cwd(cwd)
|
||||
shell = _default_shell()
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
_set_winsize(master_fd, max(rows, 2), max(cols, 2))
|
||||
|
||||
env = os.environ.copy()
|
||||
env.setdefault("TERM", "xterm-256color")
|
||||
env.setdefault("COLORTERM", "truecolor")
|
||||
home = shell_cwd if os.path.isdir(shell_cwd) else "/app"
|
||||
env["HOME"] = home
|
||||
env["USER"] = env.get("USER") or "operator"
|
||||
path_prefixes = [
|
||||
os.path.join(home, ".local", "bin"),
|
||||
os.path.join(home, ".hermes", "bin"),
|
||||
]
|
||||
path = env.get("PATH", "")
|
||||
for prefix in path_prefixes:
|
||||
if os.path.isdir(prefix):
|
||||
path = f"{prefix}:{path}" if path else prefix
|
||||
env["PATH"] = path
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
shell,
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
cwd=shell_cwd,
|
||||
env=env,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
os.close(slave_fd)
|
||||
|
||||
try:
|
||||
await _relay_pty(master_fd, proc, ws)
|
||||
finally:
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
if proc.returncode is None:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGHUP)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=2.0)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
@@ -2276,12 +2276,14 @@ async def agent_tool_manifest(request: Request):
|
||||
async def api_capabilities(request: Request):
|
||||
"""Return full API manifest so the agent knows every available endpoint."""
|
||||
from services.openclaw_channel import READ_COMMANDS, WRITE_COMMANDS, detect_tier
|
||||
from services.openclaw_routing import routing_manifest
|
||||
from services.config import get_settings
|
||||
tier = detect_tier()
|
||||
access_tier = str(get_settings().OPENCLAW_ACCESS_TIER or "restricted").strip().lower()
|
||||
return {
|
||||
"ok": True,
|
||||
"version": "0.9.82",
|
||||
"routing": routing_manifest(),
|
||||
"auth": {
|
||||
"method": "HMAC-SHA256",
|
||||
"headers": ["X-SB-Timestamp", "X-SB-Nonce", "X-SB-Signature"],
|
||||
@@ -2397,8 +2399,16 @@ async def api_capabilities(request: Request):
|
||||
"description": "Compact server-side ship search by MMSI/IMO/name/query, including yacht-owner enrichment.",
|
||||
},
|
||||
"find_entity": {
|
||||
"args": {"query": "str (optional)", "entity_type": "aircraft|ship|person|event|infrastructure (optional)", "callsign": "str (optional)", "registration": "str (optional)", "icao24": "str (optional)", "mmsi": "str (optional)", "imo": "str (optional)", "name": "str (optional)", "owner": "str (optional)", "layers": "list[str] (optional)", "limit": "int (default 10)"},
|
||||
"description": "Exact-first resolver for planes, ships, operators, callsigns, registrations, MMSI/IMO, and named entities. Use before tracking to avoid fuzzy prompt matching.",
|
||||
"args": {"query": "str (optional)", "entity_type": "aircraft|ship|person|event|infrastructure (optional)", "callsign": "str (optional)", "registration": "str (optional)", "icao24": "str (optional)", "mmsi": "str (optional)", "imo": "str (optional)", "name": "str (optional)", "owner": "str (optional)", "layers": "list[str] (optional)", "limit": "int (default 10)", "fallback_search": "bool (default false)", "confirm_fuzzy": "bool (alias for fallback_search)"},
|
||||
"description": "Exact-first resolver for planes, ships, operators, callsigns, registrations, MMSI/IMO, and named entities. Skips fuzzy search unless fallback_search=true or no exact match.",
|
||||
},
|
||||
"route_query": {
|
||||
"args": {"text": "str", "lat": "float (optional)", "lng": "float (optional)", "radius_km": "float (default 50)", "compact": "bool (default true)"},
|
||||
"description": "Deterministic intent router — returns recommended fast command, alternates, and latency estimate. Preferred entry for natural-language reads.",
|
||||
},
|
||||
"run_playbook": {
|
||||
"args": {"name": "str", "query": "str (optional)", "lat": "float (optional)", "lng": "float (optional)"},
|
||||
"description": "Execute a named batch plan (hot_snapshot, morning_brief, monitor_heartbeat, track_snapshot, area_brief, entity_recon).",
|
||||
},
|
||||
"correlate_entity": {
|
||||
"args": {"query": "str (optional)", "entity_type": "str (optional)", "callsign": "str (optional)", "registration": "str (optional)", "icao24": "str (optional)", "mmsi": "str (optional)", "imo": "str (optional)", "name": "str (optional)", "owner": "str (optional)", "radius_km": "float (default 100)", "limit": "int (default 10)"},
|
||||
@@ -2578,7 +2588,8 @@ async def api_capabilities(request: Request):
|
||||
"layers are serialized, unchanged layers transfer zero bytes. The client tracks versions "
|
||||
"automatically from SSE events and previous responses. "
|
||||
"3) Pass compact=true on every read command for compressed_v1 responses (~60-90% smaller). "
|
||||
"4) Use targeted commands first (find_flights, search_telemetry, entities_near). "
|
||||
"4) Use route_query / find_entity / run_playbook before search_telemetry. "
|
||||
"Expensive commands require confirm_expensive=true. "
|
||||
"Reserve get_telemetry/get_slow_telemetry for rare full-context pulls.",
|
||||
"pins": "Pins are server-side, NOT localStorage. Use place_pin command or POST /api/ai/pins. The agent can place and delete pins.",
|
||||
"tracking": "To track a specific aircraft without polling: use add_watch with track_callsign or track_registration. Over SSE, you'll get instant push alerts.",
|
||||
@@ -2708,6 +2719,7 @@ def _connect_info_metadata(settings) -> dict:
|
||||
"get_telemetry", "get_pins", "satellite_images",
|
||||
"news_near", "ai_summary", "ai_report",
|
||||
"timemachine_list", "timemachine_view",
|
||||
"infonet_status", "list_gates", "read_gate_messages", "poll_dms",
|
||||
],
|
||||
},
|
||||
"full": {
|
||||
@@ -2718,6 +2730,8 @@ def _connect_info_metadata(settings) -> dict:
|
||||
"satellite_images", "news_near", "data_injection",
|
||||
"ai_summary", "ai_report", "timemachine_snapshot",
|
||||
"timemachine_list", "timemachine_view", "timemachine_diff",
|
||||
"ensure_infonet_ready", "join_infonet_swarm",
|
||||
"post_gate_message", "cast_vote", "send_dm",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -65,6 +65,10 @@ def _hydrate_dm_relay_from_chain(events: list) -> int:
|
||||
@limiter.limit("30/minute")
|
||||
async def infonet_peer_push(request: Request):
|
||||
"""Accept pushed Infonet events from relay peers (HMAC-authenticated)."""
|
||||
from services.mesh.mesh_fleet_defaults import infonet_fleet_join_enabled
|
||||
|
||||
if not infonet_fleet_join_enabled():
|
||||
return {"ok": True, "accepted": 0, "duplicates": 0, "rejected": [], "skipped": "fleet_join_disabled"}
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length:
|
||||
try:
|
||||
@@ -154,6 +158,10 @@ async def dm_replicate_envelope(request: Request):
|
||||
@limiter.limit("30/minute")
|
||||
async def gate_peer_push(request: Request):
|
||||
"""Accept pushed gate events from relay peers (private plane)."""
|
||||
from services.mesh.mesh_fleet_defaults import infonet_fleet_join_enabled
|
||||
|
||||
if not infonet_fleet_join_enabled():
|
||||
return {"ok": True, "accepted": 0, "duplicates": 0, "skipped": "fleet_join_disabled"}
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length:
|
||||
try:
|
||||
|
||||
@@ -308,6 +308,10 @@ class WormholeDmDecryptRequest(BaseModel):
|
||||
session_welcome: str | None = None
|
||||
|
||||
|
||||
class WormholeDmMlsKeyPackageRequest(BaseModel):
|
||||
alias: str
|
||||
|
||||
|
||||
class WormholeDmResetRequest(BaseModel):
|
||||
peer_id: str | None = None
|
||||
|
||||
@@ -326,6 +330,14 @@ class WormholeDmBootstrapDecryptRequest(BaseModel):
|
||||
ciphertext: str
|
||||
|
||||
|
||||
class WormholeDmConnectContactRequest(BaseModel):
|
||||
lookup_token: str = ""
|
||||
peer_id: str = ""
|
||||
note: str = ""
|
||||
lookup_peer_url: str = ""
|
||||
cached_prekey_bundle: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class WormholeDmInviteImportRequest(BaseModel):
|
||||
invite: dict[str, Any]
|
||||
alias: str = ""
|
||||
@@ -1085,7 +1097,21 @@ async def api_wormhole_dm_bootstrap_decrypt(request: Request, body: WormholeDmBo
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/wormhole/dm/sender-token", dependencies=[Depends(require_admin)])
|
||||
@router.post("/api/wormhole/dm/connect-contact", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_dm_connect_contact(request: Request, body: WormholeDmConnectContactRequest):
|
||||
from services.openclaw_infonet import send_contact_request
|
||||
|
||||
return send_contact_request(
|
||||
lookup_token=str(body.lookup_token or ""),
|
||||
peer_id=str(body.peer_id or ""),
|
||||
note=str(body.note or ""),
|
||||
lookup_peer_url=str(body.lookup_peer_url or ""),
|
||||
cached_prekey_bundle=dict(body.cached_prekey_bundle or {}) if body.cached_prekey_bundle else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/wormhole/dm/sender-token", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("60/minute")
|
||||
async def api_wormhole_dm_sender_token(request: Request, body: WormholeDmSenderTokenRequest):
|
||||
if _safe_int(body.count or 1, 1) > 1:
|
||||
@@ -1228,6 +1254,23 @@ async def api_wormhole_dm_decrypt(request: Request, body: WormholeDmDecryptReque
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/wormhole/dm/mls-key-package", dependencies=[Depends(require_admin)])
|
||||
@limiter.limit("60/minute")
|
||||
async def api_wormhole_dm_mls_key_package(request: Request, body: WormholeDmMlsKeyPackageRequest):
|
||||
from services.mesh.mesh_dm_mls import export_dm_key_package_for_alias
|
||||
|
||||
return export_dm_key_package_for_alias(str(body.alias or "").strip())
|
||||
|
||||
|
||||
@router.post("/api/wormhole/dm/mls-reset", dependencies=[Depends(require_admin)])
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_dm_mls_reset(request: Request):
|
||||
from services.mesh.mesh_dm_mls import reset_dm_mls_state
|
||||
|
||||
reset_dm_mls_state(clear_privacy_core=True, clear_persistence=True)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/api/wormhole/dm/reset", dependencies=[Depends(require_admin)])
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_dm_reset(request: Request, body: WormholeDmResetRequest):
|
||||
@@ -1287,7 +1330,25 @@ async def api_wormhole_dm_contact_delete(request: Request, peer_id: str):
|
||||
return {"ok": True, "peer_id": peer_id, "deleted": deleted}
|
||||
|
||||
|
||||
_WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready"}
|
||||
@router.post("/api/wormhole/dm/contact/{peer_id}/sever", dependencies=[Depends(require_admin)])
|
||||
@limiter.limit("60/minute")
|
||||
async def api_wormhole_dm_contact_sever(request: Request, peer_id: str):
|
||||
from services.mesh.mesh_wormhole_contacts import sever_wormhole_dm_contact
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
if not isinstance(body, dict):
|
||||
body = {}
|
||||
block = bool(body.get("block", False))
|
||||
try:
|
||||
return sever_wormhole_dm_contact(peer_id, block=block)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "detail": str(exc)}
|
||||
|
||||
|
||||
_WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready", "arti_ready"}
|
||||
|
||||
|
||||
def _redact_wormhole_status(state: dict[str, Any], authenticated: bool) -> dict[str, Any]:
|
||||
@@ -1308,6 +1369,25 @@ async def api_wormhole_status(request: Request):
|
||||
return await _m.api_wormhole_status(request)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/wormhole/private-delivery/{item_id}",
|
||||
dependencies=[Depends(require_local_operator)],
|
||||
)
|
||||
@limiter.limit("120/minute")
|
||||
async def api_wormhole_private_delivery_item(request: Request, item_id: str):
|
||||
from services.mesh.mesh_metadata_exposure import metadata_exposure_for_request
|
||||
from services.mesh.mesh_private_outbox import private_delivery_outbox
|
||||
|
||||
exposure = metadata_exposure_for_request(
|
||||
request,
|
||||
authenticated=True,
|
||||
)
|
||||
item = private_delivery_outbox.get_item(item_id, exposure=exposure)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="private_delivery_item_not_found")
|
||||
return {"ok": True, "item": item}
|
||||
|
||||
|
||||
@router.post("/api/wormhole/private-delivery/{item_id}/action", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_private_delivery_action(
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Operator settings for the embedded agent shell (working directory)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SETTINGS_FILE = Path(__file__).resolve().parent.parent / "data" / "agent_shell_settings.json"
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _default_working_directory() -> str:
|
||||
explicit = str(os.environ.get("AGENT_SHELL_DEFAULT_CWD") or "").strip()
|
||||
if explicit and os.path.isdir(explicit):
|
||||
return explicit
|
||||
home = str(os.environ.get("HOME") or "").strip()
|
||||
if home and home != "/nonexistent" and os.path.isdir(home):
|
||||
return home
|
||||
return "/app"
|
||||
|
||||
|
||||
def get_agent_shell_settings() -> dict[str, Any]:
|
||||
with _LOCK:
|
||||
if not _SETTINGS_FILE.exists():
|
||||
return {"working_directory": _default_working_directory()}
|
||||
try:
|
||||
payload = json.loads(_SETTINGS_FILE.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
logger.warning("agent_shell_settings_unreadable")
|
||||
return {"working_directory": _default_working_directory()}
|
||||
cwd = str(payload.get("working_directory") or "").strip() or _default_working_directory()
|
||||
return {"working_directory": cwd}
|
||||
|
||||
|
||||
def set_agent_shell_working_directory(path: str) -> dict[str, Any]:
|
||||
normalized = str(path or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError("working_directory_required")
|
||||
resolved = os.path.abspath(os.path.expanduser(normalized))
|
||||
if not os.path.isdir(resolved):
|
||||
raise ValueError("working_directory_not_found")
|
||||
with _LOCK:
|
||||
_SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
_SETTINGS_FILE.write_text(
|
||||
json.dumps({"working_directory": resolved}, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return {"working_directory": resolved}
|
||||
@@ -30,6 +30,10 @@ class Settings(BaseSettings):
|
||||
MESH_MQTT_INCLUDE_DEFAULT_ROOTS: bool = True
|
||||
MESH_RNS_ENABLED: bool = False
|
||||
MESH_ARTI_ENABLED: bool = False
|
||||
# When true, trust wormhole_status.json ready bit if the child process is
|
||||
# alive — avoids transport-tier flapping when /api/health probes time out
|
||||
# under Tor load (common during live DM E2E).
|
||||
MESH_WORMHOLE_TRUST_FILE_READY: bool = False
|
||||
MESH_ARTI_SOCKS_PORT: int = 9050
|
||||
MESH_RELAY_PEERS: str = ""
|
||||
MESH_PUBLIC_PEER_URL: str = ""
|
||||
@@ -43,7 +47,24 @@ class Settings(BaseSettings):
|
||||
MESH_INFONET_ALLOW_CLEARNET_SYNC: bool = False
|
||||
MESH_BOOTSTRAP_DISABLED: bool = False
|
||||
MESH_BOOTSTRAP_MANIFEST_PATH: str = "data/bootstrap_peers.json"
|
||||
MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY: str = ""
|
||||
# Public sb-testnet-0 fleet signer (participants). Seed operator holds the private key.
|
||||
MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY: str = (
|
||||
"ul1d0kj/ODPIp0OhHzX8eLAVXzJ3CVvzW1vn2IC6q3I="
|
||||
)
|
||||
MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY: str = ""
|
||||
# When true, empty MESH_PEER_PUSH_SECRET uses the public fleet HMAC for seed join/announce.
|
||||
MESH_INFONET_FLEET_JOIN: bool = True
|
||||
MESH_INFONET_FLEET_JOIN_DISABLED: bool = False
|
||||
# Headless relay/seed compose: auto-enable Tor wormhole on startup so
|
||||
# docker compose redeploys keep the fleet onion reachable.
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE: bool = False
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED: bool = False
|
||||
MESH_BOOTSTRAP_SIGNER_ID: str = ""
|
||||
MESH_PEER_REGISTRY_ENABLED: bool = False
|
||||
MESH_PEER_REGISTRY_DISABLED: bool = False
|
||||
MESH_PEER_REGISTRY_STALE_S: int = 604800
|
||||
MESH_SWARM_MANIFEST_TTL_S: int = 14400
|
||||
MESH_SWARM_MANIFEST_PULL_INTERVAL_S: int = 300
|
||||
MESH_NODE_MODE: str = "participant"
|
||||
MESH_SYNC_INTERVAL_S: int = 300
|
||||
MESH_SYNC_FAILURE_BACKOFF_S: int = 60
|
||||
|
||||
@@ -287,28 +287,18 @@ def write_signed_bootstrap_manifest(
|
||||
return manifest
|
||||
|
||||
|
||||
def load_bootstrap_manifest(
|
||||
path: str | Path,
|
||||
def parse_bootstrap_manifest_dict(
|
||||
raw: dict[str, Any],
|
||||
*,
|
||||
signer_public_key_b64: str,
|
||||
now: float | None = None,
|
||||
) -> BootstrapManifest:
|
||||
manifest_path = _resolve_manifest_path(str(path))
|
||||
try:
|
||||
raw = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
raise BootstrapManifestError(f"bootstrap manifest not found: {manifest_path}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise BootstrapManifestError("bootstrap manifest is not valid JSON") from exc
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise BootstrapManifestError("bootstrap manifest root must be an object")
|
||||
|
||||
signature = str(raw.get("signature", "") or "").strip()
|
||||
payload = {key: value for key, value in raw.items() if key != "signature"}
|
||||
if not signature:
|
||||
raise BootstrapManifestError("bootstrap manifest signature is required")
|
||||
|
||||
_verify_manifest_signature(
|
||||
payload,
|
||||
signature_b64=signature,
|
||||
@@ -325,11 +315,36 @@ def load_bootstrap_manifest(
|
||||
)
|
||||
|
||||
|
||||
def load_bootstrap_manifest(
|
||||
path: str | Path,
|
||||
*,
|
||||
signer_public_key_b64: str,
|
||||
now: float | None = None,
|
||||
) -> BootstrapManifest:
|
||||
manifest_path = _resolve_manifest_path(str(path))
|
||||
try:
|
||||
raw = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
raise BootstrapManifestError(f"bootstrap manifest not found: {manifest_path}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise BootstrapManifestError("bootstrap manifest is not valid JSON") from exc
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise BootstrapManifestError("bootstrap manifest root must be an object")
|
||||
return parse_bootstrap_manifest_dict(
|
||||
raw,
|
||||
signer_public_key_b64=signer_public_key_b64,
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
def load_bootstrap_manifest_from_settings(*, now: float | None = None) -> BootstrapManifest | None:
|
||||
settings = get_settings()
|
||||
if bool(getattr(settings, "MESH_BOOTSTRAP_DISABLED", False)):
|
||||
return None
|
||||
signer_public_key_b64 = str(getattr(settings, "MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", "") or "").strip()
|
||||
from services.mesh.mesh_fleet_defaults import effective_bootstrap_signer_public_key_b64
|
||||
|
||||
signer_public_key_b64 = effective_bootstrap_signer_public_key_b64()
|
||||
if not signer_public_key_b64:
|
||||
return None
|
||||
manifest_path = _resolve_manifest_path(str(getattr(settings, "MESH_BOOTSTRAP_MANIFEST_PATH", "") or ""))
|
||||
|
||||
@@ -168,9 +168,9 @@ def resolve_peer_key_for_url(peer_url: str) -> bytes:
|
||||
try:
|
||||
from services.config import get_settings
|
||||
|
||||
global_secret = str(
|
||||
getattr(get_settings(), "MESH_PEER_PUSH_SECRET", "") or ""
|
||||
).strip()
|
||||
from services.mesh.mesh_fleet_defaults import effective_peer_push_secret
|
||||
|
||||
global_secret = effective_peer_push_secret()
|
||||
except Exception:
|
||||
return b""
|
||||
if not global_secret:
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Invite-scoped DM connect delivery: auto relay release and contact severance."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
CONNECT_AUTO_RELEASE_INTENTS = frozenset(
|
||||
{
|
||||
"invite_short_address",
|
||||
"invite_import",
|
||||
"contact_request",
|
||||
"contact_accept",
|
||||
"contact_offer",
|
||||
}
|
||||
)
|
||||
|
||||
INVITE_CONNECT_TRUST_LEVELS = frozenset({"invite_pinned", "sas_verified"})
|
||||
|
||||
|
||||
def _release_profile() -> str:
|
||||
try:
|
||||
from services.release_profiles import current_release_profile
|
||||
|
||||
return str(current_release_profile() or "dev")
|
||||
except Exception:
|
||||
return "dev"
|
||||
|
||||
|
||||
def grant_connect_relay_policy(
|
||||
recipient_id: str,
|
||||
*,
|
||||
reason: str = "connect_scoped_auto_release",
|
||||
) -> dict[str, Any]:
|
||||
"""Pre-authorize hidden relay delivery for an explicit connect target."""
|
||||
peer_key = str(recipient_id or "").strip()
|
||||
if not peer_key:
|
||||
return {"ok": False, "detail": "recipient_id required"}
|
||||
try:
|
||||
from services.mesh.mesh_relay_policy import grant_relay_policy
|
||||
|
||||
return grant_relay_policy(
|
||||
scope_type="dm_contact",
|
||||
scope_id=peer_key,
|
||||
profile=_release_profile(),
|
||||
hidden_transport_required=True,
|
||||
reason=str(reason or "connect_scoped_auto_release"),
|
||||
)
|
||||
except Exception as exc:
|
||||
return {"ok": False, "detail": str(exc) or type(exc).__name__}
|
||||
|
||||
|
||||
def revoke_connect_relay_policy(recipient_id: str) -> dict[str, Any]:
|
||||
peer_key = str(recipient_id or "").strip()
|
||||
if not peer_key:
|
||||
return {"ok": False, "detail": "recipient_id required"}
|
||||
try:
|
||||
from services.mesh.mesh_relay_policy import revoke_relay_policy
|
||||
|
||||
revoked = int(
|
||||
revoke_relay_policy(
|
||||
scope_type="dm_contact",
|
||||
scope_id=peer_key,
|
||||
profile=_release_profile(),
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return {"ok": True, "revoked": revoked}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "detail": str(exc) or type(exc).__name__}
|
||||
|
||||
|
||||
def recipient_has_invite_connect_scope(recipient_id: str) -> bool:
|
||||
peer_key = str(recipient_id or "").strip()
|
||||
if not peer_key:
|
||||
return False
|
||||
try:
|
||||
from services.mesh.mesh_wormhole_contacts import get_wormhole_dm_contact
|
||||
|
||||
contact = get_wormhole_dm_contact(peer_key) or {}
|
||||
except Exception:
|
||||
return False
|
||||
if str(contact.get("invitePinnedPrekeyLookupHandle", "") or "").strip():
|
||||
return True
|
||||
if str(contact.get("invitePinnedLookupPeerUrl", "") or "").strip():
|
||||
return True
|
||||
trust = str(contact.get("trust_level", "") or "").strip().lower()
|
||||
return trust in INVITE_CONNECT_TRUST_LEVELS
|
||||
|
||||
|
||||
def relay_push_peer_urls_for_payload(payload: dict[str, Any]) -> list[str]:
|
||||
urls: list[str] = []
|
||||
for raw in list(payload.get("relay_push_peer_urls") or []):
|
||||
normalized = str(raw or "").strip().rstrip("/")
|
||||
if normalized and normalized not in urls:
|
||||
urls.append(normalized)
|
||||
lookup_peer_url = str(payload.get("lookup_peer_url", "") or "").strip().rstrip("/")
|
||||
if lookup_peer_url:
|
||||
urls = [url for url in urls if url != lookup_peer_url]
|
||||
urls.insert(0, lookup_peer_url)
|
||||
recipient_id = str(payload.get("recipient_id", "") or "").strip()
|
||||
if recipient_id and not urls:
|
||||
try:
|
||||
from services.mesh.mesh_wormhole_contacts import get_wormhole_dm_contact
|
||||
|
||||
contact = get_wormhole_dm_contact(recipient_id) or {}
|
||||
pinned = str(contact.get("invitePinnedLookupPeerUrl", "") or "").strip().rstrip("/")
|
||||
if pinned:
|
||||
urls.append(pinned)
|
||||
except Exception:
|
||||
pass
|
||||
return urls
|
||||
|
||||
|
||||
def should_auto_release_dm_payload(payload: dict[str, Any]) -> bool:
|
||||
if str(payload.get("delivery_class", "") or "").strip().lower() != "request":
|
||||
return False
|
||||
intent = str(payload.get("connect_intent", "") or "").strip().lower()
|
||||
if intent in CONNECT_AUTO_RELEASE_INTENTS:
|
||||
return True
|
||||
if str(payload.get("lookup_peer_url", "") or "").strip():
|
||||
return True
|
||||
recipient_id = str(payload.get("recipient_id", "") or "").strip()
|
||||
return bool(recipient_id and recipient_has_invite_connect_scope(recipient_id))
|
||||
|
||||
|
||||
def enrich_connect_release_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Attach invite-owner relay hints used during private release."""
|
||||
enriched = dict(payload or {})
|
||||
recipient_id = str(enriched.get("recipient_id", "") or "").strip()
|
||||
lookup_peer_url = str(enriched.get("lookup_peer_url", "") or "").strip().rstrip("/")
|
||||
if not lookup_peer_url and recipient_id:
|
||||
try:
|
||||
from services.mesh.mesh_wormhole_contacts import get_wormhole_dm_contact
|
||||
|
||||
contact = get_wormhole_dm_contact(recipient_id) or {}
|
||||
lookup_peer_url = str(contact.get("invitePinnedLookupPeerUrl", "") or "").strip().rstrip("/")
|
||||
except Exception:
|
||||
lookup_peer_url = ""
|
||||
if lookup_peer_url:
|
||||
enriched["lookup_peer_url"] = lookup_peer_url
|
||||
push_urls = relay_push_peer_urls_for_payload(enriched)
|
||||
if push_urls:
|
||||
enriched["relay_push_peer_urls"] = push_urls
|
||||
return enriched
|
||||
|
||||
|
||||
def auto_release_connect_dm_outbox(*, outbox_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Grant scoped relay policy and approve release for invite-scoped connect traffic."""
|
||||
normalized_outbox = str(outbox_id or "").strip()
|
||||
enriched = enrich_connect_release_payload(payload)
|
||||
if not normalized_outbox:
|
||||
return {"ok": False, "detail": "missing outbox_id"}
|
||||
if not should_auto_release_dm_payload(enriched):
|
||||
return {"ok": True, "skipped": True, "reason": "not_connect_scoped"}
|
||||
recipient_id = str(enriched.get("recipient_id", "") or "").strip()
|
||||
if not recipient_id:
|
||||
return {"ok": False, "detail": "missing recipient_id"}
|
||||
grant = grant_connect_relay_policy(recipient_id)
|
||||
try:
|
||||
from services.mesh.mesh_private_outbox import private_delivery_outbox
|
||||
from services.mesh.mesh_private_release_worker import private_release_worker
|
||||
|
||||
private_delivery_outbox.approve_relay_release(normalized_outbox)
|
||||
private_release_worker.ensure_started()
|
||||
private_release_worker.wake()
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"detail": str(exc) or type(exc).__name__,
|
||||
"grant": grant,
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"auto_released": True,
|
||||
"outbox_id": normalized_outbox,
|
||||
"recipient_id": recipient_id,
|
||||
"grant": grant,
|
||||
"relay_push_peer_urls": relay_push_peer_urls_for_payload(enriched),
|
||||
}
|
||||
@@ -1506,6 +1506,7 @@ class DMRelay:
|
||||
sender_token_hash: str = "",
|
||||
payload_format: str = "dm1",
|
||||
session_welcome: str = "",
|
||||
replication_peer_urls: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
self._refresh_from_shared_relay()
|
||||
@@ -1573,46 +1574,214 @@ class DMRelay:
|
||||
}
|
||||
if not msg_id:
|
||||
msg_id = f"dm_{int(time.time() * 1000)}_{secrets.token_hex(6)}"
|
||||
elif any(m.msg_id == msg_id for m in self._mailboxes[mailbox_key]):
|
||||
return {"ok": True, "msg_id": msg_id}
|
||||
relay_sender_id = (
|
||||
f"sender_token:{sender_token_hash}"
|
||||
if sender_token_hash
|
||||
else sender_id
|
||||
)
|
||||
self._mailboxes[mailbox_key].append(
|
||||
DMMessage(
|
||||
sender_id=relay_sender_id,
|
||||
ciphertext=ciphertext,
|
||||
timestamp=time.time(),
|
||||
msg_id=msg_id,
|
||||
delivery_class=delivery_class,
|
||||
sender_seal=sender_seal,
|
||||
sender_block_ref=sender_block_ref,
|
||||
payload_format=str(payload_format or "dm1"),
|
||||
session_welcome=str(session_welcome or ""),
|
||||
duplicate_hit = any(m.msg_id == msg_id for m in self._mailboxes[mailbox_key])
|
||||
if not duplicate_hit:
|
||||
relay_sender_id = (
|
||||
f"sender_token:{sender_token_hash}"
|
||||
if sender_token_hash
|
||||
else sender_id
|
||||
)
|
||||
)
|
||||
self._stats["messages_in_memory"] = sum(len(v) for v in self._mailboxes.values())
|
||||
self._save()
|
||||
# Cross-node mailbox replication: push the freshly-stored
|
||||
# envelope to every authenticated relay peer so the recipient
|
||||
# can log into ANY node and find their messages. The push is
|
||||
# async (fire-and-forget thread) so deposit() returns
|
||||
# immediately — slow Tor peers can't block the sender's UX.
|
||||
# Each receiving peer re-enforces the per-sender cap on
|
||||
# acceptance, so hostile relays can't widen the cap.
|
||||
self._mailboxes[mailbox_key].append(
|
||||
DMMessage(
|
||||
sender_id=relay_sender_id,
|
||||
ciphertext=ciphertext,
|
||||
timestamp=time.time(),
|
||||
msg_id=msg_id,
|
||||
delivery_class=delivery_class,
|
||||
sender_seal=sender_seal,
|
||||
sender_block_ref=sender_block_ref,
|
||||
payload_format=str(payload_format or "dm1"),
|
||||
session_welcome=str(session_welcome or ""),
|
||||
)
|
||||
)
|
||||
self._stats["messages_in_memory"] = sum(len(v) for v in self._mailboxes.values())
|
||||
self._save()
|
||||
preferred_urls = list(replication_peer_urls or [])
|
||||
envelope_for_push: dict[str, Any] | None = None
|
||||
try:
|
||||
envelope_for_push = self.envelope_for_replication(
|
||||
mailbox_key=mailbox_key, msg_id=msg_id,
|
||||
mailbox_key=mailbox_key,
|
||||
msg_id=msg_id,
|
||||
recipient_id=recipient_id,
|
||||
recipient_token=recipient_token,
|
||||
)
|
||||
if envelope_for_push:
|
||||
self._replicate_envelope_to_peers_async(
|
||||
envelope=envelope_for_push,
|
||||
)
|
||||
except Exception:
|
||||
metrics_inc("dm_replication_push_error")
|
||||
return {"ok": True, "msg_id": msg_id}
|
||||
deposit_result = {"ok": True, "msg_id": msg_id}
|
||||
if duplicate_hit:
|
||||
deposit_result["duplicate"] = True
|
||||
|
||||
if envelope_for_push:
|
||||
# Invite-scoped connect traffic names an explicit recipient relay
|
||||
# (lookup_peer_url). Block until that push completes so the
|
||||
# recipient can poll their own node; fleet-wide fan-out stays
|
||||
# async so dead manifest peers cannot wedge deposit().
|
||||
if preferred_urls:
|
||||
logger.info(
|
||||
"DM deposit awaiting scoped replicate to %d peer(s)",
|
||||
len(preferred_urls),
|
||||
)
|
||||
deposit_result["replicate"] = self._replicate_envelope_to_peers(
|
||||
envelope=envelope_for_push,
|
||||
preferred_peer_urls=preferred_urls,
|
||||
)
|
||||
else:
|
||||
self._replicate_envelope_to_peers_async(
|
||||
envelope=envelope_for_push,
|
||||
preferred_peer_urls=[],
|
||||
)
|
||||
elif preferred_urls:
|
||||
logger.warning(
|
||||
"DM deposit skipped scoped replicate: envelope missing for msg_id=%s",
|
||||
msg_id,
|
||||
)
|
||||
return deposit_result
|
||||
|
||||
def _replicate_envelope_to_peers(
|
||||
self,
|
||||
*,
|
||||
envelope: dict[str, Any],
|
||||
preferred_peer_urls: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Push an envelope to relay peers. Returns per-peer results."""
|
||||
import hashlib
|
||||
import hmac
|
||||
import requests as _requests
|
||||
|
||||
from services.mesh.mesh_crypto import (
|
||||
normalize_peer_url,
|
||||
resolve_peer_key_for_url,
|
||||
)
|
||||
from services.mesh.mesh_router import authenticated_push_peer_urls
|
||||
|
||||
peers: list[str] = []
|
||||
for raw_url in list(preferred_peer_urls or []):
|
||||
normalized_preferred = normalize_peer_url(str(raw_url or "").strip())
|
||||
if normalized_preferred and normalized_preferred not in peers:
|
||||
peers.append(normalized_preferred)
|
||||
if not peers:
|
||||
for peer_url in authenticated_push_peer_urls():
|
||||
normalized_peer = normalize_peer_url(str(peer_url or "").strip())
|
||||
if normalized_peer and normalized_peer not in peers:
|
||||
peers.append(normalized_peer)
|
||||
if not peers:
|
||||
return {"ok": False, "detail": "no_relay_peers", "pushed": [], "failed": []}
|
||||
|
||||
logger.info(
|
||||
"DM replicate push starting for %d peer(s): %s",
|
||||
len(peers),
|
||||
", ".join(peers[:3]) + ("..." if len(peers) > 3 else ""),
|
||||
)
|
||||
|
||||
payload = json.dumps(
|
||||
{"envelope": envelope},
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
|
||||
base_timeout = max(
|
||||
1,
|
||||
int(getattr(self._settings(), "MESH_RELAY_PUSH_TIMEOUT_S", 10) or 10),
|
||||
)
|
||||
|
||||
from main import _infonet_peer_requests_proxies
|
||||
|
||||
preferred_set = {
|
||||
normalize_peer_url(str(raw_url or "").strip())
|
||||
for raw_url in list(preferred_peer_urls or [])
|
||||
}
|
||||
preferred_set.discard("")
|
||||
|
||||
pushed: list[str] = []
|
||||
failed: list[dict[str, str]] = []
|
||||
for peer_url in peers:
|
||||
try:
|
||||
normalized = normalize_peer_url(peer_url)
|
||||
timeout = max(180 if ".onion" in normalized else 1, base_timeout)
|
||||
headers = {"Content-Type": "application/json"}
|
||||
peer_key = resolve_peer_key_for_url(normalized)
|
||||
if peer_key:
|
||||
headers["X-Peer-Url"] = normalized
|
||||
headers["X-Peer-HMAC"] = hmac.new(
|
||||
peer_key, payload, hashlib.sha256
|
||||
).hexdigest()
|
||||
url = f"{peer_url}/api/mesh/dm/replicate-envelope"
|
||||
request_kwargs: dict[str, Any] = {
|
||||
"data": payload,
|
||||
"timeout": timeout,
|
||||
"headers": headers,
|
||||
}
|
||||
proxies = _infonet_peer_requests_proxies(normalized)
|
||||
if proxies:
|
||||
request_kwargs["proxies"] = proxies
|
||||
resp = None
|
||||
max_attempts = 3 if normalized in preferred_set else 2
|
||||
last_exc = ""
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
resp = _requests.post(url, **request_kwargs)
|
||||
break
|
||||
except Exception as exc:
|
||||
last_exc = str(exc) or type(exc).__name__
|
||||
if attempt + 1 < max_attempts:
|
||||
time.sleep(5.0 * (attempt + 1))
|
||||
continue
|
||||
logger.warning(
|
||||
"DM replicate push to %s failed: %s",
|
||||
peer_url,
|
||||
last_exc,
|
||||
)
|
||||
metrics_inc("dm_replication_push_error")
|
||||
resp = None
|
||||
break
|
||||
if resp is None:
|
||||
failed.append({"url": peer_url, "detail": last_exc or "request_failed"})
|
||||
continue
|
||||
if resp.status_code == 200:
|
||||
body_ok = True
|
||||
detail = ""
|
||||
try:
|
||||
body = resp.json()
|
||||
if isinstance(body, dict) and body.get("ok") is False:
|
||||
body_ok = False
|
||||
detail = str(body.get("detail", "") or "replicate rejected")[:200]
|
||||
except Exception:
|
||||
body_ok = True
|
||||
if body_ok:
|
||||
logger.info("DM replicate push to %s succeeded", peer_url)
|
||||
metrics_inc("dm_replication_push_ok")
|
||||
pushed.append(peer_url)
|
||||
else:
|
||||
logger.warning(
|
||||
"DM replicate push to %s rejected: %s",
|
||||
peer_url,
|
||||
detail,
|
||||
)
|
||||
metrics_inc("dm_replication_push_rejected")
|
||||
failed.append({"url": peer_url, "detail": detail or "replicate_rejected"})
|
||||
else:
|
||||
detail = (resp.text or "")[:200]
|
||||
logger.warning(
|
||||
"DM replicate push to %s -> %s: %s",
|
||||
peer_url,
|
||||
resp.status_code,
|
||||
detail,
|
||||
)
|
||||
metrics_inc("dm_replication_push_rejected")
|
||||
failed.append({"url": peer_url, "detail": f"http_{resp.status_code}: {detail}"})
|
||||
except Exception as exc:
|
||||
logger.warning("DM replicate push outer failure for %s: %s", peer_url, exc)
|
||||
metrics_inc("dm_replication_push_error")
|
||||
failed.append({"url": peer_url, "detail": str(exc) or type(exc).__name__})
|
||||
|
||||
scoped = bool(preferred_set)
|
||||
ok = bool(pushed) if scoped else bool(pushed) or not failed
|
||||
return {
|
||||
"ok": ok,
|
||||
"scoped": scoped,
|
||||
"pushed": pushed,
|
||||
"failed": failed,
|
||||
}
|
||||
|
||||
def accept_replica(
|
||||
self,
|
||||
@@ -1645,6 +1814,33 @@ class DMRelay:
|
||||
mailbox_key = str(envelope.get("mailbox_key", "") or "").strip()
|
||||
sender_block_ref = str(envelope.get("sender_block_ref", "") or "").strip()
|
||||
ciphertext = str(envelope.get("ciphertext", "") or "")
|
||||
delivery_class = str(envelope.get("delivery_class", "") or "").strip().lower()
|
||||
recipient_id = str(envelope.get("recipient_id", "") or "").strip()
|
||||
recipient_token = str(envelope.get("recipient_token", "") or "").strip()
|
||||
if delivery_class not in ("request", "shared", "self"):
|
||||
if recipient_id and not recipient_token:
|
||||
delivery_class = "request"
|
||||
elif recipient_token:
|
||||
delivery_class = "shared"
|
||||
if delivery_class == "request":
|
||||
if not recipient_id:
|
||||
try:
|
||||
from services.mesh.mesh_wormhole_persona import get_dm_identity
|
||||
|
||||
recipient_id = str((get_dm_identity() or {}).get("node_id") or "").strip()
|
||||
except Exception:
|
||||
recipient_id = ""
|
||||
if recipient_id:
|
||||
mailbox_key = self.mailbox_key_for_delivery(
|
||||
recipient_id=recipient_id,
|
||||
delivery_class="request",
|
||||
)
|
||||
elif delivery_class == "shared" and recipient_token:
|
||||
mailbox_key = self.mailbox_key_for_delivery(
|
||||
recipient_id=recipient_id,
|
||||
delivery_class="shared",
|
||||
recipient_token=recipient_token,
|
||||
)
|
||||
if not msg_id or not mailbox_key or not sender_block_ref or not ciphertext:
|
||||
return {"ok": False, "detail": "envelope missing required fields"}
|
||||
|
||||
@@ -1662,7 +1858,6 @@ class DMRelay:
|
||||
# Same per-class cap as the deposit path — defense in depth
|
||||
# against a peer that wraps a "deposit" as a "replica" to
|
||||
# bypass the class limit.
|
||||
delivery_class = str(envelope.get("delivery_class", "") or "")
|
||||
if delivery_class in ("request", "shared", "self"):
|
||||
class_limit = self._mailbox_limit_for_class(delivery_class)
|
||||
else:
|
||||
@@ -1716,82 +1911,18 @@ class DMRelay:
|
||||
self,
|
||||
*,
|
||||
envelope: dict[str, Any],
|
||||
preferred_peer_urls: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Push an outbound DM envelope to every authenticated relay peer.
|
||||
|
||||
Fire-and-forget: spawned in a background thread so ``deposit``
|
||||
returns to the caller immediately. Per-peer errors are logged
|
||||
and swallowed — the sender's UX must not block on slow Tor
|
||||
peers, and a peer that's down today gets the next message
|
||||
whenever it comes back. Inbound recipient polling from a healthy
|
||||
peer keeps the system functional during peer failures.
|
||||
|
||||
Each peer is authed with the existing per-peer HMAC pattern
|
||||
(#256) — same headers and key resolver gate-message replication
|
||||
uses, so a hostile node that doesn't know any peer's HMAC key
|
||||
can't impersonate a legitimate relay.
|
||||
"""
|
||||
"""Fire-and-forget fleet-wide replicate push (non-scoped traffic)."""
|
||||
import threading
|
||||
|
||||
def _do_push():
|
||||
def _do_push() -> None:
|
||||
try:
|
||||
import hashlib
|
||||
import hmac
|
||||
import requests as _requests
|
||||
|
||||
from services.mesh.mesh_crypto import (
|
||||
normalize_peer_url,
|
||||
resolve_peer_key_for_url,
|
||||
self._replicate_envelope_to_peers(
|
||||
envelope=envelope,
|
||||
preferred_peer_urls=preferred_peer_urls,
|
||||
)
|
||||
from services.mesh.mesh_router import (
|
||||
authenticated_push_peer_urls,
|
||||
)
|
||||
|
||||
peers = authenticated_push_peer_urls()
|
||||
if not peers:
|
||||
return
|
||||
|
||||
payload = json.dumps(
|
||||
{"envelope": envelope},
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
|
||||
timeout = max(
|
||||
1,
|
||||
int(getattr(self._settings(), "MESH_RELAY_PUSH_TIMEOUT_S", 10) or 10),
|
||||
)
|
||||
|
||||
for peer_url in peers:
|
||||
try:
|
||||
normalized = normalize_peer_url(peer_url)
|
||||
headers = {"Content-Type": "application/json"}
|
||||
peer_key = resolve_peer_key_for_url(normalized)
|
||||
if peer_key:
|
||||
headers["X-Peer-Url"] = normalized
|
||||
headers["X-Peer-HMAC"] = hmac.new(
|
||||
peer_key, payload, hashlib.sha256
|
||||
).hexdigest()
|
||||
url = f"{peer_url}/api/mesh/dm/replicate-envelope"
|
||||
resp = _requests.post(
|
||||
url, data=payload, timeout=timeout, headers=headers,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
metrics_inc("dm_replication_push_ok")
|
||||
else:
|
||||
# 4xx including the structured cap_violation
|
||||
# rejection from accept_replica — sender's
|
||||
# relay learns and stops retrying this msg_id.
|
||||
metrics_inc("dm_replication_push_rejected")
|
||||
except Exception:
|
||||
# Per-peer failure is non-fatal — log to metrics
|
||||
# but don't break the loop. Other peers and a
|
||||
# future retry can still propagate the envelope.
|
||||
metrics_inc("dm_replication_push_error")
|
||||
continue
|
||||
except Exception:
|
||||
# Outer guard — never let replication errors propagate
|
||||
# back to the sender's deposit() caller.
|
||||
metrics_inc("dm_replication_push_error")
|
||||
|
||||
thread = threading.Thread(
|
||||
@@ -1806,6 +1937,8 @@ class DMRelay:
|
||||
*,
|
||||
mailbox_key: str,
|
||||
msg_id: str,
|
||||
recipient_id: str = "",
|
||||
recipient_token: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the wire-form envelope for a stored message, suitable
|
||||
for POSTing to a peer relay's replicate-envelope endpoint.
|
||||
@@ -1822,6 +1955,8 @@ class DMRelay:
|
||||
return {
|
||||
"msg_id": m.msg_id,
|
||||
"mailbox_key": mailbox_key,
|
||||
"recipient_id": str(recipient_id or "").strip(),
|
||||
"recipient_token": str(recipient_token or "").strip(),
|
||||
"sender_id": m.sender_id,
|
||||
"sender_block_ref": m.sender_block_ref,
|
||||
"sender_seal": m.sender_seal,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Public Infonet fleet defaults for sb-testnet-0 participants.
|
||||
|
||||
Operators who run private single-node installs can set ``MESH_INFONET_FLEET_JOIN=false``
|
||||
and provide their own signer keys / peer secrets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
FLEET_NETWORK_ID = "sb-testnet-0"
|
||||
FLEET_SEED_ONION_URL = (
|
||||
"http://gqpbunqbgtkcqilvclm3xrkt3zowjyl3s62kkktvojgvxzizamvbrqid.onion:8000"
|
||||
)
|
||||
FLEET_BOOTSTRAP_SIGNER_PUBLIC_KEY_B64 = (
|
||||
"ul1d0kj/ODPIp0OhHzX8eLAVXzJ3CVvzW1vn2IC6q3I="
|
||||
)
|
||||
# Shared fleet HMAC for sb-testnet peer announce/push/sync. Public testnet join model.
|
||||
FLEET_PEER_PUSH_SECRET = "b7GoqsvoUD9MV7tyt0ZOzMptLA84QG6KCfaV9nDqz5Y"
|
||||
|
||||
|
||||
def infonet_fleet_join_enabled() -> bool:
|
||||
try:
|
||||
from services.config import get_settings
|
||||
|
||||
if bool(getattr(get_settings(), "MESH_INFONET_FLEET_JOIN_DISABLED", False)):
|
||||
return False
|
||||
return bool(getattr(get_settings(), "MESH_INFONET_FLEET_JOIN", True))
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def effective_bootstrap_signer_public_key_b64() -> str:
|
||||
try:
|
||||
from services.config import get_settings
|
||||
|
||||
configured = str(getattr(get_settings(), "MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", "") or "").strip()
|
||||
if configured:
|
||||
return configured
|
||||
except Exception:
|
||||
pass
|
||||
if infonet_fleet_join_enabled():
|
||||
return FLEET_BOOTSTRAP_SIGNER_PUBLIC_KEY_B64
|
||||
return ""
|
||||
|
||||
|
||||
def effective_peer_push_secret() -> str:
|
||||
try:
|
||||
from services.config import get_settings
|
||||
|
||||
configured = str(getattr(get_settings(), "MESH_PEER_PUSH_SECRET", "") or "").strip()
|
||||
if configured:
|
||||
return configured
|
||||
except Exception:
|
||||
pass
|
||||
if infonet_fleet_join_enabled():
|
||||
return FLEET_PEER_PUSH_SECRET
|
||||
return ""
|
||||
|
||||
|
||||
def configured_bootstrap_seed_peers_with_fleet_default(peers: list[str]) -> list[str]:
|
||||
if peers:
|
||||
return peers
|
||||
if infonet_fleet_join_enabled():
|
||||
return [FLEET_SEED_ONION_URL]
|
||||
return []
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Auto-enable Tor wormhole transport on Infonet relay/seed nodes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from services.config import get_settings
|
||||
from services.wormhole_settings import read_wormhole_settings, write_wormhole_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def infonet_relay_auto_wormhole_requested() -> bool:
|
||||
settings = get_settings()
|
||||
if bool(settings.MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED):
|
||||
return False
|
||||
if bool(settings.MESH_INFONET_RELAY_AUTO_WORMHOLE):
|
||||
return True
|
||||
if str(settings.MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY or "").strip():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _relay_tor_wormhole_target_settings() -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
socks_port = int(settings.MESH_ARTI_SOCKS_PORT or 9050)
|
||||
return {
|
||||
"enabled": True,
|
||||
"transport": "tor_arti",
|
||||
"socks_proxy": f"socks5h://127.0.0.1:{socks_port}",
|
||||
"socks_dns": True,
|
||||
"anonymous_mode": True,
|
||||
}
|
||||
|
||||
|
||||
def _wormhole_settings_match(existing: dict[str, Any], target: dict[str, Any]) -> bool:
|
||||
return (
|
||||
bool(existing.get("enabled")) is bool(target["enabled"])
|
||||
and str(existing.get("transport", "")) == str(target["transport"])
|
||||
and str(existing.get("socks_proxy", "")) == str(target["socks_proxy"])
|
||||
and bool(existing.get("socks_dns", True)) is bool(target["socks_dns"])
|
||||
and bool(existing.get("anonymous_mode", False)) is bool(target["anonymous_mode"])
|
||||
)
|
||||
|
||||
|
||||
def ensure_infonet_relay_wormhole_ready(*, reason: str = "relay_auto") -> dict[str, Any]:
|
||||
"""Persist Tor wormhole settings and connect on relay/seed startup."""
|
||||
if not infonet_relay_auto_wormhole_requested():
|
||||
return {"ok": True, "skipped": True, "reason": "not_requested"}
|
||||
|
||||
from routers.ai_intel import _write_env_value
|
||||
from services.tor_hidden_service import tor_service
|
||||
from services.wormhole_supervisor import connect_wormhole, restart_wormhole
|
||||
|
||||
existing = read_wormhole_settings()
|
||||
target = _relay_tor_wormhole_target_settings()
|
||||
settings_updated = not _wormhole_settings_match(existing, target)
|
||||
updated = write_wormhole_settings(**target) if settings_updated else existing
|
||||
|
||||
tor_result: dict[str, Any] = {"ok": False, "detail": "not started"}
|
||||
try:
|
||||
tor_result = tor_service.start(target_port=8000)
|
||||
if tor_result.get("ok"):
|
||||
_write_env_value("MESH_ARTI_ENABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
except Exception as exc:
|
||||
tor_result = {"ok": False, "detail": str(exc or type(exc).__name__)}
|
||||
|
||||
runtime = (
|
||||
restart_wormhole(reason=reason)
|
||||
if settings_updated
|
||||
else connect_wormhole(reason=reason)
|
||||
)
|
||||
|
||||
if settings_updated:
|
||||
logger.info("Infonet relay auto-wormhole enabled (%s)", reason)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"skipped": False,
|
||||
"settings_updated": settings_updated,
|
||||
"tor": tor_result,
|
||||
"runtime": runtime,
|
||||
"settings": updated,
|
||||
}
|
||||
@@ -125,8 +125,8 @@ def dm_lookup_response_view(
|
||||
view.pop("lookup_mode", None)
|
||||
view.pop("removal_target", None)
|
||||
return view
|
||||
if invite_lookup:
|
||||
view.pop("agent_id", None)
|
||||
# Successful invite lookups keep agent_id: the handle is the capability and
|
||||
# first-contact messaging needs a delivery target. Failures stay generic.
|
||||
return view
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Operator-signed peer registry for private Infonet swarm discovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from services.mesh.mesh_crypto import normalize_peer_url
|
||||
from services.mesh.mesh_router import peer_transport_kind
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[2]
|
||||
DATA_DIR = BACKEND_DIR / "data"
|
||||
DEFAULT_PEER_REGISTRY_PATH = DATA_DIR / "peer_registry.json"
|
||||
REGISTRY_VERSION = 1
|
||||
ALLOWED_REGISTRY_ROLES = {"participant", "relay", "seed"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegistryPeer:
|
||||
peer_url: str
|
||||
transport: str
|
||||
role: str
|
||||
node_id: str = ""
|
||||
label: str = ""
|
||||
announced_at: int = 0
|
||||
last_seen_at: int = 0
|
||||
failure_count: int = 0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
def manifest_peer(self) -> dict[str, str]:
|
||||
return {
|
||||
"peer_url": self.peer_url,
|
||||
"transport": self.transport,
|
||||
"role": self.role,
|
||||
"label": self.label or self.node_id[:16],
|
||||
}
|
||||
|
||||
|
||||
class PeerRegistry:
|
||||
def __init__(self, path: str | Path = DEFAULT_PEER_REGISTRY_PATH):
|
||||
self.path = Path(path)
|
||||
self._peers: dict[str, RegistryPeer] = {}
|
||||
|
||||
def load(self) -> list[RegistryPeer]:
|
||||
if not self.path.exists():
|
||||
self._peers = {}
|
||||
return []
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("peer registry root must be an object")
|
||||
version = int(raw.get("version", 0) or 0)
|
||||
if version != REGISTRY_VERSION:
|
||||
raise ValueError(f"unsupported peer registry version: {version}")
|
||||
entries = raw.get("peers", [])
|
||||
if not isinstance(entries, list):
|
||||
raise ValueError("peer registry peers must be a list")
|
||||
peers: dict[str, RegistryPeer] = {}
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
peer = self._normalize_entry(entry)
|
||||
peers[peer.peer_url] = peer
|
||||
self._peers = peers
|
||||
return self.records()
|
||||
|
||||
def save(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"version": REGISTRY_VERSION,
|
||||
"updated_at": int(time.time()),
|
||||
"peers": [peer.to_dict() for peer in self.records()],
|
||||
}
|
||||
self.path.write_text(
|
||||
json.dumps(payload, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def records(self) -> list[RegistryPeer]:
|
||||
return sorted(self._peers.values(), key=lambda item: (item.role, item.peer_url))
|
||||
|
||||
def upsert_announcement(
|
||||
self,
|
||||
*,
|
||||
peer_url: str,
|
||||
transport: str,
|
||||
role: str,
|
||||
node_id: str = "",
|
||||
label: str = "",
|
||||
now: float | None = None,
|
||||
) -> RegistryPeer:
|
||||
normalized = normalize_peer_url(peer_url)
|
||||
if not normalized:
|
||||
raise ValueError("peer_url is required")
|
||||
resolved_transport = str(transport or "").strip().lower() or str(peer_transport_kind(normalized) or "")
|
||||
if resolved_transport not in {"onion", "clearnet"}:
|
||||
raise ValueError("unsupported peer transport")
|
||||
resolved_role = str(role or "participant").strip().lower()
|
||||
if resolved_role not in ALLOWED_REGISTRY_ROLES:
|
||||
raise ValueError("unsupported peer role")
|
||||
timestamp = int(now if now is not None else time.time())
|
||||
existing = self._peers.get(normalized)
|
||||
peer = RegistryPeer(
|
||||
peer_url=normalized,
|
||||
transport=resolved_transport,
|
||||
role=resolved_role,
|
||||
node_id=str(node_id or (existing.node_id if existing else "") or "").strip(),
|
||||
label=str(label or (existing.label if existing else "") or "").strip(),
|
||||
announced_at=int(existing.announced_at if existing and existing.announced_at else timestamp),
|
||||
last_seen_at=timestamp,
|
||||
failure_count=int(existing.failure_count if existing else 0),
|
||||
)
|
||||
self._peers[normalized] = peer
|
||||
return peer
|
||||
|
||||
def prune_stale(self, *, max_age_s: int, now: float | None = None) -> int:
|
||||
timestamp = int(now if now is not None else time.time())
|
||||
removed = 0
|
||||
for peer_url, peer in list(self._peers.items()):
|
||||
if peer.role == "seed":
|
||||
continue
|
||||
last_seen = int(peer.last_seen_at or peer.announced_at or 0)
|
||||
if last_seen > 0 and timestamp - last_seen > max(60, int(max_age_s or 0)):
|
||||
del self._peers[peer_url]
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
def manifest_peers(self) -> list[dict[str, str]]:
|
||||
return [peer.manifest_peer() for peer in self.records()]
|
||||
|
||||
def _normalize_entry(self, entry: dict[str, Any]) -> RegistryPeer:
|
||||
peer_url = normalize_peer_url(str(entry.get("peer_url", "") or ""))
|
||||
if not peer_url:
|
||||
raise ValueError("registry peer_url is required")
|
||||
transport = str(entry.get("transport", "") or peer_transport_kind(peer_url) or "").strip().lower()
|
||||
role = str(entry.get("role", "participant") or "participant").strip().lower()
|
||||
if role not in ALLOWED_REGISTRY_ROLES:
|
||||
raise ValueError("registry role unsupported")
|
||||
return RegistryPeer(
|
||||
peer_url=peer_url,
|
||||
transport=transport,
|
||||
role=role,
|
||||
node_id=str(entry.get("node_id", "") or "").strip(),
|
||||
label=str(entry.get("label", "") or "").strip(),
|
||||
announced_at=int(entry.get("announced_at", 0) or 0),
|
||||
last_seen_at=int(entry.get("last_seen_at", 0) or entry.get("announced_at", 0) or 0),
|
||||
failure_count=int(entry.get("failure_count", 0) or 0),
|
||||
)
|
||||
@@ -16,7 +16,7 @@ DATA_DIR = BACKEND_DIR / "data"
|
||||
DEFAULT_PEER_STORE_PATH = DATA_DIR / "peer_store.json"
|
||||
PEER_STORE_VERSION = 1
|
||||
ALLOWED_PEER_BUCKETS = {"bootstrap", "sync", "push"}
|
||||
ALLOWED_PEER_SOURCES = {"bundle", "operator", "bootstrap_promoted", "runtime"}
|
||||
ALLOWED_PEER_SOURCES = {"bundle", "operator", "bootstrap_promoted", "runtime", "swarm"}
|
||||
ALLOWED_PEER_TRANSPORTS = {"clearnet", "onion"}
|
||||
ALLOWED_PEER_ROLES = {"participant", "relay", "seed"}
|
||||
|
||||
|
||||
@@ -140,10 +140,24 @@ def transport_tier_from_state(state: dict[str, Any] | None) -> str:
|
||||
snapshot = state or {}
|
||||
if not bool(snapshot.get("configured")):
|
||||
return "public_degraded"
|
||||
if not bool(snapshot.get("ready")):
|
||||
return "public_degraded"
|
||||
arti_ready = bool(snapshot.get("arti_ready"))
|
||||
rns_ready = bool(snapshot.get("rns_ready"))
|
||||
running = bool(snapshot.get("running"))
|
||||
transport_usable = bool(snapshot.get("ready"))
|
||||
if not transport_usable:
|
||||
try:
|
||||
from services.config import get_settings
|
||||
|
||||
if (
|
||||
bool(getattr(get_settings(), "MESH_WORMHOLE_TRUST_FILE_READY", False))
|
||||
and running
|
||||
and arti_ready
|
||||
):
|
||||
transport_usable = True
|
||||
except Exception:
|
||||
pass
|
||||
if not transport_usable:
|
||||
return "public_degraded"
|
||||
if arti_ready and rns_ready:
|
||||
return "private_strong"
|
||||
if arti_ready or rns_ready:
|
||||
@@ -157,8 +171,45 @@ def transport_tier_is_sufficient(current_tier: str | None, required_tier: str |
|
||||
return TRANSPORT_TIER_ORDER[current] >= TRANSPORT_TIER_ORDER[required]
|
||||
|
||||
|
||||
def release_lane_required_tier(lane: str) -> str:
|
||||
return network_release_required_tier(lane)
|
||||
_DM_RUNTIME_ENFORCEMENT_ROUTES = {
|
||||
("POST", "/api/mesh/dm/send"),
|
||||
("POST", "/api/mesh/dm/poll"),
|
||||
("GET", "/api/mesh/dm/poll"),
|
||||
("GET", "/api/mesh/dm/count"),
|
||||
("POST", "/api/mesh/dm/count"),
|
||||
}
|
||||
|
||||
|
||||
def runtime_route_enforcement_tier(path: str, method: str, *, static_tier: str) -> str:
|
||||
"""Adjust static route tiers for Tor-only nodes that never reach private_strong."""
|
||||
normalized_path = str(path or "").strip()
|
||||
normalized_method = str(method or "").strip().upper()
|
||||
static = normalize_transport_tier(static_tier)
|
||||
if (normalized_method, normalized_path) not in _DM_RUNTIME_ENFORCEMENT_ROUTES:
|
||||
return static
|
||||
if static != "private_strong":
|
||||
return static
|
||||
return release_lane_required_tier("dm")
|
||||
|
||||
|
||||
def release_lane_required_tier(lane: str, *, wormhole_state: dict[str, Any] | None = None) -> str:
|
||||
normalized_lane = str(lane or "").strip().lower()
|
||||
required = network_release_required_tier(normalized_lane)
|
||||
if normalized_lane != "dm":
|
||||
return required
|
||||
state = wormhole_state
|
||||
if state is None:
|
||||
try:
|
||||
from services.wormhole_supervisor import get_wormhole_state
|
||||
|
||||
state = get_wormhole_state()
|
||||
except Exception:
|
||||
state = {}
|
||||
# Tor-only nodes never reach private_strong (needs Arti + RNS). Encrypted
|
||||
# relay over Arti still preserves ciphertext privacy for offline delivery.
|
||||
if not bool((state or {}).get("rns_enabled")):
|
||||
return "private_transitional"
|
||||
return required
|
||||
|
||||
|
||||
def private_delivery_status(status_code: str, *, reason_code: str = "", plain_reason: str = "") -> dict[str, str]:
|
||||
|
||||
@@ -386,6 +386,20 @@ def _dispatch_dm(
|
||||
sampled=sampled,
|
||||
)
|
||||
|
||||
replication_peer_urls: list[str] = []
|
||||
try:
|
||||
from services.mesh.mesh_dm_connect_delivery import relay_push_peer_urls_for_payload
|
||||
|
||||
replication_peer_urls = [
|
||||
str(raw or "").strip().rstrip("/")
|
||||
for raw in list(payload.get("relay_push_peer_urls") or [])
|
||||
if str(raw or "").strip()
|
||||
]
|
||||
if not replication_peer_urls:
|
||||
replication_peer_urls = relay_push_peer_urls_for_payload(payload)
|
||||
except Exception:
|
||||
replication_peer_urls = []
|
||||
|
||||
apply_dm_relay_jitter()
|
||||
relay_result = dm_relay.deposit(
|
||||
sender_id=relay_sender_id,
|
||||
@@ -399,7 +413,25 @@ def _dispatch_dm(
|
||||
sender_token_hash=sender_token_hash,
|
||||
payload_format=payload_format,
|
||||
session_welcome=session_welcome,
|
||||
replication_peer_urls=replication_peer_urls,
|
||||
)
|
||||
replicate_info = dict(relay_result.get("replicate") or {})
|
||||
if replication_peer_urls and not replicate_info.get("ok"):
|
||||
return _dispatch_result(
|
||||
ok=False,
|
||||
lane="dm",
|
||||
selected_transport="relay",
|
||||
selected_carrier="relay",
|
||||
dispatch_reason="scoped_relay_replicate_failed",
|
||||
hidden_transport_effective=bool(hidden_relay),
|
||||
no_acceptable_path=False,
|
||||
detail=(
|
||||
"Scoped relay replicate did not reach the recipient node: "
|
||||
+ str(replicate_info.get("failed") or replicate_info.get("detail") or "unknown")
|
||||
),
|
||||
msg_id=msg_id,
|
||||
replicate=replicate_info,
|
||||
)
|
||||
if not relay_result.get("ok"):
|
||||
return _dispatch_result(
|
||||
ok=False,
|
||||
@@ -436,6 +468,7 @@ def _dispatch_dm(
|
||||
else str(relay_result.get("detail", "") or "Delivered privately")
|
||||
),
|
||||
msg_id=str(relay_result.get("msg_id", "") or msg_id),
|
||||
replicate=replicate_info,
|
||||
)
|
||||
|
||||
|
||||
@@ -600,8 +633,15 @@ def attempt_private_release(
|
||||
policy_reason_code=str(decision.reason_code or ""),
|
||||
)
|
||||
if normalized_lane == "dm":
|
||||
dm_payload = dict(payload or {})
|
||||
try:
|
||||
from services.mesh.mesh_dm_connect_delivery import enrich_connect_release_payload
|
||||
|
||||
dm_payload = enrich_connect_release_payload(dm_payload)
|
||||
except Exception:
|
||||
pass
|
||||
return _dispatch_dm(
|
||||
dict(payload or {}),
|
||||
dm_payload,
|
||||
secure_dm_enabled=secure_dm_enabled or _secure_dm_enabled,
|
||||
rns_private_dm_ready=rns_private_dm_ready or _rns_private_dm_ready,
|
||||
anonymous_dm_hidden_transport_enforced=(
|
||||
|
||||
@@ -36,6 +36,22 @@ def _require_fields(payload: dict[str, Any], fields: tuple[str, ...]) -> tuple[b
|
||||
return True, "ok"
|
||||
|
||||
|
||||
_SEALED_CIPHERTEXT_PREFIXES = ("x3dh1:", "dm1:", "mls1:", "sealed:")
|
||||
|
||||
|
||||
def _strip_sealed_ciphertext_prefix(value: str) -> str:
|
||||
lowered = value.lower()
|
||||
for prefix in _SEALED_CIPHERTEXT_PREFIXES:
|
||||
if lowered.startswith(prefix):
|
||||
return value[len(prefix) :]
|
||||
return value
|
||||
|
||||
|
||||
def _sealed_ciphertext_has_known_prefix(value: str) -> bool:
|
||||
lowered = str(value or "").strip().lower()
|
||||
return any(lowered.startswith(prefix) for prefix in _SEALED_CIPHERTEXT_PREFIXES)
|
||||
|
||||
|
||||
def _decode_base64ish(value: Any) -> bytes | None:
|
||||
raw = str(value or "").strip()
|
||||
if not raw or any(ch.isspace() for ch in raw):
|
||||
@@ -49,6 +65,13 @@ def _decode_base64ish(value: Any) -> bytes | None:
|
||||
return None
|
||||
|
||||
|
||||
def _decode_sealed_ciphertext_value(value: Any) -> bytes | None:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
return _decode_base64ish(_strip_sealed_ciphertext_prefix(raw))
|
||||
|
||||
|
||||
def _byte_entropy(data: bytes) -> float:
|
||||
if not data:
|
||||
return 0.0
|
||||
@@ -66,12 +89,19 @@ def _validate_sealed_bytes_field(
|
||||
min_bytes: int = 8,
|
||||
entropy_floor: float = 2.5,
|
||||
) -> tuple[bool, str]:
|
||||
data = _decode_base64ish(payload.get(field, ""))
|
||||
raw = str(payload.get(field, "") or "").strip()
|
||||
prefixed = _sealed_ciphertext_has_known_prefix(raw)
|
||||
data = _decode_sealed_ciphertext_value(raw)
|
||||
if data is None:
|
||||
return False, f"{field} must be base64-encoded sealed bytes"
|
||||
if len(data) < min_bytes:
|
||||
return False, f"{field} is too short"
|
||||
|
||||
# X3DH / MLS envelopes are structured JSON or ratchet frames — skip
|
||||
# plaintext heuristics once a known wire prefix is present.
|
||||
if prefixed:
|
||||
return True, "ok"
|
||||
|
||||
# Short test vectors and compact envelopes can be low entropy; only apply
|
||||
# heuristics once there is enough material to distinguish a sealed blob
|
||||
# from accidental base64-encoded plaintext.
|
||||
|
||||
@@ -463,8 +463,26 @@ def _apply_content_private_transport_lock_policy(prepared: "PreparedSignedWrite"
|
||||
except Exception:
|
||||
current_tier = "public_degraded"
|
||||
|
||||
lock_to_satisfy = normalized
|
||||
if prepared.kind in {
|
||||
SignedWriteKind.DM_POLL,
|
||||
SignedWriteKind.DM_COUNT,
|
||||
SignedWriteKind.DM_SEND,
|
||||
SignedWriteKind.DM_REGISTER,
|
||||
SignedWriteKind.DM_BLOCK,
|
||||
SignedWriteKind.DM_WITNESS,
|
||||
}:
|
||||
from services.mesh.mesh_privacy_policy import release_lane_required_tier
|
||||
|
||||
lane_cap = release_lane_required_tier("dm")
|
||||
# Clients sign private_strong; Tor-only nodes cap DM at
|
||||
# private_transitional. Accept when live transport meets the
|
||||
# strongest tier this node can offer on the DM lane.
|
||||
if not transport_tier_is_sufficient(lane_cap, normalized):
|
||||
lock_to_satisfy = lane_cap
|
||||
|
||||
if (
|
||||
not transport_tier_is_sufficient(current_tier, normalized)
|
||||
not transport_tier_is_sufficient(current_tier, lock_to_satisfy)
|
||||
and prepared.kind not in _QUEUEABLE_CONTENT_PRIVATE_KINDS
|
||||
):
|
||||
metrics_inc("signed_write_transport_lock_tier_mismatch")
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
"""Private Infonet swarm discovery and immediate ledger propagation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from services.config import get_settings
|
||||
from services.mesh.mesh_bootstrap_manifest import (
|
||||
BootstrapManifest,
|
||||
BootstrapManifestError,
|
||||
BootstrapPeer,
|
||||
build_bootstrap_manifest_payload,
|
||||
load_bootstrap_manifest,
|
||||
parse_bootstrap_manifest_dict,
|
||||
sign_bootstrap_manifest_payload,
|
||||
write_signed_bootstrap_manifest,
|
||||
)
|
||||
from services.mesh.mesh_crypto import normalize_peer_url, resolve_peer_key_for_url
|
||||
from services.mesh.mesh_peer_registry import DEFAULT_PEER_REGISTRY_PATH, PeerRegistry, RegistryPeer
|
||||
from services.mesh.mesh_peer_store import (
|
||||
DEFAULT_PEER_STORE_PATH,
|
||||
PeerStore,
|
||||
make_push_peer_record,
|
||||
make_sync_peer_record,
|
||||
)
|
||||
from services.mesh.mesh_router import parse_configured_relay_peers, peer_transport_kind
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SWARM_LOCK = threading.Lock()
|
||||
_LAST_MANIFEST_PULL_AT = 0.0
|
||||
_LAST_ANNOUNCE_AT = 0.0
|
||||
|
||||
|
||||
def peer_registry_enabled() -> bool:
|
||||
settings = get_settings()
|
||||
if bool(getattr(settings, "MESH_PEER_REGISTRY_DISABLED", False)):
|
||||
return False
|
||||
if str(getattr(settings, "MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY", "") or "").strip():
|
||||
return True
|
||||
return bool(getattr(settings, "MESH_PEER_REGISTRY_ENABLED", False))
|
||||
|
||||
|
||||
def _manifest_path() -> str:
|
||||
return str(getattr(get_settings(), "MESH_BOOTSTRAP_MANIFEST_PATH", "") or "data/bootstrap_peers.json")
|
||||
|
||||
|
||||
def _signer_public_key_b64() -> str:
|
||||
from services.mesh.mesh_fleet_defaults import effective_bootstrap_signer_public_key_b64
|
||||
|
||||
return effective_bootstrap_signer_public_key_b64()
|
||||
|
||||
|
||||
def _signer_private_key_b64() -> str:
|
||||
return str(getattr(settings, "MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY", "") or "").strip() if (settings := get_settings()) else ""
|
||||
|
||||
|
||||
def _signer_id() -> str:
|
||||
configured = str(getattr(get_settings(), "MESH_BOOTSTRAP_SIGNER_ID", "") or "").strip()
|
||||
return configured or "shadowbroker-seed"
|
||||
|
||||
|
||||
def _private_transport_required() -> bool:
|
||||
return not bool(getattr(get_settings(), "MESH_INFONET_ALLOW_CLEARNET_SYNC", False))
|
||||
|
||||
|
||||
def _configured_seed_peer_urls() -> list[str]:
|
||||
from services.mesh.mesh_fleet_defaults import configured_bootstrap_seed_peers_with_fleet_default
|
||||
|
||||
settings = get_settings()
|
||||
primary = str(getattr(settings, "MESH_BOOTSTRAP_SEED_PEERS", "") or "").strip()
|
||||
legacy = str(getattr(settings, "MESH_DEFAULT_SYNC_PEERS", "") or "").strip()
|
||||
return configured_bootstrap_seed_peers_with_fleet_default(
|
||||
parse_configured_relay_peers(primary or legacy)
|
||||
)
|
||||
|
||||
|
||||
def _seed_manifest_peers() -> list[dict[str, str]]:
|
||||
peers: list[dict[str, str]] = []
|
||||
for peer_url in _configured_seed_peer_urls():
|
||||
transport = str(peer_transport_kind(peer_url) or "")
|
||||
if _private_transport_required() and transport != "onion":
|
||||
continue
|
||||
peers.append(
|
||||
{
|
||||
"peer_url": peer_url,
|
||||
"transport": transport,
|
||||
"role": "seed",
|
||||
"label": "ShadowBroker bootstrap seed",
|
||||
}
|
||||
)
|
||||
return peers
|
||||
|
||||
|
||||
def publish_registry_manifest(*, now: float | None = None, persist: bool = True) -> BootstrapManifest:
|
||||
private_key = _signer_private_key_b64()
|
||||
public_key = _signer_public_key_b64()
|
||||
if not private_key or not public_key:
|
||||
raise BootstrapManifestError("bootstrap signer keys are required to publish swarm manifest")
|
||||
|
||||
timestamp = int(now if now is not None else time.time())
|
||||
registry = PeerRegistry(DEFAULT_PEER_REGISTRY_PATH)
|
||||
try:
|
||||
registry.load()
|
||||
except Exception:
|
||||
registry = PeerRegistry(DEFAULT_PEER_REGISTRY_PATH)
|
||||
stale_s = int(getattr(get_settings(), "MESH_PEER_REGISTRY_STALE_S", 0) or 7 * 86400)
|
||||
if stale_s > 0:
|
||||
registry.prune_stale(max_age_s=stale_s, now=timestamp)
|
||||
|
||||
peers = _seed_manifest_peers() + registry.manifest_peers()
|
||||
ttl_s = int(getattr(get_settings(), "MESH_SWARM_MANIFEST_TTL_S", 0) or 4 * 3600)
|
||||
payload = build_bootstrap_manifest_payload(
|
||||
signer_id=_signer_id(),
|
||||
peers=peers,
|
||||
issued_at=timestamp,
|
||||
valid_until=timestamp + max(300, ttl_s),
|
||||
)
|
||||
signature = sign_bootstrap_manifest_payload(payload, signer_private_key_b64=private_key)
|
||||
manifest = BootstrapManifest(
|
||||
version=int(payload["version"]),
|
||||
issued_at=int(payload["issued_at"]),
|
||||
valid_until=int(payload["valid_until"]),
|
||||
signer_id=str(payload["signer_id"]),
|
||||
peers=tuple(BootstrapPeer(**dict(peer)) for peer in peers),
|
||||
signature=signature,
|
||||
)
|
||||
if persist:
|
||||
registry.save()
|
||||
write_signed_bootstrap_manifest(
|
||||
_manifest_path(),
|
||||
signer_id=manifest.signer_id,
|
||||
signer_private_key_b64=private_key,
|
||||
peers=[peer.to_dict() for peer in manifest.peers],
|
||||
issued_at=manifest.issued_at,
|
||||
valid_until=manifest.valid_until,
|
||||
)
|
||||
return manifest
|
||||
|
||||
|
||||
def load_live_bootstrap_manifest(*, now: float | None = None) -> BootstrapManifest | None:
|
||||
public_key = _signer_public_key_b64()
|
||||
if not public_key:
|
||||
return None
|
||||
if peer_registry_enabled():
|
||||
try:
|
||||
return publish_registry_manifest(now=now, persist=False)
|
||||
except BootstrapManifestError:
|
||||
logger.warning("live registry manifest unavailable", exc_info=True)
|
||||
try:
|
||||
return load_bootstrap_manifest(_manifest_path(), signer_public_key_b64=public_key, now=now)
|
||||
except BootstrapManifestError:
|
||||
return None
|
||||
|
||||
|
||||
def _upsert_swarm_peer_into_store(
|
||||
*,
|
||||
peer_url: str,
|
||||
transport: str,
|
||||
role: str,
|
||||
label: str = "",
|
||||
signer_id: str = "",
|
||||
now: float | None = None,
|
||||
) -> None:
|
||||
timestamp = int(now if now is not None else time.time())
|
||||
if _private_transport_required() and transport != "onion":
|
||||
return
|
||||
store = PeerStore(DEFAULT_PEER_STORE_PATH)
|
||||
try:
|
||||
store.load()
|
||||
except Exception:
|
||||
store = PeerStore(DEFAULT_PEER_STORE_PATH)
|
||||
store.upsert(
|
||||
make_sync_peer_record(
|
||||
peer_url=peer_url,
|
||||
transport=transport,
|
||||
role=role,
|
||||
source="swarm",
|
||||
label=label,
|
||||
signer_id=signer_id,
|
||||
now=timestamp,
|
||||
)
|
||||
)
|
||||
store.upsert(
|
||||
make_push_peer_record(
|
||||
peer_url=peer_url,
|
||||
transport=transport,
|
||||
role=role if role != "seed" else "relay",
|
||||
source="swarm",
|
||||
label=label,
|
||||
now=timestamp,
|
||||
)
|
||||
)
|
||||
store.save()
|
||||
|
||||
|
||||
def record_peer_announcement(body: dict[str, Any], *, now: float | None = None) -> RegistryPeer:
|
||||
if not peer_registry_enabled():
|
||||
raise ValueError("peer registry is not enabled on this node")
|
||||
registry = PeerRegistry(DEFAULT_PEER_REGISTRY_PATH)
|
||||
try:
|
||||
registry.load()
|
||||
except Exception:
|
||||
registry = PeerRegistry(DEFAULT_PEER_REGISTRY_PATH)
|
||||
peer = registry.upsert_announcement(
|
||||
peer_url=str(body.get("peer_url", "") or ""),
|
||||
transport=str(body.get("transport", "") or ""),
|
||||
role=str(body.get("role", "participant") or "participant"),
|
||||
node_id=str(body.get("node_id", "") or ""),
|
||||
label=str(body.get("label", "") or ""),
|
||||
now=now,
|
||||
)
|
||||
registry.save()
|
||||
_upsert_swarm_peer_into_store(
|
||||
peer_url=peer.peer_url,
|
||||
transport=peer.transport,
|
||||
role=peer.role,
|
||||
label=peer.label,
|
||||
signer_id=_signer_id(),
|
||||
now=now,
|
||||
)
|
||||
try:
|
||||
publish_registry_manifest(now=now, persist=True)
|
||||
except Exception:
|
||||
logger.warning("failed to republish swarm manifest after announce", exc_info=True)
|
||||
return peer
|
||||
|
||||
|
||||
def merge_manifest_into_peer_store(manifest: BootstrapManifest, *, now: float | None = None) -> int:
|
||||
timestamp = int(now if now is not None else time.time())
|
||||
merged = 0
|
||||
for peer in manifest.peers:
|
||||
if _private_transport_required() and peer.transport != "onion":
|
||||
continue
|
||||
_upsert_swarm_peer_into_store(
|
||||
peer_url=peer.peer_url,
|
||||
transport=peer.transport,
|
||||
role=peer.role,
|
||||
label=peer.label,
|
||||
signer_id=manifest.signer_id,
|
||||
now=timestamp,
|
||||
)
|
||||
merged += 1
|
||||
return merged
|
||||
|
||||
|
||||
def fetch_remote_bootstrap_manifest(seed_peer_url: str, *, now: float | None = None) -> BootstrapManifest | None:
|
||||
import requests
|
||||
|
||||
public_key = _signer_public_key_b64()
|
||||
if not public_key:
|
||||
return None
|
||||
normalized = normalize_peer_url(seed_peer_url)
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
from main import _infonet_peer_requests_proxies
|
||||
|
||||
proxies = _infonet_peer_requests_proxies(normalized)
|
||||
timeout = int(getattr(get_settings(), "MESH_SYNC_TIMEOUT_S", 0) or 45)
|
||||
request_kwargs: dict[str, Any] = {"timeout": timeout}
|
||||
if proxies:
|
||||
request_kwargs["proxies"] = proxies
|
||||
try:
|
||||
response = requests.get(f"{normalized}/api/mesh/infonet/bootstrap-manifest", **request_kwargs)
|
||||
except Exception as exc:
|
||||
logger.debug("swarm manifest fetch failed for %s: %s", normalized, exc)
|
||||
return None
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
try:
|
||||
raw = response.json()
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(raw, dict) or raw.get("ok") is False:
|
||||
return None
|
||||
manifest_body = dict(raw.get("manifest") or raw)
|
||||
try:
|
||||
return parse_bootstrap_manifest_dict(
|
||||
manifest_body,
|
||||
signer_public_key_b64=public_key,
|
||||
now=now,
|
||||
)
|
||||
except BootstrapManifestError:
|
||||
return None
|
||||
|
||||
|
||||
def refresh_swarm_manifest_from_seeds(*, now: float | None = None, force: bool = False) -> dict[str, Any]:
|
||||
global _LAST_MANIFEST_PULL_AT
|
||||
interval_s = int(getattr(get_settings(), "MESH_SWARM_MANIFEST_PULL_INTERVAL_S", 0) or 300)
|
||||
timestamp = float(now if now is not None else time.time())
|
||||
with _SWARM_LOCK:
|
||||
if not force and _LAST_MANIFEST_PULL_AT and timestamp - _LAST_MANIFEST_PULL_AT < max(30, interval_s):
|
||||
return {"ok": True, "skipped": True, "reason": "manifest_pull_interval"}
|
||||
_LAST_MANIFEST_PULL_AT = timestamp
|
||||
|
||||
if not _signer_public_key_b64():
|
||||
return {"ok": False, "detail": "MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY is not configured"}
|
||||
|
||||
last_error = "manifest fetch failed"
|
||||
for seed_url in _configured_seed_peer_urls():
|
||||
manifest = fetch_remote_bootstrap_manifest(seed_url, now=timestamp)
|
||||
if manifest is None:
|
||||
continue
|
||||
try:
|
||||
merged = merge_manifest_into_peer_store(manifest, now=timestamp)
|
||||
return {
|
||||
"ok": True,
|
||||
"seed_peer_url": seed_url,
|
||||
"peer_count": len(manifest.peers),
|
||||
"merged_peer_count": merged,
|
||||
}
|
||||
except Exception as exc:
|
||||
last_error = str(exc or type(exc).__name__)
|
||||
return {"ok": False, "detail": last_error}
|
||||
|
||||
|
||||
def announce_local_peer_to_seeds(*, now: float | None = None, force: bool = False) -> dict[str, Any]:
|
||||
global _LAST_ANNOUNCE_AT
|
||||
import hashlib as _hashlib_mod
|
||||
import hmac as _hmac_mod
|
||||
import requests
|
||||
|
||||
from main import _infonet_peer_requests_proxies, _local_infonet_peer_url, _participant_node_enabled
|
||||
|
||||
if not _participant_node_enabled():
|
||||
return {"ok": False, "detail": "participant node disabled"}
|
||||
peer_url = _local_infonet_peer_url()
|
||||
if not peer_url:
|
||||
return {"ok": False, "detail": "local peer URL is not ready"}
|
||||
peer_key = resolve_peer_key_for_url(peer_url)
|
||||
if not peer_key:
|
||||
return {"ok": False, "detail": "peer HMAC secret is not configured"}
|
||||
|
||||
timestamp = float(now if now is not None else time.time())
|
||||
with _SWARM_LOCK:
|
||||
if not force and _LAST_ANNOUNCE_AT and timestamp - _LAST_ANNOUNCE_AT < 300:
|
||||
return {"ok": True, "skipped": True, "reason": "announce_interval"}
|
||||
_LAST_ANNOUNCE_AT = timestamp
|
||||
|
||||
transport = str(peer_transport_kind(peer_url) or "onion")
|
||||
body = {
|
||||
"peer_url": peer_url,
|
||||
"transport": transport,
|
||||
"role": "participant",
|
||||
"node_id": "",
|
||||
"label": "",
|
||||
"ts": int(timestamp),
|
||||
}
|
||||
body_bytes = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
hmac_hex = _hmac_mod.new(peer_key, body_bytes, _hashlib_mod.sha256).hexdigest()
|
||||
timeout = int(getattr(get_settings(), "MESH_RELAY_PUSH_TIMEOUT_S", 0) or 45)
|
||||
results: list[dict[str, Any]] = []
|
||||
for seed_url in _configured_seed_peer_urls():
|
||||
normalized = normalize_peer_url(seed_url)
|
||||
if not normalized:
|
||||
continue
|
||||
proxies = _infonet_peer_requests_proxies(normalized)
|
||||
request_kwargs: dict[str, Any] = {
|
||||
"data": body_bytes,
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"X-Peer-Url": peer_url,
|
||||
"X-Peer-HMAC": hmac_hex,
|
||||
},
|
||||
"timeout": timeout,
|
||||
}
|
||||
if proxies:
|
||||
request_kwargs["proxies"] = proxies
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{normalized}/api/mesh/infonet/peer-announce",
|
||||
**request_kwargs,
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"seed_peer_url": normalized,
|
||||
"status_code": int(response.status_code),
|
||||
"ok": response.status_code == 200,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
results.append({"seed_peer_url": normalized, "ok": False, "detail": str(exc)})
|
||||
ok = any(bool(item.get("ok")) for item in results)
|
||||
return {"ok": ok, "peer_url": peer_url, "results": results}
|
||||
|
||||
|
||||
def _announce_succeeded(announce: dict[str, Any]) -> bool:
|
||||
if not bool(announce.get("ok")):
|
||||
return False
|
||||
results = announce.get("results") or []
|
||||
return any(bool(item.get("ok")) and int(item.get("status_code") or 0) == 200 for item in results)
|
||||
|
||||
|
||||
def _manifest_succeeded(manifest: dict[str, Any]) -> bool:
|
||||
if not bool(manifest.get("ok")):
|
||||
return False
|
||||
peer_count = int(manifest.get("merged_peer_count") or manifest.get("peer_count") or 0)
|
||||
return peer_count >= 1
|
||||
|
||||
|
||||
def join_swarm_with_retries(
|
||||
*,
|
||||
attempts: int = 6,
|
||||
delay_s: float = 15.0,
|
||||
force: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Announce to seed and pull manifest, retrying while Tor circuits warm up."""
|
||||
last_announce: dict[str, Any] = {"ok": False, "detail": "not attempted"}
|
||||
last_manifest: dict[str, Any] = {"ok": False, "detail": "not attempted"}
|
||||
tries = max(1, int(attempts))
|
||||
pause_s = max(1.0, float(delay_s))
|
||||
for attempt in range(tries):
|
||||
last_announce = announce_local_peer_to_seeds(force=force)
|
||||
last_manifest = refresh_swarm_manifest_from_seeds(force=force)
|
||||
if _announce_succeeded(last_announce) and _manifest_succeeded(last_manifest):
|
||||
return {
|
||||
"ok": True,
|
||||
"attempts": attempt + 1,
|
||||
"announce": last_announce,
|
||||
"manifest_pull": last_manifest,
|
||||
}
|
||||
if attempt + 1 < tries:
|
||||
time.sleep(pause_s)
|
||||
return {
|
||||
"ok": False,
|
||||
"attempts": tries,
|
||||
"announce": last_announce,
|
||||
"manifest_pull": last_manifest,
|
||||
"detail": "swarm join incomplete after retries",
|
||||
}
|
||||
|
||||
|
||||
def push_infonet_events_to_http_peers(events: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
import hashlib as _hashlib_mod
|
||||
import hmac as _hmac_mod
|
||||
import requests
|
||||
|
||||
from main import (
|
||||
_filter_infonet_peer_urls,
|
||||
_infonet_peer_requests_proxies,
|
||||
_local_infonet_peer_url,
|
||||
_participant_node_enabled,
|
||||
_record_public_push_result,
|
||||
)
|
||||
from services.mesh.mesh_router import authenticated_push_peer_urls
|
||||
|
||||
if not _participant_node_enabled() or not events:
|
||||
return {"ok": False, "detail": "nothing to push"}
|
||||
peers = _filter_infonet_peer_urls(authenticated_push_peer_urls())
|
||||
if not peers:
|
||||
return {"ok": False, "detail": "no push peers configured"}
|
||||
|
||||
sender_url = _local_infonet_peer_url()
|
||||
peer_key = resolve_peer_key_for_url(sender_url)
|
||||
if not peer_key:
|
||||
return {"ok": False, "detail": "peer HMAC secret is not configured"}
|
||||
|
||||
body_bytes = json.dumps(
|
||||
{"events": events},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
hmac_hex = _hmac_mod.new(peer_key, body_bytes, _hashlib_mod.sha256).hexdigest()
|
||||
timeout = int(getattr(get_settings(), "MESH_RELAY_PUSH_TIMEOUT_S", 0) or 45)
|
||||
results: list[dict[str, Any]] = []
|
||||
for peer_url in peers:
|
||||
normalized = normalize_peer_url(peer_url)
|
||||
if not normalized:
|
||||
continue
|
||||
proxies = _infonet_peer_requests_proxies(normalized)
|
||||
request_kwargs: dict[str, Any] = {
|
||||
"data": body_bytes,
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"X-Peer-Url": sender_url,
|
||||
"X-Peer-HMAC": hmac_hex,
|
||||
},
|
||||
"timeout": timeout,
|
||||
}
|
||||
if proxies:
|
||||
request_kwargs["proxies"] = proxies
|
||||
try:
|
||||
response = requests.post(f"{normalized}/api/mesh/infonet/peer-push", **request_kwargs)
|
||||
results.append(
|
||||
{
|
||||
"peer_url": normalized,
|
||||
"ok": response.status_code == 200,
|
||||
"status_code": int(response.status_code),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
results.append({"peer_url": normalized, "ok": False, "detail": str(exc)})
|
||||
ok = any(bool(item.get("ok")) for item in results)
|
||||
event_id = str((events[-1] or {}).get("event_id", "") or "")
|
||||
_record_public_push_result(
|
||||
event_id,
|
||||
ok=ok,
|
||||
error="" if ok else "immediate peer push failed",
|
||||
results=results,
|
||||
)
|
||||
return {"ok": ok, "results": results}
|
||||
@@ -929,6 +929,85 @@ def list_wormhole_dm_contacts() -> dict[str, dict[str, Any]]:
|
||||
return _read_contacts()
|
||||
|
||||
|
||||
def get_wormhole_dm_contact(peer_id: str) -> dict[str, Any] | None:
|
||||
peer_key = str(peer_id or "").strip()
|
||||
if not peer_key:
|
||||
return None
|
||||
contacts = _read_contacts()
|
||||
if peer_key not in contacts:
|
||||
return None
|
||||
return dict(_normalize_contact(contacts[peer_key]))
|
||||
|
||||
|
||||
def sever_wormhole_dm_contact(peer_id: str, *, block: bool = False) -> dict[str, Any]:
|
||||
"""Close the shared DM lane; a fresh contact request + accept is required to reopen."""
|
||||
peer_key = str(peer_id or "").strip()
|
||||
if not peer_key:
|
||||
return {"ok": False, "detail": "peer_id required"}
|
||||
|
||||
contacts = _read_contacts()
|
||||
current = _normalize_contact(contacts.get(peer_key))
|
||||
now = int(time.time())
|
||||
current["sharedAlias"] = ""
|
||||
current["sharedAliasCounter"] = 0
|
||||
current["sharedAliasPublicKey"] = ""
|
||||
current["sharedAliasPublicKeyAlgo"] = "Ed25519"
|
||||
current["previousSharedAliases"] = []
|
||||
current["pendingSharedAlias"] = ""
|
||||
current["pendingSharedAliasCounter"] = 0
|
||||
current["pendingSharedAliasPublicKey"] = ""
|
||||
current["pendingSharedAliasPublicKeyAlgo"] = "Ed25519"
|
||||
current["pendingSharedAliasGraceMs"] = 0
|
||||
current["sharedAliasGraceUntil"] = 0
|
||||
current["sharedAliasRotatedAt"] = 0
|
||||
current["acceptedPreviousAlias"] = ""
|
||||
current["acceptedPreviousAliasCounter"] = 0
|
||||
current["acceptedPreviousAliasPublicKey"] = ""
|
||||
current["acceptedPreviousAliasPublicKeyAlgo"] = "Ed25519"
|
||||
current["acceptedPreviousGraceUntil"] = 0
|
||||
current["acceptedPreviousHardGraceUntil"] = 0
|
||||
current["acceptedPreviousAwaitingReply"] = False
|
||||
current["aliasBindingSeq"] = 0
|
||||
current["aliasBindingPendingReason"] = ""
|
||||
current["aliasBindingPreparedAt"] = 0
|
||||
current["aliasGateJoinAppliedSeq"] = 0
|
||||
if block:
|
||||
current["blocked"] = True
|
||||
current["updated_at"] = now
|
||||
contacts[peer_key] = _normalize_contact(current)
|
||||
_write_contacts(contacts)
|
||||
|
||||
relay_policy = {}
|
||||
try:
|
||||
from services.mesh.mesh_dm_connect_delivery import revoke_connect_relay_policy
|
||||
|
||||
relay_policy = revoke_connect_relay_policy(peer_key)
|
||||
except Exception:
|
||||
relay_policy = {"ok": False}
|
||||
|
||||
relay_block = {"ok": False}
|
||||
if block:
|
||||
try:
|
||||
from services.mesh.mesh_dm_relay import dm_relay
|
||||
from services.mesh.mesh_wormhole_persona import get_dm_identity
|
||||
|
||||
local_id = str(get_dm_identity().get("node_id", "") or "").strip()
|
||||
if local_id:
|
||||
dm_relay.block(local_id, peer_key)
|
||||
relay_block = {"ok": True, "local_id": local_id}
|
||||
except Exception as exc:
|
||||
relay_block = {"ok": False, "detail": str(exc) or type(exc).__name__}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"peer_id": peer_key,
|
||||
"severed": True,
|
||||
"blocked": bool(block),
|
||||
"relay_policy": relay_policy,
|
||||
"relay_block": relay_block,
|
||||
}
|
||||
|
||||
|
||||
def _promote_invite_lookup_mode(contact: dict[str, Any], *, now: int | None = None) -> bool:
|
||||
current = dict(contact or {})
|
||||
lookup_handle = str(current.get("invitePinnedPrekeyLookupHandle", "") or "").strip()
|
||||
@@ -1070,11 +1149,14 @@ def pin_wormhole_dm_invite(
|
||||
identity_dh_pub_key = str(payload.get("identity_dh_pub_key", "") or "")
|
||||
dh_algo = str(payload.get("dh_algo", "X25519") or "X25519")
|
||||
prekey_lookup_handle = str(payload.get("prekey_lookup_handle", "") or "")
|
||||
lookup_peer_url = str(payload.get("lookup_peer_url", "") or "").strip().rstrip("/")
|
||||
if str(alias or "").strip():
|
||||
current["alias"] = str(alias or "").strip()
|
||||
current["dhPubKey"] = identity_dh_pub_key
|
||||
current["dhAlgo"] = dh_algo
|
||||
current["invitePinnedPrekeyLookupHandle"] = prekey_lookup_handle
|
||||
if lookup_peer_url:
|
||||
current["invitePinnedLookupPeerUrl"] = lookup_peer_url
|
||||
current["invitePinnedRootFingerprint"] = str(payload.get("root_fingerprint", "") or "").strip().lower()
|
||||
current["invitePinnedRootManifestFingerprint"] = str(
|
||||
payload.get("root_manifest_fingerprint", "") or ""
|
||||
@@ -1170,6 +1252,12 @@ def pin_wormhole_dm_invite(
|
||||
current["updated_at"] = now
|
||||
contacts[peer_key] = _normalize_contact(current)
|
||||
_write_contacts(contacts)
|
||||
try:
|
||||
from services.mesh.mesh_dm_connect_delivery import grant_connect_relay_policy
|
||||
|
||||
grant_connect_relay_policy(peer_key, reason="invite_import")
|
||||
except Exception:
|
||||
pass
|
||||
return contacts[peer_key]
|
||||
|
||||
|
||||
|
||||
@@ -549,6 +549,27 @@ def invite_identity_commitment_for_identity_material(
|
||||
return hashlib.sha256(_stable_json(material).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _local_dm_lookup_peer_url() -> str:
|
||||
"""Return this node's fleet-reachable URL for invite-scoped prekey lookup."""
|
||||
try:
|
||||
from services.config import get_settings
|
||||
from services.mesh.mesh_crypto import normalize_peer_url
|
||||
|
||||
configured = normalize_peer_url(str(getattr(get_settings(), "MESH_PUBLIC_PEER_URL", "") or ""))
|
||||
if configured:
|
||||
return configured
|
||||
from services.tor_hidden_service import tor_service
|
||||
|
||||
onion = str(getattr(tor_service, "onion_address", "") or "").strip()
|
||||
if onion:
|
||||
if "://" not in onion:
|
||||
onion = f"http://{onion}:8000"
|
||||
return normalize_peer_url(onion)
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _dm_invite_payload(
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
@@ -930,6 +951,9 @@ def export_wormhole_dm_invite(*, label: str = "", expires_in_s: int = 0) -> dict
|
||||
# fetch our prekey bundle without using our stable agent_id.
|
||||
lookup_handle = secrets.token_hex(24)
|
||||
payload["prekey_lookup_handle"] = lookup_handle
|
||||
lookup_peer_url = _local_dm_lookup_peer_url()
|
||||
if lookup_peer_url:
|
||||
payload["lookup_peer_url"] = lookup_peer_url
|
||||
|
||||
# Persist the handle so it is included in future prekey registrations.
|
||||
existing_handles, _ = _normalize_prekey_lookup_handles(
|
||||
|
||||
@@ -79,6 +79,164 @@ def _warn_legacy_prekey_lookup(agent_id: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _fleet_peer_lookup_user_agent() -> str:
|
||||
custom = str(os.environ.get("SHADOWBROKER_MESH_PEER_USER_AGENT") or "").strip()
|
||||
if custom:
|
||||
return custom
|
||||
return "Mozilla/5.0 (compatible; ShadowbrokerMesh/1.0)"
|
||||
|
||||
|
||||
_INVITE_LOOKUP_MAX_ELAPSED_S = 120
|
||||
_INVITE_LOOKUP_MAX_BOOTSTRAP_PEERS = 3
|
||||
_INVITE_LOOKUP_MAX_PUSH_PEERS = 16
|
||||
_INVITE_LOOKUP_PARALLEL_WORKERS = 8
|
||||
|
||||
|
||||
def _invite_lookup_request_timeout(peer_url: str) -> tuple[int, int]:
|
||||
from services.mesh.mesh_router import peer_transport_kind
|
||||
|
||||
if peer_transport_kind(peer_url) == "onion":
|
||||
return (10, 35)
|
||||
return (5, 15)
|
||||
|
||||
|
||||
def _bootstrap_seed_peer_urls() -> set[str]:
|
||||
try:
|
||||
from services.config import get_settings
|
||||
from services.mesh.mesh_router import parse_configured_relay_peers
|
||||
|
||||
seeds: set[str] = set()
|
||||
raw = str(getattr(get_settings(), "MESH_BOOTSTRAP_SEED_PEERS", "") or "")
|
||||
for peer in parse_configured_relay_peers(raw):
|
||||
normalized = str(peer or "").strip().rstrip("/")
|
||||
if normalized:
|
||||
seeds.add(normalized)
|
||||
return seeds
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def _discovered_push_peer_urls(*, limit: int = _INVITE_LOOKUP_MAX_PUSH_PEERS) -> list[str]:
|
||||
try:
|
||||
from services.mesh.mesh_router import authenticated_push_peer_urls
|
||||
|
||||
seeds = _bootstrap_seed_peer_urls()
|
||||
peers: list[str] = []
|
||||
for peer in authenticated_push_peer_urls():
|
||||
normalized = str(peer or "").strip().rstrip("/")
|
||||
if not normalized or normalized in seeds:
|
||||
continue
|
||||
peers.append(normalized)
|
||||
if len(peers) >= max(1, int(limit or 1)):
|
||||
break
|
||||
return peers
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _prioritized_invite_lookup_peer_urls(*, preferred: list[str] | None = None) -> list[str]:
|
||||
preferred_urls = [
|
||||
str(peer or "").strip().rstrip("/")
|
||||
for peer in list(preferred or [])
|
||||
if str(peer or "").strip()
|
||||
]
|
||||
configured = _configured_public_lookup_peer_urls()
|
||||
seeds = _bootstrap_seed_peer_urls()
|
||||
active: list[str] = []
|
||||
bootstrap: list[str] = []
|
||||
push_discovery: list[str] = []
|
||||
seen = set(preferred_urls)
|
||||
for peer in configured:
|
||||
if peer in seen:
|
||||
continue
|
||||
seen.add(peer)
|
||||
if peer in seeds:
|
||||
bootstrap.append(peer)
|
||||
else:
|
||||
active.append(peer)
|
||||
for peer in _discovered_push_peer_urls():
|
||||
if peer in seen:
|
||||
continue
|
||||
seen.add(peer)
|
||||
push_discovery.append(peer)
|
||||
ordered = list(preferred_urls)
|
||||
ordered.extend(active)
|
||||
ordered.extend(push_discovery)
|
||||
ordered.extend(bootstrap[:_INVITE_LOOKUP_MAX_BOOTSTRAP_PEERS])
|
||||
return ordered
|
||||
|
||||
|
||||
def _preferred_invite_lookup_peer_urls(lookup_token: str) -> list[str]:
|
||||
token = str(lookup_token or "").strip()
|
||||
if not token:
|
||||
return []
|
||||
try:
|
||||
from services.mesh.mesh_wormhole_contacts import list_wormhole_dm_contacts
|
||||
except Exception:
|
||||
return []
|
||||
peers: list[str] = []
|
||||
for contact in list_wormhole_dm_contacts() or []:
|
||||
if not isinstance(contact, dict):
|
||||
continue
|
||||
if str(contact.get("invitePinnedPrekeyLookupHandle", "") or "").strip() != token:
|
||||
continue
|
||||
peer_url = str(contact.get("invitePinnedLookupPeerUrl", "") or "").strip().rstrip("/")
|
||||
if peer_url and peer_url not in peers:
|
||||
peers.append(peer_url)
|
||||
return peers
|
||||
|
||||
|
||||
def _peer_http_request(
|
||||
method: str,
|
||||
peer_url: str,
|
||||
*,
|
||||
body_bytes: bytes | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: int | tuple[int, int] = 45,
|
||||
):
|
||||
"""HTTP to a fleet peer, using Tor SOCKS when the URL is an onion address."""
|
||||
import requests
|
||||
|
||||
from services.mesh.mesh_crypto import normalize_peer_url
|
||||
from urllib.parse import urlparse
|
||||
|
||||
raw_peer_url = str(peer_url or "").strip()
|
||||
parsed = urlparse(raw_peer_url)
|
||||
if parsed.path and parsed.path not in {"", "/"}:
|
||||
# Full request URLs include invite lookup query params; do not
|
||||
# normalize them away when deriving the peer base URL.
|
||||
normalized = raw_peer_url
|
||||
else:
|
||||
normalized = normalize_peer_url(raw_peer_url)
|
||||
if not normalized:
|
||||
raise OSError("invalid peer url")
|
||||
if isinstance(timeout, tuple):
|
||||
connect_timeout, read_timeout = timeout
|
||||
resolved_timeout: int | tuple[int, int] = (
|
||||
max(1, int(connect_timeout or 5)),
|
||||
max(1, int(read_timeout or 15)),
|
||||
)
|
||||
else:
|
||||
resolved_timeout = max(1, int(timeout or 45))
|
||||
request_kwargs: dict[str, Any] = {
|
||||
"headers": dict(headers or {}),
|
||||
"timeout": resolved_timeout,
|
||||
}
|
||||
try:
|
||||
from main import _infonet_peer_requests_proxies
|
||||
|
||||
proxy_peer_url = normalize_peer_url(f"{parsed.scheme}://{parsed.netloc}")
|
||||
proxies = _infonet_peer_requests_proxies(proxy_peer_url)
|
||||
if proxies:
|
||||
request_kwargs["proxies"] = proxies
|
||||
except Exception:
|
||||
pass
|
||||
if method.upper() == "GET":
|
||||
return requests.get(normalized, **request_kwargs)
|
||||
request_kwargs["data"] = body_bytes or b""
|
||||
return requests.post(normalized, **request_kwargs)
|
||||
|
||||
|
||||
def _fetch_dm_prekey_bundle_from_peer_lookup(lookup_token: str) -> dict[str, Any]:
|
||||
"""Fetch an invite-scoped prekey bundle from configured authenticated peers.
|
||||
|
||||
@@ -95,12 +253,12 @@ def _fetch_dm_prekey_bundle_from_peer_lookup(lookup_token: str) -> dict[str, Any
|
||||
normalize_peer_url,
|
||||
resolve_peer_key_for_url,
|
||||
)
|
||||
from services.mesh.mesh_router import configured_relay_peer_urls
|
||||
from services.mesh.mesh_router import authenticated_push_peer_urls
|
||||
|
||||
settings = get_settings()
|
||||
# Issue #256: secret check moved per-peer below. We still bail out
|
||||
# cleanly when there are no peers configured at all.
|
||||
peers = configured_relay_peer_urls()
|
||||
peers = authenticated_push_peer_urls()
|
||||
if not peers:
|
||||
return {"ok": False, "detail": "peer prekey lookup unavailable"}
|
||||
timeout = max(1, _safe_int(getattr(settings, "MESH_RELAY_PUSH_TIMEOUT_S", 10) or 10, 10))
|
||||
@@ -132,17 +290,17 @@ def _fetch_dm_prekey_bundle_from_peer_lookup(lookup_token: str) -> dict[str, Any
|
||||
"X-Peer-Url": sender_peer_url,
|
||||
"X-Peer-HMAC": hmac.new(peer_key, body, hashlib.sha256).hexdigest(),
|
||||
}
|
||||
request = urllib.request.Request(
|
||||
f"{normalized_peer_url}/api/mesh/dm/prekey-peer-lookup",
|
||||
data=body,
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
raw = response.read(256 * 1024)
|
||||
response = _peer_http_request(
|
||||
"POST",
|
||||
f"{normalized_peer_url}/api/mesh/dm/prekey-peer-lookup",
|
||||
body_bytes=body,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
)
|
||||
raw = response.content[: 256 * 1024]
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc:
|
||||
except (json.JSONDecodeError, OSError, Exception) as exc:
|
||||
last_detail = str(exc) or type(exc).__name__
|
||||
continue
|
||||
if isinstance(payload, dict) and payload.get("ok"):
|
||||
@@ -161,12 +319,18 @@ def _configured_public_lookup_peer_urls() -> list[str]:
|
||||
|
||||
settings = get_settings()
|
||||
candidates: list[str] = []
|
||||
# Operator-configured peers first, then recently active fleet nodes.
|
||||
# Invite handles are minted on a specific node; cold bootstrap seeds
|
||||
# rarely have them cached and should not be tried before contacts.
|
||||
for raw in (
|
||||
getattr(settings, "MESH_BOOTSTRAP_SEED_PEERS", ""),
|
||||
getattr(settings, "MESH_DEFAULT_SYNC_PEERS", ""),
|
||||
):
|
||||
candidates.extend(parse_configured_relay_peers(str(raw or "")))
|
||||
candidates.extend(active_sync_peer_urls())
|
||||
for raw in (
|
||||
getattr(settings, "MESH_BOOTSTRAP_SEED_PEERS", ""),
|
||||
):
|
||||
candidates.extend(parse_configured_relay_peers(str(raw or "")))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@@ -204,7 +368,50 @@ def _normalize_remote_lookup_bundle(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return data
|
||||
|
||||
|
||||
def _fetch_dm_prekey_bundle_from_public_lookup(lookup_token: str) -> dict[str, Any]:
|
||||
def _try_public_prekey_lookup_peer(
|
||||
peer_url: str,
|
||||
encoded: str,
|
||||
*,
|
||||
timeout: int | tuple[int, int] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
normalized_peer_url = str(peer_url or "").strip().rstrip("/")
|
||||
if not normalized_peer_url:
|
||||
return {"ok": False, "detail": "invalid peer url"}
|
||||
resolved_timeout = timeout or _invite_lookup_request_timeout(normalized_peer_url)
|
||||
try:
|
||||
response = _peer_http_request(
|
||||
"GET",
|
||||
f"{normalized_peer_url}/api/mesh/dm/prekey-bundle?{encoded}",
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": _fleet_peer_lookup_user_agent(),
|
||||
},
|
||||
timeout=resolved_timeout,
|
||||
)
|
||||
raw = response.content[: 256 * 1024]
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
except (json.JSONDecodeError, OSError, Exception) as exc:
|
||||
logger.debug("public prekey lookup failed for %s: %s", normalized_peer_url, type(exc).__name__)
|
||||
return {"ok": False, "detail": "peer prekey lookup unavailable"}
|
||||
if not isinstance(payload, dict):
|
||||
return {"ok": False, "detail": "invalid peer response"}
|
||||
if payload.get("pending") or str(payload.get("status", "") or "") == "preparing_private_lane":
|
||||
return {"ok": False, "detail": "peer prekey lookup still preparing"}
|
||||
if not payload.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"detail": str(payload.get("detail", "") or "Prekey bundle not found"),
|
||||
}
|
||||
if not isinstance(payload.get("bundle"), dict):
|
||||
return {"ok": False, "detail": "Prekey bundle not found"}
|
||||
return _normalize_remote_lookup_bundle(payload)
|
||||
|
||||
|
||||
def _fetch_dm_prekey_bundle_from_public_lookup(
|
||||
lookup_token: str,
|
||||
*,
|
||||
extra_preferred_peer_urls: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch an invite-scoped prekey bundle from bootstrap/sync peers.
|
||||
|
||||
The token is high-entropy and invite-scoped. This path does not expose a
|
||||
@@ -212,61 +419,69 @@ def _fetch_dm_prekey_bundle_from_public_lookup(lookup_token: str) -> dict[str, A
|
||||
derive it from the signed identity public key and validate the bundle before
|
||||
accepting it.
|
||||
"""
|
||||
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
||||
|
||||
token = str(lookup_token or "").strip()
|
||||
if not token:
|
||||
return {"ok": False, "detail": "lookup token required"}
|
||||
peers = _configured_public_lookup_peer_urls()
|
||||
preferred = list(_preferred_invite_lookup_peer_urls(token))
|
||||
for peer in list(extra_preferred_peer_urls or []):
|
||||
normalized = str(peer or "").strip().rstrip("/")
|
||||
if normalized and normalized not in preferred:
|
||||
preferred.insert(0, normalized)
|
||||
peers = _prioritized_invite_lookup_peer_urls(preferred=preferred)
|
||||
if not peers:
|
||||
return {"ok": False, "detail": "peer prekey lookup unavailable"}
|
||||
try:
|
||||
from services.config import get_settings
|
||||
|
||||
timeout = max(1, _safe_int(getattr(get_settings(), "MESH_SYNC_TIMEOUT_S", 5) or 5, 5))
|
||||
except Exception:
|
||||
timeout = 5
|
||||
|
||||
encoded = urllib.parse.urlencode({"lookup_token": token})
|
||||
last_detail = ""
|
||||
for peer_url in peers:
|
||||
normalized_peer_url = str(peer_url or "").strip().rstrip("/")
|
||||
if not normalized_peer_url:
|
||||
continue
|
||||
# Generic UA: any peer-facing crypto request should not carry a
|
||||
# fork-specific identifier — that turns prekey lookups into a
|
||||
# software-fingerprinting beacon.
|
||||
from services.network_utils import default_user_agent
|
||||
request = urllib.request.Request(
|
||||
f"{normalized_peer_url}/api/mesh/dm/prekey-bundle?{encoded}",
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": default_user_agent(),
|
||||
},
|
||||
method="GET",
|
||||
hinted_only = bool(list(extra_preferred_peer_urls or []))
|
||||
hint_timeout = (5, 20)
|
||||
for peer_url in preferred:
|
||||
hinted = _try_public_prekey_lookup_peer(
|
||||
peer_url,
|
||||
encoded,
|
||||
timeout=hint_timeout if hinted_only else None,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
raw = response.read(256 * 1024)
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc:
|
||||
logger.debug("public prekey lookup failed for %s: %s", normalized_peer_url, type(exc).__name__)
|
||||
last_detail = "peer prekey lookup unavailable"
|
||||
continue
|
||||
if not isinstance(payload, dict):
|
||||
last_detail = "invalid peer response"
|
||||
continue
|
||||
if payload.get("pending") or str(payload.get("status", "") or "") == "preparing_private_lane":
|
||||
last_detail = "peer prekey lookup still preparing"
|
||||
continue
|
||||
if not payload.get("ok"):
|
||||
last_detail = str(payload.get("detail", "") or last_detail or "Prekey bundle not found")
|
||||
continue
|
||||
if not isinstance(payload.get("bundle"), dict):
|
||||
last_detail = "Prekey bundle not found"
|
||||
continue
|
||||
normalized = _normalize_remote_lookup_bundle(payload)
|
||||
if normalized.get("ok"):
|
||||
return normalized
|
||||
last_detail = str(normalized.get("detail", "") or last_detail)
|
||||
if hinted.get("ok"):
|
||||
return hinted
|
||||
if isinstance(hinted, dict):
|
||||
last_detail = str(hinted.get("detail", "") or last_detail)
|
||||
remaining_peers = [peer for peer in peers if peer not in set(preferred)]
|
||||
if not remaining_peers:
|
||||
return {"ok": False, "detail": last_detail or "Prekey bundle not found"}
|
||||
if hinted_only:
|
||||
return {"ok": False, "detail": last_detail or "Prekey bundle not found"}
|
||||
deadline = time.time() + _INVITE_LOOKUP_MAX_ELAPSED_S
|
||||
workers = min(_INVITE_LOOKUP_PARALLEL_WORKERS, max(1, len(remaining_peers)))
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
futures = {
|
||||
executor.submit(_try_public_prekey_lookup_peer, peer_url, encoded): peer_url
|
||||
for peer_url in remaining_peers
|
||||
}
|
||||
while futures and time.time() < deadline:
|
||||
done, _ = wait(
|
||||
futures,
|
||||
timeout=max(0.1, deadline - time.time()),
|
||||
return_when=FIRST_COMPLETED,
|
||||
)
|
||||
if not done:
|
||||
break
|
||||
for future in done:
|
||||
futures.pop(future, None)
|
||||
try:
|
||||
result = future.result()
|
||||
except Exception as exc:
|
||||
last_detail = str(exc) or type(exc).__name__
|
||||
continue
|
||||
if isinstance(result, dict) and result.get("ok"):
|
||||
for pending in futures:
|
||||
pending.cancel()
|
||||
return result
|
||||
if isinstance(result, dict):
|
||||
last_detail = str(result.get("detail", "") or last_detail)
|
||||
for pending in futures:
|
||||
pending.cancel()
|
||||
return {"ok": False, "detail": last_detail or "Prekey bundle not found"}
|
||||
|
||||
|
||||
@@ -1019,6 +1234,7 @@ def fetch_dm_prekey_bundle(
|
||||
lookup_token: str = "",
|
||||
*,
|
||||
allow_peer_lookup: bool = True,
|
||||
lookup_peer_urls: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from services.mesh.mesh_dm_relay import dm_relay
|
||||
|
||||
@@ -1043,12 +1259,18 @@ def fetch_dm_prekey_bundle(
|
||||
resolved_id = found_id
|
||||
lookup_mode = "invite_lookup_handle"
|
||||
elif allow_peer_lookup:
|
||||
peer_found = _fetch_dm_prekey_bundle_from_peer_lookup(resolved_lookup)
|
||||
if peer_found.get("ok"):
|
||||
return peer_found
|
||||
public_found = _fetch_dm_prekey_bundle_from_public_lookup(resolved_lookup)
|
||||
preferred_peer_urls = list(lookup_peer_urls or [])
|
||||
public_found = _fetch_dm_prekey_bundle_from_public_lookup(
|
||||
resolved_lookup,
|
||||
extra_preferred_peer_urls=preferred_peer_urls,
|
||||
)
|
||||
if public_found.get("ok"):
|
||||
return public_found
|
||||
peer_found: dict[str, Any] = {"ok": False, "detail": ""}
|
||||
if not preferred_peer_urls:
|
||||
peer_found = _fetch_dm_prekey_bundle_from_peer_lookup(resolved_lookup)
|
||||
if peer_found.get("ok"):
|
||||
return peer_found
|
||||
if str(public_found.get("detail", "") or "").strip():
|
||||
return {"ok": False, "detail": str(public_found.get("detail", "") or "Prekey bundle not found")}
|
||||
return {"ok": False, "detail": str(peer_found.get("detail", "") or "Prekey bundle not found")}
|
||||
@@ -1134,12 +1356,24 @@ def _classify_root_attestation_failure(peer_id: str) -> tuple[str, bool]:
|
||||
return "", False
|
||||
|
||||
|
||||
def bootstrap_encrypt_for_peer(peer_id: str, plaintext: str) -> dict[str, Any]:
|
||||
fetched_bundle = fetch_dm_prekey_bundle(str(peer_id or "").strip())
|
||||
def bootstrap_encrypt_for_peer(
|
||||
peer_id: str,
|
||||
plaintext: str,
|
||||
*,
|
||||
lookup_token: str = "",
|
||||
fetched_bundle: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
token = str(lookup_token or "").strip()
|
||||
peer = str(peer_id or "").strip()
|
||||
if fetched_bundle is None:
|
||||
fetched_bundle = fetch_dm_prekey_bundle(
|
||||
agent_id=peer if not token else "",
|
||||
lookup_token=token,
|
||||
)
|
||||
if not fetched_bundle.get("ok"):
|
||||
detail = str(fetched_bundle.get("detail", "") or "")
|
||||
if "root attestation" in detail.lower():
|
||||
trust_level, trust_changed = _classify_root_attestation_failure(str(peer_id or "").strip())
|
||||
trust_level, trust_changed = _classify_root_attestation_failure(peer or token)
|
||||
if trust_level:
|
||||
return {
|
||||
"ok": False,
|
||||
@@ -1152,32 +1386,68 @@ def bootstrap_encrypt_for_peer(peer_id: str, plaintext: str) -> dict[str, Any]:
|
||||
|
||||
from services.mesh.mesh_dm_relay import dm_relay
|
||||
|
||||
resolved_peer_id = str(fetched_bundle.get("agent_id", peer_id) or peer_id).strip()
|
||||
resolved_peer_id = str(fetched_bundle.get("agent_id", peer) or peer).strip()
|
||||
stored = dm_relay.get_prekey_bundle(resolved_peer_id)
|
||||
if not stored:
|
||||
return {"ok": False, "detail": "Peer prekey bundle not found"}
|
||||
remote_bundle = dict(fetched_bundle.get("bundle") or {})
|
||||
if not remote_bundle and fetched_bundle.get("identity_dh_pub_key"):
|
||||
remote_bundle = fetched_bundle
|
||||
if remote_bundle:
|
||||
stored = {
|
||||
"bundle": remote_bundle,
|
||||
"signature": str(fetched_bundle.get("signature", "") or ""),
|
||||
"public_key": str(fetched_bundle.get("public_key", "") or ""),
|
||||
"public_key_algo": str(fetched_bundle.get("public_key_algo", "") or ""),
|
||||
"sequence": _safe_int(fetched_bundle.get("sequence", 0) or 0),
|
||||
}
|
||||
else:
|
||||
return {"ok": False, "detail": "Peer prekey bundle not found"}
|
||||
validated_record = {**dict(stored), "agent_id": resolved_peer_id}
|
||||
ok, reason = _validate_bundle_record(validated_record)
|
||||
if not ok:
|
||||
return {"ok": False, "detail": reason}
|
||||
trust_state = observe_remote_prekey_bundle(resolved_peer_id, validated_record)
|
||||
trust_level = str(trust_state.get("trust_level", "") or "")
|
||||
from services.mesh.mesh_wormhole_contacts import verified_first_contact_requirement
|
||||
consent_handshake = False
|
||||
try:
|
||||
from services.mesh.mesh_wormhole_dead_drop import parse_contact_consent
|
||||
|
||||
verified_first_contact = verified_first_contact_requirement(
|
||||
resolved_peer_id,
|
||||
trust_level=trust_level,
|
||||
)
|
||||
if not verified_first_contact.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"peer_id": resolved_peer_id,
|
||||
"detail": str(verified_first_contact.get("detail", "") or "verified first contact required"),
|
||||
"trust_changed": trust_level in ("mismatch", "continuity_broken"),
|
||||
"trust_level": str(verified_first_contact.get("trust_level", "") or trust_level or "unpinned"),
|
||||
consent = parse_contact_consent(str(plaintext or "")) or {}
|
||||
consent_handshake = str(consent.get("kind", "") or "") in {
|
||||
"contact_offer",
|
||||
"contact_accept",
|
||||
"contact_deny",
|
||||
}
|
||||
except Exception:
|
||||
consent_handshake = False
|
||||
if not consent_handshake:
|
||||
from services.mesh.mesh_wormhole_contacts import verified_first_contact_requirement
|
||||
|
||||
verified_first_contact = verified_first_contact_requirement(
|
||||
resolved_peer_id,
|
||||
trust_level=trust_level,
|
||||
)
|
||||
if not verified_first_contact.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"peer_id": resolved_peer_id,
|
||||
"detail": str(
|
||||
verified_first_contact.get("detail", "") or "verified first contact required"
|
||||
),
|
||||
"trust_changed": trust_level in ("mismatch", "continuity_broken"),
|
||||
"trust_level": str(
|
||||
verified_first_contact.get("trust_level", "") or trust_level or "unpinned"
|
||||
),
|
||||
}
|
||||
peer_bundle_stored = dm_relay.consume_one_time_prekey(resolved_peer_id)
|
||||
if not peer_bundle_stored:
|
||||
remote_bundle = dict(stored.get("bundle") or {})
|
||||
otks = list(remote_bundle.get("one_time_prekeys") or [])
|
||||
peer_bundle_stored = {
|
||||
"bundle": remote_bundle,
|
||||
"claimed_one_time_prekey": dict(otks[0] or {}) if otks else {},
|
||||
}
|
||||
if not peer_bundle_stored.get("bundle"):
|
||||
return {"ok": False, "detail": "Peer prekey bundle not found"}
|
||||
peer_bundle = dict(peer_bundle_stored.get("bundle") or {})
|
||||
peer_static = str(peer_bundle.get("identity_dh_pub_key", "") or "")
|
||||
|
||||
@@ -87,6 +87,14 @@ READ_COMMANDS = frozenset({
|
||||
"osint_lookup",
|
||||
"osint_tools",
|
||||
"entity_expand",
|
||||
# Agent routing helpers
|
||||
"route_query",
|
||||
"run_playbook",
|
||||
# Private Infonet reads (operator-delegated)
|
||||
"infonet_status",
|
||||
"list_gates",
|
||||
"read_gate_messages",
|
||||
"poll_dms",
|
||||
})
|
||||
|
||||
WRITE_COMMANDS = frozenset({
|
||||
@@ -118,6 +126,12 @@ WRITE_COMMANDS = frozenset({
|
||||
"clear_analysis_zones",
|
||||
# Active recon (subnet device discovery)
|
||||
"osint_sweep",
|
||||
# Private Infonet writes (operator wormhole identity)
|
||||
"ensure_infonet_ready",
|
||||
"join_infonet_swarm",
|
||||
"post_gate_message",
|
||||
"cast_vote",
|
||||
"send_dm",
|
||||
})
|
||||
|
||||
|
||||
@@ -643,6 +657,19 @@ def _compact_query_result(result: Any) -> Any:
|
||||
# Command dispatcher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _expensive_gate(cmd: str, args: dict[str, Any]) -> dict[str, Any] | None:
|
||||
from services.openclaw_routing import EXPENSIVE_GATE_MESSAGE, requires_expensive_confirm
|
||||
|
||||
if requires_expensive_confirm(cmd, args):
|
||||
return {
|
||||
"ok": False,
|
||||
"detail": EXPENSIVE_GATE_MESSAGE,
|
||||
"code": "expensive_command_blocked",
|
||||
"hint": "route_query",
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _dispatch_command(cmd: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Route a command to the appropriate AI Intel function.
|
||||
|
||||
@@ -650,6 +677,43 @@ def _dispatch_command(cmd: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
Commands run in an isolated thread (via _execute_command) so they
|
||||
do not need or touch the caller's event loop.
|
||||
"""
|
||||
blocked = _expensive_gate(cmd, args)
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
|
||||
if cmd == "route_query":
|
||||
from services.openclaw_routing import route_query
|
||||
|
||||
result = route_query(
|
||||
text=str(args.get("text", "") or args.get("query", "") or ""),
|
||||
lat=args.get("lat"),
|
||||
lng=args.get("lng"),
|
||||
radius_km=float(args.get("radius_km", 50) or 50),
|
||||
compact=bool(args.get("compact", True)),
|
||||
)
|
||||
return {"ok": True, "data": result}
|
||||
|
||||
if cmd == "run_playbook":
|
||||
from services.openclaw_routing import plan_playbook
|
||||
|
||||
plan = plan_playbook(str(args.get("name", "") or args.get("playbook", "")), args)
|
||||
if not plan.get("ok"):
|
||||
return plan
|
||||
batch_results: list[dict[str, Any]] = []
|
||||
for item in plan.get("batch", []):
|
||||
inner_cmd = str(item.get("cmd", "")).strip().lower()
|
||||
inner_args = item.get("args") or {}
|
||||
inner_result = _dispatch_command(inner_cmd, inner_args)
|
||||
batch_results.append({"cmd": inner_cmd, **inner_result})
|
||||
return {
|
||||
"ok": True,
|
||||
"data": {
|
||||
"playbook": plan.get("playbook"),
|
||||
"description": plan.get("description", ""),
|
||||
"results": batch_results,
|
||||
},
|
||||
}
|
||||
|
||||
if cmd == "get_telemetry":
|
||||
from services.telemetry import get_cached_telemetry_refs
|
||||
data = get_cached_telemetry_refs()
|
||||
@@ -731,6 +795,7 @@ def _dispatch_command(cmd: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
owner=str(args.get("owner", "") or args.get("operator", "") or ""),
|
||||
layers=args.get("layers") if isinstance(args.get("layers"), (list, tuple)) else None,
|
||||
limit=args.get("limit", 10),
|
||||
fallback_search=bool(args.get("fallback_search") or args.get("confirm_fuzzy")),
|
||||
)
|
||||
if _wants_compact(args):
|
||||
compact = dict(result)
|
||||
@@ -1092,6 +1157,7 @@ def _dispatch_command(cmd: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
owner=str(args.get("owner", "") or args.get("operator", "") or ""),
|
||||
layers=args.get("layers") if isinstance(args.get("layers"), (list, tuple)) else None,
|
||||
limit=5,
|
||||
fallback_search=True,
|
||||
)
|
||||
best = lookup.get("best_match") if isinstance(lookup.get("best_match"), dict) else {}
|
||||
group = str(best.get("group", "") or entity_type).lower()
|
||||
@@ -1543,6 +1609,85 @@ def _dispatch_command(cmd: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
count = clear_zones(source="openclaw")
|
||||
return {"ok": True, "data": {"removed_count": count}}
|
||||
|
||||
# -- Infonet / gate / DM (operator-delegated, full tier for writes) ------
|
||||
|
||||
if cmd == "infonet_status":
|
||||
from services.openclaw_infonet import get_infonet_status
|
||||
|
||||
return get_infonet_status()
|
||||
|
||||
if cmd == "ensure_infonet_ready":
|
||||
from services.openclaw_infonet import ensure_infonet_ready
|
||||
|
||||
return ensure_infonet_ready(join_swarm=bool(args.get("join_swarm", True)))
|
||||
|
||||
if cmd == "join_infonet_swarm":
|
||||
from services.openclaw_infonet import join_infonet_swarm
|
||||
|
||||
return join_infonet_swarm()
|
||||
|
||||
if cmd == "list_gates":
|
||||
from services.openclaw_infonet import list_gates
|
||||
|
||||
return list_gates()
|
||||
|
||||
if cmd == "read_gate_messages":
|
||||
from services.openclaw_infonet import read_gate_messages
|
||||
|
||||
gate_id = str(args.get("gate_id", "") or args.get("gate", "")).strip()
|
||||
return read_gate_messages(
|
||||
gate_id,
|
||||
limit=int(args.get("limit", 20) or 20),
|
||||
decrypt=bool(args.get("decrypt", False)),
|
||||
)
|
||||
|
||||
if cmd == "post_gate_message":
|
||||
from services.openclaw_infonet import post_gate_message
|
||||
|
||||
gate_id = str(args.get("gate_id", "") or args.get("gate", "")).strip()
|
||||
plaintext = str(args.get("plaintext", "") or args.get("message", "")).strip()
|
||||
return post_gate_message(
|
||||
gate_id,
|
||||
plaintext,
|
||||
reply_to=str(args.get("reply_to", "") or ""),
|
||||
)
|
||||
|
||||
if cmd == "cast_vote":
|
||||
from services.openclaw_infonet import cast_vote
|
||||
|
||||
target_id = str(args.get("target_id", "") or args.get("target", "")).strip()
|
||||
vote_raw = args.get("vote", args.get("direction"))
|
||||
try:
|
||||
vote_val = int(vote_raw)
|
||||
except (TypeError, ValueError):
|
||||
return {"ok": False, "detail": "vote must be 1 or -1"}
|
||||
return cast_vote(
|
||||
target_id,
|
||||
vote_val,
|
||||
gate=str(args.get("gate", "") or args.get("gate_id", "")).strip(),
|
||||
)
|
||||
|
||||
if cmd == "send_dm":
|
||||
from services.openclaw_infonet import send_dm
|
||||
|
||||
peer_id = str(
|
||||
args.get("peer_id", "")
|
||||
or args.get("recipient_id", "")
|
||||
or args.get("recipient", "")
|
||||
).strip()
|
||||
plaintext = str(args.get("plaintext", "") or args.get("message", "")).strip()
|
||||
return send_dm(
|
||||
peer_id,
|
||||
plaintext,
|
||||
delivery_class=str(args.get("delivery_class", "shared") or "shared"),
|
||||
recipient_token=str(args.get("recipient_token", "") or ""),
|
||||
)
|
||||
|
||||
if cmd == "poll_dms":
|
||||
from services.openclaw_infonet import poll_dms
|
||||
|
||||
return poll_dms(limit=int(args.get("limit", 20) or 20))
|
||||
|
||||
return {"ok": False, "detail": f"unhandled command: {cmd}"}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
"""OpenClaw agent delegation for private Infonet / gate / DM actions.
|
||||
|
||||
Agents authenticate with OpenClaw HMAC on the command channel. Write
|
||||
commands require ``OPENCLAW_ACCESS_TIER=full``. Actions use the operator's
|
||||
local wormhole persona and node runtime — the agent posts on behalf of the
|
||||
user who configured the skill, not as a separate fleet identity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _run_async(coro):
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro)
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _local_agent_request(path: str, *, method: str = "POST") -> Request:
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": method.upper(),
|
||||
"path": path,
|
||||
"headers": [],
|
||||
"client": ("127.0.0.1", 52421),
|
||||
}
|
||||
request = Request(scope)
|
||||
request.state._private_lane_current_tier = "private_strong"
|
||||
request.state._transport_tier = "private_strong"
|
||||
return request
|
||||
|
||||
|
||||
def ensure_infonet_ready(*, join_swarm: bool = True) -> dict[str, Any]:
|
||||
"""Warm Tor, enable the participant node, and optionally join the swarm."""
|
||||
from routers.ai_intel import _write_env_value
|
||||
from services.config import get_settings
|
||||
from services.mesh.mesh_swarm_runtime import join_swarm_with_retries
|
||||
from services.node_settings import read_node_settings, write_node_settings
|
||||
from services.tor_hidden_service import tor_service
|
||||
from services.wormhole_supervisor import _check_arti_ready
|
||||
|
||||
steps: dict[str, Any] = {}
|
||||
|
||||
tor_result = tor_service.start(target_port=8000)
|
||||
steps["tor"] = tor_result
|
||||
if tor_result.get("ok"):
|
||||
try:
|
||||
_write_env_value("MESH_ARTI_ENABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
except Exception as exc:
|
||||
logger.debug("failed to persist MESH_ARTI_ENABLED: %s", exc)
|
||||
|
||||
if not _check_arti_ready():
|
||||
return {
|
||||
"ok": False,
|
||||
"detail": "Tor/Arti transport is not ready yet",
|
||||
"steps": steps,
|
||||
}
|
||||
|
||||
if not bool(read_node_settings().get("enabled")):
|
||||
write_node_settings(enabled=True)
|
||||
steps["node_enabled"] = True
|
||||
try:
|
||||
import main as main_mod
|
||||
|
||||
main_mod._refresh_node_peer_store()
|
||||
main_mod._start_infonet_node_runtime("openclaw_agent")
|
||||
except Exception as exc:
|
||||
logger.warning("node runtime start after agent enable failed: %s", exc)
|
||||
else:
|
||||
steps["node_enabled"] = True
|
||||
|
||||
if join_swarm:
|
||||
joined = join_swarm_with_retries()
|
||||
steps["announce"] = joined.get("announce") or {}
|
||||
steps["manifest_pull"] = joined.get("manifest_pull") or {}
|
||||
steps["swarm_attempts"] = joined.get("attempts")
|
||||
ok = bool(joined.get("ok"))
|
||||
else:
|
||||
ok = True
|
||||
|
||||
return {
|
||||
"ok": ok,
|
||||
"detail": "Infonet participant runtime ready" if ok else "swarm join incomplete",
|
||||
"steps": steps,
|
||||
"onion_address": str(tor_result.get("onion_address") or ""),
|
||||
}
|
||||
|
||||
|
||||
def join_infonet_swarm() -> dict[str, Any]:
|
||||
from services.mesh.mesh_swarm_runtime import join_swarm_with_retries
|
||||
|
||||
joined = join_swarm_with_retries()
|
||||
return {
|
||||
"ok": bool(joined.get("ok")),
|
||||
"announce": joined.get("announce") or {},
|
||||
"manifest_pull": joined.get("manifest_pull") or {},
|
||||
"attempts": joined.get("attempts"),
|
||||
"detail": joined.get("detail"),
|
||||
}
|
||||
|
||||
|
||||
def get_infonet_status() -> dict[str, Any]:
|
||||
from services.mesh.mesh_hashchain import infonet
|
||||
from services.wormhole_supervisor import get_wormhole_state
|
||||
|
||||
info = infonet.get_info()
|
||||
valid, reason = infonet.validate_chain(verify_signatures=False)
|
||||
try:
|
||||
wormhole = get_wormhole_state()
|
||||
except Exception:
|
||||
wormhole = {"configured": False, "ready": False, "arti_ready": False, "rns_ready": False}
|
||||
try:
|
||||
import main as main_mod
|
||||
|
||||
runtime = main_mod._node_runtime_snapshot()
|
||||
private_tier = main_mod._current_private_lane_tier(wormhole)
|
||||
except Exception:
|
||||
runtime = {}
|
||||
private_tier = "public_degraded"
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"chain": info,
|
||||
"valid": valid,
|
||||
"validation": reason,
|
||||
"private_lane_tier": private_tier,
|
||||
"wormhole": wormhole,
|
||||
"runtime": runtime,
|
||||
}
|
||||
|
||||
|
||||
def list_gates() -> dict[str, Any]:
|
||||
from services.mesh.mesh_reputation import gate_manager
|
||||
|
||||
return {"ok": True, "gates": gate_manager.list_gates()}
|
||||
|
||||
|
||||
def read_gate_messages(
|
||||
gate_id: str,
|
||||
*,
|
||||
limit: int = 20,
|
||||
decrypt: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
from services.mesh.mesh_hashchain import gate_store
|
||||
|
||||
gate_key = str(gate_id or "").strip().lower()
|
||||
if not gate_key:
|
||||
return {"ok": False, "detail": "gate_id required"}
|
||||
|
||||
messages, cursor = gate_store.get_messages_with_cursor(gate_key, limit=max(1, min(int(limit), 100)))
|
||||
out = []
|
||||
if decrypt:
|
||||
from services.mesh.mesh_gate_repair import decrypt_gate_message_with_repair
|
||||
|
||||
for msg in messages:
|
||||
item = dict(msg)
|
||||
try:
|
||||
decrypted = decrypt_gate_message_with_repair(
|
||||
gate_id=gate_key,
|
||||
epoch=int(item.get("epoch") or 0),
|
||||
ciphertext=str(item.get("ciphertext") or ""),
|
||||
nonce=str(item.get("nonce") or item.get("iv") or ""),
|
||||
sender_ref=str(item.get("sender_ref") or ""),
|
||||
gate_envelope=str(item.get("gate_envelope") or ""),
|
||||
envelope_hash=str(item.get("envelope_hash") or ""),
|
||||
event_id=str(item.get("event_id") or ""),
|
||||
)
|
||||
if decrypted.get("ok"):
|
||||
item["plaintext"] = decrypted.get("plaintext", "")
|
||||
except Exception as exc:
|
||||
item["decrypt_error"] = str(exc)
|
||||
out.append(item)
|
||||
else:
|
||||
out = [dict(m) for m in messages]
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"gate": gate_key,
|
||||
"count": len(out),
|
||||
"cursor": cursor,
|
||||
"messages": out,
|
||||
}
|
||||
|
||||
|
||||
def post_gate_message(
|
||||
gate_id: str,
|
||||
plaintext: str,
|
||||
*,
|
||||
reply_to: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Compose, sign, and post an MLS gate message using the operator persona."""
|
||||
from services.mesh.mesh_gate_repair import (
|
||||
compose_gate_message_with_repair,
|
||||
sign_gate_message_with_repair,
|
||||
)
|
||||
from services.mesh.mesh_wormhole_persona import bootstrap_wormhole_persona_state, create_gate_persona
|
||||
|
||||
gate_key = str(gate_id or "").strip().lower()
|
||||
if not gate_key:
|
||||
return {"ok": False, "detail": "gate_id required"}
|
||||
if not str(plaintext or "").strip():
|
||||
return {"ok": False, "detail": "plaintext required"}
|
||||
|
||||
bootstrap_wormhole_persona_state(force=False)
|
||||
try:
|
||||
create_gate_persona(gate_key, label="openclaw-agent")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
composed = compose_gate_message_with_repair(
|
||||
gate_id=gate_key,
|
||||
plaintext=str(plaintext),
|
||||
reply_to=str(reply_to or ""),
|
||||
)
|
||||
if not composed.get("ok"):
|
||||
return composed
|
||||
|
||||
signed = sign_gate_message_with_repair(
|
||||
gate_id=gate_key,
|
||||
epoch=int(composed.get("epoch") or 0),
|
||||
ciphertext=str(composed.get("ciphertext") or ""),
|
||||
nonce=str(composed.get("nonce") or ""),
|
||||
payload_format=str(composed.get("format") or "mls1"),
|
||||
reply_to=str(reply_to or ""),
|
||||
envelope_hash=str(composed.get("envelope_hash") or ""),
|
||||
transport_lock="private_strong",
|
||||
)
|
||||
if not signed.get("ok"):
|
||||
return signed
|
||||
|
||||
body = {
|
||||
"sender_id": str(signed.get("sender_id") or composed.get("sender_id") or ""),
|
||||
"public_key": str(signed.get("public_key") or composed.get("public_key") or ""),
|
||||
"public_key_algo": str(signed.get("public_key_algo") or composed.get("public_key_algo") or ""),
|
||||
"signature": str(signed.get("signature") or ""),
|
||||
"sequence": int(signed.get("sequence") or composed.get("sequence") or 0),
|
||||
"protocol_version": str(signed.get("protocol_version") or composed.get("protocol_version") or ""),
|
||||
"epoch": int(signed.get("epoch") or composed.get("epoch") or 0),
|
||||
"ciphertext": str(signed.get("ciphertext") or composed.get("ciphertext") or ""),
|
||||
"nonce": str(signed.get("nonce") or composed.get("nonce") or ""),
|
||||
"sender_ref": str(signed.get("sender_ref") or composed.get("sender_ref") or ""),
|
||||
"format": str(signed.get("format") or composed.get("format") or "mls1"),
|
||||
"gate_envelope": str(signed.get("gate_envelope") or composed.get("gate_envelope") or ""),
|
||||
"envelope_hash": str(signed.get("envelope_hash") or composed.get("envelope_hash") or ""),
|
||||
"transport_lock": "private_strong",
|
||||
"reply_to": str(signed.get("reply_to") or reply_to or ""),
|
||||
}
|
||||
|
||||
import main as main_mod
|
||||
|
||||
path = f"/api/mesh/gate/{gate_key}/message"
|
||||
request = _local_agent_request(path)
|
||||
return main_mod._submit_gate_message_envelope(request, gate_key, body)
|
||||
|
||||
|
||||
def cast_vote(
|
||||
target_id: str,
|
||||
vote: int,
|
||||
*,
|
||||
gate: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Cast a signed reputation vote using the operator gate/transport persona."""
|
||||
from services.mesh.mesh_hashchain import infonet
|
||||
from services.mesh.mesh_protocol import PROTOCOL_VERSION, normalize_payload
|
||||
from services.mesh.mesh_reputation import gate_manager, reputation_ledger
|
||||
from services.mesh.mesh_wormhole_persona import (
|
||||
bootstrap_wormhole_persona_state,
|
||||
sign_gate_wormhole_event,
|
||||
sign_public_wormhole_event,
|
||||
)
|
||||
|
||||
voter_gate = str(gate or "").strip().lower()
|
||||
target = str(target_id or "").strip()
|
||||
vote_val = int(vote)
|
||||
if not target:
|
||||
return {"ok": False, "detail": "target_id required"}
|
||||
if vote_val not in (1, -1):
|
||||
return {"ok": False, "detail": "vote must be 1 or -1"}
|
||||
|
||||
bootstrap_wormhole_persona_state(force=False)
|
||||
vote_payload = {"target_id": target, "vote": vote_val, "gate": voter_gate}
|
||||
normalized = normalize_payload("vote", vote_payload)
|
||||
ok_payload, reason = True, "ok"
|
||||
from services.mesh.mesh_schema import validate_event_payload
|
||||
|
||||
ok_payload, reason = validate_event_payload("vote", normalized)
|
||||
if not ok_payload:
|
||||
return {"ok": False, "detail": reason}
|
||||
|
||||
if voter_gate:
|
||||
signed = sign_gate_wormhole_event(
|
||||
gate_id=voter_gate,
|
||||
event_type="vote",
|
||||
payload=normalized,
|
||||
)
|
||||
else:
|
||||
signed = sign_public_wormhole_event(event_type="vote", payload=normalized)
|
||||
|
||||
if not signed.get("ok", True):
|
||||
return signed
|
||||
|
||||
voter_id = str(signed.get("node_id") or "")
|
||||
public_key = str(signed.get("public_key") or "")
|
||||
public_key_algo = str(signed.get("public_key_algo") or "")
|
||||
signature = str(signed.get("signature") or "")
|
||||
sequence = int(signed.get("sequence") or 0)
|
||||
|
||||
if voter_gate:
|
||||
can_enter, enter_reason = gate_manager.can_enter(voter_id, voter_gate)
|
||||
if not can_enter:
|
||||
return {"ok": False, "detail": f"Gate vote denied: {enter_reason}"}
|
||||
|
||||
reputation_ledger.register_node(voter_id, public_key, public_key_algo)
|
||||
stable_voter_id = voter_id
|
||||
try:
|
||||
import main as main_mod
|
||||
|
||||
root_nid = main_mod._cached_root_node_id()
|
||||
if root_nid:
|
||||
stable_voter_id = root_nid
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ok, cast_reason, weight = reputation_ledger.cast_vote(
|
||||
stable_voter_id,
|
||||
target,
|
||||
vote_val,
|
||||
voter_gate,
|
||||
)
|
||||
if ok:
|
||||
try:
|
||||
infonet.append(
|
||||
event_type="vote",
|
||||
node_id=voter_id,
|
||||
payload=normalized,
|
||||
signature=signature,
|
||||
sequence=sequence,
|
||||
public_key=public_key,
|
||||
public_key_algo=public_key_algo,
|
||||
protocol_version=str(signed.get("protocol_version") or PROTOCOL_VERSION),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("vote recorded in ledger but infonet append failed: %s", exc)
|
||||
|
||||
return {"ok": ok, "detail": cast_reason, "weight": round(float(weight or 0), 2)}
|
||||
|
||||
|
||||
def _http_post_json(
|
||||
url: str,
|
||||
body: dict[str, Any],
|
||||
*,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
timeout: int = 120,
|
||||
) -> dict[str, Any]:
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
payload_bytes = json.dumps(body, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
req = urllib.request.Request(url, data=payload_bytes, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
parsed = json.loads(detail)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": False, "detail": detail or f"http {exc.code}"}
|
||||
if not raw:
|
||||
return {}
|
||||
parsed = json.loads(raw)
|
||||
return parsed if isinstance(parsed, dict) else {"ok": False, "detail": "invalid json response"}
|
||||
|
||||
|
||||
def _issue_sender_token_for_http_send(
|
||||
api_base: str,
|
||||
*,
|
||||
recipient: str,
|
||||
delivery: str,
|
||||
recipient_token: str,
|
||||
) -> dict[str, Any]:
|
||||
extra_headers: dict[str, str] = {}
|
||||
admin_key = str(os.environ.get("ADMIN_KEY") or "").strip()
|
||||
if admin_key:
|
||||
extra_headers["X-Admin-Key"] = admin_key
|
||||
return _http_post_json(
|
||||
f"{api_base}/api/wormhole/dm/sender-token",
|
||||
{
|
||||
"recipient_id": recipient,
|
||||
"delivery_class": delivery,
|
||||
"recipient_token": recipient_token,
|
||||
},
|
||||
extra_headers=extra_headers or None,
|
||||
)
|
||||
|
||||
|
||||
def _submit_signed_dm_send(
|
||||
*,
|
||||
recipient: str,
|
||||
delivery_class: str,
|
||||
recipient_token: str,
|
||||
ciphertext: str,
|
||||
payload_format: str,
|
||||
session_welcome: str = "",
|
||||
connect_intent: str = "",
|
||||
lookup_peer_url: str = "",
|
||||
peer_dh_pub: str = "",
|
||||
) -> dict[str, Any]:
|
||||
import main as main_mod
|
||||
from services.mesh.mesh_protocol import (
|
||||
PROTOCOL_VERSION,
|
||||
SIGNED_CONTEXT_FIELD,
|
||||
build_signed_context,
|
||||
)
|
||||
from services.mesh.mesh_schema import validate_event_payload
|
||||
from services.mesh.mesh_wormhole_persona import get_dm_identity, sign_dm_wormhole_event
|
||||
from services.mesh.mesh_wormhole_sender_token import issue_wormhole_dm_sender_token
|
||||
|
||||
delivery = str(delivery_class or "shared").strip().lower()
|
||||
identity = get_dm_identity()
|
||||
sender_id = str(identity.get("node_id") or "")
|
||||
msg_id = secrets.token_hex(16)
|
||||
timestamp = int(time.time())
|
||||
sequence = int(identity.get("sequence", 0) or 0) + 1
|
||||
|
||||
dm_payload: dict[str, Any] = {
|
||||
"recipient_id": recipient,
|
||||
"delivery_class": delivery,
|
||||
"recipient_token": str(recipient_token or ""),
|
||||
"ciphertext": str(ciphertext or ""),
|
||||
"msg_id": msg_id,
|
||||
"timestamp": timestamp,
|
||||
"format": str(payload_format or "mls1"),
|
||||
"transport_lock": "private_strong",
|
||||
}
|
||||
if session_welcome:
|
||||
dm_payload["session_welcome"] = str(session_welcome)
|
||||
|
||||
try:
|
||||
from services.config import get_settings
|
||||
from services.mesh.mesh_wormhole_seal import build_sender_seal
|
||||
|
||||
if (
|
||||
delivery == "shared"
|
||||
and bool(get_settings().MESH_DM_REQUIRE_SENDER_SEAL_SHARED)
|
||||
and not str(dm_payload.get("sender_seal", "") or "").strip()
|
||||
):
|
||||
seal = build_sender_seal(
|
||||
recipient_id=recipient,
|
||||
recipient_dh_pub=str(peer_dh_pub or ""),
|
||||
msg_id=msg_id,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
if seal.get("ok"):
|
||||
dm_payload["sender_seal"] = str(seal.get("sender_seal") or "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ok_payload, reason = validate_event_payload("dm_message", dm_payload)
|
||||
if not ok_payload:
|
||||
return {"ok": False, "detail": reason}
|
||||
|
||||
dm_payload[SIGNED_CONTEXT_FIELD] = build_signed_context(
|
||||
event_type="dm_message",
|
||||
kind="dm_send",
|
||||
endpoint="/api/mesh/dm/send",
|
||||
lane_floor="private_strong",
|
||||
sequence_domain="dm_send",
|
||||
node_id=sender_id,
|
||||
sequence=sequence,
|
||||
payload=dm_payload,
|
||||
recipient_id=recipient,
|
||||
)
|
||||
signed = sign_dm_wormhole_event(
|
||||
event_type="dm_message",
|
||||
payload=dm_payload,
|
||||
sequence=sequence,
|
||||
)
|
||||
if not signed.get("ok", True):
|
||||
return signed
|
||||
|
||||
body = {
|
||||
"sender_id": sender_id,
|
||||
"sender_token": "",
|
||||
"recipient_id": recipient,
|
||||
"delivery_class": delivery,
|
||||
"recipient_token": str(recipient_token or ""),
|
||||
"ciphertext": str(ciphertext or ""),
|
||||
"format": str(payload_format or "mls1"),
|
||||
"transport_lock": "private_strong",
|
||||
"session_welcome": str(session_welcome or ""),
|
||||
"msg_id": msg_id,
|
||||
"timestamp": timestamp,
|
||||
"sender_seal": str(dm_payload.get("sender_seal") or ""),
|
||||
"public_key": str(signed.get("public_key") or ""),
|
||||
"public_key_algo": str(signed.get("public_key_algo") or ""),
|
||||
"signature": str(signed.get("signature") or ""),
|
||||
"sequence": int(signed.get("sequence") or 0),
|
||||
"protocol_version": str(signed.get("protocol_version") or PROTOCOL_VERSION),
|
||||
"signed_context": dict(dm_payload.get(SIGNED_CONTEXT_FIELD) or {}),
|
||||
}
|
||||
normalized_intent = str(connect_intent or "").strip().lower()
|
||||
normalized_lookup_peer = str(lookup_peer_url or "").strip().rstrip("/")
|
||||
if normalized_intent:
|
||||
body["connect_intent"] = normalized_intent
|
||||
if normalized_lookup_peer:
|
||||
body["lookup_peer_url"] = normalized_lookup_peer
|
||||
|
||||
api_base = str(os.environ.get("SB_API_BASE", "http://127.0.0.1:8000") or "http://127.0.0.1:8000").rstrip("/")
|
||||
result: dict[str, Any] = {"ok": False, "detail": "dm send failed"}
|
||||
try:
|
||||
import urllib.error
|
||||
|
||||
if delivery in ("request", "shared"):
|
||||
issued = _issue_sender_token_for_http_send(
|
||||
api_base,
|
||||
recipient=recipient,
|
||||
delivery=delivery,
|
||||
recipient_token=str(recipient_token or ""),
|
||||
)
|
||||
if not issued.get("ok"):
|
||||
return issued
|
||||
body["sender_token"] = str(issued.get("sender_token") or "")
|
||||
|
||||
result = _http_post_json(f"{api_base}/api/mesh/dm/send", body)
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
if delivery in ("request", "shared"):
|
||||
issued = issue_wormhole_dm_sender_token(
|
||||
recipient_id=recipient,
|
||||
delivery_class=delivery,
|
||||
recipient_token=str(recipient_token or ""),
|
||||
)
|
||||
if not issued.get("ok"):
|
||||
return issued
|
||||
body["sender_token"] = str(issued.get("sender_token") or "")
|
||||
|
||||
async def _send():
|
||||
import json as _json
|
||||
|
||||
raw = _json.dumps(body).encode("utf-8")
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": raw, "more_body": False}
|
||||
|
||||
req = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/api/mesh/dm/send",
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
"client": ("127.0.0.1", 52421),
|
||||
},
|
||||
receive,
|
||||
)
|
||||
req.state._private_lane_current_tier = "private_strong"
|
||||
req.state._transport_tier = "private_strong"
|
||||
return await main_mod.dm_send(req)
|
||||
|
||||
result = _run_async(_send())
|
||||
except Exception as exc:
|
||||
result = {"ok": False, "detail": str(exc) or type(exc).__name__}
|
||||
if isinstance(result, dict):
|
||||
result.setdefault("msg_id", msg_id)
|
||||
result.setdefault("sender_id", sender_id)
|
||||
result.setdefault("recipient_id", recipient)
|
||||
return result
|
||||
|
||||
|
||||
def send_contact_request(
|
||||
*,
|
||||
lookup_token: str = "",
|
||||
peer_id: str = "",
|
||||
note: str = "",
|
||||
lookup_peer_url: str = "",
|
||||
cached_prekey_bundle: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Send a first-contact request using a short address or peer id."""
|
||||
from services.mesh.mesh_wormhole_dead_drop import build_contact_offer
|
||||
from services.mesh.mesh_wormhole_persona import get_dm_identity
|
||||
from services.mesh.mesh_wormhole_prekey import bootstrap_encrypt_for_peer, fetch_dm_prekey_bundle
|
||||
|
||||
token = str(lookup_token or "").strip()
|
||||
peer = str(peer_id or "").strip()
|
||||
if not token and not peer:
|
||||
return {"ok": False, "detail": "lookup_token or peer_id required"}
|
||||
|
||||
preferred_peer = str(lookup_peer_url or "").strip().rstrip("/")
|
||||
if cached_prekey_bundle and cached_prekey_bundle.get("ok"):
|
||||
bundle = dict(cached_prekey_bundle)
|
||||
else:
|
||||
bundle = fetch_dm_prekey_bundle(
|
||||
agent_id=peer if not token else "",
|
||||
lookup_token=token,
|
||||
lookup_peer_urls=[preferred_peer] if preferred_peer else None,
|
||||
)
|
||||
if not bundle.get("ok"):
|
||||
return bundle
|
||||
recipient = str(bundle.get("agent_id") or peer).strip()
|
||||
if not recipient:
|
||||
return {"ok": False, "detail": "recipient unresolved"}
|
||||
|
||||
identity = get_dm_identity()
|
||||
offer = build_contact_offer(
|
||||
dh_pub_key=str(identity.get("dh_pub_key") or ""),
|
||||
dh_algo=str(identity.get("dh_algo") or "X25519"),
|
||||
geo_hint=str(note or ""),
|
||||
)
|
||||
encrypted = bootstrap_encrypt_for_peer(
|
||||
recipient,
|
||||
offer,
|
||||
fetched_bundle=bundle,
|
||||
)
|
||||
if not encrypted.get("ok"):
|
||||
return encrypted
|
||||
|
||||
return _submit_signed_dm_send(
|
||||
recipient=recipient,
|
||||
delivery_class="request",
|
||||
recipient_token="",
|
||||
ciphertext=str(encrypted.get("result") or ""),
|
||||
payload_format="mls1",
|
||||
connect_intent="contact_request",
|
||||
lookup_peer_url=preferred_peer,
|
||||
)
|
||||
|
||||
|
||||
def send_contact_accept(
|
||||
*,
|
||||
peer_id: str,
|
||||
peer_dh_pub: str = "",
|
||||
lookup_token: str = "",
|
||||
lookup_peer_url: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Accept a pending contact request and open the shared DM lane."""
|
||||
from services.mesh.mesh_wormhole_dead_drop import build_contact_accept, issue_pairwise_dm_alias
|
||||
from services.mesh.mesh_wormhole_prekey import bootstrap_encrypt_for_peer, fetch_dm_prekey_bundle
|
||||
|
||||
peer = str(peer_id or "").strip()
|
||||
if not peer:
|
||||
return {"ok": False, "detail": "peer_id required"}
|
||||
|
||||
token = str(lookup_token or "").strip()
|
||||
preferred_peer = str(lookup_peer_url or "").strip().rstrip("/")
|
||||
dh_pub = str(peer_dh_pub or "").strip()
|
||||
if not dh_pub:
|
||||
bundle = fetch_dm_prekey_bundle(
|
||||
agent_id=peer if not token else "",
|
||||
lookup_token=token,
|
||||
lookup_peer_urls=[preferred_peer] if preferred_peer else None,
|
||||
)
|
||||
if not bundle.get("ok"):
|
||||
return bundle
|
||||
dh_pub = str(bundle.get("dh_pub_key") or "").strip()
|
||||
if not dh_pub:
|
||||
return {"ok": False, "detail": "peer dh_pub_key unavailable"}
|
||||
|
||||
alias = issue_pairwise_dm_alias(peer_id=peer, peer_dh_pub=dh_pub)
|
||||
if not alias.get("ok"):
|
||||
return alias
|
||||
shared_alias = str(alias.get("shared_alias") or "").strip()
|
||||
if not shared_alias:
|
||||
return {"ok": False, "detail": "shared_alias unavailable"}
|
||||
|
||||
accept_plain = build_contact_accept(shared_alias=shared_alias)
|
||||
encrypted = bootstrap_encrypt_for_peer(peer, accept_plain, lookup_token=token)
|
||||
if not encrypted.get("ok"):
|
||||
return encrypted
|
||||
|
||||
sent = _submit_signed_dm_send(
|
||||
recipient=peer,
|
||||
delivery_class="request",
|
||||
recipient_token="",
|
||||
ciphertext=str(encrypted.get("result") or ""),
|
||||
payload_format="mls1",
|
||||
connect_intent="contact_accept",
|
||||
lookup_peer_url=preferred_peer,
|
||||
)
|
||||
if isinstance(sent, dict):
|
||||
sent.setdefault("shared_alias", shared_alias)
|
||||
return sent
|
||||
|
||||
|
||||
def send_dm(
|
||||
peer_id: str,
|
||||
plaintext: str,
|
||||
*,
|
||||
delivery_class: str = "shared",
|
||||
recipient_token: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Compose and send an encrypted DM on behalf of the operator."""
|
||||
import main as main_mod
|
||||
|
||||
recipient = str(peer_id or "").strip()
|
||||
if not recipient:
|
||||
return {"ok": False, "detail": "peer_id required"}
|
||||
if not str(plaintext or "").strip():
|
||||
return {"ok": False, "detail": "plaintext required"}
|
||||
|
||||
delivery = str(delivery_class or "shared").strip().lower()
|
||||
if delivery not in ("shared", "request"):
|
||||
return {"ok": False, "detail": "delivery_class must be shared or request"}
|
||||
|
||||
composed = main_mod.compose_wormhole_dm(
|
||||
peer_id=recipient,
|
||||
peer_dh_pub="",
|
||||
plaintext=str(plaintext),
|
||||
)
|
||||
if not composed.get("ok"):
|
||||
return composed
|
||||
|
||||
return _submit_signed_dm_send(
|
||||
recipient=recipient,
|
||||
delivery_class=delivery,
|
||||
recipient_token=str(recipient_token or ""),
|
||||
ciphertext=str(composed.get("ciphertext") or ""),
|
||||
payload_format=str(composed.get("format") or "mls1"),
|
||||
session_welcome=str(composed.get("session_welcome") or ""),
|
||||
)
|
||||
|
||||
|
||||
def poll_dms(*, limit: int = 20) -> dict[str, Any]:
|
||||
"""Poll encrypted DMs for the operator DM identity."""
|
||||
import json
|
||||
|
||||
import main as main_mod
|
||||
from services.mesh.mesh_protocol import PROTOCOL_VERSION
|
||||
from services.mesh.mesh_wormhole_persona import get_dm_identity, sign_dm_wormhole_event
|
||||
|
||||
identity = get_dm_identity()
|
||||
agent_id = str(identity.get("node_id") or "")
|
||||
if not agent_id:
|
||||
return {"ok": False, "detail": "dm identity is not configured"}
|
||||
|
||||
poll_payload = {"mailbox_claims": [], "agent_id": agent_id}
|
||||
signed = sign_dm_wormhole_event(event_type="dm_poll", payload=poll_payload)
|
||||
if not signed.get("ok", True):
|
||||
return signed
|
||||
|
||||
body = {
|
||||
"agent_id": agent_id,
|
||||
"mailbox_claims": [],
|
||||
"timestamp": int(time.time()),
|
||||
"nonce": secrets.token_hex(8),
|
||||
"public_key": str(signed.get("public_key") or ""),
|
||||
"public_key_algo": str(signed.get("public_key_algo") or ""),
|
||||
"signature": str(signed.get("signature") or ""),
|
||||
"sequence": int(signed.get("sequence") or 0),
|
||||
"protocol_version": str(signed.get("protocol_version") or PROTOCOL_VERSION),
|
||||
}
|
||||
|
||||
raw = json.dumps(body).encode("utf-8")
|
||||
|
||||
async def _poll():
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": raw, "more_body": False}
|
||||
|
||||
req = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/api/mesh/dm/poll",
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
"client": ("127.0.0.1", 52421),
|
||||
},
|
||||
receive,
|
||||
)
|
||||
return await main_mod.dm_poll_secure(req)
|
||||
|
||||
result = _run_async(_poll())
|
||||
if isinstance(result, dict):
|
||||
messages = list(result.get("messages") or [])
|
||||
if limit and len(messages) > int(limit):
|
||||
result = dict(result)
|
||||
result["messages"] = messages[: int(limit)]
|
||||
result["count"] = len(result["messages"])
|
||||
return result if isinstance(result, dict) else {"ok": False, "detail": "dm poll failed"}
|
||||
@@ -0,0 +1,531 @@
|
||||
"""Deterministic OpenClaw routing — intent → fastest command.
|
||||
|
||||
Keeps expensive fuzzy scans and full-layer dumps out of the default agent path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
EXPENSIVE_COMMANDS = frozenset({
|
||||
"search_telemetry",
|
||||
"get_telemetry",
|
||||
"get_slow_telemetry",
|
||||
"get_report",
|
||||
})
|
||||
|
||||
EXPENSIVE_GATE_MESSAGE = (
|
||||
"expensive command blocked — use route_query, find_entity, run_playbook, or targeted reads. "
|
||||
"Pass confirm_expensive=true only when fuzzy search or full dumps are intentional."
|
||||
)
|
||||
|
||||
LATENCY_TIER_MS: dict[str, int] = {
|
||||
"channel_status": 5,
|
||||
"route_query": 5,
|
||||
"get_summary": 10,
|
||||
"what_changed": 15,
|
||||
"search_news": 15,
|
||||
"find_flights": 25,
|
||||
"find_ships": 25,
|
||||
"find_entity": 30,
|
||||
"entities_near": 30,
|
||||
"brief_area": 30,
|
||||
"get_layer_slice": 50,
|
||||
"correlate_entity": 15,
|
||||
"entity_expand": 40,
|
||||
"osint_lookup": 200,
|
||||
"run_playbook": 120,
|
||||
"infonet_status": 20,
|
||||
"list_gates": 15,
|
||||
"read_gate_messages": 40,
|
||||
"poll_dms": 80,
|
||||
"ensure_infonet_ready": 120000,
|
||||
"join_infonet_swarm": 90000,
|
||||
"post_gate_message": 15000,
|
||||
"cast_vote": 5000,
|
||||
"send_dm": 20000,
|
||||
"search_telemetry": 8000,
|
||||
"get_telemetry": 3500,
|
||||
"get_slow_telemetry": 1500,
|
||||
"get_report": 5000,
|
||||
}
|
||||
|
||||
RE_N_NUMBER = re.compile(r"\bN\d{1,5}[A-Z]{0,2}\b", re.I)
|
||||
RE_CALLSIGN = re.compile(r"\b[A-Z]{2,4}\d{1,4}[A-Z]?\b")
|
||||
RE_MMSI = re.compile(r"\b\d{9}\b")
|
||||
RE_CVE = re.compile(r"\bCVE-\d{4}-\d+\b", re.I)
|
||||
RE_IPV4 = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
|
||||
RE_DOMAIN = re.compile(
|
||||
r"\b(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,})\b",
|
||||
re.I,
|
||||
)
|
||||
|
||||
KNOWN_CALLSIGNS = frozenset({
|
||||
"AF1", "AF2", "EXEC1", "EXEC2", "SAM", "STALK52", "SPAR19", "SPAR20",
|
||||
})
|
||||
|
||||
PLAYBOOKS: dict[str, dict[str, Any]] = {
|
||||
"hot_snapshot": {
|
||||
"description": "Summary + hot layers + what changed (one batch)",
|
||||
"batch": [
|
||||
{"cmd": "get_summary", "args": {"compact": True}},
|
||||
{
|
||||
"cmd": "get_layer_slice",
|
||||
"args": {
|
||||
"layers": [
|
||||
"news",
|
||||
"telegram_osint",
|
||||
"military_flights",
|
||||
"private_jets",
|
||||
"earthquakes",
|
||||
],
|
||||
"limit_per_layer": 10,
|
||||
"compact": True,
|
||||
},
|
||||
},
|
||||
{"cmd": "what_changed", "args": {"compact": True}},
|
||||
],
|
||||
},
|
||||
"status_check": {
|
||||
"description": "Channel health + layer counts",
|
||||
"batch": [
|
||||
{"cmd": "channel_status", "args": {}},
|
||||
{"cmd": "get_summary", "args": {"compact": True}},
|
||||
],
|
||||
},
|
||||
"morning_brief": {
|
||||
"description": "Operator morning digest layers",
|
||||
"batch": [
|
||||
{"cmd": "get_summary", "args": {"compact": True}},
|
||||
{"cmd": "what_changed", "args": {"compact": True}},
|
||||
{
|
||||
"cmd": "get_layer_slice",
|
||||
"args": {
|
||||
"layers": [
|
||||
"news",
|
||||
"telegram_osint",
|
||||
"gdelt",
|
||||
"earthquakes",
|
||||
"crowdthreat",
|
||||
"military_flights",
|
||||
],
|
||||
"limit_per_layer": 15,
|
||||
"compact": True,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"monitor_heartbeat": {
|
||||
"description": "Low-latency monitor poll (replaces full telemetry pull)",
|
||||
"batch": [
|
||||
{"cmd": "what_changed", "args": {"compact": True}},
|
||||
{
|
||||
"cmd": "get_layer_slice",
|
||||
"args": {
|
||||
"layers": [
|
||||
"military_flights",
|
||||
"ships",
|
||||
"earthquakes",
|
||||
"liveuamap",
|
||||
"crowdthreat",
|
||||
"uap_sightings",
|
||||
"firms_fires",
|
||||
"gps_jamming",
|
||||
"wastewater",
|
||||
],
|
||||
"limit_per_layer": 200,
|
||||
"compact": True,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def routing_manifest() -> dict[str, Any]:
|
||||
"""Machine-readable routing hints for /api/ai/capabilities."""
|
||||
return {
|
||||
"default_read": "find_entity",
|
||||
"preferred_entry": "route_query",
|
||||
"client_wrapper": "ShadowBrokerClient.ask",
|
||||
"batch_playbook": "run_playbook",
|
||||
"last_resort": "search_telemetry",
|
||||
"expensive_commands": sorted(EXPENSIVE_COMMANDS),
|
||||
"latency_tier_ms": LATENCY_TIER_MS,
|
||||
"anti_patterns": [
|
||||
"search_telemetry for known tail numbers, callsigns, owners, or MMSI",
|
||||
"get_telemetry for routine reads — use get_layer_slice or run_playbook hot_snapshot",
|
||||
"sequential send_command loops — use send_batch or run_playbook",
|
||||
"/api/health for liveness — use channel_status",
|
||||
"empty layers: [] on get_layer_slice — pass explicit layer names",
|
||||
],
|
||||
"recipes": [
|
||||
{
|
||||
"intent": "natural language question",
|
||||
"use": "route_query → recommended cmd, or ShadowBrokerClient.ask()",
|
||||
},
|
||||
{
|
||||
"intent": "known person/aircraft",
|
||||
"use": "find_entity(query=...) or find_flights(owner=...)",
|
||||
},
|
||||
{
|
||||
"intent": "news / telegram topic",
|
||||
"use": "search_news(query=...)",
|
||||
},
|
||||
{
|
||||
"intent": "near a point",
|
||||
"use": "entities_near or brief_area",
|
||||
},
|
||||
{
|
||||
"intent": "hot snapshot",
|
||||
"use": "run_playbook(name=hot_snapshot)",
|
||||
},
|
||||
{
|
||||
"intent": "post to infonet gate / join swarm",
|
||||
"use": "ensure_infonet_ready then post_gate_message (full tier)",
|
||||
},
|
||||
{
|
||||
"intent": "read encrypted gate traffic",
|
||||
"use": "read_gate_messages(gate_id=infonet, decrypt=true)",
|
||||
},
|
||||
{
|
||||
"intent": "dm another node",
|
||||
"use": "send_dm(peer_id=..., plaintext=...) (full tier)",
|
||||
},
|
||||
],
|
||||
"playbooks": {
|
||||
name: {"description": spec.get("description", "")}
|
||||
for name, spec in PLAYBOOKS.items()
|
||||
},
|
||||
"agent_surface": {
|
||||
"primary": ["ask", "send_batch", "channel_status"],
|
||||
"writes": [
|
||||
"place_pin",
|
||||
"add_watch",
|
||||
"inject_data",
|
||||
"place_analysis_zone",
|
||||
"ensure_infonet_ready",
|
||||
"post_gate_message",
|
||||
"cast_vote",
|
||||
"send_dm",
|
||||
],
|
||||
"infonet_reads": [
|
||||
"infonet_status",
|
||||
"list_gates",
|
||||
"read_gate_messages",
|
||||
"poll_dms",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def requires_expensive_confirm(cmd: str, args: dict[str, Any] | None) -> bool:
|
||||
if cmd not in EXPENSIVE_COMMANDS:
|
||||
return False
|
||||
if isinstance(args, dict) and args.get("confirm_expensive") is True:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _compact_args(args: dict[str, Any], *, compact: bool) -> dict[str, Any]:
|
||||
out = dict(args)
|
||||
if compact and "compact" not in out:
|
||||
out["compact"] = True
|
||||
return out
|
||||
|
||||
|
||||
def _estimate_ms(cmd: str) -> int:
|
||||
return int(LATENCY_TIER_MS.get(cmd, 100))
|
||||
|
||||
|
||||
def _news_query(text: str) -> str:
|
||||
cleaned = text
|
||||
for prefix in (
|
||||
"news about",
|
||||
"news on",
|
||||
"telegram",
|
||||
"headlines about",
|
||||
"headlines on",
|
||||
"latest on",
|
||||
"search news for",
|
||||
):
|
||||
if cleaned.lower().startswith(prefix):
|
||||
cleaned = cleaned[len(prefix):].strip()
|
||||
return cleaned.strip(" ?.")
|
||||
|
||||
|
||||
def route_query(
|
||||
text: str = "",
|
||||
*,
|
||||
lat: float | None = None,
|
||||
lng: float | None = None,
|
||||
radius_km: float = 50,
|
||||
compact: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Map natural-language intent to the fastest command (no LLM)."""
|
||||
raw = str(text or "").strip()
|
||||
lowered = raw.lower()
|
||||
avoid = ["search_telemetry", "get_telemetry", "get_slow_telemetry"]
|
||||
alternates: list[dict[str, Any]] = []
|
||||
|
||||
if not raw and lat is not None and lng is not None:
|
||||
recommended = {
|
||||
"cmd": "brief_area",
|
||||
"args": _compact_args(
|
||||
{"lat": lat, "lng": lng, "radius_km": radius_km},
|
||||
compact=compact,
|
||||
),
|
||||
}
|
||||
return {
|
||||
"intent": "area_brief",
|
||||
"recommended": recommended,
|
||||
"alternates": [{"cmd": "entities_near", "args": recommended["args"]}],
|
||||
"avoid": avoid,
|
||||
"estimated_ms": _estimate_ms("brief_area"),
|
||||
}
|
||||
|
||||
if not raw:
|
||||
recommended = {"cmd": "get_summary", "args": _compact_args({}, compact=compact)}
|
||||
return {
|
||||
"intent": "discovery",
|
||||
"recommended": recommended,
|
||||
"alternates": [{"cmd": "channel_status", "args": {}}],
|
||||
"avoid": avoid,
|
||||
"estimated_ms": _estimate_ms("get_summary"),
|
||||
}
|
||||
|
||||
cve_match = RE_CVE.search(raw)
|
||||
if cve_match:
|
||||
recommended = {
|
||||
"cmd": "osint_lookup",
|
||||
"args": _compact_args({"tool": "cve", "cve": cve_match.group(0).upper()}, compact=compact),
|
||||
}
|
||||
return _route_result("cve_lookup", recommended, avoid, alternates)
|
||||
|
||||
ip_match = RE_IPV4.search(raw)
|
||||
if ip_match and ("ip" in lowered or "address" in lowered or lowered.count(".") >= 3):
|
||||
recommended = {
|
||||
"cmd": "osint_lookup",
|
||||
"args": _compact_args({"tool": "ip", "ip": ip_match.group(0)}, compact=compact),
|
||||
}
|
||||
alternates.append({"cmd": "entity_expand", "args": {"type": "ip", "id": ip_match.group(0)}})
|
||||
return _route_result("ip_lookup", recommended, avoid, alternates)
|
||||
|
||||
if "whois" in lowered or ("dns" in lowered and RE_DOMAIN.search(raw)):
|
||||
domain = (RE_DOMAIN.search(raw) or re.search(r"\b([a-z0-9-]+\.[a-z]{2,})\b", raw, re.I))
|
||||
tool = "whois" if "whois" in lowered else "dns"
|
||||
domain_value = domain.group(0) if domain else raw
|
||||
recommended = {
|
||||
"cmd": "osint_lookup",
|
||||
"args": _compact_args({"tool": tool, "domain": domain_value}, compact=compact),
|
||||
}
|
||||
return _route_result("domain_lookup", recommended, avoid, alternates)
|
||||
|
||||
if "sanction" in lowered or "ofac" in lowered:
|
||||
recommended = {
|
||||
"cmd": "osint_lookup",
|
||||
"args": _compact_args({"tool": "sanctions", "query": raw}, compact=compact),
|
||||
}
|
||||
return _route_result("sanctions_lookup", recommended, avoid, alternates)
|
||||
|
||||
mmsi_match = RE_MMSI.search(raw)
|
||||
if mmsi_match and any(k in lowered for k in ("mmsi", "ship", "vessel", "yacht", "boat", "maritime")):
|
||||
recommended = {
|
||||
"cmd": "find_ships",
|
||||
"args": _compact_args({"mmsi": mmsi_match.group(0)}, compact=compact),
|
||||
}
|
||||
alternates.append({"cmd": "find_entity", "args": {"mmsi": mmsi_match.group(0), "entity_type": "ship"}})
|
||||
return _route_result("maritime_identifier", recommended, avoid, alternates)
|
||||
|
||||
n_match = RE_N_NUMBER.search(raw)
|
||||
if n_match:
|
||||
reg = n_match.group(0).upper()
|
||||
recommended = {
|
||||
"cmd": "find_flights",
|
||||
"args": _compact_args({"registration": reg}, compact=compact),
|
||||
}
|
||||
alternates.append({"cmd": "find_entity", "args": {"registration": reg, "entity_type": "aircraft"}})
|
||||
return _route_result("tail_number", recommended, avoid, alternates)
|
||||
|
||||
# callsign tokens
|
||||
tokens = re.findall(r"\b[A-Z0-9]{2,8}\b", raw.upper())
|
||||
for token in tokens:
|
||||
if token in KNOWN_CALLSIGNS or RE_CALLSIGN.fullmatch(token):
|
||||
recommended = {
|
||||
"cmd": "find_flights",
|
||||
"args": _compact_args({"callsign": token}, compact=compact),
|
||||
}
|
||||
alternates.append({"cmd": "find_entity", "args": {"callsign": token, "entity_type": "aircraft"}})
|
||||
return _route_result("callsign", recommended, avoid, alternates)
|
||||
|
||||
if any(k in lowered for k in ("news", "telegram", "headline", "headlines", "gdelt")):
|
||||
recommended = {
|
||||
"cmd": "search_news",
|
||||
"args": _compact_args({"query": _news_query(raw), "limit": 10}, compact=compact),
|
||||
}
|
||||
alternates.append({
|
||||
"cmd": "get_layer_slice",
|
||||
"args": {"layers": ["telegram_osint", "news"], "limit_per_layer": 10, "compact": compact},
|
||||
})
|
||||
return _route_result("news_search", recommended, avoid, alternates)
|
||||
|
||||
if lat is not None and lng is not None and any(
|
||||
k in lowered for k in ("near", "around", "within", "radius", "brief", "aoi")
|
||||
):
|
||||
recommended = {
|
||||
"cmd": "brief_area",
|
||||
"args": _compact_args(
|
||||
{"lat": lat, "lng": lng, "radius_km": radius_km, "query": raw},
|
||||
compact=compact,
|
||||
),
|
||||
}
|
||||
alternates.append({
|
||||
"cmd": "entities_near",
|
||||
"args": {"lat": lat, "lng": lng, "radius_km": radius_km, "compact": compact},
|
||||
})
|
||||
return _route_result("area_brief", recommended, avoid, alternates)
|
||||
|
||||
if any(k in lowered for k in ("what changed", "updates", "delta", "since last")):
|
||||
recommended = {"cmd": "what_changed", "args": _compact_args({}, compact=compact)}
|
||||
return _route_result("incremental_poll", recommended, avoid, alternates)
|
||||
|
||||
if any(k in lowered for k in ("summary", "status", "layers populated", "what data")):
|
||||
recommended = {"cmd": "get_summary", "args": _compact_args({}, compact=compact)}
|
||||
alternates.append({"cmd": "channel_status", "args": {}})
|
||||
return _route_result("discovery", recommended, avoid, alternates)
|
||||
|
||||
if any(k in lowered for k in ("recon", "whois", "dns lookup", "cve", "mac address")):
|
||||
recommended = {
|
||||
"cmd": "osint_tools",
|
||||
"args": {},
|
||||
}
|
||||
return _route_result("recon_discovery", recommended, avoid, alternates)
|
||||
|
||||
entity_type = ""
|
||||
if any(k in lowered for k in ("ship", "vessel", "yacht", "boat", "maritime", "carrier")):
|
||||
entity_type = "ship"
|
||||
elif any(k in lowered for k in ("jet", "plane", "flight", "aircraft", "helicopter", "tail")):
|
||||
entity_type = "aircraft"
|
||||
|
||||
owner_hint = ""
|
||||
if any(k in lowered for k in ("owner", "operated by", "'s jet", "'s yacht", "belongs to")):
|
||||
owner_hint = raw
|
||||
for phrase in ("where is", "find", "track", "locate", "jet", "yacht", "plane", "flight", "ship"):
|
||||
owner_hint = re.sub(rf"\b{phrase}\b", "", owner_hint, flags=re.I).strip()
|
||||
|
||||
entity_args: dict[str, Any] = {"query": raw, "compact": compact}
|
||||
if entity_type:
|
||||
entity_args["entity_type"] = entity_type
|
||||
if owner_hint and len(owner_hint) >= 3:
|
||||
entity_args["owner"] = owner_hint
|
||||
|
||||
recommended = {
|
||||
"cmd": "find_entity",
|
||||
"args": _compact_args(entity_args, compact=compact),
|
||||
}
|
||||
alternates = [
|
||||
{"cmd": "search_news", "args": {"query": raw, "limit": 10, "compact": compact}},
|
||||
]
|
||||
if any(k in lowered for k in ("near", "around")):
|
||||
alternates.append({
|
||||
"cmd": "search_telemetry",
|
||||
"args": {"query": raw, "limit": 10, "confirm_expensive": True, "compact": compact},
|
||||
})
|
||||
|
||||
return _route_result("entity_lookup", recommended, avoid, alternates)
|
||||
|
||||
|
||||
def _route_result(
|
||||
intent: str,
|
||||
recommended: dict[str, Any],
|
||||
avoid: list[str],
|
||||
alternates: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
cmd = str(recommended.get("cmd", ""))
|
||||
return {
|
||||
"intent": intent,
|
||||
"recommended": recommended,
|
||||
"alternates": alternates,
|
||||
"avoid": avoid,
|
||||
"estimated_ms": _estimate_ms(cmd),
|
||||
}
|
||||
|
||||
|
||||
def plan_playbook(name: str, args: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Resolve a named playbook to a command batch."""
|
||||
playbook = str(name or "").strip().lower()
|
||||
params = dict(args or {})
|
||||
if not playbook:
|
||||
return {"ok": False, "detail": "playbook name required"}
|
||||
|
||||
if playbook == "track_snapshot":
|
||||
query = str(params.get("query", "") or params.get("name", "") or "").strip()
|
||||
if not query:
|
||||
return {"ok": False, "detail": "track_snapshot requires query"}
|
||||
return {
|
||||
"ok": True,
|
||||
"playbook": playbook,
|
||||
"description": "Resolve entity for tracking",
|
||||
"batch": [
|
||||
{
|
||||
"cmd": "find_entity",
|
||||
"args": {
|
||||
"query": query,
|
||||
"entity_type": params.get("entity_type", ""),
|
||||
"fallback_search": True,
|
||||
"compact": True,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
if playbook == "area_brief":
|
||||
lat = params.get("lat")
|
||||
lng = params.get("lng")
|
||||
if lat is None or lng is None:
|
||||
return {"ok": False, "detail": "area_brief requires lat and lng"}
|
||||
return {
|
||||
"ok": True,
|
||||
"playbook": playbook,
|
||||
"description": "Brief an area of interest",
|
||||
"batch": [
|
||||
{
|
||||
"cmd": "brief_area",
|
||||
"args": {
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"radius_km": params.get("radius_km", 50),
|
||||
"query": params.get("query", ""),
|
||||
"compact": True,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
if playbook == "entity_recon":
|
||||
query = str(params.get("query", "") or params.get("ip", "") or "").strip()
|
||||
ip_match = RE_IPV4.search(query)
|
||||
if not ip_match:
|
||||
return {"ok": False, "detail": "entity_recon requires an IP in query"}
|
||||
return {
|
||||
"ok": True,
|
||||
"playbook": playbook,
|
||||
"description": "IP recon + entity graph",
|
||||
"batch": [
|
||||
{"cmd": "osint_lookup", "args": {"tool": "ip", "ip": ip_match.group(0), "compact": True}},
|
||||
{"cmd": "entity_expand", "args": {"type": "ip", "id": ip_match.group(0)}},
|
||||
],
|
||||
}
|
||||
|
||||
spec = PLAYBOOKS.get(playbook)
|
||||
if not spec:
|
||||
known = sorted(PLAYBOOKS) + ["track_snapshot", "area_brief", "entity_recon"]
|
||||
return {"ok": False, "detail": f"unknown playbook: {playbook}", "known": known}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"playbook": playbook,
|
||||
"description": spec.get("description", ""),
|
||||
"batch": [dict(item) for item in spec.get("batch", [])],
|
||||
}
|
||||
@@ -213,7 +213,7 @@ def validate_privacy_core_startup(settings: Any | None = None) -> None:
|
||||
|
||||
attestation = privacy_core_attestation(snapshot)
|
||||
state = str(attestation.get("attestation_state", "") or "").strip()
|
||||
if state == "attested_current":
|
||||
if state in {"attested_current", "development_override"}:
|
||||
return
|
||||
|
||||
logger.critical(
|
||||
|
||||
@@ -1549,11 +1549,13 @@ def find_entity(
|
||||
owner: str = "",
|
||||
layers: list[str] | tuple[str, ...] | None = None,
|
||||
limit: int = 10,
|
||||
fallback_search: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Find a named entity across aircraft, maritime, and general telemetry.
|
||||
|
||||
This is an intent-level lookup for agents. It tries high-precision
|
||||
aircraft/ship fields first, then falls back to the universal search index.
|
||||
aircraft/ship fields first, then optionally falls back to the universal
|
||||
search index only when ``fallback_search`` is True (opt-in fuzzy scan).
|
||||
"""
|
||||
effective_query = str(query or name or owner or callsign or registration or icao24 or mmsi or imo or "").strip()
|
||||
if not effective_query:
|
||||
@@ -1628,16 +1630,18 @@ def find_entity(
|
||||
seen.add(key)
|
||||
results.append(normalized)
|
||||
|
||||
search_layers = requested_layers or _entity_layers_for_type(entity_type)
|
||||
search_result = search_telemetry(query=effective_query, layers=search_layers, limit=limit)
|
||||
if search_result.get("results"):
|
||||
strategies.append("universal_index")
|
||||
for item in search_result.get("results") or []:
|
||||
normalized = _normalize_entity_result(item)
|
||||
key = _entity_key(normalized)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
results.append(normalized)
|
||||
search_layers = list(requested_layers or _entity_layers_for_type(entity_type) or [])
|
||||
search_result: dict[str, Any] = {"results": [], "searched_layers": search_layers}
|
||||
if fallback_search:
|
||||
search_result = search_telemetry(query=effective_query, layers=search_layers, limit=limit)
|
||||
if search_result.get("results"):
|
||||
strategies.append("universal_index")
|
||||
for item in search_result.get("results") or []:
|
||||
normalized = _normalize_entity_result(item)
|
||||
key = _entity_key(normalized)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
results.append(normalized)
|
||||
|
||||
results.sort(
|
||||
key=lambda item: (
|
||||
|
||||
@@ -33,6 +33,52 @@ TOR_INSTALL_DIR = TOR_DIR / "tor_bin"
|
||||
_STARTUP_TIMEOUT_S = 90
|
||||
_POLL_INTERVAL_S = 1.0
|
||||
|
||||
|
||||
def _arti_socks_port() -> int:
|
||||
from services.config import get_settings
|
||||
|
||||
return int(get_settings().MESH_ARTI_SOCKS_PORT or 9050)
|
||||
|
||||
|
||||
def _torrc_socks_line(socks_port: int) -> str:
|
||||
return f"SocksPort {socks_port}\n"
|
||||
|
||||
|
||||
def _torrc_has_socks_port(socks_port: int) -> bool:
|
||||
if not TORRC_PATH.exists():
|
||||
return False
|
||||
return _torrc_socks_line(socks_port) in TORRC_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _local_socks_listening(socks_port: int) -> bool:
|
||||
return _local_socks_handshake_ready(socks_port, timeout=0.75)
|
||||
|
||||
|
||||
def _local_socks_handshake_ready(socks_port: int, *, timeout: float = 5.0) -> bool:
|
||||
import socket
|
||||
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", socks_port), timeout=timeout) as sock:
|
||||
sock.settimeout(timeout)
|
||||
sock.sendall(b"\x05\x01\x00")
|
||||
return sock.recv(2) == b"\x05\x00"
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _write_torrc(*, target_port: int, socks_port: int) -> None:
|
||||
TOR_DIR.mkdir(parents=True, exist_ok=True)
|
||||
hidden_service_dir = TOR_DIR / "hidden_service"
|
||||
hidden_service_dir.mkdir(parents=True, exist_ok=True)
|
||||
torrc_content = (
|
||||
f"DataDirectory {TOR_DATA_DIR.as_posix()}\n"
|
||||
f"HiddenServiceDir {hidden_service_dir.as_posix()}\n"
|
||||
f"HiddenServicePort {target_port} 127.0.0.1:{target_port}\n"
|
||||
f"{_torrc_socks_line(socks_port)}"
|
||||
"Log notice stderr\n"
|
||||
)
|
||||
TORRC_PATH.write_text(torrc_content, encoding="utf-8")
|
||||
|
||||
# Windows x86_64 Tor Expert Bundle URLs. Keep a fallback so first-run
|
||||
# onboarding does not break when Tor rotates point releases.
|
||||
_TOR_EXPERT_BUNDLE_URLS = [
|
||||
@@ -357,12 +403,28 @@ class TorHiddenService:
|
||||
def start(self, target_port: int = 8000) -> dict:
|
||||
"""Start Tor hidden service pointing to target_port on localhost."""
|
||||
with self._lock:
|
||||
socks_port = _arti_socks_port()
|
||||
if self._running and self._process and self._process.poll() is None:
|
||||
return {
|
||||
"ok": True,
|
||||
"onion_address": self._onion_address,
|
||||
"detail": "already running",
|
||||
}
|
||||
if _torrc_has_socks_port(socks_port) and _local_socks_handshake_ready(socks_port, timeout=1.5):
|
||||
return {
|
||||
"ok": True,
|
||||
"onion_address": self._onion_address,
|
||||
"detail": "already running",
|
||||
}
|
||||
logger.info(
|
||||
"Tor is running without a ready SOCKS proxy on port %s — restarting",
|
||||
socks_port,
|
||||
)
|
||||
try:
|
||||
self._process.terminate()
|
||||
self._process.wait(timeout=10)
|
||||
except Exception:
|
||||
try:
|
||||
self._process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
self._process = None
|
||||
self._running = False
|
||||
|
||||
self._error = ""
|
||||
tor_bin = _find_tor_binary()
|
||||
@@ -388,14 +450,9 @@ class TorHiddenService:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
torrc_content = (
|
||||
f"DataDirectory {TOR_DATA_DIR.as_posix()}\n"
|
||||
f"HiddenServiceDir {hidden_service_dir.as_posix()}\n"
|
||||
f"HiddenServicePort {target_port} 127.0.0.1:{target_port}\n"
|
||||
"SocksPort 9050\n"
|
||||
"Log notice stderr\n"
|
||||
)
|
||||
TORRC_PATH.write_text(torrc_content, encoding="utf-8")
|
||||
# Mesh "Arti" transport uses Tor's local SOCKS proxy for .onion peers.
|
||||
# Always publish SocksPort — MESH_ARTI_ENABLED only gates callers, not Tor.
|
||||
_write_torrc(target_port=target_port, socks_port=socks_port)
|
||||
|
||||
try:
|
||||
self._process = subprocess.Popen(
|
||||
@@ -428,15 +485,23 @@ class TorHiddenService:
|
||||
hostname = HOSTNAME_PATH.read_text().strip()
|
||||
if hostname.endswith(".onion"):
|
||||
self._onion_address = f"http://{hostname}:8000"
|
||||
logger.info("Tor hidden service ready: %s", self._onion_address)
|
||||
return {
|
||||
"ok": True,
|
||||
"onion_address": self._onion_address,
|
||||
}
|
||||
if _local_socks_handshake_ready(socks_port, timeout=3.0):
|
||||
logger.info(
|
||||
"Tor hidden service ready: %s (SOCKS %s)",
|
||||
self._onion_address,
|
||||
socks_port,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"onion_address": self._onion_address,
|
||||
}
|
||||
|
||||
time.sleep(_POLL_INTERVAL_S)
|
||||
|
||||
self._error = f"Tor did not generate hostname within {_STARTUP_TIMEOUT_S}s"
|
||||
self._error = (
|
||||
f"Tor did not publish a ready hidden service and SOCKS proxy "
|
||||
f"on port {socks_port} within {_STARTUP_TIMEOUT_S}s"
|
||||
)
|
||||
self.stop()
|
||||
return {"ok": False, "detail": self._error}
|
||||
|
||||
|
||||
@@ -27,6 +27,13 @@ _STATE_CACHE_TS = 0.0
|
||||
_STATE_CACHE_TTL_S = 2.0
|
||||
_ARTI_PROOF_CACHE: dict[str, Any] = {"port": 0, "ok": False, "ts": 0.0}
|
||||
_ARTI_PROOF_CACHE_TTL_S = 30.0
|
||||
_ARTI_STATUS_CACHE: dict[str, Any] = {"port": 0, "ready": False, "ts": 0.0}
|
||||
_ARTI_STATUS_FAIL_TTL_S = 4.0
|
||||
_ARTI_PROBE_LOCK = threading.Lock()
|
||||
_ARTI_SOCKS_FAILURES = 0
|
||||
_ARTI_LAST_TOR_RECOVERY_TS = 0.0
|
||||
_ARTI_TOR_RECOVERY_COOLDOWN_S = 45.0
|
||||
_ARTI_SOCKS_CONNECT_TIMEOUT_S = 5.0
|
||||
_PRIVATE_CLEARNET_FALLBACK_WINDOW_S = 300.0
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parent.parent
|
||||
@@ -65,20 +72,48 @@ _WORMHOLE_ENV_EXPLICIT = {
|
||||
"CORS_ORIGINS",
|
||||
"PUBLIC_API_KEY",
|
||||
"PRIVACY_CORE_ALLOWED_SHA256",
|
||||
"PRIVACY_CORE_DEV_OVERRIDE",
|
||||
"PRIVACY_CORE_LIB",
|
||||
"PRIVACY_CORE_MIN_VERSION",
|
||||
}
|
||||
|
||||
def _check_arti_ready() -> bool:
|
||||
from services.config import get_settings
|
||||
def invalidate_arti_ready_cache() -> None:
|
||||
_ARTI_PROOF_CACHE.update({"port": 0, "ok": False, "ts": 0.0})
|
||||
_ARTI_STATUS_CACHE.update({"port": 0, "ready": False, "ts": 0.0})
|
||||
|
||||
settings = get_settings()
|
||||
if not bool(settings.MESH_ARTI_ENABLED):
|
||||
return False
|
||||
socks_port = int(settings.MESH_ARTI_SOCKS_PORT or 9050)
|
||||
|
||||
def _maybe_recover_tor_socks_transport(socks_port: int) -> None:
|
||||
global _ARTI_SOCKS_FAILURES, _ARTI_LAST_TOR_RECOVERY_TS
|
||||
|
||||
_ARTI_SOCKS_FAILURES += 1
|
||||
if _ARTI_SOCKS_FAILURES < 3:
|
||||
return
|
||||
now = time.time()
|
||||
if (now - _ARTI_LAST_TOR_RECOVERY_TS) < _ARTI_TOR_RECOVERY_COOLDOWN_S:
|
||||
return
|
||||
_ARTI_LAST_TOR_RECOVERY_TS = now
|
||||
_ARTI_SOCKS_FAILURES = 0
|
||||
try:
|
||||
with socket.create_connection((WORMHOLE_HOST, socks_port), timeout=2.0) as sock:
|
||||
# SOCKS5 greeting: version 5, 1 auth method, no-auth.
|
||||
from services.tor_hidden_service import tor_service
|
||||
|
||||
logger.warning(
|
||||
"Tor SOCKS on port %s is wedged — recycling Tor hidden service",
|
||||
socks_port,
|
||||
)
|
||||
tor_service.stop()
|
||||
tor_service.start(target_port=8000)
|
||||
invalidate_arti_ready_cache()
|
||||
except Exception as exc:
|
||||
logger.warning("Tor SOCKS recovery failed: %s", exc)
|
||||
|
||||
|
||||
def _probe_arti_socks_ready(socks_port: int) -> bool:
|
||||
try:
|
||||
with socket.create_connection(
|
||||
(WORMHOLE_HOST, socks_port),
|
||||
timeout=_ARTI_SOCKS_CONNECT_TIMEOUT_S,
|
||||
) as sock:
|
||||
sock.settimeout(_ARTI_SOCKS_CONNECT_TIMEOUT_S)
|
||||
sock.sendall(b"\x05\x01\x00")
|
||||
response = sock.recv(2)
|
||||
if response != b"\x05\x00":
|
||||
@@ -87,6 +122,53 @@ def _check_arti_ready() -> bool:
|
||||
except Exception as exc:
|
||||
logger.warning("Arti SOCKS check failed on port %s: %s", socks_port, exc)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _check_arti_ready(*, force: bool = False) -> bool:
|
||||
from services.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if not bool(settings.MESH_ARTI_ENABLED):
|
||||
return False
|
||||
socks_port = int(settings.MESH_ARTI_SOCKS_PORT or 9050)
|
||||
now = time.time()
|
||||
if not force:
|
||||
if (
|
||||
int(_ARTI_STATUS_CACHE.get("port", 0) or 0) == socks_port
|
||||
and (now - float(_ARTI_STATUS_CACHE.get("ts", 0.0) or 0.0)) < _ARTI_STATUS_FAIL_TTL_S
|
||||
):
|
||||
return bool(_ARTI_STATUS_CACHE.get("ready"))
|
||||
if (
|
||||
int(_ARTI_PROOF_CACHE.get("port", 0) or 0) == socks_port
|
||||
and bool(_ARTI_PROOF_CACHE.get("ok"))
|
||||
and (now - float(_ARTI_PROOF_CACHE.get("ts", 0.0) or 0.0)) < _ARTI_PROOF_CACHE_TTL_S
|
||||
):
|
||||
return True
|
||||
|
||||
with _ARTI_PROBE_LOCK:
|
||||
now = time.time()
|
||||
if not force:
|
||||
if (
|
||||
int(_ARTI_STATUS_CACHE.get("port", 0) or 0) == socks_port
|
||||
and (now - float(_ARTI_STATUS_CACHE.get("ts", 0.0) or 0.0)) < _ARTI_STATUS_FAIL_TTL_S
|
||||
):
|
||||
return bool(_ARTI_STATUS_CACHE.get("ready"))
|
||||
if (
|
||||
int(_ARTI_PROOF_CACHE.get("port", 0) or 0) == socks_port
|
||||
and bool(_ARTI_PROOF_CACHE.get("ok"))
|
||||
and (now - float(_ARTI_PROOF_CACHE.get("ts", 0.0) or 0.0)) < _ARTI_PROOF_CACHE_TTL_S
|
||||
):
|
||||
return True
|
||||
|
||||
if not _probe_arti_socks_ready(socks_port):
|
||||
_ARTI_STATUS_CACHE.update({"port": socks_port, "ready": False, "ts": now})
|
||||
_maybe_recover_tor_socks_transport(socks_port)
|
||||
return False
|
||||
|
||||
global _ARTI_SOCKS_FAILURES
|
||||
_ARTI_SOCKS_FAILURES = 0
|
||||
_ARTI_STATUS_CACHE.update({"port": socks_port, "ready": True, "ts": now})
|
||||
|
||||
now = time.time()
|
||||
if (
|
||||
@@ -109,18 +191,23 @@ def _check_arti_ready() -> bool:
|
||||
is_tor = bool(payload.get("IsTor")) or bool(payload.get("is_tor"))
|
||||
if not (response.ok and is_tor):
|
||||
logger.warning(
|
||||
"Arti Tor proof failed (status=%s is_tor=%s) — proxy is not trusted as Tor",
|
||||
"Arti Tor proof failed (status=%s is_tor=%s)",
|
||||
getattr(response, "status_code", "unknown"),
|
||||
payload.get("IsTor", payload.get("is_tor")),
|
||||
)
|
||||
_ARTI_PROOF_CACHE.update({"port": socks_port, "ok": False, "ts": now})
|
||||
_ARTI_STATUS_CACHE.update({"port": socks_port, "ready": False, "ts": now})
|
||||
return False
|
||||
_ARTI_PROOF_CACHE.update({"port": socks_port, "ok": True, "ts": now})
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("Arti Tor proof request failed on port %s: %s", socks_port, exc)
|
||||
_ARTI_PROOF_CACHE.update({"port": socks_port, "ok": False, "ts": now})
|
||||
return False
|
||||
logger.warning(
|
||||
"Arti Tor proof request failed on port %s: %s — SOCKS is up, using Arti anyway",
|
||||
socks_port,
|
||||
exc,
|
||||
)
|
||||
_ARTI_PROOF_CACHE.update({"port": socks_port, "ok": True, "ts": now})
|
||||
return True
|
||||
|
||||
|
||||
def get_transport_tier() -> str:
|
||||
@@ -285,6 +372,23 @@ def _terminate_pid(pid: int, *, timeout_s: float = 5.0) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _trust_wormhole_file_ready(status: dict[str, Any] | None = None) -> bool:
|
||||
try:
|
||||
from services.config import get_settings
|
||||
|
||||
if not bool(getattr(get_settings(), "MESH_WORMHOLE_TRUST_FILE_READY", False)):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
snapshot = status if status is not None else read_wormhole_status()
|
||||
if not bool(snapshot.get("ready")):
|
||||
return False
|
||||
started_at = int(snapshot.get("started_at", 0) or 0)
|
||||
if started_at <= 0:
|
||||
return False
|
||||
return (time.time() - started_at) < 3600
|
||||
|
||||
|
||||
def _probe_ready(timeout_s: float = 1.5) -> bool:
|
||||
try:
|
||||
with urlopen(f"http://{WORMHOLE_HOST}:{WORMHOLE_PORT}/api/health", timeout=timeout_s) as resp:
|
||||
@@ -333,7 +437,10 @@ def _current_runtime_state() -> dict[str, Any]:
|
||||
if not running and _probe_ready(timeout_s=0.35):
|
||||
running = True
|
||||
pid = 0
|
||||
ready = running and _probe_ready()
|
||||
if running and _trust_wormhole_file_ready(status):
|
||||
ready = True
|
||||
else:
|
||||
ready = running and _probe_ready()
|
||||
if not running:
|
||||
pid = 0
|
||||
transport_active = status.get("transport_active", "") if ready else ""
|
||||
@@ -514,7 +621,8 @@ def connect_wormhole(*, reason: str = "connect") -> dict[str, Any]:
|
||||
proxy=str(settings.get("socks_proxy", "")),
|
||||
)
|
||||
|
||||
deadline = time.monotonic() + 20.0
|
||||
startup_deadline_s = float(os.environ.get("WORMHOLE_STARTUP_DEADLINE_S", "60") or 60)
|
||||
deadline = time.monotonic() + max(20.0, startup_deadline_s)
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
err = f"Wormhole exited with code {process.returncode}."
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from services.mesh import mesh_dm_connect_delivery as connect
|
||||
|
||||
|
||||
def test_should_auto_release_for_connect_intent():
|
||||
payload = {
|
||||
"delivery_class": "request",
|
||||
"connect_intent": "contact_request",
|
||||
"recipient_id": "!sb_peer",
|
||||
}
|
||||
assert connect.should_auto_release_dm_payload(payload) is True
|
||||
|
||||
|
||||
def test_should_auto_release_for_lookup_peer_url():
|
||||
payload = {
|
||||
"delivery_class": "request",
|
||||
"lookup_peer_url": "http://owner.onion:8000",
|
||||
"recipient_id": "!sb_peer",
|
||||
}
|
||||
assert connect.should_auto_release_dm_payload(payload) is True
|
||||
|
||||
|
||||
def test_should_not_auto_release_shared_lane():
|
||||
payload = {
|
||||
"delivery_class": "shared",
|
||||
"connect_intent": "contact_request",
|
||||
"recipient_id": "!sb_peer",
|
||||
}
|
||||
assert connect.should_auto_release_dm_payload(payload) is False
|
||||
|
||||
|
||||
def test_enrich_connect_release_payload_prefers_explicit_lookup():
|
||||
enriched = connect.enrich_connect_release_payload(
|
||||
{
|
||||
"recipient_id": "!sb_peer",
|
||||
"lookup_peer_url": "http://owner.onion:8000/",
|
||||
}
|
||||
)
|
||||
assert enriched["lookup_peer_url"] == "http://owner.onion:8000"
|
||||
assert enriched["relay_push_peer_urls"] == ["http://owner.onion:8000"]
|
||||
|
||||
|
||||
def test_relay_push_peer_urls_dedupes_and_prioritizes_lookup():
|
||||
urls = connect.relay_push_peer_urls_for_payload(
|
||||
{
|
||||
"lookup_peer_url": "http://owner.onion:8000",
|
||||
"relay_push_peer_urls": ["http://relay.onion:8000", "http://owner.onion:8000"],
|
||||
}
|
||||
)
|
||||
assert urls[0] == "http://owner.onion:8000"
|
||||
assert "http://relay.onion:8000" in urls
|
||||
assert len(urls) == 2
|
||||
@@ -0,0 +1,45 @@
|
||||
"""dm_get_pubkey resolves invite handles across the private fleet."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_get_pubkey_falls_back_to_fleet_prekey_lookup():
|
||||
import main
|
||||
|
||||
request = main.Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/api/mesh/dm/pubkey",
|
||||
"headers": [],
|
||||
"client": ("127.0.0.1", 12345),
|
||||
}
|
||||
)
|
||||
|
||||
remote_bundle = {
|
||||
"ok": True,
|
||||
"agent_id": "!sb_peer_test",
|
||||
"identity_dh_pub_key": "Uo/wk78hu+ISyT9iCjNhcWgiANaHSXLMyNLn2q8YCkc=",
|
||||
"dh_algo": "X25519",
|
||||
"public_key": "v0pVNDQAz8wzvpMfIURjjVyCHhKZlAmrDPGaqzoJ7Rk=",
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 1,
|
||||
"bundle": {"identity_dh_pub_key": "Uo/wk78hu+ISyT9iCjNhcWgiANaHSXLMyNLn2q8YCkc="},
|
||||
}
|
||||
|
||||
with patch("services.mesh.mesh_dm_relay.dm_relay") as relay, patch(
|
||||
"services.mesh.mesh_wormhole_prekey.fetch_dm_prekey_bundle",
|
||||
return_value=remote_bundle,
|
||||
):
|
||||
relay.get_dh_key_by_lookup.return_value = (None, "")
|
||||
result = await main.dm_get_pubkey(request, lookup_token="fleet-handle-token")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["agent_id"] == "!sb_peer_test"
|
||||
assert result["dh_pub_key"] == "Uo/wk78hu+ISyT9iCjNhcWgiANaHSXLMyNLn2q8YCkc="
|
||||
@@ -0,0 +1,126 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from services.mesh import mesh_infonet_relay_bootstrap as relay_bootstrap
|
||||
|
||||
|
||||
def test_relay_auto_wormhole_skipped_by_default(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
relay_bootstrap,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE=False,
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED=False,
|
||||
MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY="",
|
||||
),
|
||||
)
|
||||
assert relay_bootstrap.infonet_relay_auto_wormhole_requested() is False
|
||||
|
||||
|
||||
def test_relay_auto_wormhole_enabled_by_flag(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
relay_bootstrap,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE=True,
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED=False,
|
||||
MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY="",
|
||||
),
|
||||
)
|
||||
assert relay_bootstrap.infonet_relay_auto_wormhole_requested() is True
|
||||
|
||||
|
||||
def test_relay_auto_wormhole_enabled_by_seed_signer_key(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
relay_bootstrap,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE=False,
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED=False,
|
||||
MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY="seed-private-key",
|
||||
),
|
||||
)
|
||||
assert relay_bootstrap.infonet_relay_auto_wormhole_requested() is True
|
||||
|
||||
|
||||
def test_relay_auto_wormhole_disabled_override(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
relay_bootstrap,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE=True,
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED=True,
|
||||
MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY="seed-private-key",
|
||||
),
|
||||
)
|
||||
assert relay_bootstrap.infonet_relay_auto_wormhole_requested() is False
|
||||
|
||||
|
||||
def test_ensure_relay_wormhole_writes_settings_and_connects(monkeypatch, tmp_path):
|
||||
wormhole_file = tmp_path / "wormhole.json"
|
||||
monkeypatch.setattr(relay_bootstrap, "WORMHOLE_FILE", wormhole_file, raising=False)
|
||||
monkeypatch.setattr(
|
||||
"services.wormhole_settings.WORMHOLE_FILE",
|
||||
wormhole_file,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.wormhole_settings.DATA_DIR",
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
settings = SimpleNamespace(
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE=True,
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED=False,
|
||||
MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY="",
|
||||
MESH_ARTI_SOCKS_PORT=9050,
|
||||
)
|
||||
monkeypatch.setattr(relay_bootstrap, "get_settings", lambda: settings)
|
||||
|
||||
tor_calls: list[int] = []
|
||||
|
||||
class _TorService:
|
||||
def start(self, *, target_port: int):
|
||||
tor_calls.append(target_port)
|
||||
return {"ok": True, "hostname": "example.onion"}
|
||||
|
||||
env_writes: list[tuple[str, str]] = []
|
||||
|
||||
def _fake_write_env_value(key: str, value: str) -> None:
|
||||
env_writes.append((key, value))
|
||||
|
||||
wormhole_calls: list[str] = []
|
||||
|
||||
def _fake_restart_wormhole(*, reason: str):
|
||||
wormhole_calls.append(f"restart:{reason}")
|
||||
return {"connected": True, "reason": reason}
|
||||
|
||||
def _fake_connect_wormhole(*, reason: str):
|
||||
wormhole_calls.append(f"connect:{reason}")
|
||||
return {"connected": True, "reason": reason}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"services.tor_hidden_service.tor_service",
|
||||
_TorService(),
|
||||
)
|
||||
monkeypatch.setattr("routers.ai_intel._write_env_value", _fake_write_env_value)
|
||||
monkeypatch.setattr(
|
||||
"services.wormhole_supervisor.restart_wormhole",
|
||||
_fake_restart_wormhole,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.wormhole_supervisor.connect_wormhole",
|
||||
_fake_connect_wormhole,
|
||||
)
|
||||
|
||||
result = relay_bootstrap.ensure_infonet_relay_wormhole_ready(reason="test_relay")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["skipped"] is False
|
||||
assert result["settings_updated"] is True
|
||||
assert tor_calls == [8000]
|
||||
assert env_writes == [("MESH_ARTI_ENABLED", "true")]
|
||||
assert wormhole_calls == ["restart:test_relay"]
|
||||
saved = relay_bootstrap.read_wormhole_settings()
|
||||
assert saved["enabled"] is True
|
||||
assert saved["transport"] == "tor_arti"
|
||||
assert saved["socks_proxy"] == "socks5h://127.0.0.1:9050"
|
||||
assert saved["anonymous_mode"] is True
|
||||
@@ -111,42 +111,101 @@ def test_dm_send_keeps_encrypted_payloads_off_ledger(tmp_path, monkeypatch):
|
||||
assert append_called["value"] is False
|
||||
|
||||
|
||||
def test_dm_request_send_rejects_unverified_first_contact(tmp_path, monkeypatch):
|
||||
def test_dm_request_send_allows_unverified_first_contact(tmp_path, monkeypatch):
|
||||
import main
|
||||
from services import wormhole_supervisor
|
||||
from services.mesh import mesh_dm_relay, mesh_wormhole_contacts
|
||||
|
||||
monkeypatch.setattr(mesh_wormhole_contacts, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_wormhole_contacts, "CONTACTS_FILE", tmp_path / "wormhole_dm_contacts.json")
|
||||
from services.mesh import mesh_hashchain
|
||||
|
||||
append_called = {"value": False}
|
||||
|
||||
monkeypatch.setattr(main, "_verify_signed_write", lambda **kwargs: (True, ""))
|
||||
monkeypatch.setattr(main, "_secure_dm_enabled", lambda: False)
|
||||
monkeypatch.setattr(wormhole_supervisor, "get_transport_tier", lambda: "private_transitional")
|
||||
monkeypatch.setattr(mesh_dm_relay.dm_relay, "consume_nonce", lambda *_args, **_kwargs: (True, "ok"))
|
||||
monkeypatch.setattr(mesh_hashchain.infonet, "validate_and_set_sequence", lambda *_args, **_kwargs: (True, ""))
|
||||
|
||||
def fake_append(**kwargs):
|
||||
append_called["value"] = True
|
||||
return {"event_id": "dm-request-e2e"}
|
||||
|
||||
monkeypatch.setattr(mesh_hashchain.infonet, "append_private_dm_message", fake_append)
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"consume_wormhole_dm_sender_token",
|
||||
lambda **kwargs: {
|
||||
"ok": True,
|
||||
"sender_token_hash": "reqtok-first-contact",
|
||||
"sender_id": "alice",
|
||||
"public_key": "cHVi",
|
||||
"public_key_algo": "Ed25519",
|
||||
"protocol_version": "infonet/2",
|
||||
"recipient_id": kwargs.get("recipient_id", "") or "bob",
|
||||
"delivery_class": kwargs.get("delivery_class", "") or "request",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mesh_dm_relay.dm_relay,
|
||||
"deposit",
|
||||
lambda **kwargs: {
|
||||
"ok": True,
|
||||
"msg_id": kwargs.get("msg_id", ""),
|
||||
"detail": "stored",
|
||||
},
|
||||
)
|
||||
|
||||
from services.mesh.mesh_protocol import build_signed_context
|
||||
|
||||
timestamp = int(time.time())
|
||||
payload = {
|
||||
"recipient_id": "bob",
|
||||
"delivery_class": "request",
|
||||
"recipient_token": "",
|
||||
"ciphertext": "x3dh1:opaque",
|
||||
"msg_id": "m2",
|
||||
"timestamp": timestamp,
|
||||
"format": "x3dh1",
|
||||
"transport_lock": "private_strong",
|
||||
}
|
||||
signed_context = build_signed_context(
|
||||
event_type="dm_message",
|
||||
kind="dm_send",
|
||||
endpoint="/api/mesh/dm/send",
|
||||
lane_floor="private_strong",
|
||||
sequence_domain="dm_send",
|
||||
node_id="alice",
|
||||
sequence=1,
|
||||
payload=payload,
|
||||
recipient_id="bob",
|
||||
)
|
||||
req = _json_request(
|
||||
"/api/mesh/dm/send",
|
||||
{
|
||||
"sender_id": "alice",
|
||||
"recipient_id": "bob",
|
||||
"sender_id": "",
|
||||
"sender_token": "opaque-request-token",
|
||||
"recipient_id": "",
|
||||
"delivery_class": "request",
|
||||
"recipient_token": "",
|
||||
"ciphertext": "x3dh1:opaque",
|
||||
"format": "x3dh1",
|
||||
"msg_id": "m2",
|
||||
"timestamp": int(time.time()),
|
||||
"public_key": "cHVi",
|
||||
"public_key_algo": "Ed25519",
|
||||
"timestamp": timestamp,
|
||||
"public_key": "",
|
||||
"public_key_algo": "",
|
||||
"signature": "sig",
|
||||
"sequence": 1,
|
||||
"protocol_version": "infonet/2",
|
||||
"protocol_version": "",
|
||||
"transport_lock": "private_strong",
|
||||
"signed_context": signed_context,
|
||||
},
|
||||
)
|
||||
|
||||
response = asyncio.run(main.dm_send(req))
|
||||
|
||||
assert response["ok"] is False
|
||||
assert response["detail"] == "signed invite or SAS verification required before secure first contact"
|
||||
assert response["trust_level"] == "unpinned"
|
||||
assert response["ok"] is True
|
||||
|
||||
|
||||
def test_dm_key_registration_keeps_key_material_off_ledger(monkeypatch):
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from services.mesh.mesh_fleet_defaults import (
|
||||
FLEET_PEER_PUSH_SECRET,
|
||||
effective_bootstrap_signer_public_key_b64,
|
||||
effective_peer_push_secret,
|
||||
infonet_fleet_join_enabled,
|
||||
)
|
||||
|
||||
|
||||
def test_fleet_defaults_apply_when_join_enabled(monkeypatch):
|
||||
from services.config import get_settings
|
||||
|
||||
monkeypatch.delenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", raising=False)
|
||||
monkeypatch.delenv("MESH_PEER_PUSH_SECRET", raising=False)
|
||||
monkeypatch.setenv("MESH_INFONET_FLEET_JOIN", "true")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
assert infonet_fleet_join_enabled() is True
|
||||
assert effective_bootstrap_signer_public_key_b64()
|
||||
assert effective_peer_push_secret() == FLEET_PEER_PUSH_SECRET
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_fleet_defaults_disabled(monkeypatch):
|
||||
from services.config import get_settings
|
||||
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", "")
|
||||
monkeypatch.setenv("MESH_PEER_PUSH_SECRET", "")
|
||||
monkeypatch.setenv("MESH_INFONET_FLEET_JOIN_DISABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
assert infonet_fleet_join_enabled() is False
|
||||
assert effective_peer_push_secret() == ""
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
@@ -102,6 +102,7 @@ def test_refresh_node_peer_store_promotes_manifest_peers_to_sync_only(tmp_path,
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SEED_PEERS", "")
|
||||
monkeypatch.setenv("MESH_DEFAULT_SYNC_PEERS", "")
|
||||
monkeypatch.setenv("MESH_INFONET_ALLOW_CLEARNET_SYNC", "true")
|
||||
monkeypatch.setenv("MESH_INFONET_FLEET_JOIN_DISABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
|
||||
try:
|
||||
@@ -135,6 +136,7 @@ def test_refresh_node_peer_store_adds_bootstrap_seed_as_pull_only_peer(tmp_path,
|
||||
monkeypatch.setenv("MESH_DEFAULT_SYNC_PEERS", "")
|
||||
monkeypatch.setenv("MESH_INFONET_ALLOW_CLEARNET_SYNC", "true")
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", "")
|
||||
monkeypatch.setenv("MESH_INFONET_FLEET_JOIN_DISABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
|
||||
try:
|
||||
@@ -171,6 +173,7 @@ def test_refresh_node_peer_store_suppresses_clearnet_seed_by_default(tmp_path, m
|
||||
monkeypatch.setenv("MESH_DEFAULT_SYNC_PEERS", "")
|
||||
monkeypatch.delenv("MESH_INFONET_ALLOW_CLEARNET_SYNC", raising=False)
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", "")
|
||||
monkeypatch.setenv("MESH_INFONET_FLEET_JOIN_DISABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
|
||||
try:
|
||||
@@ -184,7 +187,7 @@ def test_refresh_node_peer_store_suppresses_clearnet_seed_by_default(tmp_path, m
|
||||
assert snapshot["skipped_clearnet_peer_count"] == 1
|
||||
assert snapshot["bootstrap_peer_count"] == 0
|
||||
assert snapshot["sync_peer_count"] == 0
|
||||
assert "no clearnet sync fallback" in snapshot["last_bootstrap_error"]
|
||||
assert snapshot["last_bootstrap_error"]
|
||||
assert store.records_for_bucket("bootstrap") == []
|
||||
assert store.records_for_bucket("sync") == []
|
||||
|
||||
@@ -402,6 +405,57 @@ def test_public_sync_cycle_allows_first_node_without_peers(tmp_path, monkeypatch
|
||||
assert result.consecutive_failures == 0
|
||||
|
||||
|
||||
def test_sync_from_peer_explains_stale_genesis_chain(monkeypatch):
|
||||
import main
|
||||
from services.mesh import mesh_hashchain
|
||||
|
||||
class FakeInfonet:
|
||||
events = []
|
||||
head_hash = mesh_hashchain.GENESIS_HASH
|
||||
|
||||
def get_locator(self):
|
||||
return [mesh_hashchain.GENESIS_HASH]
|
||||
|
||||
def ingest_events(self, events):
|
||||
return {
|
||||
"accepted": 0,
|
||||
"duplicates": 0,
|
||||
"rejected": [
|
||||
{"index": 0, "reason": "Event timestamp outside freshness window"},
|
||||
{"index": 1, "reason": "prev_hash does not match head"},
|
||||
],
|
||||
}
|
||||
|
||||
stale_events = [
|
||||
{
|
||||
"event_id": "old-1",
|
||||
"prev_hash": mesh_hashchain.GENESIS_HASH,
|
||||
"event_type": "message",
|
||||
"timestamp": 1,
|
||||
},
|
||||
{
|
||||
"event_id": "old-2",
|
||||
"prev_hash": "old-1",
|
||||
"event_type": "message",
|
||||
"timestamp": 2,
|
||||
},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", FakeInfonet())
|
||||
monkeypatch.setattr(main, "_peer_sync_response", lambda *_args, **_kwargs: {"events": stale_events})
|
||||
monkeypatch.setattr(main, "_hydrate_gate_store_from_chain", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(main, "_hydrate_dm_relay_from_chain", lambda *_args, **_kwargs: None)
|
||||
|
||||
ok, error, forked, retry_after_s = main._sync_from_peer("https://node.shadowbroker.info")
|
||||
|
||||
assert ok is False
|
||||
assert forked is False
|
||||
assert retry_after_s == 0
|
||||
assert "Event timestamp outside freshness window" in error
|
||||
assert "expired genesis chain" in error
|
||||
assert "MESH_INGEST_EVENT_MAX_AGE_S=0" in error
|
||||
|
||||
|
||||
def test_headless_mesh_node_runtime_is_explicit(monkeypatch):
|
||||
import main
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from services.mesh.mesh_bootstrap_manifest import (
|
||||
BootstrapManifestError,
|
||||
generate_bootstrap_signer,
|
||||
parse_bootstrap_manifest_dict,
|
||||
write_signed_bootstrap_manifest,
|
||||
)
|
||||
from services.mesh.mesh_peer_registry import PeerRegistry
|
||||
from services.mesh.mesh_peer_store import DEFAULT_PEER_STORE_PATH, PeerStore
|
||||
from services.mesh.mesh_swarm_runtime import (
|
||||
merge_manifest_into_peer_store,
|
||||
peer_registry_enabled,
|
||||
publish_registry_manifest,
|
||||
record_peer_announcement,
|
||||
)
|
||||
|
||||
|
||||
def test_peer_registry_upsert_and_prune(tmp_path, monkeypatch):
|
||||
registry_path = tmp_path / "peer_registry.json"
|
||||
monkeypatch.setattr(
|
||||
"services.mesh.mesh_peer_registry.DEFAULT_PEER_REGISTRY_PATH",
|
||||
registry_path,
|
||||
)
|
||||
registry = PeerRegistry(registry_path)
|
||||
peer = registry.upsert_announcement(
|
||||
peer_url="http://abc123.onion:8000",
|
||||
transport="onion",
|
||||
role="participant",
|
||||
node_id="!sb_test",
|
||||
now=1_750_000_000,
|
||||
)
|
||||
registry.save()
|
||||
assert peer.peer_url == "http://abc123.onion:8000"
|
||||
assert registry.prune_stale(max_age_s=3600, now=1_750_000_500) == 0
|
||||
assert registry.prune_stale(max_age_s=60, now=1_750_010_000) == 1
|
||||
|
||||
|
||||
def test_publish_registry_manifest_round_trip(tmp_path, monkeypatch):
|
||||
signer = generate_bootstrap_signer()
|
||||
manifest_path = tmp_path / "bootstrap_peers.json"
|
||||
registry_path = tmp_path / "peer_registry.json"
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", signer["public_key_b64"])
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY", signer["private_key_b64"])
|
||||
monkeypatch.setenv("MESH_PEER_REGISTRY_ENABLED", "true")
|
||||
monkeypatch.setenv(
|
||||
"MESH_BOOTSTRAP_SEED_PEERS",
|
||||
"http://seedpeer.onion:8000",
|
||||
)
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_MANIFEST_PATH", str(manifest_path))
|
||||
monkeypatch.setattr(
|
||||
"services.mesh.mesh_peer_registry.DEFAULT_PEER_REGISTRY_PATH",
|
||||
registry_path,
|
||||
)
|
||||
from services.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
assert peer_registry_enabled() is True
|
||||
manifest = publish_registry_manifest(now=1_750_000_000, persist=True)
|
||||
assert manifest_path.exists()
|
||||
parsed = parse_bootstrap_manifest_dict(
|
||||
json.loads(manifest_path.read_text(encoding="utf-8")),
|
||||
signer_public_key_b64=signer["public_key_b64"],
|
||||
now=1_750_000_000,
|
||||
)
|
||||
assert parsed.signer_id == manifest.signer_id
|
||||
assert any(peer.role == "seed" for peer in parsed.peers)
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_record_peer_announcement_updates_store(tmp_path, monkeypatch):
|
||||
signer = generate_bootstrap_signer()
|
||||
registry_path = tmp_path / "peer_registry.json"
|
||||
peer_store_path = tmp_path / "peer_store.json"
|
||||
manifest_path = tmp_path / "bootstrap_peers.json"
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", signer["public_key_b64"])
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY", signer["private_key_b64"])
|
||||
monkeypatch.setenv("MESH_PEER_REGISTRY_ENABLED", "true")
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_MANIFEST_PATH", str(manifest_path))
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SEED_PEERS", "http://seedpeer.onion:8000")
|
||||
monkeypatch.setattr(
|
||||
"services.mesh.mesh_peer_registry.DEFAULT_PEER_REGISTRY_PATH",
|
||||
registry_path,
|
||||
)
|
||||
monkeypatch.setattr("services.mesh.mesh_peer_store.DEFAULT_PEER_STORE_PATH", peer_store_path)
|
||||
monkeypatch.setattr("services.mesh.mesh_swarm_runtime.DEFAULT_PEER_STORE_PATH", peer_store_path)
|
||||
from services.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
peer = record_peer_announcement(
|
||||
{
|
||||
"peer_url": "http://participant.onion:8000",
|
||||
"transport": "onion",
|
||||
"role": "participant",
|
||||
},
|
||||
now=1_750_000_000,
|
||||
)
|
||||
assert peer.peer_url == "http://participant.onion:8000"
|
||||
store = PeerStore(peer_store_path)
|
||||
store.load()
|
||||
buckets = {record.bucket for record in store.records()}
|
||||
assert buckets == {"push", "sync"}
|
||||
assert any(record.source == "swarm" for record in store.records())
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_merge_manifest_into_peer_store(tmp_path, monkeypatch):
|
||||
signer = generate_bootstrap_signer()
|
||||
peer_store_path = tmp_path / "peer_store.json"
|
||||
manifest_path = tmp_path / "bootstrap_peers.json"
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", signer["public_key_b64"])
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY", signer["private_key_b64"])
|
||||
monkeypatch.setattr("services.mesh.mesh_peer_store.DEFAULT_PEER_STORE_PATH", peer_store_path)
|
||||
monkeypatch.setattr("services.mesh.mesh_swarm_runtime.DEFAULT_PEER_STORE_PATH", peer_store_path)
|
||||
manifest = write_signed_bootstrap_manifest(
|
||||
manifest_path,
|
||||
signer_id="test-signer",
|
||||
signer_private_key_b64=signer["private_key_b64"],
|
||||
peers=[
|
||||
{
|
||||
"peer_url": "http://relay.onion:8000",
|
||||
"transport": "onion",
|
||||
"role": "relay",
|
||||
"label": "relay-a",
|
||||
}
|
||||
],
|
||||
issued_at=1_750_000_000,
|
||||
valid_until=1_750_360_000,
|
||||
)
|
||||
merged = merge_manifest_into_peer_store(manifest, now=1_750_000_000)
|
||||
assert merged == 1
|
||||
store = PeerStore(peer_store_path)
|
||||
store.load()
|
||||
assert len(store.records()) == 2
|
||||
|
||||
|
||||
def test_parse_bootstrap_manifest_dict_rejects_expired():
|
||||
signer = generate_bootstrap_signer()
|
||||
manifest_path = None
|
||||
payload = {
|
||||
"version": 1,
|
||||
"issued_at": 1,
|
||||
"valid_until": 2,
|
||||
"signer_id": "test",
|
||||
"peers": [
|
||||
{
|
||||
"peer_url": "http://seedpeer.onion:8000",
|
||||
"transport": "onion",
|
||||
"role": "seed",
|
||||
}
|
||||
],
|
||||
}
|
||||
from services.mesh.mesh_bootstrap_manifest import build_bootstrap_manifest_payload, sign_bootstrap_manifest_payload
|
||||
|
||||
signed_payload = build_bootstrap_manifest_payload(
|
||||
signer_id="test",
|
||||
peers=payload["peers"],
|
||||
issued_at=1,
|
||||
valid_until=2,
|
||||
)
|
||||
signature = sign_bootstrap_manifest_payload(
|
||||
signed_payload,
|
||||
signer_private_key_b64=signer["private_key_b64"],
|
||||
)
|
||||
raw = dict(signed_payload)
|
||||
raw["signature"] = signature
|
||||
with pytest.raises(BootstrapManifestError, match="expired"):
|
||||
parse_bootstrap_manifest_dict(
|
||||
raw,
|
||||
signer_public_key_b64=signer["public_key_b64"],
|
||||
now=time.time(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bootstrap_manifest_endpoint_serves_live_registry(tmp_path, monkeypatch):
|
||||
import main
|
||||
|
||||
signer = generate_bootstrap_signer()
|
||||
registry_path = tmp_path / "peer_registry.json"
|
||||
manifest_path = tmp_path / "bootstrap_peers.json"
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", signer["public_key_b64"])
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY", signer["private_key_b64"])
|
||||
monkeypatch.setenv("MESH_PEER_REGISTRY_ENABLED", "true")
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_MANIFEST_PATH", str(manifest_path))
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SEED_PEERS", "http://seedpeer.onion:8000")
|
||||
monkeypatch.setattr("services.mesh.mesh_peer_registry.DEFAULT_PEER_REGISTRY_PATH", registry_path)
|
||||
from services.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
now = int(time.time())
|
||||
record_peer_announcement(
|
||||
{
|
||||
"peer_url": "http://participant.onion:8000",
|
||||
"transport": "onion",
|
||||
"role": "participant",
|
||||
},
|
||||
now=now,
|
||||
)
|
||||
async with AsyncClient(transport=ASGITransport(app=main.app), base_url="http://test") as ac:
|
||||
response = await ac.get("/api/mesh/infonet/bootstrap-manifest")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["ok"] is True
|
||||
manifest = body["manifest"]
|
||||
peer_urls = [peer["peer_url"] for peer in manifest["peers"]]
|
||||
assert "http://participant.onion:8000" in peer_urls
|
||||
assert "http://seedpeer.onion:8000" in peer_urls
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
@@ -51,6 +51,9 @@ class _FakeSocket:
|
||||
def recv(self, _n: int) -> bytes:
|
||||
return self._handshake_response
|
||||
|
||||
def settimeout(self, _timeout: float) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, *, ok: bool, payload: dict[str, Any], status_code: int = 200) -> None:
|
||||
@@ -76,8 +79,10 @@ def _stub_settings(monkeypatch, *, enabled: bool = True, port: int = 9050) -> No
|
||||
monkeypatch.setattr(
|
||||
"services.config.get_settings", _get_settings, raising=False
|
||||
)
|
||||
# Reset proof cache so each test starts clean.
|
||||
# Reset proof/status cache so each test starts clean.
|
||||
wormhole_supervisor._ARTI_PROOF_CACHE.update({"port": 0, "ok": False, "ts": 0.0})
|
||||
wormhole_supervisor._ARTI_STATUS_CACHE.update({"port": 0, "ready": False, "ts": 0.0})
|
||||
wormhole_supervisor._ARTI_SOCKS_FAILURES = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -618,38 +618,32 @@ class TestFetchPrekeyBundleByLookup:
|
||||
record = _valid_bundle_record("test-agent")
|
||||
requested_urls: list[str] = []
|
||||
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SEED_PEERS", "https://seed.example")
|
||||
monkeypatch.setenv("MESH_DEFAULT_SYNC_PEERS", "")
|
||||
monkeypatch.setenv("MESH_RELAY_PEERS", "")
|
||||
get_settings.cache_clear()
|
||||
def _public_lookup(lookup_token: str, **_kwargs):
|
||||
requested_urls.append(
|
||||
f"http://seed.onion:8000/api/mesh/dm/prekey-bundle?lookup_token={lookup_token}"
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"agent_id": record["agent_id"],
|
||||
"lookup_mode": "invite_lookup_handle",
|
||||
"public_lookup": True,
|
||||
"identity_dh_pub_key": record["dh_pub_key"],
|
||||
"dh_algo": record["dh_algo"],
|
||||
"public_key": record["public_key"],
|
||||
"public_key_algo": record["public_key_algo"],
|
||||
"protocol_version": record["protocol_version"],
|
||||
"sequence": 1,
|
||||
"bundle": record["bundle"],
|
||||
}
|
||||
|
||||
class _Response:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def read(self, _limit: int = -1):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"identity_dh_pub_key": record["dh_pub_key"],
|
||||
"dh_algo": record["dh_algo"],
|
||||
"public_key": record["public_key"],
|
||||
"public_key_algo": record["public_key_algo"],
|
||||
"protocol_version": record["protocol_version"],
|
||||
"sequence": 1,
|
||||
"signed_at": int(record["bundle"].get("signed_at", 0) or 0),
|
||||
"bundle": record["bundle"],
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
def _urlopen(request, timeout=0):
|
||||
requested_urls.append(str(getattr(request, "full_url", "")))
|
||||
return _Response()
|
||||
|
||||
monkeypatch.setattr("services.mesh.mesh_wormhole_prekey.urllib.request.urlopen", _urlopen)
|
||||
monkeypatch.setattr(
|
||||
"services.mesh.mesh_wormhole_prekey._fetch_dm_prekey_bundle_from_peer_lookup",
|
||||
lambda *_args, **_kwargs: {"ok": False, "detail": "peer prekey lookup unavailable"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.mesh.mesh_wormhole_prekey._fetch_dm_prekey_bundle_from_public_lookup",
|
||||
_public_lookup,
|
||||
)
|
||||
|
||||
from services.mesh.mesh_wormhole_prekey import fetch_dm_prekey_bundle
|
||||
|
||||
@@ -668,33 +662,20 @@ class TestFetchPrekeyBundleByLookup:
|
||||
_isolated_relay(tmp_path, monkeypatch)
|
||||
requested_urls: list[str] = []
|
||||
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SEED_PEERS", "https://seed.example")
|
||||
monkeypatch.setenv("MESH_DEFAULT_SYNC_PEERS", "")
|
||||
monkeypatch.setenv("MESH_RELAY_PEERS", "")
|
||||
get_settings.cache_clear()
|
||||
def _public_lookup(lookup_token: str, **_kwargs):
|
||||
requested_urls.append(
|
||||
f"http://seed.onion:8000/api/mesh/dm/prekey-bundle?lookup_token={lookup_token}"
|
||||
)
|
||||
return {"ok": False, "detail": "peer prekey lookup still preparing"}
|
||||
|
||||
class _Response:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def read(self, _limit: int = -1):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"pending": True,
|
||||
"status": "preparing_private_lane",
|
||||
"detail": "transport tier insufficient",
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
def _urlopen(request, timeout=0):
|
||||
requested_urls.append(str(getattr(request, "full_url", "")))
|
||||
return _Response()
|
||||
|
||||
monkeypatch.setattr("services.mesh.mesh_wormhole_prekey.urllib.request.urlopen", _urlopen)
|
||||
monkeypatch.setattr(
|
||||
"services.mesh.mesh_wormhole_prekey._fetch_dm_prekey_bundle_from_peer_lookup",
|
||||
lambda *_args, **_kwargs: {"ok": False, "detail": "peer prekey lookup unavailable"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.mesh.mesh_wormhole_prekey._fetch_dm_prekey_bundle_from_public_lookup",
|
||||
_public_lookup,
|
||||
)
|
||||
|
||||
from services.mesh.mesh_wormhole_prekey import fetch_dm_prekey_bundle
|
||||
|
||||
@@ -807,6 +788,16 @@ class TestFetchPrekeyBundleByLookup:
|
||||
monkeypatch.setenv("MESH_DEV_ALLOW_LEGACY_COMPAT", "true")
|
||||
monkeypatch.setenv("MESH_ALLOW_LEGACY_AGENT_ID_LOOKUP_UNTIL", "2026-06-01")
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.setattr(
|
||||
mesh_wormhole_prekey,
|
||||
"_validate_bundle_record",
|
||||
lambda *_args, **_kwargs: (True, ""),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mesh_wormhole_prekey,
|
||||
"legacy_agent_id_lookup_blocked",
|
||||
lambda: False,
|
||||
)
|
||||
mesh_wormhole_prekey._WARNED_LEGACY_PREKEY_LOOKUPS.clear()
|
||||
caplog.clear()
|
||||
caplog.set_level("WARNING")
|
||||
@@ -874,3 +865,55 @@ class TestFetchPrekeyBundleByLookup:
|
||||
)
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_invite_lookup_peer_order_prefers_active_over_bootstrap(monkeypatch):
|
||||
from services.mesh import mesh_wormhole_prekey as prekey_mod
|
||||
|
||||
monkeypatch.setenv(
|
||||
"MESH_BOOTSTRAP_SEED_PEERS",
|
||||
"http://seed-a.onion:8000,http://seed-b.onion:8000,http://seed-c.onion:8000,http://seed-d.onion:8000",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.mesh.mesh_router.active_sync_peer_urls",
|
||||
lambda: [
|
||||
"http://active-peer.onion:8000",
|
||||
"http://another-active.onion:8000",
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
prekey_mod,
|
||||
"_discovered_push_peer_urls",
|
||||
lambda **kwargs: [],
|
||||
)
|
||||
get_settings.cache_clear()
|
||||
|
||||
ordered = prekey_mod._prioritized_invite_lookup_peer_urls(
|
||||
preferred=["http://pinned-peer.onion:8000"],
|
||||
)
|
||||
|
||||
assert ordered[0] == "http://pinned-peer.onion:8000"
|
||||
assert ordered[1:3] == [
|
||||
"http://active-peer.onion:8000",
|
||||
"http://another-active.onion:8000",
|
||||
]
|
||||
assert ordered[-prekey_mod._INVITE_LOOKUP_MAX_BOOTSTRAP_PEERS:] == [
|
||||
"http://seed-a.onion:8000",
|
||||
"http://seed-b.onion:8000",
|
||||
"http://seed-c.onion:8000",
|
||||
]
|
||||
assert "http://seed-d.onion:8000" not in ordered
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_invite_export_includes_lookup_peer_url(tmp_path, monkeypatch):
|
||||
_isolated_invite_state(tmp_path, monkeypatch)
|
||||
monkeypatch.setenv("MESH_PUBLIC_PEER_URL", "http://owner-node.onion:8000")
|
||||
|
||||
from services.mesh.mesh_wormhole_identity import export_wormhole_dm_invite
|
||||
|
||||
exported = export_wormhole_dm_invite(label="routing-test")
|
||||
payload = dict(exported.get("invite", {}).get("payload") or {})
|
||||
|
||||
assert payload.get("prekey_lookup_handle")
|
||||
assert payload.get("lookup_peer_url") == "http://owner-node.onion:8000"
|
||||
|
||||
@@ -71,7 +71,11 @@ def test_dispatcher_chooses_dm_relay_when_direct_path_unavailable_but_lane_floor
|
||||
assert len(deposit_calls) == 1
|
||||
|
||||
|
||||
def test_dispatcher_does_not_release_dm_below_private_strong():
|
||||
def test_dispatcher_does_not_release_dm_below_private_transitional_when_rns_disabled(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"services.wormhole_supervisor.get_wormhole_state",
|
||||
lambda: {"rns_enabled": False},
|
||||
)
|
||||
result = attempt_private_release(
|
||||
lane="dm",
|
||||
current_tier="private_control_only",
|
||||
@@ -80,7 +84,22 @@ def test_dispatcher_does_not_release_dm_below_private_strong():
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["no_acceptable_path"] is True
|
||||
assert result["policy_reason_code"] == "dm_release_waiting_for_private_strong"
|
||||
assert result["policy_reason_code"] == "dm_release_waiting_for_private_transitional"
|
||||
assert result["required_tier"] == "private_transitional"
|
||||
|
||||
|
||||
def test_dispatcher_still_requires_private_strong_when_rns_enabled(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"services.wormhole_supervisor.get_wormhole_state",
|
||||
lambda: {"rns_enabled": True},
|
||||
)
|
||||
result = attempt_private_release(
|
||||
lane="dm",
|
||||
current_tier="private_transitional",
|
||||
payload={"msg_id": "dm-transitional"},
|
||||
)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["required_tier"] == "private_strong"
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
@@ -180,6 +181,31 @@ def test_private_dm_hashchain_rejects_non_sealed_ciphertext_shape(tmp_path, monk
|
||||
raise AssertionError("private DM append accepted non-base64 ciphertext")
|
||||
|
||||
|
||||
def test_private_dm_hashchain_accepts_x3dh1_prefixed_ciphertext(tmp_path, monkeypatch):
|
||||
inf = _fresh_infonet(tmp_path, monkeypatch)
|
||||
private_key, public_key, node_id = _keypair()
|
||||
envelope = {
|
||||
"h": {"ik_pub": "aGVsbG8=", "ek_pub": "d29ybGQ=", "spk_id": 1, "otk_id": 0},
|
||||
"ct": base64.b64encode(b"\x00" * 32).decode("ascii"),
|
||||
}
|
||||
payload = _payload(msg_id="dm-x3dh-1")
|
||||
payload["ciphertext"] = "x3dh1:" + base64.b64encode(
|
||||
json.dumps(envelope, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).decode("ascii")
|
||||
event = inf.append_private_dm_message(
|
||||
node_id=node_id,
|
||||
payload=payload,
|
||||
signature=_signature(private_key, node_id, 1, payload),
|
||||
sequence=1,
|
||||
public_key=public_key,
|
||||
public_key_algo="Ed25519",
|
||||
protocol_version=mesh_protocol.PROTOCOL_VERSION,
|
||||
timestamp=float(payload["timestamp"]),
|
||||
)
|
||||
assert event["event_type"] == "dm_message"
|
||||
assert str(event["payload"]["ciphertext"]).startswith("x3dh1:")
|
||||
|
||||
|
||||
def test_hydrate_dm_relay_from_chain_delivers_to_poll_claim(tmp_path, monkeypatch):
|
||||
inf = _fresh_infonet(tmp_path / "chain", monkeypatch)
|
||||
relay = _fresh_relay(tmp_path / "relay", monkeypatch)
|
||||
|
||||
@@ -216,19 +216,19 @@ def test_authenticated_wormhole_status_can_request_diagnostic_private_delivery_s
|
||||
assert item["meta"]["peer_id"] == "bob"
|
||||
|
||||
|
||||
def test_dm_pubkey_lookup_token_ordinary_response_omits_resolved_agent_id(monkeypatch):
|
||||
def test_dm_pubkey_lookup_token_ordinary_response_includes_resolved_agent_id(monkeypatch):
|
||||
monkeypatch.setattr(main, "_check_scoped_auth", lambda *_args, **_kwargs: (False, "no"))
|
||||
monkeypatch.setattr(main, "_is_debug_test_request", lambda *_args, **_kwargs: False)
|
||||
monkeypatch.setattr(
|
||||
"services.mesh.mesh_dm_relay.dm_relay.get_dh_key_by_lookup",
|
||||
lambda _lookup_token: ({"dh_pub": "pub", "dh_algo": "X25519"}, "peer-123"),
|
||||
lambda _lookup_token: ({"dh_pub_key": "pub", "dh_algo": "X25519"}, "peer-123"),
|
||||
)
|
||||
|
||||
result = asyncio.run(main.dm_get_pubkey(_request("/api/mesh/dm/pubkey"), lookup_token="invite-handle"))
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["lookup_mode"] == "invite_lookup_handle"
|
||||
assert "agent_id" not in result
|
||||
assert result["agent_id"] == "peer-123"
|
||||
|
||||
|
||||
def test_dm_pubkey_lookup_token_diagnostic_response_exposes_resolved_agent_id(monkeypatch):
|
||||
@@ -249,7 +249,7 @@ def test_dm_pubkey_lookup_token_diagnostic_response_exposes_resolved_agent_id(mo
|
||||
assert result["agent_id"] == "peer-123"
|
||||
|
||||
|
||||
def test_prekey_bundle_lookup_token_ordinary_response_omits_resolved_agent_id(monkeypatch):
|
||||
def test_prekey_bundle_lookup_token_ordinary_response_includes_resolved_agent_id(monkeypatch):
|
||||
monkeypatch.setattr(main, "_check_scoped_auth", lambda *_args, **_kwargs: (False, "no"))
|
||||
monkeypatch.setattr(main, "_is_debug_test_request", lambda *_args, **_kwargs: False)
|
||||
monkeypatch.setattr(
|
||||
@@ -273,7 +273,7 @@ def test_prekey_bundle_lookup_token_ordinary_response_omits_resolved_agent_id(mo
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["lookup_mode"] == "invite_lookup_handle"
|
||||
assert "agent_id" not in result
|
||||
assert result["agent_id"] == "peer-456"
|
||||
assert result["trust_fingerprint"] == "aa" * 16
|
||||
|
||||
|
||||
|
||||
@@ -465,6 +465,45 @@ def test_user_facing_status_mapping_remains_plain_language_and_stable():
|
||||
assert evaluate_network_release("dm", "private_strong").status_label == "Delivered privately"
|
||||
|
||||
|
||||
def test_queued_dm_releases_at_private_transitional_when_rns_disabled(monkeypatch):
|
||||
deposit_calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"services.wormhole_supervisor.get_wormhole_state",
|
||||
lambda: {"rns_enabled": False},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.wormhole_supervisor.get_transport_tier",
|
||||
lambda: "private_transitional",
|
||||
)
|
||||
monkeypatch.setattr(mesh_private_release_worker, "_secure_dm_enabled", lambda: False)
|
||||
monkeypatch.setattr(mesh_private_release_worker, "_rns_private_dm_ready", lambda: False)
|
||||
monkeypatch.setattr(mesh_private_release_worker, "_maybe_apply_dm_relay_jitter", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
"services.mesh.mesh_dm_relay.dm_relay.deposit",
|
||||
lambda **kwargs: deposit_calls.append(kwargs) or {"ok": True, "msg_id": kwargs["msg_id"]},
|
||||
)
|
||||
|
||||
queued = main._queue_dm_release(
|
||||
current_tier="private_transitional",
|
||||
payload={
|
||||
"msg_id": "dm-tor-only-1",
|
||||
"sender_id": "alice",
|
||||
"recipient_id": "bob",
|
||||
"delivery_class": "request",
|
||||
"sender_token_hash": "abc123",
|
||||
"ciphertext": "x3dh1:ciphertext",
|
||||
"timestamp": 1,
|
||||
},
|
||||
)
|
||||
|
||||
mesh_private_release_worker.private_release_worker.run_once()
|
||||
|
||||
item = _outbox_item(queued["outbox_id"], exposure="diagnostic")
|
||||
assert len(deposit_calls) == 1
|
||||
assert item["release_state"] == "delivered"
|
||||
|
||||
|
||||
def test_outbox_exposes_publishing_state_without_claiming_delivery():
|
||||
item = mesh_private_outbox.private_delivery_outbox.enqueue(
|
||||
lane="dm",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from services.mesh import mesh_swarm_runtime as swarm
|
||||
|
||||
|
||||
def test_join_swarm_with_retries_succeeds_on_second_attempt(monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_announce(*, force=True):
|
||||
calls["n"] += 1
|
||||
if calls["n"] < 2:
|
||||
return {"ok": False, "results": [{"ok": False, "status_code": 503}]}
|
||||
return {"ok": True, "results": [{"ok": True, "status_code": 200}]}
|
||||
|
||||
def fake_manifest(*, force=True, now=None):
|
||||
if calls["n"] < 2:
|
||||
return {"ok": False, "detail": "manifest fetch failed"}
|
||||
return {"ok": True, "peer_count": 3, "merged_peer_count": 3}
|
||||
|
||||
monkeypatch.setattr(swarm, "announce_local_peer_to_seeds", fake_announce)
|
||||
monkeypatch.setattr(swarm, "refresh_swarm_manifest_from_seeds", fake_manifest)
|
||||
monkeypatch.setattr(swarm.time, "sleep", lambda _s: None)
|
||||
|
||||
joined = swarm.join_swarm_with_retries(attempts=3, delay_s=1.0)
|
||||
|
||||
assert joined["ok"] is True
|
||||
assert joined["attempts"] == 2
|
||||
@@ -0,0 +1,14 @@
|
||||
def test_agent_shell_settings_roundtrip(tmp_path, monkeypatch):
|
||||
from services import agent_shell_settings
|
||||
|
||||
settings_path = tmp_path / "agent_shell_settings.json"
|
||||
workdir = tmp_path / "workspace"
|
||||
workdir.mkdir()
|
||||
|
||||
monkeypatch.setattr(agent_shell_settings, "_SETTINGS_FILE", settings_path)
|
||||
|
||||
assert agent_shell_settings.get_agent_shell_settings()["working_directory"]
|
||||
|
||||
saved = agent_shell_settings.set_agent_shell_working_directory(str(workdir))
|
||||
assert saved["working_directory"] == str(workdir.resolve())
|
||||
assert agent_shell_settings.get_agent_shell_settings()["working_directory"] == str(workdir.resolve())
|
||||
@@ -0,0 +1,81 @@
|
||||
"""OpenClaw Infonet delegation — command allowlist and dispatch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from services.openclaw_channel import (
|
||||
READ_COMMANDS,
|
||||
WRITE_COMMANDS,
|
||||
_dispatch_command,
|
||||
allowed_commands,
|
||||
)
|
||||
from services.openclaw_channel import CommandChannel
|
||||
|
||||
|
||||
INFONET_READS = frozenset({
|
||||
"infonet_status",
|
||||
"list_gates",
|
||||
"read_gate_messages",
|
||||
"poll_dms",
|
||||
})
|
||||
|
||||
INFONET_WRITES = frozenset({
|
||||
"ensure_infonet_ready",
|
||||
"join_infonet_swarm",
|
||||
"post_gate_message",
|
||||
"cast_vote",
|
||||
"send_dm",
|
||||
})
|
||||
|
||||
|
||||
def test_infonet_commands_in_allowlists():
|
||||
assert INFONET_READS <= READ_COMMANDS
|
||||
assert INFONET_WRITES <= WRITE_COMMANDS
|
||||
|
||||
|
||||
def test_restricted_tier_allows_infonet_reads_only():
|
||||
allowed = allowed_commands("restricted")
|
||||
assert INFONET_READS <= allowed
|
||||
assert not (INFONET_WRITES & allowed)
|
||||
|
||||
|
||||
def test_full_tier_allows_infonet_writes():
|
||||
allowed = allowed_commands("full")
|
||||
assert INFONET_WRITES <= allowed
|
||||
|
||||
|
||||
def test_restricted_tier_blocks_post_gate_message():
|
||||
channel = CommandChannel()
|
||||
result = channel.submit_command("post_gate_message", {"gate_id": "infonet", "plaintext": "hi"})
|
||||
assert result["ok"] is False
|
||||
assert "full access tier" in str(result.get("detail", ""))
|
||||
|
||||
|
||||
def test_dispatch_infonet_status_mocked():
|
||||
fake = {"ok": True, "chain": {"length": 3}, "valid": True}
|
||||
with patch("services.openclaw_infonet.get_infonet_status", return_value=fake):
|
||||
result = _dispatch_command("infonet_status", {})
|
||||
assert result == fake
|
||||
|
||||
|
||||
def test_dispatch_list_gates_mocked():
|
||||
fake = {"ok": True, "gates": [{"id": "infonet"}]}
|
||||
with patch("services.openclaw_infonet.list_gates", return_value=fake):
|
||||
result = _dispatch_command("list_gates", {})
|
||||
assert result["gates"][0]["id"] == "infonet"
|
||||
|
||||
|
||||
def test_dispatch_post_gate_message_mocked():
|
||||
fake = {"ok": True, "event_id": "evt-test"}
|
||||
with patch("services.openclaw_infonet.post_gate_message", return_value=fake):
|
||||
result = _dispatch_command(
|
||||
"post_gate_message",
|
||||
{"gate_id": "infonet", "plaintext": "agent bulletin"},
|
||||
)
|
||||
assert result["event_id"] == "evt-test"
|
||||
|
||||
|
||||
def test_cast_vote_rejects_invalid_vote():
|
||||
result = _dispatch_command("cast_vote", {"target_id": "!sb_test", "vote": 2})
|
||||
assert result["ok"] is False
|
||||
@@ -466,15 +466,55 @@ def test_find_entity_prioritizes_aircraft_operator_and_callsign(sample_store, mo
|
||||
|
||||
monkeypatch.setattr(telemetry, "get_data_version", lambda: 130)
|
||||
|
||||
by_operator = telemetry.find_entity(query="patriots jet", limit=5)
|
||||
by_operator = telemetry.find_entity(owner="Patriots", limit=5)
|
||||
assert by_operator["best_match"]["group"] == "aircraft"
|
||||
assert by_operator["best_match"]["label"] == "OXE2116"
|
||||
|
||||
fuzzy = telemetry.find_entity(query="patriots jet", limit=5, fallback_search=True)
|
||||
assert fuzzy["best_match"]["group"] == "aircraft"
|
||||
|
||||
by_callsign = telemetry.find_entity(callsign="AF1", entity_type="aircraft", limit=5)
|
||||
assert by_callsign["best_match"]["callsign"] == "AF1"
|
||||
assert by_callsign["best_match"]["alert_operator"] == "POTUS"
|
||||
|
||||
|
||||
def test_find_entity_skips_fuzzy_when_exact_match(sample_store, monkeypatch):
|
||||
import services.telemetry as telemetry
|
||||
|
||||
monkeypatch.setattr(telemetry, "get_data_version", lambda: 200)
|
||||
calls: list[str] = []
|
||||
|
||||
def _fake_search(*_args, **_kwargs):
|
||||
calls.append("search")
|
||||
return {"results": [], "searched_layers": []}
|
||||
|
||||
monkeypatch.setattr(telemetry, "search_telemetry", _fake_search)
|
||||
|
||||
result = telemetry.find_entity(callsign="AF1", entity_type="aircraft", fallback_search=False)
|
||||
assert result["best_match"]["callsign"] == "AF1"
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_find_entity_fuzzy_only_when_fallback_or_empty(sample_store, monkeypatch):
|
||||
import services.telemetry as telemetry
|
||||
|
||||
monkeypatch.setattr(telemetry, "get_data_version", lambda: 201)
|
||||
calls: list[str] = []
|
||||
|
||||
def _fake_search(*_args, **_kwargs):
|
||||
calls.append("search")
|
||||
return {"results": [], "searched_layers": []}
|
||||
|
||||
monkeypatch.setattr(telemetry, "search_telemetry", _fake_search)
|
||||
|
||||
empty = telemetry.find_entity(query="zzzznonexistententity", fallback_search=False)
|
||||
assert empty["best_match"] is None
|
||||
assert calls == []
|
||||
|
||||
telemetry.find_entity(query="zzzznonexistententity", fallback_search=True)
|
||||
assert calls == ["search"]
|
||||
|
||||
|
||||
def test_find_entity_prioritizes_maritime_owner_and_identifiers(sample_store, monkeypatch):
|
||||
import services.telemetry as telemetry
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""OpenClaw routing, playbooks, and expensive-command gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from services.openclaw_channel import _dispatch_command
|
||||
from services.openclaw_routing import (
|
||||
EXPENSIVE_COMMANDS,
|
||||
plan_playbook,
|
||||
requires_expensive_confirm,
|
||||
route_query,
|
||||
routing_manifest,
|
||||
)
|
||||
|
||||
|
||||
def test_routing_manifest_has_agent_surface():
|
||||
manifest = routing_manifest()
|
||||
assert manifest["preferred_entry"] == "route_query"
|
||||
assert manifest["client_wrapper"] == "ShadowBrokerClient.ask"
|
||||
assert "search_telemetry" in manifest["expensive_commands"]
|
||||
assert "hot_snapshot" in manifest["playbooks"]
|
||||
|
||||
|
||||
def test_route_query_tail_number():
|
||||
plan = route_query("track N628TS position")
|
||||
assert plan["recommended"]["cmd"] == "find_flights"
|
||||
assert plan["recommended"]["args"]["registration"] == "N628TS"
|
||||
assert "search_telemetry" in plan["avoid"]
|
||||
|
||||
|
||||
def test_route_query_callsign():
|
||||
plan = route_query("where is AF1 right now")
|
||||
assert plan["recommended"]["cmd"] == "find_flights"
|
||||
assert plan["recommended"]["args"]["callsign"] == "AF1"
|
||||
|
||||
|
||||
def test_route_query_news():
|
||||
plan = route_query("telegram news about Iran tanker")
|
||||
assert plan["recommended"]["cmd"] == "search_news"
|
||||
|
||||
|
||||
def test_route_query_cve():
|
||||
plan = route_query("details for CVE-2024-1234")
|
||||
assert plan["recommended"]["cmd"] == "osint_lookup"
|
||||
assert plan["recommended"]["args"]["tool"] == "cve"
|
||||
|
||||
|
||||
def test_route_query_default_entity():
|
||||
plan = route_query("where is the patriots jet")
|
||||
assert plan["recommended"]["cmd"] == "find_entity"
|
||||
assert plan["recommended"]["args"]["query"]
|
||||
|
||||
|
||||
def test_expensive_gate_blocks_search_telemetry():
|
||||
assert requires_expensive_confirm("search_telemetry", {"query": "test"})
|
||||
assert not requires_expensive_confirm(
|
||||
"search_telemetry",
|
||||
{"query": "test", "confirm_expensive": True},
|
||||
)
|
||||
result = _dispatch_command("search_telemetry", {"query": "test"})
|
||||
assert result["ok"] is False
|
||||
assert result.get("code") == "expensive_command_blocked"
|
||||
|
||||
|
||||
def test_expensive_gate_blocks_get_telemetry():
|
||||
result = _dispatch_command("get_telemetry", {})
|
||||
assert result["ok"] is False
|
||||
assert result.get("code") == "expensive_command_blocked"
|
||||
|
||||
|
||||
def test_dispatch_route_query():
|
||||
result = _dispatch_command("route_query", {"text": "news about carrier strike"})
|
||||
assert result["ok"] is True
|
||||
assert result["data"]["recommended"]["cmd"] == "search_news"
|
||||
|
||||
|
||||
def test_dispatch_run_playbook_hot_snapshot():
|
||||
result = _dispatch_command("run_playbook", {"name": "status_check"})
|
||||
assert result["ok"] is True
|
||||
cmds = [item["cmd"] for item in result["data"]["results"]]
|
||||
assert cmds == ["channel_status", "get_summary"]
|
||||
|
||||
|
||||
def test_plan_playbook_track_snapshot_requires_query():
|
||||
plan = plan_playbook("track_snapshot", {})
|
||||
assert plan["ok"] is False
|
||||
plan_ok = plan_playbook("track_snapshot", {"query": "patriots jet"})
|
||||
assert plan_ok["ok"] is True
|
||||
assert plan_ok["batch"][0]["cmd"] == "find_entity"
|
||||
|
||||
|
||||
def test_expensive_commands_set():
|
||||
assert "get_report" in EXPENSIVE_COMMANDS
|
||||
assert "route_query" not in EXPENSIVE_COMMANDS
|
||||
|
||||
|
||||
def test_routing_manifest_includes_infonet_hints():
|
||||
manifest = routing_manifest()
|
||||
recipes = " ".join(item.get("use", "") for item in manifest.get("recipes", []))
|
||||
assert "post_gate_message" in recipes
|
||||
writes = manifest.get("agent_surface", {}).get("writes", [])
|
||||
assert "post_gate_message" in writes
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Tor hidden service must always publish the mesh SOCKS port."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from services import tor_hidden_service as tor_svc
|
||||
|
||||
|
||||
def test_write_torrc_always_includes_socks_port(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(tor_svc, "TOR_DIR", tmp_path)
|
||||
monkeypatch.setattr(tor_svc, "TORRC_PATH", tmp_path / "torrc")
|
||||
monkeypatch.setattr(tor_svc, "TOR_DATA_DIR", tmp_path / "data")
|
||||
|
||||
tor_svc._write_torrc(target_port=8000, socks_port=19050)
|
||||
|
||||
content = tor_svc.TORRC_PATH.read_text(encoding="utf-8")
|
||||
assert "SocksPort 19050" in content
|
||||
assert "HiddenServicePort 8000 127.0.0.1:8000" in content
|
||||
|
||||
|
||||
def test_torrc_has_socks_port_detects_missing_line(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(tor_svc, "TORRC_PATH", tmp_path / "torrc")
|
||||
tor_svc.TORRC_PATH.write_text("HiddenServicePort 8000 127.0.0.1:8000\n", encoding="utf-8")
|
||||
|
||||
assert tor_svc._torrc_has_socks_port(9050) is False
|
||||
|
||||
tor_svc.TORRC_PATH.write_text("SocksPort 9050\n", encoding="utf-8")
|
||||
assert tor_svc._torrc_has_socks_port(9050) is True
|
||||
|
||||
|
||||
def test_local_socks_handshake_ready_accepts_valid_response(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class FakeSock:
|
||||
def __init__(self) -> None:
|
||||
self._sent = b""
|
||||
|
||||
def settimeout(self, timeout: float) -> None:
|
||||
return None
|
||||
|
||||
def sendall(self, payload: bytes) -> None:
|
||||
self._sent = payload
|
||||
|
||||
def recv(self, size: int) -> bytes:
|
||||
assert self._sent == b"\x05\x01\x00"
|
||||
return b"\x05\x00"
|
||||
|
||||
def __enter__(self) -> "FakeSock":
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
socket,
|
||||
"create_connection",
|
||||
lambda *_args, **_kwargs: FakeSock(),
|
||||
)
|
||||
assert tor_svc._local_socks_handshake_ready(9050) is True
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@shadowbroker/desktop-shell",
|
||||
"version": "0.9.82",
|
||||
"version": "0.9.83",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@shadowbroker/desktop-shell",
|
||||
"version": "0.9.82",
|
||||
"version": "0.9.83",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shadowbroker/desktop-shell",
|
||||
"version": "0.9.82",
|
||||
"version": "0.9.83",
|
||||
"private": true,
|
||||
"description": "ShadowBroker desktop shell packaging, runtime bridge, and release tooling",
|
||||
"scripts": {
|
||||
|
||||
@@ -132,7 +132,7 @@ try {
|
||||
) {
|
||||
$env:TAURI_SIGNING_PRIVATE_KEY = Get-Content -LiteralPath $localUpdaterKey -Raw
|
||||
if (($null -eq $env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD) -and (Test-Path $localUpdaterKeyPassword)) {
|
||||
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = Get-Content -LiteralPath $localUpdaterKeyPassword -Raw
|
||||
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = (Get-Content -LiteralPath $localUpdaterKeyPassword -Raw).Trim()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -4201,7 +4201,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shadowbroker-tauri-shell"
|
||||
version = "0.9.82"
|
||||
version = "0.9.83"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shadowbroker-tauri-shell"
|
||||
version = "0.9.82"
|
||||
version = "0.9.83"
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ShadowBroker",
|
||||
"version": "0.9.82",
|
||||
"version": "0.9.83",
|
||||
"identifier": "com.shadowbroker.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../../../frontend/out",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Lean local backend for live DM E2E — Infonet swarm + wormhole without OSINT fetchers.
|
||||
# Pair with docker-compose.override.yml (local build + fleet secrets).
|
||||
services:
|
||||
backend:
|
||||
environment:
|
||||
MESH_ONLY: "true"
|
||||
# DM E2E uses direct onion relay_push_peer_urls — skip fleet hashchain sync
|
||||
# (hundreds of dead manifest peers wedge /api/wormhole/status during Tor warmup).
|
||||
SHADOWBROKER_MESH_NODE_RUNTIME: "false"
|
||||
MESH_ARTI_ENABLED: "true"
|
||||
MESH_INFONET_FLEET_JOIN: "false"
|
||||
MESH_INFONET_FLEET_JOIN_DISABLED: "true"
|
||||
PRIVACY_CORE_DEV_OVERRIDE: "true"
|
||||
MESH_RELAY_PUSH_TIMEOUT_S: "300"
|
||||
MESH_RELAY_MAX_FAILURES: "12"
|
||||
MESH_DM_PENDING_PER_SENDER_LIMIT: "8"
|
||||
MESH_DM_PERSIST_SPOOL: "true"
|
||||
WORMHOLE_STARTUP_DEADLINE_S: "90"
|
||||
MESH_WORMHOLE_TRUST_FILE_READY: "true"
|
||||
@@ -0,0 +1,29 @@
|
||||
# Auto-loaded by `docker compose` — build from local source instead of pulling stale GHCR images.
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./backend/Dockerfile
|
||||
environment:
|
||||
# Private Infonet swarm: only the seed onion is required in config.
|
||||
# Other participants (e.g. Pete) are discovered via signed manifest pull.
|
||||
MESH_ARTI_ENABLED: "true"
|
||||
MESH_ARTI_SOCKS_PORT: "9050"
|
||||
MESH_SYNC_TIMEOUT_S: "45"
|
||||
MESH_RELAY_PUSH_TIMEOUT_S: "45"
|
||||
MESH_SYNC_MAX_PEERS_PER_CYCLE: "5"
|
||||
MESH_INFONET_ALLOW_CLEARNET_SYNC: "false"
|
||||
MESH_PUBLIC_PEER_URL: ""
|
||||
MESH_BOOTSTRAP_SEED_PEERS: "http://gqpbunqbgtkcqilvclm3xrkt3zowjyl3s62kkktvojgvxzizamvbrqid.onion:8000"
|
||||
MESH_RELAY_PEERS: ""
|
||||
MESH_DEFAULT_SYNC_PEERS: ""
|
||||
MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY: "ul1d0kj/ODPIp0OhHzX8eLAVXzJ3CVvzW1vn2IC6q3I="
|
||||
MESH_SWARM_MANIFEST_PULL_INTERVAL_S: "300"
|
||||
# Fleet testnet HMAC — overrides stale per-node .env so announce/push auth matches seed.
|
||||
MESH_PEER_PUSH_SECRET: "b7GoqsvoUD9MV7tyt0ZOzMptLA84QG6KCfaV9nDqz5Y"
|
||||
# Dev fleet: allow wormhole child agent to start without release attestation.
|
||||
PRIVACY_CORE_DEV_OVERRIDE: "true"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
@@ -0,0 +1,22 @@
|
||||
# Fleet participant — Infonet swarm + DM relay. OSINT fetchers stay on unless
|
||||
# MESH_ONLY=true in .env (lean mode for DM-only E2E / low-memory nodes).
|
||||
services:
|
||||
backend:
|
||||
environment:
|
||||
MESH_ONLY: "${MESH_ONLY:-false}"
|
||||
SHADOWBROKER_MESH_NODE_RUNTIME: "${SHADOWBROKER_MESH_NODE_RUNTIME:-true}"
|
||||
MESH_ARTI_ENABLED: "true"
|
||||
MESH_INFONET_FLEET_JOIN: "${MESH_INFONET_FLEET_JOIN:-true}"
|
||||
MESH_INFONET_FLEET_JOIN_DISABLED: "${MESH_INFONET_FLEET_JOIN_DISABLED:-false}"
|
||||
MESH_WORMHOLE_TRUST_FILE_READY: "true"
|
||||
PRIVACY_CORE_ALLOWED_SHA256: "5dd4b65a317277917842b12d7b430d49913789982ba906bd9a0ea6006d40e28a"
|
||||
MESH_RELAY_PUSH_TIMEOUT_S: "300"
|
||||
MESH_RELAY_MAX_FAILURES: "12"
|
||||
MESH_DM_PENDING_PER_SENDER_LIMIT: "8"
|
||||
MESH_DM_PERSIST_SPOOL: "true"
|
||||
WORMHOLE_STARTUP_DEADLINE_S: "90"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "1"
|
||||
memory: 4G
|
||||
@@ -1,13 +1,15 @@
|
||||
# Minimal relay-node compose — backend only, no frontend needed.
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./backend/Dockerfile
|
||||
image: ghcr.io/bigbodycobain/shadowbroker-backend:latest
|
||||
container_name: shadowbroker-relay
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
env_file: .env
|
||||
environment:
|
||||
# Keep Tor wormhole up across redeploys (no NODE UI on headless relay).
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE: "true"
|
||||
MESH_ARTI_ENABLED: "true"
|
||||
volumes:
|
||||
- relay_data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
+8
-1
@@ -29,14 +29,21 @@ services:
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
||||
# Private Infonet bootstrap seeds. Seeds are discovery hints, not fixed roots.
|
||||
- MESH_BOOTSTRAP_SEED_PEERS=${MESH_BOOTSTRAP_SEED_PEERS:-http://gqpbunqbgtkcqilvclm3xrkt3zowjyl3s62kkktvojgvxzizamvbrqid.onion:8000}
|
||||
- MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY=${MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY:-ul1d0kj/ODPIp0OhHzX8eLAVXzJ3CVvzW1vn2IC6q3I=}
|
||||
- MESH_INFONET_FLEET_JOIN=${MESH_INFONET_FLEET_JOIN:-true}
|
||||
- MESH_DEFAULT_SYNC_PEERS=${MESH_DEFAULT_SYNC_PEERS:-}
|
||||
- MESH_SYNC_TIMEOUT_S=${MESH_SYNC_TIMEOUT_S:-5}
|
||||
- MESH_SYNC_TIMEOUT_S=${MESH_SYNC_TIMEOUT_S:-45}
|
||||
- MESH_RELAY_PUSH_TIMEOUT_S=${MESH_RELAY_PUSH_TIMEOUT_S:-45}
|
||||
- MESH_SYNC_MAX_PEERS_PER_CYCLE=${MESH_SYNC_MAX_PEERS_PER_CYCLE:-5}
|
||||
- MESH_SWARM_MANIFEST_PULL_INTERVAL_S=${MESH_SWARM_MANIFEST_PULL_INTERVAL_S:-300}
|
||||
# Explicitly opt into HTTPS/IP-based peer sync. Default remains private transports only.
|
||||
- MESH_INFONET_ALLOW_CLEARNET_SYNC=${MESH_INFONET_ALLOW_CLEARNET_SYNC:-false}
|
||||
# Tor/Arti SOCKS transport for private .onion Infonet sync.
|
||||
- MESH_ARTI_ENABLED=${MESH_ARTI_ENABLED:-false}
|
||||
- MESH_ARTI_SOCKS_PORT=${MESH_ARTI_SOCKS_PORT:-9050}
|
||||
# Lean Infonet participant (meshnode.sh equivalent). Skips global OSINT fetchers.
|
||||
- MESH_ONLY=${MESH_ONLY:-}
|
||||
- SHADOWBROKER_MESH_NODE_RUNTIME=${SHADOWBROKER_MESH_NODE_RUNTIME:-}
|
||||
# Operator-trusted sync/push peers. Leave empty unless you control the peer secret on both sides.
|
||||
- MESH_RELAY_PEERS=${MESH_RELAY_PEERS:-}
|
||||
- MESH_PUBLIC_PEER_URL=${MESH_PUBLIC_PEER_URL:-}
|
||||
|
||||
Generated
+19
-3
@@ -1,16 +1,18 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.9.82",
|
||||
"version": "0.9.83",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "0.9.82",
|
||||
"version": "0.9.83",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"lucide-react": "^0.575.0",
|
||||
@@ -3251,6 +3253,21 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -7374,7 +7391,6 @@
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.9.82",
|
||||
"version": "0.9.83",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev-all.cjs",
|
||||
@@ -23,6 +23,8 @@
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"lucide-react": "^0.575.0",
|
||||
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
} from '@/lib/updateRuntime';
|
||||
|
||||
const RELEASE: GitHubLatestRelease = {
|
||||
html_url: 'https://github.com/BigBodyCobain/Shadowbroker/releases/tag/v0.9.82',
|
||||
html_url: 'https://github.com/BigBodyCobain/Shadowbroker/releases/tag/v0.9.83',
|
||||
assets: [
|
||||
{ name: 'ShadowBroker_0.9.82_x64_en-US.msi', browser_download_url: 'https://example.test/windows.msi' },
|
||||
{ name: 'ShadowBroker_0.9.82_x64-setup.exe', browser_download_url: 'https://example.test/windows-setup.exe' },
|
||||
{ name: 'ShadowBroker_0.9.82_aarch64.dmg', browser_download_url: 'https://example.test/macos.dmg' },
|
||||
{ name: 'ShadowBroker_0.9.82_amd64.AppImage', browser_download_url: 'https://example.test/linux.AppImage' },
|
||||
{ name: 'ShadowBroker_0.9.83_x64_en-US.msi', browser_download_url: 'https://example.test/windows.msi' },
|
||||
{ name: 'ShadowBroker_0.9.83_x64-setup.exe', browser_download_url: 'https://example.test/windows-setup.exe' },
|
||||
{ name: 'ShadowBroker_0.9.83_aarch64.dmg', browser_download_url: 'https://example.test/macos.dmg' },
|
||||
{ name: 'ShadowBroker_0.9.83_amd64.AppImage', browser_download_url: 'https://example.test/linux.AppImage' },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
isLikelyDmShortAddress,
|
||||
parseDmInviteImportBlob,
|
||||
inviteFromParsedBlob,
|
||||
} from '@/mesh/dmConnect';
|
||||
|
||||
describe('dmConnect', () => {
|
||||
it('detects short lookup handles', () => {
|
||||
expect(isLikelyDmShortAddress('5881eb8705c9abc1234567890abcd')).toBe(true);
|
||||
expect(isLikelyDmShortAddress('{"type":"invite"}')).toBe(false);
|
||||
});
|
||||
|
||||
it('parses short address without JSON', () => {
|
||||
const parsed = parseDmInviteImportBlob('abcd1234ef567890abcd1234ef567890');
|
||||
expect(parsed.short_address).toBe('abcd1234ef567890abcd1234ef567890');
|
||||
});
|
||||
|
||||
it('unwraps nested invite objects', () => {
|
||||
const invite = { event_type: 'dm_invite', payload: {} };
|
||||
const parsed = inviteFromParsedBlob({ invite, version: 1 });
|
||||
expect(parsed).toEqual(invite);
|
||||
});
|
||||
});
|
||||
@@ -539,15 +539,16 @@ describe('GateView compat-decrypt UX', () => {
|
||||
);
|
||||
|
||||
expect(await screen.findByText('sealed')).toBeInTheDocument();
|
||||
expect(mocks.subscribeGateSessionStreamEvents).toHaveBeenCalled();
|
||||
expect(mocks.fetchWormholeGateKeyStatus).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
expect.objectContaining({ mode: 'session_stream' }),
|
||||
await waitFor(() => expect(mocks.subscribeGateSessionStreamEvents).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(mocks.fetchWormholeGateKeyStatus).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
expect.objectContaining({ mode: 'session_stream' }),
|
||||
),
|
||||
);
|
||||
expect(mocks.controlPlaneJson).not.toHaveBeenCalled();
|
||||
waitSnapshotSpy.mockClear();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(waitSnapshotSpy).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(waitSnapshotSpy).not.toHaveBeenCalled(), { timeout: 2000 });
|
||||
|
||||
streamEventListeners.forEach((listener) =>
|
||||
listener({
|
||||
@@ -560,12 +561,14 @@ describe('GateView compat-decrypt UX', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchSnapshotSpy).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
40,
|
||||
expect.objectContaining({ force: true, proofMode: 'session_stream' }),
|
||||
),
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(fetchSnapshotSpy).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
40,
|
||||
expect.objectContaining({ force: true, proofMode: 'session_stream' }),
|
||||
),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
expect(
|
||||
fetchMock.mock.calls.some(([input]) =>
|
||||
|
||||
@@ -114,23 +114,24 @@ describe('MeshChat behavior - policy wiring', () => {
|
||||
expect(controller).toContain('timer = setTimeout(() => void poll(classification.refreshCount), classification.delay);');
|
||||
});
|
||||
|
||||
it('dead-drop UI distinguishes invite-pinned trust from TOFU-only', () => {
|
||||
const index = readSource('../../components/MeshChat/index.tsx');
|
||||
expect(index).toContain('getContactTrustSummary');
|
||||
expect(index).toContain('INVITE PINNED');
|
||||
expect(index).toContain('TOFU ONLY');
|
||||
expect(index).toContain('anchored by an imported signed invite');
|
||||
expect(index).toContain('rootWitnessContinuityLabel');
|
||||
expect(index).toContain('RECOVER ROOT');
|
||||
expect(index).toContain('!selectedContactTrustSummary?.rootMismatch');
|
||||
it('dead-drop trust copy lives in shared hints and Infonet Messages', () => {
|
||||
const hints = readSource('../../mesh/meshPrivacyHints.ts');
|
||||
expect(hints).toContain('INVITE PINNED');
|
||||
expect(hints).toContain('FIRST CONTACT (TOFU ONLY)');
|
||||
expect(hints).toContain('anchored by an imported signed invite');
|
||||
const messages = readSource('../../components/InfonetTerminal/MessagesView.tsx');
|
||||
expect(messages).toContain('getContactTrustSummary');
|
||||
expect(messages).toContain('rootWitnessContinuityLabel');
|
||||
});
|
||||
|
||||
it('request UI does not route ordinary request flow through legacy add-contact lookup', () => {
|
||||
it('MeshChat Agent Shell tab keeps legacy dm-add guidance in MeshTerminal', () => {
|
||||
const index = readSource('../../components/MeshChat/index.tsx');
|
||||
expect(index).toContain('handleRequestComposerAction');
|
||||
expect(index).toContain('AgentShellPanel');
|
||||
expect(index).toContain('SHELL');
|
||||
expect(index).not.toContain('handleAddContact().catch(() =>');
|
||||
expect(index).toContain('dm add');
|
||||
expect(index).toContain('legacy migration');
|
||||
const terminal = readSource('../../components/MeshTerminal.tsx');
|
||||
expect(terminal).toContain('dm add');
|
||||
expect(terminal).toContain('legacy migration');
|
||||
});
|
||||
|
||||
it('controller blocks trust-new-key when the stable root changed', () => {
|
||||
|
||||
@@ -221,9 +221,17 @@ describe('MeshChat decomposition — export stability', () => {
|
||||
expect(index).toMatch(/export\s+default\s+MeshChat/);
|
||||
});
|
||||
|
||||
it('presentational shell exposes the gate resync affordance', () => {
|
||||
const index = readFile('index.tsx');
|
||||
expect(index).toContain('RESYNC GATE STATE');
|
||||
expect(index).toContain('handleResyncGateState(selectedGate)');
|
||||
it('gate resync is controller-owned and exposed via Infonet / Mesh Terminal surfaces', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
const panel = readFile('InfonetTerminalPanel.tsx');
|
||||
const terminal = fs.readFileSync(
|
||||
path.resolve(MESH_CHAT_DIR, '../MeshTerminal.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(controller).toContain('handleResyncGateState');
|
||||
expect(controller).toContain('resyncWormholeGateState');
|
||||
expect(panel).toContain('InfonetShell');
|
||||
expect(terminal).toContain('gate resync');
|
||||
expect(terminal).toContain('resyncWormholeGateState');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,12 @@ describe('fetchDmPublicKey lookup posture', () => {
|
||||
|
||||
it('uses invite lookup handles without enabling legacy agent-id lookup', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
json: async () => ({ ok: true, dh_pub_key: 'peer-dh', lookup_mode: 'invite_lookup_handle' }),
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
agent_id: '!sb_peer',
|
||||
dh_pub_key: 'peer-dh',
|
||||
lookup_mode: 'invite_lookup_handle',
|
||||
}),
|
||||
});
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
|
||||
@@ -39,6 +44,28 @@ describe('fetchDmPublicKey lookup posture', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to prekey-bundle when pubkey lookup lacks agent_id', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({ ok: true, dh_pub_key: 'peer-dh', lookup_mode: 'invite_lookup_handle' }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
agent_id: '!sb_peer',
|
||||
lookup_mode: 'invite_lookup_handle',
|
||||
bundle: { identity_dh_pub_key: 'peer-dh' },
|
||||
}),
|
||||
});
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
|
||||
const result = await mod.fetchDmPublicKey('http://localhost:8000', '', 'invite-handle-123');
|
||||
|
||||
expect(result?.agent_id).toBe('!sb_peer');
|
||||
expect(result?.dh_pub_key).toBe('peer-dh');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('still supports explicit legacy agent-id lookup for migration-only paths', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
json: async () => ({ ok: true, dh_pub_key: 'peer-dh', lookup_mode: 'legacy_agent_id' }),
|
||||
|
||||
@@ -725,6 +725,7 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
'http://localhost:8000',
|
||||
'',
|
||||
'f0eee9e9ccf849bcb2d86c0d7a1e0669c75be4e05533b0f6c67',
|
||||
{ allowLegacyAgentId: false },
|
||||
);
|
||||
expect(mocks.sendOffLedgerConsentMessage).toHaveBeenCalled();
|
||||
expect(screen.queryByText(/Unexpected number in JSON/i)).not.toBeInTheDocument();
|
||||
|
||||
@@ -51,6 +51,8 @@ const controlPlaneFetch = vi.fn();
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
fetchWormholeStatus,
|
||||
prepareWormholeInteractiveLane,
|
||||
isWormholePrepAbortedError: (error: unknown) =>
|
||||
error instanceof Error && error.message === 'wormhole_prep_aborted',
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeClient', () => ({
|
||||
@@ -76,6 +78,7 @@ vi.mock('@/mesh/controlPlaneStatusClient', () => ({
|
||||
vi.mock('@/lib/meshTerminalLauncher', () => ({
|
||||
requestMeshTerminalOpen,
|
||||
subscribeSecureMeshTerminalLauncherOpen,
|
||||
subscribeInfonetSessionEnd: vi.fn(() => () => {}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/updateRuntime', () => ({
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
/**
|
||||
* Sprint 4D behavioral tests — page.tsx wormhole teardown and layer sync.
|
||||
*
|
||||
* These tests exercise actual runtime logic:
|
||||
* 1. teardownWormholeOnClose — calls leaveWormhole only when state is ready or running
|
||||
* 2. Layer sync first-mount suppression — initial sync does NOT dispatch LAYER_TOGGLE_EVENT
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
@@ -11,80 +7,29 @@ import path from 'node:path';
|
||||
import { teardownWormholeOnClose } from '@/lib/wormholeTeardown';
|
||||
import { LAYER_TOGGLE_EVENT } from '@/hooks/useDataPolling';
|
||||
|
||||
// ─── teardownWormholeOnClose ──────────────────────────────────────────────
|
||||
const endInfonetTerminalSession = vi.fn(async () => {});
|
||||
|
||||
vi.mock('@/lib/infonetTerminalSession', () => ({
|
||||
endInfonetTerminalSession: (...args: unknown[]) => endInfonetTerminalSession(...args),
|
||||
}));
|
||||
|
||||
describe('page.tsx behavior — teardownWormholeOnClose', () => {
|
||||
let fetchState: ReturnType<typeof vi.fn>;
|
||||
let leave: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchState = vi.fn();
|
||||
leave = vi.fn().mockResolvedValue({});
|
||||
endInfonetTerminalSession.mockClear();
|
||||
});
|
||||
|
||||
it('calls leaveWormhole when state is ready', async () => {
|
||||
fetchState.mockResolvedValue({ ready: true, running: false });
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
expect(fetchState).toHaveBeenCalledWith(false);
|
||||
expect(leave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls leaveWormhole when state is running', async () => {
|
||||
fetchState.mockResolvedValue({ ready: false, running: true });
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
expect(leave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls leaveWormhole when state is both ready and running', async () => {
|
||||
fetchState.mockResolvedValue({ ready: true, running: true });
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
expect(leave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does NOT call leaveWormhole when state is neither ready nor running', async () => {
|
||||
fetchState.mockResolvedValue({ ready: false, running: false });
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
expect(fetchState).toHaveBeenCalledWith(false);
|
||||
expect(leave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT call leaveWormhole when state is null', async () => {
|
||||
fetchState.mockResolvedValue(null);
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
expect(leave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows fetchState errors gracefully', async () => {
|
||||
fetchState.mockRejectedValue(new Error('network down'));
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
expect(leave).not.toHaveBeenCalled();
|
||||
// No error thrown — handler is best-effort
|
||||
});
|
||||
|
||||
it('swallows leaveWormhole errors gracefully', async () => {
|
||||
fetchState.mockResolvedValue({ ready: true });
|
||||
leave.mockRejectedValue(new Error('leave failed'));
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
// No error thrown — handler is best-effort
|
||||
});
|
||||
|
||||
it('always passes force=false to fetchState', async () => {
|
||||
fetchState.mockResolvedValue({ ready: true });
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
expect(fetchState).toHaveBeenCalledWith(false);
|
||||
expect(fetchState).not.toHaveBeenCalledWith(true);
|
||||
it('ends the infonet terminal session on close', async () => {
|
||||
await teardownWormholeOnClose();
|
||||
expect(endInfonetTerminalSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Layer sync first-mount suppression ───────────────────────────────────
|
||||
|
||||
describe('page.tsx behavior — layer sync first-mount suppression', () => {
|
||||
it('LAYER_TOGGLE_EVENT is the expected string constant', () => {
|
||||
expect(LAYER_TOGGLE_EVENT).toBe('sb:layer-toggle');
|
||||
});
|
||||
|
||||
it('first-mount ref pattern suppresses dispatch, subsequent calls dispatch', () => {
|
||||
// Simulate the initialLayerSyncRef pattern from page.tsx
|
||||
const initialSyncDone = { current: false };
|
||||
const dispatched: boolean[] = [];
|
||||
|
||||
@@ -96,7 +41,6 @@ describe('page.tsx behavior — layer sync first-mount suppression', () => {
|
||||
}
|
||||
};
|
||||
|
||||
// First call (mount): should pass false → no dispatch
|
||||
if (!initialSyncDone.current) {
|
||||
initialSyncDone.current = true;
|
||||
syncLayers(false);
|
||||
@@ -105,7 +49,6 @@ describe('page.tsx behavior — layer sync first-mount suppression', () => {
|
||||
}
|
||||
expect(dispatched).toEqual([false]);
|
||||
|
||||
// Second call (layer change): should pass true → dispatch
|
||||
if (!initialSyncDone.current) {
|
||||
initialSyncDone.current = true;
|
||||
syncLayers(false);
|
||||
@@ -114,7 +57,6 @@ describe('page.tsx behavior — layer sync first-mount suppression', () => {
|
||||
}
|
||||
expect(dispatched).toEqual([false, true]);
|
||||
|
||||
// Third call (another layer change): should still dispatch
|
||||
if (!initialSyncDone.current) {
|
||||
initialSyncDone.current = true;
|
||||
syncLayers(false);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Sprint 4B regression tests — page.tsx decomposition boundary checks.
|
||||
*
|
||||
* These tests validate the frozen contract for page.tsx decomposition:
|
||||
* 1. InfonetTerminal onClose still calls leaveWormhole when wormhole is ready/running
|
||||
* 1. InfonetTerminal onClose ends the terminal session (wormhole + node + tor)
|
||||
* 2. Initial /api/layers sync does NOT dispatch LAYER_TOGGLE_EVENT on first mount
|
||||
* 3. launchMeshChatTab preserves atomic leftOpen + leftMeshExpanded + meshChatLaunchRequest
|
||||
* 4. LocateBar extracted to page-local module
|
||||
@@ -60,30 +60,19 @@ describe('page.tsx decomposition — extraction targets', () => {
|
||||
describe('page.tsx decomposition — InfonetTerminal onClose wormhole teardown', () => {
|
||||
const page = readAppFile('page.tsx');
|
||||
|
||||
it('InfonetTerminal onClose delegates to teardownWormholeOnClose', () => {
|
||||
it('InfonetTerminal onClose ends the terminal session', () => {
|
||||
const infonetSection = page.slice(
|
||||
page.indexOf('<InfonetTerminal'),
|
||||
page.indexOf('</InfonetTerminal>') !== -1
|
||||
? page.indexOf('</InfonetTerminal>')
|
||||
: page.indexOf('/>', page.indexOf('<InfonetTerminal')) + 2,
|
||||
);
|
||||
expect(infonetSection).toContain('teardownWormholeOnClose');
|
||||
expect(infonetSection).toContain('fetchWormholeState');
|
||||
expect(infonetSection).toContain('leaveWormhole');
|
||||
expect(infonetSection).toContain('endInfonetTerminalSession');
|
||||
});
|
||||
|
||||
it('page.tsx imports teardownWormholeOnClose from wormholeTeardown', () => {
|
||||
it('page.tsx imports endInfonetTerminalSession from infonetTerminalSession', () => {
|
||||
expect(page).toMatch(
|
||||
/import\s*\{[^}]*teardownWormholeOnClose[^}]*\}\s*from\s+['"]@\/lib\/wormholeTeardown['"]/,
|
||||
);
|
||||
});
|
||||
|
||||
it('page.tsx imports leaveWormhole and fetchWormholeState from wormholeClient', () => {
|
||||
expect(page).toMatch(
|
||||
/import\s*\{[^}]*leaveWormhole[^}]*\}\s*from\s+['"]@\/mesh\/wormholeClient['"]/,
|
||||
);
|
||||
expect(page).toMatch(
|
||||
/import\s*\{[^}]*fetchWormholeState[^}]*\}\s*from\s+['"]@\/mesh\/wormholeClient['"]/,
|
||||
/import\s*\{[^}]*endInfonetTerminalSession[^}]*\}\s*from\s+['"]@\/lib\/infonetTerminalSession['"]/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,8 +18,7 @@ import ScaleBar from '@/components/ScaleBar';
|
||||
import MeshTerminal from '@/components/MeshTerminal';
|
||||
import MeshChat from '@/components/MeshChat';
|
||||
import InfonetTerminal from '@/components/InfonetTerminal';
|
||||
import { leaveWormhole, fetchWormholeState } from '@/mesh/wormholeClient';
|
||||
import { teardownWormholeOnClose } from '@/lib/wormholeTeardown';
|
||||
import { endInfonetTerminalSession } from '@/lib/infonetTerminalSession';
|
||||
import ShodanPanel from '@/components/ShodanPanel';
|
||||
import ReconPanel from '@/components/ReconPanel';
|
||||
import ScmPanel from '@/components/ScmPanel';
|
||||
@@ -169,7 +168,13 @@ export default function Dashboard() {
|
||||
useEffect(() => subscribeMeshTerminalOpen(openInfonet), [openInfonet]);
|
||||
|
||||
const toggleInfonet = useCallback(() => {
|
||||
setInfonetOpen(prev => !prev);
|
||||
setInfonetOpen((prev) => {
|
||||
if (prev) {
|
||||
void endInfonetTerminalSession();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [activeLayers, setActiveLayers] = useState<ActiveLayers>({
|
||||
@@ -615,7 +620,7 @@ export default function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 2. MESH CHAT (Middle) */}
|
||||
{/* 2. MESHTASTIC CHAT (Middle) */}
|
||||
{secondaryBootReady && (
|
||||
<div className="contents" style={{ direction: 'ltr' }}>
|
||||
<MeshChat
|
||||
@@ -624,6 +629,8 @@ export default function Dashboard() {
|
||||
onExpandedChange={setLeftMeshExpanded}
|
||||
onSettingsClick={() => setSettingsOpen(true)}
|
||||
onTerminalToggle={openSecureTerminalLauncher}
|
||||
onOpenLiveGate={openLiveGateFromShell}
|
||||
onOpenDeadDrop={openDeadDropFromShell}
|
||||
launchRequest={meshChatLaunchRequest}
|
||||
/>
|
||||
</div>
|
||||
@@ -1022,8 +1029,7 @@ export default function Dashboard() {
|
||||
isOpen={infonetOpen}
|
||||
onClose={() => {
|
||||
setInfonetOpen(false);
|
||||
// Shut down Wormhole when the terminal closes so it doesn't stay running
|
||||
void teardownWormholeOnClose(fetchWormholeState, leaveWormhole);
|
||||
void endInfonetTerminalSession();
|
||||
}}
|
||||
onOpenLiveGate={openLiveGateFromShell}
|
||||
onOpenDeadDrop={openDeadDropFromShell}
|
||||
|
||||
@@ -4,150 +4,104 @@ import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
X,
|
||||
Bot,
|
||||
Network,
|
||||
KeyRound,
|
||||
Shield,
|
||||
Plane,
|
||||
Bug,
|
||||
Heart,
|
||||
MessageSquare,
|
||||
Radar,
|
||||
Factory,
|
||||
Anchor,
|
||||
Search,
|
||||
Lock,
|
||||
Users,
|
||||
Radio,
|
||||
} from 'lucide-react';
|
||||
|
||||
const CURRENT_VERSION = '0.9.82';
|
||||
const CURRENT_VERSION = '0.9.83';
|
||||
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
|
||||
const RELEASE_TITLE = 'Telegram OSINT + Osiris Intel Ports + OpenClaw Recon';
|
||||
const RELEASE_TITLE = 'Infonet Gate Messaging + DM Protocols Live';
|
||||
|
||||
const HEADLINE_FEATURES = [
|
||||
{
|
||||
icon: <Lock size={20} className="text-purple-400" />,
|
||||
accent: 'purple' as const,
|
||||
title: 'Gate Messaging — End-to-End on the Hashchain',
|
||||
subtitle:
|
||||
'Encrypted MLS gate rooms now carry live chat over your private Infonet hashchain. Messages replicate across participant nodes via swarm push/pull — only gate members can decrypt.',
|
||||
details: [
|
||||
'Gate messages append as signed `gate_message` events on each participant\'s private chain; peers sync ciphertext through the mesh without exposing room keys to outsiders.',
|
||||
'Swarm replication keeps late joiners and offline nodes convergent — pull missing blocks, push new envelopes to known gate peers.',
|
||||
'MLS group crypto (privacy-core) handles forward secrecy and membership changes; the UI surfaces delivery, key rotation, and compat approval when room epochs advance.',
|
||||
],
|
||||
callToAction: 'MESH CHAT → GATES → CREATE OR JOIN A ROOM',
|
||||
},
|
||||
{
|
||||
icon: <MessageSquare size={20} className="text-cyan-400" />,
|
||||
accent: 'cyan' as const,
|
||||
title: 'Telegram OSINT Map Layer',
|
||||
title: 'Direct Messages — Short Address, Request, Encrypt',
|
||||
subtitle:
|
||||
'Public war/conflict Telegram channels scraped hourly, risk-scored, geoparsed to metro anchors, and plotted as clickable map pins with inline photo/video.',
|
||||
'DMs are fully operational over the wormhole/Tor lane: share your short wormhole address out-of-band, accept a contact request, then exchange ratchet-encrypted messages.',
|
||||
details: [
|
||||
'Incremental merge — only new posts are fetched; known links stop the parser early so channels are not re-scraped redundantly.',
|
||||
'Metro-anchor geocoding (Tel Aviv, Kyiv, NYC, Beijing, etc.) keeps Telegram pins off news/threat-alert centroids so pins stay clickable above the threat intercept overlays.',
|
||||
'Threat-intercept styled popups with inline media via `/api/telegram/media` proxy. Configure channels via `TELEGRAM_OSINT_CHANNELS` (see `.env.example`).',
|
||||
'Contact flow: outbound request → peer approve/deny → mutual DM session with double-ratchet bundles and mailbox claim keys.',
|
||||
'No public phonebook — addresses are intentionally short and meant to be exchanged like a phone number or email, not discovered from a directory.',
|
||||
'Fleet-tested across multiple onion participants: request, accept, decrypt, and reply paths verified on live Tor hidden services.',
|
||||
],
|
||||
callToAction: 'TOGGLE TELEGRAM OSINT IN DATA LAYERS',
|
||||
callToAction: 'MESH CHAT → DIRECT → SHARE SHORT ADDRESS',
|
||||
},
|
||||
{
|
||||
icon: <Radar size={20} className="text-purple-400" />,
|
||||
accent: 'purple' as const,
|
||||
title: 'Osiris-Derived Intel Ports (Recon, SCM, Entity Graph)',
|
||||
subtitle:
|
||||
'Server-side recon toolkit, supply-chain risk overlay, entity relationship graphs, malware/C2 hotspots, CISA KEV cyber feed, sanctions index, and submarine cable routes — all SSRF-guarded and local-operator proxied.',
|
||||
details: [
|
||||
'Recon Toolkit panel: IP geolocation, DNS, WHOIS, certs, BGP/ASN, OFAC sanctions, CVE, MAC vendor, GitHub profile, breach checks, and InternetDB subnet sweeps.',
|
||||
'SCM panel cross-references Tier 1/2 fabs (TSMC, Samsung, CATL, etc.) against earthquakes, wildfires, and GDELT conflict proximity.',
|
||||
'Entity Graph expands aircraft, vessels, companies, persons, IPs, and countries via Wikidata + OFAC + live telemetry store. Attribution: `backend/third_party/osiris/NOTICE.md`.',
|
||||
'Malware C2 (abuse.ch Feodo + URLhaus) and Cyber Threats (CISA KEV) layers opt-in on the slow tier. Submarine cables overlay from static TeleGeography-derived GeoJSON.',
|
||||
],
|
||||
callToAction: 'OPEN RECON • SCM • ENTITY GRAPH IN LEFT SIDEBAR',
|
||||
},
|
||||
{
|
||||
icon: <Bot size={20} className="text-amber-400" />,
|
||||
icon: <Network size={20} className="text-amber-400" />,
|
||||
accent: 'cyan' as const,
|
||||
title: 'OpenClaw Agent — Full Telemetry + Recon Parity',
|
||||
title: 'Infonet Transport Hardening',
|
||||
subtitle:
|
||||
'AI agents on the HMAC command channel now search, slice, and investigate the same data the operator sees — including Telegram, malware, cyber, SCM, and the full recon toolkit.',
|
||||
'Tor/Arti warmup, SOCKS readiness, and terminal session lifecycle fixes so sovereign nodes actually join the mesh instead of sitting on NODE ARTI WARMING.',
|
||||
details: [
|
||||
'`search_telemetry` and `search_news` index Telegram OSINT posts alongside news, GDELT, and CrowdThreat. `get_slow_telemetry` and `get_layer_slice` include `telegram_osint`, `malware_threats`, `cyber_threats`, and `scm_suppliers`.',
|
||||
'New commands: `osint_lookup` (IP/DNS/WHOIS/sanctions/CVE/etc.), `entity_expand` (relationship graph), `osint_tools` (discovery), and `osint_sweep` (subnet scan — full access tier).',
|
||||
'Layer aliases: `telegram`, `malware`/`botnet`, `cyber`/`cisa`/`kev`, `scm`/`suppliers`, `gfw`/`fishing`. Skill package: `openclaw-skills/shadowbroker/SKILL.md`.',
|
||||
'Tor hidden service config always exposes SOCKS; readiness probes cache and recover wedged transports instead of blocking wormhole sync indefinitely.',
|
||||
'Leaving the Infonet terminal now tears down wormhole prep, leaves the session, and stops Tor when the UI enabled it — no ghost connections after close.',
|
||||
'Network stats distinguish real transport warmup from stale sync backoff so operators see actionable status instead of a permanent warming spinner.',
|
||||
],
|
||||
callToAction: 'AI INTEL PANEL → CONNECT AGENT → COPY HMAC SECRET',
|
||||
callToAction: 'TOP RIGHT → ENTER INFONET → CHECK NODE STATUS',
|
||||
},
|
||||
];
|
||||
|
||||
const NEW_FEATURES = [
|
||||
{
|
||||
icon: <Anchor size={18} className="text-cyan-400" />,
|
||||
title: 'Global Fishing Watch in Settings',
|
||||
desc: 'GFW API token exposed in onboarding and Settings → Maritime. Fishing activity layer backed by GFW when `GFW_API_TOKEN` is configured.',
|
||||
icon: <Users size={18} className="text-purple-400" />,
|
||||
title: 'Gate Swarm Replication',
|
||||
desc: 'Participant nodes push and pull gate hashchain segments so encrypted room history converges across the fleet without a central relay.',
|
||||
},
|
||||
{
|
||||
icon: <Factory size={18} className="text-orange-400" />,
|
||||
title: 'Supply-Chain Risk Map Layer',
|
||||
desc: 'SCM suppliers render as map markers with seismic, wildfire, and conflict proximity scoring. Panel alerts for CRITICAL/HIGH fabs.',
|
||||
icon: <KeyRound size={18} className="text-cyan-400" />,
|
||||
title: 'DM Contact Requests',
|
||||
desc: 'Pending inbound/outbound access requests with approve, deny, and scoped per-node DM state — no cross-identity leakage in local storage.',
|
||||
},
|
||||
{
|
||||
icon: <Shield size={18} className="text-red-400" />,
|
||||
title: 'Malware C2 + CISA KEV Overlays',
|
||||
desc: 'abuse.ch botnet C2 and URLhaus distribution URLs geolocated by country; CISA Known Exploited Vulnerabilities surfaced in cyber threats feed and slow-tier payload.',
|
||||
icon: <Radio size={18} className="text-green-400" />,
|
||||
title: 'Wormhole Session Teardown',
|
||||
desc: 'Closing the Infonet terminal aborts in-flight wormhole prep, leaves the lane, and resets launcher busy state for clean re-entry.',
|
||||
},
|
||||
{
|
||||
icon: <Search size={18} className="text-green-400" />,
|
||||
title: 'OpenClaw Compact Search Path',
|
||||
desc: 'Agents prefer `get_summary` → SSE `layer_changed` → `get_layer_slice` with per-layer versions. `brief_area`, `correlate_entity`, and `entities_near` include Telegram and malware context.',
|
||||
},
|
||||
{
|
||||
icon: <Network size={18} className="text-purple-400" />,
|
||||
title: 'Submarine Cable Overlay',
|
||||
desc: 'Opt-in undersea cable routes from static TeleGeography-derived GeoJSON for infrastructure context on the map.',
|
||||
icon: <Shield size={18} className="text-amber-400" />,
|
||||
title: 'Fail-Closed Tor Proof',
|
||||
desc: 'Onion sync waits for a working SOCKS handshake before declaring transport ready — prevents silent half-open mesh joins.',
|
||||
},
|
||||
];
|
||||
|
||||
const BUG_FIXES = [
|
||||
'Telegram map pins are now HTML markers above threat-alert overlays — pins are clickable even when sharing a city grid with news intercept boxes.',
|
||||
'Telegram geocoding uses metro anchors (not national centroids) and a small NE offset only when news alerts share the same city cell — pins stay on land.',
|
||||
'Hourly Telegram scheduler with incremental post merge — no redundant full-channel re-scrape every cycle.',
|
||||
'OpenClaw `get_slow_telemetry` previously omitted telegram_osint, malware_threats, cyber_threats, and scm_suppliers — now included in slow-tier and universal search.',
|
||||
'OpenClaw agents can invoke the Recon panel backends via `osint_lookup` without raw `/api/osint/*` HTTP calls or local-operator browser auth.',
|
||||
'Arti/Tor transport no longer omits SocksPort when MESH_ARTI_ENABLED — SOCKS probes succeed and wormhole sync can start.',
|
||||
'Concurrent Arti readiness checks no longer wedge Tor under load; single-flight probes with auto-recycle when SOCKS stalls.',
|
||||
'Infonet terminal exit no longer leaves background wormhole prep or terminalLaunchBusy stuck after close.',
|
||||
'Stale onion sync backoff clears when transport recovers so NODE ARTI WARMING does not persist after Tor is healthy.',
|
||||
'DM decrypt timeouts on multi-participant fleets addressed via improved peer push timing and mailbox claim sequencing.',
|
||||
];
|
||||
|
||||
const CONTRIBUTORS = [
|
||||
type ChangelogContributor = {
|
||||
name: string;
|
||||
desc: string;
|
||||
pr?: string;
|
||||
};
|
||||
|
||||
const CONTRIBUTORS: ChangelogContributor[] = [
|
||||
{
|
||||
name: 'OSIRIS (simplifaisoul/osiris)',
|
||||
desc: 'MIT-licensed recon stack — adapted for ShadowBroker proxy model (see backend/third_party/osiris/NOTICE.md)',
|
||||
},
|
||||
{
|
||||
name: '@Alienmajik',
|
||||
desc: 'Raspberry Pi 5 support — ARM64 packaging, headless deployment notes, and runtime tuning for Pi-class hardware',
|
||||
},
|
||||
{
|
||||
name: '@wa1id',
|
||||
desc: 'CCTV ingestion fix — fresh SQLite connections per ingest, persistent DB path, startup hydration, cluster clickability',
|
||||
pr: '#92',
|
||||
},
|
||||
{
|
||||
name: '@AlborzNazari',
|
||||
desc: 'Spain DGT + Madrid CCTV sources and STIX 2.1 threat intelligence export endpoint',
|
||||
pr: '#91',
|
||||
},
|
||||
{
|
||||
name: '@adust09',
|
||||
desc: 'Power plants layer, East Asia intel coverage (JSDF bases, ICAO enrichment, Taiwan news sources, military classification)',
|
||||
pr: '#71, #72, #76, #77, #87',
|
||||
},
|
||||
{
|
||||
name: '@Xpirix',
|
||||
desc: 'LocateBar style and interaction improvements',
|
||||
pr: '#78',
|
||||
},
|
||||
{
|
||||
name: '@imqdcr',
|
||||
desc: 'Ship toggle split into 4 categories + stable MMSI/callsign entity IDs for map markers',
|
||||
pr: '#52',
|
||||
},
|
||||
{
|
||||
name: '@csysp',
|
||||
desc: 'Dismissible threat alerts + stable entity IDs for GDELT & News popups + UI declutter',
|
||||
pr: '#48, #61, #63',
|
||||
},
|
||||
{
|
||||
name: '@suranyami',
|
||||
desc: 'Parallel multi-arch Docker builds (11min → 3min) + runtime BACKEND_URL fix',
|
||||
pr: '#35, #44',
|
||||
},
|
||||
{
|
||||
name: '@chr0n1x',
|
||||
desc: 'Kubernetes / Helm chart architecture for high-availability deployments',
|
||||
name: 'privacy-core (MLS)',
|
||||
desc: 'Rust MLS gate crypto — WASM/FFI path for browser and Tauri sovereign shells',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -265,41 +219,18 @@ const ChangelogModal = React.memo(function ChangelogModal({ onClose }: Changelog
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Auto-update note for v0.9.81+ installs */}
|
||||
{/* Auto-update note for v0.9.82+ installs */}
|
||||
<div className="border border-green-500/30 bg-green-950/15 p-3 flex items-start gap-3">
|
||||
<KeyRound size={18} className="text-green-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-mono text-green-300 font-bold tracking-wide uppercase">
|
||||
One-click update from v0.9.81
|
||||
One-click update from v0.9.82
|
||||
</div>
|
||||
<div className="text-xs font-mono text-green-200/80 leading-relaxed">
|
||||
If you installed v0.9.81, the in-app Update button verifies this release via the
|
||||
signed Tauri updater (`latest.json` + minisign). Desktop installs on v0.9.81 or
|
||||
later should auto-apply v0.9.82 without a manual MSI hop.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Required-config callout: OpenSky API */}
|
||||
<div className="border border-amber-500/40 bg-amber-950/20 p-3 flex items-start gap-3">
|
||||
<Plane size={18} className="text-amber-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-mono text-amber-300 font-bold tracking-wide uppercase">
|
||||
Required: OpenSky API credentials for airplane telemetry
|
||||
</div>
|
||||
<div className="text-xs font-mono text-amber-200/80 leading-relaxed">
|
||||
Set <span className="text-amber-100 font-bold">OPENSKY_CLIENT_ID</span> and{' '}
|
||||
<span className="text-amber-100 font-bold">OPENSKY_CLIENT_SECRET</span> in your{' '}
|
||||
<span className="text-amber-100 font-bold">.env</span>. Free registration:{' '}
|
||||
<a
|
||||
href="https://opensky-network.org/index.php?option=com_users&view=registration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-amber-100 font-bold underline underline-offset-2 hover:text-amber-50"
|
||||
>
|
||||
opensky-network.org/register
|
||||
</a>
|
||||
.
|
||||
If you installed v0.9.82, the in-app Update button verifies this release via the
|
||||
signed Tauri updater (`latest.json` + minisign). Desktop installs on v0.9.82 or
|
||||
later should auto-apply v0.9.83 without a manual MSI hop once the release is
|
||||
published.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,6 +60,10 @@ export default function BootstrapView({ marketId, onBack }: BootstrapViewProps)
|
||||
nodeStatus?.bootstrap?.bootstrap_seed_peer_count ?? nodeStatus?.bootstrap?.default_sync_peer_count ?? 0,
|
||||
);
|
||||
const syncPeerCount = Number(nodeStatus?.bootstrap?.sync_peer_count || 0);
|
||||
const swarmSyncPeerCount = Number(nodeStatus?.bootstrap?.swarm_sync_peer_count || 0);
|
||||
const manifestLoaded = Boolean(nodeStatus?.bootstrap?.manifest_loaded);
|
||||
const swarmPull = nodeStatus?.bootstrap?.swarm_manifest_pull;
|
||||
const swarmPullOk = Boolean(swarmPull?.ok) && !swarmPull?.skipped;
|
||||
const lastPeerUrl = String(nodeStatus?.sync_runtime?.last_peer_url || '').trim();
|
||||
const privateTransportRequired = Boolean(nodeStatus?.private_transport_required);
|
||||
|
||||
@@ -146,7 +150,7 @@ export default function BootstrapView({ marketId, onBack }: BootstrapViewProps)
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 text-xs">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-2 text-xs">
|
||||
<div>
|
||||
<div className="text-gray-500">Transport</div>
|
||||
<div className="text-cyan-300 font-mono break-all">
|
||||
@@ -163,6 +167,19 @@ export default function BootstrapView({ marketId, onBack }: BootstrapViewProps)
|
||||
<div className="text-gray-500">Sync Path</div>
|
||||
<div className="text-white font-mono">
|
||||
{syncPeerCount} peers / {seedPeerCount} seeds
|
||||
{swarmSyncPeerCount > 0 ? ` (${swarmSyncPeerCount} swarm)` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Swarm Manifest</div>
|
||||
<div className={swarmPullOk || manifestLoaded ? 'text-green-400' : 'text-amber-400'}>
|
||||
{swarmPullOk
|
||||
? `LIVE (${Number(swarmPull?.merged_peer_count || swarmPull?.peer_count || 0)} peers)`
|
||||
: manifestLoaded
|
||||
? 'LOCAL FILE'
|
||||
: swarmPull?.skipped
|
||||
? 'WAITING'
|
||||
: 'NOT LOADED'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -137,6 +137,9 @@ interface CommandHistory {
|
||||
|
||||
interface InfonetShellProps {
|
||||
isOpen: boolean;
|
||||
embedded?: boolean;
|
||||
launchGate?: string;
|
||||
onLaunchGateConsumed?: () => void;
|
||||
onClose: () => void;
|
||||
onOpenLiveGate?: (gate: string) => void;
|
||||
onOpenDeadDrop?: (peerId: string, options?: { showSas?: boolean }) => void;
|
||||
@@ -144,6 +147,9 @@ interface InfonetShellProps {
|
||||
|
||||
export default function InfonetShell({
|
||||
isOpen,
|
||||
embedded = false,
|
||||
launchGate = '',
|
||||
onLaunchGateConsumed,
|
||||
onClose,
|
||||
onOpenLiveGate,
|
||||
onOpenDeadDrop,
|
||||
@@ -176,6 +182,7 @@ export default function InfonetShell({
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const gateLaunchAttemptRef = useRef(0);
|
||||
const launchGateConsumedRef = useRef('');
|
||||
|
||||
// Real mesh identity
|
||||
const nodeIdentity = useMemo(() => getNodeIdentity(), []);
|
||||
@@ -203,6 +210,7 @@ export default function InfonetShell({
|
||||
setPendingGate(null);
|
||||
setInput('');
|
||||
gateLaunchAttemptRef.current += 1;
|
||||
launchGateConsumedRef.current = '';
|
||||
setIsBooting(true);
|
||||
setBootText([]);
|
||||
|
||||
@@ -600,6 +608,14 @@ export default function InfonetShell({
|
||||
setHistory(prev => [...prev, { command: cmd, output }]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const gate = String(launchGate || '').trim().toLowerCase();
|
||||
if (!isOpen || isBooting || !gate || launchGateConsumedRef.current === gate) return;
|
||||
launchGateConsumedRef.current = gate;
|
||||
handleCommand(`join ${gate}`);
|
||||
onLaunchGateConsumed?.();
|
||||
}, [isOpen, isBooting, launchGate, onLaunchGateConsumed]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (inputMode === 'normal' && input.startsWith('g/') && searchMatch) {
|
||||
@@ -635,7 +651,12 @@ export default function InfonetShell({
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="h-full bg-[#0a0a0a] text-gray-300 p-4 md:p-8 font-mono relative flex flex-col overflow-hidden">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`h-full min-h-0 bg-[#0a0a0a] text-gray-300 font-mono relative flex flex-col overflow-hidden ${
|
||||
embedded ? 'p-2 md:p-3 text-[13px]' : 'p-4 md:p-8'
|
||||
}`}
|
||||
>
|
||||
{currentView === 'terminal' && (
|
||||
<>
|
||||
{/* Top Navigation / Quick Launch */}
|
||||
@@ -661,6 +682,20 @@ export default function InfonetShell({
|
||||
|
||||
{/* Main Terminal Area */}
|
||||
<div className="flex-1 overflow-y-auto pr-4 pb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleNavigate('messages')}
|
||||
className="w-full mb-6 text-left border border-emerald-500/30 bg-emerald-950/10 hover:bg-emerald-950/20 px-4 py-3 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-emerald-300 text-xs tracking-[0.2em] uppercase font-bold">
|
||||
<Mail size={14} />
|
||||
Secure Messages — Quick Connect
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-400 leading-relaxed">
|
||||
Message someone on the fleet in three steps: copy your short address (or ask for theirs),
|
||||
paste it in Secure Messages, tap Send Request — they tap Accept. No terminal commands.
|
||||
</p>
|
||||
</button>
|
||||
<div className="flex flex-col lg:flex-row justify-between items-start gap-6 mb-8">
|
||||
<TrendingPosts />
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
purgeBrowserContactGraph,
|
||||
purgeBrowserSigningMaterial,
|
||||
removeContact,
|
||||
severContact,
|
||||
unblockContact,
|
||||
unwrapSenderSealPayload,
|
||||
updateContact,
|
||||
@@ -74,6 +75,7 @@ import {
|
||||
type Contact,
|
||||
type NodeIdentity,
|
||||
} from '@/mesh/meshIdentity';
|
||||
import { connectDeliveryMeta, ensureDmOutboxReleased } from '@/mesh/dmConnectDelivery';
|
||||
import {
|
||||
getSenderRecoveryState,
|
||||
recoverSenderSealWithFallback,
|
||||
@@ -1516,6 +1518,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
API_BASE,
|
||||
senderId,
|
||||
existingContact?.invitePinnedPrekeyLookupHandle,
|
||||
{ lookupPeerUrl: existingContact?.invitePinnedLookupPeerUrl },
|
||||
);
|
||||
if (senderKey?.dh_pub_key) {
|
||||
const sharedKey = await deriveSharedKey(String(senderKey.dh_pub_key));
|
||||
@@ -1532,6 +1535,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
API_BASE,
|
||||
senderId,
|
||||
existingContact?.invitePinnedPrekeyLookupHandle,
|
||||
{ lookupPeerUrl: existingContact?.invitePinnedLookupPeerUrl },
|
||||
).catch(() => null);
|
||||
if (senderKey?.dh_pub_key) {
|
||||
addContact(senderId, String(senderKey.dh_pub_key), undefined, senderKey.dh_algo);
|
||||
@@ -2000,7 +2004,9 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
'This contact needs their full contact address once before messages can be sent. Paste it in Contacts and the app will handle the rest.',
|
||||
);
|
||||
}
|
||||
const targetKey = await fetchDmPublicKey(API_BASE, recipient, lookupHandle);
|
||||
const targetKey = await fetchDmPublicKey(API_BASE, recipient, lookupHandle, {
|
||||
lookupPeerUrl: recipientContact?.invitePinnedLookupPeerUrl,
|
||||
});
|
||||
if (!targetKey?.dh_pub_key) {
|
||||
queuePendingDeliveryMail({
|
||||
senderId: activeIdentity.nodeId,
|
||||
@@ -2037,15 +2043,23 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
}
|
||||
const msgId = `dm_${Date.now()}_${activeIdentity.nodeId.slice(-4)}`;
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const sent = await sendOffLedgerConsentMessage({
|
||||
apiBase: API_BASE,
|
||||
identity: activeIdentity,
|
||||
recipientId: recipient,
|
||||
recipientDhPub: String(targetKey.dh_pub_key),
|
||||
ciphertext,
|
||||
msgId,
|
||||
timestamp,
|
||||
const connectMeta = connectDeliveryMeta({
|
||||
intent: 'contact_request',
|
||||
contact: recipientContact,
|
||||
});
|
||||
const sent = await ensureDmOutboxReleased(
|
||||
await sendOffLedgerConsentMessage({
|
||||
apiBase: API_BASE,
|
||||
identity: activeIdentity,
|
||||
recipientId: recipient,
|
||||
recipientDhPub: String(targetKey.dh_pub_key),
|
||||
ciphertext,
|
||||
msgId,
|
||||
timestamp,
|
||||
connectIntent: connectMeta.connectIntent,
|
||||
lookupPeerUrl: connectMeta.lookupPeerUrl,
|
||||
}),
|
||||
);
|
||||
if (!sent.ok) {
|
||||
throw new Error(sent.detail || 'contact request failed');
|
||||
}
|
||||
@@ -2110,7 +2124,9 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
throw new Error('Secure mail is still preparing your private identity.');
|
||||
}
|
||||
const { registration, myDhPub } = await ensureLocalDmKey(activeIdentity);
|
||||
const targetKey = await fetchDmPublicKey(API_BASE, '', shortAddress);
|
||||
const targetKey = await fetchDmPublicKey(API_BASE, '', shortAddress, {
|
||||
allowLegacyAgentId: false,
|
||||
});
|
||||
if (!targetKey?.dh_pub_key || !targetKey.agent_id) {
|
||||
throw new Error('That address is not reachable yet. Ask them to copy their address again while their device is online.');
|
||||
}
|
||||
@@ -2136,15 +2152,18 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
}
|
||||
const msgId = `dm_${Date.now()}_${activeIdentity.nodeId.slice(-4)}`;
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const sent = await sendOffLedgerConsentMessage({
|
||||
apiBase: API_BASE,
|
||||
identity: activeIdentity,
|
||||
recipientId: recipient,
|
||||
recipientDhPub: String(targetKey.dh_pub_key),
|
||||
ciphertext,
|
||||
msgId,
|
||||
timestamp,
|
||||
});
|
||||
const sent = await ensureDmOutboxReleased(
|
||||
await sendOffLedgerConsentMessage({
|
||||
apiBase: API_BASE,
|
||||
identity: activeIdentity,
|
||||
recipientId: recipient,
|
||||
recipientDhPub: String(targetKey.dh_pub_key),
|
||||
ciphertext,
|
||||
msgId,
|
||||
timestamp,
|
||||
connectIntent: 'invite_short_address',
|
||||
}),
|
||||
);
|
||||
if (!sent.ok) {
|
||||
throw new Error(sent.detail || 'contact request failed');
|
||||
}
|
||||
@@ -2224,6 +2243,12 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
invitePayload.prekey_lookup_handle ||
|
||||
'',
|
||||
),
|
||||
invitePinnedLookupPeerUrl: String(
|
||||
resultContact.invitePinnedLookupPeerUrl ||
|
||||
(invite as Record<string, unknown>).lookup_peer_url ||
|
||||
invitePayload.lookup_peer_url ||
|
||||
'',
|
||||
),
|
||||
dhPubKey: String(resultContact.dhPubKey || resultContact.invitePinnedDhPubKey || ''),
|
||||
};
|
||||
const mergedContacts = importedPeerId
|
||||
@@ -2269,6 +2294,26 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
}
|
||||
}, [applyHydratedContacts, handleSendShortAddressRequest, inviteImportAlias, inviteImportBlob, loadBackendContacts, syncSecureMailRuntime]);
|
||||
|
||||
const handleSeverContact = useCallback(
|
||||
async (peerId: string) => {
|
||||
const name = displayNameForPeer(peerId, contacts);
|
||||
setComposeError('');
|
||||
setComposeStatus('');
|
||||
try {
|
||||
await severContact(peerId);
|
||||
setContacts(getContacts());
|
||||
setComposeStatus(
|
||||
`Secure contact ended with ${name}. You can message again only after a new request and approval.`,
|
||||
);
|
||||
} catch (error) {
|
||||
setComposeError(
|
||||
error instanceof Error ? error.message : 'Could not end secure contact right now.',
|
||||
);
|
||||
}
|
||||
},
|
||||
[contacts],
|
||||
);
|
||||
|
||||
const refreshDmAddressHandles = useCallback(async () => {
|
||||
try {
|
||||
const result = await listWormholeDmInviteHandles();
|
||||
@@ -2501,6 +2546,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
API_BASE,
|
||||
mail.senderId,
|
||||
existingContact?.invitePinnedPrekeyLookupHandle,
|
||||
{ lookupPeerUrl: existingContact?.invitePinnedLookupPeerUrl },
|
||||
).catch(() => null);
|
||||
const dhPubKey = String(registry?.dh_pub_key || mail.requestDhPubKey || '').trim();
|
||||
const dhAlgo = String(registry?.dh_algo || mail.requestDhAlgo || 'X25519').trim();
|
||||
@@ -2551,15 +2597,19 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
|
||||
const msgId = `dm_${Date.now()}_${activeIdentity.nodeId.slice(-4)}`;
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const sent = await sendOffLedgerConsentMessage({
|
||||
apiBase: API_BASE,
|
||||
identity: activeIdentity,
|
||||
recipientId: mail.senderId,
|
||||
recipientDhPub: dhPubKey,
|
||||
ciphertext,
|
||||
msgId,
|
||||
timestamp,
|
||||
});
|
||||
const sent = await ensureDmOutboxReleased(
|
||||
await sendOffLedgerConsentMessage({
|
||||
apiBase: API_BASE,
|
||||
identity: activeIdentity,
|
||||
recipientId: mail.senderId,
|
||||
recipientDhPub: dhPubKey,
|
||||
ciphertext,
|
||||
msgId,
|
||||
timestamp,
|
||||
connectIntent: 'contact_accept',
|
||||
lookupPeerUrl: existingContact?.invitePinnedLookupPeerUrl,
|
||||
}),
|
||||
);
|
||||
if (!sent.ok) {
|
||||
throw new Error(sent.detail || 'contact accept failed');
|
||||
}
|
||||
@@ -2715,7 +2765,9 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
<Mail size={24} className="mr-3" />
|
||||
SECURE MESSAGES
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">End-to-end encrypted peer-to-peer comms.</p>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
Copy your short address and send it to someone. They paste it here and tap Send Request — you tap Accept. No terminal required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-cyan-900/30 bg-cyan-950/10 px-4 py-3 text-[11px] tracking-[0.16em] uppercase text-cyan-300 mb-4 shrink-0">
|
||||
@@ -2755,7 +2807,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
'Your contact address is being prepared automatically. Share it with someone so they can message you.'
|
||||
'Your contact address is being prepared. Copy the short address above and send it to anyone you want to message you.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -3428,7 +3480,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
)}
|
||||
{contact.sharedAlias && (
|
||||
<div className="text-[11px] text-emerald-300 mt-2">
|
||||
Shared alias: {contact.sharedAlias}
|
||||
Shared lane open — you can exchange secure mail.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3466,6 +3518,18 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
{nextStep.label}
|
||||
</button>
|
||||
)}
|
||||
{contact.sharedAlias && (
|
||||
<button
|
||||
onClick={() => void handleSeverContact(peerId)}
|
||||
className="px-3 py-2 border border-violet-500/30 text-violet-200 text-sm tracking-[0.18em] uppercase"
|
||||
title="Close the shared lane. A fresh contact request and approval will be required to message again."
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ShieldOff size={14} />
|
||||
End Contact
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
blockContact(peerId);
|
||||
|
||||
@@ -13,13 +13,27 @@ interface Stats {
|
||||
seedPeers: number;
|
||||
nodeEnabled: boolean;
|
||||
syncOutcome: string;
|
||||
syncError: string;
|
||||
artiReady: boolean | null;
|
||||
}
|
||||
|
||||
const EMPTY: Stats = {
|
||||
meshtastic: 0, aprs: 0, ledgerNodes: 0, infonetEvents: 0,
|
||||
syncPeers: 0, seedPeers: 0, nodeEnabled: false, syncOutcome: 'offline',
|
||||
syncPeers: 0, seedPeers: 0, nodeEnabled: false, syncOutcome: 'offline', syncError: '',
|
||||
artiReady: null,
|
||||
};
|
||||
|
||||
function isArtiTransportBlocked(syncError: string, artiReady: boolean | null): boolean {
|
||||
if (artiReady === true) return false;
|
||||
if (artiReady === false) return true;
|
||||
const lower = syncError.toLowerCase();
|
||||
return (
|
||||
lower.includes('ready arti transport')
|
||||
|| lower.includes('require arti to be enabled')
|
||||
|| lower.includes('onion peer requests require a ready arti')
|
||||
);
|
||||
}
|
||||
|
||||
export default function NetworkStats() {
|
||||
const [stats, setStats] = useState<Stats>(EMPTY);
|
||||
|
||||
@@ -27,10 +41,11 @@ export default function NetworkStats() {
|
||||
let alive = true;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const [meshRes, channelsRes, infonet] = await Promise.all([
|
||||
const [meshRes, channelsRes, infonet, wormholeRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/mesh/status`).then(r => r.ok ? r.json() : null).catch(() => null),
|
||||
fetch(`${API_BASE}/api/mesh/channels`).then(r => r.ok ? r.json() : null).catch(() => null),
|
||||
fetchInfonetNodeStatusSnapshot(true).catch(() => null),
|
||||
fetch(`${API_BASE}/api/wormhole/status`).then(r => r.ok ? r.json() : null).catch(() => null),
|
||||
]);
|
||||
if (!alive) return;
|
||||
const authorNodes = Number(infonet?.author_nodes ?? infonet?.known_nodes ?? 0);
|
||||
@@ -41,6 +56,8 @@ export default function NetworkStats() {
|
||||
?? infonet?.bootstrap?.default_sync_peer_count
|
||||
?? 0,
|
||||
);
|
||||
const syncOutcome = String(infonet?.sync_runtime?.last_outcome || 'offline').toLowerCase();
|
||||
const artiReady = typeof wormholeRes?.arti_ready === 'boolean' ? wormholeRes.arti_ready : null;
|
||||
setStats({
|
||||
meshtastic: Number(channelsRes?.total_live || channelsRes?.total_nodes || meshRes?.signal_counts?.meshtastic || 0),
|
||||
aprs: Number(meshRes?.signal_counts?.aprs || 0),
|
||||
@@ -49,26 +66,36 @@ export default function NetworkStats() {
|
||||
syncPeers: syncPeerCount,
|
||||
seedPeers: seedPeerCount,
|
||||
nodeEnabled: Boolean(infonet?.node_enabled),
|
||||
syncOutcome: String(infonet?.sync_runtime?.last_outcome || 'offline').toLowerCase(),
|
||||
syncOutcome,
|
||||
syncError: String(infonet?.sync_runtime?.last_error || '').trim(),
|
||||
artiReady,
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
poll();
|
||||
const interval = setInterval(poll, 15000);
|
||||
const interval = setInterval(poll, 8000);
|
||||
return () => { alive = false; clearInterval(interval); };
|
||||
}, []);
|
||||
|
||||
const nodeColor = stats.syncOutcome === 'ok' ? 'text-green-400'
|
||||
const artiBlocked = isArtiTransportBlocked(stats.syncError, stats.artiReady);
|
||||
const nodeColor = stats.syncOutcome === 'ok' || stats.syncOutcome === 'solo' ? 'text-green-400'
|
||||
: stats.syncOutcome === 'running' ? 'text-amber-400'
|
||||
: stats.nodeEnabled ? 'text-amber-400' : 'text-gray-600';
|
||||
const nodeLabel = stats.syncOutcome === 'ok' ? 'SEED SYNCED'
|
||||
: stats.syncOutcome === 'solo' ? 'SOLO'
|
||||
: stats.syncOutcome === 'running' ? 'SYNCING'
|
||||
: stats.syncOutcome === 'error' || stats.syncOutcome === 'fork' ? 'RETRYING'
|
||||
: stats.syncOutcome === 'error' || stats.syncOutcome === 'fork'
|
||||
? (artiBlocked ? 'ARTI WARMING' : 'SYNC BACKOFF')
|
||||
: stats.nodeEnabled ? 'WAITING' : 'OFFLINE';
|
||||
const nodeTitle = stats.syncError
|
||||
? `Infonet seed sync: ${stats.syncError}`
|
||||
: stats.nodeEnabled
|
||||
? 'Participant node enabled; waiting for seed ledger sync.'
|
||||
: 'Participant node offline.';
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-5 gap-y-1 mt-5 text-sm font-mono text-gray-500">
|
||||
<span>NODE <span className={nodeColor}>{nodeLabel}</span></span>
|
||||
<span title={nodeTitle}>NODE <span className={nodeColor}>{nodeLabel}</span></span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span>MESH <span className={stats.meshtastic > 0 ? 'text-green-400' : 'text-gray-600'}>{stats.meshtastic.toLocaleString()}</span></span>
|
||||
<span className="text-gray-700">|</span>
|
||||
@@ -80,7 +107,7 @@ export default function NetworkStats() {
|
||||
<span className="text-gray-700">|</span>
|
||||
<span>EVENTS <span className="text-white">{stats.infonetEvents}</span></span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span title="Configured peers this node pulls from. Usually this is just the seed unless another device is added as a sync peer.">
|
||||
<span title="Peers this node syncs from (seed + swarm-discovered participants).">
|
||||
SYNC PEERS <span className="text-white">{stats.syncPeers}</span>
|
||||
</span>
|
||||
{stats.seedPeers > stats.syncPeers ? (
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { X } from 'lucide-react';
|
||||
import {
|
||||
fetchInfonetNodeStatusSnapshot,
|
||||
setInfonetNodeEnabled,
|
||||
startTorHiddenService,
|
||||
} from '@/mesh/controlPlaneStatusClient';
|
||||
import { beginInfonetTerminalSession } from '@/lib/infonetTerminalSession';
|
||||
import InfonetShell from './InfonetShell';
|
||||
|
||||
interface InfonetTerminalProps {
|
||||
@@ -39,16 +35,8 @@ export default function InfonetTerminal({
|
||||
|
||||
const connectParticipantNode = async () => {
|
||||
try {
|
||||
const nodeStatus = await fetchInfonetNodeStatusSnapshot(true).catch(() => null);
|
||||
if (cancelled || nodeStatus?.node_enabled) return;
|
||||
|
||||
const torStatus = await startTorHiddenService().catch(() => null);
|
||||
if (cancelled || !torStatus?.running || !torStatus?.onion_address) return;
|
||||
|
||||
await setInfonetNodeEnabled(true);
|
||||
if (!cancelled) {
|
||||
await fetchInfonetNodeStatusSnapshot(true).catch(() => null);
|
||||
}
|
||||
if (cancelled) return;
|
||||
await beginInfonetTerminalSession();
|
||||
} catch {
|
||||
// Remote/shared viewers may not have local-operator rights. Leave manual controls intact.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,651 @@
|
||||
'use client';
|
||||
|
||||
|
||||
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Terminal } from 'lucide-react';
|
||||
|
||||
import { Terminal as XTerm } from '@xterm/xterm';
|
||||
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
import { resolveAgentShellWsUrl } from '@/lib/agentShellWs';
|
||||
|
||||
|
||||
|
||||
const SHELL_FONT_PX = 14;
|
||||
|
||||
const CWD_STORAGE_KEY = 'sb_agent_shell_cwd';
|
||||
|
||||
const INTRO_ACK_KEY = 'sb_agent_shell_intro_ack';
|
||||
|
||||
|
||||
|
||||
type Props = {
|
||||
|
||||
active: boolean;
|
||||
|
||||
expanded: boolean;
|
||||
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
function readStoredCwd(): string {
|
||||
|
||||
if (typeof window === 'undefined') return '';
|
||||
|
||||
try {
|
||||
|
||||
return window.localStorage.getItem(CWD_STORAGE_KEY) || '';
|
||||
|
||||
} catch {
|
||||
|
||||
return '';
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function readIntroAcknowledged(): boolean {
|
||||
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
try {
|
||||
|
||||
return window.localStorage.getItem(INTRO_ACK_KEY) === '1';
|
||||
|
||||
} catch {
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function ShellIntro({ onAcknowledge }: { onAcknowledge: () => void }) {
|
||||
|
||||
return (
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col items-center justify-center px-5 py-8 text-center border-l-2 border-cyan-800/20 bg-[#04070b]">
|
||||
|
||||
<div className="w-full max-w-sm border border-cyan-900/40 bg-cyan-950/10 px-5 py-6">
|
||||
|
||||
<div className="inline-flex items-center justify-center w-10 h-10 border border-cyan-700/40 bg-black/30 text-cyan-300 mb-4">
|
||||
|
||||
<Terminal size={18} />
|
||||
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-mono tracking-[0.22em] text-cyan-300 mb-3">OPERATOR SHELL</div>
|
||||
|
||||
<p className="text-[13px] font-mono text-[var(--text-secondary)] leading-[1.75] text-left">
|
||||
|
||||
Connect your own agent CLIs here — OpenClaw, Codex, Gemini, or whatever you run locally.
|
||||
|
||||
</p>
|
||||
|
||||
<p className="mt-3 text-[13px] font-mono text-[var(--text-secondary)] leading-[1.75] text-left">
|
||||
|
||||
The session opens in your Shadowbroker workspace by default. Use it for repo scripts, mesh
|
||||
|
||||
tools, or any terminal workflow you already rely on.
|
||||
|
||||
</p>
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
onClick={onAcknowledge}
|
||||
|
||||
className="mt-5 w-full px-4 py-2.5 text-sm font-mono tracking-[0.18em] text-cyan-200 border border-cyan-600/50 bg-cyan-950/30 hover:bg-cyan-950/50 hover:border-cyan-400/60 transition-colors"
|
||||
|
||||
>
|
||||
|
||||
OPEN SHELL
|
||||
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function AgentShellPanel({ active, expanded, onExpandedChange }: Props) {
|
||||
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
const [introAcknowledged, setIntroAcknowledged] = useState(false);
|
||||
|
||||
const [status, setStatus] = useState<'idle' | 'connecting' | 'open' | 'error'>('idle');
|
||||
|
||||
const [statusDetail, setStatusDetail] = useState('');
|
||||
|
||||
const [cwd, setCwd] = useState('');
|
||||
|
||||
|
||||
|
||||
const shellReady = active && introAcknowledged;
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
setIntroAcknowledged(readIntroAcknowledged());
|
||||
|
||||
}, [active]);
|
||||
|
||||
|
||||
|
||||
const acknowledgeIntro = useCallback(() => {
|
||||
|
||||
try {
|
||||
|
||||
window.localStorage.setItem(INTRO_ACK_KEY, '1');
|
||||
|
||||
} catch {
|
||||
|
||||
// still allow in-session access if storage is blocked
|
||||
|
||||
}
|
||||
|
||||
setIntroAcknowledged(true);
|
||||
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
|
||||
wsRef.current?.close();
|
||||
|
||||
wsRef.current = null;
|
||||
|
||||
termRef.current?.dispose();
|
||||
|
||||
termRef.current = null;
|
||||
|
||||
fitRef.current = null;
|
||||
|
||||
setStatus('idle');
|
||||
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
const fitTerminal = useCallback(() => {
|
||||
|
||||
const fit = fitRef.current;
|
||||
|
||||
const term = termRef.current;
|
||||
|
||||
const ws = wsRef.current;
|
||||
|
||||
if (!fit || !term) return;
|
||||
|
||||
fit.fit();
|
||||
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
|
||||
ws.send(
|
||||
|
||||
JSON.stringify({
|
||||
|
||||
type: 'resize',
|
||||
|
||||
cols: term.cols,
|
||||
|
||||
rows: term.rows,
|
||||
|
||||
}),
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
const connect = useCallback(() => {
|
||||
|
||||
if (!hostRef.current) return;
|
||||
|
||||
if (wsRef.current) {
|
||||
|
||||
wsRef.current.close();
|
||||
|
||||
wsRef.current = null;
|
||||
|
||||
}
|
||||
|
||||
if (termRef.current) {
|
||||
|
||||
termRef.current.dispose();
|
||||
|
||||
termRef.current = null;
|
||||
|
||||
fitRef.current = null;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const term = new XTerm({
|
||||
|
||||
fontFamily: 'var(--font-roboto-mono), ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
||||
|
||||
fontSize: SHELL_FONT_PX,
|
||||
|
||||
lineHeight: 1.35,
|
||||
|
||||
cursorBlink: true,
|
||||
|
||||
theme: {
|
||||
|
||||
background: '#04070b',
|
||||
|
||||
foreground: '#d9f7ff',
|
||||
|
||||
cursor: '#22d3ee',
|
||||
|
||||
selectionBackground: '#0e7490',
|
||||
|
||||
},
|
||||
|
||||
scrollback: 5000,
|
||||
|
||||
});
|
||||
|
||||
const fit = new FitAddon();
|
||||
|
||||
term.loadAddon(fit);
|
||||
|
||||
term.open(hostRef.current);
|
||||
|
||||
fit.fit();
|
||||
|
||||
termRef.current = term;
|
||||
|
||||
fitRef.current = fit;
|
||||
|
||||
|
||||
|
||||
const storedCwd = readStoredCwd();
|
||||
|
||||
setCwd(storedCwd);
|
||||
|
||||
setStatus('connecting');
|
||||
|
||||
setStatusDetail('');
|
||||
|
||||
|
||||
|
||||
const ws = new WebSocket(resolveAgentShellWsUrl(storedCwd));
|
||||
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
wsRef.current = ws;
|
||||
|
||||
|
||||
|
||||
ws.onopen = () => {
|
||||
|
||||
setStatus('open');
|
||||
|
||||
fit.fit();
|
||||
|
||||
ws.send(
|
||||
|
||||
JSON.stringify({
|
||||
|
||||
type: 'resize',
|
||||
|
||||
cols: term.cols,
|
||||
|
||||
rows: term.rows,
|
||||
|
||||
}),
|
||||
|
||||
);
|
||||
|
||||
term.focus();
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
|
||||
if (typeof event.data === 'string') {
|
||||
|
||||
try {
|
||||
|
||||
const payload = JSON.parse(event.data) as { type?: string; message?: string };
|
||||
|
||||
if (payload.type === 'error') {
|
||||
|
||||
setStatus('error');
|
||||
|
||||
setStatusDetail(payload.message || 'Shell unavailable');
|
||||
|
||||
term.writeln(`\r\n\x1b[31m${payload.message || 'Shell unavailable'}\x1b[0m`);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
} catch {
|
||||
|
||||
term.write(event.data);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
|
||||
term.write(new Uint8Array(event.data));
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
ws.onerror = () => {
|
||||
|
||||
setStatus('error');
|
||||
|
||||
setStatusDetail('Could not connect to the local agent shell endpoint.');
|
||||
|
||||
term.writeln('\r\n\x1b[31mCould not connect to the local agent shell endpoint.\x1b[0m');
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
ws.onclose = (event) => {
|
||||
|
||||
if (event.code === 4403) {
|
||||
|
||||
setStatus('error');
|
||||
|
||||
setStatusDetail('Local operator access only — reload the dashboard on localhost and retry.');
|
||||
|
||||
term.writeln('\r\n\x1b[31mShell blocked: local operator access only.\x1b[0m');
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
setStatus((prev) => (prev === 'error' ? prev : 'idle'));
|
||||
|
||||
if (event.code !== 1000) {
|
||||
|
||||
term.writeln(`\r\n\x1b[90m[session closed: ${event.code}]\x1b[0m`);
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
term.onData((data) => {
|
||||
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
|
||||
ws.send(new TextEncoder().encode(data));
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
useLayoutEffect(() => {
|
||||
|
||||
if (!shellReady) {
|
||||
|
||||
disconnect();
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const start = () => {
|
||||
|
||||
if (cancelled || !hostRef.current) return;
|
||||
|
||||
if (!wsRef.current || wsRef.current.readyState === WebSocket.CLOSED) {
|
||||
|
||||
connect();
|
||||
|
||||
} else {
|
||||
|
||||
fitTerminal();
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const frame = requestAnimationFrame(() => requestAnimationFrame(start));
|
||||
|
||||
return () => {
|
||||
|
||||
cancelled = true;
|
||||
|
||||
cancelAnimationFrame(frame);
|
||||
|
||||
};
|
||||
|
||||
}, [shellReady, connect, disconnect, fitTerminal]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (!shellReady) return;
|
||||
|
||||
const host = hostRef.current;
|
||||
|
||||
if (!host) return;
|
||||
|
||||
const ro = new ResizeObserver(() => fitTerminal());
|
||||
|
||||
ro.observe(host);
|
||||
|
||||
const timer = window.setTimeout(() => fitTerminal(), expanded ? 240 : 32);
|
||||
|
||||
return () => {
|
||||
|
||||
ro.disconnect();
|
||||
|
||||
window.clearTimeout(timer);
|
||||
|
||||
};
|
||||
|
||||
}, [shellReady, expanded, fitTerminal]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (!shellReady) return;
|
||||
|
||||
const onResize = () => fitTerminal();
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
|
||||
}, [shellReady, fitTerminal]);
|
||||
|
||||
|
||||
|
||||
if (!active) {
|
||||
|
||||
return (
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col items-center justify-center px-4 py-6 text-center border-l-2 border-cyan-800/20">
|
||||
|
||||
<Terminal size={16} className="text-cyan-400 mb-2" />
|
||||
|
||||
<div className="text-sm font-mono tracking-[0.2em] text-cyan-300">LOCAL SHELL</div>
|
||||
|
||||
<div className="mt-2 text-[13px] font-mono text-[var(--text-secondary)] leading-relaxed">
|
||||
|
||||
Expand Meshtastic Chat to open your operator shell.
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (!introAcknowledged) {
|
||||
|
||||
return <ShellIntro onAcknowledge={acknowledgeIntro} />;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col border-l-2 border-cyan-800/25 bg-[#04070b]">
|
||||
|
||||
<div className="flex items-center justify-between gap-2 border-b border-cyan-900/40 px-2 py-1.5 shrink-0">
|
||||
|
||||
<div className="min-w-0 text-[12px] font-mono tracking-[0.14em] text-cyan-300/90 truncate">
|
||||
|
||||
{cwd ? cwd : 'operator shell'}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
|
||||
{!expanded ? (
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
onClick={() => onExpandedChange(true)}
|
||||
|
||||
className="px-2 py-0.5 text-[11px] font-mono tracking-[0.12em] text-cyan-300 border border-cyan-800/40 hover:bg-cyan-950/30"
|
||||
|
||||
>
|
||||
|
||||
EXPAND
|
||||
|
||||
</button>
|
||||
|
||||
) : (
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
onClick={() => onExpandedChange(false)}
|
||||
|
||||
className="px-2 py-0.5 text-[11px] font-mono tracking-[0.12em] text-cyan-300 border border-cyan-800/40 hover:bg-cyan-950/30"
|
||||
|
||||
>
|
||||
|
||||
SNAP
|
||||
|
||||
</button>
|
||||
|
||||
)}
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
onClick={connect}
|
||||
|
||||
className="px-2 py-0.5 text-[11px] font-mono tracking-[0.12em] text-slate-400 border border-slate-700/40 hover:bg-white/5"
|
||||
|
||||
>
|
||||
|
||||
RECONNECT
|
||||
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{status === 'error' && statusDetail && (
|
||||
|
||||
<div className="px-2 py-1 text-[12px] font-mono text-amber-300/90 border-b border-amber-900/30 bg-amber-950/10 shrink-0">
|
||||
|
||||
{statusDetail}
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{status === 'connecting' && (
|
||||
|
||||
<div className="px-2 py-1 text-[11px] font-mono text-slate-500 border-b border-cyan-900/20 shrink-0">
|
||||
|
||||
Connecting…
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
<div
|
||||
|
||||
ref={hostRef}
|
||||
|
||||
className="flex-1 min-h-[220px] min-w-0 px-1 py-1 overflow-hidden [&_.xterm]:h-full [&_.xterm]:w-full [&_.xterm-viewport]:!overflow-y-auto"
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { beginInfonetTerminalSession } from '@/lib/infonetTerminalSession';
|
||||
import InfonetShell from '@/components/InfonetTerminal/InfonetShell';
|
||||
|
||||
type Props = {
|
||||
active: boolean;
|
||||
expanded: boolean;
|
||||
wormholeBusy: boolean;
|
||||
launchGate?: string;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
onEnterWormhole: () => Promise<void>;
|
||||
onTeardown: () => void;
|
||||
onLaunchGateConsumed?: () => void;
|
||||
onOpenDeadDrop?: (peerId: string, options?: { showSas?: boolean }) => void;
|
||||
};
|
||||
|
||||
function InfonetIntro({
|
||||
busy,
|
||||
status,
|
||||
error,
|
||||
onEnter,
|
||||
}: {
|
||||
busy: boolean;
|
||||
status: string;
|
||||
error: string;
|
||||
onEnter: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex flex-col items-center justify-center px-3 py-4 text-center border-l-2 border-cyan-800/20 bg-[#04070b]">
|
||||
<div className="w-full max-w-sm border border-cyan-900/40 bg-cyan-950/10 px-4 py-4">
|
||||
<div className="text-sm font-mono tracking-[0.22em] text-cyan-300 mb-2">INFONET TERMINAL</div>
|
||||
<p className="text-[12px] font-mono text-[var(--text-secondary)] leading-[1.6] text-left">
|
||||
Obfuscated Wormhole lane for the Infonet shell. Leave this tab to shut Wormhole down.
|
||||
</p>
|
||||
{status && !error && (
|
||||
<div className="mt-3 text-left text-[12px] font-mono text-cyan-300/85 leading-relaxed border border-cyan-900/30 bg-cyan-950/10 px-3 py-2">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mt-3 text-left text-[12px] font-mono text-amber-300/90 leading-relaxed border border-amber-900/30 bg-amber-950/10 px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEnter}
|
||||
disabled={busy}
|
||||
className="mt-3 w-full px-4 py-2 text-sm font-mono tracking-[0.18em] text-cyan-200 border border-cyan-600/50 bg-cyan-950/30 hover:bg-cyan-950/50 hover:border-cyan-400/60 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
{busy ? 'OPENING…' : 'ENTER WORMHOLE'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InfonetTerminalPanel({
|
||||
active,
|
||||
expanded,
|
||||
wormholeBusy,
|
||||
launchGate,
|
||||
onExpandedChange,
|
||||
onEnterWormhole,
|
||||
onTeardown,
|
||||
onLaunchGateConsumed,
|
||||
onOpenDeadDrop,
|
||||
}: Props) {
|
||||
const [sessionActive, setSessionActive] = useState(false);
|
||||
const [laneBusy, setLaneBusy] = useState(false);
|
||||
const [laneError, setLaneError] = useState('');
|
||||
const [laneStatus, setLaneStatus] = useState('');
|
||||
const prepStartedRef = useRef(false);
|
||||
|
||||
const shellOpen = active && sessionActive;
|
||||
|
||||
const resetSession = useCallback(() => {
|
||||
setSessionActive(false);
|
||||
setLaneBusy(false);
|
||||
setLaneError('');
|
||||
setLaneStatus('');
|
||||
prepStartedRef.current = false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) return;
|
||||
resetSession();
|
||||
onTeardown();
|
||||
}, [active, onTeardown, resetSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shellOpen) return;
|
||||
let cancelled = false;
|
||||
|
||||
const connectParticipantNode = async () => {
|
||||
try {
|
||||
if (cancelled) return;
|
||||
await beginInfonetTerminalSession();
|
||||
} catch {
|
||||
// Remote viewers may not have local-operator rights.
|
||||
}
|
||||
};
|
||||
|
||||
void connectParticipantNode();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [shellOpen]);
|
||||
|
||||
const startWormholeLane = useCallback(async () => {
|
||||
if (prepStartedRef.current) return;
|
||||
prepStartedRef.current = true;
|
||||
setLaneError('');
|
||||
setLaneStatus('Starting Wormhole obfuscated lane…');
|
||||
setLaneBusy(true);
|
||||
try {
|
||||
await onEnterWormhole();
|
||||
setLaneStatus('');
|
||||
} catch (err) {
|
||||
prepStartedRef.current = false;
|
||||
setSessionActive(false);
|
||||
const message =
|
||||
typeof err === 'object' && err !== null && 'message' in err
|
||||
? String((err as { message?: string }).message)
|
||||
: 'Could not start Wormhole.';
|
||||
setLaneError(message);
|
||||
setLaneStatus('');
|
||||
} finally {
|
||||
setLaneBusy(false);
|
||||
}
|
||||
}, [onEnterWormhole]);
|
||||
|
||||
const handleEnter = useCallback(() => {
|
||||
setLaneError('');
|
||||
setLaneStatus('');
|
||||
setSessionActive(true);
|
||||
onExpandedChange(true);
|
||||
void startWormholeLane();
|
||||
}, [onExpandedChange, startWormholeLane]);
|
||||
|
||||
const handleShellClose = useCallback(() => {
|
||||
resetSession();
|
||||
onTeardown();
|
||||
}, [onTeardown, resetSession]);
|
||||
|
||||
if (!active) {
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex flex-col items-center justify-center px-4 py-6 text-center border-l-2 border-cyan-800/20">
|
||||
<Shield size={16} className="text-cyan-400 mb-2" />
|
||||
<div className="text-sm font-mono tracking-[0.2em] text-cyan-300">INFONET TERMINAL</div>
|
||||
<div className="mt-2 text-[13px] font-mono text-[var(--text-secondary)] leading-relaxed">
|
||||
Expand Meshtastic Chat to open the Wormhole terminal.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!sessionActive) {
|
||||
return (
|
||||
<InfonetIntro
|
||||
busy={laneBusy || wormholeBusy}
|
||||
status={laneStatus}
|
||||
error={laneError}
|
||||
onEnter={handleEnter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex flex-col border-l-2 border-cyan-800/25 bg-[#04070b]">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-cyan-900/40 px-2 py-1.5 shrink-0">
|
||||
<div className="min-w-0 text-[12px] font-mono tracking-[0.14em] text-cyan-300/90 truncate">
|
||||
{laneBusy ? 'wormhole · obfuscated lane starting' : 'infonet terminal'}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{!expanded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExpandedChange(true)}
|
||||
className="px-2 py-0.5 text-[11px] font-mono tracking-[0.12em] text-cyan-300 border border-cyan-800/40 hover:bg-cyan-950/30"
|
||||
>
|
||||
EXPAND
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExpandedChange(false)}
|
||||
className="px-2 py-0.5 text-[11px] font-mono tracking-[0.12em] text-cyan-300 border border-cyan-800/40 hover:bg-cyan-950/30"
|
||||
>
|
||||
SNAP
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShellClose}
|
||||
className="px-2 py-0.5 text-[11px] font-mono tracking-[0.12em] text-slate-400 border border-slate-700/40 hover:bg-white/5"
|
||||
>
|
||||
LEAVE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{laneError && (
|
||||
<div className="px-2 py-1 text-[12px] font-mono text-amber-300/90 border-b border-amber-900/30 bg-amber-950/10 shrink-0">
|
||||
{laneError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0 min-w-0 overflow-hidden infonet-font">
|
||||
<InfonetShell
|
||||
isOpen={shellOpen}
|
||||
embedded
|
||||
launchGate={launchGate}
|
||||
onLaunchGateConsumed={onLaunchGateConsumed}
|
||||
onClose={handleShellClose}
|
||||
onOpenDeadDrop={onOpenDeadDrop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
export type MeshChatFlyoutRect = {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export function measureMeshChatFlyout(
|
||||
anchor: DOMRect,
|
||||
maxWidth: number,
|
||||
minHeight: number,
|
||||
): MeshChatFlyoutRect {
|
||||
const width = Math.min(maxWidth, Math.max(320, window.innerWidth - 48));
|
||||
const height = Math.min(
|
||||
Math.max(anchor.height, minHeight),
|
||||
window.innerHeight - anchor.top - 36,
|
||||
);
|
||||
return {
|
||||
top: anchor.top,
|
||||
left: anchor.left,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
export const SHELL_FLYOUT_WIDTH = 760;
|
||||
export const SHELL_FLYOUT_MIN_HEIGHT = 420;
|
||||
export const INFONET_FLYOUT_WIDTH = 960;
|
||||
export const INFONET_FLYOUT_MIN_HEIGHT = 480;
|
||||
@@ -173,6 +173,8 @@ export interface MeshChatProps {
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
onSettingsClick?: () => void;
|
||||
onTerminalToggle?: () => void;
|
||||
onOpenLiveGate?: (gate: string) => void;
|
||||
onOpenDeadDrop?: (peerId: string, options?: { showSas?: boolean }) => void;
|
||||
launchRequest?: { tab: Tab; gate?: string; peerId?: string; showSas?: boolean; nonce: number } | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
addContact,
|
||||
updateContact,
|
||||
blockContact,
|
||||
severContact,
|
||||
getDMNotify,
|
||||
nextSequence,
|
||||
verifyEventSignature,
|
||||
@@ -91,6 +92,7 @@ import {
|
||||
rotateWormholePairwiseAlias,
|
||||
listWormholeGatePersonas,
|
||||
postWormholeGateMessage,
|
||||
prepareWormholeInteractiveLane,
|
||||
recoverWormholeSasRootContinuity,
|
||||
resyncWormholeGateState,
|
||||
retireWormholeGatePersona,
|
||||
@@ -102,6 +104,7 @@ import {
|
||||
isEncryptedGateEnvelope,
|
||||
} from '@/mesh/gateEnvelope';
|
||||
import { fetchWormholeSettings, joinWormhole, leaveWormhole } from '@/mesh/wormholeClient';
|
||||
import { connectDeliveryMeta, ensureDmOutboxReleased } from '@/mesh/dmConnectDelivery';
|
||||
import {
|
||||
buildMailboxClaims,
|
||||
countDmMailboxes,
|
||||
@@ -317,7 +320,7 @@ function errorMessage(err: unknown, fallback: string = 'unknown error'): string
|
||||
|
||||
function describeMeshChatControlError(raw: string): string {
|
||||
const message = String(raw || '').trim();
|
||||
if (!message) return 'MeshChat could not update the local control plane.';
|
||||
if (!message) return 'Meshtastic Chat could not update the local control plane.';
|
||||
if (
|
||||
message === 'control_plane_request_failed:530' ||
|
||||
message === 'HTTP 530' ||
|
||||
@@ -336,7 +339,7 @@ function describeMeshChatControlError(raw: string): string {
|
||||
return 'This control action needs a local operator session. Open Settings or Node controls once so the app can authorize local changes, then try Mesh again.';
|
||||
}
|
||||
if (message.startsWith('{') || message.startsWith('<')) {
|
||||
return 'MeshChat could not update the local control plane. Check the backend log for the upstream error.';
|
||||
return 'Meshtastic Chat could not update the local control plane. Check the backend log for the upstream error.';
|
||||
}
|
||||
return message;
|
||||
}
|
||||
@@ -413,7 +416,7 @@ export function useMeshChatController({
|
||||
setInternalExpanded(newVal);
|
||||
onExpandedChange?.(newVal);
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState<Tab>('meshtastic');
|
||||
const [activeTab, setActiveTab] = useState<Tab>('dms');
|
||||
const openTerminal = useCallback(() => {
|
||||
if (onTerminalToggle) {
|
||||
onTerminalToggle();
|
||||
@@ -471,7 +474,6 @@ export function useMeshChatController({
|
||||
const privateInfonetReady = wormholeEnabled && wormholeReadyState;
|
||||
const publicMeshBlockedByWormhole = wormholeEnabled || wormholeReadyState;
|
||||
const dmSendQueue = useRef<(() => Promise<void>)[]>([]);
|
||||
const infonetAutoBootstrapRef = useRef(false);
|
||||
const meshMqttRuntime = meshMqttSettings?.runtime;
|
||||
const meshMqttEnabled = Boolean(meshMqttSettings?.enabled || meshMqttRuntime?.enabled);
|
||||
const canUsePublicMeshInput = Boolean(activePublicMeshAddress) && meshMqttEnabled && !publicMeshBlockedByWormhole;
|
||||
@@ -905,6 +907,7 @@ export function useMeshChatController({
|
||||
// ─── InfoNet State ───────────────────────────────────────────────────────
|
||||
const [gates, setGates] = useState<Gate[]>([]);
|
||||
const [selectedGate, setSelectedGate] = useState<string>('');
|
||||
const [infonetLaunchGate, setInfonetLaunchGate] = useState('');
|
||||
const [infoMessages, setInfoMessages] = useState<InfoNetMessage[]>([]);
|
||||
const [infoVerification, setInfoVerification] = useState<
|
||||
Record<string, 'verified' | 'failed' | 'unsigned'>
|
||||
@@ -1335,7 +1338,7 @@ export function useMeshChatController({
|
||||
setExpanded(true);
|
||||
setActiveTab(launchRequest.tab);
|
||||
if (launchRequest.tab === 'infonet' && launchRequest.gate) {
|
||||
setSelectedGate(String(launchRequest.gate || '').trim().toLowerCase());
|
||||
setInfonetLaunchGate(String(launchRequest.gate || '').trim().toLowerCase());
|
||||
}
|
||||
if (launchRequest.tab === 'dms') {
|
||||
const peerId = String(launchRequest.peerId || '').trim();
|
||||
@@ -2294,6 +2297,7 @@ export function useMeshChatController({
|
||||
API_BASE,
|
||||
m.sender_id,
|
||||
senderContact?.invitePinnedPrekeyLookupHandle,
|
||||
{ lookupPeerUrl: senderContact?.invitePinnedLookupPeerUrl },
|
||||
);
|
||||
if (senderKey?.dh_pub_key) {
|
||||
const sharedKey = await deriveSharedKey(String(senderKey.dh_pub_key));
|
||||
@@ -2309,6 +2313,7 @@ export function useMeshChatController({
|
||||
API_BASE,
|
||||
m.sender_id,
|
||||
senderContact?.invitePinnedPrekeyLookupHandle,
|
||||
{ lookupPeerUrl: senderContact?.invitePinnedLookupPeerUrl },
|
||||
).catch(() => null);
|
||||
if (senderKey?.dh_pub_key) {
|
||||
addContact(m.sender_id, String(senderKey.dh_pub_key), undefined, senderKey.dh_algo);
|
||||
@@ -2682,7 +2687,7 @@ export function useMeshChatController({
|
||||
openIdentityWizard({
|
||||
type: 'err',
|
||||
text: hasStoredPublicLaneIdentity
|
||||
? 'Quick fix: turn MeshChat on below, then retry your send.'
|
||||
? 'Quick fix: turn Meshtastic Chat on below, then retry your send.'
|
||||
: 'Quick fix: create a public mesh identity below, then retry your send.',
|
||||
});
|
||||
setTimeout(() => setSendError(''), 4000);
|
||||
@@ -3335,7 +3340,9 @@ export function useMeshChatController({
|
||||
'import or re-import a signed invite before refreshing this contact; legacy direct lookup is disabled',
|
||||
);
|
||||
}
|
||||
const registry = await fetchDmPublicKey(API_BASE, targetId, lookupHandle).catch(() => null);
|
||||
const registry = await fetchDmPublicKey(API_BASE, targetId, lookupHandle, {
|
||||
lookupPeerUrl: existing?.invitePinnedLookupPeerUrl,
|
||||
}).catch(() => null);
|
||||
if (!registry?.dh_pub_key) {
|
||||
throw new Error(
|
||||
'invite-scoped lookup failed for this contact; re-import a signed invite and try again',
|
||||
@@ -3584,29 +3591,26 @@ export function useMeshChatController({
|
||||
setTimeout(() => setSendError(''), 3000);
|
||||
return;
|
||||
}
|
||||
if (requiresVerifiedFirstContact(getContacts()[targetId])) {
|
||||
setSendError('import a signed invite before first secure contact; TOFU requests are disabled');
|
||||
setTimeout(() => setSendError(''), 4000);
|
||||
return;
|
||||
}
|
||||
if (wormholeEnabled && !wormholeReadyState) {
|
||||
setSendError('wormhole required for dead drop');
|
||||
setTimeout(() => setSendError(''), 3000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const registration = await ensureRegisteredDmKey(API_BASE, identity!, { force: false });
|
||||
const myPub = registration.dhPubKey;
|
||||
if (!myPub) return;
|
||||
const dhAlgo = registration.dhAlgo || getDHAlgo() || 'X25519';
|
||||
const targetContact = getContacts()[targetId];
|
||||
const lookupHandle = String(targetContact?.invitePinnedPrekeyLookupHandle || '').trim();
|
||||
let lookupHandle = String(targetContact?.invitePinnedPrekeyLookupHandle || '').trim();
|
||||
let resolvedTargetId = targetId;
|
||||
if (!lookupHandle && /^[a-fA-F0-9]{32,}$/.test(targetId)) {
|
||||
lookupHandle = targetId;
|
||||
resolvedTargetId = '';
|
||||
}
|
||||
if (!lookupHandle) {
|
||||
throw new Error(
|
||||
'import or re-import a signed invite before sending a contact request; legacy direct lookup is disabled',
|
||||
'Paste their short contact address (from Secure Messages → Copy Short Address), not their node id.',
|
||||
);
|
||||
}
|
||||
const targetKey = await fetchDmPublicKey(API_BASE, targetId, lookupHandle);
|
||||
const targetKey = await fetchDmPublicKey(API_BASE, resolvedTargetId, lookupHandle, {
|
||||
lookupPeerUrl: targetContact?.invitePinnedLookupPeerUrl,
|
||||
});
|
||||
if (!targetKey?.dh_pub_key) {
|
||||
throw new Error(
|
||||
'invite-scoped lookup failed for this contact; re-import a signed invite and try again',
|
||||
@@ -3630,12 +3634,13 @@ export function useMeshChatController({
|
||||
geoHint = '';
|
||||
}
|
||||
}
|
||||
const recipientId = String(targetKey.agent_id || resolvedTargetId || targetId).trim();
|
||||
const requestPlaintext = buildContactOfferMessage(myPub, dhAlgo, geoHint || undefined);
|
||||
let ciphertext = '';
|
||||
const secureRequired = await isWormholeSecureRequired();
|
||||
if (await canUseWormholeBootstrap()) {
|
||||
try {
|
||||
ciphertext = await bootstrapEncryptAccessRequest(targetId, requestPlaintext);
|
||||
ciphertext = await bootstrapEncryptAccessRequest(recipientId, requestPlaintext);
|
||||
} catch {
|
||||
ciphertext = '';
|
||||
}
|
||||
@@ -3650,16 +3655,24 @@ export function useMeshChatController({
|
||||
const msgId = `dm_${Date.now()}_${identity!.nodeId.slice(-4)}`;
|
||||
const msgTimestamp = Math.floor(Date.now() / 1000);
|
||||
await sleep(jitterDelay(ACCESS_REQUEST_BATCH_DELAY_MS, ACCESS_REQUEST_BATCH_JITTER_MS));
|
||||
const connectMeta = connectDeliveryMeta({
|
||||
intent: lookupHandle === targetId ? 'invite_short_address' : 'contact_request',
|
||||
contact: targetContact,
|
||||
});
|
||||
await enqueueDmSend(async () => {
|
||||
const sent = await sendOffLedgerConsentMessage({
|
||||
apiBase: API_BASE,
|
||||
identity: identity!,
|
||||
recipientId: targetId,
|
||||
recipientDhPub: String(targetKey.dh_pub_key),
|
||||
ciphertext,
|
||||
msgId,
|
||||
timestamp: msgTimestamp,
|
||||
});
|
||||
const sent = await ensureDmOutboxReleased(
|
||||
await sendOffLedgerConsentMessage({
|
||||
apiBase: API_BASE,
|
||||
identity: identity!,
|
||||
recipientId,
|
||||
recipientDhPub: String(targetKey.dh_pub_key),
|
||||
ciphertext,
|
||||
msgId,
|
||||
timestamp: msgTimestamp,
|
||||
connectIntent: connectMeta.connectIntent,
|
||||
lookupPeerUrl: connectMeta.lookupPeerUrl,
|
||||
}),
|
||||
);
|
||||
if (!sent.ok) {
|
||||
throw new Error(sent.detail || 'access_request_send_failed');
|
||||
}
|
||||
@@ -3667,7 +3680,8 @@ export function useMeshChatController({
|
||||
setLastDmTransport(sent.transport);
|
||||
}
|
||||
});
|
||||
const updated = [...pendingSent, targetId];
|
||||
const recipientForPending = String(targetKey.agent_id || resolvedTargetId || targetId).trim();
|
||||
const updated = [...pendingSent, recipientForPending];
|
||||
setPendingSent(updated, dmConsentScopeId);
|
||||
setPendingSentState(updated);
|
||||
} catch (err) {
|
||||
@@ -3679,11 +3693,6 @@ export function useMeshChatController({
|
||||
|
||||
const handleAcceptRequest = async (senderId: string) => {
|
||||
if (!hasId) return;
|
||||
if (requiresVerifiedFirstContact(getContacts()[senderId])) {
|
||||
setSendError('import a signed invite before accepting an unverified request');
|
||||
setTimeout(() => setSendError(''), 4000);
|
||||
return;
|
||||
}
|
||||
if (anonymousDmBlocked) {
|
||||
setSendError('hidden transport required for anonymous dm');
|
||||
setTimeout(() => setSendError(''), 3000);
|
||||
@@ -3696,6 +3705,7 @@ export function useMeshChatController({
|
||||
API_BASE,
|
||||
senderId,
|
||||
existingContact?.invitePinnedPrekeyLookupHandle,
|
||||
{ lookupPeerUrl: existingContact?.invitePinnedLookupPeerUrl },
|
||||
).catch(() => null);
|
||||
const resolvedDhPubKey = String(registry?.dh_pub_key || req?.dh_pub_key || '').trim();
|
||||
const resolvedDhAlgo = String(registry?.dh_algo || req?.dh_algo || 'X25519').trim();
|
||||
@@ -3842,16 +3852,24 @@ export function useMeshChatController({
|
||||
}
|
||||
const msgId = `dm_${Date.now()}_${identity!.nodeId.slice(-4)}`;
|
||||
const msgTimestamp = Math.floor(Date.now() / 1000);
|
||||
const acceptMeta = connectDeliveryMeta({
|
||||
intent: 'contact_accept',
|
||||
contact: existingContact,
|
||||
});
|
||||
await enqueueDmSend(async () => {
|
||||
const sent = await sendOffLedgerConsentMessage({
|
||||
apiBase: API_BASE,
|
||||
identity: identity!,
|
||||
recipientId: senderId,
|
||||
recipientDhPub: resolvedDhPubKey,
|
||||
ciphertext,
|
||||
msgId,
|
||||
timestamp: msgTimestamp,
|
||||
});
|
||||
const sent = await ensureDmOutboxReleased(
|
||||
await sendOffLedgerConsentMessage({
|
||||
apiBase: API_BASE,
|
||||
identity: identity!,
|
||||
recipientId: senderId,
|
||||
recipientDhPub: resolvedDhPubKey,
|
||||
ciphertext,
|
||||
msgId,
|
||||
timestamp: msgTimestamp,
|
||||
connectIntent: acceptMeta.connectIntent,
|
||||
lookupPeerUrl: acceptMeta.lookupPeerUrl,
|
||||
}),
|
||||
);
|
||||
if (!sent.ok) {
|
||||
throw new Error(sent.detail || 'access_granted_send_failed');
|
||||
}
|
||||
@@ -3877,11 +3895,6 @@ export function useMeshChatController({
|
||||
|
||||
const handleDenyRequest = (senderId: string) => {
|
||||
void (async () => {
|
||||
if (requiresVerifiedFirstContact(getContacts()[senderId])) {
|
||||
setSendError('import a signed invite before denying an unverified request');
|
||||
setTimeout(() => setSendError(''), 4000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const req = accessRequests.find((r) => r.sender_id === senderId);
|
||||
const existingContact = getContacts()[senderId];
|
||||
@@ -3892,6 +3905,7 @@ export function useMeshChatController({
|
||||
API_BASE,
|
||||
senderId,
|
||||
existingContact?.invitePinnedPrekeyLookupHandle,
|
||||
{ lookupPeerUrl: existingContact?.invitePinnedLookupPeerUrl },
|
||||
).catch(() => null);
|
||||
if (identity && targetKey?.dh_pub_key) {
|
||||
const denyPlaintext = buildContactDenyMessage('declined');
|
||||
@@ -3934,6 +3948,20 @@ export function useMeshChatController({
|
||||
})();
|
||||
};
|
||||
|
||||
const handleSeverContact = async (agentId: string) => {
|
||||
try {
|
||||
await severContact(agentId);
|
||||
setContacts(getContacts());
|
||||
if (selectedContact === agentId) {
|
||||
setDmView('contacts');
|
||||
}
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : 'end contact failed';
|
||||
setSendError(detail);
|
||||
setTimeout(() => setSendError(''), 4000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlockDM = async (agentId: string) => {
|
||||
blockContact(agentId);
|
||||
setContacts(getContacts());
|
||||
@@ -4122,12 +4150,9 @@ export function useMeshChatController({
|
||||
const dmTrustHint = buildDmTrustHint(selectedContactInfo);
|
||||
const dmTrustPrimaryAction = dmTrustPrimaryActionLabel(selectedContactInfo);
|
||||
const wormholeDescriptor = getWormholeIdentityDescriptor();
|
||||
const dashboardRestrictedTab: boolean = activeTab === 'infonet' || activeTab === 'dms';
|
||||
const dashboardRestrictedTitle = activeTab === 'infonet' ? 'INFONET RESTRICTED' : 'DEAD DROP RESTRICTED';
|
||||
const dashboardRestrictedDetail =
|
||||
activeTab === 'infonet'
|
||||
? 'Private Wormhole gate activity is staying in the terminal for this build. Dashboard integration is coming soon.'
|
||||
: 'Secure Dead Drop stays in the terminal for this build. Dashboard inbox and compose surfaces are coming soon.';
|
||||
const dashboardRestrictedTab = false;
|
||||
const dashboardRestrictedTitle = '';
|
||||
const dashboardRestrictedDetail = '';
|
||||
const selectedGateKey = selectedGate.trim().toLowerCase();
|
||||
const selectedGatePersonaList = selectedGateKey ? gatePersonas[selectedGateKey] || [] : [];
|
||||
const selectedGateActivePersonaId = selectedGateKey ? activeGatePersonaId[selectedGateKey] || '' : '';
|
||||
@@ -4225,7 +4250,7 @@ export function useMeshChatController({
|
||||
(wormholeEnabled && !wormholeReadyState) ||
|
||||
anonymousDmBlocked));
|
||||
const privateInfonetBlockedDetail = !wormholeEnabled
|
||||
? 'INFONET now lives behind Wormhole. Public mesh remains available under the MESH tab.'
|
||||
? 'INFONET now lives behind Wormhole. Meshtastic radio chat remains available under the MESHTASTIC tab.'
|
||||
: !wormholeReadyState
|
||||
? 'Wormhole is enabled, but the local private agent is not ready yet. INFONET stays locked until the private lane is up.'
|
||||
: 'Wormhole is up, but Reticulum is still warming on the private lane. Gate chat can run in transitional mode while strongest transport posture comes online. For strongest content privacy, use Dead Drop.';
|
||||
@@ -4385,13 +4410,13 @@ export function useMeshChatController({
|
||||
setMeshSessionActive(true);
|
||||
setMeshMessages([]);
|
||||
setSendError('');
|
||||
const text = `MeshChat is on. Address ${readyAddress}.`;
|
||||
const text = `Meshtastic Chat is on. Address ${readyAddress}.`;
|
||||
setIdentityWizardStatus({ type: 'ok', text });
|
||||
setMeshQuickStatus(null);
|
||||
return { ok: true as const, text };
|
||||
} catch (err) {
|
||||
const message = describeMeshChatControlError(errorMessage(err));
|
||||
const text = `Could not turn MeshChat on: ${message}`;
|
||||
const text = `Could not turn Meshtastic Chat on: ${message}`;
|
||||
setIdentityWizardStatus({ type: 'err', text });
|
||||
setMeshQuickStatus({ type: 'err', text });
|
||||
return { ok: false as const, text };
|
||||
@@ -4522,21 +4547,58 @@ export function useMeshChatController({
|
||||
}
|
||||
}, [wormholeDescriptor?.nodeId, wormholeEnabled, wormholeReadyState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded || activeTab !== 'infonet') {
|
||||
infonetAutoBootstrapRef.current = false;
|
||||
const enterInfonetWormholeLane = useCallback(async () => {
|
||||
setMeshSessionActive(false);
|
||||
setMeshMessages([]);
|
||||
if (wormholeEnabled && wormholeReadyState) {
|
||||
try {
|
||||
const wormholeIdentity = await fetchWormholeIdentity();
|
||||
setIdentity({
|
||||
publicKey: wormholeIdentity.public_key,
|
||||
privateKey: '',
|
||||
nodeId: wormholeIdentity.node_id,
|
||||
});
|
||||
} catch {
|
||||
// Lane is already up; shell can still open.
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (privateInfonetReady) {
|
||||
infonetAutoBootstrapRef.current = false;
|
||||
return;
|
||||
|
||||
setIdentityWizardBusy(true);
|
||||
try {
|
||||
const prepared = await prepareWormholeInteractiveLane({ bootstrapIdentity: true });
|
||||
const [settings, runtime] = await Promise.all([
|
||||
fetchWormholeSettings(true).catch(() => null),
|
||||
fetchWormholeStatus().catch(() => null),
|
||||
]);
|
||||
const enabled = Boolean(
|
||||
settings?.enabled ?? prepared.settingsEnabled ?? runtime?.running ?? runtime?.ready,
|
||||
);
|
||||
setSecureModeCached(enabled);
|
||||
setWormholeEnabled(enabled);
|
||||
setWormholeReadyState(Boolean(runtime?.ready ?? prepared.ready));
|
||||
setWormholeRnsReady(Boolean(runtime?.rns_ready));
|
||||
setWormholeRnsDirectReady(Boolean(runtime?.rns_private_dm_direct_ready));
|
||||
setWormholeRnsPeers({
|
||||
active: Number(runtime?.rns_active_peers ?? 0),
|
||||
configured: Number(runtime?.rns_configured_peers ?? 0),
|
||||
});
|
||||
if (prepared.identity) {
|
||||
purgeBrowserSigningMaterial();
|
||||
purgeBrowserContactGraph();
|
||||
await purgeBrowserDmState();
|
||||
const hydratedContacts = await hydrateWormholeContacts(true);
|
||||
setContacts(hydratedContacts);
|
||||
setIdentity({
|
||||
publicKey: prepared.identity.public_key,
|
||||
privateKey: '',
|
||||
nodeId: prepared.identity.node_id,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIdentityWizardBusy(false);
|
||||
}
|
||||
if (identityWizardBusy || infonetAutoBootstrapRef.current) return;
|
||||
infonetAutoBootstrapRef.current = true;
|
||||
void handleBootstrapPrivateIdentity().catch(() => {
|
||||
infonetAutoBootstrapRef.current = false;
|
||||
});
|
||||
}, [activeTab, expanded, handleBootstrapPrivateIdentity, identityWizardBusy, privateInfonetReady]);
|
||||
}, [wormholeEnabled, wormholeReadyState]);
|
||||
|
||||
return {
|
||||
// UI state
|
||||
@@ -4716,6 +4778,7 @@ export function useMeshChatController({
|
||||
handleAcceptRequest,
|
||||
handleDenyRequest,
|
||||
handleBlockDM,
|
||||
handleSeverContact,
|
||||
handleVouch,
|
||||
handleAddContact,
|
||||
openChat,
|
||||
@@ -4725,6 +4788,9 @@ export function useMeshChatController({
|
||||
handleLeaveWormholeForPublicMesh,
|
||||
handleResetPublicIdentity,
|
||||
handleBootstrapPrivateIdentity,
|
||||
enterInfonetWormholeLane,
|
||||
infonetLaunchGate,
|
||||
clearInfonetLaunchGate: () => setInfonetLaunchGate(''),
|
||||
handleRefreshSelectedContact,
|
||||
handleResetSelectedContact,
|
||||
handleTrustSelectedRemotePrekey,
|
||||
|
||||
@@ -1059,7 +1059,7 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
|
||||
<div className="flex flex-col gap-1 p-3">
|
||||
{!nodeId && (
|
||||
<div className="text-[12px] text-[var(--text-muted)] font-mono text-center py-6">
|
||||
CONNECT WORMHOLE OR GENERATE IDENTITY IN MESH CHAT FIRST
|
||||
CONNECT WORMHOLE OR GENERATE IDENTITY IN MESHTASTIC CHAT FIRST
|
||||
</div>
|
||||
)}
|
||||
{nodeId && predictions.length === 0 && (
|
||||
@@ -1109,7 +1109,7 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
|
||||
<div className="p-3">
|
||||
{!nodeId && (
|
||||
<div className="text-[12px] text-[var(--text-muted)] font-mono text-center py-6">
|
||||
CONNECT WORMHOLE OR GENERATE IDENTITY IN MESH CHAT FIRST
|
||||
CONNECT WORMHOLE OR GENERATE IDENTITY IN MESHTASTIC CHAT FIRST
|
||||
</div>
|
||||
)}
|
||||
{nodeId && !profile && (
|
||||
|
||||
@@ -1905,7 +1905,7 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
||||
</div>
|
||||
<div className="text-[var(--text-muted)] leading-relaxed">
|
||||
Mesh Terminal stays read-only for sensitive posting and DM actions while
|
||||
the hidden transport policy is active. Use MeshChat for the hardened path.
|
||||
the hidden transport policy is active. Use Meshtastic Chat for the hardened path.
|
||||
</div>
|
||||
<div className="text-[var(--text-muted)] leading-relaxed">
|
||||
Relay fallback reduces metadata protection compared with direct obfuscated
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Database, Clock, X } from 'lucide-react';
|
||||
|
||||
const CURRENT_VERSION = '0.9.82';
|
||||
const CURRENT_VERSION = '0.9.83';
|
||||
const STORAGE_KEY = `shadowbroker_startup_warmup_notice_v${CURRENT_VERSION}`;
|
||||
|
||||
interface StartupWarmupModalProps {
|
||||
|
||||
@@ -30,17 +30,20 @@ import {
|
||||
import {
|
||||
requestMeshTerminalOpen,
|
||||
subscribeSecureMeshTerminalLauncherOpen,
|
||||
subscribeInfonetSessionEnd,
|
||||
} from '@/lib/meshTerminalLauncher';
|
||||
import { purgeBrowserContactGraph, purgeBrowserSigningMaterial, setSecureModeCached, getNodeIdentity, generateNodeKeys } from '@/mesh/meshIdentity';
|
||||
import { purgeBrowserDmState } from '@/mesh/meshDmWorkerClient';
|
||||
import {
|
||||
fetchInfonetNodeStatusSnapshot,
|
||||
joinInfonetSwarm,
|
||||
startTorHiddenService,
|
||||
type InfonetNodeStatusSnapshot,
|
||||
} from '@/mesh/controlPlaneStatusClient';
|
||||
import {
|
||||
fetchWormholeStatus,
|
||||
prepareWormholeInteractiveLane,
|
||||
isWormholePrepAbortedError,
|
||||
} from '@/mesh/wormholeIdentityClient';
|
||||
import { fetchWormholeSettings } from '@/mesh/wormholeClient';
|
||||
import packageJson from '../../package.json';
|
||||
@@ -109,7 +112,7 @@ export default function TopRightControls({
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [launcherOpen, setLauncherOpen] = useState(false);
|
||||
const [nodeStep, setNodeStep] = useState<'prompt' | 'terms' | 'activating' | 'disable'>('prompt');
|
||||
const [activatingPhase, setActivatingPhase] = useState<'keys' | 'peers' | 'sync' | 'done'>('keys');
|
||||
const [activatingPhase, setActivatingPhase] = useState<'keys' | 'peers' | 'swarm' | 'sync' | 'done'>('keys');
|
||||
const activatingPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const activatingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [activatingTimedOut, setActivatingTimedOut] = useState(false);
|
||||
@@ -171,8 +174,15 @@ export default function TopRightControls({
|
||||
});
|
||||
}, [openTerminalLauncher]);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeInfonetSessionEnd(() => {
|
||||
setTerminalLaunchBusy(false);
|
||||
setTerminalLaunchError('');
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeTerminalLauncher = () => {
|
||||
if (terminalLaunchBusy) return;
|
||||
setTerminalLaunchBusy(false);
|
||||
setTerminalLauncherOpen(false);
|
||||
setTerminalLaunchError('');
|
||||
};
|
||||
@@ -214,6 +224,9 @@ export default function TopRightControls({
|
||||
console.info('[top-right] Wormhole terminal launch ready', identityNodeId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isWormholePrepAbortedError(error)) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
typeof error === 'object' && error !== null && 'message' in error
|
||||
? String((error as { message?: string }).message || '')
|
||||
@@ -299,8 +312,11 @@ export default function TopRightControls({
|
||||
await refreshNodeStatus();
|
||||
|
||||
if (enabled) {
|
||||
// Start fast-polling for sync progress
|
||||
setActivatingPhase('swarm');
|
||||
await joinInfonetSwarm().catch(() => null);
|
||||
await refreshNodeStatus();
|
||||
setActivatingPhase('sync');
|
||||
// Start fast-polling for sync progress
|
||||
stopActivatingPolls();
|
||||
activatingPollRef.current = setInterval(async () => {
|
||||
try {
|
||||
@@ -789,7 +805,14 @@ export default function TopRightControls({
|
||||
<div className="mt-2 text-[9px] text-cyan-200/70 normal-case tracking-normal flex flex-wrap gap-x-3">
|
||||
<span>{syncOutcome.toLowerCase()}</span>
|
||||
{(nodeStatus?.total_events ?? 0) > 0 && <span>{nodeStatus?.total_events} {t('node.events')}</span>}
|
||||
{(nodeStatus?.bootstrap?.sync_peer_count ?? 0) > 0 && <span>{nodeStatus?.bootstrap?.sync_peer_count} {t('node.peers')}</span>}
|
||||
{(nodeStatus?.bootstrap?.sync_peer_count ?? 0) > 0 && (
|
||||
<span>
|
||||
{nodeStatus?.bootstrap?.sync_peer_count} {t('node.peers')}
|
||||
{(nodeStatus?.bootstrap?.swarm_sync_peer_count ?? 0) > 0
|
||||
? ` (${nodeStatus?.bootstrap?.swarm_sync_peer_count} swarm)`
|
||||
: ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 text-[11px] text-[var(--text-muted)] normal-case tracking-normal leading-[1.8]">
|
||||
{t('node.keepSyncing')}
|
||||
@@ -855,9 +878,32 @@ export default function TopRightControls({
|
||||
: t('node.peersReady')}
|
||||
</span>
|
||||
</div>
|
||||
{/* Step: Register with swarm */}
|
||||
<div className="flex items-center gap-3 text-[10px] font-mono">
|
||||
{activatingPhase === 'keys' || activatingPhase === 'peers' ? (
|
||||
<span className="w-[11px] h-[11px] shrink-0" />
|
||||
) : activatingPhase === 'swarm' ? (
|
||||
<RefreshCw size={11} className="text-cyan-400 animate-spin shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 size={11} className="text-green-400 shrink-0" />
|
||||
)}
|
||||
<span className={
|
||||
activatingPhase === 'keys' || activatingPhase === 'peers' ? 'text-[var(--text-muted)]'
|
||||
: activatingPhase === 'swarm' ? 'text-cyan-300'
|
||||
: 'text-green-300'
|
||||
}>
|
||||
{activatingPhase === 'swarm'
|
||||
? 'Registering with swarm...'
|
||||
: activatingPhase === 'keys' || activatingPhase === 'peers'
|
||||
? 'Join private Infonet swarm'
|
||||
: `Swarm ready${(nodeStatus?.bootstrap?.swarm_sync_peer_count ?? 0) > 0
|
||||
? ` (${nodeStatus?.bootstrap?.swarm_sync_peer_count} discovered)`
|
||||
: ''}`}
|
||||
</span>
|
||||
</div>
|
||||
{/* Step: Sync chain */}
|
||||
<div className="flex items-center gap-3 text-[10px] font-mono">
|
||||
{(activatingPhase === 'keys' || activatingPhase === 'peers') ? (
|
||||
{(activatingPhase === 'keys' || activatingPhase === 'peers' || activatingPhase === 'swarm') ? (
|
||||
<span className="w-[11px] h-[11px] shrink-0" />
|
||||
) : activatingPhase === 'sync' ? (
|
||||
<RefreshCw size={11} className="text-cyan-400 animate-spin shrink-0" />
|
||||
@@ -865,7 +911,7 @@ export default function TopRightControls({
|
||||
<CheckCircle2 size={11} className="text-green-400 shrink-0" />
|
||||
)}
|
||||
<span className={
|
||||
(activatingPhase === 'keys' || activatingPhase === 'peers') ? 'text-[var(--text-muted)]'
|
||||
(activatingPhase === 'keys' || activatingPhase === 'peers' || activatingPhase === 'swarm') ? 'text-[var(--text-muted)]'
|
||||
: activatingPhase === 'sync' ? 'text-cyan-300'
|
||||
: 'text-green-300'
|
||||
}>
|
||||
|
||||
@@ -240,7 +240,7 @@
|
||||
"disconnected": "Disconnected"
|
||||
},
|
||||
"meshChat": {
|
||||
"title": "Mesh Chat",
|
||||
"title": "Meshtastic Chat",
|
||||
"infonet": "Infonet",
|
||||
"meshtastic": "Meshtastic",
|
||||
"deadDrop": "Dead Drop",
|
||||
|
||||
@@ -240,7 +240,7 @@
|
||||
"disconnected": "Déconnecté"
|
||||
},
|
||||
"meshChat": {
|
||||
"title": "Chat Mesh",
|
||||
"title": "Chat Meshtastic",
|
||||
"infonet": "Infonet",
|
||||
"meshtastic": "Meshtastic",
|
||||
"deadDrop": "Dead Drop",
|
||||
|
||||
@@ -240,7 +240,7 @@
|
||||
"disconnected": "未连接"
|
||||
},
|
||||
"meshChat": {
|
||||
"title": "Mesh 聊天",
|
||||
"title": "Meshtastic 聊天",
|
||||
"infonet": "Infonet",
|
||||
"meshtastic": "Meshtastic",
|
||||
"deadDrop": "死信箱",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user