Compare commits

..

9 Commits

Author SHA1 Message Date
duffyduck 9ed9c99b0e fix(bridge): 3-Schichten-Schutz gegen Bridge-Hangs + Chat-History in beide Boxen
Bridge hat seit 5+h still gehangen — Container Up, asyncio idle im
selectors.select(), TCP-Verbindung zum RVS ESTABLISHED, aber keine
Events mehr verarbeitet. Klassischer Fall: NAT-Tabelle/Firewall hat
die TCP-Verbindung still gekillt (kein RST), Linux-Kernel mit Default-
Keepalive (2h idle) hat's nicht gemerkt, und der ws.ping()-Future hat
im Limbo gehangen ohne Exception zu werfen.

Schicht 1 — TCP-Keepalive aufm Socket:
  SO_KEEPALIVE=1, TCP_KEEPIDLE=30s, TCP_KEEPINTVL=10s, TCP_KEEPCNT=3.
  Halb-tote Verbindungen werden in ~1 min mit ECONNRESET sichtbar statt
  nach 2h. Loest 80% der Faelle direkt.

Schicht 2 — Asyncio-Watchdog (_rvs_heartbeat_watchdog):
  Separate Coroutine parallel zu _rvs_heartbeat. Letzterer markiert
  _last_heartbeat_ok nach jedem erfolgreichen pong. Watchdog checkt
  alle 20s: > 60s stale → ws.close() + transport.close() als Notausgang.
  Schuetzt gegen ws.ping()-Limbo.

Schicht 3 — File-Based Liveness Thread:
  Separater OS-Thread (NICHT asyncio) — immun gegen asyncio-Hangs.
  Schreibt /shared/health/bridge_alive periodisch. Wenn
  _last_heartbeat_ok > 180s stale: os._exit(1), Docker restart_policy
  uebernimmt. Last-Resort wenn Schichten 1+2 versagen.

Plus: chat_history-Render nach Reload bezog nur #chat-box, nicht
#chat-box-fs (Vollbild). Wer im FS-Modus reloaded hat sah eine leere
Box statt der History. Jetzt rendert der Handler in beide Boxen
(gleicher Pattern wie addChat / addAriaFile).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:39:52 +02:00
duffyduck 1ea614c26b fix(brain): CPU-only torch — verhindert 5 GB CUDA-Bloat im Brain-Image
sentence-transformers zieht torch als Dependency, und der Default-Wheel
auf x86_64-linux ist die CUDA-Variante mit allen NVIDIA-Libs
(nvidia-cudnn, nvidia-cublas, cuda-toolkit, triton, ...). ~5 GB pro
Build-Layer, frisst die 22-GB-VM auf.

Fix: torch CPU-Wheel explizit zuerst installieren. Damit ist die
torch-Dependency erfuellt wenn sentence-transformers spaeter kommt,
und die CUDA-Libs werden nie gezogen.

Brain laeuft eh komplett auf CPU (MiniLM-Embeddings ~120 MB), GPU-Bloat
war reine Disk-Verschwendung.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:45:51 +02:00
duffyduck acaa9fc3f2 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>
2026-05-23 15:39:54 +02:00
duffyduck 0887674497 fix(brain): Proxy-Timeout 20min -> 24h Read, split httpx-Timeouts, Cleanup-Pfade
Brain timed bei langen Pentests nach exakt 20:00 min raus, obwohl ARIAs
Subprozess fleissig weiterarbeitete und der Live-View alles zeigte.
Root-Cause: proxy_client.py hatte einen 1200s httpx.Client-Timeout —
genau der Wert, den wir vor 5 Tagen am Proxy auf 24h hochgezogen hatten.
Schicht uebersehen.

- docker-compose.yml: PROXY_TIMEOUT_SEC=86400 als brain-env.
- proxy_client.py: httpx.Timeout split (connect=10, read=86400, write=30,
  pool=10). Toter Proxy wird in 10s erkannt, lange ARIA-Sessions duerfen
  24h laufen.
- routes.js handleNonStreamingResponse: res.on("close") + isComplete-Flag.
  Brain-Disconnect killt jetzt den Subprozess statt ihn verwaisen zu lassen.
- agent.py chat(): try/except — bei Exception nach dem User-Turn wird ein
  Assistant-Error-Marker geschrieben, damit Conversation user->assistant
  konsistent bleibt (kein Tool-Call-Loop-Fail in Folge-Calls).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:24:22 +02:00
duffyduck f5243b1abb fix(proxy): Idle-Watchdog statt Hard-Timeout fuer lange Agent-Sessions
Pentests u.ae. brauchen oft >20min — der bisherige 20-min Hard-Cutoff
in claude-max-api-proxy's subprocess/manager.js killte den Subprocess
mitten in der Arbeit, egal wie aktiv ARIA gerade war.

Loesung:
- Hard-Timeout via sed auf 24h hochgesetzt (Last-Resort gegen wirklich
  haengende Subprozesse).
- Eigener Idle-Watchdog in routes.js: Subprocess wird gekillt erst wenn
  ueber ARIA_IDLE_TIMEOUT_MS (Default 20min) keine message/content_delta
  Events ankommen. Jede Aktivitaet resettet den Timer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:02:04 +02:00
duffyduck eb5c178139 fix(proxy): tool_result Events ueber generic 'message' statt nicht-existentem 'user'
Der claude-max-api-proxy Subprocess-Manager emittiert nur 'message',
'assistant', 'content_delta', 'result', 'error', 'close', 'raw' —
KEIN 'user'. tool_result-Blocks landen daher ausschliesslich im
generischen 'message'-Event mit type==='user'. Filter darauf statt
auf einen Event-Namen der nicht existiert, sonst kam in der ARIA-Live-
View nichts an.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:56:17 +02:00
duffyduck 31b0bfaac1 feat(diagnostic): ARIA-Live (read-only Terminal-Mirror) + Not-Aus statt SSH-Tab
SSH-Tab raus — funktionierte eh nicht zuverlaessig und war konzeptionell
falsch. Stattdessen Live-Mirror der Claude-Code-Session:

- proxy-patches/routes.js: assistant + user Events parsed → POSTed Tool-
  Inputs (truncated 2 KB) + Tool-Results (truncated 4 KB) + Assistant-Text
  an aria-bridge:8090/internal/agent-stream. start/end Marker pro Session.
  Subprocess-Tracking (_activeSubprocesses Map) + interner Side-Channel
  auf Port 3457 mit POST /cancel-all fuer Hard-Kill.

- bridge: neuer /internal/agent-stream Endpoint pusht 1:1 als RVS
  agent_stream. cancel_request Handler nimmt optional 'hard'-Flag —
  triggert dann zusaetzlich _cancel_proxy_subprocesses() das den Proxy-
  Side-Channel ruft.

- rvs: agent_stream whitelisted.

- diagnostic: SSH-Tab → 'ARIA Live'. Monospace-Stream, farbcodiert
  (text=hell, tool_use=cyan, tool_result=gruen/rot, thinking=gelb-italic),
  Auto-Scroll, max 2000 Zeilen Backlog. Roter  Not-Aus-Button mit
  Confirm → aria_panic_stop action → diagnostic-server broadcastet
  cancel_request mit hard:true.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:23:13 +02:00
