feat: browser extension setup guide
This commit is contained in:
@@ -1275,10 +1275,51 @@ def cmd_setup_credentials(args: argparse.Namespace) -> int:
|
||||
return 0 if result.success else 1
|
||||
|
||||
|
||||
def _find_chrome_bin() -> str | None:
|
||||
"""Return the path to a Chrome/Chromium binary, or None if not found."""
|
||||
import shutil
|
||||
|
||||
for candidate in (
|
||||
"google-chrome",
|
||||
"google-chrome-stable",
|
||||
"chromium",
|
||||
"chromium-browser",
|
||||
"microsoft-edge",
|
||||
"microsoft-edge-stable",
|
||||
):
|
||||
if shutil.which(candidate):
|
||||
return candidate
|
||||
|
||||
mac_paths = [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
Path.home() / "Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
]
|
||||
for p in mac_paths:
|
||||
if Path(p).exists():
|
||||
return str(p)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _open_browser(url: str) -> None:
|
||||
"""Open URL in the default browser (best-effort, non-blocking)."""
|
||||
"""Open URL in the browser (best-effort, non-blocking)."""
|
||||
import subprocess
|
||||
|
||||
chrome = _find_chrome_bin()
|
||||
|
||||
try:
|
||||
if chrome:
|
||||
subprocess.Popen(
|
||||
[chrome, url],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: open with system default browser
|
||||
try:
|
||||
if sys.platform == "darwin":
|
||||
subprocess.Popen(
|
||||
|
||||
@@ -165,6 +165,37 @@ async def handle_health(request: web.Request) -> web.Response:
|
||||
)
|
||||
|
||||
|
||||
async def handle_browser_status(request: web.Request) -> web.Response:
|
||||
"""GET /api/browser/status — proxy the GCU bridge status check server-side.
|
||||
|
||||
Checks http://127.0.0.1:9230/status so the browser never makes a
|
||||
cross-origin request that would log ERR_CONNECTION_REFUSED in the console.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
bridge_port = int(os.environ.get("HIVE_BRIDGE_PORT", "9229"))
|
||||
status_port = bridge_port + 1
|
||||
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection("127.0.0.1", status_port), timeout=0.5
|
||||
)
|
||||
writer.write(b"GET /status HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n")
|
||||
await writer.drain()
|
||||
raw = await asyncio.wait_for(reader.read(512), timeout=0.5)
|
||||
writer.close()
|
||||
# Parse JSON body after the blank line
|
||||
if b"\r\n\r\n" in raw:
|
||||
body = raw.split(b"\r\n\r\n", 1)[1]
|
||||
import json
|
||||
data = json.loads(body)
|
||||
return web.json_response({"bridge": True, "connected": data.get("connected", False)})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return web.json_response({"bridge": False, "connected": False})
|
||||
|
||||
|
||||
def create_app(model: str | None = None) -> web.Application:
|
||||
"""Create and configure the aiohttp Application.
|
||||
|
||||
@@ -210,6 +241,7 @@ def create_app(model: str | None = None) -> web.Application:
|
||||
|
||||
# Health check
|
||||
app.router.add_get("/api/health", handle_health)
|
||||
app.router.add_get("/api/browser/status", handle_browser_status)
|
||||
|
||||
# Register route modules
|
||||
from framework.server.routes_credentials import register_routes as register_credential_routes
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
type BridgeStatus = "checking" | "connected" | "disconnected" | "offline";
|
||||
|
||||
const BRIDGE_STATUS_URL = "/api/browser/status";
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
|
||||
export default function BrowserStatusBadge() {
|
||||
const [status, setStatus] = useState<BridgeStatus>("checking");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const check = async () => {
|
||||
try {
|
||||
const res = await fetch(BRIDGE_STATUS_URL, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setStatus(data.connected ? "connected" : "disconnected");
|
||||
} else {
|
||||
setStatus("offline");
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setStatus("offline");
|
||||
}
|
||||
};
|
||||
|
||||
check();
|
||||
const timer = setInterval(check, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (status === "checking") return null;
|
||||
|
||||
const label =
|
||||
status === "connected"
|
||||
? "Browser connected"
|
||||
: status === "disconnected"
|
||||
? "Extension not connected"
|
||||
: "Browser offline";
|
||||
|
||||
const dotClass =
|
||||
status === "connected"
|
||||
? "bg-green-500"
|
||||
: status === "disconnected"
|
||||
? "bg-yellow-500"
|
||||
: "bg-muted-foreground/40";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1.5 text-xs select-none"
|
||||
title={label}
|
||||
>
|
||||
<span className="relative flex h-2 w-2 flex-shrink-0">
|
||||
{status === "connected" && (
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-60" />
|
||||
)}
|
||||
<span className={`relative inline-flex rounded-full h-2 w-2 ${dotClass}`} />
|
||||
</span>
|
||||
<span className="text-muted-foreground hidden sm:inline">Browser</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { Crown, X } from "lucide-react";
|
||||
import { sessionsApi } from "@/api/sessions";
|
||||
import { loadPersistedTabs, savePersistedTabs, TAB_STORAGE_KEY, type PersistedTabState } from "@/lib/tab-persistence";
|
||||
import BrowserStatusBadge from "@/components/BrowserStatusBadge";
|
||||
|
||||
export interface TopBarTab {
|
||||
agentType: string;
|
||||
@@ -129,11 +130,14 @@ export default function TopBar({ tabs: tabsProp, onTabClick, onCloseTab, canClos
|
||||
)}
|
||||
</div>
|
||||
|
||||
{children && (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<BrowserStatusBadge />
|
||||
{children && (
|
||||
<div className="flex items-center gap-1">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1847,6 +1847,76 @@ fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# Step 4b: Load browser extension into Chrome (one-time setup)
|
||||
# ============================================================
|
||||
|
||||
echo -e "${YELLOW}⬢${NC} ${BLUE}${BOLD}Setting up browser extension...${NC}"
|
||||
echo ""
|
||||
|
||||
EXTENSION_PATH="$SCRIPT_DIR/tools/browser-extension"
|
||||
CHROME_BIN=""
|
||||
CHROME_LAUNCHED=false
|
||||
|
||||
# Find Chrome binary
|
||||
for _bin in "google-chrome" "google-chrome-stable" "chromium" "chromium-browser" "microsoft-edge" "microsoft-edge-stable"; do
|
||||
if command -v "$_bin" &> /dev/null; then
|
||||
CHROME_BIN="$_bin"
|
||||
break
|
||||
fi
|
||||
done
|
||||
# macOS
|
||||
if [ -z "$CHROME_BIN" ]; then
|
||||
for _path in \
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
|
||||
"$HOME/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"; do
|
||||
if [ -e "$_path" ]; then
|
||||
CHROME_BIN="$_path"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ ! -d "$EXTENSION_PATH" ]; then
|
||||
echo -e "${YELLOW} Extension not found at $EXTENSION_PATH — skipping${NC}"
|
||||
elif [ -z "$CHROME_BIN" ]; then
|
||||
echo -e "${YELLOW} Chrome not found — skipping${NC}"
|
||||
echo -e "${DIM} Install Chrome, then load: $EXTENSION_PATH via chrome://extensions${NC}"
|
||||
else
|
||||
# Copy path to clipboard (best-effort)
|
||||
if command -v xclip &> /dev/null; then
|
||||
printf '%s' "$EXTENSION_PATH" | xclip -selection clipboard 2>/dev/null && _copied=true
|
||||
elif command -v xsel &> /dev/null; then
|
||||
printf '%s' "$EXTENSION_PATH" | xsel --clipboard --input 2>/dev/null && _copied=true
|
||||
elif command -v pbcopy &> /dev/null; then
|
||||
printf '%s' "$EXTENSION_PATH" | pbcopy 2>/dev/null && _copied=true
|
||||
fi
|
||||
|
||||
# Open chrome://extensions directly in Chrome — works whether Chrome is running or not
|
||||
echo " Opening chrome://extensions in Chrome..."
|
||||
"$CHROME_BIN" "chrome://extensions" > /dev/null 2>&1 &
|
||||
sleep 1
|
||||
|
||||
echo ""
|
||||
echo -e " ${BOLD}In the Chrome window that just opened:${NC}"
|
||||
echo -e " ${CYAN}1.${NC} Enable ${BOLD}Developer mode${NC} (toggle in the top-right corner)"
|
||||
echo -e " ${CYAN}2.${NC} Click ${BOLD}Load unpacked${NC}"
|
||||
echo -e " ${CYAN}3.${NC} Paste this path into the folder picker:"
|
||||
echo ""
|
||||
echo -e " ${BOLD}$EXTENSION_PATH${NC}"
|
||||
echo ""
|
||||
if [ "${_copied:-false}" = "true" ]; then
|
||||
echo -e " ${DIM}(path already copied to clipboard — just Ctrl+V in the folder picker)${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
read -r -p " Press Enter once you see 'Hive Browser Bridge' in the extensions list... " _dummy || true
|
||||
CHROME_LAUNCHED=true
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# Step 5: Verify Setup
|
||||
# ============================================================
|
||||
@@ -1903,6 +1973,17 @@ else
|
||||
echo -e "${YELLOW}--${NC}"
|
||||
fi
|
||||
|
||||
echo -n " ⬡ browser extension... "
|
||||
if [ "$CHROME_LAUNCHED" = true ]; then
|
||||
echo -e "${GREEN}ok${NC}"
|
||||
elif [ -d "$EXTENSION_PATH" ] && [ -n "$CHROME_BIN" ]; then
|
||||
echo -e "${GREEN}ok${NC}"
|
||||
elif [ -d "$EXTENSION_PATH" ]; then
|
||||
echo -e "${YELLOW}-- (Chrome not found)${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}--${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
if [ $ERRORS -gt 0 ]; then
|
||||
|
||||
@@ -56,12 +56,16 @@ def _get_active_profile() -> str:
|
||||
return "default"
|
||||
|
||||
|
||||
STATUS_PORT = BRIDGE_PORT + 1 # 9230 — plain HTTP status endpoint
|
||||
|
||||
|
||||
class BeelineBridge:
|
||||
"""WebSocket server that accepts a single connection from the Chrome extension."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._ws: object | None = None # websockets.ServerConnection
|
||||
self._server: object | None = None # websockets.Server
|
||||
self._status_server: object | None = None # asyncio.Server (HTTP)
|
||||
self._pending: dict[str, asyncio.Future] = {}
|
||||
self._counter = 0
|
||||
self._cdp_attached: set[int] = set() # Track tabs with CDP attached
|
||||
@@ -71,7 +75,7 @@ class BeelineBridge:
|
||||
return self._ws is not None
|
||||
|
||||
async def start(self, port: int = BRIDGE_PORT) -> None:
|
||||
"""Start the WebSocket server."""
|
||||
"""Start the WebSocket server and the HTTP status server."""
|
||||
try:
|
||||
import websockets
|
||||
except ImportError:
|
||||
@@ -99,6 +103,20 @@ class BeelineBridge:
|
||||
except OSError as e:
|
||||
logger.warning("Beeline bridge could not start on port %d: %s", port, e)
|
||||
|
||||
# Start a tiny HTTP server on port+1 for status polling.
|
||||
# websockets 16 rejects plain HTTP before process_request is called, so
|
||||
# we need a separate server.
|
||||
status_port = port + 1
|
||||
try:
|
||||
self._status_server = await asyncio.start_server(
|
||||
self._http_status_handler,
|
||||
"127.0.0.1",
|
||||
status_port,
|
||||
)
|
||||
logger.info("Bridge status endpoint on http://127.0.0.1:%d/status", status_port)
|
||||
except OSError as e:
|
||||
logger.warning("Bridge status server could not start on port %d: %s", status_port, e)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._server:
|
||||
self._server.close()
|
||||
@@ -107,6 +125,47 @@ class BeelineBridge:
|
||||
except Exception:
|
||||
pass
|
||||
self._server = None
|
||||
if self._status_server:
|
||||
self._status_server.close()
|
||||
try:
|
||||
await self._status_server.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
self._status_server = None
|
||||
|
||||
async def _http_status_handler(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
"""Minimal asyncio TCP handler serving HTTP GET /status on the status port."""
|
||||
try:
|
||||
raw = await asyncio.wait_for(reader.read(512), timeout=2.0)
|
||||
first_line = raw.split(b"\r\n", 1)[0].decode(errors="replace")
|
||||
if first_line.startswith("GET /status"):
|
||||
body = json.dumps({"connected": self.is_connected, "bridge": "running"}).encode()
|
||||
response = (
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Content-Type: application/json\r\n"
|
||||
b"Access-Control-Allow-Origin: *\r\n"
|
||||
b"Access-Control-Allow-Headers: *\r\n"
|
||||
+ b"Content-Length: " + str(len(body)).encode() + b"\r\n"
|
||||
+ b"Connection: close\r\n"
|
||||
b"\r\n" + body
|
||||
)
|
||||
elif first_line.startswith("OPTIONS "):
|
||||
response = (
|
||||
b"HTTP/1.1 204 No Content\r\n"
|
||||
b"Access-Control-Allow-Origin: *\r\n"
|
||||
b"Access-Control-Allow-Headers: *\r\n"
|
||||
b"Content-Length: 0\r\n"
|
||||
b"Connection: close\r\n"
|
||||
b"\r\n"
|
||||
)
|
||||
else:
|
||||
response = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
writer.write(response)
|
||||
await writer.drain()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
async def _handle_connection(self, ws) -> None:
|
||||
logger.info("Chrome extension connected")
|
||||
@@ -141,14 +200,16 @@ class BeelineBridge:
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
logger.info("Chrome extension disconnected")
|
||||
log_connection_event("disconnect")
|
||||
self._ws = None
|
||||
# Cancel any pending requests
|
||||
for fut in self._pending.values():
|
||||
if not fut.done():
|
||||
fut.cancel()
|
||||
self._pending.clear()
|
||||
# Only clear self._ws if this handler still owns it.
|
||||
if self._ws is ws:
|
||||
logger.info("Chrome extension disconnected")
|
||||
log_connection_event("disconnect")
|
||||
self._ws = None
|
||||
# Cancel any pending requests
|
||||
for fut in self._pending.values():
|
||||
if not fut.done():
|
||||
fut.cancel()
|
||||
self._pending.clear()
|
||||
|
||||
async def _send(self, type_: str, **params) -> dict:
|
||||
"""Send a command to the extension and wait for the result."""
|
||||
|
||||
@@ -8,7 +8,9 @@ No Playwright required - all operations go through the Chrome extension.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
@@ -30,9 +32,60 @@ def _resolve_profile(profile: str | None) -> str:
|
||||
return _active_profile.get()
|
||||
|
||||
|
||||
# Resolve extension path relative to this file: tools/browser-extension/
|
||||
_EXTENSION_PATH = (
|
||||
Path(__file__).parent.parent.parent.parent.parent / "browser-extension"
|
||||
).resolve()
|
||||
|
||||
|
||||
def register_lifecycle_tools(mcp: FastMCP) -> None:
|
||||
"""Register browser lifecycle management tools."""
|
||||
|
||||
@mcp.tool()
|
||||
async def browser_setup() -> dict:
|
||||
"""
|
||||
Check browser extension status and show installation instructions if needed.
|
||||
|
||||
Call this first if browser tools are not working. It checks whether the
|
||||
Hive Chrome extension is installed and connected, and provides step-by-step
|
||||
instructions to install it if not.
|
||||
|
||||
Returns:
|
||||
Dict with connection status and setup instructions if needed
|
||||
"""
|
||||
bridge = get_bridge()
|
||||
connected = bool(bridge and bridge.is_connected)
|
||||
|
||||
ext_path = str(_EXTENSION_PATH)
|
||||
ext_exists = _EXTENSION_PATH.exists()
|
||||
|
||||
if connected:
|
||||
return {
|
||||
"ok": True,
|
||||
"connected": True,
|
||||
"status": "Extension is connected and ready. Call browser_start to begin.",
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": False,
|
||||
"connected": False,
|
||||
"status": "Extension not connected",
|
||||
"instructions": {
|
||||
"step_1": "Open Chrome and go to chrome://extensions",
|
||||
"step_2": "Enable 'Developer mode' (toggle in the top-right corner)",
|
||||
"step_3": "Click 'Load unpacked'",
|
||||
"step_4": f"Select this directory: {ext_path}",
|
||||
"step_5": "Click the extension icon in the Chrome toolbar to confirm it says 'Connected'",
|
||||
"step_6": "Return here and call browser_start",
|
||||
},
|
||||
"extensionPath": ext_path,
|
||||
"extensionPathExists": ext_exists,
|
||||
"note": (
|
||||
"The extension connects via WebSocket on ws://127.0.0.1:9229/beeline. "
|
||||
"Make sure Chrome is running before loading the extension."
|
||||
),
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def browser_status(profile: str | None = None) -> dict:
|
||||
"""
|
||||
@@ -51,7 +104,7 @@ def register_lifecycle_tools(mcp: FastMCP) -> None:
|
||||
if not bridge or not bridge.is_connected:
|
||||
result = {
|
||||
"ok": False,
|
||||
"error": "Browser extension not connected",
|
||||
"error": "Browser extension not connected. Call browser_setup for installation instructions.",
|
||||
"connected": False,
|
||||
}
|
||||
log_tool_call("browser_status", params, result=result)
|
||||
@@ -133,7 +186,7 @@ def register_lifecycle_tools(mcp: FastMCP) -> None:
|
||||
result = {
|
||||
"ok": False,
|
||||
"error": (
|
||||
"Browser extension not connected. Install the Beeline extension and connect it."
|
||||
"Browser extension not connected. Call browser_setup for installation instructions."
|
||||
),
|
||||
}
|
||||
log_tool_call("browser_start", params, result=result)
|
||||
|
||||
Reference in New Issue
Block a user