Bridge distribution: auth, heartbeat, installer script, per-user ports
This commit is contained in:
+225
-1
@@ -11,7 +11,7 @@ import os
|
||||
import secrets
|
||||
|
||||
from database import get_db, init_db
|
||||
from models import User, AgentCatalog, AgentInstance, Run, LLMProvider
|
||||
from models import User, AgentCatalog, AgentInstance, Run, LLMProvider, Bridge
|
||||
|
||||
app = FastAPI(title="Agent Command Center", version="2026.04.12.01")
|
||||
|
||||
@@ -479,6 +479,230 @@ def admin_delete_catalog(catalog_id: str, admin: dict = Depends(require_admin),
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
# --- Bridge Management ---
|
||||
|
||||
class BridgeRegister(BaseModel):
|
||||
user_id: int
|
||||
api_key: str
|
||||
bridge_url: str
|
||||
hostname: str = ""
|
||||
platform: str = "macos"
|
||||
capabilities: list = []
|
||||
|
||||
|
||||
class BridgeHeartbeat(BaseModel):
|
||||
bridge_url: str = ""
|
||||
capabilities: list = []
|
||||
|
||||
|
||||
@app.post("/api/bridge/register")
|
||||
def register_bridge(data: BridgeRegister, db: Session = Depends(get_db)):
|
||||
"""Called by bridge installer to register with the dashboard."""
|
||||
user = db.query(User).filter(User.id == data.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
# Remove existing bridge for this user (one bridge per user)
|
||||
db.query(Bridge).filter(Bridge.user_id == data.user_id).delete()
|
||||
bridge = Bridge(
|
||||
user_id=data.user_id,
|
||||
api_key=data.api_key,
|
||||
bridge_url=data.bridge_url,
|
||||
hostname=data.hostname,
|
||||
platform=data.platform,
|
||||
capabilities=data.capabilities,
|
||||
status="online",
|
||||
last_heartbeat=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(bridge)
|
||||
db.commit()
|
||||
return {"status": "registered", "bridge_id": bridge.id}
|
||||
|
||||
|
||||
@app.post("/api/bridge/heartbeat")
|
||||
def bridge_heartbeat(data: BridgeHeartbeat, request_api_key: str = None,
|
||||
db: Session = Depends(get_db)):
|
||||
"""Called periodically by the bridge to update its status and IP."""
|
||||
# Auth via X-Bridge-Token header
|
||||
from fastapi import Request
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/bridge/{api_key}/heartbeat")
|
||||
def bridge_heartbeat_keyed(api_key: str, data: BridgeHeartbeat, db: Session = Depends(get_db)):
|
||||
"""Heartbeat endpoint keyed by bridge API key."""
|
||||
bridge = db.query(Bridge).filter(Bridge.api_key == api_key).first()
|
||||
if not bridge:
|
||||
raise HTTPException(status_code=404, detail="Bridge not found")
|
||||
if data.bridge_url:
|
||||
bridge.bridge_url = data.bridge_url
|
||||
if data.capabilities:
|
||||
bridge.capabilities = data.capabilities
|
||||
bridge.status = "online"
|
||||
bridge.last_heartbeat = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/bridge/me")
|
||||
def get_my_bridge(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
|
||||
"""Get the current user's bridge info."""
|
||||
bridge = db.query(Bridge).filter(Bridge.user_id == user["user_id"]).first()
|
||||
if not bridge:
|
||||
return {"connected": False}
|
||||
# Mark offline if no heartbeat in 10 minutes
|
||||
if bridge.last_heartbeat:
|
||||
age = (datetime.now(timezone.utc) - bridge.last_heartbeat).total_seconds()
|
||||
if age > 600:
|
||||
bridge.status = "offline"
|
||||
db.commit()
|
||||
return {
|
||||
"connected": True,
|
||||
"bridge_url": bridge.bridge_url,
|
||||
"hostname": bridge.hostname,
|
||||
"platform": bridge.platform,
|
||||
"capabilities": bridge.capabilities or [],
|
||||
"status": bridge.status,
|
||||
"last_heartbeat": bridge.last_heartbeat.isoformat() if bridge.last_heartbeat else None,
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/api/bridge/me")
|
||||
def disconnect_bridge(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
|
||||
"""Disconnect the current user's bridge."""
|
||||
db.query(Bridge).filter(Bridge.user_id == user["user_id"]).delete()
|
||||
db.commit()
|
||||
return {"status": "disconnected"}
|
||||
|
||||
|
||||
@app.get("/api/users/{user_id}/bridge")
|
||||
def get_user_bridge(user_id: int, db: Session = Depends(get_db)):
|
||||
"""Internal endpoint for agents to get a user's bridge URL + auth."""
|
||||
bridge = db.query(Bridge).filter(Bridge.user_id == user_id).first()
|
||||
if not bridge or bridge.status == "offline":
|
||||
return {"available": False}
|
||||
return {
|
||||
"available": True,
|
||||
"bridge_url": bridge.bridge_url,
|
||||
"api_key": bridge.api_key,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/bridge/install-script")
|
||||
def get_install_script(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
|
||||
"""Generate a personalized bridge installer script for the current user."""
|
||||
user_id = user["user_id"]
|
||||
username = user["username"]
|
||||
api_key = secrets.token_urlsafe(32)
|
||||
dashboard_url = "https://agents.jfamily.io"
|
||||
|
||||
script = f'''#!/bin/bash
|
||||
# Agent Command Center — Mac Bridge Installer
|
||||
# Generated for: {username} (user {user_id})
|
||||
# Run this on your Mac to install the Apple Ecosystem Bridge.
|
||||
|
||||
set -e
|
||||
echo "Installing Agent Command Center Mac Bridge for {username}..."
|
||||
|
||||
BRIDGE_DIR="$HOME/.local/apple-bridge"
|
||||
mkdir -p "$BRIDGE_DIR"
|
||||
|
||||
# Download bridge.py
|
||||
cat > "$BRIDGE_DIR/bridge.py" << 'BRIDGE_EOF'
|
||||
BRIDGE_PLACEHOLDER
|
||||
BRIDGE_EOF
|
||||
|
||||
# Create config
|
||||
cat > "$BRIDGE_DIR/config.json" << 'CONFIG_EOF'
|
||||
{{
|
||||
"user_id": {user_id},
|
||||
"username": "{username}",
|
||||
"api_key": "{api_key}",
|
||||
"dashboard_url": "{dashboard_url}",
|
||||
"port": {8551 + user_id}
|
||||
}}
|
||||
CONFIG_EOF
|
||||
|
||||
# Create launcher
|
||||
cat > "$BRIDGE_DIR/start-bridge.sh" << 'LAUNCHER_EOF'
|
||||
#!/bin/bash
|
||||
exec /opt/homebrew/bin/python3 "$HOME/.local/apple-bridge/bridge.py"
|
||||
LAUNCHER_EOF
|
||||
chmod +x "$BRIDGE_DIR/start-bridge.sh"
|
||||
|
||||
# Create launchd plist
|
||||
PLIST="$HOME/Library/LaunchAgents/com.jfamily.apple-bridge.plist"
|
||||
cat > "$PLIST" << PLIST_EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.jfamily.apple-bridge</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$BRIDGE_DIR/start-bridge.sh</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/apple-bridge.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/apple-bridge.err</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST_EOF
|
||||
|
||||
# Install Python dependencies
|
||||
echo "Installing Python dependencies..."
|
||||
/opt/homebrew/bin/pip3 install --break-system-packages -q fastapi uvicorn 2>/dev/null || pip3 install --break-system-packages -q fastapi uvicorn
|
||||
|
||||
# Start the bridge
|
||||
echo "Starting bridge..."
|
||||
launchctl unload "$PLIST" 2>/dev/null
|
||||
launchctl load "$PLIST"
|
||||
sleep 3
|
||||
|
||||
# Register with dashboard
|
||||
BRIDGE_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "unknown")
|
||||
BRIDGE_PORT={8551 + user_id}
|
||||
HOSTNAME=$(hostname)
|
||||
|
||||
echo "Registering with dashboard..."
|
||||
curl -s -X POST "{dashboard_url}/api/bridge/register" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{{"user_id": {user_id}, "api_key": "{api_key}", "bridge_url": "http://'$BRIDGE_IP':'$BRIDGE_PORT'", "hostname": "'$HOSTNAME'", "platform": "macos", "capabilities": ["notes", "reading-list"]}}'
|
||||
|
||||
echo ""
|
||||
echo "===================================="
|
||||
echo " Mac Bridge installed for {username}!"
|
||||
echo " Bridge URL: http://$BRIDGE_IP:$BRIDGE_PORT"
|
||||
echo " Dashboard: {dashboard_url}"
|
||||
echo "===================================="
|
||||
'''
|
||||
from fastapi.responses import PlainTextResponse
|
||||
return PlainTextResponse(script, media_type="text/x-shellscript",
|
||||
headers={"Content-Disposition": f"attachment; filename=install-bridge-{username}.sh"})
|
||||
|
||||
|
||||
# Admin: Bridge overview
|
||||
@app.get("/api/admin/bridges")
|
||||
def admin_list_bridges(admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
|
||||
bridges = db.query(Bridge).all()
|
||||
return [{
|
||||
"id": b.id,
|
||||
"user_id": b.user_id,
|
||||
"username": db.query(User).filter(User.id == b.user_id).first().username if db.query(User).filter(User.id == b.user_id).first() else "?",
|
||||
"bridge_url": b.bridge_url,
|
||||
"hostname": b.hostname,
|
||||
"platform": b.platform,
|
||||
"capabilities": b.capabilities or [],
|
||||
"status": b.status,
|
||||
"last_heartbeat": b.last_heartbeat.isoformat() if b.last_heartbeat else None,
|
||||
} for b in bridges]
|
||||
|
||||
|
||||
# --- Static files ---
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
Reference in New Issue
Block a user