Compare commits

...
Author SHA1 Message Date
duffyduckandClaude Opus 5 d221c1a320 feat(brain): Sicherheitsfilter blockiert nicht mehr das ganze Projekt
Anthropics Filter ("safeguards flagged this message", Details: [cyber]) trifft
genau unsere Themen — Ports, Tunnel, Zugangsdaten. Bisher war danach der
Projekt-Chat tot, und zwar aus einem Grund, den man der Meldung nicht ansieht:

Der Filter meldet sich NICHT als HTTP-Fehler. Die CLI liefert ein voellig
normales Ergebnis mit is_error=true und dem Fehlertext im content — also dort,
wo sonst ARIAs Antwort steht. proxy_client.py warf das Flag weg, der Agent
konnte Fehler und Antwort nicht unterscheiden und schrieb den Text per
conversation.add("assistant", ...) in den Verlauf. Weil der Verlauf bei jedem
Turn mitgeht, scheiterte ab da JEDE weitere Frage an derselben Stelle.

Zwei Stufen dagegen (aria-brain/safeguard.py):

1. Modellwechsel. Der Filter haengt am Modell ("Opus 5's safeguards ..."),
   Anthropic raet selbst zu "change your model". Derselbe Turn wird automatisch
   mit dem Ausweich-Modell neu gefragt; laeuft der Rest des Turns (Tool-Schritte)
   gleich mit darauf weiter. Der ausloesende Turn wird vermerkt, damit die
   naechsten Turns nicht erst wieder gegen die Wand laufen. Der Vermerk
   verfaellt von selbst, sobald der Turn aus dem Rolling Window gerollt ist —
   nichts muss aufgeraeumt werden, und das Projekt faellt von allein aufs
   Hauptmodell zurueck.

2. Quarantaene. Blockt auch das Ausweich-Modell, liegt es am Verlauf selbst.
   Dann faellt der ausloesende Turn aus dem Prompt-Fenster. NICHT geloescht: er
   bleibt in conversation.jsonl, steht mit Text in der Diagnostic und ist per
   Klick zurueckholbar. Der rohe Fehlertext wird nie persistiert — ARIA sagt in
   eigenen Worten, was passiert ist.

Der Zustand liegt in /data/safeguard.json, bewusst nicht in conversation.jsonl:
ein Markieren dort erzwaenge ein Rewrite, und das schreibt nur das In-Memory-
Window zurueck — alles schon Destillierte waere weg.

Erkennung bewusst zweistufig: mit is_error genuegt ein Marker, ohne muss der
Text mit "API Error" beginnen. Sonst wuerde eine Antwort, in der ARIA ueber den
Filter *spricht*, einen Modellwechsel ausloesen.

Mitgenommen:
- Das Memory-Destillat schickt denselben Verlauf nochmal durch und wurde
  genauso blockiert — nur fiel es dort als "unparsbares JSON" auf und
  wiederholte sich bei jedem Lauf. Weicht jetzt ebenfalls aus.
- clean_poisoned_turns.py raeumt die Altlasten: Fehlertexte, die vor diesem Fix
  schon im Verlauf stehen. Neu auch in Projekten (dort sterben sie ja), mit dem
  ausloesenden User-Turn desselben Kontexts, Dry-Run per Default.
- Diagnostic: Ausweich-Modell einstellbar (runtime.json, ohne Neustart, weil
  safeguard.py erst im Ernstfall liest) und ein Panel, das zeigt was blockiert
  wurde und ob es NOCH wirkt.

Tests: 35/35 fuer die Rettungskette gegen den echten agent.chat(), 16/16 fuer
das Aufraeum-Script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 10:16:24 +02:00
duffyduckandClaude Opus 5 d53dd83df4 feat(diagnostic): "+ Projekt" direkt ueber dem Chat
Im Kontext-Streifen gab es keinen Weg, ein Projekt anzulegen — das ging nur
unten in den Einstellungen. Der Knopf steht bewusst AUSSERHALB des scrollenden
Streifens: bei mehreren Projekten waere er dort nach rechts rausgerutscht.

Anlegen zieht jetzt beide Projekt-Oberflaechen nach (Einstellungs-Liste UND
Streifen) und setzt den Fokus aufs neue Projekt. Vorher aktualisierte nur die
Liste, das neue Projekt blieb im Streifen bis zum 15s-Poll unsichtbar — und der
Fokus blieb im Hauptchat, obwohl create_project() serverseitig umschaltet. Man
legte also ein Projekt an und schrieb weiter in den Hauptchat.

Dazu ein Fehlerpfad: ein fehlgeschlagenes Anlegen (z.B. leerer Name, 400 vom
Brain) wurde stillschweigend verschluckt — das Modal schloss, als waere alles
gut. Jetzt kommt die Begruendung des Brains als Meldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 10:16:01 +02:00
duffyduckandClaude Opus 5 58d60adbaf feat(diagnostic): Satelliten und Host-Agenten umbenennen
Beide hiessen bisher so, wie es in IHRER .env steht. Umbenennen hiess also: auf
den Rechner, Datei aendern, Dienst neu starten — beim Fire TV sogar neu
provisionieren. In der Oberflaeche gab es dafuer gar keinen Weg.

Jetzt ein ✎ pro Eintrag. Der Alias liegt zentral in
/shared/config/display_names.json ({"host": {...}, "satellite": {...}}) und
wirkt sofort fuer JEDEN schon ausgerollten Agenten, ohne ihn anzufassen.

Der entscheidende Teil ist die Rueckrichtung: die Bridge loest den Alias beim
ADRESSIEREN wieder auf (_resolve_host / _resolve_satellite). Ohne das haette man
etwas umbenennen koennen und es danach nicht mehr ansprechen — der Agent
vergleicht die Adresse gegen seine EIGENE Kennung und kennt den Alias nicht.
Angesprochen werden kann weiterhin alles: Alias, Eigenname oder technische ID.

Der Eigenname bleibt in der Liste klein daneben sichtbar, damit man den Bezug
zum Geraet nicht verliert. Leerer Name entfernt den Alias.

Diagnostic und Bridge teilen sich die Datei ueber /shared — kein neuer
Nachrichtentyp, keine Allow-List-Aenderung, eine Wahrheit.

Nebenbei behoben: _display_name_set legte fest "/shared/config" an, obwohl der
Dateipfad konfigurierbar ist. Damit scheiterte das Schreiben stillschweigend,
sobald der Pfad abwich. Verzeichnis wird jetzt aus dem Pfad abgeleitet.

17 Pruefungen: Aliasvergabe, Liste, Aufloesung in beide Richtungen,
Gross/Kleinschreibung, alter Name bleibt gueltig, anderer Agent unberuehrt,
beide Bereiche in einer Datei ohne sich zu stoeren, Entfernen, unbekanntes Ziel
wird nicht still umgebogen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 18:54:36 +02:00
duffyduckandClaude Opus 5 6404b99510 feat(satellite): der Satelliten-Rechner steht selbst mit in der Geraeteliste
Der Satellit laeuft auf einem ganz normalen Rechner — bei Stefan auf
duffy-desktop. Dort laufen typischerweise noch andere Dienste (FreeCAD-MCP,
Web-App, Dateiserver). Bisher war ausgerechnet dieser Rechner unsichtbar: man
findet sich nicht per mDNS/ARP selbst.

Er erscheint jetzt als normales Geraet — ansprechbar wie jedes andere, nur
zusaetzlich mit 🛰️ markiert und blau abgesetzt, damit klar ist, dass dort der
Satellit traegt. Abschaltbar ueber LIST_SELF=false, falls man den Traeger
bewusst heraushalten will.

Nebeneffekt, der ein echtes Problem loest: der eigene Rechner gilt damit als
"gefunden" und ist ohne Eintrag in TUNNEL_ALLOW tunnelbar. Genau der Fall, um
den es ging — der FreeCAD-MCP laeuft auf dem Satelliten-Host selbst.

Beim Zusammenfuehren bleibt die Markierung erhalten: findet mDNS denselben
Rechner nochmal, haette der Merge isSatellite sonst verschluckt. hostname kommt
aus demselben Grund mit in die erhaltenen Felder.

Dazu ein ✎-Knopf pro Geraet in der Diagnostic: Namen vergeben, damit ARIA
"duffy-desktop" ansprechen kann statt einer IP. Der Name haengt an der MAC und
ueberlebt einen DHCP-Wechsel.

8 Pruefungen: Selbst-Eintrag vollstaendig, Markierung ueberlebt den Merge,
mDNS-Infos bleiben erhalten, eigener Rechner ohne TUNNEL_ALLOW erlaubt, fremde
IP weiterhin blockiert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 18:49:49 +02:00
duffyduckandClaude Opus 5 cfd17b200a feat(satellite): Port-Tunnel ins fremde Netz + Geraete-Namen
ARIA sitzt woanders und kommt nicht ins Buero-Netz. Der Satellit kann beides:
er sieht das LAN und haelt eine ausgehende Verbindung zum RVS. Also reicht er
eine TCP-Verbindung durch — wie der VNC-Tunnel, nur fuer beliebige Ports.
Damit erreicht ARIA einen FreeCAD-MCP, eine Web-App, ein NAS-Interface.

Entscheidend fuer die Brauchbarkeit: die Bridge macht daraus einen GANZ
NORMALEN lokalen Port (aria-bridge:19000...). Dadurch funktioniert alles, was
sowieso TCP spricht — MCP-Client, curl, Browser — ohne Sonder-API. Jede
eingehende Verbindung bekommt einen eigenen Tunnel, parallele Verbindungen
sind also kein Problem (ein Browser oeffnet gern sechs).

Geraete-Namen: "duffy-desktop" ist ansprechbar, "192.168.1.47" nicht. Stefan
kann jedem gefundenen Geraet einen Namen geben, ARIA adressiert danach.
Schluessel ist die MAC, nicht die IP — per DHCP wandert die IP, die MAC bleibt.

Absicherung, weil das ein Weg in ein fremdes Netz ist:
- TUNNEL_ENABLED default AUS, pro Satellit.
- Ohne TUNNEL_ALLOW nur Ziele, die bei der Suche GEFUNDEN wurden. Der Tunnel
  kann damit nichts ansteuern, was nicht ohnehin im LAN sichtbar ist — und
  nichts im Internet.
- Obergrenze gleichzeitiger Tunnel + Reaper fuer ungenutzte (sonst bleibt eine
  vergessene Verbindung offen, bis der Satellit neu startet).
- Faehigkeit 'tunnel' wird im sat_hello gemeldet, damit Diagnostic und ARIA
  sehen, ob der Satellit das ueberhaupt erlaubt.

Neue RVS-Typen eingetragen (sat_tunnel_*, sat_name_*) — ohne das verwirft die
Allow-List sie still, und nichts funktioniert ohne Fehlermeldung.

16 Pruefungen gegen BEIDE echten Implementierungen (nur der RVS dazwischen ist
kurzgeschlossen): Namensaufloesung, Freigabe-Regeln, fremder Satellit
ignoriert, abgeschaltet blockt, echter Byte-Durchlauf (PONG:HALLO), unbekanntes
Ziel schliesst sauber statt zu haengen, Liste/Schliessen/Doppel-Schliessen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 18:35:40 +02:00
duffyduckandClaude Opus 5 ae3a112868 docs: README holt den Nachmittag nach — Modelle, Datenschutz, Flugwetter, Overlay
Die README war vom Vormittag, danach kamen neun Commits. Keiner davon stand
drin.

