diff --git a/.env.example b/.env.example index 370d705..04b1d6c 100644 --- a/.env.example +++ b/.env.example @@ -16,11 +16,21 @@ ARIA_AUTH_TOKEN=change-me-to-a-long-random-string # Alle muessen den gleichen Host, Port und Token nutzen. # Hostname des RVS-Servers (z.B. rvs.example.de oder mobil.hacker-net.de) +# WICHTIG: muss oeffentlich aufloesbar sein (DNS), nicht nur intern. +# Wird auch fuer OAuth-Callback-URLs verwendet — Spotify/Google/etc. +# redirecten Stefan im Browser an https://{RVS_HOST}/oauth/callback/{service}. RVS_HOST=rvs.example.de # Port auf dem der RVS laeuft (muss mit rvs/docker-compose.yml uebereinstimmen) RVS_PORT=443 +# Oeffentlich erreichbarer TLS-Port — was Browser/Provider von aussen sehen. +# Meist identisch mit RVS_PORT, kann aber abweichen wenn ein TLS-Terminator +# (Caddy/Nginx) davor steht der z.B. 444 auf intern 3000 mappt. Wird fuer +# die OAuth-Callback-URL benutzt; muss zu dem Eintrag im Provider-Dashboard +# passen. Leer/ungesetzt = RVS_PORT wird verwendet. +RVS_PORT_PUBLIC= + # TLS (wss://) verwenden? true = verschluesselt, false = unverschluesselt (ws://) RVS_TLS=true @@ -35,6 +45,21 @@ RVS_TLS_FALLBACK=true # Generieren: ./generate-token.sh (traegt den Token automatisch ein) RVS_TOKEN= +# ── Brain-Timeouts ─────────────────────────────── +# Brain redet via HTTP mit dem Proxy-Container. Da der Proxy non-streaming +# antwortet (Response kommt erst nach subprocess-close), kann ein Brain-Call +# bei langen Agent-Sessions (Pentests, Multi-Step-Tasks) >1h dauern. +# PROXY_TIMEOUT_SEC ist der httpx-Read-Timeout im Brain — wir setzen ihn +# bewusst hoch (24h), der Proxy hat einen eigenen Idle-Watchdog +# (ARIA_IDLE_TIMEOUT_MS in der proxy-Logik, default 20min Inaktivitaet) +# der den Subprocess killt wenn wirklich was haengt. +# Connect/Write/Pool bleiben klein damit toter Proxy in 10s erkannt wird. +PROXY_TIMEOUT_SEC=86400 +# Diese drei sind defensive Defaults — aendern nur wenn netzwerk-bedingt noetig. +# PROXY_CONNECT_TIMEOUT_SEC=10 +# PROXY_WRITE_TIMEOUT_SEC=30 +# PROXY_POOL_TIMEOUT_SEC=10 + # ── Gitea — Release-Verwaltung ─────────────────── # Wird von release.sh genutzt um APKs auf Gitea zu veroeffentlichen. # Kennwort wird beim Release interaktiv abgefragt (nicht in .env!). diff --git a/README.md b/README.md index 47698b8..b2071b9 100644 --- a/README.md +++ b/README.md @@ -332,7 +332,7 @@ Erreichbar unter `http://:3001`. Teilt das Netzwerk mit der Bridge. **Auflösung**: Background-Loop tickt alle 8s (vorher 30s — bei 100 km/h durch einen 300m-Radius war eine Vorbeifahrt nur ~22s drin und konnte verpasst werden). Plus event-getrieben: Bridge ruft nach jedem `location_update` von der App sofort einen `/triggers/check-now` im Brain — Watcher sehen die frische Position in Millisekunden statt im Polling-Takt. `near()`-Funktionen ignorieren GPS-Daten älter als 5 Minuten (verhindert Phantom-Fires bei abgeschaltetem Tracking). - **Dateien**: Browser fuer `/shared/uploads/` mit Multi-Select + "Alle markieren" + Bulk-Download (ZIP bei 2+) + Bulk-Delete. Live-Update der Chat-Bubbles beim Delete. -- **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning + Voice Export/Import, Whisper, Sprachmodell (brainModel), Onboarding-QR, App-Cleanup +- **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning + Voice Export/Import, **FLUX Bildgenerierung** (Default-Modell + Raw/Switch-Keywords + HF-Token), **OAuth-Apps** (Spotify, Google, GitHub, Strava, Microsoft, ...) mit client_id+client_secret pro Service + One-Click-Autorisieren, Whisper, Sprachmodell (brainModel), Onboarding-QR, App-Cleanup ### Was zusaetzlich noch drin steckt @@ -342,7 +342,8 @@ Erreichbar unter `http://:3001`. Teilt das Netzwerk mit der Bridge. - **Voice Export/Import**: einzelne Stimmen als `.tar.gz` zwischen Gameboxen mitnehmen - **Settings Export/Import**: `voice_config.json` + `highlight_triggers.json` als JSON-Bundle - **Claude Login**: Browser-Terminal zum Einloggen in den Proxy -- **SSH Terminal**: direkter SSH-Zugang zu aria-wohnung +- **ARIA Live**: read-only Mirror der Claude-Code-Session — alle Tool-Calls + Inputs + Outputs live in einer Monospace-Liste, farbcodiert. Plus ⛔ **Not-Aus**-Button der per RVS einen `cancel_request` mit `hard:true` ausloest → aria-bridge ruft den proxy-internen `/cancel-all` Side-Channel → alle Claude-Subprocesses werden sofort gekillt +- **OAuth-Callback-Pipeline**: RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket. Provider (Spotify/Google/...) redirecten den User an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet als `oauth_callback`-WS-Message → aria-bridge forwarded an Brain → Brain matched `state`, tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json`. Token-Refresh laeuft automatisch. ARIA hat `oauth_authorize` / `oauth_get_token` / `oauth_revoke` als Brain-Tools --- diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 163c2dc..71a04d1 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -30,6 +30,7 @@ from proxy_client import ProxyClient, Message as ProxyMessage import skills as skills_mod import triggers as triggers_mod import watcher as watcher_mod +import oauth as oauth_mod BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090") # FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start @@ -245,6 +246,88 @@ META_TOOLS = [ }, }, }, + { + "type": "function", + "function": { + "name": "oauth_authorize", + "description": ( + "Startet einen OAuth2-Authorize-Flow fuer einen externen " + "Service (Spotify, Google, GitHub, Strava, Microsoft, ...). " + "Returnt eine URL die Stefan im Browser oeffnen muss — er " + "loggt sich beim Provider ein und stimmt den Scopes zu, der " + "Provider redirected zu unserem RVS-Callback, RVS forwarded " + "an Brain, Token wird automatisch gespeichert.\n\n" + "**Nutze das wenn:** Stefan moechte einen Service nutzen " + "(z.B. \"verbinde mich mit Spotify\", \"baue einen Spotify-" + "Skill\"), aber `oauth_get_token` wirft *Kein Token gespeichert*.\n\n" + "**Workflow:**\n" + "1. `oauth_authorize(service='spotify')` -> URL\n" + "2. Gib Stefan die URL als anklickbaren Link\n" + "3. Warte bis er sagt dass er autorisiert hat\n" + "4. `oauth_get_token('spotify')` -> access_token, kannst Du im API-Call nutzen\n\n" + "Voraussetzung: Stefan hat in Diagnostic > OAuth-Apps fuer den " + "Service `client_id` + `client_secret` eingetragen. Falls nicht, " + "wirft das Tool eine entsprechende Fehlermeldung — sage Stefan " + "er soll das machen, NICHT versuchen die Credentials selbst zu " + "raten oder zu generieren." + ), + "parameters": { + "type": "object", + "properties": { + "service": { + "type": "string", + "description": "Service-Name. Vordefinierte: spotify, google, github, strava, microsoft. Custom-Services moeglich wenn Stefan sie in oauth_apps.json eingetragen hat (mit auth_url + token_url).", + }, + "scopes": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional: Provider-spezifische Scopes (z.B. fuer Spotify ['user-read-playback-state','playlist-modify-public']). Wenn weggelassen, werden die Default-Scopes des Services genutzt.", + }, + }, + "required": ["service"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "oauth_get_token", + "description": ( + "Liefert das aktuelle access_token fuer einen Service. " + "Refresht automatisch wenn abgelaufen (oder < 60s Restzeit) " + "und der Provider einen refresh_token mitgegeben hat.\n\n" + "**Nutze das in Skills** wenn Du Provider-APIs callen willst — " + "der token kommt als Bearer-Header in Deinen HTTP-Request, " + "z.B. `Authorization: Bearer `.\n\n" + "Wirft wenn Service noch nicht authentifiziert ist oder der " + "Refresh fehlschlaegt → dann erst `oauth_authorize` aufrufen." + ), + "parameters": { + "type": "object", + "properties": { + "service": {"type": "string", "description": "z.B. spotify, google, ..."}, + }, + "required": ["service"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "oauth_revoke", + "description": ( + "Loescht das gespeicherte Token fuer einen Service (lokal). " + "Stefan muss danach via `oauth_authorize` neu autorisieren wenn " + "er den Service wieder nutzen will. Nutze das wenn Stefan sagt " + "\"melde mich bei X ab\" oder \"vergiss meine Spotify-Anmeldung\"." + ), + "parameters": { + "type": "object", + "properties": {"service": {"type": "string"}}, + "required": ["service"], + }, + }, + }, { "type": "function", "function": { @@ -540,11 +623,24 @@ class Agent: # 5. System-Prompt + Window-Messages flux_config = _load_flux_config() + # OAuth-Block: aktuelle Service-States + Callback-URL fuer ARIA + try: + oauth_services = oauth_mod.list_services() + except Exception as exc: + logger.warning("oauth list_services fehlgeschlagen: %s", exc) + oauth_services = None + oauth_host = os.environ.get("RVS_HOST", "").strip() + oauth_port = os.environ.get("RVS_PORT_PUBLIC", os.environ.get("RVS_PORT", "443")).strip() + oauth_tls = os.environ.get("RVS_TLS", "true").strip().lower() != "false" system_prompt = build_system_prompt(hot, cold, skills=all_skills, triggers=all_triggers, condition_vars=condition_vars, condition_funcs=condition_funcs, - flux_config=flux_config) + flux_config=flux_config, + oauth_services=oauth_services, + oauth_callback_host=oauth_host, + oauth_callback_port=oauth_port, + oauth_callback_tls=oauth_tls) messages = [ProxyMessage(role="system", content=system_prompt)] for t in self.conversation.window(): messages.append(ProxyMessage(role=t.role, content=t.content)) @@ -730,6 +826,52 @@ class Agent: else: lines.append(f"- {t['name']} ({t['type']}, {state})") return "\n".join(lines) + if name == "oauth_authorize": + svc = (arguments.get("service") or "").strip() + if not svc: + return "FEHLER: service ist Pflicht (z.B. 'spotify')." + scopes = arguments.get("scopes") if isinstance(arguments.get("scopes"), list) else None + try: + info = oauth_mod.build_authorize_url(svc, scopes=scopes) + except RuntimeError as exc: + return f"FEHLER: {exc}" + except Exception as exc: + logger.exception("oauth_authorize fehlgeschlagen") + return f"FEHLER: {exc}" + return ( + f"OK — Authorize-URL fuer {svc} bereit.\n" + f"Sage Stefan: Klicke diesen Link um Dich bei {svc} anzumelden:\n\n" + f"{info['url']}\n\n" + f"Nach Zustimmung schickt Dich der Provider zu unserem Callback " + f"({info['redirect_uri']}); RVS schnappt sich den code automatisch, " + f"Brain tauscht ihn gegen ein Token. Du musst nichts copy-pasten.\n" + f"Falls beim Provider 'redirect_uri_mismatch' auftaucht, muss Stefan " + f"`{info['redirect_uri']}` einmalig im Provider-Dashboard als gueltige " + f"Redirect-URI eintragen." + ) + if name == "oauth_get_token": + svc = (arguments.get("service") or "").strip() + if not svc: + return "FEHLER: service ist Pflicht." + try: + record = oauth_mod.get_token(svc) + except RuntimeError as exc: + return f"FEHLER: {exc}" + tok = record.get("access_token", "") + ttype = record.get("token_type", "Bearer") + exp = record.get("expires_at", 0) + remain = max(0, int(exp) - int(__import__("time").time())) + return ( + f"OK — Token fuer {svc} (Typ: {ttype}, gueltig noch {remain}s).\n" + f"access_token: {tok}\n" + f"Nutze als HTTP-Header: Authorization: {ttype} {tok}" + ) + if name == "oauth_revoke": + svc = (arguments.get("service") or "").strip() + if not svc: + return "FEHLER: service ist Pflicht." + ok = oauth_mod.revoke(svc) + return f"OK — Token fuer {svc} entfernt." if ok else f"Kein Token fuer {svc} vorhanden." if name == "flux_generate": prompt = (arguments.get("prompt") or "").strip() if not prompt: diff --git a/aria-brain/main.py b/aria-brain/main.py index faa7231..a2da99c 100644 --- a/aria-brain/main.py +++ b/aria-brain/main.py @@ -36,6 +36,7 @@ import metrics as metrics_mod import triggers as triggers_mod import watcher as watcher_mod import background as background_mod +import oauth as oauth_mod logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") logger = logging.getLogger("aria-brain") @@ -849,3 +850,118 @@ async def skills_import(request: Request, overwrite: bool = False): except ValueError as exc: raise HTTPException(400, str(exc)) return {"imported": manifest} + + +# ── OAuth ───────────────────────────────────────────────────────── + + +@app.get("/oauth/services") +async def oauth_services_list(): + """Liste aller Services mit Status (configured/authenticated/expires).""" + return {"services": oauth_mod.list_services()} + + +@app.get("/oauth/apps") +async def oauth_apps_get(): + """Liefert die persistierte Provider-Config (client_id sichtbar, client_secret + NICHT — wer den Wert braucht muss ihn neu eintragen). Fuer Diagnostic-UI.""" + apps = oauth_mod._load_json(oauth_mod.APPS_FILE) + safe = {} + for service, entry in apps.items(): + if not isinstance(entry, dict): + continue + safe[service] = { + "client_id": entry.get("client_id", ""), + "has_client_secret": bool(entry.get("client_secret")), + "scopes": entry.get("scopes"), + "auth_url": entry.get("auth_url"), + "token_url": entry.get("token_url"), + } + return {"apps": safe, "defaults": list(oauth_mod.DEFAULT_PROVIDERS.keys())} + + +class OAuthAppIn(BaseModel): + service: str + client_id: str = "" + client_secret: str = "" + scopes: Optional[List[str]] = None + auth_url: Optional[str] = None + token_url: Optional[str] = None + + +@app.post("/oauth/apps") +async def oauth_apps_set(body: OAuthAppIn): + """Speichert/aktualisiert eine Provider-Config. Leerer client_secret laesst + den bestehenden Wert stehen (damit man die Form ohne Re-Eingabe absenden + kann fuer reine scope-Aenderungen).""" + service = (body.service or "").strip() + if not service or not service.isidentifier() and not all(c.isalnum() or c in "_-" for c in service): + raise HTTPException(400, "Ungueltiger service-Name (a-z0-9_- erlaubt)") + apps = oauth_mod._load_json(oauth_mod.APPS_FILE) + entry = apps.get(service) or {} + if body.client_id: + entry["client_id"] = body.client_id.strip() + if body.client_secret: + entry["client_secret"] = body.client_secret.strip() + if body.scopes is not None: + entry["scopes"] = body.scopes + if body.auth_url: + entry["auth_url"] = body.auth_url.strip() + if body.token_url: + entry["token_url"] = body.token_url.strip() + apps[service] = entry + oauth_mod._save_json(oauth_mod.APPS_FILE, apps) + logger.info("OAuth-App %s gespeichert (client_id=%s, has_secret=%s)", + service, entry.get("client_id", ""), bool(entry.get("client_secret"))) + return {"ok": True, "service": service} + + +@app.delete("/oauth/apps/{service}") +async def oauth_apps_delete(service: str): + apps = oauth_mod._load_json(oauth_mod.APPS_FILE) + if service in apps: + apps.pop(service) + oauth_mod._save_json(oauth_mod.APPS_FILE, apps) + # Token auch wegwerfen + oauth_mod.revoke(service) + return {"ok": True} + + +@app.post("/oauth/{service}/revoke") +async def oauth_revoke_endpoint(service: str): + return {"ok": oauth_mod.revoke(service)} + + +class OAuthAuthorizeIn(BaseModel): + service: str + scopes: Optional[List[str]] = None + + +@app.post("/oauth/authorize") +async def oauth_authorize_endpoint(body: OAuthAuthorizeIn): + """Baut eine Authorize-URL fuer einen Service. Diagnostic kann das nutzen + um den Auth-Flow manuell anzustossen. ARIA selbst nutzt das Tool + `oauth_authorize` (in agent._dispatch_tool gemapped auf die gleiche Logik).""" + try: + return oauth_mod.build_authorize_url(body.service, scopes=body.scopes) + except RuntimeError as exc: + raise HTTPException(400, str(exc)) + + +@app.post("/internal/oauth-callback") +async def oauth_callback_internal(request: Request): + """Wird von aria-bridge gerufen wenn ein RVS oauth_callback ankommt. + Macht den state-Match + token-exchange und persistiert.""" + try: + body = await request.json() + except Exception as exc: + raise HTTPException(400, f"bad json: {exc}") + service = (body.get("service") or "").strip() + code = (body.get("code") or "").strip() + state = (body.get("state") or "").strip() + err = body.get("error") or None + err_desc = body.get("errorDescription") or None + if not service: + raise HTTPException(400, "service erforderlich") + result = oauth_mod.handle_callback(service, code, state, error=err, error_description=err_desc) + return result diff --git a/aria-brain/oauth.py b/aria-brain/oauth.py new file mode 100644 index 0000000..c19ed49 --- /dev/null +++ b/aria-brain/oauth.py @@ -0,0 +1,425 @@ +""" +OAuth-Manager fuer ARIA. Generischer OAuth2 Authorization-Code-Flow fuer +Spotify, Google, GitHub, Strava, Microsoft etc. + +Architektur: + - Brain haelt einen Pending-Store: state-String → pending Auth-Request + (mit timeout). Wenn ein Callback ankommt (via aria-bridge ueber RVS), + matched der state und der code wird gegen access_token getauscht. + - Token-Storage: /shared/config/oauth_tokens.json (pro Service ein Eintrag + mit access_token, refresh_token, expires_at, scope). + - Provider-Configs: /shared/config/oauth_apps.json — pro Service + {client_id, client_secret, auth_url, token_url, scopes, ...}. Wird + typischerweise via Diagnostic-UI gefuellt. + - Token-Refresh: automatisch wenn access_token abgelaufen oder < 60s + bis Ablauf bei get_token() Aufruf. + +OAuth-Callback-URL: https://{RVS_HOST}:{RVS_PORT_PUBLIC}/oauth/callback/{service} +RVS_PORT_PUBLIC ist nicht zwingend gleich RVS_PORT (port-mapping via TLS-Proxy). +ARIA setzt die URL beim Auth-Request automatisch — Stefan muss sie EINMAL pro +Service im Provider-Dashboard registrieren. +""" + +from __future__ import annotations + +import base64 +import json +import logging +import os +import secrets +import time +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +CONFIG_DIR = Path("/shared/config") +APPS_FILE = CONFIG_DIR / "oauth_apps.json" +TOKENS_FILE = CONFIG_DIR / "oauth_tokens.json" + +# Default-Provider-Configs. Werden von oauth_apps.json gemergt (User-Config +# uebersteuert). Stefan muss nur client_id + client_secret eintragen. +DEFAULT_PROVIDERS: dict[str, dict] = { + "spotify": { + "auth_url": "https://accounts.spotify.com/authorize", + "token_url": "https://accounts.spotify.com/api/token", + "scopes": ["user-read-playback-state", "user-modify-playback-state", + "user-read-currently-playing", "playlist-read-private", + "user-library-read"], + "client_auth": "basic", # client_id:client_secret als Basic-Auth-Header + }, + "google": { + "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "scopes": ["openid", "email", "profile"], + "client_auth": "body", # client_id+secret im Body + "extra_auth_params": {"access_type": "offline", "prompt": "consent"}, + }, + "github": { + "auth_url": "https://github.com/login/oauth/authorize", + "token_url": "https://github.com/login/oauth/access_token", + "scopes": ["read:user"], + "client_auth": "body", + "accept_header": "application/json", # GitHub returns form-urlencoded otherwise + }, + "strava": { + "auth_url": "https://www.strava.com/oauth/authorize", + "token_url": "https://www.strava.com/oauth/token", + "scopes": ["read", "activity:read_all"], + "client_auth": "body", + "extra_auth_params": {"approval_prompt": "auto"}, + }, + "microsoft": { + "auth_url": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + "token_url": "https://login.microsoftonline.com/common/oauth2/v2.0/token", + "scopes": ["User.Read", "offline_access"], + "client_auth": "body", + }, +} + +# Pending Auth-Requests: state → {service, scopes, redirect_uri, created_at} +_PENDING: dict[str, dict] = {} +PENDING_TTL_SEC = 600 # 10 min — laenger nicht sinnvoll, OAuth-Codes sind eh kurzlebig + + +# ── Helpers ───────────────────────────────────────────────── + + +def _callback_url(service: str) -> str: + """Baut die Redirect-URL die wir bei der Provider-Auth angeben. + Liest RVS_HOST / RVS_PORT_PUBLIC / RVS_TLS aus env.""" + host = os.environ.get("RVS_HOST", "").strip() + if not host: + raise RuntimeError("RVS_HOST nicht gesetzt — OAuth-Callbacks nicht moeglich") + port = os.environ.get("RVS_PORT_PUBLIC", os.environ.get("RVS_PORT", "443")).strip() + tls = os.environ.get("RVS_TLS", "true").strip().lower() != "false" + scheme = "https" if tls else "http" + # Default-Ports 443/80 nicht in URL anhaengen + if (tls and port == "443") or (not tls and port == "80"): + return f"{scheme}://{host}/oauth/callback/{service}" + return f"{scheme}://{host}:{port}/oauth/callback/{service}" + + +def _load_json(path: Path) -> dict: + try: + if path.exists(): + return json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + logger.warning("OAuth-Datei %s lesen fehlgeschlagen: %s", path, exc) + return {} + + +def _save_json(path: Path, data: dict) -> None: + try: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + tmp.replace(path) + # 600 — enthaelt Secrets + try: os.chmod(path, 0o600) + except OSError: pass + except Exception as exc: + logger.error("OAuth-Datei %s speichern fehlgeschlagen: %s", path, exc) + + +def _provider_config(service: str) -> dict: + """Mergt Default-Provider-Config mit User-Override aus oauth_apps.json.""" + defaults = DEFAULT_PROVIDERS.get(service, {}).copy() + apps = _load_json(APPS_FILE) + user = (apps.get(service) or {}).copy() + # Tiefes Merge nicht noetig — die kollidierenden Felder sind alle scalar/list. + merged = {**defaults, **user} + return merged + + +def _provider_credentials(service: str) -> tuple[str, str]: + """Liest client_id + client_secret aus oauth_apps.json. Wirft wenn nicht + konfiguriert — der OAuth-Flow kann ohne nicht starten.""" + apps = _load_json(APPS_FILE) + entry = apps.get(service) or {} + cid = (entry.get("client_id") or "").strip() + sec = (entry.get("client_secret") or "").strip() + if not cid or not sec: + raise RuntimeError( + f"OAuth-App '{service}' nicht konfiguriert. Bitte in Diagnostic > " + f"OAuth-Apps client_id + client_secret eintragen." + ) + return cid, sec + + +def _cleanup_pending() -> None: + """Entfernt abgelaufene Pending-Auths.""" + now = time.time() + for state, info in list(_PENDING.items()): + if now - info.get("created_at", 0) > PENDING_TTL_SEC: + _PENDING.pop(state, None) + + +# ── Authorize ─────────────────────────────────────────────── + + +def build_authorize_url(service: str, scopes: Optional[list[str]] = None, + extra_params: Optional[dict] = None) -> dict: + """Baut die Authorize-URL fuer einen Provider. Speichert den state + im Pending-Store. Returns {url, state, redirect_uri, service}. + + Wird vom Brain-Tool oauth_authorize gerufen. ARIA gibt die url an Stefan, + der oeffnet sie im Browser, autorisiert, Provider redirected zur + redirect_uri (= RVS), RVS broadcasted, bridge forwarded, brain matched + state → exchange. + """ + _cleanup_pending() + cfg = _provider_config(service) + if not cfg.get("auth_url") or not cfg.get("token_url"): + raise RuntimeError(f"Provider '{service}' hat keine auth_url/token_url. " + f"In oauth_apps.json eintragen oder einen der " + f"vordefinierten Services nutzen ({', '.join(DEFAULT_PROVIDERS)}).") + cid, _ = _provider_credentials(service) + redirect_uri = _callback_url(service) + state = secrets.token_urlsafe(32) + use_scopes = scopes if scopes else cfg.get("scopes") or [] + + params = { + "client_id": cid, + "response_type": "code", + "redirect_uri": redirect_uri, + "state": state, + } + if use_scopes: + params["scope"] = " ".join(use_scopes) + params.update(cfg.get("extra_auth_params") or {}) + if extra_params: + params.update(extra_params) + + url = cfg["auth_url"] + "?" + urllib.parse.urlencode(params) + + _PENDING[state] = { + "service": service, + "redirect_uri": redirect_uri, + "scopes": use_scopes, + "created_at": time.time(), + } + logger.info("[oauth] Authorize-URL fuer %s gebaut: state=%s redirect=%s", + service, state[:8] + "...", redirect_uri) + return {"url": url, "state": state, "redirect_uri": redirect_uri, "service": service} + + +# ── Token-Exchange ────────────────────────────────────────── + + +def _token_request(token_url: str, body_params: dict, cfg: dict, + client_id: str, client_secret: str) -> dict: + """POST an provider /token endpoint. Returns parsed JSON oder wirft.""" + data = urllib.parse.urlencode(body_params).encode("utf-8") + headers = {"Content-Type": "application/x-www-form-urlencoded"} + if cfg.get("accept_header"): + headers["Accept"] = cfg["accept_header"] + # Client-Auth: 'basic' (Header) oder 'body' (im Form-Body) + if cfg.get("client_auth") == "basic": + auth_str = f"{client_id}:{client_secret}" + b64 = base64.b64encode(auth_str.encode("utf-8")).decode("ascii") + headers["Authorization"] = f"Basic {b64}" + else: + # bereits im body_params drin (siehe Caller) + pass + req = urllib.request.Request(token_url, data=data, method="POST", headers=headers) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + raw = resp.read().decode("utf-8", "ignore") + try: + return json.loads(raw) + except json.JSONDecodeError: + # GitHub default ist form-urlencoded — accept_header sollte + # JSON anfordern, aber falls's doch mal kommt: + parsed = urllib.parse.parse_qs(raw) + return {k: v[0] if isinstance(v, list) and v else v for k, v in parsed.items()} + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", "ignore")[:500] + raise RuntimeError(f"Token-Request HTTP {e.code}: {body}") from e + + +def handle_callback(service: str, code: str, state: str, + error: Optional[str] = None, + error_description: Optional[str] = None) -> dict: + """Verarbeitet einen OAuth-Callback. Validiert state, tauscht code gegen + Token, speichert. Returns {ok, service, message, ...}. + + Wird von /internal/oauth-callback (HTTP, von aria-bridge) gerufen. + """ + _cleanup_pending() + + if error: + # Provider hat User-Abbruch oder Fehler gemeldet + _PENDING.pop(state, None) if state else None + logger.warning("[oauth] Provider-Error %s/%s: %s — %s", + service, state[:8] + "..." if state else "?", error, error_description) + return {"ok": False, "service": service, "error": error, + "errorDescription": error_description} + + pending = _PENDING.pop(state, None) + if not pending: + logger.warning("[oauth] Unknown state %s fuer %s — abgelaufen oder CSRF?", state[:8] + "...", service) + return {"ok": False, "service": service, + "error": "invalid_state", + "errorDescription": "Unbekannter oder abgelaufener state (Auth-Anfrage muss erst per oauth_authorize neu gestartet werden)."} + if pending.get("service") != service: + logger.warning("[oauth] state-Service-Mismatch: pending=%s vs callback=%s", + pending.get("service"), service) + return {"ok": False, "service": service, + "error": "service_mismatch", + "errorDescription": "state gehoert zu einem anderen Service."} + + if not code: + return {"ok": False, "service": service, "error": "no_code"} + + cfg = _provider_config(service) + try: + client_id, client_secret = _provider_credentials(service) + except RuntimeError as exc: + return {"ok": False, "service": service, "error": "no_credentials", + "errorDescription": str(exc)} + + body = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": pending["redirect_uri"], + } + if cfg.get("client_auth") != "basic": + body["client_id"] = client_id + body["client_secret"] = client_secret + + try: + token_data = _token_request(cfg["token_url"], body, cfg, client_id, client_secret) + except Exception as exc: + logger.exception("[oauth] Token-Exchange fehlgeschlagen fuer %s", service) + return {"ok": False, "service": service, "error": "exchange_failed", + "errorDescription": str(exc)[:200]} + + access = token_data.get("access_token") + if not access: + return {"ok": False, "service": service, "error": "no_access_token", + "errorDescription": str(token_data)[:200]} + + expires_in = int(token_data.get("expires_in") or 3600) + refresh = token_data.get("refresh_token") or "" + scope = token_data.get("scope") or " ".join(pending.get("scopes") or []) + token_type = token_data.get("token_type") or "Bearer" + + record = { + "service": service, + "access_token": access, + "refresh_token": refresh, + "token_type": token_type, + "scope": scope, + "expires_at": int(time.time()) + expires_in, + "obtained_at": int(time.time()), + } + _persist_token(service, record) + logger.info("[oauth] %s authentifiziert — expires in %ds, refresh=%s", + service, expires_in, "ja" if refresh else "nein") + return {"ok": True, "service": service, "expiresIn": expires_in, + "hasRefresh": bool(refresh), "scope": scope} + + +# ── Token-Storage / Refresh / Revoke ───────────────────────── + + +def _persist_token(service: str, record: dict) -> None: + tokens = _load_json(TOKENS_FILE) + tokens[service] = record + _save_json(TOKENS_FILE, tokens) + + +def _load_token(service: str) -> Optional[dict]: + return _load_json(TOKENS_FILE).get(service) + + +def get_token(service: str, refresh_threshold_sec: int = 60) -> dict: + """Holt das aktuelle access_token fuer einen Service. Refresht automatisch + wenn weniger als refresh_threshold_sec Restzeit. Returns das ganze + record-dict — Caller nimmt sich access_token raus. + + Wirft wenn nicht authentifiziert oder Refresh fehlschlaegt — Tool-Aufrufer + soll dann oauth_authorize anbieten.""" + record = _load_token(service) + if not record: + raise RuntimeError(f"Kein Token fuer '{service}' gespeichert. Erst per " + f"oauth_authorize authentifizieren.") + exp = int(record.get("expires_at") or 0) + remaining = exp - int(time.time()) + if remaining > refresh_threshold_sec: + return record + # Refresh noetig + refresh_tok = (record.get("refresh_token") or "").strip() + if not refresh_tok: + raise RuntimeError(f"Token fuer '{service}' abgelaufen und kein refresh_token " + f"vorhanden — bitte neu autorisieren mit oauth_authorize.") + cfg = _provider_config(service) + client_id, client_secret = _provider_credentials(service) + body = { + "grant_type": "refresh_token", + "refresh_token": refresh_tok, + } + if cfg.get("client_auth") != "basic": + body["client_id"] = client_id + body["client_secret"] = client_secret + try: + new_data = _token_request(cfg["token_url"], body, cfg, client_id, client_secret) + except Exception as exc: + raise RuntimeError(f"Token-Refresh fuer '{service}' fehlgeschlagen: {exc}") from exc + + new_access = new_data.get("access_token") + if not new_access: + raise RuntimeError(f"Refresh-Antwort ohne access_token: {new_data}") + expires_in = int(new_data.get("expires_in") or 3600) + # refresh_token kann (manche Provider) bei jedem Refresh rotieren + new_refresh = (new_data.get("refresh_token") or refresh_tok).strip() + record.update({ + "access_token": new_access, + "refresh_token": new_refresh, + "expires_at": int(time.time()) + expires_in, + "obtained_at": int(time.time()), + }) + if new_data.get("scope"): + record["scope"] = new_data["scope"] + _persist_token(service, record) + logger.info("[oauth] %s Token refreshed — neue Restzeit %ds", service, expires_in) + return record + + +def revoke(service: str) -> bool: + """Entfernt das Token aus dem Storage (Best-Effort, kein Provider-Revoke-Call).""" + tokens = _load_json(TOKENS_FILE) + if service not in tokens: + return False + tokens.pop(service, None) + _save_json(TOKENS_FILE, tokens) + logger.info("[oauth] %s Token geloescht (lokal).", service) + return True + + +def list_services() -> list[dict]: + """Diagnostik: zeigt fuer jeden konfigurierten Service ob Token da ist + + Ablaufzeit. Wird von Diagnostic genutzt.""" + apps = _load_json(APPS_FILE) + tokens = _load_json(TOKENS_FILE) + out = [] + services = set(apps.keys()) | set(tokens.keys()) | set(DEFAULT_PROVIDERS.keys()) + now = int(time.time()) + for s in sorted(services): + app = apps.get(s) or {} + tok = tokens.get(s) or {} + configured = bool(app.get("client_id") and app.get("client_secret")) + out.append({ + "service": s, + "configured": configured, + "authenticated": bool(tok.get("access_token")), + "expiresAt": tok.get("expires_at"), + "expiresInSec": (tok.get("expires_at", 0) - now) if tok.get("expires_at") else None, + "hasRefresh": bool(tok.get("refresh_token")), + "scope": tok.get("scope", ""), + "isDefault": s in DEFAULT_PROVIDERS, + }) + return out diff --git a/aria-brain/prompts.py b/aria-brain/prompts.py index 5406cd3..7303345 100644 --- a/aria-brain/prompts.py +++ b/aria-brain/prompts.py @@ -240,6 +240,63 @@ def build_triggers_section( return "\n".join(lines) +def build_oauth_section(oauth_services: list[dict] | None, + callback_host: str = "", + callback_port: str = "443", + callback_tls: bool = True) -> str: + """Block fuer den System-Prompt: zeigt ARIA welche externen Services + via OAuth verfuegbar sind, welche schon authentifiziert sind, und welche + Callback-URL beim Provider eingetragen werden muss.""" + scheme = "https" if callback_tls else "http" + if callback_host: + if (callback_tls and callback_port == "443") or (not callback_tls and callback_port == "80"): + base = f"{scheme}://{callback_host}/oauth/callback/" + else: + base = f"{scheme}://{callback_host}:{callback_port}/oauth/callback/" + else: + base = "" + + lines = [ + "## OAuth externe Services", + "", + "Du kannst Spotify, Google, GitHub, Strava, Microsoft (und custom-konfigurierte) " + "Services via OAuth2 ansprechen. Workflow ist IMMER:", + "1. `oauth_get_token(service)` versuchen — Token vorhanden? → benutzen.", + "2. Wirft 'Kein Token gespeichert'? → `oauth_authorize(service)` aufrufen, URL an Stefan, warten, dann nochmal `oauth_get_token`.", + "", + f"**Callback-URL (fest, NICHT raten):** `{base}`", + "Diese URL muss Stefan EINMAL pro Service im Provider-Dashboard als gueltige " + "Redirect-URI eintragen. Sie ist hardcoded an die RVS-Infrastruktur gebunden " + "und aendert sich nicht — auch nicht wenn Du als Brain neu aufgesetzt wirst.", + "", + "**NICHT** versuchen client_id / client_secret selbst zu generieren oder zu " + "raten. Wenn nicht eingetragen → Stefan sagen er soll es in Diagnostic > " + "OAuth-Apps machen.", + ] + if oauth_services: + lines.append("") + lines.append("**Aktuelle Service-Status:**") + for s in oauth_services: + name = s.get("service", "?") + configured = s.get("configured", False) + auth = s.get("authenticated", False) + remain = s.get("expiresInSec") + parts = [] + if not configured: + parts.append("Credentials fehlen") + elif not auth: + parts.append("nicht authentifiziert") + else: + if remain is None: + parts.append("authentifiziert") + elif remain > 0: + parts.append(f"authentifiziert, Token gueltig noch {remain}s") + else: + parts.append("Token abgelaufen (wird automatisch refresht)") + lines.append(f"- `{name}`: {' / '.join(parts)}") + return "\n".join(lines) + + def build_flux_section(flux_config: dict) -> str: """Block fuer den System-Prompt: aktuelle Diagnostic-Settings fuer Bildgenerierung (Default-Modell + User-konfigurierbare Keywords). @@ -279,8 +336,12 @@ def build_system_prompt( condition_vars: List[dict] | None = None, condition_funcs: List[dict] | None = None, flux_config: dict | None = None, + oauth_services: list[dict] | None = None, + oauth_callback_host: str = "", + oauth_callback_port: str = "443", + oauth_callback_tls: bool = True, ) -> str: - """Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX.""" + """Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth.""" parts = [build_hot_memory_section(pinned), "", build_time_section()] if skills: parts.append("") @@ -291,6 +352,15 @@ def build_system_prompt( if flux_config is not None: parts.append("") parts.append(build_flux_section(flux_config)) + # OAuth-Block bauen wir nur wenn RVS_HOST konfiguriert ist (sonst hat + # die Callback-URL keinen Sinn). Sonst lassen wir den Block weg statt + # ARIA eine ""-URL zu zeigen. + if oauth_callback_host: + parts.append("") + parts.append(build_oauth_section(oauth_services, + callback_host=oauth_callback_host, + callback_port=oauth_callback_port, + callback_tls=oauth_callback_tls)) if cold: parts.append("") parts.append(build_cold_memory_section(cold)) diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index 68b4edd..56f9f22 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -2338,6 +2338,13 @@ class ARIABridge: future.set_result(text) return + elif msg_type == "oauth_callback": + # RVS hat einen OAuth-Provider-Callback empfangen (z.B. Spotify + # nach User-Authorize) und broadcastet ihn. Wir forwarden an Brain, + # das den state-Match macht + code gegen access_token tauscht. + asyncio.create_task(self._forward_oauth_callback(payload)) + return + elif msg_type == "flux_response": # Antwort der flux-bridge auf unseren flux_request. Erste Nachricht # mit state='rendering' ist nur Progress-Ping — die echte Antwort @@ -2715,6 +2722,32 @@ class ARIABridge: status = await asyncio.get_event_loop().run_in_executor(None, _do_request) logger.info("[cancel] Diagnostic /api/cancel: %s", status) + async def _forward_oauth_callback(self, payload: dict) -> None: + """Forwarded den OAuth-Callback (kommt via RVS vom RVS-HTTP-Handler) + per HTTP an Brain. Brain hat den pending-state + macht den token- + exchange. Fire-and-forget — bei Failure loggen wir nur.""" + service = (payload.get("service") or "").strip() + if not service: + logger.warning("[oauth] callback ohne service, ignoriert") + return + brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080") + url = f"{brain_url}/internal/oauth-callback" + + def _do_request(): + try: + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + url, data=data, method="POST", + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return resp.status, resp.read().decode("utf-8", "ignore")[:200] + except Exception as e: + return f"error: {e}", "" + + status, body = await asyncio.get_event_loop().run_in_executor(None, _do_request) + logger.info("[oauth] Forward %s → brain: %s %s", service, status, body) + async def _cancel_proxy_subprocesses(self) -> None: """Not-Aus: ruft den proxy-internen /cancel-all Side-Channel auf (siehe proxy-patches/routes.js). Killt alle aktiven Claude-Code- diff --git a/diagnostic/index.html b/diagnostic/index.html index 201558d..69b401f 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -680,6 +680,34 @@ + +
+

