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:
+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:
|
||||
|
||||
Reference in New Issue
Block a user