UI-Entwurf umgesetzt, Rocket.Chat-Tool, Deploymentcenter 2.4

Sicherungspunkt vor dem Aufraeumen. Buendelt die Arbeit, die seit dem
Abschluss der Avalonia-Portierung im Arbeitsverzeichnis lag.

Oberflaeche
- Entwurf aus Mockup/ umgesetzt: Theme.axaml (Farben je Thema, Barlow als
  mitgelieferte Schrift), Icons.axaml (Symbolgeometrien), Shell.axaml
  (eigene ControlThemes statt Fluent umzufaerben).
- Neue Steuerelemente StrokeIcon und BlueprintFrame, Seiten fuer
  Token-Verbrauch und Agenten-Chats, Werkzeug-Einstellungen als Seite
  statt eigenem Fenster, Texteditor-Fenster.
- ThemeManager mit hellem und dunklem Thema; die beiden Pinsel-Konverter
  entfallen, weil ein fester Farbwert den Themenwechsel nicht ueberlebt.

Rocket.Chat
- Neues Tool-Projekt (Client, Konfiguration, Workspace-Dateien) nach der
  Bauform des Telegram-Tools: rocketchat_poll als Tool-Job, geweckt wird
  nur, wenn wirklich etwas anliegt.
- send_file ist freigabepflichtig, send_message bewusst nicht: Der Raum
  ist Arbeitsraum, der Schutz sitzt an der Raum-Allowlist.
- Konzept-Doc um die Messung gegen die echte Instanz 8.7 ergaenzt; drei
  Annahmen waren falsch und sind korrigiert.

Deploymentcenter
- DC6 (Update anwenden) und DC7 (Erstinstallation ueber setup.json)
  erledigt, DC3 fuer win-x64/dev; deploy/publish.py als Release-Strecke.
- AppHost.DisposeAsync gegen doppeltes Herunterfahren gesperrt - sonst
  ueberschreibt eine zweite Abmeldung den Wartungszustand am Watchdog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-23 12:16:05 +02:00
