Files
Shadowbroker/backend/services/openclaw_channel.py
T
2026-05-01 22:56:50 -06:00

1602 lines
65 KiB
Python

"""OpenClaw Bidirectional Command Channel.
Provides an authenticated command channel between OpenClaw agents and
ShadowBroker. Supports both directions:
Agent → SB: Commands (get_telemetry, place_pin, etc.)
SB → Agent: Tasks/alerts pushed by the operator
Current transport:
HMAC Direct: Commands travel via HMAC-SHA256 authenticated HTTP.
Body integrity is bound into the signature (P1A).
No end-to-end encryption — relies on TLS for wire privacy.
Future (not yet implemented):
MLS E2EE: Planned upgrade to route commands via Wormhole DM with
MLS forward secrecy. Not currently wired into this channel.
"""
from __future__ import annotations
import concurrent.futures
import logging
import secrets
import threading
import time
from collections import OrderedDict
from typing import Any
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Persistent thread pool — avoids per-command ThreadPoolExecutor overhead
# ---------------------------------------------------------------------------
_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=4, thread_name_prefix="openclaw-cmd"
)
# ---------------------------------------------------------------------------
# Queue limits
# ---------------------------------------------------------------------------
MAX_PENDING_COMMANDS = 64
MAX_PENDING_TASKS = 32
COMMAND_RESULT_TTL = 300 # 5 minutes
TASK_TTL = 600 # 10 minutes
COMMAND_TIMEOUT = 30 # seconds — hard cap per command execution
# ---------------------------------------------------------------------------
# Command allowlists (keyed by access tier)
# ---------------------------------------------------------------------------
READ_COMMANDS = frozenset({
"get_telemetry",
"get_slow_telemetry",
"get_summary",
"get_report",
"get_layer_slice",
"find_flights",
"find_ships",
"find_entity",
"correlate_entity",
"brief_area",
"what_changed",
"search_telemetry",
"search_news",
"entities_near",
"get_sigint_totals",
"get_prediction_markets",
"get_ai_pins",
"get_correlations",
"channel_status",
"list_watches",
"timemachine_list",
"timemachine_config",
"get_layers",
# SAR layer reads
"sar_status",
"sar_anomalies_recent",
"sar_anomalies_near",
"sar_scene_search",
"sar_coverage_for_aoi",
"sar_aoi_list",
"sar_pin_click",
# Analysis zones (OpenClaw map overlays)
"list_analysis_zones",
})
WRITE_COMMANDS = frozenset({
"place_pin",
"inject_data",
"take_snapshot",
"delete_pin",
"timemachine_playback",
"create_layer",
"update_layer",
"delete_layer",
"refresh_feed",
"add_watch",
"track_entity",
"watch_area",
"remove_watch",
"clear_watches",
"show_satellite",
"show_sentinel",
# SAR layer writes
"sar_aoi_add",
"sar_aoi_remove",
"sar_pin_from_anomaly",
"sar_watch_anomaly",
"sar_focus_aoi",
# Analysis zones (OpenClaw map overlays)
"place_analysis_zone",
"delete_analysis_zone",
"clear_analysis_zones",
})
def allowed_commands(access_tier: str) -> frozenset[str]:
"""Return the set of commands allowed for the given access tier."""
if access_tier == "full":
return READ_COMMANDS | WRITE_COMMANDS
return READ_COMMANDS
# ---------------------------------------------------------------------------
# Tier detection
# ---------------------------------------------------------------------------
_tier_cache: dict[str, Any] | None = None
_tier_cache_ts: float = 0
_TIER_CACHE_TTL = 30 # seconds — tier changes are rare, avoid per-command imports
def detect_tier() -> dict[str, Any]:
"""Detect which communication tier is currently in use.
The command channel currently operates exclusively over HMAC-authenticated
HTTP (Tier 1). MLS E2EE (Tier 2) is planned but not yet wired into
command dispatch — detect_tier never returns tier 2 until that work
is complete.
Results are cached for 30s to avoid expensive dynamic imports on every
command submission.
Returns:
{tier: 1, reason: str, transport: str, forward_secrecy: False,
sealed_sender: False, mls_upgrade_available: bool}
"""
global _tier_cache, _tier_cache_ts
now = time.time()
if _tier_cache is not None and (now - _tier_cache_ts) < _TIER_CACHE_TTL:
return _tier_cache
mls_upgrade_available = False
transport = "unknown"
try:
from services.wormhole_supervisor import get_wormhole_state, transport_tier_from_state
state = get_wormhole_state()
transport = transport_tier_from_state(state) or "unknown"
if transport == "private_strong":
try:
from services.privacy_core_client import PrivacyCoreClient
client = PrivacyCoreClient.load()
if client:
from services.openclaw_bridge import get_agent_public_info
info = get_agent_public_info()
if info.get("bootstrapped"):
# Infrastructure is present but channel dispatch does
# not use it yet — flag for UI without overclaiming.
mls_upgrade_available = True
except Exception:
pass
except Exception:
pass
result = {
"tier": 1,
"reason": "HMAC-authenticated HTTP — commands are signed but not end-to-end encrypted",
"transport": transport,
"forward_secrecy": False,
"sealed_sender": False,
"mls_upgrade_available": mls_upgrade_available,
}
_tier_cache = result
_tier_cache_ts = now
return result
# ---------------------------------------------------------------------------
# Command & Task entries
# ---------------------------------------------------------------------------
class CommandEntry:
"""A command submitted by the agent."""
__slots__ = ("id", "cmd", "args", "submitted_at", "status", "result",
"completed_at", "tier")
def __init__(self, cmd: str, args: dict[str, Any], tier: int = 1):
self.id: str = f"cmd_{int(time.time() * 1000)}_{secrets.token_hex(4)}"
self.cmd = cmd
self.args = dict(args or {})
self.submitted_at = time.time()
self.status = "pending" # pending → executing → completed | failed
self.result: dict[str, Any] | None = None
self.completed_at: float = 0
self.tier = tier
def to_dict(self) -> dict[str, Any]:
d: dict[str, Any] = {
"id": self.id,
"cmd": self.cmd,
"status": self.status,
"submitted_at": self.submitted_at,
"tier": self.tier,
}
if self.result is not None:
d["result"] = self.result
if self.completed_at:
d["completed_at"] = self.completed_at
return d
class TaskEntry:
"""A task pushed by the operator to the agent."""
__slots__ = ("id", "task_type", "payload", "created_at", "picked_up",
"picked_up_at")
def __init__(self, task_type: str, payload: dict[str, Any]):
self.id: str = f"task_{int(time.time() * 1000)}_{secrets.token_hex(4)}"
self.task_type = task_type # alert, request, sync, custom
self.payload = dict(payload or {})
self.created_at = time.time()
self.picked_up = False
self.picked_up_at: float = 0
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"type": self.task_type,
"payload": self.payload,
"created_at": self.created_at,
}
# ---------------------------------------------------------------------------
# Channel singleton
# ---------------------------------------------------------------------------
class CommandChannel:
"""Bidirectional command channel between OpenClaw agent and ShadowBroker."""
def __init__(self) -> None:
self._lock = threading.RLock()
# Agent → SB
self._commands: OrderedDict[str, CommandEntry] = OrderedDict()
# SB → Agent
self._tasks: OrderedDict[str, TaskEntry] = OrderedDict()
self._stats = {
"commands_received": 0,
"commands_executed": 0,
"commands_failed": 0,
"tasks_pushed": 0,
"tasks_picked_up": 0,
}
def _prune_expired(self) -> None:
"""Remove completed commands past TTL and expired tasks."""
now = time.time()
# Prune completed/failed commands
expired_cmds = [
cid for cid, entry in self._commands.items()
if entry.status in ("completed", "failed")
and entry.completed_at
and (now - entry.completed_at) > COMMAND_RESULT_TTL
]
for cid in expired_cmds:
self._commands.pop(cid, None)
# Prune expired tasks
expired_tasks = [
tid for tid, entry in self._tasks.items()
if (now - entry.created_at) > TASK_TTL
]
for tid in expired_tasks:
self._tasks.pop(tid, None)
# -- Agent → SB: Command submission -----------------------------------
def submit_command(self, cmd: str, args: dict[str, Any],
access_tier: str = "restricted") -> dict[str, Any]:
"""Submit a command from the agent.
Returns the command ID for tracking, or an error.
"""
cmd = str(cmd or "").strip().lower()
if not cmd:
return {"ok": False, "detail": "empty command"}
allowed = allowed_commands(access_tier)
if cmd not in allowed:
if cmd in WRITE_COMMANDS and access_tier != "full":
return {
"ok": False,
"detail": f"command '{cmd}' requires full access tier",
}
return {"ok": False, "detail": f"unknown command: {cmd}"}
tier_info = detect_tier()
with self._lock:
self._prune_expired()
pending = sum(
1 for e in self._commands.values() if e.status == "pending"
)
if pending >= MAX_PENDING_COMMANDS:
return {"ok": False, "detail": "command queue full"}
entry = CommandEntry(cmd, args, tier=tier_info["tier"])
self._commands[entry.id] = entry
self._stats["commands_received"] += 1
# Execute with timeout protection
self._execute_command(entry)
return {
"ok": True,
"command_id": entry.id,
"tier": tier_info["tier"],
"status": entry.status,
"result": entry.result,
}
def submit_batch(
self,
commands: list[dict[str, Any]],
access_tier: str = "restricted",
) -> dict[str, Any]:
"""Submit multiple commands in one call and return all results.
Each element should be {"cmd": str, "args": dict}.
Commands execute concurrently in the shared thread pool, so
independent queries (e.g. find_flights + search_news) overlap
instead of serialising behind N HTTP round-trips.
Returns {"ok": True, "results": [...], "tier": int}.
"""
MAX_BATCH = 20
if not commands:
return {"ok": False, "detail": "empty batch"}
if len(commands) > MAX_BATCH:
return {"ok": False, "detail": f"batch too large (max {MAX_BATCH})"}
tier_info = detect_tier()
allowed = allowed_commands(access_tier)
# Pre-allocate results in input order so the caller can match
# result[i] to command[i] by index.
results: list[dict[str, Any]] = [None] * len(commands) # type: ignore[list-item]
entries_with_index: list[tuple[int, CommandEntry]] = []
with self._lock:
self._prune_expired()
pending = sum(
1 for e in self._commands.values() if e.status == "pending"
)
if pending + len(commands) > MAX_PENDING_COMMANDS:
return {"ok": False, "detail": "command queue full"}
# Validate all commands, recording their original index
for idx, item in enumerate(commands):
cmd = str(item.get("cmd", "")).strip().lower()
args = item.get("args") or {}
if not cmd:
results[idx] = {"cmd": cmd, "ok": False, "detail": "empty command"}
continue
if cmd not in allowed:
detail = (f"command '{cmd}' requires full access tier"
if cmd in WRITE_COMMANDS and access_tier != "full"
else f"unknown command: {cmd}")
results[idx] = {"cmd": cmd, "ok": False, "detail": detail}
continue
entry = CommandEntry(cmd, args, tier=tier_info["tier"])
entries_with_index.append((idx, entry))
with self._lock:
self._commands[entry.id] = entry
self._stats["commands_received"] += 1
# Execute valid commands concurrently
if entries_with_index:
future_to_idx: dict[concurrent.futures.Future, tuple[int, CommandEntry]] = {
_executor.submit(_dispatch_command, entry.cmd, entry.args): (idx, entry)
for idx, entry in entries_with_index
}
for future in concurrent.futures.as_completed(
future_to_idx, timeout=COMMAND_TIMEOUT + 5
):
idx, entry = future_to_idx[future]
entry.status = "executing"
try:
entry.result = future.result(timeout=0)
entry.status = "completed"
self._stats["commands_executed"] += 1
except concurrent.futures.TimeoutError:
entry.result = {
"ok": False,
"detail": f"command timed out after {COMMAND_TIMEOUT}s",
}
entry.status = "failed"
self._stats["commands_failed"] += 1
except Exception as exc:
entry.result = {"ok": False, "detail": str(exc)}
entry.status = "failed"
self._stats["commands_failed"] += 1
entry.completed_at = time.time()
results[idx] = {
"cmd": entry.cmd,
"command_id": entry.id,
"ok": entry.status == "completed",
"status": entry.status,
"result": entry.result,
}
return {
"ok": True,
"results": results,
"tier": tier_info["tier"],
"count": len(results),
}
def _execute_command(self, entry: CommandEntry) -> None:
"""Execute a command with timeout protection."""
entry.status = "executing"
try:
future = _executor.submit(_dispatch_command, entry.cmd, entry.args)
result = future.result(timeout=COMMAND_TIMEOUT)
entry.result = result
entry.status = "completed"
self._stats["commands_executed"] += 1
except concurrent.futures.TimeoutError:
entry.result = {
"ok": False,
"detail": f"command timed out after {COMMAND_TIMEOUT}s",
}
entry.status = "failed"
self._stats["commands_failed"] += 1
logger.warning("Command %s timed out after %ds", entry.cmd, COMMAND_TIMEOUT)
except Exception as exc:
entry.result = {"ok": False, "detail": str(exc)}
entry.status = "failed"
self._stats["commands_failed"] += 1
logger.warning("Command %s failed: %s", entry.cmd, exc)
entry.completed_at = time.time()
def get_command_result(self, command_id: str) -> dict[str, Any] | None:
"""Get result for a specific command."""
with self._lock:
entry = self._commands.get(command_id)
if entry is None:
return None
return entry.to_dict()
def get_completed_commands(self) -> list[dict[str, Any]]:
"""Get all completed/failed command results (destructive read)."""
with self._lock:
self._prune_expired()
results = []
consumed = []
for cid, entry in self._commands.items():
if entry.status in ("completed", "failed"):
results.append(entry.to_dict())
consumed.append(cid)
for cid in consumed:
self._commands.pop(cid, None)
return results
# -- SB → Agent: Task push --------------------------------------------
def push_task(self, task_type: str, payload: dict[str, Any]) -> dict[str, Any]:
"""Operator pushes a task to the agent."""
task_type = str(task_type or "custom").strip().lower()
if task_type not in ("alert", "request", "sync", "custom"):
return {"ok": False, "detail": f"invalid task type: {task_type}"}
with self._lock:
self._prune_expired()
pending = sum(1 for t in self._tasks.values() if not t.picked_up)
if pending >= MAX_PENDING_TASKS:
return {"ok": False, "detail": "task queue full"}
entry = TaskEntry(task_type, payload)
self._tasks[entry.id] = entry
self._stats["tasks_pushed"] += 1
return {"ok": True, "task_id": entry.id}
def poll_tasks(self) -> list[dict[str, Any]]:
"""Agent picks up pending tasks (destructive read)."""
with self._lock:
self._prune_expired()
tasks = []
for tid, entry in list(self._tasks.items()):
if not entry.picked_up:
entry.picked_up = True
entry.picked_up_at = time.time()
tasks.append(entry.to_dict())
self._stats["tasks_picked_up"] += 1
# Remove picked-up tasks
consumed = [
tid for tid, entry in self._tasks.items() if entry.picked_up
]
for tid in consumed:
self._tasks.pop(tid, None)
return tasks
# -- Status ------------------------------------------------------------
def status(self) -> dict[str, Any]:
"""Return channel status for the operator."""
tier_info = detect_tier()
with self._lock:
self._prune_expired()
pending_commands = sum(
1 for e in self._commands.values()
if e.status in ("pending", "executing")
)
completed_commands = sum(
1 for e in self._commands.values()
if e.status in ("completed", "failed")
)
pending_tasks = sum(
1 for t in self._tasks.values() if not t.picked_up
)
return {
"ok": True,
**tier_info,
"pending_commands": pending_commands,
"completed_commands": completed_commands,
"pending_tasks": pending_tasks,
"stats": dict(self._stats),
}
# ---------------------------------------------------------------------------
# Compact response helper — reuses the Time Machine compressed_v1 schema.
#
# When an agent passes ``compact=true`` (or ``format="compact"``) on any
# command that returns full telemetry, we reduce each layer to positional
# + identity fields and strip None values. This cuts JSON parse time and
# token count on the agent side without removing information the agent
# actually uses for map reasoning.
# ---------------------------------------------------------------------------
def _wants_compact(args: dict[str, Any]) -> bool:
"""True if the agent requested compact/compressed response formatting."""
if not isinstance(args, dict):
return False
if args.get("compact") is True:
return True
fmt = args.get("format")
if isinstance(fmt, str) and fmt.lower() in ("compact", "compressed", "compressed_v1"):
return True
return False
def _compact_telemetry_dict(data: dict[str, Any] | None) -> dict[str, Any]:
"""Apply the compressed_v1 schema to every layer in a telemetry dict.
Non-layer keys (metadata like ``last_updated``, ``freshness``, scalar
totals) are passed through untouched. Unknown layers fall back to
the generic id/lat/lng/name projection from ``_compress_entity``.
"""
if not isinstance(data, dict):
return data or {}
try:
from routers.ai_intel import _compress_layer_data
except Exception:
return data # compression module unavailable — return as-is
result: dict[str, Any] = {}
for key, val in data.items():
# Metadata / scalars pass through.
if not isinstance(val, (list, dict)):
result[key] = val
continue
# sigint is a dict-of-lists; _compress_layer_data handles that shape.
if isinstance(val, list) or key == "sigint":
try:
result[key] = _compress_layer_data(key, val)
except Exception:
result[key] = val
else:
result[key] = val
return result
def _compact_result_entry(entry: Any) -> Any:
"""Tighten a single search-result dict for compact output.
Query commands (find_flights, find_ships, entities_near, search_*)
already return projected dicts — so the main wins here are:
dropping empty strings / None values, and rounding lat/lng to 3
decimals to match the compressed_v1 precision budget. Non-dict
entries pass through unchanged.
"""
if not isinstance(entry, dict):
return entry
out: dict[str, Any] = {}
for k, v in entry.items():
if v is None:
continue
if isinstance(v, str) and not v:
continue
if k in ("lat", "lng") and isinstance(v, (int, float)):
out[k] = round(float(v), 3)
else:
out[k] = v
return out
def _compact_query_result(result: Any) -> Any:
"""Apply compact projection to a query-command result payload.
Shape is typically ``{"results": [...], "version": N, "truncated": bool}``.
Non-dict payloads and unrecognized shapes pass through.
"""
if not isinstance(result, dict):
return result
results = result.get("results")
if not isinstance(results, list):
return result
out = dict(result)
out["results"] = [_compact_result_entry(r) for r in results]
return out
# ---------------------------------------------------------------------------
# Command dispatcher
# ---------------------------------------------------------------------------
def _dispatch_command(cmd: str, args: dict[str, Any]) -> dict[str, Any]:
"""Route a command to the appropriate AI Intel function.
All commands execute synchronously and return a result dict.
Commands run in an isolated thread (via _execute_command) so they
do not need or touch the caller's event loop.
"""
if cmd == "get_telemetry":
from services.telemetry import get_cached_telemetry_refs
data = get_cached_telemetry_refs()
if _wants_compact(args):
data = _compact_telemetry_dict(data)
return {"ok": True, "data": data, "format": "compressed_v1"}
return {"ok": True, "data": data}
if cmd == "get_slow_telemetry":
from services.telemetry import get_cached_slow_telemetry_refs
data = get_cached_slow_telemetry_refs()
if _wants_compact(args):
data = _compact_telemetry_dict(data)
return {"ok": True, "data": data, "format": "compressed_v1"}
return {"ok": True, "data": data}
if cmd == "get_summary":
from services.telemetry import get_telemetry_summary
summary = get_telemetry_summary()
return {"ok": True, "data": summary, "version": summary.get("version")}
if cmd == "get_layer_slice":
from services.telemetry import get_layer_slice
layers = args.get("layers") or []
slv = args.get("since_layer_versions")
result = get_layer_slice(
layers=layers if isinstance(layers, (list, tuple)) else [],
limit_per_layer=args.get("limit_per_layer"),
since_version=args.get("since_version"),
since_layer_versions=slv if isinstance(slv, dict) else None,
)
if _wants_compact(args) and isinstance(result, dict):
inner = result.get("layers")
if isinstance(inner, dict):
result = dict(result)
result["layers"] = _compact_telemetry_dict(inner)
result["format"] = "compressed_v1"
return {"ok": True, "data": result}
if cmd == "find_flights":
from services.telemetry import find_flights
result = find_flights(
query=str(args.get("query", "") or ""),
callsign=str(args.get("callsign", "") or ""),
registration=str(args.get("registration", "") or ""),
icao24=str(args.get("icao24", "") or ""),
owner=str(args.get("owner", "") or ""),
categories=args.get("categories") if isinstance(args.get("categories"), (list, tuple)) else None,
limit=args.get("limit", 25),
)
if _wants_compact(args):
return {"ok": True, "data": _compact_query_result(result), "format": "compressed_v1"}
return {"ok": True, "data": result}
if cmd == "find_ships":
from services.telemetry import find_ships
result = find_ships(
query=str(args.get("query", "") or ""),
mmsi=str(args.get("mmsi", "") or ""),
imo=str(args.get("imo", "") or ""),
name=str(args.get("name", "") or ""),
limit=args.get("limit", 25),
)
if _wants_compact(args):
return {"ok": True, "data": _compact_query_result(result), "format": "compressed_v1"}
return {"ok": True, "data": result}
if cmd == "find_entity":
from services.telemetry import find_entity
result = find_entity(
query=str(args.get("query", "") or ""),
entity_type=str(args.get("entity_type", "") or args.get("type", "") or ""),
callsign=str(args.get("callsign", "") or ""),
registration=str(args.get("registration", "") or args.get("tail_number", "") or ""),
icao24=str(args.get("icao24", "") or ""),
mmsi=str(args.get("mmsi", "") or ""),
imo=str(args.get("imo", "") or ""),
name=str(args.get("name", "") or ""),
owner=str(args.get("owner", "") or args.get("operator", "") or ""),
layers=args.get("layers") if isinstance(args.get("layers"), (list, tuple)) else None,
limit=args.get("limit", 10),
)
if _wants_compact(args):
compact = dict(result)
compact["results"] = [_compact_result_entry(r) for r in result.get("results", [])]
if isinstance(result.get("best_match"), dict):
compact["best_match"] = _compact_result_entry(result["best_match"])
return {"ok": True, "data": compact, "format": "compressed_v1"}
return {"ok": True, "data": result}
if cmd == "correlate_entity":
from services.telemetry import correlate_entity
result = correlate_entity(
query=str(args.get("query", "") or ""),
entity_type=str(args.get("entity_type", "") or args.get("type", "") or ""),
callsign=str(args.get("callsign", "") or ""),
registration=str(args.get("registration", "") or args.get("tail_number", "") or ""),
icao24=str(args.get("icao24", "") or ""),
mmsi=str(args.get("mmsi", "") or ""),
imo=str(args.get("imo", "") or ""),
name=str(args.get("name", "") or ""),
owner=str(args.get("owner", "") or args.get("operator", "") or ""),
radius_km=args.get("radius_km", 100),
limit=args.get("limit", 10),
)
if _wants_compact(args):
compact = dict(result)
if isinstance(compact.get("lookup"), dict):
compact["lookup"] = dict(compact["lookup"])
compact["lookup"]["results"] = [
_compact_result_entry(r) for r in compact["lookup"].get("results", [])
]
if isinstance(compact["lookup"].get("best_match"), dict):
compact["lookup"]["best_match"] = _compact_result_entry(compact["lookup"]["best_match"])
if isinstance(compact.get("entity"), dict):
compact["entity"] = _compact_result_entry(compact["entity"])
return {"ok": True, "data": compact, "format": "compressed_v1"}
return {"ok": True, "data": result}
if cmd == "search_telemetry":
from services.telemetry import search_telemetry
result = search_telemetry(
query=str(args.get("query", "") or ""),
layers=args.get("layers") if isinstance(args.get("layers"), (list, tuple)) else None,
limit=args.get("limit", 25),
)
if _wants_compact(args):
return {"ok": True, "data": _compact_query_result(result), "format": "compressed_v1"}
return {"ok": True, "data": result}
if cmd == "search_news":
from services.telemetry import search_news
result = search_news(
query=str(args.get("query", "") or ""),
limit=args.get("limit", 10),
include_gdelt=bool(args.get("include_gdelt", True)),
)
if _wants_compact(args):
return {"ok": True, "data": _compact_query_result(result), "format": "compressed_v1"}
return {"ok": True, "data": result}
if cmd == "brief_area":
from services.telemetry import entities_near, search_news, get_layer_slice
lat = args.get("lat")
lng = args.get("lng") if args.get("lng") is not None else args.get("lon")
if lat is None or lng is None:
return {"ok": False, "detail": "lat and lng required"}
radius_km = args.get("radius_km", 50)
entity_types = args.get("entity_types") if isinstance(args.get("entity_types"), (list, tuple)) else None
nearby = entities_near(
lat=lat,
lng=lng,
radius_km=radius_km,
entity_types=entity_types,
limit=args.get("limit", 25),
)
topic = str(args.get("query", "") or args.get("topic", "") or "").strip()
news = search_news(query=topic, limit=10) if topic else {"results": [], "truncated": False}
layers = ["weather_alerts", "earthquakes", "internet_outages", "sar_anomalies"]
context = get_layer_slice(layers=layers, limit_per_layer=args.get("context_limit", 10))
return {
"ok": True,
"data": {
"center": {"lat": float(lat), "lng": float(lng)},
"radius_km": float(radius_km),
"nearby": nearby,
"topic_news": news,
"context_layers": context,
},
}
if cmd == "what_changed":
from services.telemetry import get_layer_slice, get_telemetry_summary
layers = args.get("layers") if isinstance(args.get("layers"), (list, tuple)) else []
if not layers:
return {"ok": True, "data": get_telemetry_summary()}
since_layer_versions = args.get("since_layer_versions")
result = get_layer_slice(
layers=layers,
limit_per_layer=args.get("limit_per_layer", 25),
since_version=args.get("since_version"),
since_layer_versions=since_layer_versions if isinstance(since_layer_versions, dict) else None,
)
return {"ok": True, "data": result}
if cmd == "entities_near":
from services.telemetry import entities_near
lat = args.get("lat")
lng = args.get("lng")
if lat is None or lng is None:
return {"ok": False, "detail": "lat and lng required"}
result = entities_near(
lat=lat,
lng=lng,
radius_km=args.get("radius_km", 50),
entity_types=args.get("entity_types") if isinstance(args.get("entity_types"), (list, tuple)) else None,
limit=args.get("limit", 25),
)
if _wants_compact(args):
return {"ok": True, "data": _compact_query_result(result), "format": "compressed_v1"}
return {"ok": True, "data": result}
if cmd == "get_report":
from services.telemetry import get_cached_telemetry_refs, get_cached_slow_telemetry_refs
fast = get_cached_telemetry_refs()
slow = get_cached_slow_telemetry_refs()
if _wants_compact(args):
return {
"ok": True,
"data": {
"fast": _compact_telemetry_dict(fast),
"slow": _compact_telemetry_dict(slow),
},
"format": "compressed_v1",
}
return {"ok": True, "data": {"fast": fast, "slow": slow}}
if cmd == "get_sigint_totals":
from services.telemetry import get_cached_telemetry_refs
data = get_cached_telemetry_refs()
sigint = data.get("sigint", {}) if data else {}
totals = {}
for key in ("meshtastic", "aprs", "js8call"):
items = sigint.get(key, [])
totals[key] = len(items) if isinstance(items, list) else 0
return {"ok": True, "data": totals}
if cmd == "get_prediction_markets":
from services.telemetry import get_cached_slow_telemetry_refs
slow = get_cached_slow_telemetry_refs()
markets = slow.get("prediction_markets", []) if slow else []
return {"ok": True, "data": markets}
if cmd == "get_ai_pins":
from services.ai_intel_store import get_all_intel_pins
pins = get_all_intel_pins()
return {"ok": True, "data": pins}
if cmd == "get_layers":
from services.ai_intel_store import get_intel_layers
layers = get_intel_layers()
return {"ok": True, "data": layers}
if cmd == "get_correlations":
from services.fetchers._store import get_latest_data_subset_refs
snap = get_latest_data_subset_refs("correlations")
return {"ok": True, "data": snap.get("correlations") or []}
if cmd == "channel_status":
return channel.status()
if cmd == "list_watches":
from services.openclaw_watchdog import list_watches
return {"ok": True, "data": list_watches()}
# -- Write commands (full access only) ---------------------------------
if cmd == "place_pin":
from services.ai_intel_store import add_intel_pin
pin = add_intel_pin(args)
return {"ok": True, "data": pin}
if cmd == "delete_pin":
pin_id = str(args.get("id", "") or args.get("pin_id", "")).strip()
if not pin_id:
return {"ok": False, "detail": "pin id required"}
from services.ai_intel_store import delete_intel_pin
result = delete_intel_pin(pin_id)
return {"ok": True, "data": result}
if cmd == "inject_data":
layer = str(args.get("layer", "")).strip()
items = args.get("items", [])
if not layer or not items:
return {"ok": False, "detail": "layer and items required"}
from services.ai_intel_store import inject_layer_data
result = inject_layer_data(layer, items)
return {"ok": True, "data": result}
if cmd == "create_layer":
from services.ai_intel_store import create_intel_layer
name = str(args.get("name", "")).strip()
if not name:
return {"ok": False, "detail": "layer name required"}
layer = create_intel_layer(args)
return {"ok": True, "data": layer}
if cmd == "update_layer":
layer_id = str(args.get("layer_id", "") or args.get("id", "")).strip()
if not layer_id:
return {"ok": False, "detail": "layer_id required"}
from services.ai_intel_store import update_intel_layer
result = update_intel_layer(layer_id, args)
if result is None:
return {"ok": False, "detail": f"layer '{layer_id}' not found"}
return {"ok": True, "data": result}
if cmd == "delete_layer":
layer_id = str(args.get("layer_id", "") or args.get("id", "")).strip()
if not layer_id:
return {"ok": False, "detail": "layer_id required"}
from services.ai_intel_store import delete_intel_layer
removed = delete_intel_layer(layer_id)
return {"ok": True, "data": {"layer_id": layer_id, "pins_removed": removed}}
if cmd == "refresh_feed":
layer_id = str(args.get("layer_id", "") or args.get("id", "")).strip()
if not layer_id:
return {"ok": False, "detail": "layer_id required"}
from services.ai_intel_store import get_intel_layers
layers = get_intel_layers()
target = next((l for l in layers if l["id"] == layer_id), None)
if target is None:
return {"ok": False, "detail": f"layer '{layer_id}' not found"}
if not target.get("feed_url"):
return {"ok": False, "detail": "layer has no feed URL"}
from services.feed_ingester import _fetch_layer_feed
_fetch_layer_feed(target)
# Re-fetch for updated state
layers = get_intel_layers()
updated = next((l for l in layers if l["id"] == layer_id), target)
return {"ok": True, "data": updated}
if cmd == "take_snapshot":
from routers.ai_intel import _take_snapshot_internal
layers = args.get("layers") or []
compress = args.get("compress", True)
result = _take_snapshot_internal(
layers=layers if layers else None,
profile="openclaw",
compress=compress,
)
return {"ok": True, "data": result}
if cmd == "timemachine_list":
from routers.ai_intel import _snapshots, _snapshots_lock
from services.node_settings import read_node_settings
tm_on = read_node_settings().get("timemachine_enabled", False)
with _snapshots_lock:
recent = [
{"id": s["id"], "timestamp": s["timestamp"],
"format": s.get("format", "full"),
"layers": s["layers"], "layer_counts": s["layer_counts"]}
for s in _snapshots[-20:]
]
return {"ok": True, "data": recent, "enabled": tm_on,
"notice": None if tm_on else "Time Machine auto-snapshots are currently OFF. "
"The operator can enable them in Settings > Protocol. "
"Warn the user: ~68 MB/day (~2 GB/month) storage cost."}
if cmd == "timemachine_playback":
snapshot_id = str(args.get("snapshot_id", "")).strip()
if not snapshot_id:
return {"ok": False, "detail": "snapshot_id required"}
from routers.ai_intel import _snapshots, _snapshots_lock, _expand_compressed_entity
with _snapshots_lock:
target = None
for snap in _snapshots:
if snap["id"] == snapshot_id:
target = snap
break
if target is None:
return {"ok": False, "detail": f"snapshot '{snapshot_id}' not found"}
data = target.get("data", {})
if target.get("format") == "compressed_v1":
expanded = {}
for layer, items in data.items():
if isinstance(items, list):
expanded[layer] = [_expand_compressed_entity(layer, e) for e in items]
else:
expanded[layer] = items
data = expanded
return {"ok": True, "data": {
"snapshot_id": target["id"], "timestamp": target["timestamp"],
"mode": "playback", "layers": target["layers"], "data": data,
}}
if cmd == "timemachine_config":
from routers.ai_intel import _timemachine_config
from services.node_settings import read_node_settings
tm_on = read_node_settings().get("timemachine_enabled", False)
return {"ok": True, "data": {
**_timemachine_config,
"enabled": tm_on,
"storage_notice": "Time Machine auto-snapshots use ~68 MB/day (~2 GB/month) of compressed storage. "
"This feature is OFF by default. The operator must explicitly enable it in Settings > Protocol. "
"Always inform the user of the storage cost before recommending they turn it on.",
}}
# -- Watchdog commands (write access — agent sets up its own alerts) ----
if cmd == "add_watch":
from services.openclaw_watchdog import add_watch
watch_type = str(args.get("type", "")).strip()
if not watch_type:
return {"ok": False, "detail": "watch type required (track_aircraft, track_callsign, track_registration, track_ship, track_entity, geofence, keyword, prediction_market)"}
watch_params = args.get("params", {})
if not watch_params:
# Allow flat args (e.g. {type: "track_callsign", callsign: "N189AM"})
watch_params = {k: v for k, v in args.items() if k not in ("type", "params")}
result = add_watch(watch_type, watch_params)
return {"ok": True, "data": result}
if cmd == "track_entity":
from services.openclaw_watchdog import add_watch
from services.telemetry import find_entity
query = str(args.get("query", "") or args.get("name", "") or "").strip()
entity_type = str(args.get("entity_type", "") or args.get("type", "") or "").strip().lower()
lookup = find_entity(
query=query,
entity_type=entity_type,
callsign=str(args.get("callsign", "") or ""),
registration=str(args.get("registration", "") or args.get("tail_number", "") or ""),
icao24=str(args.get("icao24", "") or ""),
mmsi=str(args.get("mmsi", "") or ""),
imo=str(args.get("imo", "") or ""),
name=str(args.get("name", "") or ""),
owner=str(args.get("owner", "") or args.get("operator", "") or ""),
layers=args.get("layers") if isinstance(args.get("layers"), (list, tuple)) else None,
limit=5,
)
best = lookup.get("best_match") if isinstance(lookup.get("best_match"), dict) else {}
group = str(best.get("group", "") or entity_type).lower()
params = {
"query": query or best.get("label") or best.get("name") or "",
"entity_type": entity_type or group,
"callsign": args.get("callsign") or best.get("callsign") or (best.get("label") if group == "aircraft" else "") or "",
"registration": args.get("registration") or args.get("tail_number") or best.get("registration") or (best.get("id") if group == "aircraft" else "") or "",
"icao24": (
args.get("icao24")
or best.get("icao24")
or (best.get("id") if group == "aircraft" else "")
),
"mmsi": args.get("mmsi") or best.get("mmsi") or "",
"imo": args.get("imo") or best.get("imo") or "",
"name": args.get("name") or best.get("name") or best.get("label") or "",
"owner": args.get("owner") or args.get("operator") or best.get("owner") or "",
}
if group == "aircraft" or entity_type in {"aircraft", "plane", "flight", "jet", "helicopter"} or any(params.get(k) for k in ("callsign", "registration", "icao24")):
watch_type = "track_aircraft"
elif group == "maritime" or entity_type in {"ship", "ships", "vessel", "boat", "yacht", "maritime"} or any(params.get(k) for k in ("mmsi", "imo")):
watch_type = "track_ship"
else:
watch_type = "track_entity"
if isinstance(args.get("layers"), (list, tuple)):
params["layers"] = list(args.get("layers") or [])
result = add_watch(watch_type, {k: v for k, v in params.items() if v not in (None, "")})
return {"ok": True, "data": {"watch": result, "watch_type": watch_type, "initial_lookup": lookup}}
if cmd == "watch_area":
from services.openclaw_watchdog import add_watch
lat = args.get("lat")
lng = args.get("lng") if args.get("lng") is not None else args.get("lon")
if lat is None or lng is None:
return {"ok": False, "detail": "lat and lng required"}
entity_types = args.get("entity_types")
if not isinstance(entity_types, (list, tuple)):
entity_types = ["aircraft", "ships"]
params = {
"lat": float(lat),
"lng": float(lng),
"radius_km": float(args.get("radius_km", 50) or 50),
"entity_types": list(entity_types),
}
if args.get("label"):
params["label"] = str(args.get("label"))
result = add_watch("geofence", params)
return {"ok": True, "data": result}
if cmd == "remove_watch":
from services.openclaw_watchdog import remove_watch
watch_id = str(args.get("id", "") or args.get("watch_id", "")).strip()
if not watch_id:
return {"ok": False, "detail": "watch id required"}
return remove_watch(watch_id)
if cmd == "clear_watches":
from services.openclaw_watchdog import clear_watches
return clear_watches()
# -- Display commands (agent shows imagery to user) ----------------------
if cmd == "show_satellite":
lat = args.get("lat")
lng = args.get("lng")
if lat is None or lng is None:
return {"ok": False, "detail": "lat and lng required"}
try:
lat, lng = float(lat), float(lng)
except (ValueError, TypeError):
return {"ok": False, "detail": "lat/lng must be numbers"}
# Fetch satellite imagery
from services.sentinel_search import search_sentinel2_scene
scene = search_sentinel2_scene(lat, lng)
# Push display action to frontend
from routers.ai_intel import push_agent_action
push_agent_action({
"action": "show_image",
"source": "sentinel2",
"lat": lat,
"lng": lng,
"sentinel2": scene,
"caption": str(args.get("caption", "")) or None,
})
return {"ok": True, "data": {
"displayed": True,
"lat": lat,
"lng": lng,
"scene": scene,
}}
if cmd == "show_sentinel":
lat = args.get("lat")
lng = args.get("lng")
if lat is None or lng is None:
return {"ok": False, "detail": "lat and lng required"}
try:
lat, lng = float(lat), float(lng)
except (ValueError, TypeError):
return {"ok": False, "detail": "lat/lng must be numbers"}
preset = str(args.get("preset", "TRUE-COLOR")).upper()
if preset not in ("TRUE-COLOR", "FALSE-COLOR", "NDVI", "MOISTURE-INDEX"):
preset = "TRUE-COLOR"
# Build a Sentinel Hub Process API image URL via the existing backend proxy.
# The frontend will need CDSE credentials to be configured.
# For the agent, we generate the tile request params so the frontend can fetch it.
from routers.ai_intel import push_agent_action
push_agent_action({
"action": "show_image",
"source": "sentinel_hub",
"lat": lat,
"lng": lng,
"preset": preset,
"caption": str(args.get("caption", "")) or None,
})
return {"ok": True, "data": {
"displayed": True,
"lat": lat,
"lng": lng,
"preset": preset,
"note": "Image will display if user has Copernicus CDSE credentials configured. "
"Falls back to Sentinel-2 STAC (free) if not.",
}}
# -- SAR layer commands ------------------------------------------------
# Read-only commands return data even when Mode B is disabled — the
# status payload tells the agent how to enable it.
if cmd == "sar_status":
from services.sar.sar_config import (
catalog_enabled as _sar_catalog_enabled,
openclaw_enabled as _sar_openclaw_enabled,
products_fetch_status,
require_private_tier_for_publish,
)
if not _sar_openclaw_enabled():
return {"ok": False, "detail": "SAR OpenClaw integration disabled (MESH_SAR_OPENCLAW_ENABLED=false)"}
return {
"ok": True,
"data": {
"catalog_enabled": _sar_catalog_enabled(),
"products": products_fetch_status(),
"require_private_tier": require_private_tier_for_publish(),
},
}
if cmd == "sar_anomalies_recent":
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
if not _sar_openclaw_enabled():
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
from services.fetchers._store import get_latest_data_subset_refs
snap = get_latest_data_subset_refs("sar_anomalies")
items = list(snap.get("sar_anomalies") or [])
kind = str(args.get("kind", "") or "").strip()
if kind:
items = [a for a in items if a.get("kind") == kind]
limit = int(args.get("limit", 50) or 50)
return {"ok": True, "data": items[:limit]}
if cmd == "sar_anomalies_near":
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
if not _sar_openclaw_enabled():
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
lat = args.get("lat")
lng = args.get("lng") if args.get("lng") is not None else args.get("lon")
if lat is None or lng is None:
return {"ok": False, "detail": "lat and lng required"}
try:
lat_f = float(lat)
lng_f = float(lng)
except (TypeError, ValueError):
return {"ok": False, "detail": "lat/lng must be numeric"}
radius_km = float(args.get("radius_km", 50) or 50)
from services.fetchers._store import get_latest_data_subset_refs
from services.sar.sar_aoi import haversine_km
snap = get_latest_data_subset_refs("sar_anomalies")
matches = []
for a in (snap.get("sar_anomalies") or []):
try:
d = haversine_km(lat_f, lng_f, float(a.get("lat", 0.0)), float(a.get("lon", 0.0)))
except (TypeError, ValueError):
continue
if d <= radius_km:
a2 = dict(a)
a2["distance_km"] = round(d, 2)
matches.append(a2)
matches.sort(key=lambda x: x.get("distance_km", 0))
limit = int(args.get("limit", 25) or 25)
return {"ok": True, "data": matches[:limit]}
if cmd == "sar_scene_search":
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
if not _sar_openclaw_enabled():
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
from services.fetchers._store import get_latest_data_subset_refs
snap = get_latest_data_subset_refs("sar_scenes")
items = list(snap.get("sar_scenes") or [])
aoi_id = str(args.get("aoi_id", "") or "").strip().lower()
if aoi_id:
items = [s for s in items if (s.get("aoi_id") or "").lower() == aoi_id]
limit = int(args.get("limit", 50) or 50)
return {"ok": True, "data": items[:limit]}
if cmd == "sar_coverage_for_aoi":
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
if not _sar_openclaw_enabled():
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
from services.fetchers._store import get_latest_data_subset_refs
snap = get_latest_data_subset_refs("sar_aoi_coverage")
coverage = list(snap.get("sar_aoi_coverage") or [])
aoi_id = str(args.get("aoi_id", "") or "").strip().lower()
if aoi_id:
coverage = [c for c in coverage if (c.get("aoi_id") or "").lower() == aoi_id]
return {"ok": True, "data": coverage}
if cmd == "sar_aoi_list":
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
if not _sar_openclaw_enabled():
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
from services.sar.sar_aoi import load_aois
return {"ok": True, "data": [a.to_dict() for a in load_aois(force=True)]}
if cmd == "sar_aoi_add":
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
if not _sar_openclaw_enabled():
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
try:
from services.sar.sar_aoi import SarAoi, add_aoi
aoi = SarAoi(
id=str(args.get("id", "")).strip().lower(),
name=str(args.get("name", "")).strip() or str(args.get("id", "")),
description=str(args.get("description", "")).strip(),
center_lat=float(args.get("center_lat", args.get("lat", 0.0))),
center_lon=float(args.get("center_lon", args.get("lon", 0.0))),
radius_km=float(args.get("radius_km", 25.0)),
polygon=args.get("polygon") if isinstance(args.get("polygon"), list) else None,
category=str(args.get("category", "watchlist")).strip().lower() or "watchlist",
)
except (TypeError, ValueError) as exc:
return {"ok": False, "detail": f"invalid AOI: {exc}"}
if not aoi.id:
return {"ok": False, "detail": "AOI id required"}
add_aoi(aoi)
return {"ok": True, "data": aoi.to_dict()}
if cmd == "sar_aoi_remove":
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
if not _sar_openclaw_enabled():
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
from services.sar.sar_aoi import remove_aoi
aoi_id = str(args.get("id", "") or args.get("aoi_id", "")).strip().lower()
if not aoi_id:
return {"ok": False, "detail": "aoi id required"}
removed = remove_aoi(aoi_id)
return {"ok": True, "data": {"removed": removed, "id": aoi_id}}
if cmd == "sar_pin_from_anomaly":
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
if not _sar_openclaw_enabled():
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
anomaly_id = str(args.get("anomaly_id", "")).strip()
if not anomaly_id:
return {"ok": False, "detail": "anomaly_id required"}
from services.fetchers._store import get_latest_data_subset_refs
snap = get_latest_data_subset_refs("sar_anomalies")
match = next(
(a for a in (snap.get("sar_anomalies") or []) if a.get("anomaly_id") == anomaly_id),
None,
)
if match is None:
return {"ok": False, "detail": f"anomaly '{anomaly_id}' not found"}
from services.ai_intel_store import add_intel_pin
kind = match.get("kind", "sar_anomaly")
pin_args = {
"lat": match.get("lat", 0.0),
"lng": match.get("lon", 0.0),
"label": str(args.get("label") or f"SAR {kind}")[:200],
"category": "sar",
"description": str(
args.get("description")
or f"{kind} (mag={match.get('magnitude')} {match.get('magnitude_unit','')})"
),
"source": match.get("solver", "sar"),
"source_url": match.get("source_url", ""),
"confidence": float(match.get("confidence", 0.5)),
"metadata": {
"anomaly_id": anomaly_id,
"evidence_hash": match.get("evidence_hash"),
"stack_id": match.get("stack_id"),
"constellation": match.get("source_constellation"),
"first_seen": match.get("first_seen"),
"last_seen": match.get("last_seen"),
},
}
pin = add_intel_pin(pin_args)
return {"ok": True, "data": pin}
if cmd == "sar_pin_click":
# Return the full detail payload that the map popup shows when a
# user clicks a SAR anomaly pin. Lets OpenClaw "inspect" a pin
# programmatically without screen-scraping the popup.
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
if not _sar_openclaw_enabled():
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
anomaly_id = str(args.get("anomaly_id", "") or args.get("id", "")).strip()
if not anomaly_id:
return {"ok": False, "detail": "anomaly_id required"}
from services.fetchers._store import get_latest_data_subset_refs
snap = get_latest_data_subset_refs("sar_anomalies")
anomaly = next(
(a for a in (snap.get("sar_anomalies") or []) if a.get("anomaly_id") == anomaly_id),
None,
)
if anomaly is None:
return {"ok": False, "detail": f"anomaly '{anomaly_id}' not found"}
# Pull AOI metadata + recent scenes over the same AOI, mirroring
# the detail popup the operator would see.
aoi_id = str(anomaly.get("aoi_id") or "").lower()
aoi_meta: dict[str, Any] | None = None
recent_scenes: list[dict[str, Any]] = []
if aoi_id:
try:
from services.sar.sar_aoi import load_aois
match = next((a for a in load_aois() if a.id.lower() == aoi_id), None)
if match is not None:
aoi_meta = match.to_dict()
except Exception:
pass
try:
scenes_snap = get_latest_data_subset_refs("sar_scenes")
all_scenes = list(scenes_snap.get("sar_scenes") or [])
recent_scenes = [
s for s in all_scenes if (s.get("aoi_id") or "").lower() == aoi_id
][:10]
except Exception:
pass
return {
"ok": True,
"data": {
"anomaly": anomaly,
"aoi": aoi_meta,
"recent_scenes": recent_scenes,
},
}
if cmd == "sar_focus_aoi":
# Fly the user's map to an AOI's center (and optionally open its
# detail popup via selectedEntity semantics on the frontend side).
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
if not _sar_openclaw_enabled():
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
aoi_id = str(args.get("aoi_id", "") or args.get("id", "")).strip().lower()
if not aoi_id:
return {"ok": False, "detail": "aoi_id required"}
try:
from services.sar.sar_aoi import load_aois
match = next((a for a in load_aois() if a.id.lower() == aoi_id), None)
except Exception as exc:
return {"ok": False, "detail": f"aoi load failed: {exc}"}
if match is None:
return {"ok": False, "detail": f"aoi '{aoi_id}' not found"}
try:
zoom = float(args.get("zoom", 8.0))
except (TypeError, ValueError):
zoom = 8.0
from routers.ai_intel import push_agent_action
push_agent_action({
"action": "fly_to",
"source": "sar_focus_aoi",
"lat": float(match.center_lat),
"lng": float(match.center_lon),
"zoom": zoom,
"aoi_id": match.id,
"caption": f"AOI: {match.name}",
})
return {
"ok": True,
"data": {
"dispatched": True,
"aoi": match.to_dict(),
},
}
if cmd == "sar_watch_anomaly":
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
if not _sar_openclaw_enabled():
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
try:
from services.openclaw_watchdog import add_watch
except ImportError:
return {"ok": False, "detail": "watchdog module unavailable"}
aoi_id = str(args.get("aoi_id", "")).strip().lower()
kind = str(args.get("kind", "")).strip()
if not aoi_id:
return {"ok": False, "detail": "aoi_id required"}
watch_params = {
"label": str(args.get("label") or f"SAR watch {aoi_id}"),
"aoi_id": aoi_id,
"kind": kind,
"min_magnitude": float(args.get("min_magnitude", 0.0) or 0.0),
}
result = add_watch("sar_anomaly", watch_params)
return {"ok": True, "data": result}
# ------------------------------------------------------------------
# Analysis zones — OpenClaw map overlays (yellow squares with reports)
# ------------------------------------------------------------------
if cmd == "list_analysis_zones":
from services.analysis_zone_store import list_zones
return {"ok": True, "data": {"zones": list_zones()}}
if cmd == "place_analysis_zone":
from services.analysis_zone_store import create_zone
lat = args.get("lat")
lng = args.get("lng")
if lat is None or lng is None:
return {"ok": False, "detail": "lat and lng required"}
title = str(args.get("title", "Analysis Zone")).strip()
body = str(args.get("body", "")).strip()
if not body:
return {"ok": False, "detail": "body (analysis text) required"}
zone = create_zone(
lat=float(lat),
lng=float(lng),
title=title,
body=body,
category=str(args.get("category", "analysis")).strip().lower(),
severity=str(args.get("severity", "medium")).strip().lower(),
cell_size_deg=float(args.get("cell_size_deg", 1.0) or 1.0),
ttl_hours=float(args.get("ttl_hours", 0) or 0),
source="openclaw",
drivers=args.get("drivers"),
)
return {"ok": True, "data": {"zone": zone}}
if cmd == "delete_analysis_zone":
from services.analysis_zone_store import delete_zone
zone_id = str(args.get("zone_id", "") or args.get("id", "")).strip()
if not zone_id:
return {"ok": False, "detail": "zone_id required"}
removed = delete_zone(zone_id)
if not removed:
return {"ok": False, "detail": "zone not found"}
return {"ok": True, "data": {"removed": zone_id}}
if cmd == "clear_analysis_zones":
from services.analysis_zone_store import clear_zones
count = clear_zones(source="openclaw")
return {"ok": True, "data": {"removed_count": count}}
return {"ok": False, "detail": f"unhandled command: {cmd}"}
# ---------------------------------------------------------------------------
# Cover traffic for command channel polling
# ---------------------------------------------------------------------------
# When high-privacy mode is active, the channel emits synthetic poll
# responses at fixed intervals so an observer watching the HTTP cadence
# cannot distinguish active agent sessions from idle ones.
#
# Design mirrors mesh_rns._cover_loop: fixed interval + jitter, no adaptive
# backoff (S8A ruling: expanding the interval when real traffic is present
# leaks activity state).
#
# This is response-surface only — cover polls return the same JSON shape as
# real polls but with empty result arrays. No relay internals are touched.
# ---------------------------------------------------------------------------
COVER_POLL_INTERVAL = 10 # seconds between synthetic polls
COVER_POLL_JITTER = (0.7, 1.3) # multiplier range
_cover_poll_enabled = False
_cover_poll_thread = None
def _is_high_privacy_channel() -> bool:
"""Check if high-privacy mode is active (same check as mesh cover loop)."""
try:
from services.config import get_settings
settings = get_settings()
return bool(getattr(settings, "MESH_RNS_HIGH_PRIVACY", False))
except Exception:
return False
def _cover_poll_loop() -> None:
"""Daemon thread that generates synthetic poll cadence.
Records synthetic poll events in the channel stats so an external
observer sees uniform poll timing regardless of agent activity.
"""
import random
while _cover_poll_enabled:
try:
if not _is_high_privacy_channel():
time.sleep(3)
continue
# Synthetic poll — same shape as real poll response but empty.
# This touches only the stats counter, not the queue.
with channel._lock:
channel._stats.setdefault("cover_polls", 0)
channel._stats["cover_polls"] += 1
jitter = random.uniform(*COVER_POLL_JITTER)
time.sleep(COVER_POLL_INTERVAL * jitter)
except Exception:
time.sleep(5)
def start_cover_poll() -> None:
"""Start the cover poll daemon if not already running."""
global _cover_poll_enabled, _cover_poll_thread
if _cover_poll_thread and _cover_poll_thread.is_alive():
return
_cover_poll_enabled = True
_cover_poll_thread = threading.Thread(
target=_cover_poll_loop, daemon=True, name="openclaw-cover-poll"
)
_cover_poll_thread.start()
logger.info("OpenClaw cover poll daemon started (interval=%ds)", COVER_POLL_INTERVAL)
def stop_cover_poll() -> None:
"""Stop the cover poll daemon."""
global _cover_poll_enabled
_cover_poll_enabled = False
# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------
channel = CommandChannel()