mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-11 00:27:55 +02:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1e1be4016 | |||
| 0afb85e241 | |||
| 039a0f9d0c | |||
| b9b99c1fa8 | |||
| a8fd33a758 | |||
| 7346129d0e | |||
| eb8f39f84e |
@@ -370,6 +370,7 @@ osint_router = _load_optional_router("routers.osint")
|
|||||||
scm_router = _load_optional_router("routers.scm")
|
scm_router = _load_optional_router("routers.scm")
|
||||||
entity_graph_router = _load_optional_router("routers.entity_graph")
|
entity_graph_router = _load_optional_router("routers.entity_graph")
|
||||||
intel_feeds_router = _load_optional_router("routers.intel_feeds")
|
intel_feeds_router = _load_optional_router("routers.intel_feeds")
|
||||||
|
agent_shell_router = _load_optional_router("routers.agent_shell")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -3651,6 +3652,7 @@ app.include_router(osint_router)
|
|||||||
app.include_router(scm_router)
|
app.include_router(scm_router)
|
||||||
app.include_router(entity_graph_router)
|
app.include_router(entity_graph_router)
|
||||||
app.include_router(intel_feeds_router)
|
app.include_router(intel_feeds_router)
|
||||||
|
app.include_router(agent_shell_router)
|
||||||
|
|
||||||
from services.data_fetcher import update_all_data
|
from services.data_fetcher import update_all_data
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""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)
|
||||||
|
|
||||||
|
|
||||||
|
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 (_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")
|
||||||
|
|
||||||
|
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):
|
async def api_capabilities(request: Request):
|
||||||
"""Return full API manifest so the agent knows every available endpoint."""
|
"""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_channel import READ_COMMANDS, WRITE_COMMANDS, detect_tier
|
||||||
|
from services.openclaw_routing import routing_manifest
|
||||||
from services.config import get_settings
|
from services.config import get_settings
|
||||||
tier = detect_tier()
|
tier = detect_tier()
|
||||||
access_tier = str(get_settings().OPENCLAW_ACCESS_TIER or "restricted").strip().lower()
|
access_tier = str(get_settings().OPENCLAW_ACCESS_TIER or "restricted").strip().lower()
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"version": "0.9.82",
|
"version": "0.9.82",
|
||||||
|
"routing": routing_manifest(),
|
||||||
"auth": {
|
"auth": {
|
||||||
"method": "HMAC-SHA256",
|
"method": "HMAC-SHA256",
|
||||||
"headers": ["X-SB-Timestamp", "X-SB-Nonce", "X-SB-Signature"],
|
"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.",
|
"description": "Compact server-side ship search by MMSI/IMO/name/query, including yacht-owner enrichment.",
|
||||||
},
|
},
|
||||||
"find_entity": {
|
"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)"},
|
"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. Use before tracking to avoid fuzzy prompt matching.",
|
"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": {
|
"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)"},
|
"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 "
|
"layers are serialized, unchanged layers transfer zero bytes. The client tracks versions "
|
||||||
"automatically from SSE events and previous responses. "
|
"automatically from SSE events and previous responses. "
|
||||||
"3) Pass compact=true on every read command for compressed_v1 responses (~60-90% smaller). "
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""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:
|
||||||
|
return os.environ.get("AGENT_SHELL_DEFAULT_CWD") or os.environ.get("HOME") or "/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}
|
||||||
@@ -87,6 +87,9 @@ READ_COMMANDS = frozenset({
|
|||||||
"osint_lookup",
|
"osint_lookup",
|
||||||
"osint_tools",
|
"osint_tools",
|
||||||
"entity_expand",
|
"entity_expand",
|
||||||
|
# Agent routing helpers
|
||||||
|
"route_query",
|
||||||
|
"run_playbook",
|
||||||
})
|
})
|
||||||
|
|
||||||
WRITE_COMMANDS = frozenset({
|
WRITE_COMMANDS = frozenset({
|
||||||
@@ -643,6 +646,19 @@ def _compact_query_result(result: Any) -> Any:
|
|||||||
# Command dispatcher
|
# 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]:
|
def _dispatch_command(cmd: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Route a command to the appropriate AI Intel function.
|
"""Route a command to the appropriate AI Intel function.
|
||||||
|
|
||||||
@@ -650,6 +666,43 @@ def _dispatch_command(cmd: str, args: dict[str, Any]) -> dict[str, Any]:
|
|||||||
Commands run in an isolated thread (via _execute_command) so they
|
Commands run in an isolated thread (via _execute_command) so they
|
||||||
do not need or touch the caller's event loop.
|
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":
|
if cmd == "get_telemetry":
|
||||||
from services.telemetry import get_cached_telemetry_refs
|
from services.telemetry import get_cached_telemetry_refs
|
||||||
data = get_cached_telemetry_refs()
|
data = get_cached_telemetry_refs()
|
||||||
@@ -731,6 +784,7 @@ def _dispatch_command(cmd: str, args: dict[str, Any]) -> dict[str, Any]:
|
|||||||
owner=str(args.get("owner", "") or args.get("operator", "") or ""),
|
owner=str(args.get("owner", "") or args.get("operator", "") or ""),
|
||||||
layers=args.get("layers") if isinstance(args.get("layers"), (list, tuple)) else None,
|
layers=args.get("layers") if isinstance(args.get("layers"), (list, tuple)) else None,
|
||||||
limit=args.get("limit", 10),
|
limit=args.get("limit", 10),
|
||||||
|
fallback_search=bool(args.get("fallback_search") or args.get("confirm_fuzzy")),
|
||||||
)
|
)
|
||||||
if _wants_compact(args):
|
if _wants_compact(args):
|
||||||
compact = dict(result)
|
compact = dict(result)
|
||||||
@@ -1092,6 +1146,7 @@ def _dispatch_command(cmd: str, args: dict[str, Any]) -> dict[str, Any]:
|
|||||||
owner=str(args.get("owner", "") or args.get("operator", "") or ""),
|
owner=str(args.get("owner", "") or args.get("operator", "") or ""),
|
||||||
layers=args.get("layers") if isinstance(args.get("layers"), (list, tuple)) else None,
|
layers=args.get("layers") if isinstance(args.get("layers"), (list, tuple)) else None,
|
||||||
limit=5,
|
limit=5,
|
||||||
|
fallback_search=True,
|
||||||
)
|
)
|
||||||
best = lookup.get("best_match") if isinstance(lookup.get("best_match"), dict) else {}
|
best = lookup.get("best_match") if isinstance(lookup.get("best_match"), dict) else {}
|
||||||
group = str(best.get("group", "") or entity_type).lower()
|
group = str(best.get("group", "") or entity_type).lower()
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
"""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,
|
||||||
|
"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)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"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",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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", [])],
|
||||||
|
}
|
||||||
@@ -1549,11 +1549,13 @@ def find_entity(
|
|||||||
owner: str = "",
|
owner: str = "",
|
||||||
layers: list[str] | tuple[str, ...] | None = None,
|
layers: list[str] | tuple[str, ...] | None = None,
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
|
fallback_search: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Find a named entity across aircraft, maritime, and general telemetry.
|
"""Find a named entity across aircraft, maritime, and general telemetry.
|
||||||
|
|
||||||
This is an intent-level lookup for agents. It tries high-precision
|
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()
|
effective_query = str(query or name or owner or callsign or registration or icao24 or mmsi or imo or "").strip()
|
||||||
if not effective_query:
|
if not effective_query:
|
||||||
@@ -1628,7 +1630,9 @@ def find_entity(
|
|||||||
seen.add(key)
|
seen.add(key)
|
||||||
results.append(normalized)
|
results.append(normalized)
|
||||||
|
|
||||||
search_layers = requested_layers or _entity_layers_for_type(entity_type)
|
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)
|
search_result = search_telemetry(query=effective_query, layers=search_layers, limit=limit)
|
||||||
if search_result.get("results"):
|
if search_result.get("results"):
|
||||||
strategies.append("universal_index")
|
strategies.append("universal_index")
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -466,15 +466,55 @@ def test_find_entity_prioritizes_aircraft_operator_and_callsign(sample_store, mo
|
|||||||
|
|
||||||
monkeypatch.setattr(telemetry, "get_data_version", lambda: 130)
|
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"]["group"] == "aircraft"
|
||||||
assert by_operator["best_match"]["label"] == "OXE2116"
|
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)
|
by_callsign = telemetry.find_entity(callsign="AF1", entity_type="aircraft", limit=5)
|
||||||
assert by_callsign["best_match"]["callsign"] == "AF1"
|
assert by_callsign["best_match"]["callsign"] == "AF1"
|
||||||
assert by_callsign["best_match"]["alert_operator"] == "POTUS"
|
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):
|
def test_find_entity_prioritizes_maritime_owner_and_identifiers(sample_store, monkeypatch):
|
||||||
import services.telemetry as telemetry
|
import services.telemetry as telemetry
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""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
|
||||||
Generated
+17
-1
@@ -11,6 +11,8 @@
|
|||||||
"@mapbox/point-geometry": "^1.1.0",
|
"@mapbox/point-geometry": "^1.1.0",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"hls.js": "^1.6.15",
|
"hls.js": "^1.6.15",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
@@ -3251,6 +3253,21 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -7374,7 +7391,6 @@
|
|||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"@mapbox/point-geometry": "^1.1.0",
|
"@mapbox/point-geometry": "^1.1.0",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"hls.js": "^1.6.15",
|
"hls.js": "^1.6.15",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
|
|||||||
@@ -114,23 +114,24 @@ describe('MeshChat behavior - policy wiring', () => {
|
|||||||
expect(controller).toContain('timer = setTimeout(() => void poll(classification.refreshCount), classification.delay);');
|
expect(controller).toContain('timer = setTimeout(() => void poll(classification.refreshCount), classification.delay);');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dead-drop UI distinguishes invite-pinned trust from TOFU-only', () => {
|
it('dead-drop trust copy lives in shared hints and Infonet Messages', () => {
|
||||||
const index = readSource('../../components/MeshChat/index.tsx');
|
const hints = readSource('../../mesh/meshPrivacyHints.ts');
|
||||||
expect(index).toContain('getContactTrustSummary');
|
expect(hints).toContain('INVITE PINNED');
|
||||||
expect(index).toContain('INVITE PINNED');
|
expect(hints).toContain('FIRST CONTACT (TOFU ONLY)');
|
||||||
expect(index).toContain('TOFU ONLY');
|
expect(hints).toContain('anchored by an imported signed invite');
|
||||||
expect(index).toContain('anchored by an imported signed invite');
|
const messages = readSource('../../components/InfonetTerminal/MessagesView.tsx');
|
||||||
expect(index).toContain('rootWitnessContinuityLabel');
|
expect(messages).toContain('getContactTrustSummary');
|
||||||
expect(index).toContain('RECOVER ROOT');
|
expect(messages).toContain('rootWitnessContinuityLabel');
|
||||||
expect(index).toContain('!selectedContactTrustSummary?.rootMismatch');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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');
|
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).not.toContain('handleAddContact().catch(() =>');
|
||||||
expect(index).toContain('dm add');
|
const terminal = readSource('../../components/MeshTerminal.tsx');
|
||||||
expect(index).toContain('legacy migration');
|
expect(terminal).toContain('dm add');
|
||||||
|
expect(terminal).toContain('legacy migration');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('controller blocks trust-new-key when the stable root changed', () => {
|
it('controller blocks trust-new-key when the stable root changed', () => {
|
||||||
|
|||||||
@@ -101,54 +101,17 @@ const BUG_FIXES = [
|
|||||||
'OpenClaw agents can invoke the Recon panel backends via `osint_lookup` without raw `/api/osint/*` HTTP calls or local-operator browser auth.',
|
'OpenClaw agents can invoke the Recon panel backends via `osint_lookup` without raw `/api/osint/*` HTTP calls or local-operator browser auth.',
|
||||||
];
|
];
|
||||||
|
|
||||||
const CONTRIBUTORS = [
|
type ChangelogContributor = {
|
||||||
|
name: string;
|
||||||
|
desc: string;
|
||||||
|
pr?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONTRIBUTORS: ChangelogContributor[] = [
|
||||||
{
|
{
|
||||||
name: 'OSIRIS (simplifaisoul/osiris)',
|
name: 'OSIRIS (simplifaisoul/osiris)',
|
||||||
desc: 'MIT-licensed recon stack — adapted for ShadowBroker proxy model (see backend/third_party/osiris/NOTICE.md)',
|
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',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function useChangelog() {
|
export function useChangelog() {
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, 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';
|
||||||
|
|
||||||
|
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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [status, setStatus] = useState<'idle' | 'connecting' | 'open' | 'error'>('idle');
|
||||||
|
const [statusDetail, setStatusDetail] = useState('');
|
||||||
|
const [cwd, setCwd] = useState('');
|
||||||
|
|
||||||
|
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 (!active || !hostRef.current) return;
|
||||||
|
disconnect();
|
||||||
|
|
||||||
|
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 = () => {
|
||||||
|
setStatus((prev) => (prev === 'error' ? prev : 'idle'));
|
||||||
|
term.writeln('\r\n\x1b[90m[session closed]\x1b[0m');
|
||||||
|
};
|
||||||
|
|
||||||
|
term.onData((data) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(new TextEncoder().encode(data));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [active, disconnect]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) {
|
||||||
|
disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
return () => disconnect();
|
||||||
|
}, [active, connect, disconnect]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const timer = window.setTimeout(() => fitTerminal(), expanded ? 220 : 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [active, expanded, fitTerminal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const onResize = () => fitTerminal();
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
return () => window.removeEventListener('resize', onResize);
|
||||||
|
}, [active, 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 Mesh Chat to open your operator shell.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={hostRef} className="flex-1 min-h-[220px] px-1 py-1 overflow-hidden" />
|
||||||
|
|
||||||
|
<div className="border-t border-cyan-900/30 px-2 py-1 text-[11px] font-mono text-slate-500 shrink-0">
|
||||||
|
{status === 'connecting'
|
||||||
|
? 'Connecting…'
|
||||||
|
: status === 'open'
|
||||||
|
? `${expanded ? 'Expanded' : 'Docked'} · ${SHELL_FONT_PX}px · map stays interactive`
|
||||||
|
: 'Local operator shell'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import AgentShellPanel from './AgentShellPanel';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Antenna,
|
Antenna,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
Radio,
|
Radio,
|
||||||
Shield,
|
Shield,
|
||||||
Terminal,
|
Terminal,
|
||||||
|
SquareTerminal,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Lock,
|
Lock,
|
||||||
Check,
|
Check,
|
||||||
@@ -89,6 +91,8 @@ function describeGateCompatReason(reason: string, gateId: string): string {
|
|||||||
// NO direct trust-mutating imports — all mutations go through the hook.
|
// NO direct trust-mutating imports — all mutations go through the hook.
|
||||||
|
|
||||||
const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||||
|
const panelBoxRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [shellExpanded, setShellExpanded] = useState(false);
|
||||||
const ctrl = useMeshChatController(props);
|
const ctrl = useMeshChatController(props);
|
||||||
const {
|
const {
|
||||||
// UI state
|
// UI state
|
||||||
@@ -317,6 +321,12 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
recentPrivateFallbackReason,
|
recentPrivateFallbackReason,
|
||||||
onSettingsClick,
|
onSettingsClick,
|
||||||
} = ctrl;
|
} = ctrl;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'dms') {
|
||||||
|
setShellExpanded(false);
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
const selectedContactTrustSummary = selectedContactInfo
|
const selectedContactTrustSummary = selectedContactInfo
|
||||||
? getContactTrustSummary(selectedContactInfo)
|
? getContactTrustSummary(selectedContactInfo)
|
||||||
: null;
|
: null;
|
||||||
@@ -398,7 +408,12 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
>
|
>
|
||||||
{/* Single unified box — matches Data Layers panel skin */}
|
{/* Single unified box — matches Data Layers panel skin */}
|
||||||
<div
|
<div
|
||||||
className={`bg-[#0a0a0a]/90 backdrop-blur-sm border border-cyan-900/40 flex flex-col relative overflow-hidden`}
|
ref={panelBoxRef}
|
||||||
|
className={`bg-[#0a0a0a]/90 backdrop-blur-sm border border-cyan-900/40 flex flex-col relative overflow-hidden transition-[width,min-width] duration-200 ${
|
||||||
|
activeTab === 'dms' && shellExpanded
|
||||||
|
? 'z-[210] min-w-[min(760px,calc(100vw-3rem))] w-[min(760px,calc(100vw-3rem))]'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
style={{ boxShadow: '0 0 15px rgba(8,145,178,0.06), inset 0 0 20px rgba(0,0,0,0.4)', ...(expanded ? { flex: '1 1 0', minHeight: 0 } : {}) }}
|
style={{ boxShadow: '0 0 15px rgba(8,145,178,0.06), inset 0 0 20px rgba(0,0,0,0.4)', ...(expanded ? { flex: '1 1 0', minHeight: 0 } : {}) }}
|
||||||
>
|
>
|
||||||
{/* HEADER */}
|
{/* HEADER */}
|
||||||
@@ -435,16 +450,15 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
{ key: 'meshtastic' as Tab, label: 'MESH', icon: <Radio size={10} />, badge: 0 },
|
{ key: 'meshtastic' as Tab, label: 'MESH', icon: <Radio size={10} />, badge: 0 },
|
||||||
{
|
{
|
||||||
key: 'dms' as Tab,
|
key: 'dms' as Tab,
|
||||||
label: 'DEAD DROP',
|
label: 'SHELL',
|
||||||
icon: <Lock size={10} />,
|
icon: <SquareTerminal size={10} />,
|
||||||
badge: totalDmNotify,
|
badge: 0,
|
||||||
},
|
},
|
||||||
].map((tab) => (
|
].map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveTab(tab.key);
|
setActiveTab(tab.key);
|
||||||
if (tab.key === 'dms') setDmView('contacts');
|
|
||||||
}}
|
}}
|
||||||
className={`flex-1 flex items-center justify-center gap-1 py-1.5 text-[12px] font-mono tracking-wider transition-colors ${
|
className={`flex-1 flex items-center justify-center gap-1 py-1.5 text-[12px] font-mono tracking-wider transition-colors ${
|
||||||
activeTab === tab.key
|
activeTab === tab.key
|
||||||
@@ -478,21 +492,21 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab !== 'meshtastic' && wormholeEnabled && !wormholeReadyState && (
|
{activeTab !== 'dms' && activeTab !== 'meshtastic' && wormholeEnabled && !wormholeReadyState && (
|
||||||
<div className="px-3 py-2 text-sm font-mono text-red-400/90 border-b border-red-900/30 bg-red-950/20 leading-[1.65] shrink-0">
|
<div className="px-3 py-2 text-sm font-mono text-red-400/90 border-b border-red-900/30 bg-red-950/20 leading-[1.65] shrink-0">
|
||||||
Wormhole secure mode is enabled but the local agent is not ready. Dead Drop is
|
Wormhole secure mode is enabled but the local agent is not ready. Dead Drop is
|
||||||
blocked until Wormhole is running.
|
blocked until Wormhole is running.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab !== 'meshtastic' && wormholeEnabled && wormholeReadyState && (
|
{activeTab !== 'dms' && activeTab !== 'meshtastic' && wormholeEnabled && wormholeReadyState && (
|
||||||
<div className="px-3 py-2 text-sm font-mono text-yellow-400/80 border-b border-yellow-900/20 bg-yellow-950/10 leading-[1.65] shrink-0">
|
<div className="px-3 py-2 text-sm font-mono text-yellow-400/80 border-b border-yellow-900/20 bg-yellow-950/10 leading-[1.65] shrink-0">
|
||||||
Wormhole secure mode is active. Experimental private-lane operations are routed
|
Wormhole secure mode is active. Experimental private-lane operations are routed
|
||||||
through the local agent and current secure transport paths.
|
through the local agent and current secure transport paths.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab !== 'meshtastic' && wormholeEnabled && wormholeReadyState && !wormholeRnsReady && (
|
{activeTab !== 'dms' && activeTab !== 'meshtastic' && wormholeEnabled && wormholeReadyState && !wormholeRnsReady && (
|
||||||
<div className="px-3 py-2 text-sm font-mono text-amber-300/90 border-b border-amber-900/30 bg-amber-950/20 leading-[1.65] shrink-0">
|
<div className="px-3 py-2 text-sm font-mono text-amber-300/90 border-b border-amber-900/30 bg-amber-950/20 leading-[1.65] shrink-0">
|
||||||
TRANSITIONAL PRIVATE LANE. Wormhole is up and gate chat is available on the
|
TRANSITIONAL PRIVATE LANE. Wormhole is up and gate chat is available on the
|
||||||
transitional lane. Reticulum is still warming — Dead Drop / DM requires the
|
transitional lane. Reticulum is still warming — Dead Drop / DM requires the
|
||||||
@@ -500,7 +514,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab !== 'meshtastic' && anonymousModeEnabled && !anonymousModeReady && (
|
{activeTab !== 'dms' && activeTab !== 'meshtastic' && anonymousModeEnabled && !anonymousModeReady && (
|
||||||
<div className="px-3 py-2 text-sm font-mono text-red-400/90 border-b border-red-900/30 bg-red-950/20 leading-[1.65] shrink-0">
|
<div className="px-3 py-2 text-sm font-mono text-red-400/90 border-b border-red-900/30 bg-red-950/20 leading-[1.65] shrink-0">
|
||||||
Anonymous mode is active, but hidden transport is not ready. Dead Drop is blocked
|
Anonymous mode is active, but hidden transport is not ready. Dead Drop is blocked
|
||||||
until Wormhole is running over Tor, I2P, or Mixnet.
|
until Wormhole is running over Tor, I2P, or Mixnet.
|
||||||
@@ -541,11 +555,18 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
|
|
||||||
{/* CONTENT AREA */}
|
{/* CONTENT AREA */}
|
||||||
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
|
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||||
|
{activeTab === 'dms' && (
|
||||||
|
<AgentShellPanel
|
||||||
|
active={expanded && activeTab === 'dms'}
|
||||||
|
expanded={shellExpanded}
|
||||||
|
onExpandedChange={setShellExpanded}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{dashboardRestrictedTab && (
|
{dashboardRestrictedTab && (
|
||||||
<div className="flex-1 overflow-y-auto styled-scrollbar px-4 py-6 border-l-2 border-cyan-800/25 flex items-center justify-center">
|
<div className="flex-1 overflow-y-auto styled-scrollbar px-4 py-6 border-l-2 border-cyan-800/25 flex items-center justify-center">
|
||||||
<div className="max-w-md w-full border border-cyan-900/30 bg-cyan-950/10 px-5 py-6 text-center">
|
<div className="max-w-md w-full border border-cyan-900/30 bg-cyan-950/10 px-5 py-6 text-center">
|
||||||
<div className="inline-flex items-center justify-center w-11 h-11 border border-cyan-700/40 bg-black/30 text-cyan-300 mb-3">
|
<div className="inline-flex items-center justify-center w-11 h-11 border border-cyan-700/40 bg-black/30 text-cyan-300 mb-3">
|
||||||
{activeTab === 'infonet' ? <Shield size={17} /> : <Lock size={17} />}
|
<Shield size={17} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-mono tracking-[0.24em] text-cyan-300 mb-2">
|
<div className="text-sm font-mono tracking-[0.24em] text-cyan-300 mb-2">
|
||||||
{dashboardRestrictedTitle}
|
{dashboardRestrictedTitle}
|
||||||
@@ -1484,796 +1505,30 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ─── Dead Drop Tab ─── */}
|
{/* Dead Drop chat UI: Infonet Terminal → Messages */}
|
||||||
{!dashboardRestrictedTab && activeTab === 'dms' && (
|
|
||||||
<>
|
|
||||||
{/* Sub-nav: Contacts | Inbox | Muted | (back to contacts from chat) */}
|
|
||||||
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-[var(--border-primary)]/30 shrink-0">
|
|
||||||
{dmView === 'chat' ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setDmView('contacts');
|
|
||||||
setSelectedContact('');
|
|
||||||
setDmMessages([]);
|
|
||||||
}}
|
|
||||||
className="text-[13px] font-mono text-[var(--text-muted)] hover:text-cyan-400 transition-colors"
|
|
||||||
>
|
|
||||||
< BACK
|
|
||||||
</button>
|
|
||||||
<span className="text-sm font-mono text-cyan-400 ml-2 truncate">
|
|
||||||
{selectedContact.slice(0, 16)}
|
|
||||||
</span>
|
|
||||||
{(() => {
|
|
||||||
const c = contacts[selectedContact];
|
|
||||||
if (!c) return null;
|
|
||||||
const trust = getContactTrustSummary(c);
|
|
||||||
if (trust?.transparencyConflict) {
|
|
||||||
return (
|
|
||||||
<span className="ml-2 text-[12px] font-mono px-1.5 py-0.5 border border-red-500/40 text-red-300 bg-red-950/20">
|
|
||||||
HISTORY CONFLICT
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (trust?.state === 'continuity_broken') {
|
|
||||||
return (
|
|
||||||
<span className="ml-2 text-[12px] font-mono px-1.5 py-0.5 border border-red-500/40 text-red-300 bg-red-950/20">
|
|
||||||
CONTINUITY BROKEN
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (trust?.state === 'mismatch') {
|
|
||||||
return (
|
|
||||||
<span className="ml-2 text-[12px] font-mono px-1.5 py-0.5 border border-orange-500/40 text-orange-300 bg-orange-950/20">
|
|
||||||
PREKEY CHANGED
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (trust?.registryMismatch) {
|
|
||||||
return (
|
|
||||||
<span className="ml-2 text-[12px] font-mono px-1.5 py-0.5 border border-red-500/40 text-red-400 bg-red-950/20">
|
|
||||||
KEY MISMATCH
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (trust?.state === 'sas_verified') {
|
|
||||||
return (
|
|
||||||
<span className="ml-2 text-[12px] font-mono px-1.5 py-0.5 border border-green-500/40 text-green-400 bg-green-950/20">
|
|
||||||
SAS VERIFIED
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (trust?.state === 'invite_pinned') {
|
|
||||||
return (
|
|
||||||
<span className="ml-2 text-[12px] font-mono px-1.5 py-0.5 border border-emerald-500/40 text-emerald-300 bg-emerald-950/20">
|
|
||||||
INVITE PINNED
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (trust?.state === 'tofu_pinned') {
|
|
||||||
return (
|
|
||||||
<span className="ml-2 text-[12px] font-mono px-1.5 py-0.5 border border-amber-500/30 text-amber-300 bg-amber-950/10">
|
|
||||||
TOFU ONLY
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()}
|
|
||||||
{(() => {
|
|
||||||
const c = contacts[selectedContact];
|
|
||||||
if (!c) return null;
|
|
||||||
if (c.witness_count && c.witness_count > 0) {
|
|
||||||
return (
|
|
||||||
<span className="ml-2 text-[12px] font-mono px-1.5 py-0.5 border border-cyan-500/30 text-cyan-300 bg-cyan-950/10">
|
|
||||||
WITNESSED {c.witness_count}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()}
|
|
||||||
{(() => {
|
|
||||||
const c = contacts[selectedContact];
|
|
||||||
if (!c) return null;
|
|
||||||
if (c.vouch_count && c.vouch_count > 0) {
|
|
||||||
return (
|
|
||||||
<span className="ml-2 text-[12px] font-mono px-1.5 py-0.5 border border-purple-500/30 text-purple-300 bg-purple-950/10">
|
|
||||||
VOUCHES {c.vouch_count}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()}
|
|
||||||
<button
|
|
||||||
onClick={handleDmTrustPrimaryAction}
|
|
||||||
className="ml-auto text-[12px] font-mono px-2 py-0.5 border border-cyan-800/40 text-cyan-400/90 hover:text-cyan-300 hover:border-cyan-600/60 transition-colors"
|
|
||||||
>
|
|
||||||
{dmTrustPrimaryButtonLabel}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleVouch(selectedContact)}
|
|
||||||
className="ml-2 text-[12px] font-mono px-2 py-0.5 border border-purple-800/40 text-purple-400/90 hover:text-purple-300 hover:border-purple-600/60 transition-colors"
|
|
||||||
>
|
|
||||||
VOUCH
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => void handleRefreshSelectedContact()}
|
|
||||||
disabled={dmMaintenanceBusy}
|
|
||||||
className="ml-2 text-[12px] font-mono px-2 py-0.5 border border-amber-800/40 text-amber-300/90 hover:text-amber-200 hover:border-amber-600/60 transition-colors disabled:opacity-40"
|
|
||||||
>
|
|
||||||
REFRESH
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => void handleResetSelectedContact()}
|
|
||||||
disabled={dmMaintenanceBusy}
|
|
||||||
className="ml-2 text-[12px] font-mono px-2 py-0.5 border border-red-800/40 text-red-300/90 hover:text-red-200 hover:border-red-600/60 transition-colors disabled:opacity-40"
|
|
||||||
>
|
|
||||||
RESET
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => setDmView('contacts')}
|
|
||||||
className={`text-[13px] font-mono px-2 py-0.5 transition-colors ${
|
|
||||||
dmView === 'contacts'
|
|
||||||
? 'text-cyan-400 bg-cyan-950/30'
|
|
||||||
: 'text-[var(--text-muted)] hover:text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
CONTACTS
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setDmView('inbox')}
|
|
||||||
className={`text-[13px] font-mono px-2 py-0.5 transition-colors flex items-center gap-1 ${
|
|
||||||
dmView === 'inbox'
|
|
||||||
? 'text-cyan-400 bg-cyan-950/30'
|
|
||||||
: 'text-[var(--text-muted)] hover:text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
INBOX
|
|
||||||
{accessRequests.length > 0 && (
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-[blink_1s_step-end_infinite]" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setDmView('muted')}
|
|
||||||
className={`text-[13px] font-mono px-2 py-0.5 transition-colors flex items-center gap-1 ${
|
|
||||||
dmView === 'muted'
|
|
||||||
? 'text-cyan-400 bg-cyan-950/30'
|
|
||||||
: 'text-[var(--text-muted)] hover:text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<EyeOff size={8} />
|
|
||||||
MUTED
|
|
||||||
{mutedArray.length > 0 && (
|
|
||||||
<span className="text-[11px] text-[var(--text-muted)]">
|
|
||||||
({mutedArray.length})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddContact(!showAddContact)}
|
|
||||||
disabled={secureDmBlocked}
|
|
||||||
className="ml-auto p-1 hover:bg-[var(--hover-accent)] text-[var(--text-muted)] hover:text-cyan-400 transition-colors"
|
|
||||||
title="Request access"
|
|
||||||
>
|
|
||||||
<UserPlus size={11} />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{dmView === 'chat' && showSas && sasPhrase && (
|
|
||||||
<div className="px-3 pb-1 text-[13px] font-mono text-cyan-400/80 border-b border-[var(--border-primary)]/20">
|
|
||||||
SAS: <span className="text-cyan-300">{sasPhrase}</span>
|
|
||||||
{selectedContactInfo &&
|
|
||||||
selectedContactTrustSummary?.state === 'invite_pinned' && (
|
|
||||||
<div className="mt-1 text-[12px] font-mono text-emerald-300/90 leading-[1.65]">
|
|
||||||
This contact was anchored by an imported signed invite. SAS is still useful
|
|
||||||
as an extra continuity check.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedContactInfo &&
|
|
||||||
selectedContactTrustSummary?.state === 'tofu_pinned' && (
|
|
||||||
<div className="mt-1 text-[12px] font-mono text-amber-300/90 leading-[1.65]">
|
|
||||||
First contact is still TOFU-only. Compare this phrase out of band before
|
|
||||||
treating the sender as verified.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedContactInfo &&
|
|
||||||
selectedContactTrustSummary?.state !== 'sas_verified' &&
|
|
||||||
selectedContactTrustSummary?.state !== 'mismatch' &&
|
|
||||||
selectedContactTrustSummary?.state !== 'continuity_broken' &&
|
|
||||||
!selectedContactTrustSummary?.transparencyConflict && (
|
|
||||||
<div className="mt-2 flex items-center gap-1.5">
|
|
||||||
<input
|
|
||||||
value={sasConfirmInput}
|
|
||||||
onChange={(e) => setSasConfirmInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
void handleConfirmSelectedContactSas();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Type the phrase you both confirmed"
|
|
||||||
className="flex-1 min-w-0 bg-black/30 border border-cyan-900/30 px-2 py-1 text-[12px] font-mono text-cyan-100 placeholder:text-cyan-700/70 focus:outline-none focus:border-cyan-600/60"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => void handleConfirmSelectedContactSas()}
|
|
||||||
disabled={dmMaintenanceBusy}
|
|
||||||
className="text-[12px] font-mono px-2 py-1 border border-emerald-800/40 text-emerald-300 hover:text-emerald-200 hover:border-emerald-600/60 transition-colors disabled:opacity-40"
|
|
||||||
>
|
|
||||||
CONFIRM SAS
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedContactInfo &&
|
|
||||||
selectedContactTrustSummary?.state === 'continuity_broken' &&
|
|
||||||
selectedContactTrustSummary?.rootMismatch && (
|
|
||||||
<>
|
|
||||||
<div className="mt-1 text-[12px] font-mono text-red-300/90 leading-[1.65]">
|
|
||||||
{`${rootWitnessContinuityLabel(selectedContactTrustSummary)} changed for this contact.`}{' '}
|
|
||||||
Compare the SAS phrase for the newly observed root, then recover only if
|
|
||||||
the ceremony checks out.
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex items-center gap-1.5">
|
|
||||||
<input
|
|
||||||
value={sasConfirmInput}
|
|
||||||
onChange={(e) => setSasConfirmInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
void handleRecoverSelectedContactRootContinuity();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Type the phrase you both confirmed for the new root"
|
|
||||||
className="flex-1 min-w-0 bg-black/30 border border-red-900/30 px-2 py-1 text-[12px] font-mono text-cyan-100 placeholder:text-red-700/70 focus:outline-none focus:border-red-600/60"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => void handleRecoverSelectedContactRootContinuity()}
|
|
||||||
disabled={dmMaintenanceBusy}
|
|
||||||
className="text-[12px] font-mono px-2 py-1 border border-red-800/40 text-red-300 hover:text-red-200 hover:border-red-600/60 transition-colors disabled:opacity-40"
|
|
||||||
>
|
|
||||||
RECOVER ROOT
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{selectedContactInfo?.remotePrekeyMismatch && (
|
|
||||||
<div className="mt-2 text-[12px] font-mono text-red-300/85 leading-[1.65]">
|
|
||||||
{selectedContactTrustSummary?.rootMismatch
|
|
||||||
? `${rootWitnessContinuityLabel(selectedContactTrustSummary)} changed. Recover only after you compare the new SAS phrase out of band.`
|
|
||||||
: 'Acknowledge the changed fingerprint first, then compare and confirm SAS again.'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'dms' && !secureDmBlocked && (
|
|
||||||
<div className="px-3 py-1.5 border-b border-[var(--border-primary)]/20 shrink-0 flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`text-[12px] font-mono px-1.5 py-0.5 border ${dmTransportStatus.className}`}
|
|
||||||
>
|
|
||||||
{dmTransportStatus.label}
|
|
||||||
</span>
|
|
||||||
<span className="text-[12px] font-mono text-[var(--text-muted)]">
|
|
||||||
{dmTransportMode === 'reticulum'
|
|
||||||
? 'Direct private delivery active.'
|
|
||||||
: dmTransportMode === 'hidden'
|
|
||||||
? 'Hidden transport active.'
|
|
||||||
: dmTransportMode === 'relay'
|
|
||||||
? 'Relay fallback active.'
|
|
||||||
: dmTransportMode === 'ready'
|
|
||||||
? 'Private lane ready.'
|
|
||||||
: 'Lower-trust mode.'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'dms' && unresolvedSenderSealCount > 0 && (
|
|
||||||
<div className="px-3 py-2 border-b border-red-900/30 bg-red-950/18 text-red-300 leading-[1.65] shrink-0">
|
|
||||||
<div className="text-[13px] font-mono tracking-[0.18em] mb-1">
|
|
||||||
UNRESOLVED SEALED SENDERS
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-mono">
|
|
||||||
{unresolvedSenderSealCount} sealed-sender message
|
|
||||||
{unresolvedSenderSealCount === 1 ? '' : 's'} could not be mapped to a
|
|
||||||
trusted contact or verified sender key. Keep Wormhole reachable and refresh
|
|
||||||
contact trust before relying on them.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'dms' && dmView === 'chat' && dmTrustHint && selectedContactInfo && (
|
|
||||||
<div
|
|
||||||
className={`px-3 py-2 border-b leading-[1.65] shrink-0 ${
|
|
||||||
dmTrustHint.severity === 'danger'
|
|
||||||
? 'border-red-900/30 bg-red-950/20 text-red-300'
|
|
||||||
: 'border-amber-900/30 bg-amber-950/10 text-amber-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-[13px] font-mono tracking-[0.18em] mb-1">
|
|
||||||
{dmTrustHint.title}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-mono">{dmTrustHint.detail}</div>
|
|
||||||
{selectedContactInfo.remotePrekeyMismatch && (
|
|
||||||
<div className="mt-2 text-[13px] font-mono text-red-200/85">
|
|
||||||
pinned {shortTrustFingerprint(selectedContactInfo.remotePrekeyFingerprint)} • observed{' '}
|
|
||||||
{shortTrustFingerprint(selectedContactInfo.remotePrekeyObservedFingerprint)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!selectedContactInfo.remotePrekeyMismatch &&
|
|
||||||
selectedContactInfo.remotePrekeyRootMismatch && (
|
|
||||||
<div className="mt-2 text-[13px] font-mono text-red-200/85">
|
|
||||||
pinned root {shortTrustFingerprint(selectedContactInfo.remotePrekeyRootFingerprint)} •
|
|
||||||
observed root{' '}
|
|
||||||
{shortTrustFingerprint(selectedContactInfo.remotePrekeyObservedRootFingerprint)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!selectedContactInfo.remotePrekeyMismatch &&
|
|
||||||
selectedContactTrustSummary?.state === 'tofu_pinned' &&
|
|
||||||
selectedContactInfo.remotePrekeyFingerprint && (
|
|
||||||
<div className="mt-2 text-[13px] font-mono text-amber-200/85">
|
|
||||||
first-sight pin {shortTrustFingerprint(selectedContactInfo.remotePrekeyFingerprint)} •
|
|
||||||
verify before sensitive use
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!selectedContactInfo.remotePrekeyMismatch &&
|
|
||||||
selectedContactTrustSummary?.state === 'invite_pinned' &&
|
|
||||||
(selectedContactInfo.invitePinnedTrustFingerprint ||
|
|
||||||
selectedContactInfo.remotePrekeyFingerprint) && (
|
|
||||||
<div className="mt-2 text-[13px] font-mono text-emerald-200/85">
|
|
||||||
invite pin{' '}
|
|
||||||
{shortTrustFingerprint(
|
|
||||||
selectedContactInfo.invitePinnedTrustFingerprint ||
|
|
||||||
selectedContactInfo.remotePrekeyFingerprint,
|
|
||||||
)}{' '}
|
|
||||||
•
|
|
||||||
{selectedContactTrustSummary?.rootAttested &&
|
|
||||||
(selectedContactInfo.invitePinnedRootFingerprint ||
|
|
||||||
selectedContactInfo.remotePrekeyRootFingerprint)
|
|
||||||
? ` ${rootWitnessBadgeLabel(selectedContactTrustSummary).toLowerCase()} ${shortTrustFingerprint(
|
|
||||||
selectedContactInfo.invitePinnedRootFingerprint ||
|
|
||||||
selectedContactInfo.remotePrekeyRootFingerprint,
|
|
||||||
)} •`
|
|
||||||
: ''}{' '}
|
|
||||||
imported out of band before first contact
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedContactTrustSummary?.state === 'continuity_broken' &&
|
|
||||||
selectedContactTrustSummary?.rootMismatch && (
|
|
||||||
<div className="mt-2 text-[13px] font-mono text-red-200/85 leading-[1.7]">
|
|
||||||
{`${rootWitnessContinuityLabel(selectedContactTrustSummary).toLowerCase()} broke for this contact.`}{' '}
|
|
||||||
Re-verify SAS or replace the signed invite before trusting the new
|
|
||||||
root.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedContactInfo.remotePrekeyTransparencyConflict && (
|
|
||||||
<div className="mt-2 text-[13px] font-mono text-red-200/85 leading-[1.7]">
|
|
||||||
prekey history conflict observed and trust stays degraded until you
|
|
||||||
explicitly acknowledge the changed fingerprint.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedContactInfo.remotePrekeyLookupMode === 'legacy_agent_id' && (
|
|
||||||
<div className="mt-2 text-[13px] font-mono text-yellow-200/85 leading-[1.7]">
|
|
||||||
bootstrap path: legacy direct agent ID lookup.
|
|
||||||
{selectedContactInfo.invitePinnedPrekeyLookupHandle
|
|
||||||
? ' Refresh from the signed invite to tighten lookup privacy.'
|
|
||||||
: ' Import or re-import a signed invite to avoid stable-ID lookup.'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedContactInfo.remotePrekeyLookupMode === 'invite_lookup_handle' && (
|
|
||||||
<div className="mt-2 text-[13px] font-mono text-cyan-200/85 leading-[1.7]">
|
|
||||||
bootstrap path: invite-scoped lookup handle. Stable agent ID was not
|
|
||||||
required on the lookup path.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(selectedContactInfo.witness_count ?? 0) > 0 && (
|
|
||||||
<div className="mt-2 text-[13px] font-mono text-cyan-200/75 leading-[1.7]">
|
|
||||||
witness observations: {selectedContactInfo.witness_count}
|
|
||||||
{selectedContactInfo.witness_checked_at
|
|
||||||
? `, last seen ${timeAgo(
|
|
||||||
selectedContactInfo.witness_checked_at > 1_000_000_000_000
|
|
||||||
? selectedContactInfo.witness_checked_at
|
|
||||||
: selectedContactInfo.witness_checked_at * 1000,
|
|
||||||
)}`
|
|
||||||
: ''}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
|
||||||
<button
|
|
||||||
onClick={handleDmTrustPrimaryAction}
|
|
||||||
className="text-[12px] font-mono px-2 py-0.5 border border-cyan-800/40 text-cyan-300 hover:text-cyan-200 hover:border-cyan-600/60 transition-colors"
|
|
||||||
>
|
|
||||||
{dmTrustPrimaryButtonLabel}
|
|
||||||
</button>
|
|
||||||
{selectedContactInfo.remotePrekeyMismatch &&
|
|
||||||
!selectedContactTrustSummary?.rootMismatch && (
|
|
||||||
<button
|
|
||||||
onClick={() => void handleTrustSelectedRemotePrekey()}
|
|
||||||
disabled={dmMaintenanceBusy}
|
|
||||||
className="text-[12px] font-mono px-2 py-0.5 border border-orange-700/40 text-orange-300 hover:text-orange-200 hover:border-orange-500/60 transition-colors disabled:opacity-40"
|
|
||||||
>
|
|
||||||
TRUST NEW KEY
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add contact / request access form */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{showAddContact && dmView !== 'chat' && !secureDmBlocked && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ height: 0 }}
|
|
||||||
animate={{ height: 'auto' }}
|
|
||||||
exit={{ height: 0 }}
|
|
||||||
className="overflow-hidden border-b border-[var(--border-primary)]/30 shrink-0"
|
|
||||||
>
|
|
||||||
<div className="px-3 py-2 space-y-1.5">
|
|
||||||
<div className="text-[13px] font-mono text-[var(--text-muted)] leading-[1.65]">
|
|
||||||
Enter an Agent ID for a contact you already pinned with a signed invite
|
|
||||||
to request Dead Drop access. If you only have older local state, use
|
|
||||||
terminal <span className="text-yellow-400">dm add</span> only for
|
|
||||||
legacy migration.
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<input
|
|
||||||
value={addContactId}
|
|
||||||
onChange={(e) => setAddContactId(e.target.value)}
|
|
||||||
placeholder="!sb_a3f2c891..."
|
|
||||||
className="flex-1 bg-[var(--bg-secondary)]/50 border border-[var(--border-primary)] text-sm font-mono text-cyan-300 px-2 py-1 outline-none placeholder:text-[var(--text-muted)]"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
handleRequestComposerAction();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleRequestComposerAction}
|
|
||||||
disabled={!addContactId.trim() || !hasId}
|
|
||||||
className="text-[13px] font-mono px-2 py-1 bg-cyan-900/20 text-cyan-400 hover:bg-cyan-800/30 disabled:opacity-30 transition-colors"
|
|
||||||
>
|
|
||||||
REQUEST
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{pendingSent.includes(addContactId.trim()) && (
|
|
||||||
<div className="text-[13px] font-mono text-yellow-500/70">
|
|
||||||
Request already sent
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Content area */}
|
|
||||||
<div className="flex-1 overflow-y-auto styled-scrollbar px-3 py-1.5 space-y-0.5 border-l-2 border-cyan-800/25">
|
|
||||||
{secureDmBlocked && (
|
|
||||||
<div className="flex h-full min-h-[220px] items-center justify-center py-6">
|
|
||||||
<div className="max-w-sm w-full border border-cyan-900/30 bg-cyan-950/10 px-4 py-5 text-center">
|
|
||||||
<div className="inline-flex items-center justify-center w-10 h-10 border border-cyan-700/40 bg-black/30 text-cyan-300 mb-3">
|
|
||||||
<Lock size={16} />
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-mono tracking-[0.24em] text-cyan-300 mb-2">
|
|
||||||
DEAD DROP LOCKED
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-mono text-[var(--text-secondary)] leading-[1.7]">
|
|
||||||
Need Wormhole activated.
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-[13px] font-mono text-cyan-300/70">
|
|
||||||
Contacts, inbox, and private messages unlock once the private lane is up.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* CONTACTS VIEW */}
|
|
||||||
{!secureDmBlocked && dmView === 'contacts' && (
|
|
||||||
<>
|
|
||||||
{contactList.length === 0 && (
|
|
||||||
<div className="text-sm font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
|
|
||||||
No contacts yet. Use <span className="text-cyan-500/70">+</span> to
|
|
||||||
request access.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{contactList.map(([id, c]) => {
|
|
||||||
const trust = getContactTrustSummary(c);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={id}
|
|
||||||
className="flex items-center gap-2 py-1.5 border-b border-[var(--border-primary)]/30 last:border-0 cursor-pointer hover:bg-[var(--bg-secondary)]/50 px-1 -mx-1 transition-colors"
|
|
||||||
onClick={() => openChat(id)}
|
|
||||||
>
|
|
||||||
<Lock size={10} className="text-[var(--text-muted)] shrink-0" />
|
|
||||||
<span className="text-sm font-mono text-cyan-300 truncate">
|
|
||||||
{c.alias || id.slice(0, 16)}
|
|
||||||
</span>
|
|
||||||
{c.remotePrekeyMismatch && (
|
|
||||||
<span className="text-[11px] font-mono px-1.5 py-0.5 border border-orange-500/40 text-orange-300 bg-orange-950/20">
|
|
||||||
REVERIFY
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!c.remotePrekeyMismatch && c.verify_mismatch && (
|
|
||||||
<span className="text-[11px] font-mono px-1.5 py-0.5 border border-red-500/40 text-red-300 bg-red-950/20">
|
|
||||||
MISMATCH
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!c.remotePrekeyMismatch && !c.verify_mismatch && trust?.state === 'invite_pinned' && (
|
|
||||||
<span className="text-[11px] font-mono px-1.5 py-0.5 border border-emerald-500/40 text-emerald-300 bg-emerald-950/20">
|
|
||||||
INVITE PINNED
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!c.remotePrekeyMismatch && !c.verify_mismatch && trust?.state === 'sas_verified' && (
|
|
||||||
<span className="text-[11px] font-mono px-1.5 py-0.5 border border-green-500/40 text-green-400 bg-green-950/20">
|
|
||||||
SAS VERIFIED
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!c.remotePrekeyMismatch &&
|
|
||||||
!c.verify_mismatch &&
|
|
||||||
!c.remotePrekeyTransparencyConflict &&
|
|
||||||
c.remotePrekeyLookupMode === 'legacy_agent_id' && (
|
|
||||||
<span className="text-[11px] font-mono px-1.5 py-0.5 border border-yellow-500/30 text-yellow-300 bg-yellow-950/10">
|
|
||||||
LEGACY LOOKUP
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!c.remotePrekeyMismatch && !c.verify_mismatch && c.remotePrekeyTransparencyConflict && (
|
|
||||||
<span className="text-[11px] font-mono px-1.5 py-0.5 border border-red-500/40 text-red-300 bg-red-950/20">
|
|
||||||
HISTORY CONFLICT
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!c.remotePrekeyMismatch &&
|
|
||||||
!c.verify_mismatch &&
|
|
||||||
trust?.state === 'tofu_pinned' && (
|
|
||||||
<span className="text-[11px] font-mono px-1.5 py-0.5 border border-amber-500/30 text-amber-300 bg-amber-950/10">
|
|
||||||
TOFU ONLY
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleBlockDM(id);
|
|
||||||
}}
|
|
||||||
className="ml-auto p-0.5 text-[var(--text-muted)] hover:text-red-400 hover:bg-red-900/20 transition-colors"
|
|
||||||
title="Block"
|
|
||||||
>
|
|
||||||
<Ban size={10} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{pendingSent.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="text-[13px] font-mono text-[var(--text-muted)] mt-2 mb-1">
|
|
||||||
PENDING SENT
|
|
||||||
</div>
|
|
||||||
{pendingSent.map((id) => (
|
|
||||||
<div
|
|
||||||
key={id}
|
|
||||||
className="flex items-center gap-2 py-1 text-sm font-mono text-[var(--text-muted)]"
|
|
||||||
>
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-yellow-600/50" />
|
|
||||||
<span className="truncate">{id.slice(0, 16)}</span>
|
|
||||||
<span className="ml-auto text-[12px] text-[var(--text-muted)]">
|
|
||||||
awaiting
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* INBOX VIEW — access requests */}
|
|
||||||
{!secureDmBlocked && dmView === 'inbox' && (
|
|
||||||
<>
|
|
||||||
{accessRequests.length === 0 && (
|
|
||||||
<div className="text-sm font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
|
|
||||||
No incoming requests
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{accessRequests.map((req) => {
|
|
||||||
const requestActionsAllowed = shouldAllowRequestActions(req);
|
|
||||||
const recoveryState = req.sender_recovery_state;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={req.sender_id}
|
|
||||||
className="py-2 border-b border-[var(--border-primary)]/30 last:border-0"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<UserPlus size={10} className="text-cyan-500 shrink-0" />
|
|
||||||
<span className="text-sm font-mono text-cyan-300 truncate">
|
|
||||||
{req.sender_id.slice(0, 16)}
|
|
||||||
</span>
|
|
||||||
{recoveryState === 'verified' && (
|
|
||||||
<span className="text-[12px] font-mono px-1.5 py-0.5 border border-green-500/30 text-green-400 bg-green-950/20">
|
|
||||||
VERIFIED
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{recoveryState === 'pending' && (
|
|
||||||
<span className="text-[12px] font-mono px-1.5 py-0.5 border border-yellow-500/30 text-yellow-300 bg-yellow-950/20">
|
|
||||||
RECOVERY PENDING
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{recoveryState === 'failed' && (
|
|
||||||
<span className="text-[12px] font-mono px-1.5 py-0.5 border border-red-500/30 text-red-300 bg-red-950/20">
|
|
||||||
RECOVERY FAILED
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-[12px] font-mono text-[var(--text-muted)] ml-auto shrink-0">
|
|
||||||
{timeAgo(req.timestamp)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-[13px] font-mono text-[var(--text-muted)] mt-0.5 leading-[1.65]">
|
|
||||||
Requesting Dead Drop access
|
|
||||||
</div>
|
|
||||||
{req.geo_hint && (
|
|
||||||
<div className="text-[12px] font-mono text-[var(--text-muted)] mt-0.5">
|
|
||||||
Geo hint (not proof): {req.geo_hint}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!requestActionsAllowed && (
|
|
||||||
<div className="text-[12px] font-mono text-yellow-300 mt-0.5 leading-[1.65]">
|
|
||||||
Sender authority is not verified yet. Actions stay disabled until
|
|
||||||
local recovery succeeds.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-1.5 mt-1.5">
|
|
||||||
<button
|
|
||||||
onClick={() => handleAcceptRequest(req.sender_id)}
|
|
||||||
disabled={!requestActionsAllowed}
|
|
||||||
className={`flex items-center gap-1 text-[13px] font-mono px-2 py-0.5 transition-colors ${
|
|
||||||
requestActionsAllowed
|
|
||||||
? 'bg-cyan-900/20 text-cyan-400 hover:bg-cyan-800/30'
|
|
||||||
: 'bg-cyan-950/10 text-cyan-700 cursor-not-allowed opacity-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Check size={9} /> ACCEPT
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDenyRequest(req.sender_id)}
|
|
||||||
disabled={!requestActionsAllowed}
|
|
||||||
className={`flex items-center gap-1 text-[13px] font-mono px-2 py-0.5 transition-colors ${
|
|
||||||
requestActionsAllowed
|
|
||||||
? 'bg-gray-900/30 text-gray-400 hover:bg-gray-800/40'
|
|
||||||
: 'bg-gray-950/20 text-gray-600 cursor-not-allowed opacity-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<X size={9} /> DENY
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleBlockDM(req.sender_id)}
|
|
||||||
disabled={!requestActionsAllowed}
|
|
||||||
className={`flex items-center gap-1 text-[13px] font-mono px-2 py-0.5 ml-auto transition-colors ${
|
|
||||||
requestActionsAllowed
|
|
||||||
? 'text-[var(--text-muted)] hover:text-red-400 hover:bg-red-900/20'
|
|
||||||
: 'text-[var(--text-muted)] opacity-50 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Ban size={9} /> BLOCK
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* MUTED LIST VIEW */}
|
|
||||||
{!secureDmBlocked && dmView === 'muted' && (
|
|
||||||
<>
|
|
||||||
{mutedArray.length === 0 && (
|
|
||||||
<div className="text-sm font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
|
|
||||||
No muted users
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{mutedArray.map((uid) => (
|
|
||||||
<div
|
|
||||||
key={uid}
|
|
||||||
className="flex items-center gap-2 py-1.5 border-b border-[var(--border-primary)]/30 last:border-0 px-1 -mx-1"
|
|
||||||
>
|
|
||||||
<EyeOff size={10} className="text-[var(--text-muted)] shrink-0" />
|
|
||||||
<span className="text-sm font-mono text-[var(--text-secondary)] truncate flex-1">
|
|
||||||
{uid.slice(0, 20)}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => handleUnmute(uid)}
|
|
||||||
className="flex items-center gap-1 text-[12px] font-mono px-2 py-0.5 bg-cyan-900/20 text-cyan-500 hover:bg-cyan-800/30 transition-colors"
|
|
||||||
>
|
|
||||||
<Eye size={8} /> UNMUTE
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* CHAT VIEW */}
|
|
||||||
{!secureDmBlocked && dmView === 'chat' && (
|
|
||||||
<>
|
|
||||||
{dmMessages.length === 0 && (
|
|
||||||
<div className="text-sm font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
|
|
||||||
<Lock size={11} className="inline mr-1 mb-0.5" />
|
|
||||||
E2E encrypted dead drop — no messages yet
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{dmMessages.map((m) => (
|
|
||||||
<div key={m.msg_id} className="py-0.5 leading-[1.65]">
|
|
||||||
<div className="flex gap-1.5 text-sm font-mono">
|
|
||||||
<span
|
|
||||||
className={`shrink-0 ${
|
|
||||||
m.sender_id === identity?.nodeId
|
|
||||||
? 'text-cyan-500'
|
|
||||||
: 'text-cyan-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{m.sender_id === identity?.nodeId
|
|
||||||
? 'you'
|
|
||||||
: m.sender_id.slice(0, 12)}
|
|
||||||
</span>
|
|
||||||
{m.sender_id !== identity?.nodeId && m.seal_verified === true && (
|
|
||||||
<span className="text-[12px] font-mono px-1.5 py-0.5 border border-green-500/30 text-green-400 bg-green-950/20">
|
|
||||||
VERIFIED
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{m.sender_id !== identity?.nodeId && m.seal_resolution_failed && (
|
|
||||||
<span className="text-[12px] font-mono px-1.5 py-0.5 border border-red-500/30 text-red-300 bg-red-950/20">
|
|
||||||
SEAL UNRESOLVED
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{m.sender_id !== identity?.nodeId &&
|
|
||||||
!m.seal_resolution_failed &&
|
|
||||||
m.seal_verified === false && (
|
|
||||||
<span className="text-[12px] font-mono px-1.5 py-0.5 border border-red-500/30 text-red-400 bg-red-950/20">
|
|
||||||
UNVERIFIED
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{m.transport && (
|
|
||||||
<span
|
|
||||||
className={`text-[12px] font-mono px-1.5 py-0.5 border ${
|
|
||||||
m.transport === 'reticulum'
|
|
||||||
? 'border-green-500/30 text-green-400 bg-green-950/20'
|
|
||||||
: 'border-yellow-500/30 text-yellow-400 bg-yellow-950/20'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{m.transport === 'reticulum' ? 'DIRECT' : 'RELAY'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-[var(--text-secondary)] break-words whitespace-pre-wrap flex-1">
|
|
||||||
{m.plaintext || '[encrypted]'}
|
|
||||||
</span>
|
|
||||||
<span className="text-[var(--text-muted)] shrink-0 text-[13px]">
|
|
||||||
{timeAgo(m.timestamp)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* INPUT BAR */}
|
{/* INPUT BAR */}
|
||||||
{dashboardRestrictedTab ? (
|
{activeTab === 'dms' ? (
|
||||||
|
<div className="mx-2 mb-2 mt-1 border border-cyan-800/40 bg-black/30 shrink-0 relative">
|
||||||
|
<span className="absolute -top-[7px] left-3 bg-[var(--bg-primary)] px-1 text-[11px] font-mono text-cyan-700/60 tracking-[0.15em] select-none">
|
||||||
|
SHELL
|
||||||
|
</span>
|
||||||
|
<div className="px-3 py-2.5 text-[13px] font-mono text-[var(--text-secondary)] leading-relaxed">
|
||||||
|
Real local shell over PTY. EXPAND widens this panel; SNAP docks it back into Mesh Chat.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : dashboardRestrictedTab ? (
|
||||||
<div className="mx-2 mb-2 mt-1 border border-cyan-800/40 bg-black/30 shrink-0 relative">
|
<div className="mx-2 mb-2 mt-1 border border-cyan-800/40 bg-black/30 shrink-0 relative">
|
||||||
<span className="absolute -top-[7px] left-3 bg-[var(--bg-primary)] px-1 text-[11px] font-mono text-cyan-700/60 tracking-[0.15em] select-none">
|
<span className="absolute -top-[7px] left-3 bg-[var(--bg-primary)] px-1 text-[11px] font-mono text-cyan-700/60 tracking-[0.15em] select-none">
|
||||||
ACCESS
|
ACCESS
|
||||||
</span>
|
</span>
|
||||||
<div className="px-3 py-3 flex flex-col gap-2">
|
<div className="px-3 py-3 flex flex-col gap-2">
|
||||||
<div className="text-[12px] font-mono tracking-widest text-[var(--text-muted)] uppercase">
|
<div className="text-[12px] font-mono tracking-widest text-[var(--text-muted)] uppercase">
|
||||||
{activeTab === 'infonet'
|
→ PRIVATE INFONET / TERMINAL ONLY
|
||||||
? '→ PRIVATE INFONET / TERMINAL ONLY'
|
|
||||||
: '→ DEAD DROP / TERMINAL ONLY'}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[13px] font-mono text-[var(--text-secondary)] leading-[1.65]">
|
<div className="text-[13px] font-mono text-[var(--text-secondary)] leading-[1.65]">
|
||||||
{activeTab === 'infonet'
|
Private gate posting and reading are restricted to the terminal for now. Dashboard support is coming soon.
|
||||||
? 'Private gate posting and reading are restricted to the terminal for now. Dashboard support is coming soon.'
|
|
||||||
: 'Secure messages are restricted to the terminal for now. Dashboard inbox, requests, and compose are coming soon.'}
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={openTerminal}
|
onClick={openTerminal}
|
||||||
@@ -2328,8 +1583,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
? privateInfonetReady
|
? privateInfonetReady
|
||||||
? `→ INFONET${selectedGate ? ` / ${selectedGate}` : ''}${privateInfonetTransportReady ? '' : ' / EXPERIMENTAL ENCRYPTION'}`
|
? `→ INFONET${selectedGate ? ` / ${selectedGate}` : ''}${privateInfonetTransportReady ? '' : ' / EXPERIMENTAL ENCRYPTION'}`
|
||||||
: '→ PRIVATE LANE LOCKED'
|
: '→ PRIVATE LANE LOCKED'
|
||||||
: activeTab === 'meshtastic'
|
: canUsePublicMeshInput
|
||||||
? canUsePublicMeshInput
|
|
||||||
? meshDirectTarget
|
? meshDirectTarget
|
||||||
? `→ MESH / TO ${meshDirectTarget.toUpperCase()} / FROM ${activePublicMeshAddress.toUpperCase()}`
|
? `→ MESH / TO ${meshDirectTarget.toUpperCase()} / FROM ${activePublicMeshAddress.toUpperCase()}`
|
||||||
: `→ MESH / ${meshRegion} / ${meshChannel} / ${activePublicMeshAddress.toUpperCase()}`
|
: `→ MESH / ${meshRegion} / ${meshChannel} / ${activePublicMeshAddress.toUpperCase()}`
|
||||||
@@ -2337,12 +1591,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
? '→ MESH BLOCKED / WORMHOLE ACTIVE'
|
? '→ MESH BLOCKED / WORMHOLE ACTIVE'
|
||||||
: hasStoredPublicLaneIdentity
|
: hasStoredPublicLaneIdentity
|
||||||
? '→ MESH OFF'
|
? '→ MESH OFF'
|
||||||
: '→ MESH LOCKED'
|
: '→ MESH LOCKED'}
|
||||||
: activeTab === 'dms' && secureDmBlocked
|
|
||||||
? '→ DEAD DROP LOCKED'
|
|
||||||
: dmView === 'chat' && selectedContact
|
|
||||||
? `→ DEAD DROP / ${selectedContact.slice(0, 14)}`
|
|
||||||
: '→ SELECT TARGET'}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2373,19 +1622,6 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
OPEN PRIVATE LANE BRIEF
|
OPEN PRIVATE LANE BRIEF
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
) : activeTab === 'dms' && secureDmBlocked ? (
|
|
||||||
<button
|
|
||||||
onClick={() => setDeadDropUnlockOpen(true)}
|
|
||||||
className="w-full flex items-center justify-between gap-2 px-3 py-2 border border-cyan-700/40 bg-cyan-950/15 text-cyan-300 hover:bg-cyan-950/25 hover:border-cyan-500/50 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="inline-flex items-center gap-2 text-sm font-mono tracking-[0.2em]">
|
|
||||||
<Lock size={11} />
|
|
||||||
UNLOCK DEAD DROP
|
|
||||||
</span>
|
|
||||||
<span className="text-[12px] font-mono text-cyan-300/70">
|
|
||||||
NEED WORMHOLE
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
) : activeTab === 'meshtastic' && !canUsePublicMeshInput ? (
|
) : activeTab === 'meshtastic' && !canUsePublicMeshInput ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleMeshActivationAction}
|
onClick={handleMeshActivationAction}
|
||||||
|
|||||||
@@ -4122,12 +4122,10 @@ export function useMeshChatController({
|
|||||||
const dmTrustHint = buildDmTrustHint(selectedContactInfo);
|
const dmTrustHint = buildDmTrustHint(selectedContactInfo);
|
||||||
const dmTrustPrimaryAction = dmTrustPrimaryActionLabel(selectedContactInfo);
|
const dmTrustPrimaryAction = dmTrustPrimaryActionLabel(selectedContactInfo);
|
||||||
const wormholeDescriptor = getWormholeIdentityDescriptor();
|
const wormholeDescriptor = getWormholeIdentityDescriptor();
|
||||||
const dashboardRestrictedTab: boolean = activeTab === 'infonet' || activeTab === 'dms';
|
const dashboardRestrictedTab: boolean = activeTab === 'infonet';
|
||||||
const dashboardRestrictedTitle = activeTab === 'infonet' ? 'INFONET RESTRICTED' : 'DEAD DROP RESTRICTED';
|
const dashboardRestrictedTitle = 'INFONET RESTRICTED';
|
||||||
const dashboardRestrictedDetail =
|
const dashboardRestrictedDetail =
|
||||||
activeTab === 'infonet'
|
'Private Wormhole gate activity is staying in the terminal for this build. Dashboard integration is coming soon.';
|
||||||
? '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 selectedGateKey = selectedGate.trim().toLowerCase();
|
const selectedGateKey = selectedGate.trim().toLowerCase();
|
||||||
const selectedGatePersonaList = selectedGateKey ? gatePersonas[selectedGateKey] || [] : [];
|
const selectedGatePersonaList = selectedGateKey ? gatePersonas[selectedGateKey] || [] : [];
|
||||||
const selectedGateActivePersonaId = selectedGateKey ? activeGatePersonaId[selectedGateKey] || '' : '';
|
const selectedGateActivePersonaId = selectedGateKey ? activeGatePersonaId[selectedGateKey] || '' : '';
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export function resolveAgentShellWsUrl(cwd?: string): string {
|
||||||
|
if (typeof window === 'undefined') return '';
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const host = window.location.hostname || '127.0.0.1';
|
||||||
|
const port =
|
||||||
|
process.env.NEXT_PUBLIC_BACKEND_PORT ||
|
||||||
|
(window.location.port === '3000' ? '8000' : window.location.port || '8000');
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const trimmed = String(cwd || '').trim();
|
||||||
|
if (trimmed) params.set('cwd', trimmed);
|
||||||
|
const query = params.toString();
|
||||||
|
return `${protocol}://${host}:${port}/api/agent-shell/ws${query ? `?${query}` : ''}`;
|
||||||
|
}
|
||||||
@@ -14,6 +14,44 @@ running on `localhost:8000`. It tracks military flights, ships, satellites, SIGI
|
|||||||
earthquakes, fires, GDELT conflict events, prediction markets, and 30+ other data
|
earthquakes, fires, GDELT conflict events, prediction markets, and 30+ other data
|
||||||
layers — all with geographic coordinates.
|
layers — all with geographic coordinates.
|
||||||
|
|
||||||
|
## Agent Fast Path (read first)
|
||||||
|
|
||||||
|
ShadowBroker exposes dozens of read commands. **Do not explore them.** Use the
|
||||||
|
three-tool surface:
|
||||||
|
|
||||||
|
| Tool | When |
|
||||||
|
|------|------|
|
||||||
|
| `await sb.ask("natural language question")` | **Default for reads** — server routes to fastest command |
|
||||||
|
| `await sb.run_playbook("hot_snapshot")` | Pre-batched snapshots (morning brief, monitor poll, status) |
|
||||||
|
| `await sb.channel_status()` | Liveness (~5 ms) — never `/api/health` |
|
||||||
|
|
||||||
|
**Latency tiers:** `find_entity` / `find_flights` / `search_news` / `entities_near` → **⚡ <30 ms**.
|
||||||
|
`search_telemetry` / `get_telemetry` / `get_report` → **🔴 seconds** — blocked unless `confirm_expensive=true`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Default read path (route + execute)
|
||||||
|
answer = await sb.ask("where is the Patriots jet")
|
||||||
|
|
||||||
|
# Named batch plans
|
||||||
|
brief = await sb.run_playbook("hot_snapshot")
|
||||||
|
monitor = await sb.run_playbook("monitor_heartbeat")
|
||||||
|
|
||||||
|
# Structured lookup when you already parsed fields
|
||||||
|
entity = await sb.send_command("find_entity", {"owner": "musk", "compact": True})
|
||||||
|
|
||||||
|
# Multi-command — always batch, never sequential loops
|
||||||
|
batch = await sb.send_batch([
|
||||||
|
{"cmd": "get_summary", "args": {"compact": True}},
|
||||||
|
{"cmd": "what_changed", "args": {"compact": True}},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Playbooks:** `hot_snapshot`, `morning_brief`, `status_check`, `monitor_heartbeat`, `track_snapshot`, `area_brief`, `entity_recon`.
|
||||||
|
|
||||||
|
**Anti-patterns:** `search_telemetry` for known tail numbers; `get_telemetry` for routine polls; sequential `send_command` loops; empty `layers: []` on `get_layer_slice`.
|
||||||
|
|
||||||
|
Load machine-readable routing hints once: `GET /api/ai/capabilities` → `routing`.
|
||||||
|
|
||||||
## How to Use This Skill
|
## How to Use This Skill
|
||||||
|
|
||||||
Import the client and call methods:
|
Import the client and call methods:
|
||||||
@@ -118,8 +156,11 @@ The channel operates over HMAC-authenticated HTTP with body-integrity binding:
|
|||||||
| `sb.stream_updates()` | SSE push: `layer_changed`, alerts, tasks | **Open first, keep open** — tells you exactly which layers updated |
|
| `sb.stream_updates()` | SSE push: `layer_changed`, alerts, tasks | **Open first, keep open** — tells you exactly which layers updated |
|
||||||
| `await sb.get_layer_slice(["ships", "gdelt"])` | Only the requested layers, with per-layer incremental | **Primary fetch method** — automatically skips layers you already have |
|
| `await sb.get_layer_slice(["ships", "gdelt"])` | Only the requested layers, with per-layer incremental | **Primary fetch method** — automatically skips layers you already have |
|
||||||
| `await sb.send_command("get_summary")` | Lightweight counts-only summary | Discover what data exists before pulling anything |
|
| `await sb.send_command("get_summary")` | Lightweight counts-only summary | Discover what data exists before pulling anything |
|
||||||
|
| `await sb.ask("...")` | **Route + execute** | **Default** for natural-language reads |
|
||||||
|
| `await sb.send_command("find_entity", {...})` | Exact-first entity resolver | Parsed person/tail/callsign/MMSI — skips fuzzy unless `fallback_search=true` |
|
||||||
| `await sb.send_command("find_flights", {...})` | Targeted flight search | When you know the domain (callsign, tail number) |
|
| `await sb.send_command("find_flights", {...})` | Targeted flight search | When you know the domain (callsign, tail number) |
|
||||||
| `await sb.send_command("search_telemetry", {...})` | Cross-layer keyword search | When you don't know which layer has the answer |
|
| `await sb.send_command("route_query", {...})` | Routing plan only | Inspect recommended command before executing |
|
||||||
|
| `await sb.send_command("search_telemetry", {...})` | Cross-layer fuzzy search | **Last resort** — requires `confirm_expensive=true` |
|
||||||
|
|
||||||
**Full telemetry dumps (use sparingly — large payloads):**
|
**Full telemetry dumps (use sparingly — large payloads):**
|
||||||
|
|
||||||
@@ -598,10 +639,10 @@ When the user asks a question, follow this decision tree:
|
|||||||
fresh data, pushes alerts instantly, and eliminates blind polling.
|
fresh data, pushes alerts instantly, and eliminates blind polling.
|
||||||
|
|
||||||
2. **Does ShadowBroker have this data already?**
|
2. **Does ShadowBroker have this data already?**
|
||||||
- **Start with `get_summary()`** to see what layers are populated and their counts.
|
- **Natural language** → `await sb.ask(question)` (routes server-side)
|
||||||
- **Known domain** (flight callsign, ship name, keyword) → use the targeted command:
|
- **Batch snapshot** → `await sb.run_playbook("hot_snapshot")`
|
||||||
`find_flights`, `find_ships`, `search_news`, `entities_near`, `search_telemetry`
|
- **Known domain** → `find_entity`, `find_flights`, `find_ships`, `search_news`, `entities_near`
|
||||||
- **Unknown domain** → `search_telemetry` (cross-layer keyword search, ranked results)
|
- **Unknown domain** → `find_entity` first; only then `search_telemetry` with `confirm_expensive=true`
|
||||||
- **Need specific layers** → `get_layer_slice(["military_flights", "gdelt"])` — only
|
- **Need specific layers** → `get_layer_slice(["military_flights", "gdelt"])` — only
|
||||||
fetches layers that changed since your last call (per-layer incremental).
|
fetches layers that changed since your last call (per-layer incremental).
|
||||||
- **Near a location** → `entities_near()` or `get_near_me()` (scans all layers within radius)
|
- **Near a location** → `entities_near()` or `get_near_me()` (scans all layers within radius)
|
||||||
|
|||||||
@@ -424,6 +424,27 @@ def query_snapshots(
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Playbook flattening (monitor_heartbeat → detect_anomalies shape)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _layers_from_playbook(playbook_data: dict) -> dict:
|
||||||
|
"""Turn run_playbook(monitor_heartbeat) results into a layer-keyed dict."""
|
||||||
|
merged: dict[str, list] = {}
|
||||||
|
if not isinstance(playbook_data, dict):
|
||||||
|
return merged
|
||||||
|
for item in playbook_data.get("results") or []:
|
||||||
|
if not isinstance(item, dict) or not item.get("ok"):
|
||||||
|
continue
|
||||||
|
payload = item.get("data") if isinstance(item.get("data"), dict) else {}
|
||||||
|
if item.get("cmd") == "get_layer_slice":
|
||||||
|
layers = payload.get("layers") if isinstance(payload.get("layers"), dict) else {}
|
||||||
|
for layer, rows in layers.items():
|
||||||
|
if isinstance(rows, list):
|
||||||
|
merged[layer] = rows
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Main heartbeat handler
|
# Main heartbeat handler
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -437,7 +458,13 @@ async def heartbeat(sb_client) -> list[str]:
|
|||||||
messages = []
|
messages = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. Pull fresh telemetry (fast + slow merged for full visibility)
|
# 1. Low-latency monitor poll (playbook) — fallback to legacy full pull
|
||||||
|
try:
|
||||||
|
playbook_data = await sb_client.run_playbook("monitor_heartbeat", {})
|
||||||
|
data = _layers_from_playbook(playbook_data)
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
if not data:
|
||||||
data = await sb_client.get_full_telemetry()
|
data = await sb_client.get_full_telemetry()
|
||||||
|
|
||||||
# 2. Run anomaly detection
|
# 2. Run anomaly detection
|
||||||
|
|||||||
@@ -270,6 +270,82 @@ class ShadowBrokerClient:
|
|||||||
r = await self._get("/api/ai/channel/status")
|
r = await self._get("/api/ai/channel/status")
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def unwrap_channel_result(resp: dict) -> dict:
|
||||||
|
"""Extract inner command payload from /api/ai/channel/command response."""
|
||||||
|
if not isinstance(resp, dict):
|
||||||
|
return {}
|
||||||
|
result = resp.get("result")
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return {}
|
||||||
|
if result.get("ok"):
|
||||||
|
data = result.get("data")
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def route_query(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
lat: float | None = None,
|
||||||
|
lng: float | None = None,
|
||||||
|
radius_km: float = 50,
|
||||||
|
compact: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Server-side intent routing — returns recommended command (no LLM)."""
|
||||||
|
args: dict[str, Any] = {"text": text, "radius_km": radius_km, "compact": compact}
|
||||||
|
if lat is not None:
|
||||||
|
args["lat"] = lat
|
||||||
|
if lng is not None:
|
||||||
|
args["lng"] = lng
|
||||||
|
resp = await self.send_command("route_query", args)
|
||||||
|
return self.unwrap_channel_result(resp)
|
||||||
|
|
||||||
|
async def run_playbook(self, name: str, args: dict | None = None) -> dict:
|
||||||
|
"""Execute a named server playbook (batched, concurrent)."""
|
||||||
|
payload = {"name": name, **(args or {})}
|
||||||
|
resp = await self.send_command("run_playbook", payload)
|
||||||
|
return self.unwrap_channel_result(resp)
|
||||||
|
|
||||||
|
async def ask(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
*,
|
||||||
|
lat: float | None = None,
|
||||||
|
lng: float | None = None,
|
||||||
|
radius_km: float = 50,
|
||||||
|
execute: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Natural-language read: route_query → recommended command (one round-trip or two)."""
|
||||||
|
route = await self.route_query(
|
||||||
|
question,
|
||||||
|
lat=lat,
|
||||||
|
lng=lng,
|
||||||
|
radius_km=radius_km,
|
||||||
|
compact=True,
|
||||||
|
)
|
||||||
|
if not route:
|
||||||
|
return {"ok": False, "detail": "route_query returned no plan"}
|
||||||
|
|
||||||
|
if not execute:
|
||||||
|
return {"ok": True, "route": route}
|
||||||
|
|
||||||
|
recommended = route.get("recommended") or {}
|
||||||
|
cmd = str(recommended.get("cmd", "") or "").strip()
|
||||||
|
cmd_args = recommended.get("args") or {}
|
||||||
|
if not cmd:
|
||||||
|
return {"ok": False, "detail": "route produced no command", "route": route}
|
||||||
|
|
||||||
|
exec_resp = await self.send_command(cmd, cmd_args)
|
||||||
|
exec_inner = exec_resp.get("result") if isinstance(exec_resp.get("result"), dict) else {}
|
||||||
|
return {
|
||||||
|
"ok": bool(exec_resp.get("ok") and exec_inner.get("ok")),
|
||||||
|
"route": route,
|
||||||
|
"command": cmd,
|
||||||
|
"args": cmd_args,
|
||||||
|
"result": exec_inner,
|
||||||
|
}
|
||||||
|
|
||||||
async def send_batch(self, commands: list[dict]) -> dict:
|
async def send_batch(self, commands: list[dict]) -> dict:
|
||||||
"""Send multiple commands in a single HTTP round-trip.
|
"""Send multiple commands in a single HTTP round-trip.
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,22 @@ entry_points:
|
|||||||
requirements:
|
requirements:
|
||||||
- httpx>=0.25.0
|
- httpx>=0.25.0
|
||||||
|
|
||||||
|
# Thin agent surface — expose only these to the LLM tool picker
|
||||||
|
agent_surface:
|
||||||
|
primary_reads:
|
||||||
|
- sb_query.ShadowBrokerClient.ask
|
||||||
|
- sb_query.ShadowBrokerClient.run_playbook
|
||||||
|
- sb_query.ShadowBrokerClient.send_batch
|
||||||
|
- sb_query.ShadowBrokerClient.channel_status
|
||||||
|
writes:
|
||||||
|
- sb_query.ShadowBrokerClient.place_pin
|
||||||
|
- sb_query.ShadowBrokerClient.place_pins_batch
|
||||||
|
blocked_without_confirm:
|
||||||
|
- search_telemetry
|
||||||
|
- get_telemetry
|
||||||
|
- get_slow_telemetry
|
||||||
|
- get_report
|
||||||
|
|
||||||
# Capabilities declared
|
# Capabilities declared
|
||||||
capabilities:
|
capabilities:
|
||||||
- live_telemetry # Real-time OSINT data (flights, ships, SIGINT, etc.)
|
- live_telemetry # Real-time OSINT data (flights, ships, SIGINT, etc.)
|
||||||
|
|||||||
Reference in New Issue
Block a user