from fastapi import FastAPI, Depends, HTTPException, Response, Cookie, Header 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, SessionLocal from models import ( User, AgentCatalog, AgentInstance, Run, LLMProvider, Bridge, RouteLog, APIClient, APIClientScope, APIClientCall, ServiceConfig, PirateConversation, PirateMessage, ) 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 # --- API Client auth (for external apps like Synap) --- def _hash_api_token(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() def _load_api_client(authorization: Optional[str], db: Session) -> Optional[APIClient]: """Resolve an API client from a Bearer token. Returns None if absent/invalid/revoked.""" if not authorization or not authorization.lower().startswith("bearer "): return None token = authorization[7:].strip() if not token: return None th = _hash_api_token(token) client = db.query(APIClient).filter( APIClient.token_hash == th, APIClient.revoked_at.is_(None), ).first() if client: client.last_used_at = datetime.now(timezone.utc) db.commit() return client def require_user_or_api( session: Optional[str] = Cookie(None), authorization: Optional[str] = Header(None), db: Session = Depends(get_db), ) -> dict: """Accept either a logged-in user session OR a Bearer API token. Returns a dict describing the caller: {"kind": "user", "user_id": int, "username": str, "role": str, "allowed_instance_ids": None} {"kind": "api", "api_client_id": int, "api_client_name": str, "allowed_instance_ids": set[int]} """ u = get_current_user(session) if u: return { "kind": "user", "user_id": u["user_id"], "username": u["username"], "role": u["role"], "allowed_instance_ids": None, # user auth is scoped by instance ownership } client = _load_api_client(authorization, db) if client: allowed = {s.instance_id for s in client.scopes} return { "kind": "api", "api_client_id": client.id, "api_client_name": client.name, "allowed_instance_ids": allowed, } raise HTTPException(status_code=401, detail="Not authenticated") def caller_can_access_instance(caller: dict, inst: AgentInstance) -> bool: """Authorization check: does this caller have rights to read/trigger this instance?""" if caller["kind"] == "user": # Admins can access any instance; users only their own return caller["role"] == "admin" or inst.user_id == caller["user_id"] # API client: must have instance in scope return inst.id in caller["allowed_instance_ids"] def caller_label(caller: dict) -> str: """Short identifier for logging: 'user:eric' or 'api_client:synap'.""" if caller["kind"] == "user": return f"user:{caller['username']}" return f"api_client:{caller['api_client_name']}" def log_api_client_call(db: Session, caller: dict, endpoint: str, instance_id: Optional[int], status_code: int): """Record an audit entry for API client calls (no-op for user sessions).""" if caller["kind"] != "api": return try: entry = APIClientCall( api_client_id=caller["api_client_id"], instance_id=instance_id, endpoint=endpoint, status_code=status_code, ) db.add(entry) db.commit() except Exception: db.rollback() # --- 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 = {} result: Optional[dict] = None # structured data for API consumers run_id: Optional[int] = None # if set, update this run instead of creating a new one 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 class APIClientCreate(BaseModel): name: str description: str = "" instance_ids: list[int] = [] # which instances this client can trigger/read class APIClientUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None instance_ids: Optional[list[int]] = None # replaces existing scopes if provided class ServiceConfigUpsert(BaseModel): service_name: str base_url: str = "" api_key: str = "" username: str = "" password: str = "" extra: dict = {} class PirateChatRequest(BaseModel): message: str conversation_id: Optional[int] = None # None = auto (pick latest non-stale thread, or start new) # --- 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, "result_schema": e.result_schema or {}, "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, "requires_llm": entry.requires_llm, "result_schema": entry.result_schema or {}, } # --- 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: # Must assign a NEW dict — SQLAlchemy won't detect in-place mutations on JSON columns new_config = {**(inst.config or {}), **update.config} inst.config = new_config # Auto-rename instance if project_name is set (e.g. "Project Monitor - WSIT") if new_config.get("project_name") and inst.catalog_id == "project-monitor": inst.name = f"Project Monitor - {new_config['project_name']}" from sqlalchemy.orm.attributes import flag_modified flag_modified(inst, "config") 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"} SUB_AGENT_SCRIPTS = { "weather": "weather_agent.py", "calendar": "calendar_agent.py", "reminders": "reminders_agent.py", "notes": "notes_agent.py", "reading-list": "reading_list_agent.py", } @app.post("/api/instances/{instance_id}/trigger") def trigger_instance( instance_id: int, caller: dict = Depends(require_user_or_api), db: Session = Depends(get_db), ): """Trigger a manual run of an agent instance. Accepts either a user session cookie or a Bearer app token (scoped to this instance). Returns immediately with a run_id to poll + the last successful run's cached result.""" inst = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first() if not inst: raise HTTPException(status_code=404) if not caller_can_access_instance(caller, inst): log_api_client_call(db, caller, f"POST /api/instances/{instance_id}/trigger", instance_id, 403) raise HTTPException(status_code=403, detail="Not authorized for this instance") catalog_id = inst.catalog_id u = db.query(User).filter(User.id == inst.user_id).first() # Look up last successful run — we return its cached result immediately last = db.query(Run).filter( Run.instance_id == instance_id, Run.status == "success", ).order_by(Run.started_at.desc()).first() last_result = last.result if last else None last_run_at = last.started_at.isoformat() if last and last.started_at else None # Create a placeholder run row so the caller gets an id to poll new_run = Run( instance_id=instance_id, user_id=inst.user_id, status="queued", triggered_by=caller_label(caller), ) db.add(new_run) db.commit() db.refresh(new_run) import subprocess agent_dir = "/app/agents" env = { **dict(os.environ), "PYTHONPATH": agent_dir, "RUN_ID": str(new_run.id), "INSTANCE_ID": str(instance_id), } cmd = None if catalog_id == "daily-briefing": script_map = {"eric": "eric_briefing.py", "angela": "angela_briefing.py"} script = script_map.get(u.username) env_key = f"{u.username.upper().replace('.', '_')}_INSTANCE_ID" env[env_key] = str(instance_id) if script: cmd = ["python3", f"{agent_dir}/{script}"] else: 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': {{}}}})"] elif catalog_id == "project-monitor": config_path = f"/tmp/pm_config_{instance_id}.json" with open(config_path, "w") as f: json.dump({"config": inst.config or {}, "user_id": inst.user_id, "instance_id": instance_id}, f) cmd = ["python3", "-c", f"import sys, json; sys.path.insert(0, '{agent_dir}'); " f"d = json.load(open('{config_path}')); " f"from project_monitor import run; " f"run(d['config'], user_id=d['user_id'], instance_id=d['instance_id'])"] elif catalog_id in SUB_AGENT_SCRIPTS: cmd = ["python3", f"{agent_dir}/{SUB_AGENT_SCRIPTS[catalog_id]}", "--from-api"] if not cmd: new_run.status = "failed" new_run.error = f"No runner configured for catalog_id={catalog_id}" new_run.finished_at = datetime.now(timezone.utc) db.commit() log_api_client_call(db, caller, f"POST /api/instances/{instance_id}/trigger", instance_id, 400) raise HTTPException(status_code=400, detail=f"Manual trigger not supported for {catalog_id}") subprocess.Popen(cmd, env=env, cwd=agent_dir) log_api_client_call(db, caller, f"POST /api/instances/{instance_id}/trigger", instance_id, 200) return { "run_id": new_run.id, "status": "queued", "instance_id": instance_id, "catalog_id": catalog_id, "triggered_by": new_run.triggered_by, "last_result": last_result, "last_run_at": last_run_at, } # --- 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)): """Internal endpoint: agents POST here when they start and finish. If run.run_id is set, update that existing row (used when /trigger pre-created a run). Otherwise create a new row (used by cron-launched agents that weren't pre-triggered).""" inst = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first() if not inst: raise HTTPException(status_code=404) if run.run_id: existing = db.query(Run).filter(Run.id == run.run_id, Run.instance_id == instance_id).first() if not existing: raise HTTPException(status_code=404, detail=f"Run {run.run_id} not found for this instance") existing.status = run.status if run.output: existing.output = run.output if run.error: existing.error = run.error if run.metadata: existing.metadata_ = run.metadata if run.result is not None: existing.result = run.result if run.status in ("success", "failed"): existing.finished_at = datetime.now(timezone.utc) db.commit() return {"id": existing.id, "status": existing.status} new_run = Run( instance_id=instance_id, user_id=inst.user_id, status=run.status, output=run.output, error=run.error, metadata_=run.metadata, result=run.result, ) 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} def _serialize_run(r: Run) -> dict: return { "id": r.id, "instance_id": r.instance_id, "status": r.status, "result": r.result, "output": r.output, "error": r.error, "started_at": r.started_at.isoformat() if r.started_at else None, "finished_at": r.finished_at.isoformat() if r.finished_at else None, "triggered_by": getattr(r, "triggered_by", "") or "", "metadata": r.metadata_, } # --- Runs (user or api-client 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 [_serialize_run(r) for r in runs] @app.get("/api/runs/{run_id}") def get_run( run_id: int, caller: dict = Depends(require_user_or_api), db: Session = Depends(get_db), ): """Fetch a single run. Used by callers polling a triggered run until it finishes. Accepts user session or Bearer app token (scoped to the run's instance).""" run = db.query(Run).filter(Run.id == run_id).first() if not run: raise HTTPException(status_code=404) inst = db.query(AgentInstance).filter(AgentInstance.id == run.instance_id).first() if not inst or not caller_can_access_instance(caller, inst): log_api_client_call(db, caller, f"GET /api/runs/{run_id}", run.instance_id, 403) raise HTTPException(status_code=403, detail="Not authorized for this run") log_api_client_call(db, caller, f"GET /api/runs/{run_id}", run.instance_id, 200) return _serialize_run(run) # --- 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: API Clients (external app tokens) --- def _serialize_api_client(client: APIClient, include_token: Optional[str] = None) -> dict: return { "id": client.id, "name": client.name, "description": client.description or "", "token_prefix": client.token_prefix or "", "token": include_token, # only set once, on creation "instance_ids": [s.instance_id for s in client.scopes], "created_at": client.created_at.isoformat() if client.created_at else None, "last_used_at": client.last_used_at.isoformat() if client.last_used_at else None, "revoked_at": client.revoked_at.isoformat() if client.revoked_at else None, "revoked": client.revoked_at is not None, } @app.get("/api/admin/instances") def admin_list_all_instances(admin: dict = Depends(require_admin), db: Session = Depends(get_db)): """Admin-only: list every instance across all users. Used by the API Clients UI to pick which instances a token can access.""" rows = db.query(AgentInstance, User).join(User, AgentInstance.user_id == User.id)\ .order_by(User.username, AgentInstance.catalog_id).all() return [{ "id": inst.id, "name": inst.name, "catalog_id": inst.catalog_id, "status": inst.status, "user_id": u.id, "username": u.username, "display_name": u.display_name, } for inst, u in rows] @app.get("/api/admin/api-clients") def admin_list_api_clients(admin: dict = Depends(require_admin), db: Session = Depends(get_db)): clients = db.query(APIClient).order_by(APIClient.created_at.desc()).all() return [_serialize_api_client(c) for c in clients] @app.post("/api/admin/api-clients") def admin_create_api_client(data: APIClientCreate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)): if db.query(APIClient).filter(APIClient.name == data.name).first(): raise HTTPException(status_code=409, detail="A client with that name already exists") # Verify every instance_id exists for iid in data.instance_ids: if not db.query(AgentInstance).filter(AgentInstance.id == iid).first(): raise HTTPException(status_code=400, detail=f"Instance {iid} does not exist") # Generate a token: 'acc_' + 40 hex chars (no ambiguity with OAuth/other schemes) plaintext = "acc_" + secrets.token_hex(20) client = APIClient( name=data.name, description=data.description, token_hash=_hash_api_token(plaintext), token_prefix=plaintext[:12], # "acc_" + 8 hex ) db.add(client) db.flush() for iid in data.instance_ids: db.add(APIClientScope(api_client_id=client.id, instance_id=iid)) db.commit() db.refresh(client) return _serialize_api_client(client, include_token=plaintext) @app.put("/api/admin/api-clients/{client_id}") def admin_update_api_client(client_id: int, update: APIClientUpdate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)): client = db.query(APIClient).filter(APIClient.id == client_id).first() if not client: raise HTTPException(status_code=404) if update.name is not None: dup = db.query(APIClient).filter(APIClient.name == update.name, APIClient.id != client_id).first() if dup: raise HTTPException(status_code=409, detail="A client with that name already exists") client.name = update.name if update.description is not None: client.description = update.description if update.instance_ids is not None: for iid in update.instance_ids: if not db.query(AgentInstance).filter(AgentInstance.id == iid).first(): raise HTTPException(status_code=400, detail=f"Instance {iid} does not exist") # Replace scope set db.query(APIClientScope).filter(APIClientScope.api_client_id == client_id).delete() for iid in update.instance_ids: db.add(APIClientScope(api_client_id=client_id, instance_id=iid)) db.commit() db.refresh(client) return _serialize_api_client(client) @app.post("/api/admin/api-clients/{client_id}/revoke") def admin_revoke_api_client(client_id: int, admin: dict = Depends(require_admin), db: Session = Depends(get_db)): client = db.query(APIClient).filter(APIClient.id == client_id).first() if not client: raise HTTPException(status_code=404) client.revoked_at = datetime.now(timezone.utc) db.commit() return {"id": client.id, "status": "revoked"} @app.post("/api/admin/api-clients/{client_id}/rotate") def admin_rotate_api_client(client_id: int, admin: dict = Depends(require_admin), db: Session = Depends(get_db)): """Issue a new token for an existing client (invalidates the old one).""" client = db.query(APIClient).filter(APIClient.id == client_id).first() if not client: raise HTTPException(status_code=404) plaintext = "acc_" + secrets.token_hex(20) client.token_hash = _hash_api_token(plaintext) client.token_prefix = plaintext[:12] client.revoked_at = None # un-revoke if it was db.commit() db.refresh(client) return _serialize_api_client(client, include_token=plaintext) @app.delete("/api/admin/api-clients/{client_id}") def admin_delete_api_client(client_id: int, admin: dict = Depends(require_admin), db: Session = Depends(get_db)): client = db.query(APIClient).filter(APIClient.id == client_id).first() if not client: raise HTTPException(status_code=404) db.delete(client) db.commit() return {"status": "deleted"} @app.get("/api/admin/api-clients/{client_id}/calls") def admin_api_client_calls(client_id: int, limit: int = 50, admin: dict = Depends(require_admin), db: Session = Depends(get_db)): if not db.query(APIClient).filter(APIClient.id == client_id).first(): raise HTTPException(status_code=404) calls = db.query(APIClientCall).filter(APIClientCall.api_client_id == client_id)\ .order_by(APIClientCall.called_at.desc()).limit(limit).all() return [{ "id": c.id, "endpoint": c.endpoint, "instance_id": c.instance_id, "status_code": c.status_code, "called_at": c.called_at.isoformat() if c.called_at else None, } for c in calls] # --- Admin: Service Configs (system-wide creds shared by agents) --- # Services The Pirate agent knows how to talk to. Admin fills in URLs + keys. KNOWN_SERVICES = [ ("sonarr", "Sonarr", "TV show management"), ("radarr", "Radarr", "Movie management"), ("lidarr", "Lidarr", "Music management"), ("whisparr", "Whisparr", "Adult content management"), ("prowlarr", "Prowlarr", "Indexer aggregator"), ("bazarr", "Bazarr", "Subtitle management"), ("overseerr", "Overseerr", "Media request portal"), ("qbittorrent", "qBittorrent", "Torrent client"), ("plex", "Plex", "Media server (watch history, on-deck)"), ] def _serialize_service(s: ServiceConfig, include_secrets: bool = False) -> dict: return { "service_name": s.service_name, "base_url": s.base_url or "", "api_key": (s.api_key or "") if include_secrets else ("set" if s.api_key else ""), "username": s.username or "", "password": ("set" if s.password else "") if not include_secrets else (s.password or ""), "extra": s.extra or {}, "updated_at": s.updated_at.isoformat() if s.updated_at else None, "configured": bool(s.base_url), } @app.get("/api/admin/services") def admin_list_services(admin: dict = Depends(require_admin), db: Session = Depends(get_db)): """List all known services + whether admin has filled them in. Secrets are masked.""" existing = {s.service_name: s for s in db.query(ServiceConfig).all()} out = [] for slug, label, desc in KNOWN_SERVICES: s = existing.get(slug) if s: row = _serialize_service(s) else: row = { "service_name": slug, "base_url": "", "api_key": "", "username": "", "password": "", "extra": {}, "updated_at": None, "configured": False, } row["label"] = label row["description"] = desc out.append(row) return out @app.put("/api/admin/services/{service_name}") def admin_upsert_service(service_name: str, data: ServiceConfigUpsert, admin: dict = Depends(require_admin), db: Session = Depends(get_db)): known = {s for s, _, _ in KNOWN_SERVICES} if service_name not in known: raise HTTPException(status_code=400, detail=f"Unknown service: {service_name}") existing = db.query(ServiceConfig).filter(ServiceConfig.service_name == service_name).first() if existing: existing.base_url = data.base_url # Preserve existing secrets if field is empty (lets you edit url without re-typing keys) if data.api_key: existing.api_key = data.api_key if data.password: existing.password = data.password existing.username = data.username or existing.username if data.extra: existing.extra = data.extra else: db.add(ServiceConfig( service_name=service_name, base_url=data.base_url, api_key=data.api_key, username=data.username, password=data.password, extra=data.extra or {}, )) db.commit() return {"service_name": service_name, "status": "saved"} @app.delete("/api/admin/services/{service_name}") def admin_delete_service(service_name: str, admin: dict = Depends(require_admin), db: Session = Depends(get_db)): existing = db.query(ServiceConfig).filter(ServiceConfig.service_name == service_name).first() if not existing: raise HTTPException(status_code=404) db.delete(existing) db.commit() return {"status": "deleted"} @app.get("/api/internal/services/{service_name}") def internal_get_service(service_name: str, db: Session = Depends(get_db)): """Internal endpoint used by agents running in subprocess. No auth — same pattern as the existing /api/instances/{id}/config endpoint. Returns full creds.""" s = db.query(ServiceConfig).filter(ServiceConfig.service_name == service_name).first() if not s: raise HTTPException(status_code=404, detail=f"Service '{service_name}' not configured") return _serialize_service(s, include_secrets=True) # --- 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 requires_llm: bool = False result_schema: dict = {} 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 requires_llm: Optional[bool] = None result_schema: Optional[dict] = 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"} # --- Agent Router --- class RouterRequest(BaseModel): request: str @app.get("/api/catalog/all") def list_catalog_all(db: Session = Depends(get_db)): """Internal: full catalog for the router (no auth).""" entries = db.query(AgentCatalog).all() return [{ "id": e.id, "name": e.name, "description": e.description, "category": e.category, "supports_schedule": e.supports_schedule, "is_sub_agent": e.is_sub_agent, "requires_llm": e.requires_llm, } for e in entries] @app.post("/api/router") def ask_router(data: RouterRequest, user: dict = Depends(require_auth), db: Session = Depends(get_db)): """Route a natural language request to the best agent.""" import subprocess import sys # Call the router agent agent_dir = "/app/agents" config_path = f"/tmp/router_{user['user_id']}_{secrets.token_hex(4)}.json" with open(config_path, "w") as f: json.dump({"user_id": user["user_id"], "request": data.request}, f) try: result = subprocess.run( ["python3", "-c", f"import sys, json; sys.path.insert(0, '{agent_dir}'); " f"d = json.load(open('{config_path}')); " f"from agent_router import route; " f"r = route(d['user_id'], d['request']); " f"print(json.dumps(r))"], capture_output=True, text=True, timeout=120, cwd=agent_dir, env={**dict(os.environ), "PYTHONPATH": agent_dir}, ) os.remove(config_path) if result.returncode != 0: raise RuntimeError(result.stderr[:500]) recommendation = json.loads(result.stdout.strip()) except Exception as e: return {"error": str(e), "action": "not_possible", "reasoning": f"Router error: {e}"} # Log the route log = RouteLog( user_id=user["user_id"], request_text=data.request, recommended_agent=recommendation.get("agent_name", ""), action=recommendation.get("action", ""), reasoning=recommendation.get("reasoning", ""), outcome="pending", metadata_={ "instance_id": recommendation.get("instance_id"), "catalog_id": recommendation.get("catalog_id"), "config": recommendation.get("config"), "model": recommendation.get("model", ""), "tokens_in": recommendation.get("tokens_in", 0), "tokens_out": recommendation.get("tokens_out", 0), }, ) db.add(log) db.commit() recommendation["route_id"] = log.id return recommendation @app.post("/api/router/{route_id}/accept") def accept_route(route_id: int, user: dict = Depends(require_auth), db: Session = Depends(get_db)): """Accept a router suggestion and execute it.""" log = db.query(RouteLog).filter(RouteLog.id == route_id, RouteLog.user_id == user["user_id"]).first() if not log: raise HTTPException(status_code=404) log.outcome = "accepted" meta = log.metadata_ or {} action = log.action instance_id = meta.get("instance_id") # Execute the recommended action if action == "run_existing" and instance_id: # Trigger the instance import subprocess inst = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first() if inst: agent_dir = "/app/agents" catalog_id = inst.catalog_id u = db.query(User).filter(User.id == user["user_id"]).first() 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) if script: env_key = f"{u.username.upper().replace('.', '_')}_INSTANCE_ID" env[env_key] = str(instance_id) subprocess.Popen(["python3", f"{agent_dir}/{script}"], env=env, cwd=agent_dir) elif catalog_id == "project-monitor": config_path = f"/tmp/pm_config_{instance_id}.json" with open(config_path, "w") as f: json.dump({"config": inst.config or {}, "user_id": user["user_id"], "instance_id": instance_id}, f) subprocess.Popen( ["python3", "-c", f"import sys, json; sys.path.insert(0, '{agent_dir}'); " f"d = json.load(open('{config_path}')); " f"from project_monitor import run; " f"run(d['config'], user_id=d['user_id'], instance_id=d['instance_id'])"], env=env, cwd=agent_dir, ) db.commit() return {"status": "executing", "message": f"Running {inst.name}"} elif action == "configure" and instance_id: inst = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first() if inst and meta.get("config"): new_config = {**(inst.config or {}), **meta["config"]} inst.config = new_config from sqlalchemy.orm.attributes import flag_modified flag_modified(inst, "config") if new_config.get("project_name") and inst.catalog_id == "project-monitor": inst.name = f"Project Monitor - {new_config['project_name']}" db.commit() return {"status": "configured", "message": f"Updated {inst.name}"} elif action == "create_and_run": catalog_id = meta.get("catalog_id") catalog = db.query(AgentCatalog).filter(AgentCatalog.id == catalog_id).first() if catalog: config = {**(catalog.default_config or {}), **(meta.get("config") or {})} inst = AgentInstance( user_id=user["user_id"], catalog_id=catalog_id, name=meta.get("instance_name") or catalog.name, config=config, schedule="manual", ) db.add(inst) db.commit() return {"status": "created", "message": f"Created and ready: {inst.name}", "instance_id": inst.id} elif action == "info": db.commit() return {"status": "info", "message": log.reasoning} db.commit() return {"status": "accepted"} @app.post("/api/router/{route_id}/reject") def reject_route(route_id: int, user: dict = Depends(require_auth), db: Session = Depends(get_db)): """Reject a router suggestion.""" log = db.query(RouteLog).filter(RouteLog.id == route_id, RouteLog.user_id == user["user_id"]).first() if not log: raise HTTPException(status_code=404) log.outcome = "rejected" db.commit() return {"status": "rejected"} # --- 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") @app.get("/pirate") def pirate_page(session: Optional[str] = Cookie(None)): user = get_current_user(session) if not user: return RedirectResponse("/login", status_code=302) return FileResponse("static/pirate.html") # --- The Pirate: conversational media agent --- PIRATE_IDLE_RESET_HOURS = 24 def _caller_user_id_for_pirate(caller: dict, db: Session) -> int: """Pirate is a per-user agent. Resolve the target user from the caller. - user session: the logged-in user - api token: the user_id of the pirate instance the token is scoped to Tokens scoped to zero or multiple pirate instances are rejected. """ if caller["kind"] == "user": return caller["user_id"] # API token — find pirate instances it can reach pirate_scopes = db.query(AgentInstance).filter( AgentInstance.id.in_(caller["allowed_instance_ids"]), AgentInstance.catalog_id == "pirate", ).all() if not pirate_scopes: raise HTTPException(status_code=403, detail="Token is not scoped to a Pirate instance") if len(pirate_scopes) > 1: raise HTTPException(status_code=400, detail="Token scoped to multiple Pirate instances; ambiguous") return pirate_scopes[0].user_id def _pick_or_create_conversation(db: Session, user_id: int, conversation_id: Optional[int]) -> PirateConversation: """If conversation_id is provided, load it (and authorize). Otherwise find the user's most recent conversation; reuse it if active (< idle window), else start a new one.""" if conversation_id: conv = db.query(PirateConversation).filter(PirateConversation.id == conversation_id).first() if not conv: raise HTTPException(status_code=404, detail="Conversation not found") if conv.user_id != user_id: raise HTTPException(status_code=403, detail="Not your conversation") return conv # Auto-pick: latest active thread for this user, else new now = datetime.now(timezone.utc) latest = db.query(PirateConversation).filter( PirateConversation.user_id == user_id, ).order_by(PirateConversation.last_message_at.desc()).first() if latest: last = latest.last_message_at if last and last.tzinfo is None: last = last.replace(tzinfo=timezone.utc) if last and (now - last) < timedelta(hours=PIRATE_IDLE_RESET_HOURS): return latest conv = PirateConversation(user_id=user_id, title="") db.add(conv) db.commit() db.refresh(conv) return conv def _serialize_pirate_message(m: PirateMessage) -> dict: return { "id": m.id, "role": m.role, "content": m.content or "", "tool_calls": m.tool_calls, "tool_call_id": m.tool_call_id or "", "tool_name": m.tool_name or "", "tool_result": m.tool_result, "model": m.model or "", "input_tokens": m.input_tokens or 0, "output_tokens": m.output_tokens or 0, "created_at": m.created_at.isoformat() if m.created_at else None, } def _serialize_conversation(conv: PirateConversation, include_messages: bool = False) -> dict: out = { "id": conv.id, "user_id": conv.user_id, "title": conv.title or "", "created_at": conv.created_at.isoformat() if conv.created_at else None, "last_message_at": conv.last_message_at.isoformat() if conv.last_message_at else None, } if include_messages: out["messages"] = [_serialize_pirate_message(m) for m in conv.messages] return out @app.post("/api/pirate/chat") def pirate_chat( data: PirateChatRequest, caller: dict = Depends(require_user_or_api), db: Session = Depends(get_db), ): """Send a message to The Pirate and get a response. Runs the LLM tool-use loop synchronously (Pirate conversations need the response immediately — no async polling pattern here).""" user_id = _caller_user_id_for_pirate(caller, db) conv = _pick_or_create_conversation(db, user_id, data.conversation_id) # Persist the user turn user_msg = PirateMessage(conversation_id=conv.id, role="user", content=data.message) db.add(user_msg) if not conv.title: conv.title = data.message.strip()[:80] conv.last_message_at = datetime.now(timezone.utc) db.commit() # Invoke the Pirate runtime as a subprocess so it runs in the agent container's Python env # (where the tool package + LLM client live). Use a helper entry point. import subprocess agent_dir = "/app/agents" env = { **dict(os.environ), "PYTHONPATH": agent_dir, "PIRATE_CONVERSATION_ID": str(conv.id), "PIRATE_USER_ID": str(user_id), } result = subprocess.run( ["python3", "-c", "import sys; sys.path.insert(0, '/app/agents'); " "from pirate.runtime import chat_turn; chat_turn()"], env=env, cwd=agent_dir, capture_output=True, text=True, timeout=120, ) if result.returncode != 0: err = (result.stderr or result.stdout or "")[-2000:] err_msg = PirateMessage( conversation_id=conv.id, role="assistant", content=f"[Pirate error] {err[-500:]}", ) db.add(err_msg) conv.last_message_at = datetime.now(timezone.utc) db.commit() raise HTTPException(status_code=500, detail=f"Pirate runtime failed: {err[-500:]}") # Reload conversation to return fresh state (runtime appended assistant + tool messages) db.refresh(conv) log_api_client_call(db, caller, "POST /api/pirate/chat", None, 200) return _serialize_conversation(conv, include_messages=True) @app.get("/api/pirate/conversations") def pirate_list_conversations( caller: dict = Depends(require_user_or_api), db: Session = Depends(get_db), ): user_id = _caller_user_id_for_pirate(caller, db) convs = db.query(PirateConversation).filter( PirateConversation.user_id == user_id, ).order_by(PirateConversation.last_message_at.desc()).limit(50).all() return [_serialize_conversation(c, include_messages=False) for c in convs] @app.get("/api/pirate/conversations/{conv_id}") def pirate_get_conversation( conv_id: int, caller: dict = Depends(require_user_or_api), db: Session = Depends(get_db), ): user_id = _caller_user_id_for_pirate(caller, db) conv = db.query(PirateConversation).filter(PirateConversation.id == conv_id).first() if not conv: raise HTTPException(status_code=404) if conv.user_id != user_id: raise HTTPException(status_code=403) return _serialize_conversation(conv, include_messages=True) @app.post("/api/pirate/conversations/new") def pirate_new_conversation( caller: dict = Depends(require_user_or_api), db: Session = Depends(get_db), ): """Force-start a new conversation thread (user clicked 'New Chat').""" user_id = _caller_user_id_for_pirate(caller, db) conv = PirateConversation(user_id=user_id, title="") db.add(conv) db.commit() db.refresh(conv) return _serialize_conversation(conv, include_messages=True) @app.delete("/api/pirate/conversations/{conv_id}") def pirate_delete_conversation( conv_id: int, caller: dict = Depends(require_user_or_api), db: Session = Depends(get_db), ): user_id = _caller_user_id_for_pirate(caller, db) conv = db.query(PirateConversation).filter(PirateConversation.id == conv_id).first() if not conv or conv.user_id != user_id: raise HTTPException(status_code=404) db.delete(conv) db.commit() return {"status": "deleted"} # Internal endpoints used by the pirate runtime subprocess ------------------ @app.get("/api/internal/pirate/conversation/{conv_id}") def internal_get_conversation(conv_id: int, db: Session = Depends(get_db)): conv = db.query(PirateConversation).filter(PirateConversation.id == conv_id).first() if not conv: raise HTTPException(status_code=404) return _serialize_conversation(conv, include_messages=True) class InternalMessageCreate(BaseModel): role: str content: str = "" tool_calls: Optional[list] = None tool_call_id: str = "" tool_name: str = "" tool_result: Optional[dict] = None model: str = "" input_tokens: int = 0 output_tokens: int = 0 @app.post("/api/internal/pirate/conversation/{conv_id}/messages") def internal_append_message(conv_id: int, data: InternalMessageCreate, db: Session = Depends(get_db)): conv = db.query(PirateConversation).filter(PirateConversation.id == conv_id).first() if not conv: raise HTTPException(status_code=404) msg = PirateMessage( conversation_id=conv_id, role=data.role, content=data.content, tool_calls=data.tool_calls, tool_call_id=data.tool_call_id, tool_name=data.tool_name, tool_result=data.tool_result, model=data.model, input_tokens=data.input_tokens, output_tokens=data.output_tokens, ) db.add(msg) conv.last_message_at = datetime.now(timezone.utc) db.commit() return {"id": msg.id} # --- Result schemas (what each agent's structured result looks like) --- RESULT_SCHEMAS = { "weather": { "description": "Current conditions + 7-day forecast for a configured location.", "shape": { "location": { "name": "string", "state": "string", "country": "string", "lat": "number", "lon": "number", "label": "string", }, "current": { "condition": "string", "weather_code": "int", "temperature_f": "int", "feels_like_f": "int", "wind_mph": "int", "humidity_pct": "int", }, "forecast": "array of 7 day objects: {date, weekday, condition, weather_code, temp_high_f, temp_low_f, precip_in, wind_max_mph, sunrise, sunset}", "fetched_at": "ISO datetime string (Mountain Time)", }, }, "calendar": { "description": "Upcoming calendar events across configured sources. Structured result not yet populated — markdown only.", "shape": {"note": "result_schema not yet implemented for this agent"}, }, "reminders": { "description": "Pending/overdue reminders from configured CalDAV sources. Structured result not yet populated — markdown only.", "shape": {"note": "result_schema not yet implemented for this agent"}, }, "notes": { "description": "Recent Apple Notes via Mac bridge. Structured result not yet populated — markdown only.", "shape": {"note": "result_schema not yet implemented for this agent"}, }, "reading-list": { "description": "Safari Reading List items via Mac bridge. Structured result not yet populated — markdown only.", "shape": {"note": "result_schema not yet implemented for this agent"}, }, "daily-briefing": { "description": "Aggregated daily briefing: wiki doc posted + each sub-agent's result nested under sections.", "shape": { "date": "YYYY-MM-DD", "generated_at": "ISO datetime string", "person": "string", "location": "object (same shape as weather.location)", "sections": "object keyed by sub-agent (weather|calendar|reminders|notes|reading_list|projects), each value is {name, summary, result, error?}", "wiki_doc_id": "string (Outline doc id) or null", "wiki_action": "'created' | 'updated' | null", }, }, "project-monitor": { "description": "LLM-generated status report for a tracked project.", "shape": { "project_name": "string", "app_url": "string or null", "wiki_collection_id": "string or null", "wiki_report_id": "string or null (Outline doc id of the full report)", "gitea_repo": "string or null", "summary": "string (first paragraph of the report)", "report_markdown": "string (full LLM-generated report)", "model": "string (LLM model used)", "tokens": {"input": "int", "output": "int"}, "generated_at": "ISO datetime string", }, }, "pirate": { "description": "Conversational read-only media agent. Chat with it about Sonarr, Radarr, qBittorrent, Plex. Phase 1 is read-only; Phase 2 adds media request + torrent control writes.", "shape": { "note": "Pirate does not use the run/result pattern. It lives behind /api/pirate/chat. Each call returns a full conversation history (messages with role, content, tool_calls, tool_result).", }, }, } # Catalog entries that should exist even if admin never adds them manually. # Seeded on startup if missing. SEEDED_CATALOG_ENTRIES = [ { "id": "pirate", "name": "The Pirate", "description": "Conversational read-only media agent (Phase 1). Chat about TV, movies, music, and downloads; no write actions yet.", "category": "intelligence", "config_schema": { "services": "Services the Pirate can query (configured system-wide in /admin → Services)", }, "default_config": {}, "supports_schedule": False, "is_sub_agent": False, "requires_llm": True, }, ] def _seed_catalog(db: Session): """Insert baseline catalog entries for agents the platform itself depends on (like The Pirate). Idempotent — only inserts when id is missing.""" for entry in SEEDED_CATALOG_ENTRIES: existing = db.query(AgentCatalog).filter(AgentCatalog.id == entry["id"]).first() if existing: continue db.add(AgentCatalog(**entry)) db.commit() def _seed_pirate_instances(db: Session): """Every non-admin user gets one Pirate instance automatically. Idempotent.""" users = db.query(User).filter(User.role != "admin").all() for u in users: existing = db.query(AgentInstance).filter( AgentInstance.user_id == u.id, AgentInstance.catalog_id == "pirate", ).first() if existing: continue db.add(AgentInstance( user_id=u.id, catalog_id="pirate", name=f"{u.display_name or u.username}'s Pirate", config={}, schedule="manual", status="active", )) db.commit() # Defaults loaded from the Media Stack Reference wiki page. Seeded only when the # service_configs table has no row for that slug — admin-entered values are never overwritten. _SEED_SERVICE_DEFAULTS = { "sonarr": {"base_url": "http://192.168.1.203:8989", "api_key": "d494ea4c9ec74d3793a9a84dfae7c4c8"}, "radarr": {"base_url": "http://192.168.1.203:7878", "api_key": "4df49af333574d1d989e221375b928ef"}, "lidarr": {"base_url": "http://192.168.1.203:8686", "api_key": "58ad42ac15e44001927226461d606c34"}, "whisparr": {"base_url": "http://192.168.1.203:6969", "api_key": "99dee8e33f63470bad8b4e41bed6af4a"}, "prowlarr": {"base_url": "http://192.168.1.203:9696", "api_key": "35bb6983a11d4decbcf4422be3218568"}, "bazarr": {"base_url": "http://192.168.1.203:6767", "api_key": "4bc3869b8fef0b38c09f3da2754d5595"}, "overseerr": {"base_url": "http://192.168.1.203:5055", "api_key": "MTc2OTI2OTIwNzU0MDdkYmNhMTg1LTgxZTMtNDdjOC04MTBhLTE2YzFlNjJiNzZhYw=="}, "qbittorrent": {"base_url": "http://192.168.1.239:8080", "api_key": ""}, # LAN no-auth today # plex: no public token yet, leave empty for admin to fill in } def _seed_service_configs(db: Session): for slug, defaults in _SEED_SERVICE_DEFAULTS.items(): existing = db.query(ServiceConfig).filter(ServiceConfig.service_name == slug).first() if existing: continue db.add(ServiceConfig( service_name=slug, base_url=defaults.get("base_url", ""), api_key=defaults.get("api_key", ""), username=defaults.get("username", ""), password=defaults.get("password", ""), extra=defaults.get("extra", {}), )) db.commit() def _seed_result_schemas(db: Session): """Populate agent_catalog.result_schema for known agents. Idempotent — only fills empty.""" for catalog_id, schema in RESULT_SCHEMAS.items(): entry = db.query(AgentCatalog).filter(AgentCatalog.id == catalog_id).first() if entry and not entry.result_schema: entry.result_schema = schema db.commit() # --- Startup --- @app.on_event("startup") def startup(): init_db() db = SessionLocal() try: _seed_catalog(db) _seed_result_schemas(db) _seed_pirate_instances(db) _seed_service_configs(db) finally: db.close()