diff --git a/ClawdDotNet.slnx b/ClawdDotNet.slnx
index f56edb6..51678ec 100644
--- a/ClawdDotNet.slnx
+++ b/ClawdDotNet.slnx
@@ -20,6 +20,7 @@
+
diff --git a/docs/Instanzmanager-Konzept.md b/docs/Instanzmanager-Konzept.md
index be4298d..4ecff0e 100644
--- a/docs/Instanzmanager-Konzept.md
+++ b/docs/Instanzmanager-Konzept.md
@@ -499,3 +499,53 @@ in einem Job-Objekt, das seine Kinder sofort mitnimmt; der Instanzprozess starb
bevor er die erste Protokollzeile schreiben konnte. Das ist ein Artefakt der Umgebung und
keine Eigenschaft des Managers — aber es heißt, dass ein Abnahmetest dieser Art nicht in
die gewöhnliche Testreihe gehört.
+
+### 9.4 Lizenz und Sub-Token im Manager
+
+**Ein neues Projekt: `ClawdDotNet.Licensing`.** `LicenseGate`, `LicenseWatch`,
+`LicenseInfo` und `ILicensePrompt` sind aus `ClawdDotNet.App` dorthin gezogen. Der Grund
+ist der Schnitt: Zwei Prozesse brauchen dieselbe Lizenzprüfung, und keiner von beiden darf
+den anderen kennen. In `Core` konnte es nicht landen — dann zöge jedes der sechzehn
+Werkzeugprojekte das Deploymentcenter-SDK mit.
+
+Dabei ist die Kopplung an `SettingsManager` verschwunden: `LicenseGate` kennt jetzt nur
+`ILicenseStore` — Serveradresse lesen, Schlüssel lesen und schreiben, speichern. Die
+Anwendung führt ihn in `Settings.json`, der Manager in `manager.json`. Der Torwächter prüft
+Schlüssel; Dateien verwaltet er nicht. Die Anwendungsversion kommt als Parameter statt aus
+einem globalen Zugriff — diese Schicht weiß nicht, welche Anwendung sie benutzt.
+
+**`ManagerCore` ist die Klammer:** eigene Einstellungen, Lizenz, Register, Meldeweg. Was
+darüber liegt — ein Fenster, die eine Weboberfläche für alle — setzt darauf auf.
+
+- **Ohne gültige Lizenz startet er keine Instanz.** Laufende werden trotzdem in die Liste
+ genommen: Man soll sie sehen — und anhalten können.
+- **Ein Widerruf hält alle an.** Der einzige Fall, in dem der Manager das von sich aus
+ tut. Ein Serverausfall gehört ausdrücklich nicht dazu; das SDK trennt Urteil und
+ gescheiterten Versuch, und nur das Urteil kommt hier an. Sonst nähme ein Netzproblem
+ sämtliche Installationen gleichzeitig mit.
+- **Ohne hinterlegte Lizenzprüfung sperrt er nichts.** Sonst gäbe es eine Henne-Ei-Sperre,
+ bevor überhaupt jemand einen Schlüssel eintragen kann.
+
+**Sub-Token je Instanz.** Der Manager hält das Master-Token und tauscht es beim **Anlegen**
+einer Instanz gegen ein eigenes — `watchdog:ping` und `bugtracker:report`, einzeln
+widerrufbar. Beim Anlegen und nicht beim ersten Start, weil die Instanzdatei dann
+zweifelsfrei niemandem gehört; sie später zu schreiben, während der Instanzprozess läuft,
+hieße zwei Schreiber auf einer Datei. Für Instanzen, die es vor dem Manager schon gab, wird
+es vor dem Start nachgeholt.
+
+Danach wird nachgefragt (`whoami`), ob das neue Token an ein Projekt gebunden ist — seit
+SDK 2.8.1 erbt ein Watchdog-Monitor das Projekt seines Ping-Tokens, und ohne Bindung landen
+die Befunde im Scheinprojekt „default". Ein fehlgeschlagener Tokenbezug hält die Instanz
+nicht auf: Monitoring, das nur bei perfekter Rechtelage läuft, ist genau dann still, wenn
+man es braucht.
+
+**Was noch fehlt, damit E-M3 vollständig ist:** Die Instanzen prüfen die Lizenz weiterhin
+selbst. Das lässt sich erst abschalten, wenn eine Instanz feststellen kann, ob ein Manager
+da ist (Abschnitt 7) — vorher entstünde eine Fassung ganz ohne Durchsetzung. Bis dahin
+führen beide ihren eigenen Schlüssel.
+
+**Ein Testfehler, der zweimal derselbe war.** In den Manager-Tests hatte ich die Antwort des
+Sockets hinterlegt, *bevor* der Manager hochfuhr — damit sagt man „diese Instanz läuft
+bereits", der Manager übernimmt sie (E-M6), der Starter wird nie angefasst, und der Test
+prüft etwas anderes, als er behauptet. Es gibt jetzt eine Hilfsmethode, die genau das
+ausschließt.
diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs
index c4e5a1b..08e72ab 100644
--- a/src/ClawdDotNet.App/AppHost.cs
+++ b/src/ClawdDotNet.App/AppHost.cs
@@ -30,6 +30,7 @@ using ClawdDotNet.Tools.WebFetch;
using ClawdDotNet.Tools.WebMonitor;
using Microsoft.Extensions.Logging;
using ClawdDotNet.Core.Instances;
+using ClawdDotNet.Licensing;
namespace ClawdDotNet.App;
@@ -269,8 +270,11 @@ public sealed class AppHost : IAsyncDisposable
}
// ─── 4. Lizenz ───
- var license = new LicenseGate(settings, loggerFactory.CreateLogger("ClawdDotNet.License"),
- callbacks.License);
+ var license = new LicenseGate(
+ new SettingsLicenseStore(settings),
+ loggerFactory.CreateLogger("ClawdDotNet.License"),
+ callbacks.License,
+ ReleaseInfo.Version);
if (!await license.RunStartupCheckAsync(ct))
{
diff --git a/src/ClawdDotNet.App/ClawdDotNet.App.csproj b/src/ClawdDotNet.App/ClawdDotNet.App.csproj
index ed7fc1c..c9aeb55 100644
--- a/src/ClawdDotNet.App/ClawdDotNet.App.csproj
+++ b/src/ClawdDotNet.App/ClawdDotNet.App.csproj
@@ -23,6 +23,7 @@
Verweis auf Avalonia oder WinForms ergaenzt, hat den Schnitt verletzt. -->
+
diff --git a/src/ClawdDotNet.App/Services/DeploymentcenterService.cs b/src/ClawdDotNet.App/Services/DeploymentcenterService.cs
index 92eb134..9276905 100644
--- a/src/ClawdDotNet.App/Services/DeploymentcenterService.cs
+++ b/src/ClawdDotNet.App/Services/DeploymentcenterService.cs
@@ -3,6 +3,7 @@ using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Deploymentcenter;
using ClawdDotNet.Core.Deploymentcenter.Watchdog;
using Microsoft.Extensions.Logging;
+using ClawdDotNet.Licensing;
namespace ClawdDotNet.App.Services;
diff --git a/src/ClawdDotNet.App/Settings/SettingsLicenseStore.cs b/src/ClawdDotNet.App/Settings/SettingsLicenseStore.cs
new file mode 100644
index 0000000..428a52e
--- /dev/null
+++ b/src/ClawdDotNet.App/Settings/SettingsLicenseStore.cs
@@ -0,0 +1,24 @@
+using ClawdDotNet.Licensing;
+
+namespace ClawdDotNet.App.Settings;
+
+///
+/// Die Anwendungseinstellungen als Ablage für den Lizenzschlüssel.
+///
+/// Der Torwächter kennt seit #528 (E-M3) nur noch — er
+/// prüft Schlüssel und verwaltet keine Dateien. Diese Klasse ist die eine Zeile, die aus
+/// Settings.json eine solche Ablage macht; der Instanzmanager hat sein
+/// Gegenstück für manager.json.
+///
+public sealed class SettingsLicenseStore(SettingsManager settings) : ILicenseStore
+{
+ public string ServerUrl => settings.AppSettings.DeploymentcenterUrl;
+
+ public string LicenseKey
+ {
+ get => settings.AppSettings.LicenseKey;
+ set => settings.AppSettings.LicenseKey = value;
+ }
+
+ public void Save() => settings.Save();
+}
diff --git a/src/ClawdDotNet.Desktop/HeadlessRun.cs b/src/ClawdDotNet.Desktop/HeadlessRun.cs
index 5014cb1..8eac663 100644
--- a/src/ClawdDotNet.Desktop/HeadlessRun.cs
+++ b/src/ClawdDotNet.Desktop/HeadlessRun.cs
@@ -1,6 +1,7 @@
using System.Runtime.InteropServices;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
+using ClawdDotNet.Licensing;
using ClawdDotNet.Web;
using ClawdDotNet.Web.Endpoints;
using Microsoft.Extensions.Logging;
diff --git a/src/ClawdDotNet.Desktop/Services/AvaloniaLicensePrompt.cs b/src/ClawdDotNet.Desktop/Services/AvaloniaLicensePrompt.cs
index b483e7c..573fcd7 100644
--- a/src/ClawdDotNet.Desktop/Services/AvaloniaLicensePrompt.cs
+++ b/src/ClawdDotNet.Desktop/Services/AvaloniaLicensePrompt.cs
@@ -3,6 +3,7 @@ using Avalonia.Threading;
using ClawdDotNet.App.Services;
using ClawdDotNet.Desktop.ViewModels;
using ClawdDotNet.Desktop.Views;
+using ClawdDotNet.Licensing;
namespace ClawdDotNet.Desktop.Services;
diff --git a/src/ClawdDotNet.Licensing/ClawdDotNet.Licensing.csproj b/src/ClawdDotNet.Licensing/ClawdDotNet.Licensing.csproj
new file mode 100644
index 0000000..458bebc
--- /dev/null
+++ b/src/ClawdDotNet.Licensing/ClawdDotNet.Licensing.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net10.0
+ enable
+ enable
+ ClawdDotNet.Licensing
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.App/Services/ILicensePrompt.cs b/src/ClawdDotNet.Licensing/ILicensePrompt.cs
similarity index 99%
rename from src/ClawdDotNet.App/Services/ILicensePrompt.cs
rename to src/ClawdDotNet.Licensing/ILicensePrompt.cs
index e8d3c07..4ba3b00 100644
--- a/src/ClawdDotNet.App/Services/ILicensePrompt.cs
+++ b/src/ClawdDotNet.Licensing/ILicensePrompt.cs
@@ -1,4 +1,4 @@
-namespace ClawdDotNet.App.Services;
+namespace ClawdDotNet.Licensing;
///
/// Wie der mit dem Benutzer spricht.
diff --git a/src/ClawdDotNet.Licensing/ILicenseStore.cs b/src/ClawdDotNet.Licensing/ILicenseStore.cs
new file mode 100644
index 0000000..17b3dc4
--- /dev/null
+++ b/src/ClawdDotNet.Licensing/ILicenseStore.cs
@@ -0,0 +1,31 @@
+namespace ClawdDotNet.Licensing;
+
+///
+/// Wo der Lizenzschlüssel liegt — und wo die Adresse steht, gegen die geprüft wird.
+///
+/// Eine Schnittstelle, weil es zwei Ablagen gibt (#528, E-M3). Die Anwendung
+/// führt den Schlüssel in Settings.json, der Instanzmanager in
+/// manager.json. Der Torwächter selbst soll von beidem nichts wissen: Er prüft
+/// Schlüssel, er verwaltet keine Dateien.
+///
+/// Nach E-M3 gehört die Lizenz auf Dauer dem Manager — eine Lizenz gilt der
+/// Installation und erlaubt beliebig viele Instanzen. Bis eine Instanz feststellen kann,
+/// ob ein Manager da ist, führen beide ihren eigenen Schlüssel; sonst entstünde
+/// zwischendurch eine Fassung ganz ohne Durchsetzung.
+///
+public interface ILicenseStore
+{
+ /// Die Adresse des Deploymentcenters. Leer heißt: Vorgabe aus .
+ string ServerUrl { get; }
+
+ /// Der hinterlegte Schlüssel. Der Torwächter schreibt ihn zurück, wenn ein neuer eingegeben wurde.
+ string LicenseKey { get; set; }
+
+ ///
+ /// Schreibt den Schlüssel fest.
+ ///
+ /// Darf werfen — der Aufrufer behandelt das als „gilt, ließ sich aber nicht
+ /// sichern" und läuft weiter.
+ ///
+ void Save();
+}
diff --git a/src/ClawdDotNet.App/Services/LicenseGate.cs b/src/ClawdDotNet.Licensing/LicenseGate.cs
similarity index 88%
rename from src/ClawdDotNet.App/Services/LicenseGate.cs
rename to src/ClawdDotNet.Licensing/LicenseGate.cs
index 4087d00..5df615a 100644
--- a/src/ClawdDotNet.App/Services/LicenseGate.cs
+++ b/src/ClawdDotNet.Licensing/LicenseGate.cs
@@ -1,8 +1,7 @@
-using ClawdDotNet.App.Settings;
using Deploymentcenter.Client;
using Microsoft.Extensions.Logging;
-namespace ClawdDotNet.App.Services;
+namespace ClawdDotNet.Licensing;
///
/// Durchsetzung der Lizenz für ClawdDotNet gegen das Lizenzmodul des Deploymentcenters.
@@ -27,27 +26,39 @@ namespace ClawdDotNet.App.Services;
///
public sealed class LicenseGate
{
- private readonly SettingsManager _settings;
+ private readonly ILicenseStore _store;
private readonly ILogger _logger;
private readonly ILicensePrompt _prompt;
private readonly LicenseClient _client;
private readonly string _serverUrl;
+ private readonly string _appVersion;
private string? _hardwareId;
- public LicenseGate(SettingsManager settings, ILogger logger, ILicensePrompt prompt)
+ ///
+ /// Wo Schlüssel und Serveradresse liegen. Eine Schnittstelle und keine
+ /// Einstellungsklasse, weil zwei Prozesse sie führen: die Anwendung in
+ /// Settings.json, der Instanzmanager in manager.json (E-M3).
+ ///
+ ///
+ /// Landet in der Aktivierungsliste des Deploymentcenters. Als Parameter und nicht
+ /// als globaler Zugriff: Diese Schicht weiß nicht, welche Anwendung sie benutzt.
+ ///
+ public LicenseGate(
+ ILicenseStore store, ILogger logger, ILicensePrompt prompt, string appVersion)
{
- _settings = settings;
+ _store = store;
_logger = logger;
_prompt = prompt;
+ _appVersion = appVersion;
- _serverUrl = string.IsNullOrWhiteSpace(settings.AppSettings.DeploymentcenterUrl)
+ _serverUrl = string.IsNullOrWhiteSpace(store.ServerUrl)
? LicenseInfo.DefaultServerUrl
- : settings.AppSettings.DeploymentcenterUrl.Trim();
+ : store.ServerUrl.Trim();
// Die Version landet in der Aktivierungsliste des Deploymentcenters. Ohne das
// trug dort jede Installation dieselbe "1.0.0", obwohl die Spalte dafür da ist.
- LicenseClient.DefaultAppVersion = ReleaseInfo.Version;
+ LicenseClient.DefaultAppVersion = appVersion;
// Kein eigener HttpClient mehr: Der interne des SDK hat seit 2.1 eine Zeitgrenze
// von 15 s. Vorher waren es 100 s — und damit ein Standbild beim Start, wenn der
@@ -76,7 +87,7 @@ public sealed class LicenseGate
/// widerrufener Schlüssel schlägt damit auch durch, ohne dass jemand neu startet.
///
public Task RevalidateAsync(CancellationToken ct = default)
- => ValidateAsync(_settings.AppSettings.LicenseKey, ct);
+ => ValidateAsync(_store.LicenseKey, ct);
///
/// Startprüfung. Fragt über nach, bis eine nutzbare
@@ -92,7 +103,7 @@ public sealed class LicenseGate
return true;
}
- var key = _settings.AppSettings.LicenseKey;
+ var key = _store.LicenseKey;
if (string.IsNullOrWhiteSpace(key))
{
@@ -142,13 +153,13 @@ public sealed class LicenseGate
result = await ValidateAsync(key, ct);
}
- _settings.AppSettings.LicenseKey = key;
+ _store.LicenseKey = key;
try
{
- _settings.Save();
+ _store.Save();
}
- catch (SettingsPersistenceException ex)
+ catch (Exception ex)
{
// Der Schlüssel ist gültig, ließ sich aber nicht sichern. Weiterlaufen ja —
// beim nächsten Start wird eben erneut gefragt.
@@ -183,7 +194,7 @@ public sealed class LicenseGate
try
{
return await _client.ValidateAsync(
- LicenseInfo.ProductSlug, key, _serverUrl, ReleaseInfo.Version, ct);
+ LicenseInfo.ProductSlug, key, _serverUrl, _appVersion, ct);
}
catch (OperationCanceledException)
{
diff --git a/src/ClawdDotNet.App/Services/LicenseInfo.cs b/src/ClawdDotNet.Licensing/LicenseInfo.cs
similarity index 98%
rename from src/ClawdDotNet.App/Services/LicenseInfo.cs
rename to src/ClawdDotNet.Licensing/LicenseInfo.cs
index dd35388..80d5f1c 100644
--- a/src/ClawdDotNet.App/Services/LicenseInfo.cs
+++ b/src/ClawdDotNet.Licensing/LicenseInfo.cs
@@ -1,4 +1,4 @@
-namespace ClawdDotNet.App.Services;
+namespace ClawdDotNet.Licensing;
///
/// Fest hinterlegte Lizenz-Eckdaten.
diff --git a/src/ClawdDotNet.App/Services/LicenseWatch.cs b/src/ClawdDotNet.Licensing/LicenseWatch.cs
similarity index 98%
rename from src/ClawdDotNet.App/Services/LicenseWatch.cs
rename to src/ClawdDotNet.Licensing/LicenseWatch.cs
index 7f3aa79..0e1619e 100644
--- a/src/ClawdDotNet.App/Services/LicenseWatch.cs
+++ b/src/ClawdDotNet.Licensing/LicenseWatch.cs
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Logging;
-namespace ClawdDotNet.App.Services;
+namespace ClawdDotNet.Licensing;
///
/// Prüft die Lizenz im laufenden Betrieb nach.
diff --git a/src/ClawdDotNet.Manager/ClawdDotNet.Manager.csproj b/src/ClawdDotNet.Manager/ClawdDotNet.Manager.csproj
index b50353c..d46f86a 100644
--- a/src/ClawdDotNet.Manager/ClawdDotNet.Manager.csproj
+++ b/src/ClawdDotNet.Manager/ClawdDotNet.Manager.csproj
@@ -20,6 +20,7 @@
Werkzeugprojekt ergaenzt, hat den Schnitt verletzt. -->
+
diff --git a/src/ClawdDotNet.Manager/InstanceTokenSource.cs b/src/ClawdDotNet.Manager/InstanceTokenSource.cs
new file mode 100644
index 0000000..d14ac20
--- /dev/null
+++ b/src/ClawdDotNet.Manager/InstanceTokenSource.cs
@@ -0,0 +1,126 @@
+using ClawdDotNet.Core.Deploymentcenter;
+using ClawdDotNet.Core.Instances;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.Manager;
+
+///
+/// Sorgt dafür, dass eine Instanz ein eigenes Melde-Token hat.
+///
+/// Als Schnittstelle, damit das Register geprüft werden kann, ohne mit einem Server
+/// zu sprechen — und damit ein Manager ohne Deploymentcenter-Anbindung einfach keinen
+/// bekommt statt einen kaputten.
+///
+public interface IInstanceTokenSource
+{
+ ///
+ /// true, wenn die Instanz danach ein eigenes Token hat — auch wenn sie schon
+ /// eines hatte. false heißt: Es bleibt beim bisherigen Weg.
+ ///
+ Task EnsureTokenAsync(string instanceDirectory, CancellationToken ct = default);
+}
+
+/// Tut nichts. Für einen Manager ohne Deploymentcenter-Anbindung.
+public sealed class NoInstanceTokenSource : IInstanceTokenSource
+{
+ public Task EnsureTokenAsync(string instanceDirectory, CancellationToken ct = default)
+ => Task.FromResult(false);
+}
+
+///
+/// Tauscht das Master-Token des Managers gegen ein Sub-Token je Instanz.
+///
+/// Warum der Manager das macht und nicht die Instanz (#528, E-M3): Er hält
+/// das Master-Token, sie nicht. Nach dem Umbau liegt auf keiner Instanz mehr ein
+/// Schlüssel, mit dem sich weitere ausstellen ließen — jedes Sub-Token kann nur
+/// watchdog:ping und bugtracker:report und ist einzeln widerrufbar. Rechte
+/// lassen sich beim Ausstellen ohnehin nur einschränken, nie erweitern.
+///
+/// Nur vor dem Start. Hier wird die Instanzdatei geschrieben, und die gehört
+/// dem Instanzprozess, sobald er läuft. Zwei Schreiber auf einer Datei sind keine
+/// Verlangsamung, sondern ein verlorener Wert — deshalb geschieht das ausschließlich,
+/// solange die Instanz aus ist.
+///
+/// Und danach wird nachgefragt. Seit SDK 2.8.1 erbt ein Watchdog-Monitor das
+/// Projekt seines Ping-Tokens; ohne Bindung landen die Befunde im Scheinprojekt „default",
+/// und eine Abfrage nach dem eigenen Projekt liefert für eine laufende, fleißig meldende
+/// Instanz null Befunde. Die Aufsicht sähe aus wie „nichts auffällig". Ob die
+/// Bindung steht, sagt nur der Server (whoami) — also wird er gefragt.
+///
+public sealed class DeploymentcenterTokenSource(
+ InstanceDirectoryManager directories,
+ DeploymentcenterApi api,
+ ILogger logger) : IInstanceTokenSource
+{
+ public async Task EnsureTokenAsync(
+ string instanceDirectory, CancellationToken ct = default)
+ {
+ try
+ {
+ var config = directories.LoadInstanceConfig(instanceDirectory);
+
+ if (config.Watchdog.HasToken)
+ return true; // Hat schon eines. Ein zweites waere nur ein zweiter Eintrag.
+
+ var provisioner = new TokenProvisioner(api);
+
+ var token = await provisioner.ProvisionAsync(
+ clientName: $"ClawdDotNet {config.InstanceName}",
+ instanceId: config.InstanceId,
+ scopes: TokenProvisioner.InstanceScopes,
+ ct: ct).ConfigureAwait(false);
+
+ config.Watchdog.AgentToken = token.Token;
+ directories.SaveInstanceConfig(instanceDirectory, config);
+
+ logger.LogInformation(
+ "Instanz {Name} hat ein eigenes Melde-Token bekommen ({TokenId}, Rechte: {Scopes}).",
+ config.InstanceName, token.TokenId, string.Join(", ", token.Scopes));
+
+ await WarnIfUnboundAsync(token.Token, config.InstanceName, ct).ConfigureAwait(false);
+
+ return true;
+ }
+ catch (Exception ex)
+ {
+ // Haeufigster Fall: Das Token des Managers ist selbst ein Sub-Token und darf
+ // keine weiteren ausstellen. Kein Grund, die Instanz nicht zu starten -
+ // Monitoring, das nur bei perfekter Rechtelage laeuft, ist genau dann still,
+ // wenn man es braucht.
+ logger.LogInformation(
+ "Kein eigenes Token für {Pfad} beziehbar ({Grund}) — die Instanz meldet "
+ + "mit dem, was sie hat.", instanceDirectory, ex.Message);
+
+ return false;
+ }
+ }
+
+ private async Task WarnIfUnboundAsync(string token, string name, CancellationToken ct)
+ {
+ try
+ {
+ using var eigene = new DeploymentcenterApi(api.BaseUrl, token);
+ var identity = await new TokenProvisioner(eigene).WhoAmIAsync(ct).ConfigureAwait(false);
+
+ if (identity.IsProjectBound)
+ {
+ logger.LogInformation(
+ "Meldungen der Instanz {Name} gehen an Projekt {Projekt}.",
+ name, identity.ProjectSlug);
+
+ return;
+ }
+
+ logger.LogWarning(
+ "Das Melde-Token der Instanz {Name} ({TokenId}) ist an kein Projekt gebunden. "
+ + "Ihre Watchdog-Befunde landen im Scheinprojekt \"default\" und tauchen in "
+ + "der Projektansicht nicht auf.", name, identity.TokenId);
+ }
+ catch (Exception ex)
+ {
+ // Aelterer Server ohne whoami, kein Netz, kein Recht: Das ist eine Auskunft,
+ // die ausfaellt, und kein Grund, das eben bezogene Token zu verwerfen.
+ logger.LogDebug(ex, "Token-Auskunft für Instanz {Name} nicht verfügbar.", name);
+ }
+ }
+}
diff --git a/src/ClawdDotNet.Manager/ManagerCore.cs b/src/ClawdDotNet.Manager/ManagerCore.cs
new file mode 100644
index 0000000..28aff16
--- /dev/null
+++ b/src/ClawdDotNet.Manager/ManagerCore.cs
@@ -0,0 +1,226 @@
+using ClawdDotNet.Core.Deploymentcenter;
+using ClawdDotNet.Core.Instances;
+using ClawdDotNet.Licensing;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.Manager;
+
+/// Die Ablage des Managers als Ort für den Lizenzschlüssel (E-M3).
+public sealed class ManagerLicenseStore(ManagerSettingsStore store) : ILicenseStore
+{
+ public string ServerUrl => store.Settings.DeploymentcenterUrl;
+
+ public string LicenseKey
+ {
+ get => store.Settings.LicenseKey;
+ set => store.Settings.LicenseKey = value;
+ }
+
+ public void Save() => store.Save();
+}
+
+/// Warum eine Instanz nicht startet.
+public enum StartRefusal
+{
+ /// Sie startet.
+ None,
+
+ /// Keine gültige Lizenz. Eine Lizenz gilt der Installation, nicht der Instanz.
+ NoLicense,
+
+ /// Der Prozess kam nicht hoch oder hat sich nicht gemeldet.
+ DidNotStart
+}
+
+///
+/// Der Instanzmanager als Ganzes — ohne Fenster, ohne Weboberfläche, ohne Prozess.
+///
+/// Was er zusammenhält: die eigenen Einstellungen, die Lizenz, das Register
+/// der Instanzen und den Weg, auf dem er stellvertretend meldet. Alles darüber — ein
+/// Fenster, die eine Weboberfläche für alle (E-M5) — setzt hier auf und gehört nicht
+/// hierher.
+///
+/// Die Lizenz ist die eine Sperre, die er kennt (E-M3). Sie gilt der
+/// Installation und erlaubt beliebig viele Instanzen; ohne sie startet er keine. Und ein
+/// Widerruf ist kein Ausfall, sondern ein Urteil: Dann hält er die laufenden an — als
+/// einziger Fall, in dem er das von sich aus tut.
+///
+/// Was er nicht tut: Instanzen von selbst starten (das täte nach einem
+/// Neustart der Maschine auch das, was jemand mit Absicht angehalten hat), Instanzen beim
+/// eigenen Ende beenden (E-M6: wer den Manager schließt, will den Manager schließen), und
+/// abgestürzte Instanzen wieder hochfahren — dazu ist keine Entscheidung getroffen.
+///
+public sealed class ManagerCore : IAsyncDisposable
+{
+ private readonly ManagerSettingsStore _settings;
+ private readonly IInstanceTokenSource _tokens;
+ private readonly SupervisionReporter? _reporter;
+ private readonly LicenseGate? _license;
+ private readonly LicenseWatch? _watch;
+ private readonly ILogger _logger;
+
+ ///
+ /// Was die letzte Lizenzprüfung ergeben hat.
+ ///
+ /// Ohne Torwächter true: Ein Manager ohne hinterlegte Lizenzprüfung
+ /// sperrt nichts — sonst gäbe es eine Henne-Ei-Sperre, bevor überhaupt jemand einen
+ /// Schlüssel eintragen kann.
+ ///
+ public bool LicenseHolds { get; private set; } = true;
+
+ public ManagerCore(
+ ManagerSettingsStore settings,
+ InstanceRegistry instances,
+ IInstanceTokenSource tokens,
+ ILoggerFactory loggerFactory,
+ SupervisionReporter? reporter = null,
+ LicenseGate? license = null,
+ LicenseWatch? watch = null)
+ {
+ _settings = settings;
+ Instances = instances;
+ _tokens = tokens;
+ _reporter = reporter;
+ _license = license;
+ _watch = watch;
+ _logger = loggerFactory.CreateLogger("ClawdDotNet.Manager");
+
+ // Jedes Vorkommnis geht ins Protokoll und, wenn es niemand bestellt hat, nach
+ // draussen. Bewusst nicht abgewartet: Eine Aufsicht darf nicht auf einen Server
+ // warten muessen, um weiter aufzupassen.
+ Instances.Observed += ereignis =>
+ {
+ if (_reporter is not null)
+ _ = _reporter.ReportAsync(ereignis);
+ };
+
+ if (_watch is not null)
+ _watch.Revoked += HandleLicenseRevokedAsync;
+ }
+
+ public InstanceRegistry Instances { get; }
+
+ public ManagerSettings Settings => _settings.Settings;
+
+ // ─── Hochfahren ───
+
+ ///
+ /// Prüft die Lizenz, sieht nach, was schon läuft, und übernimmt es.
+ ///
+ /// In dieser Reihenfolge. Erst die Lizenz, weil sie die Sperre ist. Dann
+ /// übernehmen, nicht starten — was läuft, lief schon, und was aus war, bleibt aus,
+ /// bis jemand es verlangt.
+ ///
+ /// false, wenn die Lizenz die Arbeit verbietet.
+ public async Task StartAsync(CancellationToken ct = default)
+ {
+ if (_license is not null)
+ {
+ LicenseHolds = await _license.RunStartupCheckAsync(ct).ConfigureAwait(false);
+
+ if (!LicenseHolds)
+ {
+ _logger.LogWarning(
+ "Ohne gültige Lizenz startet der Instanzmanager keine Instanz.");
+
+ // Trotzdem nachsehen: Laufende Instanzen gehoeren in die Liste, damit
+ // jemand sie sieht - und damit sie sich anhalten lassen.
+ await Instances.RefreshAsync(ct).ConfigureAwait(false);
+ return false;
+ }
+ }
+
+ _watch?.Start();
+
+ await Instances.RefreshAsync(ct).ConfigureAwait(false);
+
+ var laufen = Instances.Instances.Count(i => i.Condition == InstanceCondition.Running);
+
+ _logger.LogInformation(
+ "Instanzmanager bereit: {Gesamt} Instanzen bekannt, {Laufend} laufen bereits.",
+ Instances.Instances.Count, laufen);
+
+ return true;
+ }
+
+ // ─── Instanzen ───
+
+ ///
+ /// Legt eine Instanz an und besorgt ihr gleich ihr eigenes Melde-Token.
+ ///
+ /// Beim Anlegen und nicht beim ersten Start, weil die Instanzdatei dann
+ /// zweifelsfrei niemandem gehört. Sie später zu schreiben, während der Instanzprozess
+ /// läuft, hieße zwei Schreiber auf einer Datei.
+ ///
+ public async Task CreateInstanceAsync(string name, CancellationToken ct = default)
+ {
+ var verzeichnis = Instances.Create(name);
+
+ await _tokens.EnsureTokenAsync(verzeichnis, ct).ConfigureAwait(false);
+
+ return verzeichnis;
+ }
+
+ ///
+ /// Startet eine Instanz — wenn die Lizenz es erlaubt.
+ ///
+ /// Das Token wird vorher besorgt, falls noch keines da ist: bei einer Instanz,
+ /// die es vor dem Manager schon gab, oder wenn das Deploymentcenter beim Anlegen
+ /// nicht erreichbar war. Danach ist die Datei dem Instanzprozess überlassen.
+ ///
+ public async Task StartInstanceAsync(
+ string instanceDirectory, CancellationToken ct = default)
+ {
+ if (!LicenseHolds)
+ {
+ _logger.LogWarning(
+ "Start von {Pfad} abgelehnt: keine gültige Lizenz.", instanceDirectory);
+
+ return StartRefusal.NoLicense;
+ }
+
+ await _tokens.EnsureTokenAsync(instanceDirectory, ct).ConfigureAwait(false);
+
+ return await Instances.StartAsync(instanceDirectory, ct).ConfigureAwait(false)
+ ? StartRefusal.None
+ : StartRefusal.DidNotStart;
+ }
+
+ public Task StopInstanceAsync(string instanceDirectory, CancellationToken ct = default)
+ => Instances.StopAsync(instanceDirectory, ct);
+
+ // ─── Das Urteil ───
+
+ ///
+ /// Die Lizenz gilt nicht mehr.
+ ///
+ /// Der einzige Fall, in dem der Manager von sich aus Instanzen anhält. Ein
+ /// Serverausfall gehört ausdrücklich nicht dazu — das SDK trennt beides, und nur ein
+ /// Urteil kommt hier an. Sonst nähme ein Netzproblem sämtliche Installationen
+ /// gleichzeitig mit.
+ ///
+ /// Öffentlich, weil sich das so prüfen lässt, ohne einen Lizenzserver zu
+ /// betreiben: Der Weg vom Widerruf zum Anhalten ist die Zusage, nicht die Frage, wer
+ /// ihn ausgelöst hat.
+ ///
+ public async Task HandleLicenseRevokedAsync(string grund)
+ {
+ LicenseHolds = false;
+
+ _logger.LogError("Lizenz gilt nicht mehr: {Grund}", grund);
+
+ await Instances.StopAllAsync($"Lizenz gilt nicht mehr: {grund}").ConfigureAwait(false);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_watch is not null)
+ {
+ _watch.Revoked -= HandleLicenseRevokedAsync;
+ await _watch.DisposeAsync().ConfigureAwait(false);
+ }
+
+ // Die Instanzen laufen weiter (E-M6).
+ await Instances.DisposeAsync().ConfigureAwait(false);
+ }
+}
diff --git a/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs b/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs
new file mode 100644
index 0000000..39247b5
--- /dev/null
+++ b/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs
@@ -0,0 +1,219 @@
+using ClawdDotNet.Core.Instances;
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+
+namespace ClawdDotNet.Manager.Tests;
+
+/// Zählt mit, wem ein Token besorgt wurde.
+internal sealed class FakeTokenSource : IInstanceTokenSource
+{
+ public List Fuer { get; } = [];
+
+ public bool Erfolg { get; set; } = true;
+
+ public Task EnsureTokenAsync(string instanceDirectory, CancellationToken ct = default)
+ {
+ Fuer.Add(Path.GetFullPath(instanceDirectory));
+ return Task.FromResult(Erfolg);
+ }
+}
+
+///
+/// Der Instanzmanager als Ganzes (#528, Stufe 3).
+///
+/// Die Lizenz wird hier ohne Lizenzserver geprüft, und das ist Absicht: Die
+/// Zusage lautet nicht „der Torwächter funktioniert" — das ist die Aufgabe des SDK —,
+/// sondern „ein Urteil führt zum Anhalten aller Instanzen, ein Ausfall nicht". Genau
+/// dieser Weg wird hier gegangen, ausgelöst über
+/// .
+///
+public sealed class ManagerCoreTests : IAsyncDisposable
+{
+ private readonly string _root = Path.Combine(
+ Path.GetTempPath(), "clawd-tests", "core-" + Guid.NewGuid().ToString("N")[..8]);
+
+ private readonly FakeLauncher _launcher = new();
+ private readonly FakeChannel _kanal = new();
+ private readonly FakeTokenSource _tokens = new();
+ private readonly InstanceDirectoryManager _verzeichnisse;
+
+ private static readonly SupervisionOptions Options = new(
+ ProbeInterval: TimeSpan.FromMilliseconds(50),
+ StartTimeout: TimeSpan.FromMilliseconds(600),
+ SilentChecksBeforeAlarm: 3,
+ StopGrace: TimeSpan.FromMilliseconds(300));
+
+ public ManagerCoreTests()
+ {
+ Directory.CreateDirectory(_root);
+ _verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
+ }
+
+ private ManagerCore Manager()
+ {
+ var register = new InstanceRegistry(
+ _verzeichnisse, _launcher, _kanal, NullLoggerFactory.Instance, Options);
+
+ return new ManagerCore(
+ new ManagerSettingsStore(_root), register, _tokens, NullLoggerFactory.Instance);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await Task.CompletedTask;
+ try { Directory.Delete(_root, recursive: true); }
+ catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
+ }
+
+ ///
+ /// Lässt die Instanz ab jetzt antworten — und prüft, dass sie danach wirklich
+ /// gestartet und nicht bloß übernommen wurde.
+ ///
+ /// Der Unterschied ist keine Feinheit. Wer die Antwort schon vor dem
+ /// Hochfahren des Managers hinterlegt, sagt damit „diese Instanz läuft bereits" — der
+ /// Manager übernimmt sie dann (E-M6), der Starter wird nie angefasst, und der Test
+ /// prüft etwas anderes, als er behauptet. Genau das ist mir hier zweimal
+ /// passiert.
+ ///
+ private async Task StarteUndPruefe(ManagerCore manager, string pfad, string name)
+ {
+ var vorher = _launcher.Starts;
+
+ _kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-1", name, "0.1.2");
+
+ var ergebnis = await manager.StartInstanceAsync(pfad);
+
+ _launcher.Starts.ShouldBe(vorher + 1, "Gestartet, nicht uebernommen.");
+ return ergebnis;
+ }
+
+ // ─── Hochfahren ───
+
+ [Fact]
+ public async Task Ohne_Lizenzpruefung_arbeitet_der_Manager()
+ {
+ // Kein Torwaechter heisst keine Sperre - sonst gaebe es eine Henne-Ei-Sperre,
+ // bevor ueberhaupt jemand einen Schluessel eintragen kann.
+ await using var manager = Manager();
+
+ (await manager.StartAsync()).ShouldBeTrue();
+ manager.LicenseHolds.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task Beim_Hochfahren_wird_nichts_gestartet()
+ {
+ _verzeichnisse.CreateInstance("Alpha");
+
+ await using var manager = Manager();
+ await manager.StartAsync();
+
+ manager.Instances.Instances.Count.ShouldBe(1);
+ _launcher.Starts.ShouldBe(0);
+ }
+
+ // ─── Token ───
+
+ [Fact]
+ public async Task Eine_neue_Instanz_bekommt_gleich_ihr_eigenes_Token()
+ {
+ // Beim Anlegen und nicht beim ersten Start: Dann gehoert die Instanzdatei
+ // zweifelsfrei niemandem.
+ await using var manager = Manager();
+
+ var pfad = await manager.CreateInstanceAsync("Frisch");
+
+ _tokens.Fuer.ShouldBe([Path.GetFullPath(pfad)]);
+ }
+
+ [Fact]
+ public async Task Vor_dem_Start_wird_das_Token_nachgeholt()
+ {
+ // Der Fall einer Instanz, die es vor dem Manager schon gab - oder bei der das
+ // Deploymentcenter beim Anlegen nicht erreichbar war.
+ var pfad = _verzeichnisse.CreateInstance("Alt");
+
+ await using var manager = Manager();
+ await manager.StartAsync();
+
+ (await StarteUndPruefe(manager, pfad, "Alt")).ShouldBe(StartRefusal.None);
+
+ _tokens.Fuer.ShouldContain(Path.GetFullPath(pfad));
+ }
+
+ [Fact]
+ public async Task Ein_fehlgeschlagener_Tokenbezug_verhindert_den_Start_nicht()
+ {
+ // Monitoring, das nur bei perfekter Rechtelage laeuft, ist genau dann still,
+ // wenn man es braucht.
+ _tokens.Erfolg = false;
+
+ var pfad = _verzeichnisse.CreateInstance("Alt");
+
+ await using var manager = Manager();
+ await manager.StartAsync();
+
+ (await StarteUndPruefe(manager, pfad, "Alt")).ShouldBe(StartRefusal.None);
+ }
+
+ // ─── Das Urteil ───
+
+ ///
+ /// Der einzige Fall, in dem der Manager von sich aus Instanzen anhält.
+ ///
+ /// Ein Serverausfall gehört ausdrücklich nicht dazu — das SDK trennt Urteil und
+ /// gescheiterten Versuch, und nur das Urteil kommt hier an. Sonst nähme ein
+ /// Netzproblem sämtliche Installationen gleichzeitig mit.
+ ///
+ [Fact]
+ public async Task Ein_Lizenzwiderruf_haelt_alle_laufenden_Instanzen_an()
+ {
+ var pfad = _verzeichnisse.CreateInstance("Alpha");
+
+ await using var manager = Manager();
+ await manager.StartAsync();
+ await StarteUndPruefe(manager, pfad, "Alpha");
+
+ manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Running);
+
+ // Die Instanz endet auf die Bitte hin.
+ _launcher.Last!.Exit(0);
+ _kanal.AnswerFor[Path.GetFullPath(pfad)] = null;
+
+ await manager.HandleLicenseRevokedAsync("Widerrufen durch den Hersteller");
+
+ manager.LicenseHolds.ShouldBeFalse();
+ manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Stopped);
+ _kanal.StopRequests.ShouldBe(1, "Erst bitten, nicht schiessen.");
+ }
+
+ [Fact]
+ public async Task Nach_einem_Widerruf_startet_keine_Instanz_mehr()
+ {
+ var pfad = _verzeichnisse.CreateInstance("Alpha");
+
+ await using var manager = Manager();
+ await manager.StartAsync();
+
+ await manager.HandleLicenseRevokedAsync("Widerrufen");
+
+ (await manager.StartInstanceAsync(pfad)).ShouldBe(StartRefusal.NoLicense);
+
+ _launcher.Starts.ShouldBe(0);
+ _tokens.Fuer.ShouldBeEmpty("Ohne Lizenz wird auch kein Token verbraucht.");
+ }
+
+ // ─── Wenn der Start scheitert ───
+
+ [Fact]
+ public async Task Eine_Instanz_die_sich_nicht_meldet_gilt_als_nicht_gestartet()
+ {
+ var pfad = _verzeichnisse.CreateInstance("Stumm");
+
+ await using var manager = Manager();
+ await manager.StartAsync();
+
+ // Der Prozess laeuft, die Instanz meldet sich nie.
+ (await manager.StartInstanceAsync(pfad)).ShouldBe(StartRefusal.DidNotStart);
+ }
+}