Neu dokumentiert:
- Sprachmodell: Liste aus Anthropics Modell-Katalog, Tier vs. feste Version,
  Test-Knopf samt seiner vier Ergebnisse (inkl. dem gelben Fall "nicht
  erkannt", bei dem der Proxy still opus eingesetzt haette)
- Datenschutz-Schalter: die drei Sendekanaele, warum kein Neustart noetig ist,
  und warum eine per docker-compose gesetzte Variable als gesperrt erscheint
- Warnung bei veralteten Proxy-Patches (die Falle hat zweimal Zeit gekostet)
- Anhang-Limit, Zeitstempel/Sortierung/Zeitraum bei den Gedanken
- Overlay: wie es ohne feste Dauer wieder weggeht, und warum der
  Accessibility-Weg genommen wurde statt des naheliegenden
- flugwetter als Beispiel-Skill: Quellen mit Kosten, pc_met als dokumentierte
  Kundenschnittstelle, Seitenwind gegen Pistenrichtung, Herkunft pro Zahl

Neuer Abschnitt "Secrets und was bei Anthropic landet" unter den
Sicherheitsprinzipien. Der gehoert dorthin und nicht in eine Fussnote: alles
was ARIA sieht, wird uebertragen und gespeichert, und ob daraus Trainingsdaten
werden, haengt an einer Konto-Einstellung. Dazu die drei Regeln (nie in den
Chat, Zugangsdaten in die Konfiguration, oauth_call statt Token-Herausgabe) und
ehrlich auch die Grenze: ARIA laeuft mit Root im Proxy-Container und kann
/shared/config lesen, wenn sie hinschaut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 12:42:57 +02:00
12 changed files with 1826 additions and 45 deletions
+105 -3
View File
@@ -464,6 +464,44 @@ manueller Diagnostic-Klick) gehoeren seed_rules zum Brain-Code und werden
mit jedem Deploy ausgerollt. Editieren = `SEED_RULES`-Liste anpassen,
Brain neu starten.
### Beispiel: `flugwetter`
Zeigt gut, wie ein Skill mit Zugangsdaten und mehreren Quellen aussieht —
Briefing fuer **EDWI** (Wilhelmshaven-Mariensiel, Piste 02/20), nur mit
Python-Bordmitteln, keine pip-Pakete.
| Was | Woher | Kosten |
|---|---|---|
| METAR/TAF | aviationweather.gov | frei |
| Wind, Boeen | Open-Meteo **ICON-D2** | frei |
| Bedeckung, Sicht | DWD **MOSMIX** Station 10125 | frei |
| GAFOR, Karten | **ftp.pcmet.de** | Stefans pc_met-Konto |
Die pc_met-Zugangsdaten stehen in der **Skill-Konfiguration**
(`pcmet_user` / `pcmet_password`), nie im Chat. `ftp.pcmet.de` ist die vom DWD
dokumentierte Kundenschnittstelle — kein Nachbauen des Browser-Logins noetig.
`action=discover` listet, was auf dem Server liegt; `action=fetch` holt eine
Karte nach `/shared/uploads` und gibt sie als `[FILE:]`-Marker zurueck, sodass
ARIA sie anzeigen oder an einen Agenten schicken kann.
Zwei Dinge, die den Unterschied machen:
- **Seitenwind statt nur Wind.** Gerechnet gegen Piste 02/20 und gegen Stefans
15-kt-Grenze (`crosswind_limit_kt`), bewertet werden die **Boeen**.
- **Jede Zahl nennt ihre Herkunft.** Die Wolkenuntergrenze ist entweder
*gemessen* (METAR), ein ausdruecklich markierter *Schaetzwert*
(Temp/Taupunkt, 400-ft-Regel) oder *nicht verfuegbar* — dann steht da, dass
man ins GAFOR schauen muss. `CAVOK` wird als eigene Aussage behandelt und
unterdrueckt die Schaetzung, die sonst direkt widersprechen wuerde.
Genau daran war ein Briefing vorher gescheitert: METAR meldete `NCD`
(AUTO-Station), das Bodenmodell meldete bedeckt, und niemand wusste, was gilt.
Oben im Ergebnis steht eine **KURZFASSUNG in ganzen Saetzen zum Vorlesen**
(„fuenftausend Fuss", nicht „5000 ft") — eine vorgelesene Tabelle versteht
niemand. Fuer die Ausgabe auf einem Geraet kombiniert ARIA:
`run_flugwetter` → optional `action=fetch` → `host_notify(text=<Kurzfassung>,
image_path=<Karte>, mode='both', seconds=0)`.
---
## Diagnostic — Selbstcheck-UI und Einstellungen
@@ -472,8 +510,8 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
### Tabs
- **Main**: Brain/RVS/Proxy-Status, Chat-Test, "ARIA denkt..."-Indikator, **💭 Gedanken-Stream** (zentrales Modal, zeigt live alle Tool-Calls + Phasen mit Zeitstempel und Trennlinien bei langen Pausen), End-to-End-Trace, Container-Logs
- **Gehirn**: Memory-Browser (Vector-DB), Suche mit zwei Modi (**📝 Wortlich** = Substring-Match Default + **🧠 Semantisch** mit Score-Threshold), **Advanced Search** (aufklappbares Panel, beliebig viele AND/OR-verknuepfte Felder, + Button fuer mehr Zeilen), Type+Pinned-Filter (greifen auch in der Suche), klappbare Type-Kategorien (Default eingeklappt), Add/Edit/Delete mit Category-Autosuggest, **📎 Anhaenge** pro Memory (Bilder/PDFs/...): Upload + Thumbnail-Vorschau + Lightbox + Lösch-Button, 📎N-Badge in der Liste, automatischer Cleanup beim Memory-Delete. ℹ-Info-Modal das erklaert welche Types FEST in den Prompt vs. Cold Memory wandern. **📄 Druckansicht** (Strg+P → PDF). Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz)
- **Main**: Brain/RVS/Proxy-Status, Chat-Test mit **Kontext-Streifen** (Hauptchat + Projekte als Karten mit Status-Dot; Tap wechselt den Fokus, rechts daneben **„+ Projekt"** — legt an, aktiviert und fokussiert in einem Schritt, der Knopf steht ausserhalb des scrollenden Streifens damit er bei vielen Projekten erreichbar bleibt), "ARIA denkt..."-Indikator, **💭 Gedanken-Stream** (zentrales Modal, zeigt live alle Tool-Calls + Phasen mit Zeitstempel und Trennlinien bei langen Pausen), End-to-End-Trace, Container-Logs
- **Gehirn**: Memory-Browser (Vector-DB), Suche mit zwei Modi (**📝 Wortlich** = Substring-Match Default + **🧠 Semantisch** mit Score-Threshold), **Advanced Search** (aufklappbares Panel, beliebig viele AND/OR-verknuepfte Felder, + Button fuer mehr Zeilen), Type+Pinned-Filter (greifen auch in der Suche), klappbare Type-Kategorien (Default eingeklappt), Add/Edit/Delete mit Category-Autosuggest, **📎 Anhaenge** pro Memory (Bilder/PDFs/...): Upload + Thumbnail-Vorschau + Lightbox + Lösch-Button, 📎N-Badge in der Liste, automatischer Cleanup beim Memory-Delete. ℹ-Info-Modal das erklaert welche Types FEST in den Prompt vs. Cold Memory wandern. **📄 Druckansicht** (Strg+P → PDF). **Datum/Uhrzeit pro Gedanke** („angelegt 03.10.26, 09:14 · geändert … (vor 3 Std)") — `created_at`/`updated_at` lagen immer schon in der DB und wurden nur nie angezeigt. Dazu **Sortierung** (nach Typ gruppiert oder Anlage/Aenderung auf-/absteigend; bei Zeit-Sortierung flach statt gruppiert) und ein **🕒 Zeitraum-Filter** (von/bis, umschaltbar angelegt/geaendert, Schnellwahl Heute / 7 / 30 Tage) der auch fuer Such-Treffer gilt. Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz)
- **Skills**: Liste aller Skills mit Logs pro Run, Activate/Deactivate, Export/Import als tar.gz, "von ARIA"-Badge fuer selbst gebaute
- **Trigger**: passive Aufweck-Quellen. **Timer** (einmalig, ISO-Timestamp oder via `in_seconds` als Server-Berechnung) + **Watcher** (recurring, mit Condition + Throttle). Liste aktiver Trigger + Logs pro Feuer-Event. Modal mit Type-Dropdown, Live-Anzeige aller verfuegbaren Condition-Variablen (`disk_free_gb`, `hour_of_day`, `current_lat/lon`, `last_user_message_ago_sec`, …). **Drei GPS-Funktionen** mit unterschiedlicher Semantik:
- `near(lat, lon, r)` — SOLANGE im Radius (mit Throttle gegen Spam). Use-Case: „bin ich noch in der Nähe von X?"
@@ -505,7 +543,12 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- **Debug-API ohne SSH** (Diagnostic-Server, Port 3001):
- `GET /api/chat-backup?lines=N` — letzte N Zeilen aus `chat_backup.jsonl` (Default 200, max 5000) als geparstes JSON. Hilfreich um nachzuvollziehen was ARIA tatsaechlich gemacht hat.
- `GET /api/agent-stream?lines=N` — gleiche Mechanik fuer den persistierten Live-Stream (Tool-Calls + Inputs + Outputs).
- **OAuth-Callback-Pipeline**: Caddy davor terminiert TLS via Let's Encrypt, RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket. Provider (Spotify/Dropbox/Discord/...) 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 vier Brain-Tools: **`oauth_register_provider`** (legt URLs eines neuen Providers wie Dropbox/Discord/Notion/... on-demand in `oauth_apps.json` an — Credentials bleiben Stefans Job), `oauth_authorize`, `oauth_get_token`, `oauth_revoke`
- **OAuth-Callback-Pipeline**: Caddy davor terminiert TLS via Let's Encrypt, RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket. Provider (Spotify/Dropbox/Discord/...) 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 fuenf Brain-Tools: **`oauth_register_provider`** (legt URLs eines neuen Providers wie Dropbox/Discord/Notion/... on-demand in `oauth_apps.json` an — Credentials bleiben Stefans Job), `oauth_authorize`, `oauth_get_token`, **`oauth_call`**, `oauth_revoke`
- **Sprachmodell**: die Auswahlliste kommt aus **Anthropics eigenem Modell-Katalog** (`downloads.claude.ai/model-catalog/v1/catalog.json`, Surface `cc`) — dieselbe Quelle, aus der sich Claude Code bedient. Oeffentlich, ohne API-Key. Oben die **Tier-Aliase** (opus/sonnet/haiku/fable), die automatisch der neuesten Version folgen; darunter die **exakten Versionen**, aeltere als solche markiert. „↻ Aktualisieren" holt frisch, sonst 6h-Cache mit Datei-Fallback fuer offline. **🔬 Testen** schickt eine echte Mini-Anfrage durch den ganzen Weg und zeigt, welches Modell tatsaechlich geantwortet hat (aus `modelUsage.canonicalModel` der CLI — das Modell selbst zu fragen waere unzuverlaessig). Vier Ergebnisse: Tier aufgeloest (gruen), exakte Version (gruen), **nicht erkannt** (gelb — der Proxy haette still `opus` eingesetzt), ungueltig (rot, mit CLI-Begruendung)
- **Sicherheitsfilter-Rettung** (🛡 Sicherheitsfilter): Anthropics Filter („*... safeguards flagged this message*", `Details: [cyber]`) trifft gern genau die Themen, um die es hier geht — Ports, Tunnel, Zugangsdaten. Die Meldung kommt **nicht** als HTTP-Fehler: die CLI liefert ein normales Ergebnis mit `is_error:true` und dem Fehlertext im `content`, also genau dort, wo sonst ARIAs Antwort steht. Ungeprueft landete dieser Text als ARIAs Wort im Verlauf — und weil der Verlauf bei jedem Turn mitgeht, kippte danach **jede** weitere Frage im selben Projekt an derselben Stelle. Jetzt zwei Stufen: **(1)** derselbe Turn wird automatisch mit dem **Ausweich-Modell** neu gefragt (einstellbar unter Sprachmodell, Default: anderes Tier — der Filter haengt am Modell, Anthropic raet selbst zu „change your model"); der ausloesende Turn wird vermerkt, damit die naechsten Turns nicht erst wieder gegen die Wand laufen, und der Vermerk verfaellt von selbst, sobald der Turn aus dem Rolling Window gerollt ist. **(2)** Blockt auch das Ausweich-Modell, liegt es am Verlauf: der ausloesende Turn fliegt aus dem Prompt-Fenster (**Quarantaene**) — nicht geloescht, er bleibt in `conversation.jsonl`, steht mit Text im Panel und ist per „↩ Zurueckholen" wieder drin. Der rohe API-Fehlertext wird nie persistiert; ARIA sagt in eigenen Worten, was passiert ist. Das Panel zeigt pro Vorfall Projekt, Modell, Klassifikator-Code und ob die Blockade **noch wirkt** (= Turn liegt noch im Fenster). Zustand in `/data/safeguard.json`, Code in [`aria-brain/safeguard.py`](aria-brain/safeguard.py); gilt auch fuer das Memory-Destillat, das denselben Verlauf nochmal durchschickt. **Altlasten** — Fehlertexte, die vor diesem Fix schon in `conversation.jsonl` stehen und dort weiter vergiften — raeumt [`aria-brain/clean_poisoned_turns.py`](aria-brain/clean_poisoned_turns.py) einmalig weg (Dry-Run per Default, `--apply` schreibt mit Backup; findet jetzt beide Giftsorten und in Projekten genauso wie im Hauptchat)
- **Datenschutz-Schalter**: die drei Kanaele, ueber die Claude Code zusaetzlich sendet (`DISABLE_ERROR_REPORTING`, `DISABLE_TELEMETRY`, `DISABLE_FEEDBACK_COMMAND`) pruefen und setzen. Geschrieben wird nach `~/.claude/settings.json` unter `env` — die CLI liest das bei jedem Aufruf, **kein Neustart noetig**. Geprueft wird, was wirklich im Prozess ankommt: eine per docker-compose gesetzte Variable gewinnt und wird als gesperrt angezeigt, statt einen wirkungslosen Haken anzubieten. **Nicht** dasselbe wie die Trainings-Einstellung — die steht im Anthropic-Konto (Link daneben)
- **Warnung bei veralteten Proxy-Patches**: der Proxy kopiert `proxy-patches/` beim **Start**. Wer nach einem `git pull` nur neu startet — oder neu startet, bevor der Pull durch ist — laeuft still mit der alten Fassung. Nichts deutet darauf hin. Die Einstellungen vergleichen jetzt die Zeitstempel und zeigen den fertigen Befehl (`--force-recreate proxy`)
- **Anhang-Limit** (System & Wartung): Groesse fuer Memory-Anhaenge, Default 64 MB. Liegt in `runtime.json`, das Brain liest es bei jedem Upload neu — greift sofort. Ueber 64 MB warnt die UI, weil die Handy-App darueber nichts mehr herunterlaedt
---
@@ -1067,6 +1110,26 @@ nichts zu tun. Auf dem Fire TV gibt es keinen Touchscreen, dafuer das D-Pad.
| Einblendung | Text ueber dem laufenden Bild (Overlay, braucht „Ueber anderen Apps anzeigen") |
| Sprache | ARIAs **echte** Stimme — der Agent fordert das Audio per `tts_request` ueber den normalen F5-TTS-Pfad an und spielt das PCM ab |
| Einstellung | Overlay oder Sprache als Standard, pro Anfrage ueberschreibbar (`mode`) |
| Bild + Text | `host_notify(image_path=/shared/uploads/...)` blendet ein Bild MIT Text ein — z.B. eine Wetterkarte |
**Wie die Einblendung wieder weggeht.** Eine feste Anzeigedauer ist immer
geraten: mal zu kurz zum Lesen, mal steht sie laengst im Weg. Darum bedeutet
`seconds=0`: **bleibt, bis Stefan etwas tut.**
- *Fernbedienung*: der Accessibility-Dienst bekommt `onKeyEvent` und raeumt die
Einblendung weg — gibt die Taste aber mit `return false` **weiter an den
Film**. Es geht kein Tastendruck verloren, der Player bleibt bedienbar.
- *Touch*: `FLAG_WATCH_OUTSIDE_TOUCH` meldet eine Beruehrung daneben; die geht
dank `FLAG_NOT_TOUCHABLE` gleichzeitig an die App darunter.
- *Notbremse*: nach 10 Minuten verschwindet sie in jedem Fall.
> Bewusst **nicht** der naheliegende Weg (Overlay faengt Tasten selbst ab):
> dafuer muessten `FLAG_NOT_FOCUSABLE` und `FLAG_NOT_TOUCHABLE` weg — und dann
> nimmt die Einblendung dem laufenden Film JEDE Eingabe, solange sie steht.
>
> Der Accessibility-Dienst braucht dafuer `canRequestFilterKeyEvents`. Nach
> einem Update also pruefen, ob er in den Bedienungshilfen noch aktiv ist —
> Android schaltet Dienste bei geaenderter Konfiguration gern ab.
> Fire TV Sticks haben oft **keine deutsche TTS-Engine**. Darum laeuft die
> Sprachausgabe bewusst nicht ueber die Geraete-Stimme, sondern ueber ARIAs
@@ -1147,6 +1210,40 @@ vorgeschlagen; nur wenn es keinen gibt, kommt der automatisch generierte.
6. **Panic Button**: `docker compose down` → sofort stoppen
7. **Alles loggen** — Stefan sieht immer was passiert
### Secrets und was bei Anthropic landet
ARIA laeuft ueber Claude Code auf einem Claude-Abo. **Alles, was ARIA sieht —
auch jedes Tool-Ergebnis — wird uebertragen und gespeichert.** Bei Consumer-
Plaenen (Free/Pro/Max) haengt es an einer Einstellung im Konto, ob daraus auch
Trainingsdaten werden; die Aufbewahrung betraegt entsprechend 5 Jahre oder
30 Tage ([claude.ai/settings/data-privacy-controls](https://claude.ai/settings/data-privacy-controls),
Details in Anthropics [Data-usage-Doku](https://code.claude.com/docs/en/data-usage)).
Daraus folgen drei Regeln:
1. **Secrets nie in den Chat tippen.** Das ist der Weg, ueber den hier
tatsaechlich einmal ein `client_secret` abgeflossen ist — nicht ueber ein
Tool, sondern weil es eingetippt und von ARIA zurueckzitiert wurde. Der
System-Prompt und die seed_rules weisen ARIA inzwischen an, das abzulehnen
und ein Secret nie zu wiederholen.
2. **Zugangsdaten gehoeren in eine Konfiguration, nicht in Code oder Chat.**
OAuth → Diagnostic → OAuth-Apps. Alles andere (Benutzername/Passwort,
API-Keys) → **Skill-Konfiguration** (`skill_set_config`), kommt zur Laufzeit
als `CFG_<NAME>` an und wird in der Oberflaeche maskiert.
3. **ARIA bekommt Zugangstokens nicht mehr zu sehen.** `oauth_get_token` sagt
nur noch, OB authentifiziert ist. Fuer API-Aufrufe gibt es **`oauth_call`**:
das Brain haengt die Authorization selbst an, fuehrt den Request aus und
gibt nur die Antwort zurueck — der Token verlaesst den Brain-Prozess nicht.
Abgesichert durch eine Host-Sperre: nur https und nur Hosts, die zum
Provider gehoeren (Vergleich auf den letzten zwei Labels, **nicht** per
`endswith` — sonst waere `boesespotify.com` durchgerutscht).
> Was ARIA NICHT schuetzt: sie laeuft mit Root im Proxy-Container und hat
> `/shared` gemountet. Sie *kann* `/shared/config/*.json` lesen, wenn sie
> hinschaut — und was sie liest, ist Tool-Ergebnis. Die Dateirechte (600)
> helfen dagegen nicht. Behandle jedes dort erreichbare Secret als
> „sichtbar, sobald ARIA hinschaut".
---
## Haeufige Befehle
@@ -1330,6 +1427,11 @@ docker exec aria-brain curl localhost:8080/memory/stats
- [x] **OpenAI-Endpunkt fuers lokale LLM** (+ optionales `openhands`-Profil); `.env` gewinnt immer, Diagnostic-Felder dann nur lesend
- [x] **Teilen-Ziel in der App** + **Sprachnachricht zu Anhaengen** + **Zwischenruf per Sprache**
- [x] **Trigger mit Projekt-Kontext**: die Antwort kommt dort zurueck, wo der Timer gestellt wurde (vorher immer Hauptchat), und `trigger_list` unterscheidet „schon gefeuert" von „deaktiviert"
- [x] **Modell-Liste von Anthropic** statt handgepflegt — und feste Versionen greifen wirklich. Vorher schmolz `extractModel` jeden unbekannten Namen still auf `opus` ein: eine bewusst gewaehlte Version wurde ohne Meldung durch eine andere ersetzt. Dazu ein **Test-Knopf**, der zeigt, welches Modell tatsaechlich geantwortet hat — moeglich wurde das erst, nachdem `normalizeModelName` aufhoerte, den echten Namen zu ueberschreiben
- [x] **Datenschutz**: die drei Claude-Code-Sendekanaele in der Diagnostic pruef- und abschaltbar (ohne Neustart), **`oauth_call`** statt Token-Herausgabe, Secrets-Regeln in Prompt und seed_rules, Warnung bei veralteten Proxy-Patches
- [x] **Anhang-Limit einstellbar** (Default 20 → 64 MB, greift sofort) und **Zeitstempel/Sortierung/Zeitraum** bei den Gedanken
- [x] **Flugwetter-Skill** mit pc_met-Anbindung, Seitenwind gegen Pistenrichtung und Herkunftsangabe pro Zahl; Ausgabe als Einblendung + Sprache auf einem Agenten
- [x] **Einblendung geht per Tastendruck weg** statt nach geratener Zeit — ueber `onKeyEvent` des Accessibility-Dienstes, der die Taste an den Film weiterreicht
#### Jagd auf verlorene Sprachnachrichten (die lange Kette)
+230 -9
View File
@@ -38,6 +38,7 @@ import triggers as triggers_mod
import watcher as watcher_mod
import oauth as oauth_mod
import projects as projects_mod
import safeguard
import project_vms as project_vms_mod
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
@@ -1302,6 +1303,66 @@ META_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "satellite_tunnel",
"description": (
"Macht einen Port aus dem Netz eines Satelliten HIER erreichbar. "
"Damit kommst Du an Dienste, die nur im fremden LAN lauschen — "
"z.B. ein FreeCAD-MCP auf Stefans Rechner, eine Web-App, ein "
"NAS-Interface.\n\n"
"op='open': Du bekommst eine Adresse wie `aria-bridge:19000` "
"zurueck. Die sprichst Du danach ganz normal an (http://..., "
"MCP, was auch immer) — als laege der Dienst nebenan.\n"
"op='list': welche Tunnel stehen gerade.\n"
"op='close': wieder zumachen (localPort angeben). Mach zu, was "
"Du nicht mehr brauchst — ein Tunnel ist ein offener Weg in ein "
"fremdes Netz.\n\n"
"target darf ein VERGEBENER GERAETENAME sein ('duffy-desktop') "
"oder eine IP. Namen stehen in satellite_devices im Feld 'name' "
"— das ist der bequeme Weg, Stefan spricht so ueber seine "
"Rechner.\n\n"
"Der Satellit muss das erlauben (TUNNEL_ENABLED, Faehigkeit "
"'tunnel' in satellite_list). Ohne Freigabe kommt eine klare "
"Fehlermeldung — dann Stefan Bescheid sagen, nicht herumprobieren."
),
"parameters": {
"type": "object",
"properties": {
"op": {"type": "string", "description": "open (Default) | list | close"},
"satellite": {"type": "string", "description": "Satelliten-Name/ID."},
"target": {"type": "string", "description": "Geraetename aus satellite_devices ODER IP."},
"port": {"type": "integer", "description": "Port im fremden Netz, z.B. 8080."},
"localPort": {"type": "integer", "description": "Optional fester lokaler Port (19000-19099); bei close Pflicht."},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "satellite_name",
"description": (
"Gibt einem Geraet im Satelliten-Netz einen Namen, damit man es "
"danach ansprechen kann ('duffy-desktop' statt '192.168.1.47'). "
"Der Name haengt an der MAC, ueberlebt also einen IP-Wechsel. "
"Leerer Name = Zuordnung entfernen. "
"Nimm mac aus satellite_devices, wenn vorhanden — sonst ip."
),
"parameters": {
"type": "object",
"properties": {
"satellite": {"type": "string", "description": "Satelliten-Name/ID."},
"mac": {"type": "string", "description": "MAC des Geraets (bevorzugt)."},
"ip": {"type": "string", "description": "IP, falls keine MAC bekannt."},
"name": {"type": "string", "description": "Der Name. Leer = entfernen."},
},
"required": ["satellite", "name"],
},
},
},
{
"type": "function",
"function": {
@@ -2385,6 +2446,40 @@ class Agent:
# 8 war zu knapp — ARIA brach mitten in laufender Arbeit ab. Env-tunebar.
MAX_TOOL_ITERATIONS = int(os.getenv("MAX_TOOL_ITERATIONS", "20"))
def _llm_call(self, messages, tools, project_id: str, model: str,
user_turn_ts: str):
"""Ein Claude-Call, abgesichert gegen Anthropics Sicherheitsfilter.
Gibt (result, model) zurueck — das Modell kann sich dabei aendern, dann
laufen auch die restlichen Schritte dieses Turns darauf weiter. Sonst
wuerde der naechste Tool-Schritt wieder ins Messer laufen.
Der Filter ist modellspezifisch ("Opus 5's safeguards ...") und
Anthropic empfiehlt selbst "change your model" — genau das passiert
hier, nur ohne dass Stefan davon etwas merkt.
"""
result = self.proxy.chat_full(messages, tools=tools, model=model,
project_id=project_id)
if not safeguard.looks_like_block(result.content, result.is_error):
return result, model
code = safeguard.detail_code(result.content)
fb = safeguard.fallback_for(model)
logger.warning("[safeguard] %s blockiert (code=%s) — zweiter Versuch mit %s",
model, code or "?", fb)
safeguard.record_block(project_id, user_turn_ts, model, fb, result.content)
retry = self.proxy.chat_full(messages, tools=tools, model=fb,
project_id=project_id)
if not safeguard.looks_like_block(retry.content, retry.is_error):
safeguard.mark_recovered(user_turn_ts, model, fb)
logger.info("[safeguard] %s hat geantwortet — Turn gerettet, Verlauf heil", fb)
return retry, fb
# Zweites Modell auch blockiert → Stufe 2 in chat().
safeguard.record_block(project_id, user_turn_ts, fb, "", retry.content)
raise safeguard.Blocked([model, fb], retry.content)
def chat(self, user_message: str, source: str = "",
project_id: Optional[str] = None,
pending_queue: Optional[list[str]] = None) -> tuple:
@@ -2472,9 +2567,12 @@ class Agent:
# Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False.
return fast_reply, "fast-path", speak, converse, False
# 1. User-Turn an die Konversation
self.conversation.add("user", user_message, source=source,
project_id=active_project_id)
# 1. User-Turn an die Konversation. Den Zeitstempel behalten wir: er
# identifiziert diesen Turn, wenn Anthropics Sicherheitsfilter
# zuschlaegt (safeguard.py — Vermerk + ggf. Quarantaene).
_user_turn = self.conversation.add("user", user_message, source=source,
project_id=active_project_id)
user_turn_ts = getattr(_user_turn, "ts", "")
if active_project_id:
projects_mod.touch_project(active_project_id)
@@ -2622,9 +2720,28 @@ class Agent:
# sieht der LLM nur die Projekt-Turns (sauberer Kontext); im Hauptthread
# nur die nicht-getaggten Turns.
window = self.conversation.window(project_id=active_project_id)
# Quarantaenierte Turns gehen NICHT mit zum Modell. Das sind die, an
# denen Anthropics Sicherheitsfilter schon zweimal haengen geblieben
# ist — bliebe so einer im Fenster, wuerde er jeden weiteren Turn
# dieses Projekts mit abschiessen. Gespeichert bleiben sie trotzdem.
window = safeguard.filter_window(window)
for t in window:
messages.append(ProxyMessage(role=t.role, content=t.content))
# Modellwahl fuer diesen Turn. Normalfall: das eingestellte Modell. Ist
# dieses Modell an einem Turn, der NOCH im Fenster liegt, schon am
# Filter gescheitert, nehmen wir direkt das Ausweich-Modell — sonst
# laeuft jeder Turn erst gegen dieselbe Wand. Rollt der Turn aus dem
# Fenster, gilt wieder das Hauptmodell; das verfaellt von selbst.
primary_model = self.proxy.model
turn_model = primary_model
_blocked = safeguard.blocked_models(active_project_id,
[t.ts for t in window])
if safeguard.norm_model(primary_model) in _blocked:
turn_model = safeguard.fallback_for(primary_model)
logger.warning("[safeguard] %s ist in diesem Fenster blockiert — "
"Turn laeuft direkt auf %s", primary_model, turn_model)
logger.info("chat: pinned=%d cold=%d skills=%d/%d window=%d project=%r prompt_chars=%d",
len(hot), len(cold), len(active_skills), len(all_skills),
len(window), active_project_id or "(main)", len(system_prompt))
@@ -2646,8 +2763,8 @@ class Agent:
self._ear_control = None # ear_control-Tool: 'off'|'on' oder None
try:
for iteration in range(self.MAX_TOOL_ITERATIONS):
result = self.proxy.chat_full(messages, tools=tools,
project_id=active_project_id)
result, turn_model = self._llm_call(
messages, tools, active_project_id, turn_model, user_turn_ts)
if result.tool_calls:
# Assistant-Turn mit tool_calls in messages anhaengen (nicht in Conversation!)
messages.append(ProxyMessage(
@@ -2696,6 +2813,34 @@ class Agent:
if not final_reply:
raise RuntimeError("Leerer Reply vom Proxy")
except safeguard.Blocked as exc:
# Stufe 2. Auch das Ausweich-Modell wurde blockiert — dann haengt es
# nicht am Modell, sondern am Verlauf. Der ausloesende Turn fliegt
# aus dem Prompt-Fenster, sonst scheitert ab jetzt JEDE Frage in
# diesem Projekt an derselben Stelle ("Projekt im Eimer").
# Geloescht wird nichts: der Turn bleibt in conversation.jsonl und
# ist in der Diagnostic mit einem Klick zurueckholbar.
#
# Der rohe API-Fehlertext wird NICHT persistiert. Genau das war der
# Kern des Problems: als Assistant-Turn gespeichert, vergiftet er
# jeden Folge-Turn. Stattdessen sagt ARIA in ihrer eigenen Stimme,
# was passiert ist.
safeguard.quarantine(user_turn_ts, active_project_id,
reason="Sicherheitsfilter: " + ", ".join(exc.models))
_code = safeguard.detail_code(exc.text)
logger.error("[safeguard] Auch %s blockiert (code=%s) — Turn %s in "
"Quarantaene, Projekt %r bleibt nutzbar",
exc.models[-1], _code or "?", user_turn_ts,
active_project_id or "(main)")
final_reply = (
"Das kam nicht durch: Anthropics Sicherheitsfilter hat die Anfrage "
"blockiert" + (f" ({_code})" if _code else "") + " — mit "
f"{exc.models[0]} genauso wie mit {exc.models[-1]}. "
"Ich hab deine letzte Nachricht aus meinem Arbeitsgedaechtnis "
"genommen, damit der Rest vom Projekt weiterlaeuft. Sag's gern "
"nochmal anders, dann probieren wir's neu."
)
except Exception as exc:
# Conversation-Konsistenz: User-Turn ist drin (Schritt 1), Assistant
# muss auch rein damit die Paarung stimmt. Wir schreiben einen
@@ -2722,6 +2867,7 @@ class Agent:
logger.warning("[guard] Identity-Break in Antwort erkannt — Retry")
try:
retry = self.proxy.chat_full(messages, tools=tools,
model=turn_model,
project_id=active_project_id)
retry_text = (retry.content or "").strip()
except Exception as exc:
@@ -3111,6 +3257,64 @@ class Agent:
except Exception as exc:
return f"FEHLER: Jellyfin-Aufruf fehlgeschlagen: {exc}"
def _dispatch_tunnel(self, arguments: dict) -> str:
"""satellite_tunnel — Port aus einem Satelliten-LAN hier verfuegbar machen."""
op = (arguments.get("op") or "open").strip().lower()
body = {"op": op,
"satellite": (arguments.get("satellite") or "").strip(),
"target": (arguments.get("target") or "").strip(),
"port": int(arguments.get("port") or 0),
"localPort": int(arguments.get("localPort") or 0)}
try:
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(f"{BRIDGE_URL}/internal/tunnel", data=data,
method="POST",
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as resp:
res = json.loads(resp.read().decode("utf-8", "ignore"))
except Exception as exc:
return f"FEHLER: Bridge nicht erreichbar: {exc}"
if op == "list":
tunnels = res.get("tunnels") or []
if not tunnels:
return "Es steht gerade kein Tunnel offen."
return "Offene Tunnel:\n" + "\n".join(
f"- {t['address']} -> {t['satellite']}/{t['target']}:{t['port']}"
for t in tunnels)
if not res.get("ok"):
return f"FEHLER: {res.get('error')}"
if op == "close":
return f"OK — Tunnel auf Port {res.get('localPort')} geschlossen."
return (f"OK — {body['target']}:{body['port']} aus dem Netz von "
f"'{body['satellite']}' ist jetzt erreichbar unter "
f"**{res.get('address')}**.\n"
f"Sprich diese Adresse ganz normal an (http://{res.get('address')}/..., "
f"MCP, ...). Wenn Du fertig bist: "
f"satellite_tunnel(op='close', localPort={res.get('localPort')}).")
def _dispatch_sat_name(self, arguments: dict) -> str:
"""satellite_name — Geraetenamen vergeben/entfernen."""
mac = (arguments.get("mac") or "").strip()
ip = (arguments.get("ip") or "").strip()
if not mac and not ip:
return "FEHLER: mac oder ip ist Pflicht (siehe satellite_devices)."
nm = (arguments.get("name") or "").strip()
body = {"satellite": (arguments.get("satellite") or "").strip(),
"mac": mac, "ip": ip, "name": nm}
try:
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(f"{BRIDGE_URL}/internal/satellite-name",
data=data, method="POST",
headers={"Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=15).read()
except Exception as exc:
return f"FEHLER: Bridge nicht erreichbar: {exc}"
who = mac or ip
return (f"OK — {who} heisst jetzt '{nm}'. Du kannst es ab sofort so "
f"ansprechen (z.B. satellite_tunnel target='{nm}')."
if nm else f"OK — Name von {who} entfernt.")
def _dispatch_host(self, name: str, arguments: dict) -> str:
"""Wrapper um _dispatch_host_inner: fuehrt die Host-Aktion aus und schreibt
danach einen Historie-Eintrag an die Bridge (fuer die Diagnostic-Ansicht
@@ -4175,6 +4379,10 @@ class Agent:
return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat."
if name in ("satellite_list", "satellite_devices", "satellite_command"):
return self._dispatch_satellite(name, arguments)
if name == "satellite_tunnel":
return self._dispatch_tunnel(arguments)
if name == "satellite_name":
return self._dispatch_sat_name(arguments)
if name in ("jellyfin_search", "jellyfin_devices", "jellyfin_play",
"jellyfin_control"):
return self._dispatch_jellyfin(name, arguments)
@@ -4250,11 +4458,24 @@ class Agent:
"Extrahiere die wichtigsten Fakten als JSON."
)
distill_messages = [
ProxyMessage(role="system", content=system),
ProxyMessage(role="user", content=user),
]
try:
raw = self.proxy.chat([
ProxyMessage(role="system", content=system),
ProxyMessage(role="user", content=user),
])
raw = self.proxy.chat(distill_messages)
# Auch hier kann Anthropics Filter zuschlagen — der Transcript
# enthaelt ja genau das Gespraech, das vorher schon blockiert
# wurde. Ohne diese Pruefung landet der Fehlertext in _parse_facts,
# scheitert dort als "unparsbares JSON", und das Destillat
# versucht es bei jedem Lauf aufs Neue erfolglos.
if safeguard.looks_like_block(raw, False):
fb = safeguard.fallback_for(self.proxy.model)
logger.warning("[safeguard] Destillat blockiert — zweiter Versuch mit %s", fb)
raw = self.proxy.chat(distill_messages, model=fb)
if safeguard.looks_like_block(raw, False):
logger.error("[safeguard] Destillat auch mit %s blockiert — Turns bleiben", fb)
return {"distilled": 0, "error": "Sicherheitsfilter blockiert das Destillat"}
except Exception as exc:
logger.warning("Destillat-Call fehlgeschlagen: %s — Turns bleiben", exc)
return {"distilled": 0, "error": str(exc)}
+69 -9
View File
@@ -13,11 +13,27 @@ Haltung fort (Self-Grounding rueckwaerts). Der --system-prompt-Fix verhindert
NEUE Vergiftung, aber die bestehenden Gift-Turns muessen einmalig raus, sonst
zieht die History das Modell weiter aus der Rolle.
Zweite Giftquelle: Anthropics Sicherheitsfilter
----------------------------------------------
Dasselbe Muster, anderer Auslöser. Der Filter ("... safeguards flagged this
message", `Details: [cyber]`) antwortet nicht mit einem HTTP-Fehler: die CLI
liefert ein normales Ergebnis mit is_error=true und dem Fehlertext im content.
Vor dem safeguard.py-Fix wurde dieser Text als ARIAs Antwort persistiert — und
die ausloesende Frage steht direkt davor. Beides geht bei jedem Folge-Turn
wieder mit, also greift der Filter wieder: das Projekt ist tot.
Neue Vorfaelle faengt das Brain jetzt selbst ab (Modellwechsel, im Notfall
Quarantaene). Was VORHER schon in der Datei steht, muss einmalig raus — dafuer
ist dieses Script da. Diese Sorte wird in ALLEN Kontexten gesucht, nicht nur im
Hauptthread: Projekt-Chats sind genau die, die daran sterben.
Was das Script tut
------------------
- Findet Hauptthread-Assistant-Turns (KEIN project_id), deren Inhalt eindeutig
eine Rollen-Ablehnung ist: enthaelt "claude code" UND einen zweiten Marker
(injiz/inject/fabriz/fabricat/adoptier/adopting/prompt injection/keine echten).
- Findet in JEDEM Kontext Assistant-Turns, die der Fehlertext des
Sicherheitsfilters sind.
- Entfernt diese Assistant-Turns PLUS den unmittelbar davor stehenden
Hauptthread-User-Turn (die ausloesende Frage) — also den ganzen Fehl-Dialog.
- Laesst ALLES andere unangetastet: projekt-getaggte Turns, distill-Marker,
@@ -61,10 +77,39 @@ _BREAK = re.compile(
)
# Der Fehlertext des Sicherheitsfilters. Gleiche Marker wie safeguard.py; hier
# dupliziert, damit das Script ohne Brain-Imports auf dem Host laeuft.
# Verlangt wird ZWEIERLEI: der Text muss mit "API Error" beginnen UND einen
# Filter-Marker enthalten. Sonst traefe es auch Turns, in denen ARIA ueber den
# Filter *spricht* — und eine Erklaerung ist kein Gift.
_FILTER_MARKERS = (
"safeguards flagged this message",
"anthropic.com/legal/aup",
"can't respond to this message with",
"cannot respond to this message with",
"try rephrasing the request in a new session",
)
def is_poison(content: str) -> bool:
return bool(_BREAK.search(content or ""))
def is_filter_error(content: str) -> bool:
t = (content or "").strip().lower()
if not t.startswith("api error"):
return False
return any(m in t for m in _FILTER_MARKERS)
def project_of(obj: dict) -> str:
"""Projekt-Tag eines Turns. Brain nutzt 'project_id', UI/Bridge 'projectId'."""
pid = obj.get("project_id")
if pid is None:
pid = obj.get("projectId")
return str(pid or "").strip()
def get_content(obj: dict) -> str:
"""conversation.jsonl nutzt 'content', chat_backup.jsonl nutzt 'text'."""
v = obj.get("content")
@@ -112,24 +157,38 @@ def main() -> int:
continue
if obj.get("op") == "distill":
continue
if obj.get("role") != "assistant" or not is_main_thread(obj):
if obj.get("role") != "assistant":
continue
content = get_content(obj)
if not content or not is_poison(content):
if not content:
continue
# Zwei Giftsorten mit unterschiedlichem Geltungsbereich:
# Rollen-Ablehnung — nur Hauptthread (so war es immer; in Projekten
# waere das Risiko eines False Positive hoeher als der Nutzen).
# Sicherheitsfilter — ueberall, denn genau Projekt-Chats sterben daran.
if is_filter_error(content):
reason = "Sicherheitsfilter"
elif is_main_thread(obj) and is_poison(content):
reason = "Rollen-Ablehnung"
else:
continue
# Gift-Assistant-Turn -> droppen
drop[i] = True
user_idx = None
# Unmittelbar davor stehenden Hauptthread-User-Turn (die Frage) mit weg.
pid = project_of(obj)
# Unmittelbar davor stehenden User-Turn DESSELBEN Kontexts (die
# ausloesende Frage) mit weg — sonst bleibt der Ausloeser im Fenster.
for j in range(i - 1, -1, -1):
pj = parsed[j][1]
if not isinstance(pj, dict) or pj.get("op") == "distill":
continue
if pj.get("role") == "user" and is_main_thread(pj):
if project_of(pj) != pid:
continue # fremder Kontext — ueberspringen, nicht abbrechen
if pj.get("role") == "user":
drop[j] = True
user_idx = j
break # nur der direkt vorangehende Turn
poisoned_pairs.append((i, user_idx))
break # nur der direkt vorangehende Turn dieses Kontexts
poisoned_pairs.append((i, user_idx, reason, pid))
n_drop = sum(drop)
if n_drop == 0:
@@ -138,12 +197,13 @@ def main() -> int:
print(f"Gefundene Fehl-Dialoge: {len(poisoned_pairs)} "
f"(insgesamt {n_drop} Zeilen zu entfernen)\n")
for a_idx, u_idx in poisoned_pairs:
for a_idx, u_idx, reason, pid in poisoned_pairs:
print(f" [{reason}] Kontext: {pid or 'Hauptchat'}")
if u_idx is not None:
uq = get_content(parsed[u_idx][1] or {})
print(f" Frage (Zeile {u_idx + 1}): {uq[:90]!r}")
print(f" Frage (Zeile {u_idx + 1}): {uq[:90]!r}")
ac = get_content(parsed[a_idx][1] or {})
print(f" Ablehng (Zeile {a_idx + 1}): {ac[:90]!r}")
print(f" Antwort (Zeile {a_idx + 1}): {ac[:90]!r}")
print()
if not apply:
+53
View File
@@ -38,6 +38,7 @@ import watcher as watcher_mod
import background as background_mod
import oauth as oauth_mod
import seed_rules as seed_rules_mod
import safeguard as safeguard_mod
import projects as projects_mod
import project_vms as project_vms_mod
@@ -1212,6 +1213,58 @@ def conversation_distill_now():
return agent().distill_old_turns()
# ─── Sicherheitsfilter (Anthropic) ──────────────────────────────────
# Der Filter antwortet mit is_error=true und dem Fehlertext im content. Das
# Brain erkennt das (safeguard.py), weicht aufs Ausweich-Modell aus und nimmt
# im Notfall den ausloesenden Turn aus dem Prompt-Fenster. Hier nur die
# Sichtbarkeit fuer die Diagnostic.
@app.get("/safeguard/status")
def safeguard_status(limit: int = 25):
"""Vorfaelle + Quarantaene. Fuer das Panel in der Diagnostic."""
data = safeguard_mod.status(limit=limit)
# Welche Turns noch im Fenster liegen (= wirken noch) — sonst sieht man in
# der Liste nicht, ob eine Blockade noch aktiv ist oder nur Historie.
conv = conversation()
live = {t.ts for t in conv.turns}
for inc in data.get("incidents", []):
inc["in_window"] = bool(inc.get("turn_ts") and inc["turn_ts"] in live)
for q in data.get("quarantine", []):
q["in_window"] = bool(q.get("turn_ts") and q["turn_ts"] in live)
# Den Text dazu zeigen, damit Stefan sieht, WAS blockiert wurde.
q["content"] = next((t.content[:400] for t in conv.turns
if t.ts == q.get("turn_ts")), "")
return data
class SafeguardTurnBody(BaseModel):
turn_ts: str
@app.post("/safeguard/unquarantine")
def safeguard_unquarantine(body: SafeguardTurnBody):
"""Einen quarantaenierten Turn wieder in den Verlauf aufnehmen.
Danach geht er wieder mit zum Modell — wenn er wirklich der Ausloeser war,
blockt der Filter erneut. Deshalb einzeln und von Hand."""
ok = safeguard_mod.unquarantine(body.turn_ts)
if not ok:
raise HTTPException(404, "Kein quarantaenierter Turn mit diesem Zeitstempel")
return {"ok": True}
class SafeguardClearBody(BaseModel):
project_id: Optional[str] = None
@app.post("/safeguard/clear")
def safeguard_clear(body: SafeguardClearBody):
"""Vorfallsliste leeren. Hebt damit auch den Modell-Vermerk auf — der
naechste Turn laeuft wieder aufs Hauptmodell. Quarantaene bleibt."""
removed = safeguard_mod.clear(body.project_id)
return {"ok": True, "removed": removed}
# ─── Call-Metrics (Token / Quota-Monitoring) ────────────────────────
@app.get("/metrics/calls")
+14 -1
View File
@@ -66,6 +66,13 @@ class ProxyResult(BaseModel):
content: str = ""
tool_calls: list = [] # je: {"id", "name", "arguments" (dict)}
finish_reason: str = ""
# ARIA-Zusatzfelder des Proxys (cli-to-openai.js). Wichtig: ein Fehler der
# CLI kommt NICHT als HTTP-Fehler, sondern als normale Antwort mit
# is_error=true und dem Fehlertext im content. Ohne dieses Flag kann der
# Agent einen Fehlertext nicht von einer Antwort unterscheiden — genau so
# landete die Meldung des Sicherheitsfilters als ARIAs Antwort im Verlauf.
is_error: bool = False
model_used: str = "" # Modell, das tatsaechlich geantwortet hat
class ProxyClient:
@@ -177,7 +184,13 @@ class ProxyClient:
# Call-Metric anhaengen — Token-Schaetzung fuer Quota-Monitoring
metrics.log_call(payload["model"], messages, content or "")
return ProxyResult(content=content or "", tool_calls=tool_calls, finish_reason=finish_reason)
return ProxyResult(
content=content or "",
tool_calls=tool_calls,
finish_reason=finish_reason,
is_error=bool(data.get("aria_error")),
model_used=str(data.get("aria_model") or ""),
)
def close(self):
try:
+310
View File
@@ -0,0 +1,310 @@
"""Anthropics Sicherheitsfilter — erkennen, ausweichen, Verlauf heil halten.
Der Filter ("... safeguards flagged this message", `Details: [cyber]`) meldet
sich NICHT als HTTP-Fehler. Die CLI liefert ein voellig normales Ergebnis mit
is_error=true, und der Fehlertext steht im `content` — also genau dort, wo
sonst ARIAs Antwort steht. Ungeprueft passiert deshalb Folgendes:
1. Der Fehlertext landet per conversation.add("assistant", ...) im Verlauf.
2. Jeder Folge-Turn schickt den Verlauf samt der ausloesenden Nachricht wieder
mit — der Filter greift erneut.
3. Das Projekt ist tot: jede weitere Frage scheitert an derselben Stelle.
Dagegen zwei Rettungsstufen:
Stufe 1 — Modellwechsel. Der Filter ist modellspezifisch ("Opus 5's
safeguards ..."), Anthropic empfiehlt selbst "change your model". Derselbe
Turn wird mit dem Ausweich-Modell neu gefragt. Der ausloesende Turn wird
vermerkt, damit die naechsten Turns des Projekts nicht erst wieder gegen die
Wand laufen. Der Vermerk verfaellt von selbst, sobald der Turn aus dem
Rolling Window gerollt ist — nichts muss aufgeraeumt werden.
Stufe 2 — Quarantaene. Blockt auch das Ausweich-Modell, liegt es am Verlauf
selbst. Dann wird der ausloesende Turn aus dem Prompt-Fenster genommen.
NICHT geloescht: er bleibt in conversation.jsonl, bleibt in der Diagnostic
sichtbar und ist mit einem Klick zurueckholbar. Das Projekt ist damit im
naechsten Turn wieder benutzbar.
Der Zustand liegt in /data/safeguard.json — bewusst NICHT in
conversation.jsonl: ein Markieren wuerde dort ein Rewrite erzwingen, und das
schreibt nur das In-Memory-Window zurueck (alles schon Destillierte waere weg).
"""
from __future__ import annotations
import json
import logging
import os
import re
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable, Optional
logger = logging.getLogger(__name__)
STATE_FILE = Path(os.environ.get("SAFEGUARD_STATE_FILE", "/data/safeguard.json"))
RUNTIME_CONFIG_FILE = Path(os.environ.get("RUNTIME_CONFIG_FILE", "/shared/config/runtime.json"))
# Wie viele Vorfaelle wir aufbewahren. Reicht fuer die Diagnostic-Anzeige und
# haelt die Datei klein.
MAX_INCIDENTS = 200
_lock = threading.Lock()
class Blocked(Exception):
"""Auch das Ausweich-Modell wurde blockiert.
Dann hilft kein Modellwechsel mehr — es liegt am Verlauf selbst. Der Agent
faengt das und geht auf Stufe 2 (Quarantaene).
"""
def __init__(self, models, text: str = ""):
self.models = [m for m in models if m]
self.text = text or ""
super().__init__("Sicherheitsfilter blockiert: " + ", ".join(self.models))
# Textmarker des Filters. Englisch und stabil — der Text kommt von Anthropic,
# nicht von uns. Mehrere, weil die Formulierung sich aendern kann.
_MARKERS = (
"safeguards flagged this message",
"anthropic.com/legal/aup",
"can't respond to this message with",
"cannot respond to this message with",
"try rephrasing the request in a new session",
)
_DETAIL_RE = re.compile(r"details:\s*`?\[([a-z0-9_-]+)\]`?", re.IGNORECASE)
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def norm_model(model: str) -> str:
"""Modellnamen vergleichbar machen (Gross/Klein, CLI-Prefix, Leerzeichen)."""
m = (model or "").strip().lower()
if m.startswith("claude-code-cli/"):
m = m[len("claude-code-cli/"):]
return m
# ── Erkennung ───────────────────────────────────────────────────────
def looks_like_block(text: str, is_error: bool = False) -> bool:
"""True, wenn dieser Antworttext der Sicherheitsfilter ist.
Zwei Wege, absichtlich unterschiedlich streng:
* is_error=true (die CLI sagt selbst "das war ein Fehler") — ein Marker
genuegt.
* Ohne is_error muss der Text mit "API Error" ANFANGEN. Sonst wuerde die
Erkennung auch dann zuschlagen, wenn ARIA ueber den Filter *spricht*
("der Fehler hiess: safeguards flagged ...") — eine Antwort ueber den
Fehler ist kein Fehler.
"""
t = (text or "").strip()
if not t:
return False
low = t.lower()
hits = sum(1 for m in _MARKERS if m in low)
if hits == 0:
return False
if is_error:
return True
return low.startswith("api error") and len(t) <= 2000
def detail_code(text: str) -> str:
"""Der Klassifikator-Code aus "Details: `[cyber]`" — nur fuer die Anzeige."""
m = _DETAIL_RE.search(text or "")
return m.group(1) if m else ""
# ── Ausweich-Modell ─────────────────────────────────────────────────
def _runtime_cfg() -> dict:
try:
if RUNTIME_CONFIG_FILE.exists():
return json.loads(RUNTIME_CONFIG_FILE.read_text(encoding="utf-8")) or {}
except Exception as exc:
logger.warning("[safeguard] runtime.json lesen fehlgeschlagen: %s", exc)
return {}
def fallback_for(model: str) -> str:
"""Welches Modell beim naechsten Versuch.
Vorrang hat `brainFallbackModel` aus runtime.json (Diagnostic). Zeigt das
auf dasselbe Modell, ist es wertlos — dann faellt es auf die automatische
Wahl zurueck: ein ANDERES Tier, denn der Filter haengt am Modell.
Pro Aufruf gelesen, nicht beim Import: eine Aenderung in der Diagnostic
greift so ohne Brain-Neustart.
"""
want = str(_runtime_cfg().get("brainFallbackModel") or "").strip()
primary = norm_model(model)
if want and norm_model(want) != primary:
return want
if "sonnet" in primary:
return "opus"
# Opus, Fable, Haiku und alles Unbekannte → Sonnet. Sonnet ist das
# breiteste Tier im Max-Abo und ein anderer Filter-Kontext als Opus.
return "sonnet"
# ── Zustand ─────────────────────────────────────────────────────────
def _load() -> dict:
try:
if STATE_FILE.exists():
data = json.loads(STATE_FILE.read_text(encoding="utf-8")) or {}
if isinstance(data, dict):
data.setdefault("incidents", [])
data.setdefault("quarantine", [])
return data
except Exception as exc:
logger.warning("[safeguard] Zustand lesen fehlgeschlagen: %s", exc)
return {"incidents": [], "quarantine": []}
def _save(data: dict) -> None:
try:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = STATE_FILE.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(STATE_FILE)
except Exception as exc:
logger.warning("[safeguard] Zustand schreiben fehlgeschlagen: %s", exc)
def record_block(project_id: str, turn_ts: str, model: str, fallback: str,
text: str = "", recovered: bool = False) -> None:
"""Einen Vorfall vermerken. turn_ts = der User-Turn, der mitlief."""
with _lock:
data = _load()
data["incidents"].append({
"at": _now(),
"project_id": project_id or "",
"turn_ts": turn_ts or "",
"model": model or "",
"fallback": fallback or "",
"recovered": bool(recovered),
"code": detail_code(text),
"snippet": (text or "").strip()[:300],
})
data["incidents"] = data["incidents"][-MAX_INCIDENTS:]
_save(data)
def mark_recovered(turn_ts: str, model: str, fallback_used: str) -> None:
"""Nachtragen, dass der Ausweichversuch geklappt hat (nur Anzeige)."""
with _lock:
data = _load()
for inc in reversed(data["incidents"]):
if inc.get("turn_ts") == turn_ts and norm_model(inc.get("model", "")) == norm_model(model):
inc["recovered"] = True
inc["fallback"] = fallback_used
break
_save(data)
def blocked_models(project_id: str, turn_timestamps: Iterable[str]) -> set:
"""Modelle, die an den Turns DIESES Fensters schon gescheitert sind.
Der Abgleich laeuft ueber die Turn-Zeitstempel: rollt der ausloesende Turn
aus dem Fenster, taucht er hier nicht mehr auf und das Projekt darf wieder
aufs Hauptmodell. Kein Aufraeumen, kein Ablaufdatum von Hand.
"""
want = set(turn_timestamps or ())
if not want:
return set()
pid = project_id or ""
out = set()
for inc in _load()["incidents"]:
if (inc.get("project_id") or "") != pid:
continue
ts = inc.get("turn_ts") or ""
if ts and ts in want:
m = norm_model(inc.get("model", ""))
if m:
out.add(m)
return out
# ── Quarantaene ─────────────────────────────────────────────────────
def quarantine(turn_ts: str, project_id: str = "", reason: str = "") -> None:
if not turn_ts:
return
with _lock:
data = _load()
if any(q.get("turn_ts") == turn_ts for q in data["quarantine"]):
return
data["quarantine"].append({
"turn_ts": turn_ts, "project_id": project_id or "",
"at": _now(), "reason": reason or "",
})
_save(data)
logger.warning("[safeguard] Turn %s in Quarantaene (Projekt %r)", turn_ts, project_id or "(main)")
def unquarantine(turn_ts: str) -> bool:
with _lock:
data = _load()
before = len(data["quarantine"])
data["quarantine"] = [q for q in data["quarantine"] if q.get("turn_ts") != turn_ts]
changed = len(data["quarantine"]) != before
if changed:
_save(data)
if changed:
logger.info("[safeguard] Quarantaene aufgehoben: %s", turn_ts)
return changed
def quarantined_ts() -> set:
return {q.get("turn_ts") for q in _load()["quarantine"] if q.get("turn_ts")}
def filter_window(turns: list) -> list:
"""Quarantaenierte Turns aus dem Prompt-Fenster nehmen.
Greift NUR auf dem Weg zum Modell. Gespeichert bleibt alles — die
Diagnostic und jeder Export sehen den Turn weiter.
"""
q = quarantined_ts()
if not q:
return turns
return [t for t in turns if getattr(t, "ts", "") not in q]
# ── Diagnostic ──────────────────────────────────────────────────────
def status(limit: int = 25) -> dict:
data = _load()
inc = data["incidents"][-limit:][::-1]
return {
"incidents": inc,
"total": len(data["incidents"]),
"quarantine": data["quarantine"],
"fallback_model": str(_runtime_cfg().get("brainFallbackModel") or "").strip(),
"state_file": str(STATE_FILE),
}
def clear(project_id: Optional[str] = None) -> int:
"""Vorfaelle loeschen (Anzeige + Taint). Quarantaene bleibt — die wird
einzeln aufgehoben, damit nicht ein Klick alle Blockaden zurueckholt."""
with _lock:
data = _load()
before = len(data["incidents"])
if project_id is None:
data["incidents"] = []
else:
pid = project_id or ""
data["incidents"] = [i for i in data["incidents"]
if (i.get("project_id") or "") != pid]
removed = before - len(data["incidents"])
_save(data)
return removed
+330 -7
View File
@@ -765,6 +765,17 @@ class ARIABridge:
# "task": asyncio.Task}. Wir bruecken rohes RFB-TCP (QEMU-VNC auf dem
# Host) <-> RVS (vnc_data/vnc_input, Base64-in-JSON).
self._vnc_sessions: dict[str, dict] = {}
# ── Satelliten-Port-Tunnel ───────────────────────────────────
# ARIA sitzt hier, der Dienst steht in einem fremden LAN. Der Satellit
# haelt dorthin die Verbindung; wir machen daraus einen GANZ NORMALEN
# lokalen Port. Damit kann alles im Stack einfach draufzugreifen
# (MCP-Client, curl, Browser) statt eine Sonder-API zu brauchen.
# _tunnel_listeners: port -> {"server", "satellite", "target", "port"}
# _tunnel_conns: tunnelId -> {"writer", "opened" (Future)}
self._tunnel_listeners: dict[int, dict] = {}
self._tunnel_conns: dict[str, dict] = {}
self._TUNNEL_PORT_BASE = int(os.environ.get("TUNNEL_PORT_BASE", "19000"))
self._TUNNEL_PORT_MAX = int(os.environ.get("TUNNEL_PORT_MAX", "19099"))
# VNC-Host: das aria-net-Gateway (dort bindet der Brain QEMUs VNC).
# host.docker.internal zeigt faelschlich auf docker0 (172.17.0.1) → refused.
self._vnc_host: str = (os.environ.get("ARIA_VNC_HOST")
@@ -3599,6 +3610,42 @@ class ARIABridge:
asyncio.create_task(self._check_desktop(session, port))
return
elif msg_type == "sat_tunnel_opened":
# Antwort des Satelliten auf unseren Verbindungswunsch.
tid = (payload.get("tunnelId") or "").strip()
conn = self._tunnel_conns.get(tid)
fut = conn.get("opened") if conn else None
if fut is not None and not fut.done():
fut.set_result(payload)
return
elif msg_type == "sat_tunnel_data":
# LAN -> lokaler Socket. Der Satellit schickt dasselbe Feld in die
# andere Richtung; unterschieden wird ueber die tunnelId, die nur
# hier bekannt ist.
tid = (payload.get("tunnelId") or "").strip()
conn = self._tunnel_conns.get(tid)
if conn:
try:
conn["writer"].write(base64.b64decode(payload.get("b64") or ""))
except Exception as exc:
logger.warning("[tunnel] schreiben (%s) fehlgeschlagen: %s", tid[:8], exc)
return
elif msg_type == "sat_tunnel_closed":
tid = (payload.get("tunnelId") or "").strip()
conn = self._tunnel_conns.pop(tid, None)
if conn:
fut = conn.get("opened")
if fut is not None and not fut.done():
fut.set_result({"ok": False,
"error": payload.get("reason") or "geschlossen"})
try:
conn["writer"].close()
except Exception:
pass
return
elif msg_type == "vnc_open":
session = str(payload.get("session") or "main")
try:
@@ -4700,6 +4747,57 @@ class ARIABridge:
elif method in ("GET", "POST") and path == "/internal/worker-list":
# Diagnostic/Brain fragt: welche Compute-Worker sind online (Flotte).
await _send_response(writer, 200, {"ok": True, "workers": self._worker_list()})
elif method == "POST" and path == "/internal/tunnel":
# Brain-Tool: Port aus einem Satelliten-LAN lokal verfuegbar
# machen. body: {op: open|close|list, satellite, target,
# port, localPort?}
try:
data = json.loads(body.decode("utf-8", "ignore"))
except Exception as exc:
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
return
op = (data.get("op") or "open").strip()
if op == "list":
await _send_response(writer, 200, {"ok": True, "tunnels": self.tunnel_list()})
elif op == "close":
res = await self.tunnel_stop(int(data.get("localPort") or 0))
await _send_response(writer, 200, res)
else:
res = await self.tunnel_listen(
(data.get("satellite") or "").strip(),
(data.get("target") or "").strip(),
int(data.get("port") or 0),
int(data.get("localPort") or 0))
await _send_response(writer, 200, res)
elif method == "POST" and path == "/internal/display-name":
# Anzeigename fuer einen Host-Agenten oder Satelliten.
# body: {kind: host|satellite, id, name}
try:
data = json.loads(body.decode("utf-8", "ignore"))
except Exception as exc:
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
return
res = self._display_name_set((data.get("kind") or "").strip(),
(data.get("id") or "").strip(),
data.get("name") or "")
await _send_response(writer, 200, res)
elif method == "POST" and path == "/internal/satellite-name":
# Brain/Diagnostic: Geraetenamen vergeben. body: {satellite, mac|ip, name}
try:
data = json.loads(body.decode("utf-8", "ignore"))
except Exception as exc:
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
return
await self._send_to_rvs({
"type": "sat_name_set",
"payload": {"satellite": data.get("satellite") or "",
"mac": data.get("mac") or "",
"ip": data.get("ip") or "",
"name": data.get("name") or "",
"requestId": uuid.uuid4().hex},
"timestamp": int(time.time() * 1000),
})
await _send_response(writer, 200, {"ok": True})
elif method == "POST" and path == "/internal/host-list":
# Brain fragt: welche Host-Agenten (Rechner) sind online + Capabilities.
await _send_response(writer, 200, {"ok": True, "hosts": self._host_list()})
@@ -4903,6 +5001,107 @@ class ARIABridge:
"timestamp": int(time.time() * 1000),
})
# ── Satelliten-Port-Tunnel ──────────────────────────────────────
async def tunnel_listen(self, satellite: str, target: str, port: int,
local_port: int = 0) -> dict:
"""Macht aus <satellite>/<target>:<port> einen lokalen Port.
Jede eingehende Verbindung auf dem lokalen Port wird einzeln durch den
Satelliten zum Ziel gefuehrt — mehrere parallele Verbindungen sind also
kein Problem (ein Browser oeffnet gern sechs).
"""
if local_port and not (self._TUNNEL_PORT_BASE <= local_port <= self._TUNNEL_PORT_MAX):
return {"ok": False, "error":
f"localPort muss zwischen {self._TUNNEL_PORT_BASE} und "
f"{self._TUNNEL_PORT_MAX} liegen."}
if not local_port:
for p in range(self._TUNNEL_PORT_BASE, self._TUNNEL_PORT_MAX + 1):
if p not in self._tunnel_listeners:
local_port = p
break
if not local_port:
return {"ok": False, "error": "Keine freien Tunnel-Ports mehr."}
if local_port in self._tunnel_listeners:
await self.tunnel_stop(local_port)
async def on_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
tid = uuid.uuid4().hex
opened: asyncio.Future = asyncio.get_event_loop().create_future()
self._tunnel_conns[tid] = {"writer": writer, "opened": opened}
await self._send_to_rvs({
"type": "sat_tunnel_open",
"payload": {"satellite": satellite, "tunnelId": tid,
"target": target, "port": port},
"timestamp": int(time.time() * 1000),
})
try:
res = await asyncio.wait_for(opened, timeout=20)
except asyncio.TimeoutError:
res = {"ok": False, "error": "Satellit antwortet nicht"}
if not res.get("ok"):
logger.warning("[tunnel] %s:%d nicht erreichbar: %s",
target, port, res.get("error"))
self._tunnel_conns.pop(tid, None)
try:
writer.close()
except Exception:
pass
return
try:
while True:
data = await reader.read(32768)
if not data:
break
await self._send_to_rvs({
"type": "sat_tunnel_data",
"payload": {"tunnelId": tid,
"b64": base64.b64encode(data).decode("ascii")},
"timestamp": int(time.time() * 1000),
})
except Exception:
pass
finally:
self._tunnel_conns.pop(tid, None)
await self._send_to_rvs({
"type": "sat_tunnel_close",
"payload": {"satellite": satellite, "tunnelId": tid},
"timestamp": int(time.time() * 1000),
})
try:
writer.close()
except Exception:
pass
try:
server = await asyncio.start_server(on_client, "0.0.0.0", local_port)
except Exception as exc:
return {"ok": False, "error": f"Port {local_port} nicht belegbar: {exc}"}
self._tunnel_listeners[local_port] = {
"server": server, "satellite": satellite, "target": target, "port": port}
logger.info("[tunnel] lokal :%d -> %s/%s:%d", local_port, satellite, target, port)
# Adresse aus Sicht der ANDEREN Container (Brain/Proxy liegen im selben
# Docker-Netz) — damit ARIA weiss, was sie ansprechen soll.
return {"ok": True, "localPort": local_port,
"address": f"aria-bridge:{local_port}",
"satellite": satellite, "target": target, "port": port}
async def tunnel_stop(self, local_port: int) -> dict:
entry = self._tunnel_listeners.pop(int(local_port), None)
if not entry:
return {"ok": False, "error": f"Auf Port {local_port} laeuft kein Tunnel."}
try:
entry["server"].close()
except Exception:
pass
logger.info("[tunnel] lokal :%s geschlossen", local_port)
return {"ok": True, "localPort": int(local_port)}
def tunnel_list(self) -> list[dict]:
return [{"localPort": p, "address": f"aria-bridge:{p}",
"satellite": e["satellite"], "target": e["target"], "port": e["port"]}
for p, e in sorted(self._tunnel_listeners.items())]
async def _vnc_open(self, session: str, port: int) -> None:
"""Oeffnet die TCP-Verbindung zur QEMU-VNC und streamt RFB-Bytes als
vnc_data ueber RVS. Bestehende Session wird vorher geschlossen."""
@@ -4969,15 +5168,37 @@ class ARIABridge:
def _satellite_list(self) -> list[dict]:
"""Bekannte Satelliten (frisch = in den letzten 5 Min gesehen)."""
now = time.time()
aliases = self._display_names("satellite")
out = []
for s in self._satellites.values():
own = s.get("location") or s["id"]
alias = aliases.get(s["id"], "")
out.append({
"id": s["id"], "location": s.get("location") or s["id"],
"id": s["id"], "location": alias or own,
"agentName": own, "alias": alias,
"caps": s.get("caps") or [], "control": bool(s.get("control")),
"online": (now - s.get("last_seen", 0)) < 300,
})
return out
def _resolve_satellite(self, who: str) -> str:
"""Anzeigename ODER echte Kennung -> echte Kennung.
Gleicher Grund wie bei _resolve_host: der Satellit vergleicht die
Adresse gegen seine eigene ID/Location und kennt den Alias nicht.
"""
w = (who or "").strip()
if not w or w in self._satellites:
return w
wl = w.lower()
for sid, alias in self._display_names("satellite").items():
if alias.strip().lower() == wl:
return sid
for sid, s in self._satellites.items():
if (s.get("location") or "").strip().lower() == wl:
return sid
return w
# worker_ping kommt alle ~10s; nach 35s ohne Ping gilt ein Worker als offline.
WORKER_OFFLINE_S = 35
@@ -5041,6 +5262,8 @@ class ARIABridge:
Muster identisch zu _flux_generate (requestId → Future)."""
if self.ws_rvs is None:
return {"ok": False, "error": "RVS-Verbindung nicht aktiv"}
# Anzeigename -> echte Kennung, sonst erkennt sich der Satellit nicht.
satellite = self._resolve_satellite(satellite)
request_id = str(uuid.uuid4())
loop = asyncio.get_event_loop()
future: asyncio.Future = loop.create_future()
@@ -5068,15 +5291,113 @@ class ARIABridge:
finally:
self._pending_sat.pop(request_id, None)
# ── Anzeigenamen fuer Host-Agenten ───────────────────────────────
#
# Der Name eines Agenten kommt von ihm selbst: beim Desktop-Agenten aus
# HOST_NAME in seiner .env, beim Android-Agenten aus der App-Konfiguration.
# Umbenennen hiesse also: auf den Rechner, Datei aendern, neu starten — bzw.
# beim Fire TV neu provisionieren.
#
# Darum hier ein Alias: hostId -> Anzeigename, zentral in der Bridge. Wirkt
# sofort und fuer JEDEN schon ausgerollten Agenten, ohne ihn anzufassen.
#
# WICHTIG: der Alias wird beim Adressieren wieder aufgeloest (siehe
# _resolve_host). Sonst koennte man einen Agenten zwar umbenennen, ihn unter
# dem neuen Namen aber nicht mehr ansprechen — er vergleicht intern gegen
# seine EIGENE Kennung.
# Eine Datei fuer beides: {"host": {id: name}, "satellite": {id: name}}
_DISPLAY_NAMES_FILE = "/shared/config/display_names.json"
def _display_names(self, kind: str) -> dict:
try:
with open(self._DISPLAY_NAMES_FILE) as f:
data = json.load(f)
sub = (data or {}).get(kind)
return sub if isinstance(sub, dict) else {}
except FileNotFoundError:
return {}
except Exception as exc:
logger.warning("[names] display_names.json unlesbar: %s", exc)
return {}
def _display_name_set(self, kind: str, ident: str, name: str) -> dict:
ident = (ident or "").strip()
if kind not in ("host", "satellite"):
return {"ok": False, "error": "kind muss host oder satellite sein"}
if not ident:
return {"ok": False, "error": "id ist Pflicht"}
try:
with open(self._DISPLAY_NAMES_FILE) as f:
data = json.load(f)
if not isinstance(data, dict):
data = {}
except Exception:
data = {}
sub = data.get(kind) if isinstance(data.get(kind), dict) else {}
if (name or "").strip():
sub[ident] = (name or "").strip()[:60]
else:
sub.pop(ident, None)
data[kind] = sub
try:
# Verzeichnis aus dem Dateipfad ableiten statt fest zu verdrahten —
# sonst laesst sich _DISPLAY_NAMES_FILE nicht umbiegen (Test, anderes
# Deployment) und das Schreiben scheitert stillschweigend.
os.makedirs(os.path.dirname(self._DISPLAY_NAMES_FILE) or ".", exist_ok=True)
tmp = self._DISPLAY_NAMES_FILE + ".tmp"
with open(tmp, "w") as f:
json.dump(data, f, indent=2)
os.replace(tmp, self._DISPLAY_NAMES_FILE)
except Exception as exc:
return {"ok": False, "error": str(exc)}
logger.info("[names] %s %s -> %r", kind, ident, sub.get(ident) or "(entfernt)")
return {"ok": True, "kind": kind, "id": ident, "name": sub.get(ident, "")}
def _host_names(self) -> dict:
return self._display_names("host")
def _resolve_host(self, who: str) -> str:
"""Anzeigename ODER echte Kennung -> echte Kennung.
Ohne das waere ein umbenannter Agent nicht mehr ansprechbar: er
vergleicht die Adresse gegen seine eigene HOST_ID/HOST_NAME und kennt
den Alias nicht.
"""
w = (who or "").strip()
if not w:
return w
if w in self._hosts:
return w
wl = w.lower()
for hid, alias in self._host_names().items():
if alias.strip().lower() == wl:
return hid
for hid, h in self._hosts.items():
if (h.get("name") or "").strip().lower() == wl:
return hid
return w
def _host_list(self) -> list[dict]:
"""Bekannte Host-Agenten (frisch = in den letzten 5 Min gesehen)."""
now = time.time()
return [{
"hostId": h["hostId"], "name": h.get("name") or h["hostId"],
"os": h.get("os") or "", "caps": h.get("caps") or [],
"control": bool(h.get("control")),
"online": (now - h.get("last_seen", 0)) < 300,
} for h in self._hosts.values()]
aliases = self._host_names()
out = []
for h in self._hosts.values():
hid = h["hostId"]
own = h.get("name") or hid
alias = aliases.get(hid, "")
out.append({
"hostId": hid,
"name": alias or own,
# Eigener Name des Agenten — damit in der Oberflaeche sichtbar
# bleibt, dass da ein Alias drueberliegt.
"agentName": own,
"alias": alias,
"os": h.get("os") or "", "caps": h.get("caps") or [],
"control": bool(h.get("control")),
"online": (now - h.get("last_seen", 0)) < 300,
})
return out
_AGENT_HISTORY_DIR = "/shared/config/agent_history"
_AGENT_HISTORY_MAX = 200
@@ -5130,6 +5451,8 @@ class ARIABridge:
host_result. Muster identisch zu _satellite_request."""
if self.ws_rvs is None:
return {"ok": False, "error": "RVS-Verbindung nicht aktiv"}
# Anzeigename -> echte Kennung, sonst erkennt sich der Agent nicht.
host = self._resolve_host(host)
request_id = str(uuid.uuid4())
loop = asyncio.get_event_loop()
future: asyncio.Future = loop.create_future()
+261 -10
View File
@@ -410,9 +410,16 @@
</div>
<!-- Multi-Threading: Kontext-Strip ueber dem Chat. Jeder Kontext
(Hauptchat + aktive Projekte) als kompakte Karte mit Status-Dot.
Tap wechselt den Focus — Chat-Box filtert dann auf diesen Kontext. -->
<div id="chat-context-strip" style="display:flex;gap:6px;overflow-x:auto;padding:6px 4px;margin-bottom:6px;border-bottom:1px solid #1E1E2E;">
<!-- wird von renderContextStrip() befuellt -->
Tap wechselt den Focus — Chat-Box filtert dann auf diesen Kontext.
Der „+ Projekt"-Knopf steht AUSSERHALB des scrollenden Streifens,
damit er bei vielen Projekten nicht nach rechts rausrutscht. -->
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;border-bottom:1px solid #1E1E2E;">
<div id="chat-context-strip" style="display:flex;gap:6px;overflow-x:auto;padding:6px 4px;flex:1;min-width:0;">
<!-- wird von renderContextStrip() befuellt -->
</div>
<button class="btn secondary" onclick="openCreateProjectModal()"
title="Neues Projekt anlegen — wird direkt angelegt, aktiviert und hier fokussiert"
style="flex:0 0 auto;padding:4px 10px;font-size:11px;margin-right:4px;">+ Projekt</button>
</div>
<div class="chat-box" id="chat-box"></div>
<div id="thinking-indicator" style="display:none;padding:6px 10px;font-size:12px;color:#FFD60A;background:#1E1E2E;border-radius:0 0 6px 6px;margin-top:-8px;margin-bottom:8px;align-items:center;justify-content:space-between;">
@@ -656,6 +663,28 @@
<div id="model-desc" style="font-size:10px;color:#6a6a88;margin-bottom:6px;min-height:12px;"></div>
<div id="model-test-result" style="font-size:11px;color:#8888AA;margin-bottom:8px;padding:6px 8px;background:#12121E;border:1px solid #2A2A3E;border-radius:6px;display:none;white-space:pre-wrap;"></div>
<div id="model-status" style="font-size:10px;color:#8888AA;margin-bottom:8px;"></div>
<!-- Ausweich-Modell: Rettung, wenn Anthropics Sicherheitsfilter zuschlaegt -->
<div style="border-top:1px solid #2A2A3E;margin-top:10px;padding-top:10px;margin-bottom:10px;">
<div style="font-size:11px;color:#8888AA;line-height:1.5;margin-bottom:8px;">
<strong style="color:#E0E0F0;">Ausweich-Modell</strong> — wenn Anthropics
Sicherheitsfilter eine Anfrage blockt („safeguards flagged this message“,
trifft gern Netzwerk- und Security-Themen), stellt ARIA dieselbe Frage
automatisch nochmal mit diesem Modell. Der Filter hängt am Modell;
Anthropic rät selbst zu „change your model“.
<br>
<span style="color:#3FFF9F;">✓ Kein Neustart nötig</span> — gilt ab dem
nächsten Vorfall.
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:#8888AA;white-space:nowrap;">Ausweich-Modell:</span>
<select id="setting-fallback-select" style="flex:1;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-family:inherit;font-size:12px;">
<option value="">Automatisch (anderes Tier als oben)</option>
</select>
<button class="btn" onclick="saveFallbackModel()" style="padding:4px 8px;font-size:10px;">Setzen</button>
</div>
</div>
<details style="font-size:11px;color:#8888AA;">
<summary style="cursor:pointer;">Erweitert: freie Model-ID</summary>
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
@@ -672,6 +701,33 @@
</div>
</div>
<!-- Sicherheitsfilter: Vorfaelle + Quarantaene -->
<div class="settings-section">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h2 style="margin:0;">🛡 Sicherheitsfilter</h2>
<div>
<button class="btn secondary" onclick="loadSafeguard()" style="padding:4px 10px;font-size:11px;">🔄 Aktualisieren</button>
<button class="btn secondary" onclick="clearSafeguard()" style="padding:4px 10px;font-size:11px;" title="Vorfallsliste leeren — danach läuft wieder das Hauptmodell">Liste leeren</button>
</div>
</div>
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;line-height:1.55;">
Anthropics Filter meldet sich nicht als Fehler, sondern als ganz normale
Antwort — der Fehlertext stünde sonst als ARIAs Wort im Verlauf, und weil
der Verlauf bei jedem Turn mitgeht, wäre das Projekt danach tot.
Darum zwei Stufen:
<strong style="color:#E0E0F0;">1.</strong> dieselbe Frage nochmal mit dem
Ausweich-Modell (oben einstellbar).
<strong style="color:#E0E0F0;">2.</strong> blockt auch das, liegt es am
Verlauf: der auslösende Turn fliegt aus dem Arbeitsgedächtnis — gelöscht
wird nichts, du holst ihn hier mit einem Klick zurück.
<span style="color:#555570;">Vermerke verfallen von selbst, sobald der Turn
aus dem Fenster gerollt ist.</span>
</div>
<div id="safeguard-list" class="card" style="padding:0;">
<div style="padding:14px;color:#8888AA;font-size:12px;">Lade …</div>
</div>
</div>
<div class="settings-section">
<h2>Datenschutz — was Claude Code von sich aus sendet</h2>
<div class="card" style="max-width:620px;">
@@ -2180,6 +2236,8 @@
try { loadBrainStatus(); } catch {}
// Sprachmodell-Dropdown befuellen (kuratierte Tier-Liste vom Proxy)
try { loadModelList(); } catch {}
// Sicherheitsfilter-Vorfaelle (Blockaden + Quarantaene)
try { loadSafeguard(); } catch {}
// Lokales-LLM: erst Modell-Liste (Dropdown), dann Config (Auswahl setzen)
try { loadLocalModelList().then(() => loadLocalLlmConfig()); } catch {}
};
@@ -2873,6 +2931,11 @@
const has = Array.from(sel.options).some(o => o.value === msg.model);
if (has) { sel.value = msg.model; onModelSelectChange(); }
}
const fsel = document.getElementById('setting-fallback-select');
if (fsel && msg.fallbackModel !== undefined) {
const fhas = Array.from(fsel.options).some(o => o.value === msg.fallbackModel);
fsel.value = fhas ? msg.fallbackModel : '';
}
if (st) {
st.textContent = msg.info || msg.error || '';
st.style.color = msg.error ? '#FF6B6B' : '#34C759';
@@ -3997,13 +4060,29 @@
const description = document.getElementById('project-create-desc').value.trim();
if (!name) { alert('Name darf nicht leer sein.'); return; }
try {
await fetch('/api/brain/projects/create', {
const r = await fetch('/api/brain/projects/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description }),
});
if (!r.ok) {
let why = 'HTTP ' + r.status;
try { const e = await r.json(); if (e && e.detail) why = e.detail; } catch {}
throw new Error(why);
}
const d = await r.json().catch(() => null);
closeCreateProjectModal();
// Beide Projekt-Oberflaechen nachziehen: die Liste in den Einstellungen
// UND den Kontext-Streifen ueber dem Chat. Vorher wurde nur die Liste
// aktualisiert — vom Chat aus angelegt blieb das neue Projekt bis zum
// naechsten 15s-Poll unsichtbar.
loadProjects();
await refreshDiagProjectsCache();
// create_project() setzt das Projekt serverseitig aktiv — der Focus im
// Diagnostic ist davon getrennt, also hier mitziehen. Sonst legt man
// ein Projekt an und schreibt weiter in den Hauptchat.
const pid = (d && d.id) ? d.id : '';
if (pid) switchDiagFocus(pid);
} catch (e) { alert('Anlegen fehlgeschlagen: ' + e.message); }
}
@@ -5045,7 +5124,10 @@
'<div style="display:flex;align-items:center;gap:8px;">' +
'<div style="flex:1;"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + dot + ';margin-right:6px;"></span>' +
'<strong style="color:#E0E0F0;">💻 ' + escapeHtml(h.name || h.hostId) + '</strong> ' +
(h.alias ? '<span style="color:#666;font-size:11px;" title="Der Agent selbst nennt sich so">(' + escapeHtml(h.agentName || '') + ')</span> ' : '') +
'<span style="color:#666;font-size:11px;">id=' + escapeHtml(h.hostId) + '</span></div>' +
'<button onclick="renameEntity(\'host\',\'' + escapeHtml(h.hostId) + '\',\'' + escapeHtml(h.alias || '') + '\',\'' + escapeHtml(h.agentName || h.hostId) + '\')" title="Anzeigenamen vergeben — ARIA kann den Agenten danach so ansprechen"' +
' style="background:#12121E;color:#8888AA;border:1px solid #1E1E2E;border-radius:6px;padding:4px 9px;font-size:12px;cursor:pointer;">✎</button>' +
'<button onclick="openAgentHistory(\'' + escapeHtml(h.hostId) + '\')" style="background:#12121E;color:#FFD60A;border:1px solid #FFD60A44;border-radius:6px;padding:4px 10px;font-size:12px;cursor:pointer;">🕘 Historie</button>' +
'<button onclick="forgetHost(\'' + escapeHtml(h.hostId) + '\',\'' + escapeHtml(h.name || h.hostId) + '\')" title="Aus der Liste entfernen (z.B. alter Name nach dem Umbenennen). Meldet sich der Agent wieder, erscheint er von selbst."' +
' style="background:#12121E;color:#8888AA;border:1px solid #1E1E2E;border-radius:6px;padding:4px 9px;font-size:12px;cursor:pointer;margin-left:4px;">✕</button>' +
@@ -5378,6 +5460,40 @@
renderSatellites();
send({ action: 'sat_discover', satellite: id });
}
/** Anzeigenamen fuer einen Satelliten oder Host-Agenten vergeben.
*
* Warum nicht direkt am Geraet? Ein Agent nennt sich so, wie es in SEINER
* .env steht — umbenennen hiesse: auf den Rechner, Datei aendern, neu
* starten (beim Fire TV sogar neu provisionieren). Der Alias hier wirkt
* sofort und fuer jeden schon ausgerollten Agenten. Die Bridge loest ihn
* beim Ansprechen wieder auf, damit sich der Agent weiter angesprochen
* fuehlt. */
function renameEntity(kind, id, currentAlias, ownName) {
const was = kind === 'satellite' ? 'Satellit' : 'Agent';
const name = prompt(
was + ' umbenennen\n\n'
+ 'Heißt sich selbst: ' + ownName + '\n'
+ 'ARIA spricht ihn danach unter dem neuen Namen an.\n'
+ 'Leer lassen = eigener Name gilt wieder.',
currentAlias || '');
if (name === null) return;
send({ action: 'set_display_name', kind: kind, id: id, name: name.trim() });
}
/** Geraetenamen vergeben. Danach kann ARIA das Geraet so ansprechen
* ("mach mir Port 5000 von duffy-desktop auf") statt ueber eine IP.
* Gespeichert wird an der MAC — ein IP-Wechsel per DHCP verliert den
* Namen dann nicht. */
function renameSatDevice(satId, mac, ip, current) {
const name = prompt(
'Name für ' + (ip || mac) + '\n\n'
+ 'ARIA spricht das Gerät danach so an. Leer lassen = Namen entfernen.',
current || '');
if (name === null) return; // abgebrochen
send({ action: 'sat_name_set', satellite: satId, mac: mac, ip: ip, name: name.trim() });
setTimeout(() => scanSatellite(satId), 400);
}
function renderSatellites() {
const box = document.getElementById('sat-list');
if (!box) return;
@@ -5409,6 +5525,12 @@
if (d.adbCapable) tags.push('ADB');
if (d.mac) tags.push(escapeHtml(d.mac));
const tagStr = tags.length ? ' <span style="color:#666;">(' + tags.join(' · ') + ')</span>' : '';
// Namen vergeben — danach ist das Geraet fuer ARIA ansprechbar
// ("duffy-desktop" statt einer IP).
const nameBtn = (d.ip || d.mac) ? '<button class="btn secondary" title="Namen vergeben — ARIA kann das Gerät dann so ansprechen" ' +
'onclick="renameSatDevice(\'' + escapeHtml(s.id) + '\',\'' + escapeHtml(d.mac || '') + '\',\'' +
escapeHtml(d.ip || '') + '\',\'' + escapeHtml(d.name || '') + '\')" ' +
'style="padding:1px 6px;font-size:11px;margin-left:6px;">✎</button>' : '';
const hasCreds = d.ip && (satCreds[s.id] || {})[d.ip] && (satCreds[s.id][d.ip] || []).length;
const keyBtn = d.ip ? '<button class="btn secondary" title="Zugangsdaten (SNMP / HTTP / FritzBox)" ' +
'onclick="openDeviceCreds(\'' + escapeHtml(s.id) + '\',\'' + escapeHtml(d.ip) + '\',\'' + escapeHtml(d.name || d.ip) + '\')" ' +
@@ -5418,12 +5540,22 @@
const apkBtn = canAdb ? '<button class="btn secondary" title="ARIA-Agent (APK) auf dieses Gerät ausrollen" ' +
'onclick="openApkDeploy(\'' + escapeHtml(s.id) + '\',\'' + escapeHtml(d.ip) + '\',\'' + escapeHtml(d.name || d.ip) + '\')" ' +
'style="padding:1px 6px;font-size:11px;margin-left:6px;">📦</button>' : '';
return '<div style="padding:3px 6px;background:#0D0D1A;border-radius:4px;display:flex;align-items:center;">' +
'<div style="flex:1;min-width:0;">' +
'<span style="color:#E0E0F0;">' + escapeHtml(d.name || d.id || '?') + '</span> ' +
// Der Rechner, auf dem der Satellit selbst laeuft. Er ist ein
// Host wie jeder andere (und oft der interessanteste — dort
// laufen meist noch weitere Dienste), nur eben zusaetzlich
// Traeger des Satelliten. Darum normal auflisten + markieren.
const satBadge = d.isSatellite
? '<span title="Auf diesem Rechner läuft der Satellit selbst" ' +
'style="margin-right:5px;">🛰️</span>'
: '';
const rowBg = d.isSatellite ? '#111426' : '#0D0D1A';
const rowBorder = d.isSatellite ? ';border-left:2px solid #0096FF' : '';
return '<div style="padding:3px 6px;background:' + rowBg + ';border-radius:4px;display:flex;align-items:center' + rowBorder + ';">' +
'<div style="flex:1;min-width:0;">' + satBadge +
'<span style="color:#E0E0F0;">' + escapeHtml(d.name || d.hostname || d.id || '?') + '</span> ' +
'<span style="color:#0096FF;">[' + escapeHtml(d.type || '?') + ']</span> ' +
'<span style="color:#8888AA;">' + escapeHtml(d.ip || '') + '</span>' + tagStr +
'</div>' + keyBtn + apkBtn +
'</div>' + nameBtn + keyBtn + apkBtn +
'</div>';
}).join('') + '</div>';
}
@@ -5433,9 +5565,11 @@
return '<div style="border:1px solid #1E1E2E;border-radius:8px;padding:10px;margin-bottom:8px;">' +
'<div style="display:flex;justify-content:space-between;align-items:center;">' +
'<div><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + dot + ';margin-right:6px;"></span>' +
'<strong style="color:#E0E0F0;">' + escapeHtml(s.location || s.id) + '</strong> ' +
'<strong style="color:#E0E0F0;">🛰️ ' + escapeHtml(s.location || s.id) + '</strong> ' +
(s.alias ? '<span style="color:#666;font-size:11px;" title="Der Satellit selbst nennt sich so">(' + escapeHtml(s.agentName || '') + ')</span> ' : '') +
'<span style="color:#666;font-size:11px;">id=' + escapeHtml(s.id) + '</span></div>' +
'<button class="btn secondary" onclick="scanSatellite(\'' + escapeHtml(s.id) + '\')" style="padding:3px 8px;font-size:11px;"' +
'<button class="btn secondary" onclick="renameEntity(\'satellite\',\'' + escapeHtml(s.id) + '\',\'' + escapeHtml(s.alias || '') + '\',\'' + escapeHtml(s.agentName || s.id) + '\')" title="Anzeigenamen vergeben — ARIA kann den Satelliten danach so ansprechen" style="padding:3px 8px;font-size:11px;">✎</button>' +
'<button class="btn secondary" onclick="scanSatellite(\'' + escapeHtml(s.id) + '\')" style="padding:3px 8px;font-size:11px;margin-left:4px;"' +
(scanning ? ' disabled' : '') + '>Geraete scannen</button>' +
'</div>' +
'<div style="color:#8888AA;font-size:11px;margin-top:4px;">kann: ' + escapeHtml(caps) + ' · ' + ctrl +
@@ -8089,6 +8223,14 @@
st.textContent = 'Anthropic-Katalog nicht erreichbar — nur die automatischen Tiers verfügbar.';
st.style.color = '#FFD60A';
}
// Ausweich-Dropdown aus derselben Liste — plus "automatisch" als
// erste Option. Gleiche Quelle, damit beide nie auseinanderlaufen.
const fsel = document.getElementById('setting-fallback-select');
if (fsel) {
const keep = fsel.value;
fsel.innerHTML = '<option value="">Automatisch (anderes Tier als oben)</option>' + sel.innerHTML;
if (keep) fsel.value = keep;
}
onModelSelectChange();
// Aktuelles Model vom Brain holen → markiert die richtige Option
loadModel();
@@ -8097,6 +8239,115 @@
}
}
// ── Sicherheitsfilter (Anthropic) ─────────────────────────────────
// Zwei Dinge in einem Panel: was blockiert wurde (Historie) und was gerade
// noch wirkt (Quarantaene). Nur Zweiteres ist eine Aktion.
function saveFallbackModel() {
const sel = document.getElementById('setting-fallback-select');
send({ action: 'set_fallback_model', model: sel ? sel.value : '' });
}
async function loadSafeguard() {
const box = document.getElementById('safeguard-list');
if (!box) return;
try {
const r = await fetch('/api/brain/safeguard/status');
if (!r.ok) throw new Error('HTTP ' + r.status);
renderSafeguard(await r.json());
} catch (e) {
box.innerHTML = `<div style="padding:14px;color:#FF6B6B;font-size:12px;">Status nicht ladbar: ${escapeHtml(e.message)}</div>`;
}
}
function _sgTime(iso) {
if (!iso) return '';
const ms = Date.parse(iso);
return isNaN(ms) ? iso : `${fmtMemTs(ms)} (${fmtMemAgo(ms)})`;
}
function renderSafeguard(d) {
const box = document.getElementById('safeguard-list');
if (!box) return;
const inc = d.incidents || [];
const quar = d.quarantine || [];
if (!inc.length && !quar.length) {
box.innerHTML = '<div style="padding:18px;color:#34C759;font-size:12px;text-align:center;">Noch nie blockiert worden. 👍</div>';
return;
}
const rows = [];
if (quar.length) {
rows.push(`<div style="padding:8px 12px;background:#1A1226;border-bottom:1px solid #2A2A3E;color:#FFD60A;font-size:11px;font-weight:600;">
Aus dem Arbeitsgedächtnis genommen (${quar.length})</div>`);
for (const q of quar) {
const proj = q.project_id ? `📁 ${escapeHtml(q.project_id)}` : '💬 Hauptchat';
rows.push(`<div style="padding:10px 12px;border-bottom:1px solid #1E1E2E;display:flex;gap:10px;align-items:flex-start;">
<div style="flex:1;min-width:0;">
<div style="color:#E0E0F0;font-size:12px;">${proj}
<span style="color:#555570;font-size:10px;margin-left:6px;">${escapeHtml(_sgTime(q.at))}</span></div>
<div style="color:#8888AA;font-size:11px;margin-top:3px;white-space:pre-wrap;word-break:break-word;">${escapeHtml(q.content || '(Turn nicht mehr im Fenster)')}</div>
<div style="color:#555570;font-size:10px;margin-top:3px;">${escapeHtml(q.reason || '')}</div>
</div>
<button class="btn secondary" onclick="unquarantineTurn('${escapeHtmlAttr(q.turn_ts)}')"
title="Turn wieder in den Verlauf aufnehmen — wenn er der Auslöser war, blockt der Filter erneut"
style="flex:0 0 auto;padding:3px 8px;font-size:10px;">↩ Zurückholen</button>
</div>`);
}
}
if (inc.length) {
rows.push(`<div style="padding:8px 12px;background:#12121E;border-bottom:1px solid #2A2A3E;color:#8888AA;font-size:11px;font-weight:600;">
Blockaden (${d.total || inc.length})</div>`);
for (const i of inc) {
const proj = i.project_id ? `📁 ${escapeHtml(i.project_id)}` : '💬 Hauptchat';
// "wirkt noch" = der Turn liegt noch im Fenster, also laeuft das
// Projekt weiter auf dem Ausweich-Modell. Das ist die Information,
// wegen der man hier ueberhaupt reinschaut.
const live = i.in_window
? '<span style="color:#FFD60A;font-size:10px;font-weight:600;margin-left:6px;">wirkt noch</span>'
: '<span style="color:#555570;font-size:10px;margin-left:6px;">abgelaufen</span>';
const state = i.recovered
? `<span style="color:#34C759;">→ ${escapeHtml(i.fallback || '?')} hat geantwortet</span>`
: '<span style="color:#FF6B6B;">auch der zweite Versuch blockiert</span>';
rows.push(`<div style="padding:9px 12px;border-bottom:1px solid #1E1E2E;">
<div style="color:#E0E0F0;font-size:12px;">${proj}
<span style="color:#555570;font-size:10px;margin-left:6px;">${escapeHtml(_sgTime(i.at))}</span>${live}</div>
<div style="font-size:11px;margin-top:3px;color:#8888AA;">
${escapeHtml(i.model || '?')} blockiert${i.code ? ' <span style="color:#B392F0;">[' + escapeHtml(i.code) + ']</span>' : ''} ${state}
</div>
</div>`);
}
}
box.innerHTML = rows.join('');
}
async function unquarantineTurn(ts) {
if (!confirm('Turn wieder in den Verlauf aufnehmen?\n\nWar er der Auslöser, '
+ 'blockt Anthropics Filter beim nächsten Mal erneut — dann landet '
+ 'er automatisch wieder hier.')) return;
try {
const r = await fetch('/api/brain/safeguard/unquarantine', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turn_ts: ts }),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
loadSafeguard();
} catch (e) { alert('Zurückholen fehlgeschlagen: ' + e.message); }
}
async function clearSafeguard() {
if (!confirm('Vorfallsliste leeren?\n\nDanach läuft wieder das Hauptmodell — '
+ 'auch in Projekten, die gerade aufs Ausweich-Modell ausweichen. '
+ 'Zurückgeholte Turns bleiben zurückgeholt.')) return;
try {
const r = await fetch('/api/brain/safeguard/clear', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
loadSafeguard();
} catch (e) { alert('Leeren fehlgeschlagen: ' + e.message); }
}
// ── Datenschutz-Schalter ──────────────────────────────────────────
function loadPrivacyFlags() {
const st = document.getElementById('privacy-flags-status');
+89 -2
View File
@@ -268,6 +268,10 @@ const RUNTIME_CONFIG_FIELDS = [
"RVS_HOST", "RVS_PORT", "RVS_TLS", "RVS_TOKEN",
"ARIA_AUTH_TOKEN", "WHISPER_MODEL", "WHISPER_LANGUAGE",
"brainModel",
// Ausweich-Modell, wenn Anthropics Sicherheitsfilter das Hauptmodell blockt.
// Das Brain liest es PRO Vorfall aus derselben Datei — Aenderung greift ohne
// Neustart. Leer = automatisch (anderes Tier als das Hauptmodell).
"brainFallbackModel",
// Groessen-Limit fuer Memory-Anhaenge in MB. Das Brain liest es pro Upload
// aus derselben Datei — Aenderung greift ohne Neustart.
"memoryAttachmentMaxMb",
@@ -619,13 +623,50 @@ function broadcastState() {
broadcast({ type: "state", state });
}
// ── Anzeigenamen fuer Satelliten + Host-Agenten ──────────────────
//
// Ein Agent/Satellit nennt sich selbst so, wie es in SEINER .env steht.
// Umbenennen hiesse also: auf den Rechner, Datei aendern, neu starten — beim
// Fire TV sogar neu provisionieren. Darum ein Alias, der hier zentral liegt.
//
// Dieselbe Datei nutzt auch die Bridge (sie liegt im geteilten /shared), und
// die loest den Alias beim Adressieren wieder auf. Sonst koennte man etwas
// umbenennen, es danach aber nicht mehr ansprechen.
const DISPLAY_NAMES_FILE = "/shared/config/display_names.json";
function readDisplayNames() {
try {
const d = JSON.parse(fs.readFileSync(DISPLAY_NAMES_FILE, "utf-8"));
return (d && typeof d === "object") ? d : {};
} catch { return {}; }
}
function setDisplayName(kind, id, name) {
if (!["host", "satellite"].includes(kind)) throw new Error("kind muss host oder satellite sein");
if (!id) throw new Error("id ist Pflicht");
const data = readDisplayNames();
const sub = (data[kind] && typeof data[kind] === "object") ? data[kind] : {};
const clean = String(name || "").trim().slice(0, 60);
if (clean) sub[id] = clean; else delete sub[id];
data[kind] = sub;
fs.mkdirSync("/shared/config", { recursive: true });
const tmp = DISPLAY_NAMES_FILE + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
fs.renameSync(tmp, DISPLAY_NAMES_FILE);
log("info", "server", `Anzeigename ${kind} ${id} -> ${clean || "(entfernt)"}`);
return clean;
}
// ── Satelliten-Registry (Aussenposten in fremden Netzen) ──────────
const satellites = new Map(); // id → {id, location, caps, control, last_seen}
function satelliteList() {
const now = Date.now();
const alias = readDisplayNames().satellite || {};
return Array.from(satellites.values()).map(s => ({
id: s.id, location: s.location, caps: s.caps, control: s.control, net: s.net || null,
id: s.id, location: alias[s.id] || s.location,
agentName: s.location, alias: alias[s.id] || "",
caps: s.caps, control: s.control, net: s.net || null,
online: (now - (s.last_seen || 0)) < 300000,
}));
}
@@ -639,8 +680,11 @@ const hosts = new Map(); // hostId → {hostId, name, os, caps, control, last_se
function hostList() {
const now = Date.now();
const alias = readDisplayNames().host || {};
return Array.from(hosts.values()).map(h => ({
hostId: h.hostId, name: h.name, os: h.os, caps: h.caps, control: h.control,
hostId: h.hostId, name: alias[h.hostId] || h.name,
agentName: h.name, alias: alias[h.hostId] || "",
os: h.os, caps: h.caps, control: h.control,
ip: h.ip || "", online: (now - (h.last_seen || 0)) < 300000,
}));
}
@@ -3005,6 +3049,23 @@ wss.on("connection", (ws) => {
sendToRVS_raw({ type: "sat_discover",
payload: { satellite: msg.satellite || "", force: true },
timestamp: Date.now() });
} else if (msg.action === "set_display_name") {
// Anzeigename fuer einen Satelliten oder Host-Agenten.
try {
setDisplayName(msg.kind, msg.id, msg.name);
if (msg.kind === "satellite") broadcastSatellites(); else broadcastHosts();
} catch (err) {
ws.send(JSON.stringify({ type: "log", level: "error", source: "server",
message: "Umbenennen: " + err.message }));
}
} else if (msg.action === "sat_name_set") {
// Geraetename vergeben/entfernen. Der Satellit haengt ihn an die MAC,
// damit ein IP-Wechsel den Namen nicht verliert.
sendToRVS_raw({ type: "sat_name_set",
payload: { satellite: msg.satellite || "", mac: msg.mac || "",
ip: msg.ip || "", name: msg.name || "",
requestId: "dn_" + Date.now() },
timestamp: Date.now() });
} else if (msg.action === "sat_creds_list") {
sendToRVS_raw({ type: "sat_creds_list",
payload: { satellite: msg.satellite || "", requestId: "dc_" + Date.now() },
@@ -3217,6 +3278,8 @@ wss.on("connection", (ws) => {
handleSetModel(ws, msg.model);
} else if (msg.action === "test_model") {
handleTestModel(ws, msg.model);
} else if (msg.action === "set_fallback_model") {
handleSetFallbackModel(ws, msg.model);
} else if (msg.action === "check_proxy_patches") {
handleCheckProxyPatches(ws);
} else if (msg.action === "get_privacy_flags") {
@@ -3623,6 +3686,7 @@ function handleGetModel(clientWs) {
clientWs.send(JSON.stringify({
type: "model_info",
model,
fallbackModel: (cfg.brainFallbackModel || "").trim(),
info: "Brain-Model (runtime.json) — bei Aenderung: aria-brain restarten",
}));
} catch (err) {
@@ -3630,6 +3694,29 @@ function handleGetModel(clientWs) {
}
}
// Ausweich-Modell fuer Anthropics Sicherheitsfilter. Leerer String =
// automatisch (das Brain nimmt dann ein anderes Tier als das Hauptmodell).
// Anders als brainModel braucht das KEINEN Neustart: safeguard.py liest die
// Datei erst, wenn der Filter wirklich zuschlaegt.
function handleSetFallbackModel(clientWs, model) {
try {
const val = typeof model === "string" ? model.trim() : "";
writeRuntimeConfig({ brainFallbackModel: val });
log("info", "server", `Ausweich-Model gesetzt: ${val || "(automatisch)"}`);
const cfg = readRuntimeConfig();
clientWs.send(JSON.stringify({
type: "model_info",
model: (cfg.brainModel || "").trim() || "claude-sonnet-4",
fallbackModel: val,
info: val
? `Ausweich-Model: ${val} — greift sofort, kein Neustart noetig`
: "Ausweich-Model: automatisch (anderes Tier als das Hauptmodell)",
}));
} catch (err) {
clientWs.send(JSON.stringify({ type: "model_info", error: err.message }));
}
}
function handleSetModel(clientWs, model) {
if (!model || typeof model !== "string") {
clientWs.send(JSON.stringify({ type: "model_info", error: "Kein Model angegeben" }));
+8
View File
@@ -84,6 +84,14 @@ const ALLOWED_TYPES = new Set([
// (SNMP/HTTP/FritzBox), der Satellit speichert sie verschluesselt.
"sat_creds_set", "sat_creds_delete", "sat_creds_list",
"sat_creds_result", "sat_creds_list_result",
// Port-Tunnel: ARIA erreicht ueber den Satelliten einen beliebigen Port im
// fremden LAN (FreeCAD-MCP, Web-App ...). Die Bridge macht daraus einen
// lokalen Port. sat_tunnel_data laeuft in BEIDE Richtungen — unterschieden
// wird ueber die tunnelId. Plus sat_name_* fuer vergebene Geraetenamen,
// damit ARIA "duffy-desktop" sagen kann statt einer IP.
"sat_tunnel_open", "sat_tunnel_opened", "sat_tunnel_data",
"sat_tunnel_close", "sat_tunnel_closed",
"sat_name_set", "sat_name_result",
// Host-Agenten: ein Agent laeuft direkt auf einem Rechner, meldet sich mit
// host_hello/host_ping und fuehrt host_command aus (exec/read/write/info/
// screenshot) -> host_result. ARIA steuert so Rechner auch hinter NAT.
+31
View File
@@ -89,3 +89,34 @@ HTTP_MAX_CHARS=20000 # Default-Body-Ausschnitt (offset/max_chars pro Reque
SCAN_INTERVAL_SEC=300 # Hintergrund-Rescan-Intervall
DISCOVER_TIMEOUT_SEC=6 # Dauer eines Sweeps (mDNS + SSDP)
DEVICE_CACHE_TTL_SEC=120 # wie lange ein Inventar als "frisch" gilt
# ── Port-Tunnel ──────────────────────────────────────────────────────
# ARIA erreicht ueber den Satelliten einen beliebigen Port im LAN — z.B. ein
# FreeCAD-MCP auf einem Rechner oder eine Web-App, die nur hier lauscht. Die
# Bridge macht daraus einen lokalen Port (aria-bridge:19000...).
#
# DEFAULT AUS: das ist ein Weg in dein Netz, den schaltet man bewusst ein.
# Soll der Rechner, auf dem der Satellit laeuft, SELBST in der Geraeteliste
# auftauchen? Er wird per mDNS/ARP nicht gefunden (man findet sich nicht
# selbst), ist aber oft der interessanteste Host — dort laufen typischerweise
# weitere Dienste (FreeCAD-MCP, Web-App, Dateiserver). Er erscheint dann als
# normales Geraet, zusaetzlich mit 🛰️ gekennzeichnet, und laesst sich genauso
# ansprechen. Nebeneffekt: er gilt damit als "gefunden" und ist ohne Eintrag in
# TUNNEL_ALLOW tunnelbar.
LIST_SELF=true
TUNNEL_ENABLED=false
# Welche Ziele sind erlaubt? LEER = nur Geraete, die der Satellit bei der Suche
# auch wirklich gefunden hat (dann aber jeder Port). Damit kann der Tunnel
# nichts ansteuern, was nicht ohnehin im LAN sichtbar ist — insbesondere nichts
# im Internet. Sonst Liste aus host oder host:port, * als Platzhalter:
# TUNNEL_ALLOW=192.168.1.50:8080,192.168.1.60,*:5000
TUNNEL_ALLOW=
TUNNEL_MAX=8 # gleichzeitig offene Verbindungen
TUNNEL_IDLE_SEC=900 # ungenutzte Tunnel nach 15 min schliessen
# Geraete-Namen ("duffy-desktop" statt 192.168.1.47). Schluessel ist die MAC,
# damit ein IP-Wechsel per DHCP den Namen nicht verliert.
DEVICE_NAMES_PATH=/data/device_names.json
+326 -4
View File
@@ -28,6 +28,7 @@ CONTROL_ENABLED + Allowlist. Discovery ist read-only. Keine offenen Ports.
from __future__ import annotations
import asyncio
import base64
import json
import logging
import os
@@ -139,6 +140,32 @@ CONTROL_ALLOWLIST = [
if a.strip()
]
# ── Port-Tunnel ──────────────────────────────────────────────────────
#
# ARIA sitzt woanders und kommt nicht ins fremde Netz. Der Satellit kann aber
# beides: er sieht das LAN und haelt eine ausgehende Verbindung zum RVS. Also
# reicht er eine TCP-Verbindung durch — wie der VNC-Tunnel, nur fuer beliebige
# Ports. Damit erreicht ARIA z.B. einen FreeCAD-MCP oder eine Web-App, die nur
# im Buero-Netz lauscht.
#
# DEFAULT AUS. Das ist ein Loch in ein fremdes Netz — das schaltet man bewusst
# ein, nicht aus Versehen.
TUNNEL_ENABLED = _env_bool("TUNNEL_ENABLED", False)
# Soll der Rechner, auf dem der Satellit laeuft, selbst in der Geraeteliste
# auftauchen? Default JA — meist ist er ein ganz normaler Host mit weiteren
# Diensten. Wer den Satelliten-Rechner bewusst heraushalten will, setzt das
# auf false.
LIST_SELF = _env_bool("LIST_SELF", True)
# Welche Ziele? Leer = nur Geraete, die der Satellit bei der Suche auch
# wirklich GEFUNDEN hat (beliebiger Port). Damit kann der Tunnel nichts
# ansteuern, was nicht ohnehin im LAN sichtbar ist — insbesondere nichts im
# Internet. Sonst Liste aus "host" oder "host:port", * als Platzhalter:
# TUNNEL_ALLOW=192.168.1.50:8080,192.168.1.60,*:5000
TUNNEL_ALLOW = [a.strip() for a in os.environ.get("TUNNEL_ALLOW", "").split(",") if a.strip()]
TUNNEL_MAX = int(os.environ.get("TUNNEL_MAX", "8") or 8)
TUNNEL_IDLE_SEC = int(os.environ.get("TUNNEL_IDLE_SEC", "900") or 900)
# adb.*: Android-Geraete (v.a. Fire TV) im LAN per Netzwerk-Debugging erreichen.
# install dauert lange (APK laden + uebertragen + installieren) -> grosszuegiger
# Timeout. APK-Cache in /data (persistentes Volume), damit N Sticks nur EINEN
@@ -288,9 +315,12 @@ MDNS_TYPES = [
"_androidtvremote2._tcp.local.", # Android TV Remote
]
CAPABILITIES = ["discover"]
CAPABILITIES = ["discover", "name"]
if CONTROL_ENABLED:
CAPABILITIES += CONTROL_ALLOWLIST
if TUNNEL_ENABLED:
# Macht in Diagnostic/ARIA sichtbar, dass dieser Satellit Ports durchreicht.
CAPABILITIES.append("tunnel")
# ─── Netz-Kontext / Selbstdiagnose ──────────────────────────────────
@@ -589,13 +619,150 @@ def _merge_devices(*lists) -> list[dict]:
merged = {**d, **{k: v for k, v in cur.items() if v}}
if order.get(d.get("via"), 0) >= order.get(cur.get("via"), 0):
merged.update({k: v for k, v in d.items() if v})
# DIAL-URL / mac aus beiden behalten
for key in ("dialAppUrl", "mac", "model", "manufacturer"):
# DIAL-URL / mac aus beiden behalten. isSatellite/satelliteId
# muessen mit: der Satellit findet sich selbst auch per mDNS,
# und dann duerfte die Kennzeichnung nicht verlorengehen.
for key in ("dialAppUrl", "mac", "model", "manufacturer",
"isSatellite", "satelliteId", "hostname"):
merged[key] = d.get(key) or cur.get(key) or merged.get(key)
by_ip[ip] = {k: v for k, v in merged.items() if v not in (None, "")}
return list(by_ip.values()) + loose
# ─── Geraete-Namen ──────────────────────────────────────────────────
#
# "duffy-desktop" ist ansprechbar, "192.168.1.47" nicht. Darum kann Stefan
# jedem gefundenen Geraet einen Namen geben; ARIA adressiert danach.
#
# Schluessel ist die MAC, nicht die IP: per DHCP wandert die IP, die MAC
# bleibt. Nur wenn keine MAC bekannt ist, faellt es auf die IP zurueck.
NAMES_PATH = os.environ.get("DEVICE_NAMES_PATH", "/data/device_names.json")
_names: dict = {}
def _names_load() -> None:
global _names
try:
with open(NAMES_PATH) as f:
data = json.load(f)
_names = data if isinstance(data, dict) else {}
except FileNotFoundError:
_names = {}
except Exception as exc:
logger.warning("device_names.json unlesbar (%s) — starte leer", exc)
_names = {}
def _names_save() -> None:
try:
os.makedirs(os.path.dirname(NAMES_PATH), exist_ok=True)
tmp = NAMES_PATH + ".tmp"
with open(tmp, "w") as f:
json.dump(_names, f, indent=2)
os.replace(tmp, NAMES_PATH)
except Exception as exc:
logger.warning("device_names.json schreiben fehlgeschlagen: %s", exc)
def _self_device() -> dict:
"""Der Rechner, auf dem der Satellit SELBST laeuft, als normales Geraet.
Er taucht in der Suche nicht auf (man findet sich nicht per mDNS/ARP
selbst), ist aber oft der interessanteste Host im Netz: hier laufen
typischerweise noch andere Dienste — ein FreeCAD-MCP, eine Web-App, ein
Dateiserver. Ohne diesen Eintrag waere ausgerechnet der Rechner unsichtbar,
auf dem ARIA schon eine Verbindung hat.
`isSatellite` kennzeichnet ihn; ansonsten ist er ein Geraet wie jedes
andere und laesst sich genauso ansprechen.
"""
ip = (NET or {}).get("primary_ip") or ""
if not ip:
return {}
d = {
"ip": ip,
"mac": (NET or {}).get("mac") or "",
"via": "self",
"isSatellite": True,
"satelliteId": SATELLITE_ID,
"type": "host",
}
try:
d["hostname"] = socket.gethostname()
except Exception:
pass
return {k: v for k, v in d.items() if v not in (None, "")}
def _name_key(dev: dict) -> str:
mac = (dev.get("mac") or "").strip().lower()
return mac if mac else (dev.get("ip") or "").strip()
def _apply_names(devices: list[dict]) -> list[dict]:
"""Haengt den vergebenen Namen an jedes Geraet (Feld 'name')."""
for d in devices:
n = _names.get(_name_key(d))
if n:
d["name"] = n
return devices
def _resolve_target(target: str, devices: list[dict]) -> str:
"""Name ODER IP ODER Hostname -> IP.
Erlaubt ARIA, "duffy-desktop" zu sagen statt eine Adresse zu kennen.
Vergleich case-insensitive; eine direkt angegebene IP geht unveraendert
durch.
"""
t = (target or "").strip()
if not t:
return ""
if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", t):
return t
tl = t.lower()
for d in devices:
if (d.get("name") or "").strip().lower() == tl:
return d.get("ip") or ""
# Zweite Runde: Hostname aus der Suche (mDNS liefert oft einen)
for d in devices:
for field in ("hostname", "host", "model"):
if (d.get(field) or "").strip().lower().rstrip(".") == tl:
return d.get("ip") or ""
return ""
# ─── Port-Tunnel ────────────────────────────────────────────────────
def _tunnel_allowed(ip: str, port: int, devices: list[dict]) -> tuple[bool, str]:
"""Darf zu ip:port getunnelt werden?
Ohne TUNNEL_ALLOW gilt: nur Geraete, die bei der Suche tatsaechlich
gefunden wurden. Damit kann der Tunnel nichts ansteuern, was nicht ohnehin
im LAN sichtbar ist — und schon gar nichts im Internet.
"""
if not TUNNEL_ENABLED:
return False, "Tunnel ist an diesem Satelliten aus (TUNNEL_ENABLED=false)."
if not ip:
return False, "Ziel nicht aufloesbar (Name unbekannt?)."
if not (0 < port < 65536):
return False, f"Port {port} ist ungueltig."
if TUNNEL_ALLOW:
for rule in TUNNEL_ALLOW:
rhost, _, rport = rule.partition(":")
if rhost not in ("*", ip):
continue
if rport and rport != "*" and rport != str(port):
continue
return True, ""
return False, f"{ip}:{port} steht nicht in TUNNEL_ALLOW."
known = {d.get("ip") for d in devices if d.get("ip")}
if ip in known:
return True, ""
return False, (f"{ip} wurde im Netz nicht gefunden. Erst 'Geraete suchen', "
f"oder {ip} in TUNNEL_ALLOW eintragen.")
# ─── Control ────────────────────────────────────────────────────────
async def _control(action: str, params: dict, devices: list[dict]) -> dict:
@@ -1630,6 +1797,8 @@ class Satellite:
self._devices: list[dict] = []
self._devices_ts: float = 0.0
self._scanning = False
# Offene Port-Tunnel: tunnelId -> {"writer", "task", "last", "ziel"}
self._tunnels: dict[str, dict] = {}
async def _scan(self, force: bool = False) -> list[dict]:
fresh = (time.time() - self._devices_ts) < DEVICE_CACHE_TTL_SEC
@@ -1648,7 +1817,9 @@ class Satellite:
mdns = await loop.run_in_executor(None, _discover_mdns, DISCOVER_TIMEOUT_SEC)
ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
arp = await loop.run_in_executor(None, _discover_arp)
self._devices = _merge_devices(mdns, ssdp, arp)
_self = [_self_device()] if LIST_SELF else []
_self = [d for d in _self if d]
self._devices = _apply_names(_merge_devices(mdns, ssdp, arp, _self))
if SNMP_DISCOVERY:
await self._enrich_snmp(self._devices)
await self._enrich_adb(self._devices)
@@ -1739,6 +1910,110 @@ class Satellite:
logger.info("sat_hello gesendet: id=%s location=%s caps=%s",
SATELLITE_ID, SATELLITE_LOCATION, CAPABILITIES)
# ── Port-Tunnel ──────────────────────────────────────────────────
async def _tunnel_open(self, payload: dict) -> None:
"""Oeffnet eine TCP-Verbindung ins LAN und streamt sie zur Bridge.
Gegenstueck: die Bridge haelt einen lokalen Lauscher, damit ARIA ganz
normal gegen eine Adresse sprechen kann (z.B. http://aria-bridge:19001).
"""
tid = (payload.get("tunnelId") or "").strip()
if not tid:
return
target = (payload.get("target") or payload.get("host") or "").strip()
try:
port = int(payload.get("port") or 0)
except (TypeError, ValueError):
port = 0
async def fail(msg: str) -> None:
await self._send({
"type": "sat_tunnel_opened",
"payload": {"tunnelId": tid, "satellite": SATELLITE_ID,
"ok": False, "error": msg},
"timestamp": int(time.time() * 1000),
})
if len(self._tunnels) >= TUNNEL_MAX:
await fail(f"Zu viele offene Tunnel ({TUNNEL_MAX}).")
return
devices = await self._scan()
ip = _resolve_target(target, devices)
ok, why = _tunnel_allowed(ip, port, devices)
if not ok:
await fail(why)
return
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(ip, port), timeout=10)
except Exception as exc:
await fail(f"Verbindung zu {ip}:{port} fehlgeschlagen: {exc}")
return
task = asyncio.create_task(self._tunnel_reader(tid, reader))
self._tunnels[tid] = {"writer": writer, "task": task,
"last": time.time(), "ziel": f"{ip}:{port}"}
logger.info("Tunnel auf: %s -> %s:%d (%s)", tid[:8], ip, port, target or ip)
await self._send({
"type": "sat_tunnel_opened",
"payload": {"tunnelId": tid, "satellite": SATELLITE_ID, "ok": True,
"ip": ip, "port": port, "target": target},
"timestamp": int(time.time() * 1000),
})
async def _tunnel_reader(self, tid: str, reader: asyncio.StreamReader) -> None:
"""LAN -> RVS. Endet, wenn die Gegenstelle zumacht."""
try:
while True:
data = await reader.read(32768)
if not data:
break
t = self._tunnels.get(tid)
if t:
t["last"] = time.time()
await self._send({
"type": "sat_tunnel_data",
"payload": {"tunnelId": tid,
"b64": base64.b64encode(data).decode("ascii")},
"timestamp": int(time.time() * 1000),
})
except asyncio.CancelledError:
raise
except Exception as exc:
logger.info("Tunnel %s Lesefehler: %s", tid[:8], exc)
finally:
await self._tunnel_close(tid, reason="Gegenstelle hat geschlossen")
async def _tunnel_close(self, tid: str, reason: str = "") -> None:
t = self._tunnels.pop(tid, None)
if not t:
return
try:
t["task"].cancel()
except Exception:
pass
try:
t["writer"].close()
except Exception:
pass
logger.info("Tunnel zu: %s (%s) %s", tid[:8], t.get("ziel", "?"), reason)
await self._send({
"type": "sat_tunnel_closed",
"payload": {"tunnelId": tid, "satellite": SATELLITE_ID, "reason": reason},
"timestamp": int(time.time() * 1000),
})
async def _tunnel_reaper(self) -> None:
"""Raeumt Tunnel weg, durch die lange nichts mehr geht. Ohne das bleibt
eine vergessene Verbindung offen, bis der Satellit neu startet."""
while True:
await asyncio.sleep(60)
now = time.time()
for tid in [k for k, v in self._tunnels.items()
if now - v.get("last", now) > TUNNEL_IDLE_SEC]:
await self._tunnel_close(tid, reason=f"seit {TUNNEL_IDLE_SEC}s ungenutzt")
def _for_me(self, payload: dict) -> bool:
target = (payload.get("satellite") or "").strip().lower()
if not target or target in ("all", "*"):
@@ -1766,6 +2041,50 @@ class Satellite:
"timestamp": int(time.time() * 1000),
})
elif mtype == "sat_tunnel_open":
if not self._for_me(payload):
return
await self._tunnel_open(payload)
elif mtype == "sat_tunnel_data":
# Kein _for_me: die tunnelId ist eindeutig, und ein fremder
# Satellit kennt sie nicht.
tid = (payload.get("tunnelId") or "").strip()
t = self._tunnels.get(tid)
if t:
try:
t["writer"].write(base64.b64decode(payload.get("b64") or ""))
t["last"] = time.time()
except Exception as exc:
logger.warning("Tunnel %s schreiben fehlgeschlagen: %s", tid[:8], exc)
await self._tunnel_close(tid, reason=str(exc)[:80])
elif mtype == "sat_tunnel_close":
await self._tunnel_close((payload.get("tunnelId") or "").strip(),
reason="von ARIA geschlossen")
elif mtype == "sat_name_set":
if not self._for_me(payload):
return
key = (payload.get("mac") or payload.get("ip") or "").strip().lower()
name = (payload.get("name") or "").strip()[:60]
req_id = payload.get("requestId", "")
if key:
if name:
_names[key] = name
else:
_names.pop(key, None)
_names_save()
_apply_names(self._devices)
logger.info("Geraetename: %s -> %r", key, name or "(entfernt)")
await self._send({
"type": "sat_name_result",
"payload": {"requestId": req_id, "satellite": SATELLITE_ID,
"ok": bool(key), "key": key, "name": name,
"error": "" if key else "mac oder ip noetig"},
"timestamp": int(time.time() * 1000),
})
elif mtype == "sat_command":
if not self._for_me(payload):
return
@@ -1866,7 +2185,10 @@ class Satellite:
logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).")
return
_creds_load()
_names_load()
asyncio.create_task(self._periodic_scan())
if TUNNEL_ENABLED:
asyncio.create_task(self._tunnel_reaper())
backoff = 1
# use_tls kann bei Fehlschlag einmal auf ws:// fallen (RVS_TLS_FALLBACK),
# danach wieder zurueck auf RVS_TLS (kein Sticky-Fallback). Bei TLS+RVS_SNI