OAuth-Apps (Spotify, Google, GitHub, Strava, Microsoft, ...)

+
+ Trag pro Service `client_id` + `client_secret` ein (aus dem Developer-Dashboard + des Providers). RVS stellt die Callback-URL bereit — die musst Du EINMAL pro + Service im Provider-Dashboard als gueltige Redirect-URI eintragen. + Danach kann ARIA per `oauth_authorize`-Tool eine Auth-URL bauen; Stefan klickt, + autorisiert, ARIA bekommt den Token automatisch. +
+
+ Lade Callback-URL... +
+
+
+
Lade Services...
+
+
+ +
+ client_secret wird verschlüsselt persistiert (file-mode 0600). Nicht in git, nicht im Repo. +
+
+
+
+

Whisper (Spracherkennung)

@@ -3142,11 +3170,12 @@ const oc = b.getAttribute('onclick') || ''; if (oc.includes(`'${tab}'`)) b.classList.add('active'); }); - // Einstellungen: Config + QR laden + // Einstellungen: Config + QR + OAuth-Apps laden if (tab === 'settings') { send({ action: 'get_voice_config' }); loadRuntimeConfig(); loadOnboardingQR(); + loadOAuthServices(); } else if (tab === 'brain') { loadBrainStatus(); loadBrainMemoryList(); @@ -3804,6 +3833,159 @@ } } + // ── OAuth-Apps UI ───────────────────────────────────────── + // + // Stefan traegt pro Service client_id + client_secret ein. RVS hat eine + // feste Callback-URL die Stefan im Provider-Dashboard registrieren muss. + // Status pro Service: configured / authenticated / expires_in. + function _ofmt(s) { + return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + } + function _oExpiryText(secs) { + if (secs == null) return ''; + if (secs <= 0) return 'abgelaufen (refresh beim naechsten Call)'; + if (secs < 60) return `${secs}s`; + if (secs < 3600) return `${Math.round(secs/60)} min`; + if (secs < 86400) return `${Math.round(secs/3600)} h`; + return `${Math.round(secs/86400)} Tage`; + } + async function loadOAuthServices() { + const listEl = document.getElementById('oauth-services-list'); + const hintEl = document.getElementById('oauth-callback-hint'); + if (!listEl) return; + listEl.innerHTML = '
Lade Services...
'; + try { + const [svcRes, appsRes, rcRes] = await Promise.all([ + fetch('/api/brain/oauth/services'), + fetch('/api/brain/oauth/apps'), + fetch('/api/runtime-config'), + ]); + const svc = await svcRes.json(); + const apps = await appsRes.json(); + const rc = await rcRes.json(); + const host = rc.RVS_HOST || ''; + const port = rc.RVS_PORT || '443'; + const tls = String(rc.RVS_TLS) !== 'false'; + const scheme = tls ? 'https' : 'http'; + const portPart = ((tls && port === '443') || (!tls && port === '80')) ? '' : ':' + port; + const cbBase = host ? `${scheme}://${host}${portPart}/oauth/callback/` : ''; + if (hintEl) { + hintEl.innerHTML = host + ? `Callback-URL pro Service (im Provider-Dashboard eintragen): ${_ofmt(cbBase)}<service>` + : `⚠ RVS_HOST nicht gesetzt — OAuth-Callbacks koennen nicht funktionieren. Setze RVS_HOST in der .env auf den oeffentlich erreichbaren Hostname.`; + } + const services = svc.services || []; + const appDetails = apps.apps || {}; + const knownDefaults = apps.defaults || []; + // Zusammenfuehren: jeder Service der entweder in services oder Defaults vorkommt + const allServices = Array.from(new Set([ + ...services.map(s => s.service), + ...knownDefaults, + ])).sort(); + listEl.innerHTML = ''; + for (const svcName of allServices) { + const s = services.find(x => x.service === svcName) || { service: svcName, configured: false, authenticated: false }; + const app = appDetails[svcName] || {}; + const card = document.createElement('div'); + const statusColor = s.authenticated ? '#34C759' : (s.configured ? '#FFD60A' : '#666680'); + const statusText = s.authenticated + ? `✅ verbunden${s.expiresInSec != null ? ` · Token noch ${_oExpiryText(s.expiresInSec)} gueltig` : ''}${s.hasRefresh ? ' · refresh ok' : ' · KEIN refresh_token'}` + : (s.configured ? '🟡 konfiguriert, nicht autorisiert' : '⚫ noch nicht konfiguriert'); + const isCustom = !knownDefaults.includes(svcName); + const customMark = isCustom ? ' (custom)' : ''; + card.style.cssText = 'background:#0D0D1A;border:1px solid #2A2A3E;border-radius:6px;padding:10px 12px;'; + card.innerHTML = ` +
+ ${_ofmt(svcName)}${customMark} + ${statusText} + ${s.authenticated ? `` : ''} +
+
+ + + +
+ + +
+
+ + +
+
+ `; + listEl.appendChild(card); + } + if (allServices.length === 0) { + listEl.innerHTML = '
Keine Services bekannt.
'; + } + } catch (e) { + listEl.innerHTML = `
Fehler beim Laden: ${_ofmt(e.message)}
`; + } + } + async function saveOAuthApp(service) { + const cid = document.getElementById('oauth-cid-' + service)?.value?.trim() || ''; + const sec = document.getElementById('oauth-sec-' + service)?.value || ''; + if (!cid) { + alert('client_id darf nicht leer sein.'); + return; + } + try { + const r = await fetch('/api/brain/oauth/apps', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ service, client_id: cid, client_secret: sec }), + }); + if (!r.ok) { + const t = await r.text(); + alert('Speichern fehlgeschlagen: ' + t); + return; + } + loadOAuthServices(); + } catch (e) { + alert('Speichern fehlgeschlagen: ' + e.message); + } + } + async function authorizeOAuth(service) { + try { + const r = await fetch('/api/brain/oauth/authorize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ service }), + }); + if (!r.ok) { + const t = await r.text(); + alert('Authorize fehlgeschlagen: ' + t); + return; + } + const data = await r.json(); + // Authorize-URL in neuem Tab oeffnen — Stefan kann dann beim Provider zustimmen + window.open(data.url, '_blank', 'noopener,noreferrer'); + // Status nach ein paar Sekunden refreshen — Provider redirect → RVS → Brain + setTimeout(loadOAuthServices, 8000); + } catch (e) { + alert('Authorize fehlgeschlagen: ' + e.message); + } + } + async function revokeOAuth(service) { + if (!confirm(`Token fuer ${service} wirklich loeschen? ARIA muss danach neu autorisiert werden.`)) return; + try { + const r = await fetch('/api/brain/oauth/' + service + '/revoke', { method: 'POST' }); + if (!r.ok) { + const t = await r.text(); + alert('Revoke fehlgeschlagen: ' + t); + return; + } + loadOAuthServices(); + } catch (e) { + alert('Revoke fehlgeschlagen: ' + e.message); + } + } + async function distillNow() { if (!confirm('Destillat manuell auslösen?\n\nDie ältesten Turns werden zu fact-Memories verdichtet — kostet einen Claude-Call.')) return; try { diff --git a/docker-compose.yml b/docker-compose.yml index 51b879b..7b514ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,6 +75,14 @@ services: # Connect/Write/Pool sind klein (10/30/10s) damit toter Proxy # schnell erkannt wird (siehe proxy_client.py). - PROXY_TIMEOUT_SEC=${PROXY_TIMEOUT_SEC:-86400} + # OAuth-Callback-URL Bestandteile. Brain baut daraus + # https://{RVS_HOST}:{RVS_PORT_PUBLIC}/oauth/callback/{service} als + # redirect_uri fuer Provider wie Spotify/Google/etc. RVS_PORT_PUBLIC + # ist der nach aussen exposed Port (= TLS-Port hinter Caddy/Nginx), + # nicht der interne RVS-Container-Port. + - RVS_HOST=${RVS_HOST:-} + - RVS_PORT_PUBLIC=${RVS_PORT_PUBLIC:-${RVS_PORT:-443}} + - RVS_TLS=${RVS_TLS:-true} volumes: - ./aria-data/brain/data:/data # Memory-Cache + Skills + Models (bind-mount fuer Export) - ./aria-data/brain-import:/import:ro # Quell-MDs fuer den initialen Memory-Import (read-only) diff --git a/issue.md b/issue.md index d25ae28..c0610b5 100644 --- a/issue.md +++ b/issue.md @@ -377,6 +377,20 @@ Skills mit Tool-Use. - [x] **About-Text rendete `—` literal**: JSX-Text-Knoten interpretieren keine JS-String-Escapes — `—` blieb als Backslash-u-Sequenz sichtbar. Fix: `{'—'}` als JS-Expression-Block - [x] **GPS-Heartbeat fuer stationaere User**: `watchPosition` mit `distanceFilter: 30` sendet keine Updates ohne 30 m Bewegung. Stefan stationaer → nach initialer Position keine weiteren Updates → Brain verwirft Position nach `NEAR_MAX_AGE_SEC=300` als veraltet → `near()`-Watcher feuern nie. Fix: zusaetzlich zum watchPosition laeuft ein `setInterval(60s)` Heartbeat der die zuletzt empfangene Position erneut sendet. Kein extra GPS-Wakeup, akkufreundlich — und Brain-State bleibt frisch auch ohne Bewegung +### Brain-Timeouts + Subprocess-Cleanup + +- [x] **Brain-Timeout nach exakt 20min trotz aktiver ARIA**: `httpx.Client` im `proxy_client.py` hatte einen 1200s-Read-Timeout — der gleiche Wert den wir Tage zuvor am Proxy auf 24h hochgezogen hatten, aber im Brain uebersehen. Bei langen Pentests timed Brain raus obwohl der Proxy-Subprocess noch fleissig Events emittierte. Fix: `PROXY_TIMEOUT_SEC=86400` Env in der Compose, plus split-Timeouts in `httpx.Timeout(connect=10, read=86400, write=30, pool=10)` — toter Proxy wird in 10s erkannt, lange ARIA-Sessions duerfen 24h laufen +- [x] **Verwaiste Claude-Subprocesses nach Brain-Disconnect**: `handleNonStreamingResponse` in `routes.js` hatte keinen `res.on("close")` (nur der Streaming-Branch). Wenn Brain die Verbindung gekappt hat (z.B. nach Timeout), lief der Claude-Subprocess weiter ohne dass noch jemand lauschte — Ressourcen-Leak. Fix: `res.on("close")` mit `isComplete`-Flag, Subprocess wird sofort gekillt bei Client-Disconnect +- [x] **Conversation-Inkonsistenz bei Brain-Exception**: `agent.chat()` fuegte den User-Turn ein BEVOR der Proxy-Call lief — bei Exception blieb der User-Turn ohne Assistant-Pair stehen, naechster Brain-Call sah `user → user` als letzte zwei Turns und konnte mit Tool-Calls fehlschlagen. Fix: try/except um den Tool-Loop, bei Exception wird ein Error-Marker (`[Fehler: ...]`) als Assistant-Turn geschrieben — Conversation bleibt konsistent + +### OAuth-Pipeline (Spotify / Google / GitHub / Strava / Microsoft) + +- [x] **Externe OAuth2-Provider per RVS-Callback**: ARIA brauchte Tokens fuer Spotify-Skill — bisher `redirect_uri=http://localhost:...` was vom Handy aus nicht erreichbar war, Stefan musste den Code manuell aus der URL kopieren (fragil, OAuth-Codes sind ~10min gueltig). Loesung: RVS-Server hat jetzt einen HTTP-Listener (selber Port wie WebSocket, hybrid via `http.createServer` + `wss.handleUpgrade`). Provider redirected an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet `oauth_callback`-Message → aria-bridge forwarded an Brain → Brain matched `state` (CSRF-Schutz), tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json` (file-mode 0600). Token-Refresh laeuft automatisch wenn <60s Restzeit +- [x] **Brain-Tools fuer ARIA**: `oauth_authorize(service, scopes?)` baut Auth-URL + speichert pending state, `oauth_get_token(service)` liefert aktuelles access_token (refresh wenn noetig), `oauth_revoke(service)` loescht. Skills nutzen diese statt selber Auth-Flow zu machen +- [x] **Generische Provider-Configs**: `DEFAULT_PROVIDERS` in `oauth.py` deckt Spotify, Google, GitHub, Strava, Microsoft mit ihren Quirks ab (Basic-Auth vs Body-Auth, Accept-Header fuer GitHub, `access_type=offline` fuer Google, etc.). Custom-Provider via `oauth_apps.json` moeglich +- [x] **Diagnostic-UI**: Einstellungen → OAuth-Apps. Pro Service Karte mit Status (verbunden/konfiguriert/leer), `client_id` + `client_secret` (Passwort-Toggle), Speichern + Autorisieren-Buttons. Autorisieren oeffnet Provider-Auth in neuem Tab; nach 8s Auto-Refresh +- [x] **Schoene Browser-Antwort vom RVS**: nach Callback bekommt der User eine Dark-Mode-HTML-Seite (✅ "OAuth erfolgreich, du kannst Tab schliessen — ARIA hat den Zugang erhalten") mit 4s Auto-Close — kein nackter JSON-Response + ## Offen ### App Features @@ -389,3 +403,4 @@ Skills mit Tool-Use. - [ ] Erste Skills bauen lassen (yt-dlp, pdf-extract, image-resize, etc.) — durch normale Anfragen, ARIA legt sie selbst an - [ ] Heartbeat (periodische Selbst-Checks) - [ ] Lokales LLM als Waechter (Triage vor Claude-Call) +- [ ] **Subprocess-Resume nach Kill/Timeout (Variante A — halb-automatisch)**: bei Idle-Timeout oder Brain-Disconnect ist die ARIA-Session weg (in-memory state des Claude-Code-Subprozesses, alle Tool-Outputs, Files-Reads). Stefan muss heute manuell *"weitermachen"* sagen, ARIA improvisiert aus dem Conversation-Window was sie noch weiss. Variante A: agent_stream-Events zusaetzlich in einer JSONL persistieren, beim naechsten Brain-Call die letzten N Events als „Resume-Context" in den System-Prompt einbauen — ARIA weiss dann konkret welche Tool-Calls zuletzt liefen und kann sauber fortsetzen. Aufwand ~1-2h. Nur angehen wenn die 24h-Timeouts (Commit 0887674) wirklich nochmal triggern diff --git a/rvs/server.js b/rvs/server.js index d459f87..9064570 100644 --- a/rvs/server.js +++ b/rvs/server.js @@ -1,6 +1,7 @@ "use strict"; const { WebSocketServer } = require("ws"); +const http = require("http"); const fs = require("fs"); const path = require("path"); @@ -41,6 +42,7 @@ const ALLOWED_TYPES = new Set([ "config_request", "flux_request", "flux_response", "agent_stream", + "oauth_callback", ]); // Token-Raum: token -> { clients: Set } @@ -71,8 +73,17 @@ function cleanupRooms() { } } -// ── WebSocket-Server starten ──────────────────────────────────────── - +// ── HTTP + WebSocket Server (hybrid) ──────────────────────────────── +// +// Der gleiche Port handelt jetzt sowohl WebSocket-Upgrades (App, Bridges, +// Diagnostic) als auch normale HTTP-Requests (OAuth-Callbacks von Spotify, +// Google etc.). TLS-Termination passiert wie bisher vor dem RVS-Container +// (Caddy/Nginx); RVS selber bleibt plain HTTP. Wichtig fuer OAuth: aus +// Provider-Sicht ist die Callback-URL `https://{RVS_HOST}:{PORT_oeffentlich} +// /oauth/callback/{service}` — RVS schnappt den ?code=..&state=.., broadcastet +// als WS-Message `oauth_callback` und antwortet dem Browser mit einer +// schoenen "Tab schliessen"-Seite. +// // maxPayload 100MB: TTS-Streaming + Voice-Upload (WAV als base64) + // audio_pcm Chunks koennen die ws-Library Default 1MB ueberschreiten. // Plus: file_request/file_response fuer Re-Download von Anhaengen. @@ -80,15 +91,127 @@ function cleanupRooms() { // (Code 1009 message too big, Bridge crashed im cleanup). 100 MB // deckt bis ~70 MB binaer ab; groessere Files werden Bridge-seitig // abgewiesen (siehe file_request-Handler) bevor die WS abreisst. -const wss = new WebSocketServer({ port: PORT, maxPayload: 100 * 1024 * 1024 }); +const httpServer = http.createServer(handleHttpRequest); +const wss = new WebSocketServer({ noServer: true, maxPayload: 100 * 1024 * 1024 }); -wss.on("listening", () => { - log(`RVS läuft auf Port ${PORT} | Max Sessions: ${MAX_SESSIONS}`); +// HTTP-Upgrade-Pfad → an WebSocket-Server reichen +httpServer.on("upgrade", (req, socket, head) => { + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); + }); +}); + +httpServer.listen(PORT, () => { + log(`RVS läuft auf Port ${PORT} (HTTP + WS) | Max Sessions: ${MAX_SESSIONS}`); // Beim Start pruefen ob eine APK da ist const apkInfo = getLatestAPK(); if (apkInfo) log(`APK bereit: v${apkInfo.version} (${(fs.statSync(apkInfo.path).size / 1024 / 1024).toFixed(1)}MB)`); }); +// ── HTTP Route-Handler ────────────────────────────────────────────── + +function handleHttpRequest(req, res) { + try { + const url = new URL(req.url, `http://${req.headers.host || "localhost"}`); + const pathname = url.pathname; + + // OAuth-Callback: GET /oauth/callback/{service}?code=...&state=...&error=... + // Pattern fuer Spotify, Google, Strava, GitHub, ... — alle OAuth2 Auth-Code-Flow. + // Wir broadcasten an alle Raeume (App ist nicht im selben Raum wie Bridge, + // aber Bridge schon — sie picks-up und forwardet ans Brain). + const oauthMatch = pathname.match(/^\/oauth\/callback\/([a-zA-Z0-9_-]+)\/?$/); + if (req.method === "GET" && oauthMatch) { + const service = oauthMatch[1]; + const code = url.searchParams.get("code") || ""; + const state = url.searchParams.get("state") || ""; + const err = url.searchParams.get("error") || ""; + const errDesc = url.searchParams.get("error_description") || ""; + + log(`OAuth-Callback: service=${service} code=${code.slice(0, 8)}... state=${state.slice(0, 8)}... err=${err}`); + + const payload = { service, code, state }; + if (err) { + payload.error = err; + if (errDesc) payload.errorDescription = errDesc; + } + + // An alle Clients in allen Raeumen broadcasten — Bridge picks-up. + const msg = JSON.stringify({ + type: "oauth_callback", + payload, + timestamp: Date.now(), + }); + let receivers = 0; + for (const [, room] of rooms) { + for (const client of room.clients) { + if (client.readyState === 1) { + try { client.send(msg); receivers++; } catch (_) {} + } + } + } + log(`OAuth-Callback gebroadcastet an ${receivers} Client(s)`); + + // Browser-Antwort: schoene HTML-Seite (auch bei Error) + const ok = !err; + const title = ok ? "OAuth erfolgreich" : "OAuth fehlgeschlagen"; + const bodyColor = ok ? "#34C759" : "#FF3B30"; + const icon = ok ? "✅" : "❌"; + const subtitle = ok + ? "Du kannst dieses Tab schliessen — ARIA hat den Zugang erhalten." + : `Fehler: ${escapeHtml(err)} ${errDesc ? "— " + escapeHtml(errDesc) : ""}`; + const html = ` + + + +${title} — ${escapeHtml(service)} + +
+
${icon}
+
${title}
+
${escapeHtml(service)}
+
${subtitle}
+
Du kannst zur ARIA-App zurueckkehren.
+
+ +`; + res.writeHead(ok ? 200 : 400, { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }); + res.end(html); + return; + } + + // Health-Endpoint + if (req.method === "GET" && pathname === "/health") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, rooms: rooms.size })); + return; + } + + // Default: 404 + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found\n"); + } catch (e) { + log(`HTTP handler error: ${e.message}`); + try { res.writeHead(500).end("Internal Server Error"); } catch (_) {} + } +} + +function escapeHtml(s) { + return String(s || "").replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} + wss.on("connection", (ws, req) => { // Token aus URL-Query lesen: ws://host:port/?token=abc123 const url = new URL(req.url, `http://${req.headers.host}`);