Ship ShadowBroker v0.9.83 with live Infonet gate messaging and DM protocols.

Gate hashchain replication, Tor/SOCKS transport hardening, terminal session teardown, v0.9.83 UI/changelog, and release digest pins for seamless updater verification.
This commit is contained in:
BigBodyCobain
2026-06-15 15:37:29 -06:00
parent 8fcb01276c
commit 5ede669a12
35 changed files with 589 additions and 263 deletions
+2
View File
@@ -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/
+5
View File
@@ -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"
}
}
+49 -8
View File
@@ -1247,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.
@@ -1275,15 +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)
threading.Thread(target=_swarm_bootstrap_after_transport_ready, daemon=True).start()
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)
@@ -11704,7 +11745,7 @@ async def api_wormhole_dm_contact_sever(request: Request, peer_id: str):
return {"ok": False, "detail": str(exc)}
_WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready"}
_WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready", "arti_ready"}
def _redact_wormhole_status(state: dict[str, Any], authenticated: bool) -> dict[str, Any]:
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -1348,7 +1348,7 @@ async def api_wormhole_dm_contact_sever(request: Request, peer_id: str):
return {"ok": False, "detail": str(exc)}
_WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready"}
_WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready", "arti_ready"}
def _redact_wormhole_status(state: dict[str, Any], authenticated: bool) -> dict[str, Any]:
+84 -25
View File
@@ -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,20 +450,9 @@ class TorHiddenService:
except OSError:
pass
from services.config import get_settings
settings = get_settings()
socks_port_line = ""
if not bool(getattr(settings, "MESH_ARTI_ENABLED", False)):
socks_port_line = "SocksPort 9050\n"
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"{socks_port_line}"
"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(
@@ -434,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}
+93 -11
View File
@@ -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
@@ -70,16 +77,43 @@ _WORMHOLE_ENV_EXPLICIT = {
"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":
@@ -88,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 (
@@ -110,12 +191,13 @@ 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) — SOCKS is up, using Arti anyway",
"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": True, "ts": now})
return True
_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:
@@ -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
# ---------------------------------------------------------------------------
@@ -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
+2 -2
View File
@@ -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 -1
View File
@@ -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": {
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.9.82",
"version": "0.9.83",
"private": true,
"scripts": {
"dev": "node scripts/dev-all.cjs",
@@ -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' },
],
};
@@ -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['"]/,
);
});
});
+9 -5
View File
@@ -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>({
@@ -1024,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}
+56 -88
View File
@@ -4,101 +4,92 @@ 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.',
];
type ChangelogContributor = {
@@ -109,8 +100,8 @@ type ChangelogContributor = {
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: 'privacy-core (MLS)',
desc: 'Rust MLS gate crypto — WASM/FFI path for browser and Tauri sovereign shells',
},
];
@@ -228,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>
@@ -14,13 +14,26 @@ interface Stats {
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', 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);
@@ -28,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);
@@ -43,6 +57,7 @@ export default function NetworkStats() {
?? 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),
@@ -53,6 +68,7 @@ export default function NetworkStats() {
nodeEnabled: Boolean(infonet?.node_enabled),
syncOutcome,
syncError: String(infonet?.sync_runtime?.last_error || '').trim(),
artiReady,
});
} catch { /* ignore */ }
};
@@ -61,8 +77,7 @@ export default function NetworkStats() {
return () => { alive = false; clearInterval(interval); };
}, []);
const syncErrorLower = stats.syncError.toLowerCase();
const artiBlocked = syncErrorLower.includes('arti') || syncErrorLower.includes('onion');
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';
@@ -3,9 +3,7 @@
import React, { useEffect } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { X } from 'lucide-react';
import {
ensureInfonetParticipantNodeReady,
} from '@/mesh/controlPlaneStatusClient';
import { beginInfonetTerminalSession } from '@/lib/infonetTerminalSession';
import InfonetShell from './InfonetShell';
interface InfonetTerminalProps {
@@ -38,7 +36,7 @@ export default function InfonetTerminal({
const connectParticipantNode = async () => {
try {
if (cancelled) return;
await ensureInfonetParticipantNodeReady();
await beginInfonetTerminalSession();
} catch {
// Remote/shared viewers may not have local-operator rights. Leave manual controls intact.
}
@@ -2,7 +2,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Shield } from 'lucide-react';
import { ensureInfonetParticipantNodeReady } from '@/mesh/controlPlaneStatusClient';
import { beginInfonetTerminalSession } from '@/lib/infonetTerminalSession';
import InfonetShell from '@/components/InfonetTerminal/InfonetShell';
type Props = {
@@ -98,7 +98,7 @@ export default function InfonetTerminalPanel({
const connectParticipantNode = async () => {
try {
if (cancelled) return;
await ensureInfonetParticipantNodeReady();
await beginInfonetTerminalSession();
} catch {
// Remote viewers may not have local-operator rights.
}
+2 -3
View File
@@ -11,8 +11,7 @@ import {
SHELL_FLYOUT_WIDTH,
type MeshChatFlyoutRect,
} from './meshChatFlyout';
import { fetchWormholeState, leaveWormhole } from '@/mesh/wormholeClient';
import { teardownWormholeOnClose } from '@/lib/wormholeTeardown';
import { endInfonetTerminalSession } from '@/lib/infonetTerminalSession';
import { motion, AnimatePresence } from 'framer-motion';
import {
Antenna,
@@ -439,7 +438,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
}, []);
const handleInfonetTeardown = useCallback(() => {
void teardownWormholeOnClose(fetchWormholeState, leaveWormhole);
void endInfonetTerminalSession();
}, []);
const panelFlyout =
@@ -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 {
+13 -1
View File
@@ -30,6 +30,7 @@ import {
import {
requestMeshTerminalOpen,
subscribeSecureMeshTerminalLauncherOpen,
subscribeInfonetSessionEnd,
} from '@/lib/meshTerminalLauncher';
import { purgeBrowserContactGraph, purgeBrowserSigningMaterial, setSecureModeCached, getNodeIdentity, generateNodeKeys } from '@/mesh/meshIdentity';
import { purgeBrowserDmState } from '@/mesh/meshDmWorkerClient';
@@ -42,6 +43,7 @@ import {
import {
fetchWormholeStatus,
prepareWormholeInteractiveLane,
isWormholePrepAbortedError,
} from '@/mesh/wormholeIdentityClient';
import { fetchWormholeSettings } from '@/mesh/wormholeClient';
import packageJson from '../../package.json';
@@ -172,8 +174,15 @@ export default function TopRightControls({
});
}, [openTerminalLauncher]);
useEffect(() => {
return subscribeInfonetSessionEnd(() => {
setTerminalLaunchBusy(false);
setTerminalLaunchError('');
});
}, []);
const closeTerminalLauncher = () => {
if (terminalLaunchBusy) return;
setTerminalLaunchBusy(false);
setTerminalLauncherOpen(false);
setTerminalLaunchError('');
};
@@ -215,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 || '')
@@ -0,0 +1,81 @@
/**
* Infonet terminal session lifecycle.
* Wormhole, Tor, and participant-node sync should only run while the terminal is open.
*/
import {
fetchNodeSettingsSnapshot,
setInfonetNodeEnabled,
startTorHiddenService,
stopTorHiddenService,
joinInfonetSwarm,
fetchInfonetNodeStatusSnapshot,
} from '@/mesh/controlPlaneStatusClient';
import { generateNodeKeys, getNodeIdentity, setSecureModeCached } from '@/mesh/meshIdentity';
import { fetchWormholeSettings, leaveWormhole, fetchWormholeState } from '@/mesh/wormholeClient';
import {
abortWormholeInteractivePrep,
isWormholePrepAbortedError,
} from '@/mesh/wormholeIdentityClient';
import { notifyInfonetSessionEnd } from '@/lib/meshTerminalLauncher';
let nodeWasEnabledBeforeSession = false;
let sessionEndInFlight: Promise<void> | null = null;
export async function beginInfonetTerminalSession(): Promise<void> {
try {
const before = await fetchNodeSettingsSnapshot().catch(() => null);
nodeWasEnabledBeforeSession = Boolean(before?.enabled);
if (!getNodeIdentity()) {
await generateNodeKeys().catch(() => null);
}
await startTorHiddenService().catch(() => null);
if (!nodeWasEnabledBeforeSession) {
await setInfonetNodeEnabled(true);
}
await joinInfonetSwarm().catch(() => null);
await fetchInfonetNodeStatusSnapshot(true).catch(() => null);
} catch {
// Remote viewers may not have local-operator rights.
}
}
export async function endInfonetTerminalSession(): Promise<void> {
if (sessionEndInFlight) {
return sessionEndInFlight;
}
sessionEndInFlight = (async () => {
abortWormholeInteractivePrep();
try {
const [settings, state] = await Promise.all([
fetchWormholeSettings(false).catch(() => null),
fetchWormholeState(false).catch(() => null),
]);
const wormholeActive = Boolean(
settings?.enabled
|| state?.configured
|| state?.running
|| state?.ready,
);
if (wormholeActive) {
await leaveWormhole().catch(() => null);
}
if (!nodeWasEnabledBeforeSession) {
await setInfonetNodeEnabled(false).catch(() => null);
}
await stopTorHiddenService().catch(() => null);
setSecureModeCached(false);
nodeWasEnabledBeforeSession = false;
} catch {
/* best-effort teardown */
} finally {
notifyInfonetSessionEnd();
}
})();
try {
await sessionEndInFlight;
} finally {
sessionEndInFlight = null;
}
}
export { isWormholePrepAbortedError };
+20
View File
@@ -1,5 +1,6 @@
const MESH_TERMINAL_OPEN_EVENT = 'oracle:open-mesh-terminal';
const SECURE_MESH_TERMINAL_LAUNCHER_EVENT = 'oracle:open-secure-mesh-terminal-launcher';
const INFONET_SESSION_END_EVENT = 'oracle:infonet-session-end';
export function requestMeshTerminalOpen(source = 'ui'): void {
if (typeof window === 'undefined') return;
@@ -38,3 +39,22 @@ export function subscribeSecureMeshTerminalLauncherOpen(handler: () => void): ()
window.addEventListener(SECURE_MESH_TERMINAL_LAUNCHER_EVENT, listener);
return () => window.removeEventListener(SECURE_MESH_TERMINAL_LAUNCHER_EVENT, listener);
}
export function notifyInfonetSessionEnd(): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(
new CustomEvent(INFONET_SESSION_END_EVENT, {
detail: { at: Date.now() },
}),
);
}
export function subscribeInfonetSessionEnd(handler: () => void): () => void {
if (typeof window === 'undefined') {
return () => {};
}
const listener = () => handler();
window.addEventListener(INFONET_SESSION_END_EVENT, listener);
return () => window.removeEventListener(INFONET_SESSION_END_EVENT, listener);
}
+6 -11
View File
@@ -1,17 +1,12 @@
/**
* Wormhole teardown logic extracted from InfonetTerminal close handler.
* Shuts down Wormhole when the terminal closes so it doesn't stay running.
* Delegates to the terminal session lifecycle so background prep is aborted too.
*/
import { endInfonetTerminalSession } from '@/lib/infonetTerminalSession';
export async function teardownWormholeOnClose(
fetchState: (force: boolean) => Promise<{ ready?: boolean; running?: boolean } | null>,
leave: () => Promise<unknown>,
_fetchState?: (force: boolean) => Promise<{ ready?: boolean; running?: boolean } | null>,
_leave?: () => Promise<unknown>,
): Promise<void> {
try {
const s = await fetchState(false);
if (s?.ready || s?.running) {
await leave();
}
} catch {
/* ignore — best-effort teardown */
}
await endInfonetTerminalSession();
}
@@ -249,6 +249,13 @@ export async function startTorHiddenService(): Promise<TorHiddenServiceSnapshot>
});
}
export async function stopTorHiddenService(): Promise<TorHiddenServiceSnapshot> {
return controlPlaneJson<TorHiddenServiceSnapshot>('/api/settings/tor/stop', {
method: 'POST',
requireAdminSession: false,
});
}
export interface InfonetSwarmJoinSnapshot {
ok?: boolean;
detail?: string;
@@ -51,6 +51,30 @@ const WORMHOLE_TRANSPORT_TIER_ORDER: Record<string, number> = {
private_strong: 3,
};
const wormholeInteractivePrepInflight = new Map<string, Promise<PreparedWormholeInteractiveLane>>();
let wormholePrepGeneration = 0;
export class WormholePrepAbortedError extends Error {
constructor() {
super('wormhole_prep_aborted');
this.name = 'WormholePrepAbortedError';
}
}
export function isWormholePrepAbortedError(error: unknown): boolean {
return error instanceof WormholePrepAbortedError
|| (error instanceof Error && error.message === 'wormhole_prep_aborted');
}
export function abortWormholeInteractivePrep(): void {
wormholePrepGeneration += 1;
wormholeInteractivePrepInflight.clear();
}
function assertWormholePrepActive(generation: number): void {
if (generation !== wormholePrepGeneration) {
throw new WormholePrepAbortedError();
}
}
export interface WormholeIdentity {
bootstrapped: boolean;
@@ -875,13 +899,16 @@ export async function prepareWormholeInteractiveLane(
return existingInflight;
}
const prepTask = (async (): Promise<PreparedWormholeInteractiveLane> => {
const generation = wormholePrepGeneration;
const timeoutMs = Math.max(
GATE_LIFECYCLE_PREP_POLL_MS,
Number(options.timeoutMs || GATE_LIFECYCLE_PREP_TIMEOUT_MS),
);
assertWormholePrepActive(generation);
let runtime = await fetchWormholeState(true).catch(() => null);
let settings = await fetchWormholeSettings(true).catch(() => null);
if (!runtime?.ready) {
assertWormholePrepActive(generation);
if (settings?.enabled || runtime?.configured) {
runtime = await connectWormhole({ requireAdminSession: false }).catch((error) => {
throw new Error(
@@ -908,9 +935,11 @@ export async function prepareWormholeInteractiveLane(
Date.now() < deadline &&
(!runtime?.ready || !wormholeTransportTierSatisfied(transportTierFromRuntime(runtime), minimumTransportTier))
) {
assertWormholePrepActive(generation);
await sleep(GATE_LIFECYCLE_PREP_POLL_MS);
runtime = await fetchWormholeState(true).catch(() => null);
}
assertWormholePrepActive(generation);
const resolvedTransportTier = transportTierFromRuntime(runtime);
if (!runtime?.ready || !wormholeTransportTierSatisfied(resolvedTransportTier, minimumTransportTier)) {
throw new Error('Wormhole is still warming up in the background.');
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "shadowbroker"
version = "0.9.82"
version = "0.9.83"
readme = "README.md"
requires-python = ">=3.10"
dependencies = []
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
AK="$(docker exec shadowbroker-backend printenv ADMIN_KEY)"
INV="$(curl -s -H "X-Admin-Key: ${AK}" "http://127.0.0.1:8000/api/wormhole/dm/invite?label=probe")"
echo "invite=${INV:0:200}"
HANDLE="$(python3 -c 'import json,sys; d=json.load(sys.stdin); print((d.get("invite") or {}).get("payload", {}).get("prekey_lookup_handle", ""))' <<<"${INV}")"
echo "handle=${HANDLE}"
curl -s "http://127.0.0.1:8000/api/mesh/dm/prekey-bundle?lookup_token=${HANDLE}" | head -c 400
echo