feat(oauth): generische OAuth2-Pipeline ueber RVS-Callback (Spotify/Google/GitHub/Strava/MS)
Bisher musste Stefan bei OAuth-Flows manuell den Auth-Code aus der Browser-URL kopieren (redirect_uri war localhost). Jetzt: RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket, Provider redirecten nach Auth zu https://{RVS_HOST}/oauth/callback/{service}, RVS broadcastet, aria-bridge forwarded, Brain matched state + tauscht code gegen Token. Token-Refresh laeuft automatisch. - rvs/server.js: hybrid http.createServer + WebSocketServer{noServer}. Route GET /oauth/callback/{service}, broadcast oauth_callback an alle Raeume, schoene Dark-Mode-HTML-Antwort an den Browser (Auto-Close 4s). - bridge/aria_bridge.py: empfaengt oauth_callback, POSTet an Brain /internal/oauth-callback. - aria-brain/oauth.py: neuer Manager. Pending-Store mit state+TTL, Token-Exchange (Basic-Auth oder Body je nach Provider), persistente Speicherung in /shared/config/oauth_tokens.json (mode 0600), Token-Refresh wenn <60s Restzeit. Vordefinierte Configs fuer Spotify, Google, GitHub, Strava, Microsoft. - aria-brain/agent.py: META-Tools oauth_authorize / oauth_get_token / oauth_revoke. - aria-brain/prompts.py: System-Prompt-Block zeigt ARIA die feste Callback-URL als Quelle der Wahrheit + aktuelle Service-States. - aria-brain/main.py: HTTP-Endpoints /oauth/services, /oauth/apps, /oauth/authorize, /oauth/{service}/revoke, /internal/oauth-callback. - diagnostic: neue Section "OAuth-Apps". Pro Service Karte mit Status, client_id + client_secret (Passwort-Toggle), Speichern + Autorisieren- Buttons. Authorize oeffnet Provider-Auth in neuem Tab. - docker-compose.yml: brain-env um RVS_HOST + RVS_PORT_PUBLIC + RVS_TLS ergaenzt (Brain braucht die Werte zum Bau der Callback-URL). - .env.example: RVS_PORT_PUBLIC + Brain-Timeout-Vars (PROXY_TIMEOUT_SEC + Connect/Write/Pool) dokumentiert. - README.md: OAuth-Pipeline + ARIA-Live-Mirror in Diagnostic-Section, OAuth-Apps in Einstellungen-Tab erwaehnt. - issue.md: OAuth-Pipeline + Brain-Timeout-Fix als erledigt dokumentiert. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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!).
|
||||
|
||||
@@ -332,7 +332,7 @@ Erreichbar unter `http://<VM-IP>: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://<VM-IP>: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
|
||||
|
||||
---
|
||||
|
||||
|
||||
+143
-1
@@ -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 <token>`.\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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+71
-1
@@ -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/<SERVICE>"
|
||||
else:
|
||||
base = f"{scheme}://{callback_host}:{callback_port}/oauth/callback/<SERVICE>"
|
||||
else:
|
||||
base = "<nicht konfiguriert — RVS_HOST in brain env fehlt>"
|
||||
|
||||
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 "<nicht konfiguriert>"-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))
|
||||
|
||||
@@ -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-
|
||||
|
||||
+183
-1
@@ -680,6 +680,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OAuth Apps -->
|
||||
<div class="settings-section">
|
||||
<h2>OAuth-Apps (Spotify, Google, GitHub, Strava, Microsoft, ...)</h2>
|
||||
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
|
||||
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.
|
||||
</div>
|
||||
<div style="font-size:11px;color:#666680;margin-bottom:8px;" id="oauth-callback-hint">
|
||||
Lade Callback-URL...
|
||||
</div>
|
||||
<div class="card" style="max-width:780px;">
|
||||
<div id="oauth-services-list" style="display:flex;flex-direction:column;gap:8px;">
|
||||
<div style="color:#555570;font-style:italic;">Lade Services...</div>
|
||||
</div>
|
||||
<div style="margin-top:14px;display:flex;gap:8px;align-items:center;">
|
||||
<button class="btn secondary" onclick="loadOAuthServices()" style="padding:6px 14px;font-size:12px;">
|
||||
↻ Neu laden
|
||||
</button>
|
||||
<div style="color:#666680;font-size:10px;">
|
||||
client_secret wird verschlüsselt persistiert (file-mode 0600). Nicht in git, nicht im Repo.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Whisper (STT) -->
|
||||
<div class="settings-section">
|
||||
<h2>Whisper (Spracherkennung)</h2>
|
||||
@@ -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 = '<div style="color:#555570;font-style:italic;">Lade Services...</div>';
|
||||
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/` : '<RVS_HOST nicht gesetzt>';
|
||||
if (hintEl) {
|
||||
hintEl.innerHTML = host
|
||||
? `<b>Callback-URL pro Service</b> (im Provider-Dashboard eintragen): <code style="color:#0096FF;">${_ofmt(cbBase)}<service></code>`
|
||||
: `⚠ 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 ? ' <span style="color:#8888AA;font-size:10px;">(custom)</span>' : '';
|
||||
card.style.cssText = 'background:#0D0D1A;border:1px solid #2A2A3E;border-radius:6px;padding:10px 12px;';
|
||||
card.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<strong style="color:#FFF;text-transform:capitalize;">${_ofmt(svcName)}</strong>${customMark}
|
||||
<span style="color:${statusColor};font-size:12px;flex:1;">${statusText}</span>
|
||||
${s.authenticated ? `<button class="btn secondary" onclick="revokeOAuth('${_ofmt(svcName)}')" style="padding:2px 8px;font-size:10px;" title="Token loeschen">Abmelden</button>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||
<label style="color:#8888AA;font-size:11px;">client_id:</label>
|
||||
<input type="text" id="oauth-cid-${_ofmt(svcName)}" value="${_ofmt(app.client_id || '')}" placeholder="aus dem Provider-Dashboard"
|
||||
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:4px;padding:4px 8px;font-size:12px;font-family:monospace;">
|
||||
<label style="color:#8888AA;font-size:11px;">client_secret: ${app.has_client_secret ? '<span style="color:#34C759;">(gespeichert — leer lassen zum Behalten)</span>' : '<span style="color:#FF6B6B;">(fehlt)</span>'}</label>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<input type="password" id="oauth-sec-${_ofmt(svcName)}" placeholder="${app.has_client_secret ? 'leer lassen oder neuen eingeben' : 'aus dem Provider-Dashboard'}"
|
||||
style="flex:1;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:4px;padding:4px 8px;font-size:12px;font-family:monospace;">
|
||||
<button type="button" class="btn secondary" onclick="toggleSecret('oauth-sec-${_ofmt(svcName)}', this)" style="padding:2px 8px;font-size:10px;">👁</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-top:4px;">
|
||||
<button class="btn primary" onclick="saveOAuthApp('${_ofmt(svcName)}')" style="padding:4px 12px;font-size:11px;">Speichern</button>
|
||||
<button class="btn secondary" onclick="authorizeOAuth('${_ofmt(svcName)}')" style="padding:4px 12px;font-size:11px;" ${!s.configured ? 'disabled title="Erst client_id+secret eintragen"' : ''}>
|
||||
Autorisieren ↗
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
listEl.appendChild(card);
|
||||
}
|
||||
if (allServices.length === 0) {
|
||||
listEl.innerHTML = '<div style="color:#555570;">Keine Services bekannt.</div>';
|
||||
}
|
||||
} catch (e) {
|
||||
listEl.innerHTML = `<div style="color:#FF6B6B;">Fehler beim Laden: ${_ofmt(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+128
-5
@@ -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<ws> }
|
||||
@@ -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 = `<!doctype html>
|
||||
<html lang="de"><head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${title} — ${escapeHtml(service)}</title>
|
||||
<style>
|
||||
html,body{margin:0;padding:0;height:100%;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0D0D1A;color:#E0E0F0;}
|
||||
body{display:flex;align-items:center;justify-content:center;}
|
||||
.card{background:#1E1E2E;border:1px solid #2A2A3E;border-radius:12px;padding:32px;max-width:420px;text-align:center;box-shadow:0 4px 24px rgba(0,0,0,0.4);}
|
||||
.icon{font-size:64px;line-height:1;margin-bottom:16px;}
|
||||
.title{font-size:20px;font-weight:600;color:${bodyColor};margin-bottom:8px;}
|
||||
.service{font-size:13px;color:#8888AA;margin-bottom:20px;text-transform:uppercase;letter-spacing:0.1em;}
|
||||
.sub{font-size:14px;color:#C0C0D0;line-height:1.5;}
|
||||
.hint{font-size:11px;color:#666680;margin-top:24px;}
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<div class="icon">${icon}</div>
|
||||
<div class="title">${title}</div>
|
||||
<div class="service">${escapeHtml(service)}</div>
|
||||
<div class="sub">${subtitle}</div>
|
||||
<div class="hint">Du kannst zur ARIA-App zurueckkehren.</div>
|
||||
</div>
|
||||
<script>setTimeout(()=>{try{window.close();}catch(e){}}, 4000);</script>
|
||||
</body></html>`;
|
||||
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}`);
|
||||
|
||||
Reference in New Issue
Block a user