# Drahtprotokoll

> HTML: https://remote.ivjn.us/docs/de/protocol/

Dieses Dokument ist die vollständige Spezifikation des Nachrichtenformats zwischen agent, exchange und cli. Die Definitionen liegen in `crates/proto`; alle drei Seiten teilen denselben Code. Jedes Feld wird nur einmal definiert. Abweichungen werden beim Kompilieren statt erst in einer Produktionssitzung sichtbar.

Zielgruppe sind Betriebspersonal, das Paketmitschnitte untersucht, und Entwickler, die das Protokoll ändern oder Drittanbieterclients implementieren.

---

## Inhaltsverzeichnis

- [Überblick](#überblick)
- [Transport und Endpunkte](#transport-und-endpunkte)
- [Protokollversion](#protokollversion)
- [Steuernachrichten](#steuernachrichten)
  - [DeviceUp（agent → exchange）](#deviceupagent--exchange)
  - [DeviceDown（exchange → agent）](#devicedownexchange--agent)
  - [OperatorUp（cli → exchange）](#operatorupcli--exchange)
  - [OperatorDown（exchange → cli）](#operatordownexchange--cli)
  - [Gemeinsame Datenstrukturen](#gemeinsame-datenstrukturen)
- [Binäre Datenframes](#binäre-datenframes)
- [Übersetzung zwischen zwei Sitzungsnummernräumen](#übersetzung-zwischen-zwei-sitzungsnummernräumen)
- [Handshake-Ablauf](#handshake-ablauf)
- [Fehlerbehandlung und Fehlerklassen](#fehlerbehandlung-und-fehlerklassen)
- [Verbindungserhaltung und Zeitlimits](#verbindungserhaltung-und-zeitlimits)
- [Rückstau](#rückstau)
- [Android-Bildschirmsteuerung](#android-bildschirmsteuerung)
- [Ende-zu-Ende-versiegelte Sitzungen](#ende-zu-ende-versiegelte-sitzungen)

---

## Überblick

Das Protokoll besteht aus zwei Schichten, die dieselbe WebSocket-Verbindung nutzen:

| Schicht | Transport | Kodierung | Inhalt |
|---|---|---|---|
| Steuernachrichten | WebSocket-**Text**frames | JSON | Registrierung, Heartbeats, Öffnen/Schließen von Sitzungen, Ergebnismeldungen |
| Datenframes | WebSocket-**Binär**frames | Fester 9-Byte-Header + Rohbytes | PTY-Bytestrom, stdout/stderr von Befehlen |

Streamingdaten als base64 in JSON einzubetten vergrößert sie um etwa 33 %. PTY erzeugt häufig kleine Pakete für einzelne Tastenechos; dieser Aufwand erhöht unmittelbar die Interaktionslatenz. Steuernachrichten sind selten, sodass die Lesbarkeit von JSON mehr Nutzen bringt: Mitschnitte sind direkt verständlich.

Ein binärer WebSocket-Frame enthält vereinbarungsgemäß **genau einen Datenframe**. Zusätzliche oder fehlende Bytes gelten als Fehler.

---

## Transport und Endpunkte

| Endpunkt | Verbindungsinitiator | Authentifizierung |
|---|---|---|
| `GET /ws/device` | agent | Der **erste Frame** nach Verbindungsaufbau muss eine `register`-Steuernachricht mit `device_id` und `token` sein |
| `GET /ws/operator` | remote-cli | HTTP-Anfrageheader `Authorization: Bearer <运维令牌>` beim WebSocket-Handshake |

Die beiden Pfade verwenden unterschiedliche, nicht austauschbare Zugangsdaten. Ein Gerätetoken funktioniert am Operator-Endpunkt nicht und umgekehrt.

Operator-Zugangsdaten stehen im Anfrageheader statt in der URL-Abfrage, weil URLs in Reverse-Proxy-Protokollen, im Browserverlauf und im Referer auftauchen. Gerätetoken werden im ersten Frame übermittelt, weil einige eingebettete Clients nur schwer eigene WebSocket-Handshake-Header setzen können. Außerdem lassen sich dadurch eine inkompatible Protokollversion und ein ungültiges Token mit derselben Antwortnachricht ausdrücken.

In Produktionsumgebungen ist `wss://` erforderlich: Beide Arten von Zugangsdaten erscheinen während des Handshakes im Klartext.

---

## Protokollversion

```rust
pub const PROTOCOL_VERSION: u16 = 1;
```

Beim Handshake werden die Versionen auf beiden Seiten verglichen. Abweichungen führen sofort zur Ablehnung mit einer eindeutigen Meldung. So wird verhindert, dass eine Versionsabweichung erst später als unerklärlicher Parserfehler einer Nachricht auftritt, der schwer zu diagnostizieren wäre.

- Geräteseite: agent übermittelt seine Version in `register`. Bei einer Abweichung antwortet der Server mit `register_ack` und `ok: false`; `error` nennt beide Versionen. Anschließend wird die Verbindung geschlossen.
- Operator-Seite: Nach Verbindungsaufbau sendet der Server selbstständig `welcome`. Bei einer Abweichung meldet die CLI einen Fehler und fordert zum Upgrade auf eine zu `server_version` passende Version auf.

**Unbekannte Nachrichtenwerte für `type` werden immer abgelehnt** und niemals stillschweigend als bekannter Typ behandelt.

---

## Steuernachrichten

Alle Steuernachrichten sind JSON-Objekte mit `type` als Kennzeichnung (internally-tagged Enums von serde). Kennzeichnungen und Feldnamen verwenden `snake_case`. `session_id` wird transparent als einfache Zahl serialisiert.

### DeviceUp（agent → exchange）

| `type` | Felder | Zweck |
|---|---|---|
| `register` | `protocol_version: u16`<br>`device_id: string`<br>`token: string`<br>`info: DeviceInfo` | Muss die **erste** Nachricht nach Verbindungsaufbau sein. Vor ihrem Empfang akzeptiert der Server keine andere Nachricht; das Zeitlimit beträgt 15 Sekunden |
| `heartbeat` | `metrics: Metrics` | Regelmäßige Übermittlung betrieblicher Messwerte. Die Verbindungsprüfung erfolgt getrennt durch natives WebSocket-ping/pong: Ein fehlender Heartbeat bedeutet nicht zwangsläufig eine unterbrochene Verbindung und umgekehrt |
| `exec_started` | `session_id`<br>`job_id`<br>`hold`<br>`timeout_secs` | Befehl gestartet; `job_id` dient zur hold-Liste und zum erneuten Anhängen nach Wiederverbindung |
| `exec_exit` | `session_id`<br>`code: i32 \| null`<br>`timed_out: bool` | Befehlsausführung beendet. Bei Beendigung durch ein Signal ist `code` gleich `null`; `timed_out` zeigt eine erzwungene Beendigung wegen Zeitüberschreitung an |
| `exec_list_resp` | `session_id`<br>`jobs: ExecHoldInfo[]` | Liste der hold-Aufträge |
| `ubus_resp` | `session_id`<br>`code: i32`<br>`data: any` | Antwort eines ubus-Aufrufs. `code` ist der Rückgabecode von ubus; 0 bedeutet Erfolg |
| `android_resp` | `session_id`<br>`result: AndroidResult` | Android-Bildschirmaktion abgeschlossen; diese Nachricht beendet die Sitzung |
| `pty_closed` | `session_id`<br>`code: i32 \| null` | PTY-Sitzung beendet; `code` ist der Exitcode der Shell |
| `session_error` | `session_id`<br>`message: string` | Fehler auf Sitzungsebene. Kann das Gerät eine Anfrage nicht erfüllen, antwortet es damit, statt sie stillschweigend zu verwerfen |

Ein zweites `register` auf einer bereits registrierten Verbindung gilt als Protokollmissbrauch und wird als Warnung protokolliert.

Beispiel:

```json
{"type":"register","protocol_version":1,"device_id":"lab-router","token":"a1b2….c2Vj…","info":{"hostname":"OpenWrt","model":"ath79/generic","openwrt_version":"OpenWrt 23.05.5","kernel":"5.15.137","arch":"mips","agent_version":"0.1.0"}}
{"type":"exec_exit","session_id":31,"code":0,"timed_out":false}
```

### DeviceDown（exchange → agent）

| `type` | Felder | Zweck |
|---|---|---|
| `register_ack` | `ok: bool`<br>`error: string \| null`<br>`heartbeat_interval_secs: u64` | Registrierungsergebnis. Bei `ok: false` nennt `error` den Grund; danach schließt der Server die Verbindung. Der Server legt das Heartbeat-Intervall fest, damit es bei wachsender Gerätezahl zentral verlängert werden kann. agent begrenzt 0 auf mindestens 1 Sekunde, um eine CPU-auslastende Timerschleife zu vermeiden |
| `device_access` | `managers: DeviceManager[]`<br>`updated_at: u64` (Millisekunden) | Zugriffsberechtigte für dieses Gerät. `DeviceManager` = `operator_id`, `username`, `email?`, `role`, `level` (`manage`/`view`), `source` (`role_default`/`grant`). Wird nach Registrierung und bei Berechtigungsänderungen nur an agents mit Fähigkeit `device_access` gesendet; siehe `docs/permissions.md` im Repository |
| `exec_req` | `session_id`<br>`command: string`<br>`timeout_secs: u64`<br>`hold: bool` (Standard `true`)<br>`hold_timeout_secs?: u64` | Führt einen Befehl aus. Mit `hold=true` (Standard) darf der Prozess nach Trennung des Operators weiterlaufen. Zeitlimit aus `hold_timeout_secs` (fehlend/0 → 3600, harte Obergrenze 86400). `hold=false` bricht den Befehl beim Trennen des Operators ab |
| `exec_cancel` | `session_id` | Beendet eine Befehlsausführung vorzeitig (kill) |
| `exec_detach` | `session_id` | Stoppt Ausgabe an aktuellen Operator, erhält den hold-Prozess |
| `exec_list_req` | `session_id` | Fragt aktuelle hold-Aufträge ab |
| `exec_attach_req` | `session_id`<br>`job_id` | Hängt hold-Auftrag an diese Sitzung an und spielt gepufferte Ausgabe nach |
| `exec_hold_kill` | `session_id`<br>`job_id` | Beendet hold-Auftrag anhand der job_id |
| `exec_hold_tail` | `session_id`<br>`job_id` | Verfolgt nur neue Ausgabe, ohne Pufferwiedergabe |
| `exec_hold_cat` | `session_id`<br>`job_id` | Gibt aktuellen Puffer aus und endet mit `exec_hold_cat_done` |
| `ubus_req` | `session_id`<br>`object: string`<br>`method: string`<br>`params: any` | Startet ubus-Aufruf. Bei `params` gleich `null` wird kein drittes Positionsargument an `ubus` übergeben |
| `pty_open` | `session_id`<br>`rows: u16`<br>`cols: u16` | Öffnet PTY-Sitzung. Bei Zeilen-/Spaltenwert 0 wird 24×80 verwendet |
| `pty_resize` | `session_id`<br>`rows: u16`<br>`cols: u16` | Ändert Terminalfenstergröße |
| `pty_close` | `session_id` | Schließt PTY-Sitzung |
| `android_req` | `session_id`<br>`request: AndroidAction` | Einzelne Android-Bildschirmaktion |
| `android_cancel` | `session_id` | Bricht eine noch nicht abgeschlossene Android-Anfrage ab |

### OperatorUp（cli → exchange）

Die Authentifizierung erfolgt beim WebSocket-Handshake; daher gibt es keine Anmeldenachrichten.

| `type` | Felder | Zweck |
|---|---|---|
| `open_exec` | `session_id`<br>`device_id`<br>`command`<br>`timeout_secs`<br>`hold` (Standard true)<br>`hold_timeout_secs?` | Befehl auf Zielgerät ausführen. hold ist standardmäßig aktiv; `hold=true` und Zeitlimit 0 → 3600s. `hold=false` und Zeitlimit 0 → Exchange-Standard 60s |
| `list_exec_holds` | `session_id`<br>`device_id` | Fragt exec-Aufträge und laufende Downloads, Uploads und Verzeichnisabfragen ab |
| `list_device_sessions` | `session_id`<br>`device_id` | Fragt direkt die Exchange-Routingtabelle ab, ohne Gerätesitzungsplatz |
| `kill_device_session` | `session_id`<br>`device_id`<br>`target_session_id` | Bricht anhand der geräteseitigen `target_session_id` ab und gibt Route lokal frei; einschließlich hold exec, ohne zusätzlichen Sitzungsplatz |
| `attach_exec` | `session_id`<br>`device_id`<br>`job_id` | Hängt hold-Auftrag an und empfängt weitere Ausgabe |
| `exec_hold_kill` / `exec_hold_tail` / `exec_hold_cat` | `session_id`<br>`device_id`<br>`job_id` | kill / tail / cat für hold-Aufträge |
| `detach_session` | `session_id` | Beendet Warten im Vordergrund und trennt Sitzung ab, ohne hold-Prozess zu beenden |
| `open_ubus` | `session_id`<br>`device_id: string`<br>`object: string`<br>`method: string`<br>`params: any` | Startet ubus-Aufruf |
| `open_pty` | `session_id`<br>`device_id: string`<br>`rows: u16`<br>`cols: u16` | Öffnet interaktive PTY-Sitzung |
| `open_android` | `session_id`<br>`device_id: string`<br>`request: AndroidAction` | Startet Android-Bildschirmaktion; für schreibgeschützte Konten verboten |
| `pty_resize` | `session_id`<br>`rows: u16`<br>`cols: u16` | Ändert Terminalfenstergröße. Ohne `device_id`, da der Server das Zielgerät der bestehenden Sitzung kennt |
| `close_session` | `session_id` | Beendet Sitzung aktiv: reguläres PTY-Ende oder Abbruch der Befehlsausführung durch Benutzer |

Die `session_id` der vier `open_*`-Nachrichten **wird von der CLI selbst vergeben** (siehe [Übersetzung zwischen zwei Sitzungsnummernräumen](#übersetzung-zwischen-zwei-sitzungsnummernräumen)). Folgeframes können deshalb sofort nach der Anfrage gesendet werden, ohne Nummernbestätigung abzuwarten.

### OperatorDown（exchange → cli）

| `type` | Felder | Zweck |
|---|---|---|
| `welcome` | `protocol_version: u16`<br>`server_version: string` | Server sendet nach Verbindungsaufbau zur Prüfung übereinstimmender Protokollversionen |
| `device_sessions` | `session_id`<br>`sessions: DeviceSessionInfo[]`<br>`limit`<br>`count` | Aktuelle geräteseitige Sitzungsliste von Exchange |
| `device_session_killed` | `session_id`<br>`target_session_id`<br>`released: bool` | Lokal freigegeben; false, falls nicht vorhanden |
| `exec_exit` | `session_id`<br>`code: i32 \| null`<br>`timed_out: bool` | Befehl beendet; aus gleichnamiger Gerätenachricht übersetzt |
| `ubus_resp` | `session_id`<br>`code: i32`<br>`data: any` | Antwort eines ubus-Aufrufs |
| `android_resp` | `session_id`<br>`result: AndroidResult` | Android-Ergebnis mit übersetzter Operator-Sitzungsnummer |
| `pty_closed` | `session_id`<br>`code: i32 \| null` | PTY-Sitzung beendet |
| `session_failed` | `session_id`<br>`reason: SessionFailure`<br>`message: string` | Sitzung fehlgeschlagen. `reason` unterscheidet Ursachen, damit CLI über Wiederholungen entscheiden kann |

### Gemeinsame Datenstrukturen
`ExecHoldInfo`: `job_id`, `kind` (`exec` / `download` / `upload` / `file`), `age_secs`, `session_id?`, `command_preview`, `status`, `timeout_secs`, `started_at_unix_ms`, `exit_code?`, `timed_out`. Fehlendes `kind` wird kompatibel als `exec` interpretiert, fehlendes `age_secs` als 0. Listenantworten berechnen das Alter anhand der aktuellen Uhrzeit. Befehls-/Dateipfadkurzfassungen umfassen höchstens etwa 120 Zeichen. Andere Aufträge als exec unterstützen nur kill, kein tail/cat/attach.

`DeviceSessionInfo`: `session_id: u32` (geräteseitige Nummer), `operator_session_id: u32`, `kind: string`, `hold: bool`, `age_secs: u64`, `deadline_unix_ms: u64`. Sitzungslisten und erzwungene Abbrüche beantwortet Exchange direkt, ohne neue Gerätesitzung. Schreibgeschützte Konten erhalten `forbidden`.

#### `DeviceInfo`

Statische Geräteinformationen, einmalig bei `register` gemeldet und in `devices.info` gespeichert. Außer `agent_version` sind alle Felder optional. In Nicht-OpenWrt-Umgebungen wie Entwicklungsrechnern und Containern fehlen viele Quellen; agent soll sich trotzdem registrieren statt beim Start zu scheitern. Nicht gesetzte Felder werden bei Serialisierung ausgelassen.

| Feld | Typ | Quelle |
|---|---|---|
| `hostname` | `string?` | `/proc/sys/kernel/hostname` |
| `model` | `string?` | `DISTRIB_TARGET` aus `/etc/openwrt_release`; falls nicht lesbar, `model` aus `ubus call system board` |
| `openwrt_version` | `string?` | `DISTRIB_DESCRIPTION` oder `release.description` von ubus |
| `kernel` | `string?` | `/proc/sys/kernel/osrelease` |
| `arch` | `string?` | Konstante zur Kompilierzeit, zuverlässiger als Laufzeiterkennung |
| `agent_version` | `string` | Eigene agent-Version zur Diagnose von Versionsabweichungen |

#### `Metrics`

Wird in jedem Heartbeat-Zyklus neu erfasst. Erfasst werden nur direkt aus `/proc` lesbare Werte, ohne Kindprozesse zu starten. Einzelne Lesefehler dürfen die Gesamterfassung nicht scheitern lassen: Ein fehlender Messwert ist besser als ein vollständig fehlgeschlagener Heartbeat, der das Gerät fälschlich offline erscheinen lässt.

| Feld | Typ | Beschreibung |
|---|---|---|
| `uptime_secs` | `u64` | `/proc/uptime` |
| `load` | `[f32; 3]` | Durchschnittslast über 1/5/15 Minuten aus `/proc/loadavg` |
| `mem_total_kb` | `u64` | `/proc/meminfo` |
| `mem_available_kb` | `u64` | `MemAvailable` statt `MemFree`: Letzteres berücksichtigt keine freigebbaren Caches, unterschätzt auf Routern verfügbaren Speicher deutlich und erzeugt Fehlalarme |
| `cpu_usage_pct` | `f32?` | Aus Differenz zweier `/proc/stat`-Messungen berechnet; beim ersten Heartbeat fehlt der Vergleichswert, daher ausgelassen |
| `interfaces` | `InterfaceStats[]` | `/proc/net/dev`. Meldet **kumulierte** Sende-/Empfangsbytes statt Raten, damit Server Raten für beliebige Zeitfenster berechnen kann. Bei leerem Array ausgelassen |

`InterfaceStats`: `{ name: string, rx_bytes: u64, tx_bytes: u64 }`

---

## Binäre Datenframes

```text
 0        1                    5                    9
 +--------+--------------------+--------------------+
 |  kind  |     session_id     |    payload_len     |  payload (payload_len 字节)
 |   u8   |    u32 (大端)      |    u32 (大端)      |
 +--------+--------------------+--------------------+
 └──────────── 9 字节定长头 ────────────┘
```

| Offset | Länge | Feld | Beschreibung |
|---|---|---|---|
| 0 | 1 | `kind` | Streamtyp, siehe folgende Tabelle |
| 1 | 4 | `session_id` | Big-Endian-`u32`. 0 ist Sentinel für „keiner Sitzung zugehörig“ |
| 5 | 4 | `payload_len` | Big-Endian-`u32`, Nutzlastbytes, höchstens 1 MiB |
| 9 | `payload_len` | `payload` | Rohbytes ohne Kodierung oder Escape-Sequenzen |

### Streamtypen

| Wert | Name | Richtung | Beschreibung |
|---|---|---|---|
| 1 | `ExecStdout` | Gerät → Operator | Standardausgabe des entfernten Befehls |
| 2 | `ExecStderr` | Gerät → Operator | Standardfehlerausgabe. Getrennt von stdout, damit CLI sie unterschiedlich darstellen kann, etwa in Rot |
| 3 | `PtyData` | **Beidseitig** | PTY-Bytestrom: aufwärts Tastatureingabe, abwärts Bildschirmausgabe; derselbe Typ in beiden Richtungen |

Empfängt agent abwärts `ExecStdout` / `ExecStderr`, protokolliert er eine Warnung und verwirft sie: Die Gegenstelle ist dann fehlerhaft implementiert.

### Entwurfsentscheidungen

**`payload_len` ist tatsächlich redundant zum WebSocket-Längenfeld.** Es bleibt aus zwei Gründen erhalten: Der Decoder erkennt abgeschnittene Frames, etwa angekündigte 11 statt tatsächlicher 5 Bytes, statt sie stillschweigend als kurzes Paket anzunehmen. Bei einem späteren Wechsel zu reinem TCP bleibt zudem das Frameformat unverändert.

**Die Nutzlastgrenze von 1 MiB** ist nicht für normalen Verkehr gedacht: PTY und Befehlsausgabe werden inkrementell gestreamt und erreichen diese Größenordnung nicht. Sie verhindert, dass eine Gegenstelle mit einem Header, der 2 GiB Nutzlast behauptet, den Serverspeicher erschöpft.

**Leere Nutzlasten sind gültig**: In PTY-Sitzungen kommen gelegentlich Lesevorgänge mit null Bytes vor. Der Frame besteht dann nur aus dem 9-Byte-Header.

**Die Nutzlast enthält beliebige Bytes**, einschließlich ungültiger UTF-8-Sequenzen, und muss unverändert bleiben.

### Dekodierfehler

| Fehler | Auslöser |
|---|---|
| `FrameTooShort { len }` | Gesamtlänge unter 9 Bytes |
| `UnknownStreamKind(v)` | Erstes Byte ist nicht 1/2/3 |
| `PayloadTooLarge { len }` | Angegebenes `payload_len` über 1 MiB |
| `LengthMismatch { declared, actual }` | Angegebene Länge weicht von tatsächlicher Zahl verbleibender Bytes ab |

Ein Dekodierfehler betrifft nur diesen Datenframe: exchange protokolliert eine Warnung und verwirft ihn, ohne die Verbindung zu trennen.

---

## Übersetzung zwischen zwei Sitzungsnummernräumen

**Dieser Teil des Protokolls wird am leichtesten missverstanden.**

### In einem Satz

`session_id` wird vom **Initiator** vergeben und ist **nur innerhalb seiner eigenen Verbindung eindeutig**. Operator- und Geräteseite besitzen unabhängige Nummernräume; exchange übersetzt dazwischen. Dieselbe logische Sitzung hat auf beiden Seiten unterschiedliche Nummern.

```text
  remote-cli                    exchange                      remote-agent
                                                        （设备 lab-router）

  ┌────────────┐        ┌────────────────────┐            ┌────────────┐
  │ 运维编号   │        │      路由表        │            │ 设备编号   │
  │  空间      │        │                    │            │  空间      │
  │            │        │  lab-router:       │            │            │
  │  session 7 │───────▶│    31 → (op-A, 7)  │───────────▶│ session 31 │
  │            │◀───────│                    │◀───────────│            │
  └────────────┘        │  op-A:             │            └────────────┘
                        │    7 → (lab-router,│
   由 CLI 自己的        │         31)        │             由 exchange
   SessionIdGenerator   └────────────────────┘             的 DeviceHandle
   分配（1,2,3…）                                          分配（1,2,3…）
```

### Warum dieser Entwurf?

**Eine zusätzliche Hin- und Rückübertragung vermeiden.** Bei zentraler Nummernvergabe müsste die CLI nach dem Öffnen einer PTY erst eine Bestätigung wie „Ihre Sitzungsnummer ist 31“ abwarten, bevor sie Tastatureingaben senden kann. Durch eigene Vergabe kann sie Datenframes unmittelbar nach `open_pty` senden.

**Nummernkollisionen zwischen Operatoren vermeiden.** Zwei Operatoren können gleichzeitig die lokale Nummer 1 für dasselbe Gerät verwenden. Das Gerät muss sie unterscheiden können, sonst gelangt die Ausgabe einer Person zur anderen. exchange vergibt geräteseitige Nummern pro Geräteverbindung; zwei `session 1` werden auf `31` und `32` abgebildet.

**Gerätenummern von Operatorverhalten unabhängig halten.** Die geräteseitigen Nummern werden vollständig von exchange kontrolliert; kein Operator kann sie festlegen oder erraten.

### Zwei Tabellen

exchange verwaltet pro Verbindung eine Tabelle. Beide werden stets gemeinsam aktualisiert:

| Ort | Tabelle | Schlüssel → Wert |
|---|---|---|
| `DeviceHandle.sessions` | Vorwärtstabelle der Geräteseite | Gerätenummer → (Operator-Sendehandle, Operatornummer, Operator-Konto-ID, Sitzungstyp, Audit-Zeilen-ID) |
| `OperatorHandle.sessions` | Rückwärtsindex der Operator-Seite | Operatornummer → (Geräte-ID, Gerätenummer) |

Der Rückwärtsindex dient der Bereinigung: Beim Trennen eines Operators müssen seine Sitzungen auf **allen Geräten** gefunden und einzeln beendet werden. Andernfalls blieben Prozesse und PTYs zurück, deren Ausgabe niemand empfängt.

### Wo die Übersetzung erfolgt

| Richtung | Nachricht | Übersetzung |
|---|---|---|
| Operator → Gerät (`open_*`) | Steuernachricht | Neue Nummer im Gerätenummernraum **vergeben**, beide Tabellen eintragen und neue Nummer in Nachricht ans Gerät verwenden |
| Operator → Gerät (`pty_resize` / `close_session`) | Steuernachricht | Gerätenummer im Rückwärtsindex suchen, ersetzen und weiterleiten |
| Operator → Gerät | Datenframe | Gerätenummer im Rückwärtsindex suchen, 4 Headerbytes direkt überschreiben; Nutzlast unverändert |
| Gerät → Operator | Steuernachricht | Operatornummer und Zielverbindung in Vorwärtstabelle suchen, ersetzen und zustellen |
| Gerät → Operator | Datenframe | Operatornummer in Vorwärtstabelle suchen, Header direkt überschreiben; Nutzlast unverändert |

Die Übersetzung ist die **einzige** Änderung an Datenframes: exchange analysiert, puffert oder verändert kein Nutzlastbyte. `DataFrame::with_session_id` ersetzt die Nummer direkt, ohne die Nutzlast neu zu allozieren.

### Vergaberegeln

Beide Zähler beginnen bei 1, zählen aufwärts und **überspringen 0**. 0 ist Sentinel für „keiner Sitzung zugehörig“, etwa bei verbindungsbezogenen Fehlern.

- CLI (`SessionIdGenerator`): Nach Überlauf beginnt der Zähler erneut und überspringt 0. Vier Milliarden Sitzungen auf einer Verbindung sind unrealistisch, aber Umlauf eignet sich für langlebige Prozesse besser als panic.
- Geräteseite (`DeviceHandle::open_session`): Trifft der Zähler nach Umlauf auf eine noch aktive Sitzung, sucht er den nächsten freien Platz.

exchange lehnt 0 und Wiederverwendung aktiver Operator-Sitzungsnummern ab. Gültigkeit der Geräteverbindung, Parallelitätsgrenze und Einträge in beiden Indizes werden im selben kritischen Abschnitt geprüft, damit gleichzeitiges Öffnen durch mehrere Operatoren die Grenze nicht überschreitet.

### Verhalten bei fehlender Route

**Stillschweigend verwerfen, keinen Fehler melden.** Nach dem Schließen noch eintreffende Restdaten sind ein normaler Wettlauf. Die Regel gilt in beiden Richtungen:

- Gerät antwortet für nicht mehr existierende Sitzung → debug-Protokoll; möglicherweise hat sich der Operator vorher getrennt.
- Operator sendet Daten für verwaiste Sitzung → trace-Protokoll und verwerfen.

### Bereinigungspfade für Sitzungen

| Auslöser | Bereinigung |
|---|---|
| Gerät beendet Sitzung regulär (`exec_exit` / `ubus_resp` / `android_resp` / `pty_closed` / `session_error`) | Vorwärtsroute und Rückwärtsindex der zugehörigen Verbindung gemeinsam entfernen, Audit vervollständigen, übersetzte Nachricht an Operator senden |
| Operator sendet `close_session` | Beide Indizes entfernen, ursprüngliche Geräteverbindung zur Beendigung auffordern, sofort Abschlussbestätigung an Operator senden und Abbruchaudit vervollständigen. Wiederholtes Schließen hat keine Wirkung |
| Gerät trennt Verbindung | Alle Sitzungen dieser Verbindung entnehmen, jeweils `session_failed` / `device_disconnected` an zugehörige Operatoren senden, Audit mit `{"outcome":"device_disconnected"}` ergänzen |
| Operator trennt Verbindung | Über Rückwärtsindex eigene Sitzungen auf allen Geräten finden. Nicht-hold-Sitzungen erhalten Beendigungsbefehle; hold exec nur `exec_detach`, der Prozess bleibt bestehen. **Nur eigene Sitzungen bereinigen**; andere Operatoren auf demselben Gerät bleiben unbeeinträchtigt |
| Neue Geräteverbindung ersetzt alte | Alle Sitzungen der alten Verbindung ungültig machen, Operatoren jeweils mit `device_disconnected` benachrichtigen |

Der Rückwärtsindex ist an die konkrete Geräteverbindung gebunden. Er wird nach Wiederverbindung nicht anhand der Geräte-ID auf die neue Verbindung umgeleitet. Mehrere Operator-Verbindungen desselben Kontos besitzen eigene Indizes; Trennen einer Verbindung beendet keine Sitzungen einer anderen.

Abschlussbestätigungen bei aktivem Abbruch: exec erhält `exec_exit` (`code: null`, `timed_out: false`), PTY `pty_closed` (`code: null`), ubus `session_failed` (`reason: device_error`). Damit bestätigt exchange das Schließen der Sitzung, nicht das Ende des entfernten Prozesses. Spätere Restdaten und Exitmeldungen werden verworfen.

Beim Erreichen der Parallelitätsgrenze bereinigt Exchange unter derselben Sitzungssperre zuerst Routen ohne Operator oder mit überschrittener Frist und prüft dann erneut die Grenze. Für exec gilt das tatsächlich übermittelte Zeitlimit plus 15 Sekunden, für Dateien 3600 Sekunden plus 15 Sekunden, für andere Typen 24 Stunden. hold exec innerhalb der Frist wird beim Verlassen des Operators nicht beendet. Bereinigung oder explizites `kill_device_session` sendet den passenden Abbruchbefehl ans Gerät und entfernt sofort beide Indizes, ohne Geräteantwort abzuwarten. Erreichbare Operatoren erhalten für exec `exec_exit` (`timed_out:true`), sonst `session_failed` (`device_error`, `session reclaimed`). Das Audit wird nach Möglichkeit asynchron vervollständigt.

### Gerätewiederverbindung und epoch

Jede Geräteverbindung erhält bei Registrierung eine zufällige `epoch` (UUID). Verbindet sich dasselbe Gerät erneut, **ersetzt die neue Verbindung die alte**. Doppelte Verbindungen lassen sich nicht zuverlässig von bereits toten, aber serverseitig unerkannten halb offenen Verbindungen unterscheiden. Bei Ablehnung könnte das Gerät bis zum Timeout der alten Verbindung nicht verbinden.

Die Bereinigung der alten Verbindung kann **nach** Registrierung der neuen erfolgen. Beim Austragen muss deshalb die epoch geprüft werden: Nur wenn die aktuelle Verbindung in der Tabelle dieselbe epoch besitzt, darf sie entfernt werden. Ohne diese Prüfung würde alte Bereinigung das gerade verbundene Gerät sofort wieder offline setzen.

---

## Handshake-Ablauf

### Geräteseite

```text
  remote-agent                                        exchange
      │                                                 │
      │  ①  WebSocket 握手（wss://host/ws/device）      │
      │────────────────────────────────────────────────▶│
      │◀────────────────────────────────────────────────│
      │            101 Switching Protocols              │
      │                                                 │
      │                                          ┌──────┴──────┐
      │                                          │ 启动 15 秒  │
      │                                          │ 注册计时器  │
      │                                          └──────┬──────┘
      │  ②  {"type":"register","protocol_version":1,    │
      │      "device_id":"…","token":"…","info":{…}}    │
      │────────────────────────────────────────────────▶│
      │                                                 │
      │                              ┌──────────────────┴──────────────────┐
      │                              │ a. 比对 protocol_version            │
      │                              │ b. split_token → 前缀查 devices 表  │
      │                              │ c. 校验 enabled                     │
      │                              │ d. argon2 verify（前缀不符也照做，  │
      │                              │    避免用耗时区分前缀是否存在）     │
      │                              └──────────────────┬──────────────────┘
      │                                                 │
      │  ③  {"type":"register_ack","ok":true,           │
      │      "error":null,"heartbeat_interval_secs":30} │
      │◀────────────────────────────────────────────────│
      │                                                 │
      │                                          ┌──────┴──────┐
      │                                          │ 登记连接表  │
      │                                          │ 顶替旧连接  │
      │                                          │ 回写 info 与│
      │                                          │ last_seen_at│
      │                                          └──────┬──────┘
      │                                                 │
      │  ④  退避曲线复位，写状态文件 state=connected     │
      │                                                 │
      │══════════════ 转发循环 ═══════════════════════════│
      │                                                 │
      │  heartbeat（每 30 秒，携带 Metrics）             │
      │────────────────────────────────────────────────▶│
      │  WebSocket Ping（随每次 heartbeat 发出）         │
      │────────────────────────────────────────────────▶│
      │                                     WebSocket Pong
      │◀────────────────────────────────────────────────│
      │                                     WebSocket Ping（每 30 秒）
      │◀────────────────────────────────────────────────│
      │  Pong（tungstenite 排队，agent 显式 flush）       │
      │────────────────────────────────────────────────▶│
```

**Fehlerpfade:**

- Erster Frame ist kein Textframe, kein gültiges JSON oder kein `register` → Warnung protokollieren und direkt schließen, ohne ack.
- Kein `register` innerhalb von 15 Sekunden → Verbindung schließen.
- Versionsabweichung → `register_ack{ok:false, error:"协议版本不匹配：设备为 X，服务端为 Y"}` senden und schließen.
- Ungültiges Token / Gerät nicht vorhanden / Gerät deaktiviert → immer dieselbe Meldung `"设备 ID 或令牌无效"` senden und schließen. **Keine Ursachenunterscheidung**, da sonst der Gerätebestand aufgezählt werden könnte.

agent behandelt diese beiden Fehlerklassen getrennt:

| agent-Klasse | Auslöser | Wiederverbindungsstrategie |
|---|---|---|
| `Rejected` | ack mit `ok: false` empfangen | Feste Wartezeit **300 Sekunden**. Ein falsches Token wird durch Wiederholen nicht richtig; häufige Versuche verschwenden beiderseits Ressourcen und überfluten Serverprotokolle. Die längere Pause lässt Zeit zur Konfigurationskorrektur |
| `Transport` | Verbindung unmöglich, Handshake-Timeout (45 Sekunden), fehlerhaftes Antwortformat, Verbindungsunterbrechung | Exponentielles Backoff: anfangs 1 Sekunde, jeweils verdoppelt, maximal 300 Sekunden, multipliziert mit zufälligem Jitter aus [0.5, 1.5) |

**Die Backoff-Kurve wird erst nach erfolgreicher Registrierung zurückgesetzt**, nicht beim TCP-Verbindungsaufbau. Sonst würden sofort abgelehnte Verbindungen zu dichten Wiederholungsversuchen im Millisekundentakt führen.

**Jitter ist notwendig.** Beim Serverneustart erkennen Hunderte oder Tausende Geräte gleichzeitig die Trennung. Mit derselben deterministischen Wiederverbindungskurve blieben sie synchron und würden den gerade gestarteten Server wellenweise überlasten. Ein einzelner Neustart könnte so eine dauerhafte Überlastung auslösen.

### Operator-Seite

```text
  remote-cli                                          exchange
      │                                                 │
      │  ①  WebSocket 握手（wss://host/ws/operator）    │
      │      Authorization: Bearer <prefix>.<secret>    │
      │────────────────────────────────────────────────▶│
      │                                                 │
      │                              ┌──────────────────┴──────────────────┐
      │                              │ 在 upgrade 之前完成鉴权：           │
      │                              │ split_token → 前缀查 operators 表   │
      │                              │ → argon2 verify → 校验 enabled      │
      │                              └──────────────────┬──────────────────┘
      │                                                 │
      │      鉴权失败 → HTTP 401「凭证无效」（不升级）   │
      │◀────────────────────────────────────────────────│
      │                                                 │
      │◀────────────────────────────────────────────────│
      │            101 Switching Protocols              │
      │                                                 │
      │  ②  {"type":"welcome","protocol_version":1,     │
      │      "server_version":"0.1.0"}                  │
      │◀────────────────────────────────────────────────│
      │                                                 │
      │  ③  比对 protocol_version（15 秒超时）          │
      │      不一致 → 报错退出，提示升级 CLI            │
      │                                                 │
      │══════════════ 可以发起会话 ═══════════════════════│
      │                                                 │
      │  {"type":"open_pty","session_id":1,             │
      │   "device_id":"lab-router","rows":40,"cols":120}│
      │────────────────────────────────────────────────▶│
      │                                                 │──▶ 查设备在线
      │                                                 │──▶ 查并发上限
      │                                                 │──▶ 查 readonly 角色
      │                                                 │──▶ 写审计（先于下发）
      │                                                 │──▶ 分配设备侧编号
      │  二进制帧（PtyData, session 1, 键盘输入）        │
      │────────────────────────────────────────────────▶│──▶ 改写为设备侧编号
      │                                                 │
      │  二进制帧（PtyData, session 1, 屏幕输出）        │
      │◀────────────────────────────────────────────────│◀── 改写为运维侧编号
```

Die CLI kann die Datenframes des zweiten Schritts **ohne jede Bestätigung abzuwarten** senden. Genau dafür vergibt der Initiator die Nummern selbst.

---

## Fehlerbehandlung und Fehlerklassen

### `SessionFailure`

Steht im Feld `reason` von `session_failed`; JSON-Werte verwenden snake_case.

| Wert | Bedeutung | Erzeuger | Wiederholbar |
|---|---|---|---|
| `device_offline` | Gerät existiert nicht oder ist offline | exchange findet bei `open_*` kein Gerät in der Verbindungstabelle | ✅ |
| `device_disconnected` | Gerät verliert während der Sitzung die Verbindung | Gerät getrennt / durch neue Verbindung ersetzt / Sendewarteschlange beim Zustellen bereits geschlossen | ✅ |
| `device_error` | Gerät lehnt ab oder Ausführung scheitert | agent sendet `session_error` (PTY-Erstellung, Befehlsstart oder ubus-Aufruf fehlgeschlagen), oder Gegenstelle verarbeitet weitergeleitete Daten zu langsam | ❌ |
| `rate_limited` | Grenze gleichzeitiger Sitzungen überschritten | exchange erkennt bei `open_*`, dass aktive Gerätesitzungen `--max-sessions-per-device` (Standard 32) erreicht haben | ✅ |
| `forbidden` | Operator-Zugangsdaten erlauben keinen Zugriff | Schreibgeschütztes Konto (`role = readonly`) versucht irgendeine Sitzung zu öffnen | ❌ |

`is_retryable()` schließt `forbidden` und `device_error` bewusst aus. Das Wiederholen eines Berechtigungsproblems oder internen Gerätefehlers ist sinnlos und würde die CLI nur beschäftigen.

Die CLI ergänzt die ursprüngliche `message` um einen Handlungshinweis. Bei `device_offline` empfiehlt sie etwa `remote-cli device list`, um den Gerätestatus zu prüfen. Ausnahme ist `device_error`: Die Gerätenachricht erklärt den Fehler am genauesten und soll nicht durch Standardfloskeln verwässert werden.

### Fehlersemantik nach Schicht

| Schicht | Verhalten | Auswirkung |
|---|---|---|
| Frame-Dekodierfehler | Protokollieren, Frame verwerfen | Einzelner Frame |
| Steuernachricht nicht parsebar | Warnung protokollieren, Nachricht ignorieren | Einzelne Nachricht; Verbindung bleibt bestehen |
| Sitzungsfehler | `session_failed` / `session_error` | Einzelne Sitzung; Verbindung bleibt bestehen |
| Registrierung fehlgeschlagen | Nach `register_ack{ok:false}` schließen | Gesamte Verbindung |
| Versionsabweichung | Nach eindeutiger Fehlermeldung schließen | Gesamte Verbindung |
| Warteschlange überlastet | Verbindung aktiv schließen | Gesamte Verbindung |

Grundsatz: **Lokalisierbare Fehler werden niemals zu Verbindungsfehlern hochgestuft**, denn eine Geräteverbindung kann gleichzeitig Sitzungen mehrerer Operatoren tragen.

---

## Verbindungserhaltung und Zeitlimits

| Mechanismus | Wert | Beschreibung |
|---|---|---|
| exchange WebSocket Ping | Alle `--heartbeat-secs` (Standard 30 Sekunden) | exchange sendet an Geräte und Operatoren. Clientbibliotheken reihen Pong automatisch ein; agent muss nach Ping den sink ausdrücklich flushen, damit Pong rechtzeitig gesendet wird |
| agent WebSocket Ping | Alle `heartbeat_interval_secs` (Servervorgabe im ack) | Zusätzlich zu jedem JSON-`heartbeat` sendet agent einen nativen Ping, damit verzögerte exchange-Pings nicht die betriebliche Cloudflare-Grenze von etwa 100 Sekunden Inaktivität erreichen |
| Verbindungsinaktivitätslimit | `heartbeat × --missed-pings` (Standard 30 × 3 = 90 Sekunden) | Ohne eingehende Nachricht innerhalb dieser Zeit gilt Geräte-/Operator-Verbindung als verloren und wird geschlossen. Pong zählt als Aktivität. Bei `--missed-pings` gleich 0 wird mindestens ein Heartbeat-Zyklus gewartet |
| exchange-Schreibzeitlimit | 10 Sekunden | Timeout eines einzelnen WebSocket-Daten- oder Ping-Schreibvorgangs beendet die Verbindung, damit langsame Verbindungen die Bereinigung nicht blockieren |
| Betrieblicher Heartbeat | Alle `heartbeat_interval_secs` (Servervorgabe im ack) | agent sendet JSON-`heartbeat` mit `Metrics` und separat WebSocket-Ping. Messwertübermittlung und natives ping/pong sind getrennte Aufgaben; Verbindungszustand darf nicht allein aus betrieblichen Heartbeats abgeleitet werden |
| Online-/Offline-Ereignisse | `tick_count` der Benachrichtigungsauslöser `online` / `offline` (Standard 3, Bereich 1–100); Broadcast-/Audit-Fallback `REMOTE_MISSED_THRESHOLD` / `REMOTE_ONLINE_THRESHOLD` | Operatoren konfigurieren zwei Auslöser, nicht zwei Zähler eines Auslösers. `offline` meldet nach `tick_count` aufeinanderfolgenden Fehlschlägen offline, `online` nach `tick_count` aufeinanderfolgenden Erfolgen online. `device_ids` darf eine Geräte-ID (String), mehrere IDs (Array) oder fehlend / `null` für alle Workspace-Geräte sein. Bereits gemeldeter Zustand wird nicht erneut gemeldet; zuerst müssen `tick_count` gegenteilige Ergebnisse den Auslöser erneut scharfstellen. Prozessinterne presence-Broadcasts und Audit lesen diese Auslöser nicht, sondern verwenden `REMOTE_MISSED_THRESHOLD` / `REMOTE_ONLINE_THRESHOLD` (ebenfalls Standard 3). Ob der Socket noch in der Verbindungstabelle steht, bestimmt separat `--missed-pings` |
| Geräte-Registrierungszeitlimit | 15 Sekunden | `register` muss innerhalb dieser Frist nach Verbindungsaufbau gesendet werden |
| agent-Handshake-Zeitlimit | Je Phase 45 Sekunden (`HANDSHAKE_TIMEOUT`) | `connect_device_ws` (TCP, optional HTTP CONNECT, TLS, WebSocket-Handshake) und Warten auf `register_ack` nach `register` erhalten jeweils diese Frist; nicht zusammen 45 Sekunden |
| agent-Schreib-/Pong-flush-Zeitlimit | 30 Sekunden (`WRITE_TIMEOUT`) | Jedes send (Registrierung, JSON-Heartbeat, Ping, Sitzungsausgabe) und jeder Pong-flush nach erfolgreicher Registrierung hat eigene Frist. Schreibfehler oder Timeout beenden Verbindung und lösen Wiederverbindung aus. Während Registrierung gilt für Pong-flush die oben genannte 45-Sekunden-ack-Frist |
| agent TCP-Keepalive | Prüfung nach 30 Sekunden Inaktivität | Nach Direktverbindung oder HTTP CONNECT werden `TCP_NODELAY` und TCP keepalive aktiviert. Diese TCP-Einstellungen ersetzen WebSocket Ping/Pong nicht |
| CLI-Welcome-Zeitlimit | 15 Sekunden | Wartefrist der verbundenen CLI auf `welcome` |
| Befehlszeitlimit | `timeout_secs` in `open_exec` | agent begrenzt auf mindestens 1 Sekunde. Bei Timeout erhält die gesamte Prozessgruppe SIGTERM, nach 3 Sekunden Schonfrist SIGKILL |
| ubus-Aufrufzeitlimit | 15 Sekunden (fest in agent) | ubus ist lokales RPC; nach dieser Zeit kann praktisch von einem blockierten Dienst ausgegangen werden |

Auch der Client einer inaktiven Shell antwortet mit Pong. agent flusht die automatisch eingereihte Bibliotheksantwort ausdrücklich, ohne auf den nächsten betrieblichen Heartbeat zu warten. Deshalb wird die Verbindung ohne Tastatureingaben nicht fälschlich als verloren betrachtet (#42). `--heartbeat-secs` muss größer als 0 sein; ungültige Werte werden beim Parsen der Startargumente abgelehnt.

---

## Rückstau

exchange verwaltet pro Verbindung eine begrenzte Sendewarteschlange mit **512** Nachrichten.

Eine volle Warteschlange bedeutet, dass die Gegenstelle nicht schnell genug konsumiert; bei PTY meist durch blockiertes Netzwerk oder angehaltenen Client. Die Verbindung wird **aktiv getrennt**, statt weiter zu puffern. Unbegrenzte Puffer würden einen blockierten Client in ein Speicherleck des gesamten Dienstes verwandeln. 512 Nachrichten bieten interaktiven PTYs großen Spielraum; normalerweise liegt die Länge im einstelligen Bereich. Bei Erreichen der Grenze verarbeitet die Gegenstelle praktisch keine Daten mehr.

Zustellung verwendet immer nicht blockierendes `try_send`. Im Routingpfad wird die Sperre der Verbindungstabelle gehalten. Ein `await` würde deren Haltedauer an die Verarbeitungsgeschwindigkeit der Gegenstelle koppeln; ein langsamer Client würde die gesamte Tabelle blockieren.

Zwei Zustellfehler werden unterschieden:

| Ergebnis | Bedeutung | Behandlung |
|---|---|---|
| `Lagging` | Warteschlange voll | Verbindung über separates Schließsignal unterbrechen, ihre Sitzungen bereinigen und andere Seite nach Möglichkeit über Fehler informieren |
| `Closed` | Empfänger bereits freigegeben | Normales Verbindungsende; nur debug-Protokoll |

Das Schließsignal durchläuft die Sendewarteschlange nicht. Es löst daher auch bei voller Warteschlange oder blockiertem Schreiben die Bereinigung aus. Geräteverbindungsersetzung und Tokenrotation verwenden denselben Mechanismus.

Auch agent besitzt eine ausgehende Warteschlange mit Kapazität 256. Ist sie voll, kann das Netzwerk überhaupt keine Daten mehr versenden. Weiteres Sammeln ist sinnlos; daher wird getrennt und neu verbunden. Ist die PTY-Eingabewarteschlange (64) voll, werden Tasten direkt verworfen: Kleine interaktive Eingaben anzustauen ist nicht sinnvoll.
## Android-Bildschirmsteuerung

Die Definitionen liegen in `crates/proto/src/android.rs`. Verwendet werden bestehendes Routing mit zwei Nummernräumen, Authentifizierung, Parallelitätsgrenzen und Audit. `request` in `open_android` / `android_req` ist ein durch `action` unterschiedenes JSON-Objekt:

| `action` | Weitere Felder | Verhalten |
|---|---|---|
| `status` | Keine | Lokale Berechtigungen und SDK-Version abfragen; Bildschirmfreigabe muss nicht aktiviert sein, Remotedienst muss jedoch laufen |
| `tree` | Keine | Baum zugänglicher Knoten der aktuell aktiven App |
| `screenshot` | Keine | Einzelner JPEG-Screenshot |
| `tap` | `x, y: u32` | Tippen auf ursprüngliche Bildschirm-Pixelkoordinaten |
| `swipe` | `x1, y1, x2, y2, duration_ms: u32` | Wischgeste, Dauer 50–5000 ms |
| `swipe_path` | `points: [{x, y}]` (2–64 Punkte), `duration_ms: u32` | Mehrteilige Wischgeste: einmal aufsetzen und kontinuierlich alle Punkte durchlaufen, gleichmäßig nach Länge interpoliert, Dauer 50–5000 ms |
| `global` | `key: string` | `back` / `home` / `recents` / `notifications` / `quick_settings` |
| `input_text` | `text: string` | Ersetzt aktuell fokussiertes bearbeitbares Feld; höchstens 8192 UTF-8-Bytes, kein NUL |

`AndroidResult` wird durch `kind` unterschieden; alle Felder sind Pflichtfelder:

| `kind` | Felder |
|---|---|
| `status` | `controls_enabled, accessibility_enabled, screen_capture_enabled: bool`; `sdk: u32` |
| `tree` | `nodes: AndroidNode[]`; `screen_width, screen_height: u32`; `truncated: bool` |
| `screenshot` | `mime_type: "image/jpeg"`; `data_base64: string`; `width, height, screen_width, screen_height: u32` |
| `ok` | Keine; Eingabe, Geste oder Systemaktion abgeschlossen |

Knoten enthalten Strings `class_name, package, view_id, text, description`, `bounds: [left, top, right, bottom]` (vorzeichenbehaftete Bildschirm-Pixelkoordinaten) und boolesche Felder `clickable, editable, focused, password`. Höchstens 200 Knoten und 256 UTF-16-Einheiten pro String; `truncated` kennzeichnet nur die Knotengrenze. Passwortwerte und -beschreibungen bleiben leer; agent-eigener Konfigurationsunterbaum wird ausgelassen.

Die lange Screenshotseite hat höchstens 1600 Pixel. `width/height` sind Bildabmessungen, `screen_width/screen_height` Abmessungen vor Skalierung. Gestenkoordinaten beziehen sich auf unskalierte Abmessungen. Die Java-Brücke liefert höchstens 2 MiB JSON; WebSocket-Verpackung erlaubt zusätzlich 1024 Bytes. Kein neuer binärer Frametyp wird eingeführt.

```json
{"type":"open_android","session_id":1,"device_id":"phone","request":{"action":"input_text","text":"Hello\nWorld"}}
{"type":"android_req","session_id":31,"request":{"action":"input_text","text":"Hello\nWorld"}}
{"type":"android_resp","session_id":31,"result":{"kind":"ok"}}
{"type":"android_resp","session_id":1,"result":{"kind":"ok"}}
```

`screen_capture_enabled` zeigt eine derzeit verfügbare Screenshotberechtigung an (MediaProjection oder Bedienungshilfe ab Android 11). Alle tatsächlichen Bildschirmaktionen benötigen weiterhin `controls_enabled`. Fehlende Berechtigungen, unzugängliche Fenster oder fehlgeschlagene Aktionen beantwortet agent mit `session_error`; Exchange wandelt dies in `session_failed` um. Nicht-Android-agents melden ausdrücklich fehlende Unterstützung.

Agent verarbeitet höchstens 4 Android-Anfragen gleichzeitig und kodiert jeweils nur einen Screenshot. Java-Operationen warten höchstens 10 Sekunden, auf Bildschirmframes höchstens 5 Sekunden. Das agent-Anfragelimit beträgt 15 Sekunden, das CLI-Limit 20 Sekunden. Bei Abbruch oder Operator-Trennung sendet Exchange `android_cancel`; agent schließt lokalen Socket, und APK bricht noch nicht ausgeführte Warteschlangenarbeit ab. Bereits ausgeführte Gesten oder Textänderungen werden nicht rückgängig gemacht.

Die Audit-action lautet `android`; detail speichert Aktion und Koordinaten. Bei `input_text` wird nur `characters` (Anzahl der Unicode-Zeichen) gespeichert; erfolgreiche Ergebnisse enthalten nur `outcome: "completed"`. Textinhalt, Oberflächenbaum und Bilder werden nicht im Audit gespeichert. `readonly`-Konten dürfen auch `status`, `tree` und Screenshots nicht anfordern.

## Erweiterung für Dateisitzungen

Agents mit Dateiunterstützung melden Fähigkeit `files`. Exchange übersetzt `open_file {session_id, device_id, request}` in `file_req {session_id, request}`. `request.action` ist `list`, `download` oder `upload`; Felddefinitionen stehen in `crates/proto/src/files.rs`.

Steuernachrichten beider Richtungen verwenden JSON: Der Controller sendet `file_input {session_id, input}`, das Gerät antwortet `file_event {session_id, event}`. `input.kind` ist `chunk {offset, data}`, `ack {offset}` oder `finish`; `event.kind` ist `ready {size}`, `chunk {offset, data}`, `progress {received}`, `complete {size}` oder `list {path, entries, truncated}`. Blockinhalte verwenden Standard-Base64. Pro Block höchstens 64 KiB; der Sender wartet vor dem Fortfahren auf Bestätigung.

Das erste `ready` eines Downloads benötigt `ack {offset: 0}`; danach bestätigt jeder Block die kumulierte Bytezahl. Ein Upload beginnt mit `ready`; jedes `progress` löst nächsten Block aus, nach vollständiger Übertragung wird mit `finish` abgeschlossen. `complete` und `list` sind Abschlussnachrichten; Fehler verwenden vorhandenes `session_error` / `session_failed`. `close_session` sendet `file_cancel`; Abbruch und Verbindungsverlust entfernen jeweils die Sitzungsroute. Dateinachrichten werden ausschließlich für Dateisitzungen weitergeleitet, damit Operator- und Gerätesitzungs-IDs getrennt bleiben.

Die Protokollversion bleibt 1. Neue Nachrichten erfordern passende Upgrades von Exchange und Agent. Betriebsgrenzen und LAN-Schnittstellen beschreibt [Dateiübertragung und direkte LAN-Verbindung](/docs/de/file-transfer-and-lan.md).

## Plattformübergreifende Erweiterungen

Die Protokollversion bleibt 1. Das Registrierungs-`info` erhält optionale Felder `platform` (`openwrt` / `linux` / `freebsd` / `netbsd` / `windows` / `android` / `macos`), `os_version`, `capabilities` (`exec` / `pty` / `ubus` / `android_control` / `files` / `sensors` / `net`). OpenWrt meldet `openwrt_version`.

Heartbeat-`metrics.unavailable` ist ein optionales Stringarray für derzeit nicht erfassbare Werte `uptime`, `load`, `memory`, `cpu`, `interfaces`. Fehlt das Array, wird das alte Format verwendet. Werte der darin genannten Messgrößen sind nur Kompatibilitätsplatzhalter; Clients sollen sie als fehlend anzeigen. Windows bietet keine Unix-Durchschnittslast; Android kann manche `/proc`-Quellen durch Systemberechtigungen beschränken.

Befehle werden durch die Shell des Zielgeräts interpretiert: Linux/OpenWrt/macOS verwenden `/bin/sh`, Windows PowerShell und Android `/system/bin/sh`. PTY-Frameformat und Steuernachrichten sind auf den drei Systemen identisch; Windows verwendet ConPTY. Nicht-OpenWrt-Geräte antworten auf ubus-Anfragen mit `session_error`.

## Ende-zu-Ende-versiegelte Sitzungen

Die Protokollversion bleibt 1. Neue Nachrichten: OperatorUp `open_sealed {session_id, device_id, hello}`, `sealed_input {session_id, packet}`; DeviceDown `sealed_open {session_id, hello}`, `sealed_input`, `sealed_close {session_id, detach}`; DeviceUp / OperatorDown `sealed_accept {session_id, accept}`, `sealed_event {session_id, packet, last}`. Hinzu kommen binärer Frametyp `4 = Sealed` (Nutzlast `counter ‖ AEAD(inner_kind ‖ bytes)`), `DeviceInfo.seal_key` und Fähigkeit `sealed`.

Exchange übernimmt bei versiegelten Sitzungen nur Nummernübersetzung, Parallelitätsbegrenzung und Routing. Es analysiert oder auditiert keine Nutzlast; das Audit erfasst lediglich öffentliche Schlüssel, Berechtigungsstufe und Ablauf sowie Abschlussergebnis. Es entscheidet nicht über Berechtigungen, sondern prüft nur, ob das Konto das Gerät sehen darf. Bei `last:true` wird die Route freigegeben. Versiegelte Routen akzeptieren ausschließlich `Sealed`-Frames, normale Routen keine `Sealed`-Frames. agent verwirft jeden Klartext an versiegelte Sitzungsnummern. Autorisierung, Handshake, Schlüsselableitung, Bedrohungsmodell beschreibt [Ende-zu-Ende-versiegelte Sitzungen](/docs/de/sealed-sessions.md).