duffyduck 1d3c45fdda fix(flux): Torch 2.5.1 — 2.4 crasht in transformers MoE custom_op-Registrierung
transformers 4.50+ registriert in integrations/moe.py einen torch.library
.custom_op mit String-Forward-References als Type-Annotations. Torch 2.4's
infer_schema kann diese nicht aufloesen ("Parameter input has unsupported
type torch.Tensor"), erst 2.5+ macht typing.get_type_hints() draus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:37:15 +02:00
duffyduck 84a59d7b4f fix(flux): Torch 2.4 + torchvision — transformers braucht beides
Aktuelles transformers schaltet PyTorch ab wenn < 2.4
("Disabling PyTorch because PyTorch >= 2.4 is required, found 2.3.1").
Ohne PyTorch laed diffusers das FLUX-Modell nicht. torchvision wird
zusaetzlich von CLIPImageProcessor/SiglipImageProcessor gebraucht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:59:50 +02:00
16 changed files with 1858 additions and 175 deletions
+25
View File
@@ -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!).
+3 -2
View File
@@ -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
---
+7
View File
@@ -21,6 +21,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app
# CPU-only torch zuerst — sonst zieht sentence-transformers den Default
# torch-Wheel der ~5 GB CUDA-Libs (nvidia-cudnn, nvidia-cublas, cuda-toolkit,
# triton, ...) als Dependencies einsaugt. Brain laeuft komplett auf CPU
# (MiniLM-Embeddings ~120 MB), wir brauchen das alles nicht.
RUN pip install --no-cache-dir torch==2.5.1 \
--index-url https://download.pytorch.org/whl/cpu
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
+192 -31
View File
@@ -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))
@@ -553,40 +649,59 @@ class Agent:
len(hot), len(cold), len(active_skills), len(all_skills),
len(self.conversation.window()), len(system_prompt))
# 6. Tool-Use-Loop
# 6. Tool-Use-Loop. Bei Exception (z.B. Proxy-Timeout) muss ein
# Assistant-Turn als Error-Marker geschrieben werden — der User-Turn
# ist bereits in der Conversation. Ohne Gegenpart wird die naechste
# Anfrage im Window an Claude geschickt mit user → user als letzten
# zwei Turns, was OpenAI/Anthropic verwirrt und bei strict tools-Aufrufen
# zu 400-Errors fuehren kann.
final_reply = ""
for iteration in range(self.MAX_TOOL_ITERATIONS):
result = self.proxy.chat_full(messages, tools=tools)
if result.tool_calls:
# Assistant-Turn mit tool_calls in messages anhaengen (nicht in Conversation!)
messages.append(ProxyMessage(
role="assistant",
content=result.content or None,
tool_calls=[{
"id": tc["id"], "type": "function",
"function": {"name": tc["name"], "arguments": json.dumps(tc["arguments"])},
} for tc in result.tool_calls],
))
# Tools ausfuehren + Ergebnis als role=tool zurueck
for tc in result.tool_calls:
tool_result = self._dispatch_tool(tc["name"], tc["arguments"])
try:
for iteration in range(self.MAX_TOOL_ITERATIONS):
result = self.proxy.chat_full(messages, tools=tools)
if result.tool_calls:
# Assistant-Turn mit tool_calls in messages anhaengen (nicht in Conversation!)
messages.append(ProxyMessage(
role="tool",
tool_call_id=tc["id"],
name=tc["name"],
content=tool_result[:8000],
role="assistant",
content=result.content or None,
tool_calls=[{
"id": tc["id"], "type": "function",
"function": {"name": tc["name"], "arguments": json.dumps(tc["arguments"])},
} for tc in result.tool_calls],
))
continue # next iteration mit Tool-Results
# Kein Tool-Call mehr → final reply
final_reply = (result.content or "").strip()
break
else:
# Loop-Limit erreicht
final_reply = "[Tool-Loop-Limit erreicht — ARIA hat zu viele Tool-Calls gemacht ohne fertig zu werden]"
logger.warning("Tool-Loop hit MAX_TOOL_ITERATIONS=%d", self.MAX_TOOL_ITERATIONS)
# Tools ausfuehren + Ergebnis als role=tool zurueck
for tc in result.tool_calls:
tool_result = self._dispatch_tool(tc["name"], tc["arguments"])
messages.append(ProxyMessage(
role="tool",
tool_call_id=tc["id"],
name=tc["name"],
content=tool_result[:8000],
))
continue # next iteration mit Tool-Results
# Kein Tool-Call mehr → final reply
final_reply = (result.content or "").strip()
break
else:
# Loop-Limit erreicht
final_reply = "[Tool-Loop-Limit erreicht — ARIA hat zu viele Tool-Calls gemacht ohne fertig zu werden]"
logger.warning("Tool-Loop hit MAX_TOOL_ITERATIONS=%d", self.MAX_TOOL_ITERATIONS)
if not final_reply:
raise RuntimeError("Leerer Reply vom Proxy")
if not final_reply:
raise RuntimeError("Leerer Reply vom Proxy")
except Exception as exc:
# Conversation-Konsistenz: User-Turn ist drin (Schritt 1), Assistant
# muss auch rein damit die Paarung stimmt. Wir schreiben einen
# Error-Marker statt zu rollback-en (rollback wuerde Race-Conditions
# mit der JSONL-Persistenz aufmachen).
err_text = f"[Fehler: {exc}]"
logger.error("chat() Exception — schreibe Error-Marker als Assistant-Turn: %s", exc)
try:
self.conversation.add("assistant", err_text)
except Exception as add_exc:
logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc)
raise
# 7. Assistant-Turn (final reply) in die Conversation
self.conversation.add("assistant", final_reply)
@@ -711,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:
+116
View File
@@ -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
+425
View File
@@ -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
View File
@@ -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))
+20 -3
View File
@@ -25,7 +25,17 @@ logger = logging.getLogger(__name__)
RUNTIME_CONFIG_FILE = Path("/shared/config/runtime.json")
ENV_MODEL = os.environ.get("BRAIN_MODEL", "claude-sonnet-4")
PROXY_URL = os.environ.get("PROXY_URL", "http://proxy:3456")
PROXY_TIMEOUT_SEC = float(os.environ.get("PROXY_TIMEOUT_SEC", "1200"))
# Read-Timeout: wie lange wir auf die HTTP-Antwort vom Proxy warten.
# Proxy ist non-streaming → erstes Byte kommt erst NACH subprocess close.
# Agent-Loops (Pentests etc.) koennen >1h dauern → muss hoch sein.
# Default 24h, kann via PROXY_TIMEOUT_SEC env ueberschrieben werden.
PROXY_TIMEOUT_SEC = float(os.environ.get("PROXY_TIMEOUT_SEC", "86400"))
# Connect/Write/Pool: klein damit toter Proxy schnell erkannt wird.
# Wenn der Proxy-Container nicht antwortet beim TCP-Connect oder waehrend
# wir den Request-Body schreiben, ist er kaputt — kein Grund 24h zu warten.
PROXY_CONNECT_TIMEOUT_SEC = float(os.environ.get("PROXY_CONNECT_TIMEOUT_SEC", "10"))
PROXY_WRITE_TIMEOUT_SEC = float(os.environ.get("PROXY_WRITE_TIMEOUT_SEC", "30"))
PROXY_POOL_TIMEOUT_SEC = float(os.environ.get("PROXY_POOL_TIMEOUT_SEC", "10"))
def _read_model_from_runtime() -> str:
@@ -62,8 +72,15 @@ class ProxyClient:
def __init__(self, base_url: str = PROXY_URL, model: str = DEFAULT_MODEL):
self.base_url = base_url.rstrip("/")
self.model = model
# Persistente Client-Connection — vermeidet TCP-Handshake bei jedem Call
self._client = httpx.Client(timeout=PROXY_TIMEOUT_SEC)
# Persistente Client-Connection — vermeidet TCP-Handshake bei jedem Call.
# Timeouts split nach Phase: connect/write/pool klein (toter Proxy → schnell
# ReadTimeout), read gross (ARIA darf ewig rechnen).
self._client = httpx.Client(timeout=httpx.Timeout(
connect=PROXY_CONNECT_TIMEOUT_SEC,
read=PROXY_TIMEOUT_SEC,
write=PROXY_WRITE_TIMEOUT_SEC,
pool=PROXY_POOL_TIMEOUT_SEC,
))
def chat(self, messages: List[Message], model: Optional[str] = None) -> str:
"""Convenience: einfacher Chat ohne Tools. Gibt nur den Reply-String zurueck."""
+222 -3
View File
@@ -20,7 +20,9 @@ import mimetypes
import os
import re
import signal
import socket
import ssl
import threading
import time
import sys
import tempfile
@@ -48,6 +50,35 @@ logging.basicConfig(
)
logger = logging.getLogger("aria-bridge")
# ── TCP-Keepalive Helper ────────────────────────────────────
#
# Aktiviert TCP-Level Keepalive auf einer websockets-Verbindung mit
# aggressiven Intervallen: 30s idle bis erster Probe, 10s zwischen
# Probes, 3 verfehlte → Verbindung tot. Das deckt den Fall ab dass
# NAT-Tabellen-Verfall die TCP-Verbindung still kills ohne RST — Linux-
# Default braeucht sonst 2 Stunden idle bis der Kernel selber probt.
def _enable_tcp_keepalive(ws) -> None:
try:
sock = ws.transport.get_extra_info("socket")
if sock is None:
return
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
# Linux-spezifisch — TCP_KEEPIDLE/INTVL/CNT existieren auf macOS
# mit anderem Namen; im Container ist Linux garantiert.
for opt, val in (
("TCP_KEEPIDLE", 30),
("TCP_KEEPINTVL", 10),
("TCP_KEEPCNT", 3),
):
const = getattr(socket, opt, None)
if const is not None:
sock.setsockopt(socket.IPPROTO_TCP, const, val)
logger.info("[rvs] TCP-Keepalive aktiviert (idle=30s, intvl=10s, cnt=3)")
except Exception as exc:
logger.warning("[rvs] TCP-Keepalive konnte nicht aktiviert werden: %s", exc)
# ── Konfiguration ───────────────────────────────────────────
VOICES_DIR = Path("/voices")
@@ -1500,6 +1531,20 @@ class ARIABridge:
retry_delay = 2
logger.info("[rvs] Verbunden — warte auf App-Nachrichten")
# TCP-Keepalive auf dem unterliegenden Socket aktivieren —
# damit NAT-Tabellen-Verfall oder "halb-tote" Verbindungen
# (kein RST, kein FIN) innerhalb von ~1 Minute erkannt
# werden statt nach Linux-Default (2h idle). Ohne das
# hat die Bridge schon mal 5+h auf einer toten Connection
# gehangen ohne dass irgendeine Exception kam.
_enable_tcp_keepalive(ws)
# Heartbeat-Watchdog: jeden erfolgreichen Ping markieren wir
# in _last_heartbeat_ok. Ein separater Watchdog killt die
# WS-Verbindung wenn diese Marke > 60s stale ist — schuetzt
# gegen den Fall dass ws.ping() selbst nie zurueckkommt.
self._last_heartbeat_ok = time.monotonic()
# Aktuellen Modus broadcasten damit gerade verbundene Apps/Diagnostic
# ihren UI-State sofort syncen koennen
await self._broadcast_current_mode()
@@ -1512,12 +1557,14 @@ class ARIABridge:
# Heartbeat senden (RVS erwartet Ping alle 30s)
heartbeat_task = asyncio.create_task(self._rvs_heartbeat())
watchdog_task = asyncio.create_task(self._rvs_heartbeat_watchdog())
try:
async for raw_message in ws:
await self._handle_rvs_message(raw_message)
finally:
heartbeat_task.cancel()
watchdog_task.cancel()
except websockets.ConnectionClosed:
logger.warning("[rvs] Verbindung verloren")
@@ -1544,7 +1591,12 @@ class ARIABridge:
retry_delay = min(retry_delay * 2, 30)
async def _rvs_heartbeat(self) -> None:
"""Sendet Heartbeats + WebSocket Pings an den RVS damit die Verbindung offen bleibt."""
"""Sendet Heartbeats + WebSocket Pings an den RVS damit die Verbindung offen bleibt.
Markiert nach jedem erfolgreichen Ping `_last_heartbeat_ok` —
`_rvs_heartbeat_watchdog` schaut darauf und killt die Verbindung
wenn die Marke stale ist (Fallback fuer den Fall dass ping() selbst
in einer halb-toten TCP-Verbindung ewig blockt)."""
while True:
await asyncio.sleep(15)
if self.ws_rvs:
@@ -1552,6 +1604,8 @@ class ARIABridge:
# WebSocket Protocol-Level Ping (haelt TCP-Verbindung am Leben)
pong = await self.ws_rvs.ping()
await asyncio.wait_for(pong, timeout=10)
# Erfolgreicher Pong → Watchdog-Marke updaten
self._last_heartbeat_ok = time.monotonic()
except Exception:
logger.warning("[rvs] Ping fehlgeschlagen — Verbindung tot, erzwinge Reconnect")
try:
@@ -1568,6 +1622,45 @@ class ARIABridge:
except Exception:
break
# Heartbeat-Watchdog: wenn der letzte erfolgreiche Ping > HEARTBEAT_STALE_SEC
# her ist (z.B. weil ws.ping() im Limbo haengt), erzwingen wir ein hartes
# Schliessen der Verbindung. Das wirft den `async for raw_message in ws`-
# Loop aus, der Reconnect-Loop in connect_to_rvs greift dann.
HEARTBEAT_STALE_SEC = 60.0
HEARTBEAT_WATCHDOG_INTERVAL_SEC = 20.0
async def _rvs_heartbeat_watchdog(self) -> None:
"""Independent watchdog der den Heartbeat-Status ueberwacht und
bei staleness die WS-Verbindung haert killt. Wird parallel zu
`_rvs_heartbeat` gestartet, ist aber unabhaengig davon — auch wenn
die heartbeat-Coroutine in einem await ewig haengen wuerde, laeuft
diese hier weiter (eigene Coroutine, eigener await-Slot)."""
while True:
try:
await asyncio.sleep(self.HEARTBEAT_WATCHDOG_INTERVAL_SEC)
except asyncio.CancelledError:
return
if not self.ws_rvs:
return
stale = time.monotonic() - getattr(self, "_last_heartbeat_ok", time.monotonic())
if stale > self.HEARTBEAT_STALE_SEC:
logger.error(
"[rvs] Heartbeat stale (%.0fs > %.0fs) — erzwinge harten Reconnect",
stale, self.HEARTBEAT_STALE_SEC,
)
ws = self.ws_rvs
self.ws_rvs = None
try:
# close mit Reason — falls's hängt killen wir via Underlying-Transport
await asyncio.wait_for(ws.close(code=1011, reason="heartbeat-stale"), timeout=3.0)
except Exception:
# Letzte Option: Transport direkt schliessen, das wirft den recv-Loop
try:
ws.transport.close() # type: ignore[attr-defined]
except Exception:
pass
return
async def _send_chat_ack(self, client_msg_id: Optional[str]) -> None:
"""Bestaetigt der App den Empfang einer chat/audio-Nachricht.
App nutzt das fuer Delivery-Status (✓ = sent). Ohne ACK wuerde die
@@ -1677,8 +1770,14 @@ class ARIABridge:
return
if msg_type == "cancel_request":
logger.info("[rvs] Cancel-Request von App — rufe Diagnostic /api/cancel auf")
await self._cancel_via_diagnostic()
hard = bool(payload.get("hard"))
if hard:
logger.warning("[rvs] NOT-AUS — hard cancel: Diagnostic /api/cancel + Proxy /cancel-all")
await self._cancel_via_diagnostic()
await self._cancel_proxy_subprocesses()
else:
logger.info("[rvs] Cancel-Request von App — rufe Diagnostic /api/cancel auf")
await self._cancel_via_diagnostic()
await self._emit_activity("idle", "")
return
@@ -2332,6 +2431,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
@@ -2709,6 +2815,50 @@ 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-
Subprocesses sofort. Bridge ist auf aria-net, Proxy auch — also
per Container-Name + Side-Channel-Port (Default 3457) erreichbar."""
url = os.environ.get("PROXY_INTERNAL_URL", "http://aria-proxy:3457") + "/cancel-all"
def _do_request():
try:
req = urllib.request.Request(url, method="POST", data=b"")
with urllib.request.urlopen(req, timeout=3) 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.warning("[NOT-AUS] proxy /cancel-all: %s %s", status, body)
async def _emit_activity(self, activity: str, tool: str = "", force: bool = False) -> None:
"""Sendet agent_activity an die App — nur wenn sich der State geaendert hat.
@@ -2890,6 +3040,24 @@ class ARIABridge:
# selbst wenn derselbe Name zweimal in Folge kommt.
asyncio.create_task(self._emit_activity("tool", tool, force=True))
await _send_response(writer, 200, {"ok": True})
elif method == "POST" and path == "/internal/agent-stream":
# Vom Proxy gefeuert: voller Live-Stream der Claude-Code-
# Session (assistant_text, tool_use mit Input, tool_result
# mit truncated Output, start/end Markers). Wir leiten 1:1
# als RVS agent_stream an Diagnostic (ARIA-Live-View) und
# App weiter — read-only Mirror der gerade laufenden
# ARIA-Aktivitaet.
try:
data = json.loads(body.decode("utf-8", "ignore"))
except Exception as exc:
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
return
asyncio.create_task(self._send_to_rvs({
"type": "agent_stream",
"payload": data,
"timestamp": int(time.time() * 1000),
}))
await _send_response(writer, 200, {"ok": True})
elif method == "POST" and path == "/internal/flux-generate":
# Vom Brain (flux_generate-Tool) gefeuert. Wir routen den
# Render-Request via RVS an die flux-bridge (Gamebox),
@@ -3145,6 +3313,51 @@ class ARIABridge:
self.running = False
# ── File-Based Liveness Watchdog ─────────────────────────────
#
# Separater OS-Thread (NICHT asyncio) — schreibt periodisch eine
# Liveness-Datei mit aktuellem Timestamp und prüft ob der asyncio-Loop
# noch lebt. Wenn ueber LIVENESS_SELFKILL_SEC keine erfolgreiche Heart-
# beat-Bestätigung vom RVS kam, killt der Watchdog den ganzen Prozess
# (os._exit). Docker restart-Policy startet neu. Last-Resort fuer den
# Fall dass weder TCP-Keepalive noch der asyncio-Heartbeat-Watchdog
# greifen — z.B. wenn der event loop selbst korrumpiert ist.
LIVENESS_FILE = Path("/shared/health/bridge_alive")
LIVENESS_CHECK_INTERVAL_SEC = 15
LIVENESS_SELFKILL_SEC = 180 # 3 min — alle anderen Watchdogs (TCP-Keepalive
# ~1 min, asyncio-Watchdog 60s) sollten vorher
# greifen. Wenn nicht, ist der Prozess wirklich
# kaputt.
def _liveness_watchdog(bridge: "ARIABridge") -> None:
try:
LIVENESS_FILE.parent.mkdir(parents=True, exist_ok=True)
except Exception:
pass
while True:
time.sleep(LIVENESS_CHECK_INTERVAL_SEC)
# 1) Timestamp schreiben — externe Watcher koennen das pollen
try:
LIVENESS_FILE.write_text(str(int(time.time())))
except Exception:
pass
# 2) Letzten heartbeat checken (wird vom asyncio-Loop gesetzt). Wenn
# zu lange stale → Self-Kill. Docker-restart-Policy uebernimmt.
last_ok = getattr(bridge, "_last_heartbeat_ok", None)
if last_ok is None:
continue # noch keine RVS-Verbindung gewesen, fair, kein Kill
stale = time.monotonic() - last_ok
if stale > LIVENESS_SELFKILL_SEC:
sys.stderr.write(
f"[liveness] heartbeat {int(stale)}s stale — Self-Kill "
f"(Docker restart_policy uebernimmt)\n"
)
sys.stderr.flush()
os._exit(1)
# ── Hauptprogramm ────────────────────────────────────────────
@@ -3168,6 +3381,12 @@ def main() -> None:
logger.exception("Initialisierung fehlgeschlagen")
sys.exit(1)
# Liveness-Watchdog als daemon-Thread starten (immune gegen asyncio-Hangs)
threading.Thread(target=_liveness_watchdog, args=(bridge,),
daemon=True, name="liveness-watchdog").start()
logger.info("[liveness] Watchdog-Thread gestartet (selfkill nach %ds Heartbeat-Staleness)",
LIVENESS_SELFKILL_SEC)
# Event-Loop starten
try:
asyncio.run(bridge.run())
+344 -103
View File
@@ -395,18 +395,29 @@
<div class="card" style="margin-top:12px; padding: 8px 0 0 0;">
<div style="padding: 0 12px;">
<div class="tab-bar">
<button class="tab-btn active" id="live-tab-ssh" onclick="switchLiveTab('ssh')">SSH Terminal</button>
<button class="tab-btn active" id="live-tab-aria" onclick="switchLiveTab('aria')">ARIA Live</button>
<button class="tab-btn" id="live-tab-desktop" onclick="switchLiveTab('desktop')">Desktop</button>
</div>
</div>
<div style="background:#080810; border:1px solid #1E1E2E; border-radius:0 0 6px 6px; position:relative;">
<!-- SSH Terminal -->
<div id="live-ssh" style="height:350px; padding:4px;">
<div id="live-ssh-bar" style="display:flex;gap:6px;align-items:center;padding:4px 4px 6px;">
<button class="btn" onclick="startLiveSSH()" id="btn-live-ssh" style="padding:4px 12px;font-size:11px;">Verbinden</button>
<span id="live-ssh-status" style="font-size:11px;color:#8888AA;">Nicht verbunden</span>
<!-- ARIA Live (read-only Mirror der Claude-Code-Session) -->
<div id="live-aria" style="height:350px; padding:4px; display:flex; flex-direction:column;">
<div id="live-aria-bar" style="display:flex;gap:6px;align-items:center;padding:4px 4px 6px;flex-shrink:0;">
<span id="live-aria-status" style="font-size:11px;color:#8888AA;flex:1;">Idle — warte auf ARIA-Aktivitaet</span>
<button class="btn" onclick="clearAriaLive()" style="padding:4px 12px;font-size:11px;" title="Live-Mitschrift leeren">Leeren</button>
<label style="font-size:11px;color:#8888AA;display:flex;align-items:center;gap:4px;cursor:pointer;" title="Bei jeder neuen Zeile ans Ende scrollen">
<input type="checkbox" id="live-aria-autoscroll" checked style="margin:0;"> Auto-Scroll
</label>
<button class="btn" onclick="ariaPanicStop()"
style="padding:4px 14px;font-size:11px;background:#FF3B30;color:#fff;border-color:#FF3B30;font-weight:bold;"
title="NOT-AUS: killt alle aktiven Claude-Code-Subprocesses sofort">
⛔ Not-Aus
</button>
</div>
<div id="live-aria-stream"
style="flex:1;overflow-y:auto;background:#040408;font-family:'Courier New',monospace;font-size:11px;line-height:1.4;color:#C0C0D0;padding:6px 8px;border-top:1px solid #1E1E2E;">
<div style="color:#555570;font-style:italic;">Sobald ARIA denkt oder ein Tool nutzt, taucht es hier in Echtzeit auf.</div>
</div>
<div id="live-ssh-term" style="height:calc(100% - 32px);"></div>
</div>
<!-- Desktop Viewer -->
<div id="live-desktop" style="height:350px; display:none; position:relative;">
@@ -669,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>
@@ -1412,6 +1451,11 @@
return;
}
if (msg.type === 'agent_stream') {
appendAriaStreamEvent(msg.payload || {});
return;
}
if (msg.type === 'voice_preview_audio') {
const statusEl = document.getElementById('voice-preview-status');
const audio = document.getElementById('voice-preview-audio');
@@ -1555,8 +1599,8 @@
return;
}
// core_auth WS-Event entfernt — aria-core ist raus.
// Live SSH + Desktop
if (msg.type?.startsWith('live_ssh_')) { handleLiveSSH(msg); return; }
// SSH-Terminal entfernt — durch ARIA-Live-Mirror ersetzt.
// Desktop bleibt.
if (msg.type === 'desktop_status') { handleDesktop(msg); return; }
if (msg.type === 'term_ready') {
@@ -1598,26 +1642,26 @@
showDockerLogs(msg);
return;
}
// Chat-History (nach F5 / Reconnect)
// Chat-History (nach F5 / Reconnect) — IN BEIDE Boxen rendern.
// Vorher: nur chatBox bekam die Replay, die Vollbild-Box blieb leer
// → bei Reload aus dem FS-Modus sah es so aus als ob die letzten
// Bubbles weg waeren. Live-addChat schreibt schon korrekt in beide,
// der Reload-Pfad zog nicht mit.
if (msg.type === 'chat_history') {
chatBox.innerHTML = '';
const boxes = [chatBox, document.getElementById('chat-box-fs')].filter(Boolean);
for (const b of boxes) b.innerHTML = '';
if (msg.messages && msg.messages.length > 0) {
for (const m of msg.messages) {
if (m.type === 'aria_file') {
// ARIA-Datei-Bubble rekonstruieren (statt addAriaFile damit
// kein Auto-Scroll-Race waehrend des Bulk-Loads)
addAriaFile({ serverPath: m.serverPath, name: m.name, mimeType: m.mimeType, size: m.size });
// ARIA-Datei-Bubble — addAriaFile schreibt selbst in beide Boxen
addAriaFile({ serverPath: m.serverPath, name: m.name, mimeType: m.mimeType, size: m.size, deleted: m.deleted });
continue;
}
const el = document.createElement('div');
el.className = `chat-msg ${m.type}`;
if (m.ts) el.dataset.ts = String(m.ts);
// [FILE: ...]-Marker rausfiltern (gleicher Filter wie addChat)
const cleaned = (m.text || '').replace(/\[FILE:\s*\/shared\/uploads\/[^\]]+\]/gi, '').replace(/\n{3,}/g, '\n\n').trim();
const escaped = escapeHtml(cleaned);
let linked = linkifyText(escaped);
// /shared/uploads/-Bildpfade auch im History inline rendern
// (gleicher Replace wie in addChat — sonst sieht man nach F5 nur Text-Pfade)
linked = linked.replace(/\/shared\/uploads\/[^\s<"]+\.(jpg|jpeg|png|gif|webp|svg|bmp)/gi, (match) => {
return `<a href="${match}" target="_blank">${match}</a><img src="${match}" class="chat-media" onclick="openLightbox('image','${match}')" onerror="this.style.display='none'">`;
});
@@ -1625,10 +1669,16 @@
const trashBtn = m.ts
? `<button class="bubble-trash" title="Diese Bubble loeschen" onclick="deleteDiagBubble(${m.ts})">🗑</button>`
: '';
el.innerHTML = `${trashBtn}${linked}<div class="meta">${escapeHtml(m.meta)}${time}</div>`;
chatBox.appendChild(el);
const innerHtml = `${trashBtn}${linked}<div class="meta">${escapeHtml(m.meta)} — ${time}</div>`;
for (const b of boxes) {
const el = document.createElement('div');
el.className = `chat-msg ${m.type}`;
if (m.ts) el.dataset.ts = String(m.ts);
el.innerHTML = innerHtml;
b.appendChild(el);
}
}
chatBox.scrollTop = chatBox.scrollHeight;
for (const b of boxes) b.scrollTop = b.scrollHeight;
}
return;
}
@@ -2962,96 +3012,133 @@
// ── ARIA Live-Ansicht (SSH + Desktop) ──────────────────
let liveSshTerm = null;
let liveSshFit = null;
function switchLiveTab(tab) {
document.getElementById('live-ssh').style.display = tab === 'ssh' ? 'block' : 'none';
document.getElementById('live-aria').style.display = tab === 'aria' ? 'flex' : 'none';
document.getElementById('live-desktop').style.display = tab === 'desktop' ? 'block' : 'none';
document.getElementById('live-tab-ssh').className = 'tab-btn' + (tab === 'ssh' ? ' active' : '');
document.getElementById('live-tab-aria').className = 'tab-btn' + (tab === 'aria' ? ' active' : '');
document.getElementById('live-tab-desktop').className = 'tab-btn' + (tab === 'desktop' ? ' active' : '');
if (tab === 'ssh' && liveSshTerm && liveSshFit) {
setTimeout(() => liveSshFit.fit(), 50);
}
}
function startLiveSSH() {
const statusEl = document.getElementById('live-ssh-status');
const btn = document.getElementById('btn-live-ssh');
// Wenn schon verbunden, trennen
if (liveSshTerm && liveSshTerm._sshConnected) {
send({ action: 'live_ssh_close' });
statusEl.textContent = 'Getrennt';
statusEl.style.color = '#FF6B6B';
btn.textContent = 'Verbinden';
liveSshTerm._sshConnected = false;
return;
// ── ARIA Live (read-only Mirror der Claude-Code-Session) ──────
//
// Empfaengt agent_stream Events vom RVS (Proxy → Bridge → RVS → wir).
// Rendert sie als monospace-Liste — Tool-Calls in cyan, Tool-Results
// in grau (truncated), ARIA-Text in weiss, Thinking kursiv. Auto-Scroll
// bleibt am unteren Rand kleben solange der User nicht hochgescrollt hat.
// Not-Aus killt via Bridge → Proxy-Side-Channel alle Subprocesses.
function _ariaStreamEl() { return document.getElementById('live-aria-stream'); }
function _ariaStatusEl() { return document.getElementById('live-aria-status'); }
function _ariaIsAtBottom() {
const el = _ariaStreamEl();
if (!el) return true;
return (el.scrollHeight - el.scrollTop - el.clientHeight) < 24;
}
function _ariaMaybeScroll() {
if (!document.getElementById('live-aria-autoscroll')?.checked) return;
const el = _ariaStreamEl();
if (el) el.scrollTop = el.scrollHeight;
}
// Truncate UI: groessere Backlogs koennen viele MB werden. Wir halten
// max 2000 Zeilen — beim Ueberlauf den oberen Block wegwerfen.
const ARIA_MAX_LINES = 2000;
function _ariaTrimBacklog() {
const el = _ariaStreamEl();
if (!el) return;
while (el.childElementCount > ARIA_MAX_LINES) {
el.removeChild(el.firstChild);
}
statusEl.textContent = 'Verbinde...';
statusEl.style.color = '#FFD60A';
function initSSHTerm() {
const container = document.getElementById('live-ssh-term');
if (!liveSshTerm) {
liveSshTerm = new Terminal({
theme: { background: '#080810', foreground: '#E0E0F0', cursor: '#0096FF' },
fontFamily: 'Courier New, monospace',
fontSize: 12,
cursorBlink: true,
});
liveSshFit = new FitAddon.FitAddon();
liveSshTerm.loadAddon(liveSshFit);
liveSshTerm.open(container);
liveSshFit.fit();
liveSshTerm.onData((data) => {
send({ action: 'live_ssh_input', data });
});
}
liveSshTerm.clear();
send({ action: 'live_ssh_start' });
}
if (typeof Terminal === 'undefined') {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js';
s.onload = () => {
const s2 = document.createElement('script');
s2.src = 'https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js';
s2.onload = () => initSSHTerm();
document.head.appendChild(s2);
};
document.head.appendChild(s);
}
function _ariaTimePrefix(ts) {
try {
const d = ts ? new Date(ts) : new Date();
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
const s = String(d.getSeconds()).padStart(2, '0');
return `${h}:${m}:${s}`;
} catch (_) { return ''; }
}
function _ariaEsc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function _ariaPushLine(html, color, opts = {}) {
const el = _ariaStreamEl();
if (!el) return;
const wasAtBottom = _ariaIsAtBottom();
const row = document.createElement('div');
row.style.cssText = `color:${color};${opts.style||''}`;
row.innerHTML = html;
// Erste statische "Sobald ARIA..."-Zeile beim ersten Event entfernen
const placeholder = el.querySelector('div[style*="italic"]');
if (placeholder && el.childElementCount === 1) el.removeChild(placeholder);
el.appendChild(row);
_ariaTrimBacklog();
if (wasAtBottom) _ariaMaybeScroll();
}
function appendAriaStreamEvent(p) {
const t = _ariaTimePrefix(p.ts);
const kind = p.kind || '';
if (kind === 'start') {
_ariaPushLine(
`<span style="color:#444460;">━━━ ${t} session start (${_ariaEsc(p.model || 'unknown')}) ━━━</span>`,
'#444460',
);
const st = _ariaStatusEl(); if (st) { st.textContent = 'ARIA aktiv...'; st.style.color = '#34C759'; }
} else if (kind === 'end') {
const reason = p.reason || '?';
const codePart = (p.code !== undefined && p.code !== null) ? ` code=${_ariaEsc(p.code)}` : '';
const errPart = p.error ? ` err=${_ariaEsc(String(p.error).slice(0,120))}` : '';
_ariaPushLine(
`<span style="color:#444460;">━━━ ${t} session end (${_ariaEsc(reason)}${codePart}${errPart}) ━━━</span>`,
'#444460',
);
const st = _ariaStatusEl(); if (st) { st.textContent = 'Idle'; st.style.color = '#8888AA'; }
} else if (kind === 'text') {
_ariaPushLine(
`<span style="color:#777799;">[${t}]</span> ${_ariaEsc(p.text || '')}`,
'#D0D0E0',
{ style: 'white-space:pre-wrap;word-break:break-word;' },
);
} else if (kind === 'thinking') {
_ariaPushLine(
`<span style="color:#777799;">[${t}]</span> <span style="font-style:italic;color:#888866;">💭 ${_ariaEsc(p.text || '')}</span>`,
'#888866',
{ style: 'white-space:pre-wrap;word-break:break-word;' },
);
} else if (kind === 'tool_use') {
const name = _ariaEsc(p.name || '?');
const inp = _ariaEsc(p.input || '');
const tail = p.inputTruncatedBytes ? `<span style="color:#777799;"> ...(+${p.inputTruncatedBytes} bytes)</span>` : '';
_ariaPushLine(
`<span style="color:#777799;">[${t}]</span> <span style="color:#0096FF;">▶ ${name}</span> <span style="color:#8888AA;">${inp}${tail}</span>`,
'#C0C0D0',
{ style: 'white-space:pre-wrap;word-break:break-word;' },
);
} else if (kind === 'tool_result') {
const isError = p.isError === true;
const head = isError ? '<span style="color:#FF6B6B;">✗ result (ERROR)</span>' : '<span style="color:#34C759;">✓ result</span>';
const tail = p.truncatedBytes ? `<span style="color:#777799;"> ...(+${p.truncatedBytes} bytes)</span>` : '';
_ariaPushLine(
`<span style="color:#777799;">[${t}]</span> ${head}<br><span style="color:#9090A0;white-space:pre-wrap;display:block;padding-left:14px;border-left:2px solid #2A2A3E;">${_ariaEsc(p.content || '')}${tail}</span>`,
'#9090A0',
);
} else {
initSSHTerm();
_ariaPushLine(
`<span style="color:#777799;">[${t}]</span> <span style="color:#AAAACC;">${_ariaEsc(kind)}: ${_ariaEsc(JSON.stringify(p))}</span>`,
'#AAAACC',
);
}
}
function handleLiveSSH(msg) {
const statusEl = document.getElementById('live-ssh-status');
const btn = document.getElementById('btn-live-ssh');
if (msg.type === 'live_ssh_data' && liveSshTerm) {
const raw = atob(msg.data);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
liveSshTerm.write(bytes);
} else if (msg.type === 'live_ssh_connected') {
statusEl.textContent = 'Verbunden mit aria-wohnung';
statusEl.style.color = '#34C759';
btn.textContent = 'Trennen';
if (liveSshTerm) liveSshTerm._sshConnected = true;
} else if (msg.type === 'live_ssh_error') {
statusEl.textContent = msg.error || 'Fehler';
statusEl.style.color = '#FF6B6B';
btn.textContent = 'Verbinden';
if (liveSshTerm) liveSshTerm._sshConnected = false;
} else if (msg.type === 'live_ssh_closed') {
statusEl.textContent = 'Getrennt';
statusEl.style.color = '#8888AA';
btn.textContent = 'Verbinden';
if (liveSshTerm) liveSshTerm._sshConnected = false;
}
function clearAriaLive() {
const el = _ariaStreamEl();
if (el) el.innerHTML = '<div style="color:#555570;font-style:italic;">Geleert.</div>';
}
function ariaPanicStop() {
if (!confirm('Wirklich NOT-AUS? Alle aktiven Claude-Subprocesses werden sofort gekillt.')) return;
send({ action: 'aria_panic_stop' });
_ariaPushLine(
`<span style="color:#FF3B30;font-weight:bold;">━━━ ${_ariaTimePrefix()} ⛔ NOT-AUS ausgeloest ━━━</span>`,
'#FF3B30',
);
}
function checkDesktop() {
@@ -3089,11 +3176,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();
@@ -3751,6 +3839,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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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)}&lt;service&gt;</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 {
+17
View File
@@ -633,6 +633,11 @@ function connectRVS(forcePlain) {
tool: msg.payload?.tool || msg.tool || "",
});
}
} else if (msg.type === "agent_stream") {
// Voller Live-Stream der Claude-Code-Session (assistant_text +
// tool_use mit Input + tool_result mit truncated Output). Geht
// 1:1 an Browser durch — die ARIA-Live-View rendert's.
broadcast({ type: "agent_stream", payload: msg.payload });
} else if (msg.type === "memory_saved") {
// ARIA hat selber etwas in die Qdrant-DB gespeichert (via memory_save Tool).
const m = msg.payload || {};
@@ -1887,6 +1892,18 @@ wss.on("connection", (ws) => {
if (traceActive) traceEnd(false, "Vom Benutzer abgebrochen");
broadcast({ type: "agent_activity", activity: "idle" });
dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {});
} else if (msg.action === "aria_panic_stop") {
// NOT-AUS aus ARIA-Live-View: lokales /api/cancel UND Hard-Kill via
// Bridge (die wiederum den Proxy-Side-Channel /cancel-all anruft).
log("warn", "server", "⛔ NOT-AUS — hard cancel + proxy /cancel-all");
pendingMessageTime = 0;
watchdogWarned = false;
watchdogFixAttempted = false;
if (traceActive) traceEnd(false, "Vom Benutzer per NOT-AUS abgebrochen");
broadcast({ type: "agent_activity", activity: "idle" });
// RVS-Broadcast cancel_request mit hard:true → aria-bridge ruft
// den Proxy-/cancel-all Side-Channel an, killt alle Subprocesses.
sendToRVS_raw({ type: "cancel_request", payload: { hard: true, source: "diagnostic-panic" }, timestamp: Date.now() });
} else if (msg.action === "voice_upload") {
// Voice-Samples an XTTS-Bridge via RVS weiterleiten, auf Bestätigung warten
log("info", "server", `Voice-Upload '${msg.name}' (${(msg.samples || []).length} Samples) sende an RVS...`);
+17 -1
View File
@@ -12,7 +12,7 @@ services:
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 1200000;/' $$DIST/subprocess/manager.js &&
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 86400000;/' $$DIST/subprocess/manager.js &&
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
cp /proxy-patches/routes.js $$DIST/server/routes.js &&
@@ -67,6 +67,22 @@ services:
- QDRANT_PORT=6333
- PROXY_URL=http://proxy:3456
- ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-}
# Read-Timeout fuer den Proxy-Call. Hoch, weil Agent-Loops (Pentests
# etc.) auch eine Stunde+ dauern koennen. Der Proxy seinerseits hat
# einen Idle-Watchdog (Default 20min Inaktivitaet) der den Subprocess
# killt, der dann seinen close-Event sendet — Brain bekommt also
# immer was zurueck, auch bei wirklich haengenden Subprozessen.
# 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)
+10 -3
View File
@@ -10,9 +10,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app
# PyTorch CUDA-Wheels zuerst, damit diffusers nicht CPU-Torch zieht.
# Versionsmatrix wie bei f5tts gehalten (cu121, Torch 2.3.1) — gleicher
# Treiber-Footprint, gleicher HF-Cache-Pfad.
RUN pip3 install --no-cache-dir torch==2.3.1 \
# Torch 2.5+ ist Pflicht: aktuelle transformers (4.50+, von diffusers
# transitiv reingezogen) registriert in integrations/moe.py einen
# custom_op mit String-Forward-References (`input: 'torch.Tensor'`).
# Erst torch 2.5's infer_schema kann die aufloesen — 2.4.1 crasht mit
# "Parameter input has unsupported type torch.Tensor" beim Import von
# diffusers.pipelines.flux.pipeline_flux.
# torchvision wird von den CLIP-/Siglip-ImageProcessors verlangt.
# cu121 bleibt — passt zum CUDA 12.2 Base-Image.
RUN pip3 install --no-cache-dir \
torch==2.5.1 torchvision==0.20.1 \
--index-url https://download.pytorch.org/whl/cu121
COPY requirements.txt .
+15
View File
@@ -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
+245 -23
View File
@@ -7,6 +7,10 @@
* (ARIA_TOOL_HOOK_URL, default http://aria-bridge:8090/internal/agent-activity).
* Bridge spiegelt das als RVS `agent_activity` an App+Diagnostic
* Gedanken-Stream zeigt live was ARIA gerade tool-maessig macht.
* - Voller Live-Stream (assistant_text, tool_use mit input, tool_result)
* geht an ARIA_STREAM_HOOK_URL Bridge RVS `agent_stream` Diagnostic
* "ARIA Live"-View (TeamViewer-mäßiger Mirror der Claude-Code-Session).
* - Subprocess-Tracking + POST /v1/cancel-all fuer Not-Aus (Hard-Kill).
* - Fire-and-forget, fail-open. Wenn die Bridge nicht antwortet, bricht
* der Brain-Call NICHT ab.
*
@@ -21,42 +25,180 @@ import { cliResultToOpenai, createDoneChunk, } from "../adapter/cli-to-openai.js
const TOOL_HOOK_URL = process.env.ARIA_TOOL_HOOK_URL
|| "http://aria-bridge:8090/internal/agent-activity";
const STREAM_HOOK_URL = process.env.ARIA_STREAM_HOOK_URL
|| "http://aria-bridge:8090/internal/agent-stream";
// Tool-Output kann sehr lang werden (git log -p, find /). Wir truncaten
// hart auf 4 KB pro Event — der User sieht weiterhin den Anfang und einen
// "...(N bytes truncated)" Hinweis. Vollstaendiger Output bleibt im Brain
// und wird normal verarbeitet, das hier ist NUR fuer den Live-Mirror.
const TOOL_RESULT_MAX_CHARS = 4096;
const TOOL_INPUT_MAX_CHARS = 2048;
// Idle-Timeout: Subprocess wird gekillt wenn ueber IDLE_TIMEOUT_MS keine
// Aktivitaet (message/content_delta) ankommt. Loest das alte Hard-Timeout-
// Problem fuer lange Agent-Sessions (Pentests etc.) — ARIA darf ewig
// arbeiten solange sie regelmaessig was emittiert, aber wenn der Subprocess
// hartnaeckig haengt, schlaegt der Watchdog trotzdem zu.
// Default 20min Idle. Override via env ARIA_IDLE_TIMEOUT_MS.
// 0 = deaktiviert (nicht empfohlen).
const IDLE_TIMEOUT_MS = parseInt(process.env.ARIA_IDLE_TIMEOUT_MS || "1200000", 10);
/**
* Pusht einen Tool-Use-Event an die Bridge. Fire-and-forget keine Awaits,
* keine Fehler nach oben. Logged Fehler still.
* Generic Fire-and-forget POST an die Bridge. Keine Awaits, keine Fehler
* nach oben. Eingesetzt fuer Tool-Hook + Stream-Hook.
*/
function _emitToolEvent(toolName) {
if (!toolName) return;
function _postJson(url, body) {
try {
const u = new URL(TOOL_HOOK_URL);
const body = JSON.stringify({ tool: String(toolName) });
const u = new URL(url);
const data = JSON.stringify(body);
const req = http.request({
method: "POST",
hostname: u.hostname,
port: u.port || 80,
path: u.pathname,
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) },
timeout: 2000,
}, (res) => { res.resume(); });
req.on("error", () => {});
req.on("timeout", () => req.destroy());
req.write(body);
req.write(data);
req.end();
} catch (_) { /* niemals weiterwerfen */ }
}
/**
* Hookt die `assistant`-Events des Subprozesses. Jedes assistant-Message
* kann mehrere content-Bloecke haben tool_use-Bloecke pushen wir live.
* Pusht einen Tool-Use-Event an die Bridge (alter Gedanken-Stream-Pfad).
*/
function _attachToolHook(subprocess) {
function _emitToolEvent(toolName) {
if (!toolName) return;
_postJson(TOOL_HOOK_URL, { tool: String(toolName) });
}
/**
* Pusht ein Stream-Event an die Bridge (neuer "ARIA Live"-Pfad).
* kind: "start" | "text" | "tool_use" | "tool_result" | "end"
*/
function _emitStreamEvent(requestId, kind, fields) {
_postJson(STREAM_HOOK_URL, { requestId, kind, ts: Date.now(), ...fields });
}
function _truncate(str, max) {
if (typeof str !== "string") str = String(str ?? "");
if (str.length <= max) return { text: str, truncatedBytes: 0 };
return { text: str.slice(0, max), truncatedBytes: str.length - max };
}
// ── Subprocess-Tracking fuer Not-Aus ──────────────────────────
// requestId → ClaudeSubprocess. Eintraege werden beim close/result-Event
// wieder entfernt. /v1/cancel-all iteriert und ruft .kill() auf jeden.
const _activeSubprocesses = new Map();
function _trackSubprocess(requestId, subprocess) {
_activeSubprocesses.set(requestId, subprocess);
const cleanup = () => _activeSubprocesses.delete(requestId);
subprocess.on("close", cleanup);
subprocess.on("error", cleanup);
}
/**
* Idle-Watchdog: killt den Subprocess wenn ueber IDLE_TIMEOUT_MS hinweg
* keine message/content_delta Events ankommen. Wird beim Start gesetzt,
* bei jedem Event reset, bei close/error/result gestoppt.
*
* Stream-Event 'end' wird durch den normalen close-Listener im Handler
* gefeuert wir muessen hier nichts extra emittieren.
*/
function _attachIdleWatchdog(subprocess, requestId) {
if (!IDLE_TIMEOUT_MS || IDLE_TIMEOUT_MS <= 0) return; // disabled
let timer = null;
let killed = false;
function _kill() {
if (killed) return;
killed = true;
const mins = Math.round(IDLE_TIMEOUT_MS / 60000);
console.warn(`[aria-idle] killing subprocess ${requestId} after ${mins}min idle`);
try { subprocess.kill(); } catch (_) {}
_emitStreamEvent(requestId, "end", { reason: "idle_timeout", idleMs: IDLE_TIMEOUT_MS });
}
function _reset() {
if (killed) return;
if (timer) clearTimeout(timer);
timer = setTimeout(_kill, IDLE_TIMEOUT_MS);
}
function _stop() {
if (timer) { clearTimeout(timer); timer = null; }
}
// Initial-Timer setzen
_reset();
// Jedes Event vom Subprozess zaehlt als Lebenszeichen
subprocess.on("message", _reset);
subprocess.on("content_delta", _reset);
// Result/close/error → endgueltig stop
subprocess.on("result", _stop);
subprocess.on("close", _stop);
subprocess.on("error", _stop);
}
/**
* Hookt assistant + user Events und pusht beides an Bridge:
* - Alt-API: nur Tool-Namen an /internal/agent-activity (Gedanken-Stream)
* - Neu-API: voller Stream (text/tool_use/tool_result) an /internal/agent-stream
*/
function _attachToolHook(subprocess, requestId) {
subprocess.on("assistant", (message) => {
try {
const blocks = message?.message?.content || [];
for (const b of blocks) {
if (b && b.type === "tool_use" && b.name) {
_emitToolEvent(b.name);
if (!b) continue;
if (b.type === "tool_use") {
if (b.name) _emitToolEvent(b.name);
const inputStr = b.input ? JSON.stringify(b.input) : "";
const inp = _truncate(inputStr, TOOL_INPUT_MAX_CHARS);
_emitStreamEvent(requestId, "tool_use", {
id: b.id || null,
name: b.name || "",
input: inp.text,
inputTruncatedBytes: inp.truncatedBytes,
});
} else if (b.type === "text" && b.text) {
_emitStreamEvent(requestId, "text", { text: b.text });
} else if (b.type === "thinking" && b.thinking) {
// Wenn das Modell Extended Thinking emittiert — selten in
// Claude Code CLI, aber moeglich. Markieren wir extra.
_emitStreamEvent(requestId, "thinking", { text: b.thinking });
}
}
} catch (_) { /* fail-open */ }
});
// tool_result Blocks kommen in user-Messages — die werden vom
// subprocess-Manager NICHT als 'user'-Event emittiert (gibt's nicht),
// sondern nur ueber das generische 'message'-Event mit type:'user'.
// 'message' feuert auch fuer assistant/result — wir filtern auf user
// damit wir nicht doppelt rendern (assistant geht ueber den eigenen
// assistant-Handler oben).
subprocess.on("message", (message) => {
try {
if (message?.type !== "user") return;
const blocks = message?.message?.content || [];
for (const b of blocks) {
if (b && b.type === "tool_result") {
let content = "";
if (typeof b.content === "string") content = b.content;
else if (Array.isArray(b.content)) {
content = b.content.map(c => (c && c.type === "text" && c.text) ? c.text : "").join("");
}
const out = _truncate(content, TOOL_RESULT_MAX_CHARS);
_emitStreamEvent(requestId, "tool_result", {
id: b.tool_use_id || null,
content: out.text,
truncatedBytes: out.truncatedBytes,
isError: b.is_error === true,
});
}
}
} catch (_) { /* fail-open */ }
@@ -86,9 +228,17 @@ export async function handleChatCompletions(req, res) {
// Convert to CLI input format
const cliInput = openaiToCli(body);
const subprocess = new ClaudeSubprocess();
// ARIA-Patch: Tool-Use-Events live an die Bridge weiterleiten.
// Greift fuer beide Branches (stream + non-stream).
_attachToolHook(subprocess);
// ARIA-Patch: Tool-Use-Events + voller Live-Stream an die Bridge.
// Plus: Subprocess fuer Not-Aus tracken (Hard-Kill via /v1/cancel-all).
// Plus: Idle-Watchdog — Subprocess darf ewig laufen solange Events
// kommen, wird aber gekillt nach IDLE_TIMEOUT_MS Inaktivitaet.
_attachToolHook(subprocess, requestId);
_trackSubprocess(requestId, subprocess);
_attachIdleWatchdog(subprocess, requestId);
_emitStreamEvent(requestId, "start", { model: body.model || null });
subprocess.on("result", () => _emitStreamEvent(requestId, "end", { reason: "result" }));
subprocess.on("close", (code) => _emitStreamEvent(requestId, "end", { reason: "close", code }));
subprocess.on("error", (err) => _emitStreamEvent(requestId, "end", { reason: "error", error: String(err?.message || err) }));
if (stream) {
await handleStreamingResponse(req, res, subprocess, cliInput, requestId);
}
@@ -217,21 +367,42 @@ async function handleStreamingResponse(req, res, subprocess, cliInput, requestId
async function handleNonStreamingResponse(res, subprocess, cliInput, requestId) {
return new Promise((resolve) => {
let finalResult = null;
let isComplete = false;
// Client-Disconnect-Handler — wenn Brain die HTTP-Verbindung kappt
// (z.B. nach Read-Timeout), den noch laufenden Subprocess killen.
// Im Streaming-Branch existiert das schon; non-streaming hatte's
// bisher nicht → Subprozess lief verwaist weiter, Ressourcen-Leak.
res.on("close", () => {
if (!isComplete) {
console.warn("[NonStreaming] Client disconnected before result — killing subprocess", requestId);
try { subprocess.kill(); } catch (_) {}
}
resolve();
});
subprocess.on("result", (result) => {
finalResult = result;
});
subprocess.on("error", (error) => {
console.error("[NonStreaming] Error:", error.message);
res.status(500).json({
error: {
message: error.message,
type: "server_error",
code: null,
},
});
isComplete = true;
if (!res.headersSent) {
res.status(500).json({
error: {
message: error.message,
type: "server_error",
code: null,
},
});
}
resolve();
});
subprocess.on("close", (code) => {
isComplete = true;
if (res.writableEnded) {
// Client ist eh schon weg — nichts mehr zu senden.
resolve();
return;
}
if (finalResult) {
res.json(cliResultToOpenai(finalResult, requestId));
}
@@ -306,4 +477,55 @@ export function handleHealth(_req, res) {
timestamp: new Date().toISOString(),
});
}
// ── Not-Aus Side-Channel ───────────────────────────────────
//
// claude-max-api-proxy steuert seine eigene Route-Registrierung — wir
// koennen da nicht reinpatchen ohne sed-Operationen am npm-Paket. Saubrer:
// ein dedizierter kleiner HTTP-Listener nur fuer den Not-Aus, auf einem
// internen Port im aria-net. Bridge ruft den, killt alle aktiven Claude-
// Subprocesses. App + Diagnostic sehen den Stream sofort enden.
const INTERNAL_PORT = parseInt(process.env.ARIA_PROXY_INTERNAL_PORT || "3457", 10);
const INTERNAL_HOST = "0.0.0.0"; // im aria-net erreichbar, nicht nach extern exposed
function _cancelAll() {
const ids = Array.from(_activeSubprocesses.keys());
let killed = 0;
for (const [id, subp] of _activeSubprocesses) {
try {
subp.kill();
killed++;
} catch (e) {
console.error("[aria-not-aus] kill failed for", id, e?.message);
}
}
_activeSubprocesses.clear();
return { killed, requestIds: ids };
}
try {
const internalServer = http.createServer((req, res) => {
if (req.method === "POST" && req.url === "/cancel-all") {
const result = _cancelAll();
console.warn("[aria-not-aus] /cancel-all — killed", result.killed, "subprocess(es)");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, ...result }));
return;
}
if (req.method === "GET" && req.url === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, active: _activeSubprocesses.size }));
return;
}
res.writeHead(404).end();
});
internalServer.on("error", (err) => {
console.error("[aria-not-aus] internal listener error:", err.message);
});
internalServer.listen(INTERNAL_PORT, INTERNAL_HOST, () => {
console.log("[aria-not-aus] internal listener on", INTERNAL_HOST + ":" + INTERNAL_PORT);
});
} catch (e) {
console.error("[aria-not-aus] startup failed:", e?.message);
}
//# sourceMappingURL=routes.js.map
+129 -5
View File
@@ -1,6 +1,7 @@
"use strict";
const { WebSocketServer } = require("ws");
const http = require("http");
const fs = require("fs");
const path = require("path");
@@ -40,6 +41,8 @@ const ALLOWED_TYPES = new Set([
"service_status",
"config_request",
"flux_request", "flux_response",
"agent_stream",
"oauth_callback",
]);
// Token-Raum: token -> { clients: Set<ws> }
@@ -70,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.
@@ -79,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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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}`);