feat: browser extension setup guide

This commit is contained in:
Timothy
2026-04-03 17:18:53 -07:00
parent 102866780c
commit 1e848d67bb
7 changed files with 358 additions and 17 deletions
+42 -1
View File
@@ -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(
+32
View File
@@ -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>
);
}
+9 -5
View File
@@ -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>
);
}
+81
View File
@@ -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
+70 -9
View File
@@ -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."""
+55 -2
View File
@@ -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)