'use client';
import React, { useState, useEffect, useCallback } from 'react';
import ReactDOM from 'react-dom';
import { getBackendEndpoint } from '@/lib/backendEndpoint';
import { motion, AnimatePresence } from 'framer-motion';
import {
Brain,
MapPin,
Trash2,
Minus,
Plus,
Crosshair,
Navigation,
RefreshCw,
X,
Link2,
Copy,
Check,
Shield,
Eye,
EyeOff,
AlertTriangle,
Zap,
ChevronDown,
ChevronRight,
Globe,
Rss,
} from 'lucide-react';
import { API_BASE } from '@/lib/api';
import type { AIIntelPin, AIIntelLayer, SatelliteScene } from '@/types/aiIntel';
import { useTranslation } from '@/i18n';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import {
createLayer as apiCreateLayer,
updateLayer as apiUpdateLayer,
deleteLayer as apiDeleteLayer,
refreshLayerFeed as apiRefreshLayerFeed,
fetchSatelliteImages,
} from '@/lib/aiIntelClient';
interface AIIntelPanelProps {
onFlyTo?: (lat: number, lng: number) => void;
isMinimized?: boolean;
onMinimizedChange?: (minimized: boolean) => void;
pinPlacementMode?: boolean;
onPinPlacementModeChange?: (active: boolean) => void;
}
/* ─── Agent Identity (Ed25519 keypair — future MLS upgrade) ───────── */
function WormholeIdentitySection() {
const [identity, setIdentity] = React.useState<{
bootstrapped: boolean;
node_id: string;
public_key: string;
} | null>(null);
const [loading, setLoading] = React.useState(true);
const [bootstrapping, setBootstrapping] = React.useState(false);
const fetchIdentity = React.useCallback(async () => {
try {
setLoading(true);
const res = await fetch(`${API_BASE}/api/ai/agent-identity`);
if (res.ok) {
const data = await res.json();
setIdentity(data);
}
} catch { /* ignore */ }
finally { setLoading(false); }
}, []);
React.useEffect(() => { fetchIdentity(); }, [fetchIdentity]);
const handleBootstrap = async (force: boolean = false) => {
if (force && !confirm('Regenerate agent identity? The old keypair will be permanently destroyed.')) return;
setBootstrapping(true);
try {
const res = await fetch(`${API_BASE}/api/ai/agent-identity/bootstrap`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force }),
});
if (res.ok) {
const data = await res.json();
setIdentity(data);
}
} catch { /* ignore */ }
finally { setBootstrapping(false); }
};
const handleRevoke = async () => {
if (!confirm('Permanently revoke agent identity? This cannot be undone.')) return;
try {
await fetch(`${API_BASE}/api/ai/agent-identity`, { method: 'DELETE' });
setIdentity({ bootstrapped: false, node_id: '', public_key: '' });
} catch { /* ignore */ }
};
if (loading) {
return (
Agent Identity
loading...
);
}
return (
Agent Identity (Ed25519)
HMAC AUTH
{identity?.bootstrapped ? (
Identity Active
Node ID:{identity.node_id}
Public Key:
{identity.public_key ? identity.public_key.substring(0, 12) + '...' : 'N/A'}
Agent has its own Ed25519 identity, separate from the operator. Commands currently
travel via HMAC-authenticated HTTP (not E2EE). Private key never leaves this server.
) : (
Generate an Ed25519 identity for your agent. This keypair is used for
mesh signing. Commands currently travel via HMAC-authenticated HTTP.
Commands authenticated via HMAC-SHA256 with body-integrity binding over HTTP.
Wire privacy relies on TLS. End-to-end encryption is not yet available for this channel.
);
}
/* ─── Connect OpenClaw Modal Body ─────────────────────────────────── */
interface ConnectModalBodyProps {
apiEndpoint: string;
handleCopy: (text: string) => void;
copied: boolean;
}
function ConnectModalBody({ apiEndpoint, handleCopy, copied }: ConnectModalBodyProps) {
const [riskAccepted, setRiskAccepted] = React.useState(false);
const [accessTier, setAccessTier] = React.useState<'restricted' | 'full'>('restricted');
const [connectionMode, setConnectionMode] = React.useState<'local' | 'remote'>('local');
// hmacSecret holds the FULL secret once the operator has clicked
// Reveal (or after a regenerate). maskedHmacSecret is the safe-to-show
// fingerprint returned by GET /api/ai/connect-info and is loaded on
// mount. The two are independent state slots so a stale full secret
// can never leak back into the UI after a regenerate.
const [hmacSecret, setHmacSecret] = React.useState('');
const [maskedHmacSecret, setMaskedHmacSecret] = React.useState('');
const [hmacLoading, setHmacLoading] = React.useState(false);
const [revealing, setRevealing] = React.useState(false);
const [tierSaving, setTierSaving] = React.useState(false);
const [showAdvanced, setShowAdvanced] = React.useState(false);
const [showResetConfirm, setShowResetConfirm] = React.useState(false);
const [resetting, setResetting] = React.useState(false);
const [regenerating, setRegenerating] = React.useState(false);
const [showSecret, setShowSecret] = React.useState(false);
const [snippetCopied, setSnippetCopied] = React.useState(false);
// Node state
const [nodeEnabled, setNodeEnabled] = React.useState(false);
const [nodeLoading, setNodeLoading] = React.useState(true);
const [nodeToggling, setNodeToggling] = React.useState(false);
const [nodeId, setNodeId] = React.useState('');
const [nodeConfirmed, setNodeConfirmed] = React.useState(false);
const [remoteUrl, setRemoteUrl] = React.useState('');
// Tor state
const [torStarting, setTorStarting] = React.useState(false);
const [torError, setTorError] = React.useState('');
const [torOnion, setTorOnion] = React.useState('');
// Issue #302 (tg12): the full HMAC secret no longer travels through
// GET /api/ai/connect-info on every modal open. The flow is now:
//
// 1. GET /api/ai/connect-info — always returns the masked fingerprint
// (first6 + bullets + last4). `hmacSecret` stays empty until the
// operator clicks the Reveal (eye) button below.
// 2. POST /api/ai/connect-info/bootstrap — fires once on mount if the
// backend reports `hmac_secret_set: false`. Idempotent and never
// returns the secret in the response.
// 3. POST /api/ai/connect-info/reveal — fires when the operator clicks
// Reveal or Copy without the secret yet loaded. Returns the full
// secret with strict `Cache-Control: no-store` so it doesn't land
// in browser caches or HAR exports.
React.useEffect(() => {
(async () => {
try {
setHmacLoading(true);
const res = await fetch(`${API_BASE}/api/ai/connect-info`);
if (!res.ok) return;
const data = await res.json();
setMaskedHmacSecret(data.masked_hmac_secret || '');
setAccessTier(data.access_tier === 'full' ? 'full' : 'restricted');
// Transparent first-use bootstrap. Mirrors the pre-#302 UX of
// "open modal → secret exists" without the GET side-effect.
if (!data.hmac_secret_set) {
const bootRes = await fetch(
`${API_BASE}/api/ai/connect-info/bootstrap`,
{ method: 'POST' },
);
if (bootRes.ok) {
const bootData = await bootRes.json();
setMaskedHmacSecret(bootData.masked_hmac_secret || '');
}
}
} catch { /* ignore */ }
finally { setHmacLoading(false); }
})();
(async () => {
try {
setNodeLoading(true);
const res = await fetch(`${API_BASE}/api/settings/node`);
if (res.ok) {
const data = await res.json();
setNodeEnabled(!!data.node_enabled || !!data.enabled);
}
} catch { /* ignore */ }
finally { setNodeLoading(false); }
})();
(async () => {
try {
const res = await fetch(`${API_BASE}/api/ai/agent-identity`);
if (res.ok) {
const data = await res.json();
if (data.bootstrapped) setNodeId(data.node_id || '');
}
} catch { /* ignore */ }
})();
// Fetch Tor status
(async () => {
try {
const res = await fetch(`${API_BASE}/api/settings/tor`);
if (res.ok) {
const data = await res.json();
if (data.onion_address) {
setTorOnion(data.onion_address);
setRemoteUrl(data.onion_address);
}
}
} catch { /* ignore */ }
})();
}, []);
// One-click remote setup: start node + bootstrap identity + start Tor + get address
const handleRemoteSetup = async () => {
setTorStarting(true);
setTorError('');
try {
// 1. Enable mesh node
await fetch(`${API_BASE}/api/settings/node`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true }),
});
setNodeEnabled(true);
setNodeConfirmed(true);
// 2. Bootstrap agent identity (gets node_id)
const idRes = await fetch(`${API_BASE}/api/ai/agent-identity/bootstrap`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force: false }),
});
if (idRes.ok) {
const idData = await idRes.json();
if (idData.node_id) setNodeId(idData.node_id);
}
// 3. Start Tor hidden service
const torRes = await fetch(`${API_BASE}/api/settings/tor/start`, { method: 'POST' });
const torData = await torRes.json();
if (torData.ok && torData.onion_address) {
setTorOnion(torData.onion_address);
setRemoteUrl(torData.onion_address);
} else {
setTorError(torData.detail || 'Failed to start Tor');
}
} catch {
setTorError('Failed to connect to backend');
}
finally { setTorStarting(false); }
};
const handleResetAll = async () => {
setResetting(true);
setShowResetConfirm(false);
try {
const res = await fetch(`${API_BASE}/api/settings/agent/reset-all`, { method: 'POST' });
const data = await res.json();
if (data.ok) {
// Update local state with new credentials. reset-all returns
// the new HMAC secret in-band (same one-time-disclosure rule
// as /regenerate — a deliberate destructive action). Refresh
// both slots so the masked display stays in sync.
if (data.new_hmac_secret) {
setHmacSecret(data.new_hmac_secret);
const s = String(data.new_hmac_secret);
setMaskedHmacSecret(
s.length > 10 ? s.slice(0, 6) + '•'.repeat(8) + s.slice(-4) : '•'.repeat(16),
);
}
if (data.new_onion) {
setTorOnion(data.new_onion);
setRemoteUrl(data.new_onion);
}
if (data.new_node_id) setNodeId(data.new_node_id);
}
} catch { /* ignore */ }
finally { setResetting(false); }
};
const handleTierChange = async (tier: 'restricted' | 'full') => {
setAccessTier(tier);
setTierSaving(true);
try {
await fetch(`${API_BASE}/api/ai/connect-info/access-tier`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tier }),
});
} catch { /* ignore */ }
finally { setTierSaving(false); }
};
// Issue #302: POST /reveal returns the full secret with strict
// no-store headers. Lazily fetched — never on mount. Returns the
// secret string so callers can copy it immediately without waiting
// for React state propagation.
const revealHmacSecret = async (): Promise => {
if (hmacSecret) return hmacSecret;
setRevealing(true);
try {
const res = await fetch(`${API_BASE}/api/ai/connect-info/reveal`, {
method: 'POST',
});
if (!res.ok) return '';
const data = await res.json();
const secret = String(data.hmac_secret || '');
setHmacSecret(secret);
return secret;
} catch {
return '';
} finally {
setRevealing(false);
}
};
const handleRegenerate = async () => {
setRegenerating(true);
try {
const res = await fetch(`${API_BASE}/api/ai/connect-info/regenerate`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
// Regenerate is a deliberate destructive action — operator needs
// to see the new secret once to update their OpenClaw config.
// Both the full and masked forms refresh in one shot.
setHmacSecret(data.hmac_secret || '');
setMaskedHmacSecret(data.masked_hmac_secret || '');
setShowSecret(true);
}
} catch { /* ignore */ }
finally { setRegenerating(false); }
};
const handleNodeToggle = async (enable: boolean) => {
setNodeToggling(true);
try {
const res = await fetch(`${API_BASE}/api/settings/node`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: enable }),
});
if (res.ok) {
setNodeEnabled(enable);
// Auto-bootstrap agent identity when enabling node
if (enable && !nodeId) {
try {
const idRes = await fetch(`${API_BASE}/api/ai/agent-identity/bootstrap`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force: false }),
});
if (idRes.ok) {
const idData = await idRes.json();
setNodeId(idData.node_id || '');
}
} catch { /* ignore */ }
}
}
} catch { /* ignore */ }
finally { setNodeToggling(false); }
};
// Issue #302: prefer the server-supplied fingerprint
// (maskedHmacSecret) \u2014 it's filled on mount via the (no-secret) GET.
// If the operator has clicked Reveal, fall through to deriving the
// mask from the in-memory full secret so we keep the same shape
// (first6 + bullets + last4) regardless of source. Final fallback
// (no secret loaded yet) is a generic bullet string.
const maskedSecret =
maskedHmacSecret ||
(hmacSecret
? hmacSecret.slice(0, 6) + '\u2022'.repeat(8) + hmacSecret.slice(-4)
: '\u2022'.repeat(16));
// Resolve the endpoint URL
const resolvedUrl = connectionMode === 'local'
? 'http://localhost:8000'
: remoteUrl.trim() || '';
// Connection snippet — display is masked, copy has real key
const buildSnippet = (key: string) => {
const lines: string[] = [];
if (resolvedUrl) lines.push(`SHADOWBROKER_URL=${resolvedUrl}`);
lines.push(`SHADOWBROKER_HMAC_SECRET=${key}`);
lines.push(`SHADOWBROKER_ACCESS=${accessTier}`);
if (connectionMode === 'remote' && nodeId) lines.push(`SHADOWBROKER_NODE_ID=${nodeId}`);
lines.push('');
lines.push('# AUTH DIRECTIVE:');
lines.push('# SHADOWBROKER_HMAC_SECRET is a shared HMAC signing secret, not a raw API key.');
lines.push('# Never send it as X-Admin-Key, Authorization: Bearer, a query parameter, or a plain request header.');
lines.push('# Every direct ShadowBroker API request must be HMAC-SHA256 signed with:');
lines.push('# X-SB-Timestamp, X-SB-Nonce, X-SB-Signature');
lines.push('# Signature input: METHOD|path|timestamp|nonce|sha256(body)');
lines.push('# Use the ShadowBrokerClient/openclaw skill helper so requests are signed automatically.');
lines.push('');
lines.push('# OPERATING DIRECTIVE:');
lines.push('# You are a remote OpenClaw agent connected to ShadowBroker.');
lines.push('# ShadowBroker is a live intelligence and telemetry platform, not a narrow single-purpose API.');
lines.push('# Treat the platform as having broad live telemetry unless a focused check proves otherwise.');
lines.push("# Do not claim a layer, entity type, or capability is unavailable until you verify it from live data.");
lines.push("# If something is absent, say 'not found in current telemetry' or 'not present in the current dataset' rather than saying ShadowBroker cannot track it.");
lines.push('# Full access means you MAY place pins, create layers, inject data, set watches, and trigger displays, but do not perform write actions unless the user asks or the task clearly requires it.');
lines.push('# For ordinary questions, prefer read commands and concise answers grounded in live results.');
// Tool manifest — first thing the agent should hit
if (resolvedUrl) {
lines.push('');
lines.push('# FIRST: Load your tools from this endpoint:');
lines.push(`# GET ${resolvedUrl}/api/ai/tools`);
lines.push('# Returns structured tool definitions (names, params, types, examples).');
lines.push('# Load these as your available tool/function definitions on connect.');
lines.push('# Prefer compact lookups first: get_summary, search_telemetry, find_flights, find_ships, search_news, entities_near, get_layer_slice.');
lines.push('# Reserve get_telemetry, get_slow_telemetry, and get_report for rare full-context pulls.');
lines.push('# BATCH COMMANDS: POST /api/ai/channel/batch with {"commands": [{"cmd": "...", "args": {...}}, ...]} (max 20).');
lines.push('# Batch executes all commands concurrently in one HTTP round-trip. Use it whenever you need 2+ lookups.');
lines.push('# Example: batch entities_near + search_news + get_correlations in one call instead of 3 sequential calls.');
lines.push('# INCREMENTAL UPDATES: get_layer_slice supports since_version. Pass the version from the previous response to skip unchanged data (instant 0-byte response when nothing changed).');
lines.push("# get_summary is full layer discovery: use it to learn every live telemetry layer before concluding something is unavailable.");
lines.push('# get_layer_slice is uncapped by default. Pass limit_per_layer only when you intentionally want a smaller slice.');
lines.push("# UAP sightings, wastewater, and tracked_flights/VIP aircraft are real layers when populated. Verify with get_summary/get_layer_slice before claiming they don't exist.");
lines.push("# fishing_activity is the fishing-vessel activity layer. Aliases like gfw and global_fishing_watch should be treated as fishing_activity.");
lines.push("# Use search_telemetry as the Google-style entry point whenever the user gives you a person, place, company, owner, nickname, or natural-language phrase and you do not already know the source layer.");
lines.push("# Example: 'Where is Jerry Jones yacht?' -> search_telemetry('Jerry Jones') first, then refine with find_ships only after you identify the ship match.");
lines.push("# For fuzzy natural-language lookups like 'Patriots jet' or 'Jerry Jones yacht', inspect the ranked search_telemetry candidates before making a hard claim.");
lines.push("# search_telemetry returns ranked candidates grouped by entity type, so use the groups to narrow aircraft vs ships vs events before answering.");
lines.push("# For AF1/AF2 or other VIP aircraft, use find_flights first when the domain is obvious, then get_layer_slice(['tracked_flights']) if you need raw layer context.");
lines.push("# If one domain-specific command returns 0, do not conclude the entity is absent. Fall back to search_telemetry before any layer pull.");
lines.push("# If search_telemetry returns several plausible matches, summarize the top candidates instead of pretending one uncertain hit is definitive.");
lines.push("# If a user asks 'what is near here', use entities_near before pulling large datasets.");
lines.push("# If a user asks about a topic or incident, use search_news before downloading the full slow feed.");
}
// SAR (Synthetic Aperture Radar) ground-change layer
lines.push('');
lines.push('# SAR GROUND-CHANGE LAYER:');
lines.push('# ShadowBroker has a full SAR (Synthetic Aperture Radar) layer that detects ground changes through cloud cover, at night, anywhere on Earth.');
lines.push('# Two modes — both free:');
lines.push('# Mode A (Catalog): Free Sentinel-1 scene metadata from Alaska Satellite Facility. No account needed. Shows radar passes over AOIs and next-pass timing.');
lines.push('# Mode B (Anomalies): Pre-processed ground-change alerts from NASA OPERA (DISP deformation, DSWx water, DIST-ALERT vegetation), Copernicus EGMS, GFM floods, UNOSAT/EMS damage. Requires free Earthdata token.');
lines.push('# SAR commands (all routed through /api/ai/channel/command):');
lines.push('# sar_status — check Mode A/B status. ALWAYS call this first when the user asks about SAR/radar/deformation/floods. If Mode B is off, the response includes signup URLs to paste to the user.');
lines.push('# sar_anomalies_recent(kind?, limit?) — latest anomalies. Kinds: ground_deformation, surface_water_change, flood_extent, vegetation_disturbance, damage_assessment, coherence_change.');
lines.push('# sar_anomalies_near(lat, lon, radius_km?, kind?, limit?) — anomalies within radius of a point.');
lines.push('# sar_scene_search(aoi_id?, limit?) — Sentinel-1 scene catalog (Mode A, always works when AOIs exist).');
lines.push('# sar_coverage_for_aoi(aoi_id?) — per-AOI coverage and rough next-pass estimate.');
lines.push('# sar_aoi_list — list all operator-defined Areas of Interest.');
lines.push('# sar_aoi_add(id, name, center_lat, center_lon, radius_km?, category?, description?) — create/update an AOI (write command).');
lines.push('# sar_aoi_remove(aoi_id) — delete an AOI (write command).');
lines.push('# sar_pin_click(anomaly_id) — fetch the full detail payload for a specific anomaly (same data as the map popup). Returns {anomaly, aoi, recent_scenes}.');
lines.push('# sar_focus_aoi(aoi_id, zoom?) — fly the operator\'s map to an AOI center. The frontend picks this up in real time.');
lines.push('# sar_pin_from_anomaly(anomaly_id, label?) — promote a SAR anomaly to a persistent AI Intel pin on the map (write command).');
lines.push('# sar_watch_anomaly(aoi_id, kind?) — set up a watchdog that fires when matching anomalies appear in an AOI (write command).');
lines.push('# SAR rules: (1) Call sar_status first. (2) If Mode B is off, paste the help URLs — never tell the user to "search for it". (3) Anomalies have evidence_hash — preserve it when promoting to pins. (4) AOI categories: watchlist, conflict, infrastructure, natural_hazard, border, maritime.');
// Analysis zones — agent-placed map overlays with written assessments
lines.push('');
lines.push('# ANALYSIS ZONES (agent-authored map notes):');
lines.push('# The old regex-based "contradiction detector" has been REMOVED. It pattern-matched denial keywords against outages and produced constant false positives.');
lines.push('# Instead, you — the agent — place colored square overlays on the map with a written assessment that the operator can read by clicking the zone.');
lines.push('# Think of these as sticky notes: "I noticed X in this area, here is what I think it means." The operator can delete any zone by clicking the trash icon in the popup.');
lines.push('# Analysis zone commands (all routed through /api/ai/channel/command):');
lines.push('# list_analysis_zones — list all currently active zones (read).');
lines.push('# place_analysis_zone(lat, lng, title, body, category?, severity?, drivers?, cell_size_deg?, ttl_hours?) — drop a new zone (write).');
lines.push('# category: contradiction | analysis | warning | observation | hypothesis (default: analysis)');
lines.push('# severity: high | medium | low (default: medium — controls fill opacity, not an alarm level)');
lines.push('# drivers: up to 5 short bullet strings shown as "KEY INDICATORS" in the popup.');
lines.push('# body: your full written assessment (up to ~2000 chars), shown verbatim in the "AGENT ASSESSMENT" section — newlines preserved.');
lines.push('# cell_size_deg: square size in degrees (default 2.0 ≈ ~220km). Use smaller (0.3-0.8) for city-scale, larger (3-5) for regional.');
lines.push('# ttl_hours: optional auto-expiry. Omit for permanent until user deletes.');
lines.push('# delete_analysis_zone(zone_id) — remove a specific zone you placed (write).');
lines.push('# clear_analysis_zones — wipe all zones (write, use sparingly).');
lines.push('# Analysis zone rules:');
lines.push('# (1) Only place zones when you have something genuinely worth noting. Do NOT spam the map.');
lines.push('# (2) Write the body as a short intelligence note in YOUR voice — what you observed, what it might mean, what you are NOT sure about. 2-6 sentences is ideal.');
lines.push('# (3) Use category="contradiction" (amber) when official statements conflict with telemetry; "warning" (red) for active threats; "observation" (blue) for neutral notes; "hypothesis" (purple) for speculative reads; "analysis" (cyan, default) for general assessments.');
lines.push('# (4) Prefer placing zones in response to operator questions or emerging events you spot while reviewing telemetry, not on a fixed schedule.');
lines.push('# (5) Zones persist across restarts. If yours become stale, clean them up with delete_analysis_zone.');
// SSE endpoint (preferred for remote — works over Tor, keeps circuit warm)
if (resolvedUrl) {
lines.push('');
lines.push('# Real-time push (SSE stream — works over Tor, keeps circuit warm):');
lines.push(`# GET ${resolvedUrl}/api/ai/channel/sse (keep open, receives events)`);
lines.push(`# POST ${resolvedUrl}/api/ai/channel/command (send commands)`);
lines.push('# Command replies are returned immediately from POST /api/ai/channel/command.');
lines.push('# Use SSE for pushed alerts/tasks. Use /api/ai/channel/poll only as a fallback if SSE is unavailable.');
lines.push('# Suggested lookup flow:');
lines.push('# 1. get_summary (discover available layers + counts)');
lines.push('# 2. Batch your focused lookups: POST /api/ai/channel/batch with multiple commands in one call');
lines.push('# e.g. {"commands": [{"cmd":"find_flights","args":{"callsign":"AF1"}}, {"cmd":"search_news","args":{"query":"military"}}]}');
lines.push('# 3. For repeat polling, use get_layer_slice with since_version to skip unchanged data');
lines.push('# 4. Only pull full telemetry (get_telemetry/get_report) if focused commands were insufficient');
lines.push('# 5. Use write commands only when the user explicitly wants an action on the map/system');
}
if (connectionMode === 'remote' && resolvedUrl.includes('.onion')) {
lines.push('');
lines.push('# .onion requires Tor on the agent machine too:');
lines.push('# 1. Install Tor: sudo apt install tor (or brew install tor)');
lines.push('# 2. Tor starts a SOCKS5 proxy on localhost:9050');
lines.push('# 3. Route requests through it: pip install PySocks requests[socks]');
lines.push('# proxies = {"http": "socks5h://127.0.0.1:9050", "https": "socks5h://127.0.0.1:9050"}');
lines.push('# requests.get(SHADOWBROKER_URL + "/api/health", proxies=proxies)');
}
return lines.join('\n');
};
const displaySnippet = buildSnippet(maskedSecret);
// Issue #302: the copy snippet needs the FULL secret. Pre-#302 we kept
// it in memory from the GET-with-reveal load; now we lazy-fetch via
// POST /reveal only when the operator actually clicks Copy. If they
// already revealed, the in-memory value is reused (no extra request).
const handleCopySnippet = async () => {
const secret = hmacSecret || (await revealHmacSecret());
if (!secret) return;
navigator.clipboard.writeText(buildSnippet(secret));
setSnippetCopied(true);
setTimeout(() => setSnippetCopied(false), 2000);
};
// Remote mode requires node confirmed + a reachable URL
const remoteReady = connectionMode === 'local' || (nodeConfirmed && resolvedUrl.length > 0);
return (
Connecting an AI agent gives it access to your ShadowBroker data.
You control what it can do (read-only or full access). You're
responsible for what your agent does with it.
)}
{/* ── Main flow ────────────────────────────────────── */}
{riskAccepted && (
<>
{/* Step 1: Where is your agent? */}
Step 1 — Where is your agent?
{/* Step 2 (Remote): Generate private link — one button does everything */}
{connectionMode === 'remote' && (
Step 2 — Generate your private link
{torOnion && remoteReady ? (
/* Already have an address — show it */
Private link active
{torOnion}
This .onion address is persistent and stays the same across restarts.
) : (
/* Need to generate */
{!torStarting && (
)}
{torStarting && (
SETTING UP SECURE CONNECTION...
)}
{torError && (
{torError}
)}
)}
)}
{/* Step N: Access Level */}
Step {connectionMode === 'local' ? '2' : '3'} — What can it do?
{tierSaving && saving...}
{/* Step N+1: Connection Credentials */}
Step {connectionMode === 'local' ? '3' : '4'} — Copy this into your agent
{!remoteReady ? (
Start the mesh node above first
) : (
<>
{connectionMode === 'local'
? 'Paste these as environment variables or add them to your agent\u2019s config.'
: 'Give these to your agent. The key is masked below \u2014 COPY sends the real key to your clipboard.'}
{hmacLoading ? 'Loading...' : displaySnippet}
>
)}
{/* Done indicator */}
{remoteReady && (
{connectionMode === 'local'
? 'Done. Your agent authenticates via HMAC-signed requests to localhost. Use WebSocket or SSE for persistent real-time comms.'
: 'Done. Your agent registers with this node. Open GET /api/ai/channel/sse for real-time push over a single Tor circuit.'}
{/* Issue #302: when the operator hasn't clicked
Reveal yet, hmacSecret is empty and we fall
back to maskedHmacSecret (the safe fingerprint
returned by GET /api/ai/connect-info). */}
{showSecret && hmacSecret ? hmacSecret : (maskedHmacSecret || maskedSecret)}
Regenerating creates a new key and immediately invalidates the old one.