from fastapi import FastAPI, Depends, HTTPException, Response, Cookie
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, RedirectResponse
from sqlalchemy.orm import Session
from pydantic import BaseModel
from datetime import datetime, timezone, timedelta
from typing import Optional
import hashlib
import json
import os
import secrets
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from database import get_db, init_db
from models import User, AgentCatalog, AgentInstance, Run, LLMProvider, Bridge
app = FastAPI(title="Agent Command Center", version="2026.04.12.01")
# --- Auth ---
SESSION_SECRET = os.environ.get("SESSION_SECRET", secrets.token_hex(32))
_sessions: dict[str, dict] = {} # token -> {user_id, username, role}
_magic_links: dict[str, dict] = {} # token -> {user_id, email, expires}
# SMTP Config
SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.gmail.com")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
SMTP_USER = os.environ.get("SMTP_USER", "eric.jungbauer@gmail.com")
SMTP_PASS = os.environ.get("SMTP_PASS", "jozj oags ifqy auey")
SMTP_FROM = os.environ.get("SMTP_FROM", "eric.jungbauer@gmail.com")
APP_URL = os.environ.get("APP_URL", "https://agents.jfamily.io")
def hash_password(password: str) -> str:
salt = secrets.token_hex(16)
h = hashlib.sha256((salt + password).encode()).hexdigest()
return f"{salt}:{h}"
def verify_password(password: str, password_hash: str) -> bool:
salt, h = password_hash.split(":", 1)
return hashlib.sha256((salt + password).encode()).hexdigest() == h
def create_session(user: User) -> str:
token = secrets.token_urlsafe(32)
_sessions[token] = {"user_id": user.id, "username": user.username, "role": user.role}
return token
def get_current_user(session: Optional[str] = Cookie(None)) -> Optional[dict]:
if session and session in _sessions:
return _sessions[session]
return None
def require_auth(session: Optional[str] = Cookie(None)) -> dict:
user = get_current_user(session)
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
def require_admin(session: Optional[str] = Cookie(None)) -> dict:
user = require_auth(session)
if user["role"] != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
return user
# --- Schemas ---
class LoginRequest(BaseModel):
username: str # accepts username or email
password: str
class MagicLinkRequest(BaseModel):
email: str
class InstanceCreate(BaseModel):
catalog_id: str
name: Optional[str] = None
config: dict = {}
schedule: Optional[str] = None
class InstanceUpdate(BaseModel):
name: Optional[str] = None
config: Optional[dict] = None
schedule: Optional[str] = None
status: Optional[str] = None
class RunCreate(BaseModel):
status: str = "running"
output: str = ""
error: str = ""
metadata: dict = {}
class UserCreate(BaseModel):
username: str
email: str = ""
password: str
display_name: str = ""
role: str = "user"
class UserUpdate(BaseModel):
display_name: Optional[str] = None
email: Optional[str] = None
role: Optional[str] = None
password: Optional[str] = None
class LLMProviderCreate(BaseModel):
name: str
provider_type: str = "anthropic"
api_url: str = ""
api_key: str = ""
default_model: str = ""
is_default: bool = False
class LLMProviderUpdate(BaseModel):
name: Optional[str] = None
provider_type: Optional[str] = None
api_url: Optional[str] = None
api_key: Optional[str] = None
default_model: Optional[str] = None
is_default: Optional[bool] = None
# --- Auth Routes ---
@app.post("/api/login")
def login(creds: LoginRequest, response: Response, db: Session = Depends(get_db)):
user = db.query(User).filter(
(User.username == creds.username) | (User.email == creds.username)
).first()
if not user or not verify_password(creds.password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_session(user)
response.set_cookie("session", token, httponly=True, samesite="lax", max_age=86400 * 7)
return {"status": "ok", "user": user.username, "email": user.email or "", "role": user.role, "display_name": user.display_name}
@app.post("/api/logout")
def logout(response: Response, session: Optional[str] = Cookie(None)):
if session and session in _sessions:
del _sessions[session]
response.delete_cookie("session")
return {"status": "ok"}
def send_magic_email(email: str, token: str):
"""Send a magic link email via SMTP."""
link = f"{APP_URL}/auth/verify?token={token}"
html = f"""
Agent Command Center
Click the button below to sign in:
Sign In
This link expires in 15 minutes. If you didn't request this, ignore this email.
{APP_URL}
"""
msg = MIMEMultipart("alternative")
msg["Subject"] = "Sign in to Agent Command Center"
msg["From"] = SMTP_FROM
msg["To"] = email
msg.attach(MIMEText(f"Sign in: {link}\n\nExpires in 15 minutes.", "plain"))
msg.attach(MIMEText(html, "html"))
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
server.starttls()
server.login(SMTP_USER, SMTP_PASS)
server.send_message(msg)
@app.post("/api/auth/magic-link")
def request_magic_link(data: MagicLinkRequest, db: Session = Depends(get_db)):
"""Send a magic link to the user's email."""
user = db.query(User).filter(User.email == data.email).first()
if not user:
# Don't reveal whether email exists — return success either way
return {"status": "ok", "message": "If that email is registered, a sign-in link has been sent."}
# Generate token
token = secrets.token_urlsafe(32)
_magic_links[token] = {
"user_id": user.id,
"email": user.email,
"expires": datetime.now(timezone.utc) + timedelta(minutes=15),
}
# Send email
try:
send_magic_email(user.email, token)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to send email: {e}")
return {"status": "ok", "message": "If that email is registered, a sign-in link has been sent."}
@app.get("/auth/verify")
def verify_magic_link(token: str, response: Response, db: Session = Depends(get_db)):
"""Verify a magic link token and log the user in."""
link_data = _magic_links.pop(token, None)
if not link_data:
return RedirectResponse("/login?error=invalid", status_code=302)
if datetime.now(timezone.utc) > link_data["expires"]:
return RedirectResponse("/login?error=expired", status_code=302)
user = db.query(User).filter(User.id == link_data["user_id"]).first()
if not user:
return RedirectResponse("/login?error=invalid", status_code=302)
# Create session
session_token = create_session(user)
resp = RedirectResponse("/", status_code=302)
resp.set_cookie("session", session_token, httponly=True, samesite="lax", max_age=86400 * 7)
return resp
@app.get("/api/me")
def me(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
u = db.query(User).filter(User.id == user["user_id"]).first()
llm = u.llm_config or {}
return {
"id": u.id, "username": u.username, "email": u.email or "",
"display_name": u.display_name, "role": u.role,
"has_llm": bool(llm.get("api_key")),
"llm_provider": llm.get("provider_type", ""),
"llm_model": llm.get("default_model", ""),
"created_at": u.created_at.isoformat() if u.created_at else None,
}
class UserLLMConfig(BaseModel):
provider_type: str = "" # anthropic, openai, litellm, ollama
api_url: str = ""
api_key: str = ""
default_model: str = ""
@app.get("/api/me/llm")
def get_my_llm(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
"""Get current user's LLM config."""
u = db.query(User).filter(User.id == user["user_id"]).first()
llm = u.llm_config or {}
return {
"provider_type": llm.get("provider_type", ""),
"api_url": llm.get("api_url", ""),
"api_key": "***" if llm.get("api_key") else "",
"default_model": llm.get("default_model", ""),
"configured": bool(llm.get("api_key")),
}
@app.put("/api/me/llm")
def update_my_llm(data: UserLLMConfig, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
"""Update current user's LLM config (bring your own LLM)."""
u = db.query(User).filter(User.id == user["user_id"]).first()
current = u.llm_config or {}
update = data.model_dump()
# Only update api_key if a real value was provided (not "***")
if update.get("api_key") == "***" or not update.get("api_key"):
update["api_key"] = current.get("api_key", "")
u.llm_config = update
db.commit()
return {"status": "updated"}
@app.delete("/api/me/llm")
def delete_my_llm(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
"""Remove user's LLM config (fall back to system default)."""
u = db.query(User).filter(User.id == user["user_id"]).first()
u.llm_config = {}
db.commit()
return {"status": "removed"}
@app.get("/api/users/{user_id}/llm")
def get_user_llm(user_id: int, db: Session = Depends(get_db)):
"""Internal: resolve LLM config for a user. Returns user's own config if set, otherwise system default."""
u = db.query(User).filter(User.id == user_id).first()
if not u:
raise HTTPException(status_code=404)
user_llm = u.llm_config or {}
if user_llm.get("api_key"):
return {
"source": "user",
"provider_type": user_llm.get("provider_type", ""),
"api_url": user_llm.get("api_url", ""),
"api_key": user_llm["api_key"],
"default_model": user_llm.get("default_model", ""),
}
# Fall back to system default
default = db.query(LLMProvider).filter(LLMProvider.is_default == True).first()
if default:
return {
"source": "system",
"provider_type": default.provider_type,
"api_url": default.api_url,
"api_key": default.api_key,
"default_model": default.default_model,
}
return {"source": "none", "provider_type": "", "api_url": "", "api_key": "", "default_model": ""}
# --- Health ---
@app.get("/api/health")
def health():
return {"status": "ok", "service": "agent-command-center", "version": "2026.04.12.01"}
# --- Agent Catalog ---
@app.get("/api/catalog")
def list_catalog(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
entries = db.query(AgentCatalog).all()
# Check which ones this user already has instances of
user_instance_ids = {
i.catalog_id for i in db.query(AgentInstance).filter(AgentInstance.user_id == user["user_id"]).all()
}
return [{
"id": e.id, "name": e.name, "description": e.description,
"category": e.category, "config_schema": e.config_schema or {},
"default_config": e.default_config or {},
"supports_schedule": e.supports_schedule, "is_sub_agent": e.is_sub_agent,
"requires_llm": e.requires_llm,
"enabled": e.id in user_instance_ids,
} for e in entries]
@app.get("/api/catalog/{catalog_id}")
def get_catalog_entry(catalog_id: str, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
entry = db.query(AgentCatalog).filter(AgentCatalog.id == catalog_id).first()
if not entry:
raise HTTPException(status_code=404)
return {
"id": entry.id, "name": entry.name, "description": entry.description,
"category": entry.category, "config_schema": entry.config_schema or {},
"default_config": entry.default_config or {},
"supports_schedule": entry.supports_schedule, "is_sub_agent": entry.is_sub_agent,
}
# --- Agent Instances (user-scoped) ---
def serialize_instance(inst, db):
last_run = db.query(Run).filter(Run.instance_id == inst.id).order_by(Run.started_at.desc()).first()
recent = db.query(Run).filter(Run.instance_id == inst.id).order_by(Run.started_at.desc()).limit(10).all()
streak = 0
for r in recent:
if r.status == "success":
streak += 1
else:
break
return {
"id": inst.id, "catalog_id": inst.catalog_id, "name": inst.name,
"config": inst.config or {}, "schedule": inst.schedule, "status": inst.status,
"created_at": inst.created_at.isoformat() if inst.created_at else None,
"last_run": {
"status": last_run.status,
"started_at": last_run.started_at.isoformat() if last_run.started_at else None,
"finished_at": last_run.finished_at.isoformat() if last_run.finished_at else None,
} if last_run else None,
"success_streak": streak,
"total_runs": db.query(Run).filter(Run.instance_id == inst.id).count(),
}
@app.get("/api/instances")
def list_instances(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
instances = db.query(AgentInstance).filter(AgentInstance.user_id == user["user_id"]).all()
return [serialize_instance(i, db) for i in instances]
@app.post("/api/instances")
def create_instance(data: InstanceCreate, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
catalog = db.query(AgentCatalog).filter(AgentCatalog.id == data.catalog_id).first()
if not catalog:
raise HTTPException(status_code=404, detail="Agent type not found in catalog")
# Enforce LLM requirement
if catalog.requires_llm:
u = db.query(User).filter(User.id == user["user_id"]).first()
user_llm = u.llm_config or {} if u else {}
has_user_llm = bool(user_llm.get("api_key"))
has_system_llm = db.query(LLMProvider).filter(LLMProvider.is_default == True).first() is not None
if not has_user_llm and not has_system_llm:
raise HTTPException(status_code=400, detail="This agent requires an LLM provider. Configure one via the LLM button in the header.")
config = {**(catalog.default_config or {}), **data.config}
inst = AgentInstance(
user_id=user["user_id"],
catalog_id=data.catalog_id,
name=data.name or catalog.name,
config=config,
schedule=data.schedule or ("sub-agent" if catalog.is_sub_agent else "manual"),
)
db.add(inst)
db.commit()
return {"id": inst.id, "status": "created"}
@app.get("/api/instances/{instance_id}")
def get_instance(instance_id: int, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
inst = db.query(AgentInstance).filter(
AgentInstance.id == instance_id, AgentInstance.user_id == user["user_id"]
).first()
if not inst:
raise HTTPException(status_code=404)
runs = db.query(Run).filter(Run.instance_id == instance_id).order_by(Run.started_at.desc()).limit(50).all()
catalog = db.query(AgentCatalog).filter(AgentCatalog.id == inst.catalog_id).first()
result = serialize_instance(inst, db)
result["config_schema"] = catalog.config_schema if catalog else {}
result["runs"] = [{
"id": r.id,
"started_at": r.started_at.isoformat() if r.started_at else None,
"finished_at": r.finished_at.isoformat() if r.finished_at else None,
"status": r.status, "output": r.output, "error": r.error, "metadata": r.metadata_,
} for r in runs]
return result
@app.put("/api/instances/{instance_id}")
def update_instance(instance_id: int, update: InstanceUpdate, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
inst = db.query(AgentInstance).filter(
AgentInstance.id == instance_id, AgentInstance.user_id == user["user_id"]
).first()
if not inst:
raise HTTPException(status_code=404)
if update.name is not None:
inst.name = update.name
if update.schedule is not None:
inst.schedule = update.schedule
if update.status is not None:
inst.status = update.status
if update.config is not None:
current = inst.config or {}
current.update(update.config)
inst.config = current
db.commit()
return {"id": inst.id, "status": "updated"}
@app.delete("/api/instances/{instance_id}")
def delete_instance(instance_id: int, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
inst = db.query(AgentInstance).filter(
AgentInstance.id == instance_id, AgentInstance.user_id == user["user_id"]
).first()
if not inst:
raise HTTPException(status_code=404)
db.query(Run).filter(Run.instance_id == instance_id).delete()
db.delete(inst)
db.commit()
return {"status": "deleted"}
@app.post("/api/instances/{instance_id}/trigger")
def trigger_instance(instance_id: int, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
"""Trigger a manual run of an agent instance. Runs async via subprocess."""
inst = db.query(AgentInstance).filter(
AgentInstance.id == instance_id, AgentInstance.user_id == user["user_id"]
).first()
if not inst:
raise HTTPException(status_code=404)
# Determine which script to run based on catalog type and user
catalog_id = inst.catalog_id
u = db.query(User).filter(User.id == user["user_id"]).first()
import subprocess
agent_dir = "/app/agents"
env = {**dict(os.environ), "PYTHONPATH": agent_dir}
if catalog_id == "daily-briefing":
script_map = {"eric": "eric_briefing.py", "angela": "angela_briefing.py"}
script = script_map.get(u.username, None)
env_key = f"{u.username.upper().replace('.', '_')}_INSTANCE_ID"
env[env_key] = str(instance_id)
if script:
cmd = ["python3", f"{agent_dir}/{script}"]
else:
# Generic: run the engine directly with instance config
cmd = ["python3", "-c",
f"import sys; sys.path.insert(0, '{agent_dir}'); "
f"from daily_briefing import run; "
f"run({{'person': '{u.display_name}', 'agent_id': '{catalog_id}', "
f"'instance_id': {instance_id}, 'wiki_parent_doc_id': '', 'location': {{}}}})"]
subprocess.Popen(cmd, env=env, cwd=agent_dir)
return {"status": "triggered", "message": f"Running {catalog_id} for {u.display_name}"}
if catalog_id == "project-monitor":
config_json = json.dumps(inst.config or {}).replace("'", "\\'")
cmd = ["python3", "-c",
f"import sys, json; sys.path.insert(0, '{agent_dir}'); "
f"from project_monitor import run; "
f"run(json.loads('{config_json}'), user_id={user['user_id']}, instance_id={instance_id})"]
subprocess.Popen(cmd, env=env, cwd=agent_dir)
return {"status": "triggered", "message": f"Running project monitor: {inst.name}"}
return {"status": "error", "message": f"Manual trigger not yet supported for {catalog_id}"}
# --- Internal endpoints (no auth, for agent scripts) ---
@app.get("/api/instances/by-user/{user_id}")
def get_user_instances(user_id: int, catalog_id: str = None, db: Session = Depends(get_db)):
"""Internal: get a user's instances, optionally filtered by catalog type."""
query = db.query(AgentInstance).filter(AgentInstance.user_id == user_id, AgentInstance.status == "active")
if catalog_id:
query = query.filter(AgentInstance.catalog_id == catalog_id)
instances = query.all()
return [{"id": i.id, "catalog_id": i.catalog_id, "name": i.name, "config": i.config or {}} for i in instances]
@app.get("/api/instances/{instance_id}/config")
def get_instance_config(instance_id: int, db: Session = Depends(get_db)):
inst = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first()
if not inst:
raise HTTPException(status_code=404)
return inst.config or {}
@app.post("/api/instances/{instance_id}/runs")
def create_run(instance_id: int, run: RunCreate, db: Session = Depends(get_db)):
inst = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first()
if not inst:
raise HTTPException(status_code=404)
new_run = Run(
instance_id=instance_id,
user_id=inst.user_id,
status=run.status,
output=run.output,
error=run.error,
metadata_=run.metadata,
)
if run.status in ("success", "failed"):
new_run.finished_at = datetime.now(timezone.utc)
db.add(new_run)
db.commit()
return {"id": new_run.id, "status": new_run.status}
# --- Runs (user-scoped) ---
@app.get("/api/runs")
def list_runs(limit: int = 50, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
runs = db.query(Run).filter(Run.user_id == user["user_id"]).order_by(Run.started_at.desc()).limit(limit).all()
return [{
"id": r.id, "instance_id": r.instance_id,
"started_at": r.started_at.isoformat() if r.started_at else None,
"finished_at": r.finished_at.isoformat() if r.finished_at else None,
"status": r.status, "output": r.output, "error": r.error, "metadata": r.metadata_,
} for r in runs]
# --- Admin: Users ---
@app.get("/api/admin/users")
def admin_list_users(admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
users = db.query(User).all()
return [{
"id": u.id, "username": u.username, "email": u.email or "",
"display_name": u.display_name, "role": u.role,
"created_at": u.created_at.isoformat() if u.created_at else None,
"instance_count": db.query(AgentInstance).filter(AgentInstance.user_id == u.id).count(),
} for u in users]
@app.post("/api/admin/users")
def admin_create_user(data: UserCreate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
if db.query(User).filter(User.username == data.username).first():
raise HTTPException(status_code=409, detail="Username exists")
user = User(
username=data.username,
email=data.email or None,
password_hash=hash_password(data.password),
display_name=data.display_name or data.username,
role=data.role,
)
db.add(user)
db.commit()
return {"id": user.id, "status": "created"}
@app.put("/api/admin/users/{user_id}")
def admin_update_user(user_id: int, update: UserUpdate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404)
if update.display_name is not None:
user.display_name = update.display_name
if update.email is not None:
user.email = update.email or None
if update.role is not None:
user.role = update.role
if update.password is not None:
user.password_hash = hash_password(update.password)
db.commit()
return {"id": user.id, "status": "updated"}
@app.delete("/api/admin/users/{user_id}")
def admin_delete_user(user_id: int, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404)
for inst in db.query(AgentInstance).filter(AgentInstance.user_id == user_id).all():
db.query(Run).filter(Run.instance_id == inst.id).delete()
db.delete(inst)
db.delete(user)
db.commit()
return {"status": "deleted"}
# --- Admin: LLM Providers ---
@app.get("/api/admin/llm-providers")
def admin_list_providers(admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
providers = db.query(LLMProvider).all()
return [{
"id": p.id, "name": p.name, "provider_type": p.provider_type,
"api_url": p.api_url, "api_key": "***" if p.api_key else "",
"default_model": p.default_model, "is_default": p.is_default,
} for p in providers]
@app.post("/api/admin/llm-providers")
def admin_create_provider(data: LLMProviderCreate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
if data.is_default:
db.query(LLMProvider).update({"is_default": False})
provider = LLMProvider(
name=data.name, provider_type=data.provider_type, api_url=data.api_url,
api_key=data.api_key, default_model=data.default_model, is_default=data.is_default,
)
db.add(provider)
db.commit()
return {"id": provider.id, "status": "created"}
@app.put("/api/admin/llm-providers/{provider_id}")
def admin_update_provider(provider_id: int, update: LLMProviderUpdate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
provider = db.query(LLMProvider).filter(LLMProvider.id == provider_id).first()
if not provider:
raise HTTPException(status_code=404)
if update.is_default:
db.query(LLMProvider).update({"is_default": False})
for field, value in update.model_dump(exclude_none=True).items():
setattr(provider, field, value)
db.commit()
return {"id": provider.id, "status": "updated"}
@app.delete("/api/admin/llm-providers/{provider_id}")
def admin_delete_provider(provider_id: int, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
provider = db.query(LLMProvider).filter(LLMProvider.id == provider_id).first()
if not provider:
raise HTTPException(status_code=404)
db.delete(provider)
db.commit()
return {"status": "deleted"}
# --- Admin: Catalog Management ---
class CatalogCreate(BaseModel):
id: str
name: str
description: str = ""
category: str = "utility"
config_schema: dict = {}
default_config: dict = {}
supports_schedule: bool = True
is_sub_agent: bool = False
class CatalogUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
category: Optional[str] = None
config_schema: Optional[dict] = None
default_config: Optional[dict] = None
supports_schedule: Optional[bool] = None
is_sub_agent: Optional[bool] = None
@app.post("/api/admin/catalog")
def admin_create_catalog(data: CatalogCreate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
if db.query(AgentCatalog).filter(AgentCatalog.id == data.id).first():
raise HTTPException(status_code=409, detail="Catalog entry exists")
entry = AgentCatalog(**data.model_dump())
db.add(entry)
db.commit()
return {"id": entry.id, "status": "created"}
@app.put("/api/admin/catalog/{catalog_id}")
def admin_update_catalog(catalog_id: str, update: CatalogUpdate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
entry = db.query(AgentCatalog).filter(AgentCatalog.id == catalog_id).first()
if not entry:
raise HTTPException(status_code=404)
for field, value in update.model_dump(exclude_none=True).items():
setattr(entry, field, value)
db.commit()
return {"id": entry.id, "status": "updated"}
@app.delete("/api/admin/catalog/{catalog_id}")
def admin_delete_catalog(catalog_id: str, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
entry = db.query(AgentCatalog).filter(AgentCatalog.id == catalog_id).first()
if not entry:
raise HTTPException(status_code=404)
db.delete(entry)
db.commit()
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:
hb = bridge.last_heartbeat.replace(tzinfo=timezone.utc) if bridge.last_heartbeat.tzinfo is None else bridge.last_heartbeat
age = (datetime.now(timezone.utc) - hb).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
Label
com.jfamily.apple-bridge
ProgramArguments
$BRIDGE_DIR/start-bridge.sh
RunAtLoad
KeepAlive
StandardOutPath
/tmp/apple-bridge.log
StandardErrorPath
/tmp/apple-bridge.err
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")
@app.get("/login")
def login_page():
return FileResponse("static/login.html")
@app.get("/admin")
def admin_page(session: Optional[str] = Cookie(None)):
user = get_current_user(session)
if not user:
return RedirectResponse("/login", status_code=302)
if user["role"] != "admin":
return RedirectResponse("/", status_code=302)
return FileResponse("static/admin.html")
@app.get("/")
def root(session: Optional[str] = Cookie(None)):
user = get_current_user(session)
if not user:
return RedirectResponse("/login", status_code=302)
return FileResponse("static/index.html")
# --- Startup ---
@app.on_event("startup")
def startup():
init_db()