co-authored by Claude Opus 5
parent b5bf97ae74
commit 33d95a6c3f
88 changed files with 11665 additions and 1029 deletions
+19 -2
View File
@@ -119,8 +119,25 @@ und Tippfehler in Bindungspfaden fallen erst zur Laufzeit auf.
Benachrichtigung.
- Befehle: `[RelayCommand] private void Speichern() { … }` → bindbar als
`SpeichernCommand`.
- Formatierung gehört in `Styles/Shell.axaml`, nicht an einzelne Steuerelemente.
Vorhandene Klassen: `heading`, `caption`, `toolbar`, `card`, `statusbar`.
- Formatierung gehört in `Styles/`, nicht an einzelne Steuerelemente. Seit der
Umsetzung des Entwurfs aus `Mockup/` liegt sie in drei Dateien:
`Theme.axaml` (Farben je Thema, Schriften), `Icons.axaml` (Symbolgeometrien),
`Shell.axaml` (Steuerelement-Vorlagen und Stilklassen).
Klassen: `h1`, `h2`, `kicker`, `label`, `caption`, `muted`, `mono`, `card`,
`console`, `hr`, `sep`, `toolbar`, `thead`, `tr`, `th`, `td`, `tag`,
`statusbar`, `topbar`, `sidebar`, `nav`; an Schaltflächen zusätzlich
`primary`, `toolbar`, `ghost`, `flat`, `icon`.
- Symbole über `Controls/StrokeIcon.cs` mit einer Geometrie aus `Icons.axaml`.
Die Farbe wird geerbt — nicht gesetzt.
- Rahmen mit Eckmarken über `Controls/BlueprintFrame.cs`. Sparsam: nur Dialoge
und die Info-Karte.
- Tabellen von Hand aus `Border.thead` + `ListBox.table`, nicht mit `DataGrid`.
Das Paket ist nicht mehr referenziert.
- **Avalonia 12 hat die Ressourcenschlüssel des Fluent-Themas umgebaut.** Die aus
11er-Anleitungen bekannten Namen (`ButtonBackground`, `TextControlBackground`,
`ControlCornerRadius` …) existieren nicht mehr; ein Setter darauf ist wirkungslos
und fällt nicht auf. Für neue Steuerelemente deshalb eine eigene `ControlTheme`
in `Shell.axaml` schreiben statt zu versuchen, Fluent umzufärben.
- Ordner öffnen: `Core.Storage.SystemShell.OpenFolder(pfad)`. **Kein** `explorer.exe`.
- Dateinamen erzeugen: `Core.Storage.PortableFileName.Sanitize(name)`.
@@ -0,0 +1,299 @@
# Deploymentcenter 2.2 2.4: Was noch zu tun ist
Stand: 2026-08-13. Ergänzt [Deploymentcenter-Integration](Deploymentcenter-Integration.md)
(dort steht der Stand nach 2.1) um die drei neuen Ausbaustufen.
| Fassung | Was dazukam | Betrifft uns |
|---|---|---|
| **2.2** | Plattform-Dimension, signierte Releases, Anwenden mit Rollback, `preservePatterns` | Release-Strecke, Update-Anwendung |
| **2.3** | Erstinstallation über `update-agent --action install`, `setup.json`, Installationskonto | Neu, siehe §4 |
| **2.4** | Release-Ablage hinter HTTP-Basic-Auth, Zugang über den Lizenzschlüssel | **Erledigt**, siehe §1 |
---
## 1. Zugangsschutz (2.4) — erledigt
`CheckForUpdateAsync` übergibt jetzt `ReleaseCredentials.FromLicenseKey(...)`, und
`UpdateCheckResult.Unauthorized` wird getrennt von einem Netzfehler behandelt.
**Warum das nicht warten konnte:** UPGRADE §16.1 empfiehlt „erst ausliefern, dann
scharfschalten". Für ein Produkt, das noch nie veröffentlicht hat, geht diese Reihenfolge
nicht auf. `ReleaseGuard::regenerateForProject` überspringt Verzeichnisse, die es nicht
gibt — `/releases/clawddotnet/` liefert derzeit 404, es ist also nichts geschützt. Sobald
wir das **erste** Release hochladen, entsteht das Verzeichnis, und der nächste
`tick.php`-Lauf legt den Schutz an. Der erste ausgelieferte Build muss die Zugangsdaten
also bereits mitbringen, sonst schließt sich die Tür hinter dem ersten Release.
---
## 2. Plattform (2.2) — Release-Strecke steht
Erstes Paket veröffentlicht: **0.1.0, Kanal `dev`, Plattform `win-x64`**, 116 Dateien,
25 MB, Rückgabewert 0. Die Update-Prüfung antwortet korrekt (`0.0.9` → Update, `0.1.0`
keins).
```bash
pack-and-deploy --config deploy/packager.config.json \
--project clawddotnet --version 0.1.0 \
--channel dev --platform win-x64 \
--publish-dir <dotnet-publish-Ausgabe>
```
- Zugangsdaten in `deploy/packager.config.json` (per `.gitignore` ausgeschlossen),
Vorlage ohne Werte in [`packager.config.example.json`](../deploy/packager.config.example.json).
- **`ftpRemoteBaseDir` ist `/releases`**, nicht `/public_html/releases` wie in der
Packager-Vorlage: Auf diesem Server liegt die Release-Ablage auf der FTP-Wurzel.
- Der Packager veröffentlicht mit einem **Sub-Token**, das nur `updateservice:publish`
trägt — gezogen über `/api/tokens/v1/provision`. Das Master-Token gehört nicht in eine
Konfigurationsdatei.
- **`deploy.py` ist dafür das falsche Werkzeug.** Es spiegelt den
Deploymentcenter-Projektbaum in die FTP-Wurzel und hat mit dem Veröffentlichen eines
Anwendungspakets nichts zu tun.
- Die Versionsgegenprobe des Packagers greift und passt: `<Version>` aus
[Directory.Build.props](../Directory.Build.props) stimmt mit `clawddotnet.dll` überein.
Offen: `linux-x64` (erst nach der Avalonia-Portierung) und `prod`.
Clientseitig ist nichts zu tun: Das SDK schickt die Kennung des laufenden Systems von
selbst.
### `preservePatterns` betrifft uns kaum
Unsere Konfiguration liegt seit der Linux-Portierung in `AppPaths.ConfigDirectory`
(`%APPDATA%` bzw. XDG), **nicht** neben der Programmdatei. Ein Update kann sie also gar
nicht überschreiben. Zu prüfen bleibt nur, dass keine leeren Arbeitsordner ins Paket
wandern — die `CreateWorkingDirectories`-Targets in
[ClawdDotNet.csproj](../ClawdDotNet.csproj) legen `tools/`, `Logs/` und `Instances/`
unter `OutputPath` an, und die sind mit AppPaths ohnehin überholt.
---
## 3. Update anwenden — erledigt
Aus dem Hinweis ist eine Rückfrage geworden („Jetzt installieren" / „Später"), die den
`update-agent` startet. Umgesetzt in
[`DeploymentcenterService.StartUpdate`](../src/ClawdDotNet.App/Services/DeploymentcenterService.cs)
und `App.StartUpdateAsync`.
### Der Agent wird mitgeliefert — er muss es
Die Erstinstallation legt den Agenten **nicht** ins Zielverzeichnis: Sie läuft von dort,
wo der Benutzer sie hingelegt hat. `ResolveAgentPath()` sucht ihn aber neben der
Anwendung. Ohne Mitliefern fände die Anwendung nie einen Agenten und könnte sich nicht
aktualisieren.
[`deploy/publish.py`](../deploy/publish.py) holt das ausgelieferte Binary von
`/installer/`, **prüft die SHA256 gegen `installer.json`** und legt es plattformrichtig
ab (`update-agent.exe` bzw. `update-agent`). Bewusst das offizielle statt eines selbst
gebauten: Es ist dasselbe, das die Erstinstallation verwendet, und wird zentral gepflegt.
Ein ungeprüfter Download wäre ausgerechnet auf dem Pfad, der später fremden Code
ausführt, die falsche Sparsamkeit.
Kosten: rund 28 MB im gepackten Paket (25 → 53 MB).
### Die Reihenfolge ist der eigentliche Inhalt
```
1. AnnounceUpdate(version) → Watchdog meldet beim Beenden "maintenance"
2. AppHost.DisposeAsync() → Datenbank, Scanner, Telegram, Abmeldung
3. StartUpdate(...) → Agent starten, exitCurrentApp: false
4. desktop.Shutdown() → wir beenden uns selbst
```
Die Verlockung wäre, `LaunchUpdateAgent` das Beenden zu überlassen. Das tut es aber über
`Environment.Exit` und übergeht damit Schritt 2 vollständig: keine Abmeldung, keine
geschlossene Instanzdatenbank. Deshalb `exitCurrentApp: false` und
`waitForCurrentProcess: true` — der Agent bekommt unsere Prozesskennung und wartet, bis
wir wirklich weg sind, statt über gesperrte Dateien zu kopieren.
`maintenance` statt `stopped` ist kein Schönheitsfehler: `stopped` heißt „bewusst
beendet" und lässt den Monitor liegen, bis jemand ihn anfasst. Beim Update kommt die
Instanz aber wieder.
**Doppeltes Aufräumen** war die Falle dabei: Nach Schritt 2 ruft `desktop.Shutdown()` die
Behandlung, die erneut aufräumt — und dabei den gerade gesetzten Wartungszustand mit
einer zweiten Abmeldung überschrieben hätte. `AppHost.DisposeAsync` sperrt sich jetzt
selbst gegen den zweiten Durchlauf.
---
## 4. Erstinstallation (2.3) — `setup.json` steht
Der Konfigurationsort war der Blocker: `setup.json`-Ziele waren „relativ zum
Installationsverzeichnis", unsere Konfiguration liegt aber in `%APPDATA%` bzw.
`$XDG_CONFIG_HOME` — weil `/opt/clawddotnet` unter Linux für den Dienstbenutzer nicht
schreibbar ist ([Linux-Analyse](Linux-Portierung-Analyse.md)).
Das Deploymentcenter hat daraufhin `location` am Ziel ergänzt (`install`, `config`,
`data`, `home`) samt Variablenersetzung in `file`. Damit ist der Weg frei;
[`setup.json`](../src/ClawdDotNet.Desktop/setup.json) liegt im Projekt und wird ins
Ausgabeverzeichnis kopiert, landet also im Paket neben der `manifest.json`.
### Der Ordnername ist bewusst kleingeschrieben
`AppPaths` legt das Verzeichnis plattformabhängig unterschiedlich an:
| Plattform | Pfad |
|---|---|
| Windows | `%APPDATA%\ClawdDotNet` |
| Linux | `$XDG_CONFIG_HOME/clawddotnet` (klein, Konvention) |
Eine `setup.json` kennt nur **eine** Schreibweise. `clawddotnet/Settings.json` trifft
unter Linux exakt und unter Windows ebenfalls, weil NTFS Groß- und Kleinschreibung nicht
unterscheidet. Andersherum ginge es nicht: `ClawdDotNet` wäre unter Linux ein zweites,
leeres Verzeichnis neben dem, aus dem die Anwendung liest.
### Was dabei abfällt
Das Token stellt der Server aus (`source: "provision"`), die Server-Adresse kommt aus dem
Installer (`detect:baseurl`). Damit entfällt der Absatz „bis die Avalonia-Einstellungs-
ansicht steht, von Hand in `Settings.json`" aus der
[Integrationsbeschreibung](Deploymentcenter-Integration.md) — jedenfalls für frisch
installierte Systeme.
Der Installer schreibt Lizenzschlüssel und Token **im Klartext**; er kennt unsere
DPAPI-Hülle nicht. Das ist in Ordnung und abgesichert: `SecretProtector.Unprotect` gibt
Klartext unverändert zurück, beim ersten Speichern wird verschlüsselt. Der Test dazu
steht in `SecretProtectorTests` und nennt jetzt beide Gründe, damit ihn niemand als
Altlast entfernt.
### Zwei Grenzen bleiben
- **`CLAWD_CONFIG_DIR` kennt der Installer nicht.** Wer den Ort per Umgebungsvariable
verlegt, muss die Datei selbst verschieben.
- **Wer installiert, entscheidet mit** (SETUP warnt selbst davor): `config` bezieht sich
auf das Konto, unter dem der Installer läuft. Für einen systemd-Dienst mit eigenem
Benutzer heißt das: als dieser Benutzer installieren, sonst landet die Konfiguration
im falschen Profil.
### Durchgespielt (2026-08-15)
Anmeldung mit dem Installationskonto und der gesamte Ablauf gegen den echten Server:
| Schritt | Ergebnis |
|---|---|
| `POST /api/setup/v1/login` | 201, Rolle `installer`, Recht `setup:install`, Token 30 min gültig |
| `GET /api/setup/v1/catalog?platform=win-x64` | `clawddotnet` (dev=0.1.2) erscheint. **Ohne `platform` leer** — wie die Update-Prüfung, der Agent schickt `PlatformId.Current` |
| `POST /api/setup/v1/token` | Anwendungstoken mit genau `watchdog:ping` + `bugtracker:report` |
| Rechteschranke | Das ausgestellte Token kann **kein** `updateservice:publish` nachziehen (403 `provision_denied`) |
| `SetupPaths.Resolve` gegen unsere `setup.json` | löst unter Windows auf `%APPDATA%\ClawdDotNet\Settings.json` auf (`fileWindows` greift) |
| **Round-Trip** SDK schreibt → `SettingsManager` liest | trägt: camelCase-Keys treffen, der Klartext-Lizenzschlüssel geht durch den Entschlüsselungspfad (der Klartext unverändert durchreicht) |
Damit ist der Weg vollständig: Ein frisch aufgesetztes System bekommt über den Installer
Server-Adresse, Lizenzschlüssel und ein vom Server ausgestelltes Instanz-Token in die
`Settings.json` geschrieben, die ClawdDotNet dann ohne Zutun lädt.
### Befund am Rande: alte Felder bleiben stehen
Der `SetupWriter` merged in eine vorhandene `Settings.json`, statt sie zu ersetzen —
richtig so, sonst gingen Logging-Einstellungen und Ähnliches verloren. Auf einem System
mit einer **alten** Datei bleiben dabei Felder stehen, die es in der aktuellen
`AppSettings` nicht mehr gibt (`watchdogServerUrl`, `licensePublicKeyBase64` aus der
LicenseLabrador-Zeit). Harmlos — `SettingsManager` ignoriert unbekannte Felder beim
Laden —, aber tote Einträge in der Datei. Kein Handlungsbedarf; beim ersten `Save` der
laufenden App verschwinden sie.
**Nicht enthalten** (SETUP §7): systemd-Unit und Windows-Dienst legt der Installer nicht
an. Für den kopflosen Betrieb bleibt das unsere Aufgabe.
---
## 4a. Der Update-Weg ist durchgespielt
Am 2026-08-14 gegen den echten Server geprüft, nicht nur gebaut. Ausgangslage: das
0.1.0-Paket mit Lizenzschlüssel geladen und entpackt — also eine Installation, wie sie
beim Kunden aussieht — plus eine selbst angelegte Datei, die in keinem Manifest steht.
| Fall | Ergebnis |
|---|---|
| **0.1.0 → 0.1.1** | RC 0. SHA256 des Pakets und 117 Manifest-Hashes geprüft. Fremde Datei unangetastet, `setup.json` da, `update-agent.exe` neu im Ziel |
| **Rücksprung 0.1.1 → 0.1.0** | RC 0. `update-agent.exe` als nicht mehr zum Release gehörig **entfernt** — und nur die, die fremde Datei blieb liegen |
| **Ohne Lizenzschlüssel** | `UNAUTHORIZED: … Erwartet wird der Lizenzschluessel dieser Installation`, RC 2. Sauber von einem Netzfehler unterschieden |
| **Abbruch mitten im Schreiben** (Datei exklusiv gesperrt) | RC 1, „Vorheriger Stand wurde wiederhergestellt". Version, Dateizahl und Inhalt unverändert — die Installation blieb lauffähig |
Damit trägt die Zusage aus §4B des UpdateService-Handbuchs: Ein Abbruch hinterlässt keine
halbe Installation, und verwaiste Dateien werden aufgeräumt, ohne fremde anzufassen.
Zwei Kleinigkeiten am Rand:
- Nach dem gescheiterten Lauf blieb ein **leeres** `.dc-update-backup/` zurück. Kein
Speicherverlust — der Rollback hatte alles zurückgeholt —, und der nächste erfolgreiche
Lauf hat es entfernt. Ein leeres Verzeichnis dieses Namens sieht für einen Betreiber
aber nach „Update hängt" aus.
- Der Agent weist bei **jedem** Lauf auf das unsignierte Release hin. Das ist richtig so
und wird erst still, wenn §5 erledigt ist.
---
## 5. Signierte Releases (2.2) — Schlüssel steht, Prüfung getestet
Der Signierschlüssel ist seit dem 2026-08-14 serverseitig hinterlegt (RSA-SHA256,
`canonical-line-v1`). **0.1.2 ist das erste signierte Release**; 0.1.0 und 0.1.1 bleiben
unsigniert, weil serverseitig beim Veröffentlichen signiert wird.
Am Testsystem durchgespielt:
| Fall | Ergebnis |
|---|---|
| Signiertes 0.1.2 mit `--require-signature` | RC 0, kein Unsigniert-Hinweis mehr |
| Unsigniertes 0.1.1 mit `--require-signature` | **RC 1, Abbruch vor dem Herunterladen** |
| Unsigniertes 0.1.1 ohne die Pflicht | RC 0 mit Hinweis — wie dokumentiert |
Der öffentliche Schlüssel wird beim ersten Lauf geholt und als
`dc-release-pubkey.pem` neben dem Agenten festgehalten. Ein später abweichender Schlüssel
fällt damit auf.
### Offen: die Pflicht ist aus der Anwendung heraus nicht erreichbar
`--require-signature` gibt es **nur als Kommandozeilenschalter**.
`UpdateClient.LaunchUpdateAgent` — der vom Handbuch empfohlene Weg, den auch wir
benutzen — hat dafür keinen Parameter, und der Agent liest keine Umgebungsvariable dafür
(`Program.cs:66` liest ausschließlich `HasFlag(args, "--require-signature")`).
Damit läuft jede Anwendung, die den empfohlenen Weg geht, ohne Signaturprüfung, während
derselbe Vorgang von Hand auf der Kommandozeile geschützt wäre. Wir könnten den Start
selbst nachbauen — dann verlieren wir aber `--restart`, `--wait-for-pid` und
`--wait-timeout`, also genau die Handgriffe, für die es die Hilfsmethode gibt.
**Gemeldet.** Sobald `LaunchUpdateAgent` einen Parameter dafür hat, setzen wir ihn:
Alle unsere Releases ab 0.1.2 sind signiert, ein Rückschritt auf unsignierte Stände wäre
danach kein Verlust.
---
## 6. Reihenfolge
1. **Zugangsschutz** (§1) — erledigt, muss im ersten Release drin sein.
2. **`setup.json`** (§4) — erledigt, wird mit dem ersten Paket ausgeliefert.
3. **Release-Strecke** (§2): `packager.config.json`, erster Testlauf nach `dev`.
4. **Update anwenden** (§3): Agent mitliefern, `maintenance` beim Update melden.
5. **Signatur scharf** (§5), sobald der Serverschlüssel steht.
Schritt 3 ist die Voraussetzung für alles Weitere: Solange kein Release veröffentlicht
ist, lässt sich weder Update noch Erstinstallation erproben — und die `setup.json` wirkt
erst, wenn sie in einem Paket steckt.
---
## 7. Befunde vom 2026-08-13 — alle behoben
Zur Nachvollziehbarkeit, weil einige unsere Umsetzung geformt haben:
| Befund | Behoben durch |
|---|---|
| `.htpasswd` enthielt alle Lizenzschlüssel im Klartext (Benutzernamenspalte wird nicht gehasht) | `ReleaseGuard::licenseUsername()` leitet `lic_<sha256[0..16]>` ab; `ReleaseCredentials.UsernameForLicenseKey` bildet dieselbe Ableitung nach. Die Datei enthält jetzt nur noch bcrypt über einen hochentropen Schlüssel |
| Doku beschrieb Nginx, der Schutz greift nur unter Apache; WebUI meldete „GESCHÜTZT" allein anhand vorhandener Dateien | Echter HTTP-Selbsttest (`ReleaseGuard::selfTest`, erwartet 401), Warnhinweis und eigener Nginx-Abschnitt in der Doku |
| UPDATESERVICE §7 dokumentierte `{"status":"ok"}`, der Code liefert `"success"` | Doku berichtigt |
| `setup.json` schrieb nur ins Installationsverzeichnis | `location`-Angabe am Ziel plus Variablenersetzung in `file` |
### Offen aus dem ersten Release (2026-08-13)
| Befund | Wirkung |
|---|---|
| **Veröffentlichen löst `ReleaseGuard` nicht aus.** `regenerateForProject` läuft nur bei Lizenzänderungen, Projektlöschung, Kontoänderungen und im Sechs-Stunden-Turnus von `cli/tick.php`. Ein Produktverzeichnis entsteht aber erst beim ersten Upload | `/releases/clawddotnet/` war nach dem Upload **ohne `.htaccess`** — das frische Paket bis zum nächsten Turnuslauf für jeden ladbar. Der Turnus hat es inzwischen geschlossen (401 bestätigt). Ein Aufruf am Ende von `/api/updateservice/v1/publish` würde das Fenster ganz vermeiden; das Verzeichnis existiert dort bereits |
| **Die Prüfvorschrift aus UPGRADE §16.4 meldet falsch grün.** `curl -I …/.htpasswd → 403` trifft auch dann zu, wenn die Datei gar nicht existiert: Apache sperrt `.ht*` global | Wir hatten 403 auf `.htpasswd` **und** 200 auf `package.tar.gz`. Aussagekräftig ist nur der Paket-Abruf ohne Zugangsdaten |
| **Kein Signierschlüssel auf dem Server.** `security.release_private_key` ist nicht gesetzt (UPGRADE §15.2) | Releases sind unsigniert, der Agent kann die Herkunft nicht prüfen. `--require-signature` ist damit unbenutzbar |
Die Ableitung des Benutzernamens muss auf beiden Seiten zeichengenau übereinstimmen —
`lic_` plus die ersten 16 Hexzeichen des SHA-256 über den getrimmten Schlüssel. Wer eine
Seite ändert, sperrt die gesamte Installationsbasis aus.
+8 -1
View File
@@ -221,9 +221,16 @@ Offen:
|---|---|---|
| DC1 | Oberfläche „Fehler melden" | Client vorhanden, Schaltfläche fehlt |
| DC2 | Agenten-Tool für den Bugtracker | macht den Claim/Lease-Workflow des Deploymentcenters nutzbar |
| DC3 | Release-Strecke: `pack-and-deploy` mit `<Version>` aufrufen, dann `update-agent` | ohne Release hat die Update-Prüfung nichts zu finden. Versionsnummer selbst ist erledigt (`Directory.Build.props``ReleaseInfo`) |
| DC3 | Release-Strecke | **erledigt für win-x64/dev** ([`deploy/publish.py`](../deploy/publish.py), 0.1.1 veröffentlicht). Offen: `linux-x64` nach der Avalonia-Portierung, `prod` |
| DC4 | SDK als Git-Submodul unter `external/` statt Cross-Repo-Pfad | betrifft auch die CI |
| DC5 | Betreiber: Evaluator-Cron einrichten, Token ausstellen, `parent_source` pflegen | **ohne den Cron ist die Überwachung wertlos** |
| DC6 | Update anwenden statt nur melden | **erledigt.** Agent liegt im Paket (Prüfsumme geprüft), Rückfrage in der Oberfläche, geordnetes Herunterfahren vor dem Agentenstart, `maintenance` an den Watchdog |
| DC7 | Erstinstallation über `--action install` + `setup.json` | **erledigt.** Setup-Kette live durchgespielt (Login, Katalog, Token mit Rechteschranke), Round-Trip SDK-Writer → SettingsManager trägt. `fileWindows`/`fileLinux` gesetzt |
| DC8 | Signaturpflicht (`--require-signature`) | Schlüssel steht, ab 0.1.2 wird signiert, Prüfung beidseitig getestet. **Blockiert:** `LaunchUpdateAgent` reicht den Schalter nicht durch — aus der Anwendung heraus nicht erzwingbar (gemeldet) |
Details: [Deploymentcenter 2.22.4 Integrationsplan](Deploymentcenter-2.4-Integrationsplan.md).
Der Zugangsschutz aus 2.4 (Lizenzschlüssel als Basic-Auth-Zugang zur Release-Ablage) ist
bereits umgesetzt — er muss im **ersten** veröffentlichten Release enthalten sein.
---
+219 -5
View File
@@ -62,6 +62,104 @@ Ebenso vorhanden und wiederverwendbar:
Geprüft gegen die REST- und Realtime-API von Rocket.Chat. Alles Folgende ist
Standardfunktion einer selbstgehosteten Instanz, kein Enterprise-Feature.
### 2.0 Prüfung gegen die echte Instanz (Rocket.Chat 8.7, August 2026)
Alles unten Stehende wurde gegen die Testinstanz gemessen, nicht aus der Dokumentation
übernommen. **Drei Annahmen waren falsch** — sie sind hier korrigiert.
**Bestätigt:**
| Prüfung | Ergebnis |
|---|---|
| `subscriptions.get` als Sammelabruf | liefert je Raum `rid`, `t`, `name`, `unread`, `userMentions`, `groupMentions`, `alert` — genau der Vorfilter, auf dem der Poll steht |
| `chat.postMessage`, auch mit `tmid` (Thread) | funktioniert |
| `channels.history` / `groups.history` / `im.history` mit `oldest` | funktioniert; Raumart bestimmt den Endpunkt |
| `subscriptions.read` | funktioniert; danach steht `ls` und `unread` fällt auf 0 |
| `im.create`, `groups.create`, Senden in privaten Gruppen | funktioniert |
| Erwähnungen | kommen als **`mentions[]`-Feld mit Benutzernamen** — der Erwähnungsfilter braucht kein Textparsen |
| Unbekannter Raum | `400 [invalid-channel]` |
| `users.create` mit Rolle `bot` | funktioniert (Admin) |
**Korrekturen:**
1. **Berechtigungsnamen.** Sie heißen `create-personal-access-tokens` (Rollen: `admin`,
`user`) und `user-generate-access-token` (Rolle: `admin`) — nicht wie zuvor notiert.
2. **Ein Admin kann *kein* Token für einen fremden Benutzer prägen.**
`users.generatePersonalAccessToken` lehnt `userId` ab („must NOT have additional
properties") — der Endpunkt gilt nur für den aufrufenden Benutzer.
`users.createToken` verlangt ein `secret`, für das es in dieser Instanz keine
Einstellung gibt. Beide Wege sind zu.
3. **Systemnachrichten.** Die Historie liefert auch Ereignisse wie „Benutzer beigetreten"
(Feld `t`, z. B. `uj`). Ohne Filter antwortet ein Agent auf einen Raumbeitritt. Im
Tool umgesetzt und geprüft.
**Offen geblieben:** Das Ratenlimit (`API_Enable_Rate_Limiter` = an, 10 Aufrufe/60 s)
griff bei 14 schnellen Aufrufen **nicht** — Administratoren umgehen es. Für einen
Benutzer mit reiner `bot`-Rolle ist es damit **nicht** gemessen. Der Entwurf bleibt mit
einem Sammelabruf je Takt weit darunter; nachzumessen, sobald ein Agenten-Benutzer
nutzbar ist.
### 2.0.1 Der Stolperstein: 2FA verhindert die automatische Bereitstellung
Ein frisch per API angelegter Benutzer **kann sich nicht anmelden**: Rocket.Chat antwortet
mit `totp-required` und schickt einen Code per E-Mail. Ursache ist
`Accounts_TwoFactorAuthentication_By_Email_Auto_Opt_In` (in der Testinstanz aktiv) — jeder
neue Benutzer bekommt E-Mail-2FA automatisch.
Geprüft und ausgeschlossen: `users.update` kennt kein Feld dafür („must NOT have
additional properties"), `users.resetTOTP` betrifft nur App-basiertes TOTP.
**Es gibt keinen Weg über die Admin-API, das E-Mail-2FA eines einzelnen Benutzers
abzuschalten.**
Damit stehen drei Wege offen — die Entscheidung gehört dir, weil sie eine
Sicherheitseinstellung berührt:
| Weg | Ablauf | Preis |
|---|---|---|
| **A (empfohlen)** | `Auto_Opt_In` global auf **aus**, dann Benutzer anlegen (Rollen `bot` + `user`), als dieser anmelden, PAT erzeugen, Passwort verwerfen | Neue **menschliche** Benutzer bekommen E-Mail-2FA dann nicht mehr automatisch. Bestehende Konten und TOTP bleiben unberührt |
| **B** | Setting bleibt; jeder Agent braucht ein echtes Postfach, ClawdDotNet holt den 2FA-Code per IMAP (das Mail-Tool kann das) | Funktioniert, ist aber ein zerbrechlicher Umweg |
| **C** | Halbautomatisch: API legt den Benutzer an, ein Mensch erzeugt das PAT einmalig in der Oberfläche | Kein „HR-Agent" möglich — bei jedem neuen Agenten Handarbeit |
**Weg A ist umgesetzt und gemessen** (August 2026): Nach dem Abschalten von `Auto_Opt_In`
läuft die Kette vollständig durch —
```
users.create (Rollen bot + user) → login als dieser Benutzer →
users.generatePersonalAccessToken → PAT
```
Damit ist ein „HR-Agent", der einen neuen Agenten samt Chat-Konto einrichtet, technisch
möglich. Die Rolle `user` ist dabei nötig, **nicht** nur `bot`: Nur sie bringt die
Berechtigung `create-personal-access-tokens` mit.
Ein Benutzer, der **vor** der Umstellung angelegt wurde, behält sein 2FA-Flag dauerhaft —
er lässt sich nicht nachträglich retten und muss neu angelegt werden.
Ob sich `Auto_Opt_In` nach der Bereitstellung wieder einschalten lässt, ohne die
bestehenden Agenten zu verlieren, ist plausibel (das Flag wird beim Anlegen gesetzt), aber
weiterhin **nicht gemessen**.
Für das Tool selbst ist die Frage folgenlos: Es nimmt `userId` und `authToken` aus der
Konfiguration entgegen, gleich woher sie stammen.
### 2.0.2 Zwei Fehler, die erst der Live-Test zeigte
Beide wären in keinem Schreibtischtest aufgefallen und sind behoben:
1. **`unread` zählt in dieser Instanz nur Erwähnungen.** Eine gewöhnliche Nachricht setzt
allein `alert=true`. Schwerer wiegt: `userMentions` ist ein Zähler über *ungelesene*
Erwähnungen und bleibt stehen, solange nichts gelesen wurde. Ein einziger alter,
ungelesener Ruf machte den Raum damit dauerhaft „heiß" — und anschließend wurde **jede**
weitere Nachricht ausgeliefert, auch ohne Erwähnung.
*Behoben:* Die Erwähnungsprüfung sitzt jetzt an der einzelnen Nachricht; der
Raum-Zähler ist nur noch ein billiger Vorfilter. Zusätzlich wird jeder geprüfte Raum als
gelesen markiert, auch wenn nichts zu wecken war — sonst veralten die Zähler.
2. **Die Startmarke verschluckte die erste echte Nachricht.** Ein Raum wird erst dann zum
Kandidaten, wenn Verkehr da ist — genau dann setzte der alte Code aber „Marke auf jetzt,
nichts wecken". Die auslösende Nachricht ging verloren.
*Behoben:* Beim ersten Abruf eines Raums wird begrenzt zurückgeschaut
(`initialLookbackMinutes`, Standard 5) statt zu überspringen.
### 2.1 Identität — ein echter Benutzer je Agent
Die Anforderung „jeder Agent mit eigenem Benutzer, in Gruppen und im Direktkontakt" ist
@@ -198,27 +296,143 @@ ist das am Ende nicht optional.
### 2.8 Tool-Zuschnitt
**Umgesetzt** (`src/ClawdDotNet.Tools.RocketChat`, gegen die Testinstanz geprüft):
```
Tool: RocketChat
Aktionen: send_message | reply | send_file | list_rooms | read_room
| mark_read | search
Aktionen: send_message | reply | send_file | list_rooms | read_room | mark_read
Job: rocketchat_poll
```
Noch nicht umgesetzt: `search`.
**Dateiversand.** `send_file` schickt eine Datei aus dem Workspace direkt in einen Raum
oder als Direktnachricht — der kurze Weg für „stell mir das zusammen und schick es rüber",
ohne Umweg über die Cloud. Pfade tragen dieselben Prefixe wie bei FileRW und FTP
(`personal:` / `shared:` / ohne Prefix), samt Prüfung gegen einen Ausbruch aus dem
Verzeichnis. Grenze über `maxUploadMb` (Standard 25, Serverseite erlaubt 100).
Dabei zeigte sich die **dritte Doku-Korrektur**: Der Ein-Schritt-Endpunkt `rooms.upload`
existiert in 8.7 nicht mehr — er antwortet mit einem nackten HTML-404. Aktuell ist ein
zweistufiger Ablauf:
1. `rooms.media/:rid` nimmt die Datei als `multipart/form-data` und liefert eine Datei-Id;
sichtbar ist damit noch **nichts**.
2. `rooms.mediaConfirm/:rid/:fileId` veröffentlicht sie als Nachricht.
Ohne den zweiten Schritt liegt die Datei hochgeladen, aber unsichtbar auf dem Server.
Voraussetzung ist damit Rocket.Chat 6.x oder neuer; einen Rückfall auf `rooms.upload` für
ältere Instanzen gibt es bewusst nicht, weil er sich hier nicht prüfen ließe.
Anders als `send_message` steht `RocketChat.send_file` in der Staging-Policy auf
**`approve`**: Eine Nachricht formuliert der Agent, eine Datei verlässt den Workspace als
Ganzes. Wer das im Alltag als zu hinderlich empfindet, streicht die eine Zeile in
`StagingPolicy.DefaultRules` — dann schützt weiterhin die Raum-Allowlist.
Geprüft gegen die Testinstanz (8 von 8): Markdown aus dem persönlichen Workspace · CSV aus
dem geteilten mit abweichendem Dateinamen · Datei per Direktnachricht · Ausbruchsversuch
`../` abgewiesen · absoluter Pfad abgewiesen · fehlende Datei · fehlender `localPath` ·
nicht freigegebener Raum.
### 2.9 Eingehende Dateien und Links
Was hereinkommt, ist genauso wichtig wie das, was hinausgeht — und stand anfangs nicht im
Entwurf.
**Dateien.** Eine Dateisendung trägt den Begleittext in `msg`, die Datei selbst aber
daneben in `files[]`. Wer nur `msg` liest, sieht bei einer reinen Dateisendung einen
**leeren Beitrag**. Weckmeldung und `read_room` führen Anhänge deshalb eigens auf:
```
[20:06] @richard (messageId: rD68…): schau dir die Zahlen bitte an.
Datei: auswertung.csv (text/csv, 77 B) — fileId: 6a821828ea0ad1bcab878f74
```
Mit dieser `fileId` holt `download_file` die Datei in den Workspace. Der Abruf geht gegen
`/file-upload/{fileId}/download` mit denselben Kopfzeilen wie die API — ohne sie antwortet
der Server mit 403 (`FileUpload_ProtectFiles` ist aktiv).
Schutzmaßnahmen, weil Name und Inhalt vom Absender bestimmt sind:
- Der Zielname wird auf den reinen Dateinamen reduziert; Pfadangaben darin verfallen.
Nur das Verzeichnis (`personal:` / `shared:`) darf der Agent wählen.
- Ausführbare Endungen (`.exe`, `.ps1`, `.bat`, `.jar`, …) werden abgelehnt, sofern nicht
`allowDangerousDownloads` gesetzt ist.
- Größengrenze `maxDownloadMb` (Standard 25) — geprüft an `Content-Length` **und**
während des Schreibens, da die Angabe fehlen darf.
- Geschrieben wird über eine `.part`-Nebendatei; bricht der Abruf ab, bleibt keine halbe
Datei liegen, die der Agent für vollständig hält.
Dabei fiel ein Windows-Fehler auf, der leicht zu übersehen ist: `Path.GetFileName` behält
`"shared:kopie.csv"` unverändert bei, weil `:` dort kein Pfadtrenner ist, sondern ein
**NTFS-Alternativdatenstrom** eingeleitet wird. Die Datei landete als Datenstrom am
Verzeichnis statt als eigene Datei. Der Präfix wird jetzt vor der Namensbereinigung
abgetrennt, und `WorkspaceFile` weist Doppelpunkte grundsätzlich ab.
**Links.** Rocket.Chat entpackt Links selbst und legt in `urls[].meta` Titel und
Beschreibung der Zielseite ab. Der Agent bekommt das mitgeliefert:
```
Link: https://www.rocket.chat/ — Rocket.Chat | Secure CommsOS™ …
```
Das erspart oft einen eigenen Seitenabruf. **Wichtig:** Diese Vorschau ist Text der
verlinkten Seite, also fremdbestimmt — sie steht deshalb wie alles andere innerhalb der
`<untrusted_content>`-Rahmung. Ein tatsächlicher Abruf der Seite bleibt Sache des
WebFetch-Tools und damit eine bewusste Entscheidung des Agenten, keine Nebenwirkung des
Empfangens.
Geprüft (9 von 9): Datei in der Weckmeldung samt `fileId` · Link mit Titel · Download in
den persönlichen und in den geteilten Workspace · Inhalt stimmt · Ausbruch über den
Dateinamen entschärft · gefährliche Endung abgelehnt · unbekannte `fileId` · fehlende
`fileId`.
Der Weckpfad ist gegen die Testinstanz durchgespielt (6 von 6 Erwartungen):
ruhiger Takt weckt nicht · Nachricht ohne Erwähnung weckt nicht · Erwähnung weckt ·
Direktnachricht weckt auch ohne Erwähnung · eigene Nachricht weckt nicht ·
private Gruppe weckt.
Konfiguration je Agent:
```json
"RocketChat": {
"baseUrl": "https://chat.example.org",
"userId": "aBcD…",
"authToken": "…", // von ConfigSecrets geschützt
"allowedRooms": ["GENERAL", "finanz-team"],
"authToken": "…", // von ConfigSecrets geschützt (Schlüssel "authtoken")
"username": "agent-hermes", // für den Erwähnungsfilter
"allowedRooms": ["GENERAL", "finanz-team", "richard"],
"defaultRoom": "finanz-team",
"mentionOnly": true,
"maxWakesPerRoomPerHour": 12
"agentUsernames": ["agent-hermes", "agent-atlas"],
"maxWakesPerRoomPerHour": 12,
"maxMessagesPerRoom": 20,
"initialLookbackMinutes": 5,
"maxUploadMb": 25
}
```
**`allowedRooms` gilt auch für Direktnachrichten.** Ein DM-Raum trägt den Namen des
Gegenübers — wer per DM erreichbar sein soll, steht dort mit seinem **Benutzernamen**
(oben `"richard"`). Ohne Eintrag ignoriert der Agent die Direktnachricht. Eine leere Liste
erlaubt alles; das Tool erfindet keine Allowlist.
Mit `"@benutzername"` als `room` beginnt der Agent auch ein **neues** Gespräch (`im.create`)
— nur bei ausdrücklicher `@`-Schreibweise, damit ein vertippter Kanalname nicht
stillschweigend zur Direktnachricht wird.
Zum Verhalten des Abrufs:
- **Erster Takt je Raum setzt nur die Marke** und weckt nicht — sonst käme beim Einrichten
die gesamte Raumgeschichte auf einmal in den Kontext.
- Die Marke wandert auf die jüngste **gesehene** Nachricht, auch auf gefilterte. Sonst
würde eine ignorierte Agentennachricht bei jedem Takt erneut geprüft.
- **Gelesen-Markierung erst nach dem Einsammeln** — bricht der Takt vorher ab, bleibt der
Zähler stehen und nichts geht verloren.
- Der Schleifenschutz vergleicht gegen `agentUsernames`, nicht gegen ein Server-Flag: Wir
wissen selbst am besten, welche Konten unsere Agenten sind.
- **Kein Eintrag in der Staging-Policy** — `RocketChat.send_message` bleibt bewusst `auto`
(Standard). Der Schutz sitzt an `allowedRooms`, nicht an einer Einzelfreigabe.
---
## 3 — Redundanz: was passiert, wenn Rocket.Chat ausfällt