diff --git a/ClawdDotNet.slnx b/ClawdDotNet.slnx index 6507084..0b90662 100644 --- a/ClawdDotNet.slnx +++ b/ClawdDotNet.slnx @@ -12,6 +12,7 @@ + diff --git a/deploy/publish.py b/deploy/publish.py index da490d4..1dd2906 100644 --- a/deploy/publish.py +++ b/deploy/publish.py @@ -36,6 +36,7 @@ from pathlib import Path REPO = Path(__file__).resolve().parent.parent PROJECT = REPO / "src" / "ClawdDotNet.Desktop" / "ClawdDotNet.Desktop.csproj" MANAGER_PROJECT = REPO / "src" / "ClawdDotNet.ManagerApp" / "ClawdDotNet.ManagerApp.csproj" +SHELL_PROJECT = REPO / "src" / "ClawdDotNet.Shell" / "ClawdDotNet.Shell.csproj" CONFIG = REPO / "deploy" / "packager.config.json" PACKAGER = Path( "J:/Softwareprojekte/Deploymentcenter/client-dotnet/Deploymentcenter.Packager" @@ -59,7 +60,7 @@ def run(cmd: list[str], **kw) -> None: sys.exit(f"Abgebrochen (Rueckgabewert {result.returncode}): {cmd[0]}") -def publish(rid: str, out_dir: Path) -> None: +def publish(rid: str, out_dir: Path, include_shell: bool = True) -> None: if out_dir.exists(): shutil.rmtree(out_dir) out_dir.mkdir(parents=True) @@ -90,6 +91,9 @@ def publish(rid: str, out_dir: Path) -> None: ]) merge_manager(manager_dir, out_dir) + if include_shell: + publish_shell(rid, out_dir, stamp) + # #795: Die systemd-Vorlagen gehoeren ins Linux-Paket - sonst steht die Anleitung in # einem Repository, das auf der Zielmaschine niemand hat. if rid.startswith("linux"): @@ -100,6 +104,44 @@ def publish(rid: str, out_dir: Path) -> None: print(f" systemd-Vorlagen: {len(list(units.glob('*.service')))} nach systemd/") +def publish_shell(rid: str, out_dir: Path, stamp: str) -> None: + """#764: Die Huelle - Fenster mit eingebettetem Chromium - nach shell/. + + Eigener Ordner, weil sie Avalonia 11.3 mitbringt (CefGlue ist dagegen gebaut) und der + Instanzprozess weder Avalonia noch Chromium laden soll. Kein merge_manager: Die + Bibliotheken der Huelle sollen gerade NICHT neben denen der Instanz liegen. + + build statt publish: Das Chromium-Laufzeitpaket kopiert seine Dateien nur im + AfterBuild-Ziel mit gesetzter Plattform; ein publish liess sie aus (23 MB statt + ~370 MB, und kein libcef). Unter Linux sucht Chromium icudtl.dat und die .pak-Dateien + neben libcef.so, das Paket legt sie nach Resources/ - sie werden hochgezogen. + """ + shell_dir = out_dir / "shell" + if shell_dir.exists(): + shutil.rmtree(shell_dir) + + run([ + "dotnet", "build", str(SHELL_PROJECT), + "-c", "Release", "-r", rid, "--self-contained", "false", + "-o", str(shell_dir), "--nologo", "-v", "q", stamp, + ]) + + resources = shell_dir / "Resources" + if rid.startswith("linux") and resources.is_dir(): + for item in resources.iterdir(): + target = shell_dir / item.name + if target.exists(): + shutil.rmtree(target) if target.is_dir() else target.unlink() + shutil.move(str(item), str(target)) + resources.rmdir() + + for pdb in shell_dir.rglob("*.pdb"): + pdb.unlink() + + size = sum(f.stat().st_size for f in shell_dir.rglob("*") if f.is_file()) + print(f" Huelle: {size / 1_048_576:.1f} MB nach shell/") + + def merge_manager(src: Path, dst: Path) -> None: """Legt den Manager neben die Instanz. @@ -183,6 +225,8 @@ def main() -> None: parser.add_argument("--agent-file", type=Path, help="vorhandene Kopie des update-agent statt Download; " "die Pruefsumme wird trotzdem gegen installer.json geprueft") + parser.add_argument("--no-shell", action="store_true", + help="die Huelle (Fenster mit Chromium, ~370 MB) nicht mitliefern") parser.add_argument("--dry-run", action="store_true", help="nur bauen und pruefen, nicht hochladen") args = parser.parse_args() @@ -193,7 +237,7 @@ def main() -> None: out_dir = Path(tempfile.gettempdir()) / f"clawddotnet-publish-{args.rid}" print("\n[1/3] dotnet publish") - publish(args.rid, out_dir) + publish(args.rid, out_dir, include_shell=not args.no_shell) print("\n[2/3] update-agent") if args.no_agent: diff --git a/docs/umsetzungsplaene/WebUI-Only-und-Englisch.md b/docs/umsetzungsplaene/WebUI-Only-und-Englisch.md index 2d35668..4fecd0d 100644 --- a/docs/umsetzungsplaene/WebUI-Only-und-Englisch.md +++ b/docs/umsetzungsplaene/WebUI-Only-und-Englisch.md @@ -132,6 +132,19 @@ Probelauf am selben Tag mit `CefGlue.Next.Avalonia` 152.7977.831 (CEF 152, net10 | Größe: ~370 MB (linux-x64) zusätzlich | Paket wird entsprechend größer | | Die Linux-/macOS-CEF-Pakete baut ein kleiner Fork (`Deon-Berlin/CefGlue`) selbst; Windows nutzt die offiziellen `chromiumembeddedframework.runtime` | Lieferkette: native Binärdateien von einem Einzelprojekt | +**Stand 2026-10-03: gebaut** (Branch `feat/764-stufe2-huelle`, nach Stufe 3): + +| Teil | Wie | +|---|---| +| `src/ClawdDotNet.Shell` | Eigenes Programm, Avalonia 11.3.17 + CefGlue.Next 152, im Paket unter `shell/` (~378 MB unter Linux). Fenster mit der WebUI, Taskleistensymbol mit Zustand (über den Socket), Menü „öffnen" / „Fenster beenden". Der Manager läuft beim Schließen weiter. `--selftest` meldet, ob die Anmeldung trägt | +| Manager-Socket | `manager-shell.sock` neben `manager.json` (0600) bzw. Named Pipe nur für den eigenen Benutzer. Dort gibt es **nur** `/api/shell/*`, über TCP gibt es diese Pfade nicht (404 in beide Richtungen) | +| Einmal-Link | `POST /api/shell/login-code` (nur Socket) → Code mit 256 Bit, einmal, höchstens 60 s, im **Fragment** (`#code=`), erreicht also keinen Server und kein Protokoll. Die WebUI tauscht ihn über `POST /api/login/exchange` gegen das Token und entfernt ihn sofort aus der Adresse | +| Starter | `ClawdDotNet` ohne Schalter startet `shell/ClawdDotNet.Shell`, fehlt die Hülle, den Systembrowser. Manager finden oder starten (`ManagerStarter` in Core): Antwortet er, nichts tun; gibt es `clawddotnet-manager.service`, den Dienst starten; sonst das Programm | +| Packaging | `publish.py` baut die Hülle mit `dotnet build -r ` (publish ließ Chromium aus) und zieht unter Linux `Resources/*` neben `libcef.so`. `OutputType` für Linux `Exe`, sonst kopiert das Laufzeitpaket nichts. `--no-shell` lässt sie weg | +| Geprüft | Tests: zweimal benutzt, zu spät, über TCP angefordert, Socket führt nur zu `/api/shell` (ShellLoginTests, 8). `--selftest` unter Windows (Hülle startet den Manager selbst) und auf der VM unter Xvfb :97: angemeldet, Code aus der Adresse, TCP 404, Socket `srw-------` | + +Auf der VM stieß der Selbststart auf den echten Dienst `clawddotnet-manager.service` (Alltag) und startete — richtig — diesen statt eines zweiten Managers. Für einen Test mit eigener Konfiguration den Wegwerf-Manager deshalb vorher selbst starten. + ### Stufe 3 — Avalonia-Fenster entfernen (Flash: mit Vorsicht) **Stand 2026-10-02: erledigt, vor Stufe 2 gezogen.** Der Mensch hat so entschieden, weil CEF diff --git a/src/ClawdDotNet.Core/Instances/InstanceEndpoint.cs b/src/ClawdDotNet.Core/Instances/InstanceEndpoint.cs index beb9604..372fd85 100644 --- a/src/ClawdDotNet.Core/Instances/InstanceEndpoint.cs +++ b/src/ClawdDotNet.Core/Instances/InstanceEndpoint.cs @@ -121,7 +121,7 @@ public static class InstanceEndpoint ClearStaleFile(McpSocketPath(instanceDirectory)); } - private static void ClearStaleFile(string path) + internal static void ClearStaleFile(string path) { try { @@ -152,7 +152,7 @@ public static class InstanceEndpoint RestrictFile(McpSocketPath(instanceDirectory)); } - private static void RestrictFile(string path) + internal static void RestrictFile(string path) { if (OperatingSystem.IsWindows()) return; @@ -185,7 +185,7 @@ public static class InstanceEndpoint public static HttpMessageHandler CreateHandler(string instanceDirectory) => CreateHandler(SocketPath(instanceDirectory), PipeName(instanceDirectory)); - private static HttpMessageHandler CreateHandler(string socketPath, string pipeName) + internal static HttpMessageHandler CreateHandler(string socketPath, string pipeName) { if (OperatingSystem.IsWindows()) { @@ -239,7 +239,7 @@ public static class InstanceEndpoint /// müssen denselben Pipenamen ergeben. Unter Linux sind es zwei Verzeichnisse und /// dürfen es nicht. /// - private static string Fingerprint(string instanceDirectory) + internal static string Fingerprint(string instanceDirectory) { var full = Path.GetFullPath(instanceDirectory).TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); diff --git a/src/ClawdDotNet.Core/Instances/ManagerEndpoint.cs b/src/ClawdDotNet.Core/Instances/ManagerEndpoint.cs new file mode 100644 index 0000000..a407686 --- /dev/null +++ b/src/ClawdDotNet.Core/Instances/ManagerEndpoint.cs @@ -0,0 +1,46 @@ +namespace ClawdDotNet.Core.Instances; + +/// +/// #764: The instance manager's local socket — the way the shell (ClawdDotNet.Shell) talks to +/// it without the web access token. +/// +/// Why a socket and not the web port. The shell opens the web interface signed in, +/// through a one-time code. Asking for that code must prove that the asker is the user who runs +/// the manager on this machine — not anybody who reaches the port. The file system says that: +/// the socket is 0600 (Linux) or a pipe for the current user only (Windows), exactly like the +/// instance socket (). Over TCP the shell endpoints do not exist. +/// +/// It lives next to manager.json: the shell finds the manager the same way it finds +/// its port. +/// +public static class ManagerEndpoint +{ + public const string SocketFileName = "manager-shell.sock"; + + public static string SocketPath(string configDirectory) + => Path.Combine(Path.GetFullPath(configDirectory), SocketFileName); + + /// Pipe names are machine-wide; a fingerprint of the folder keeps two installations apart. + public static string PipeName(string configDirectory) + => "clawd-manager-" + InstanceEndpoint.Fingerprint(configDirectory); + + public static HttpClient CreateClient(string configDirectory) + => new(InstanceEndpoint.CreateHandler(SocketPath(configDirectory), PipeName(configDirectory))) + { + BaseAddress = new Uri("http://manager") + }; + + /// A socket file left by a hard end would block the next bind (see ). + public static void ClearStale(string configDirectory) + { + if (OperatingSystem.IsWindows()) return; + InstanceEndpoint.ClearStaleFile(SocketPath(configDirectory)); + } + + /// 0600 after binding; on Windows the pipe's CurrentUserOnly carries the same promise. + public static void Restrict(string configDirectory) + { + if (OperatingSystem.IsWindows()) return; + InstanceEndpoint.RestrictFile(SocketPath(configDirectory)); + } +} diff --git a/src/ClawdDotNet.Core/Instances/ManagerStarter.cs b/src/ClawdDotNet.Core/Instances/ManagerStarter.cs new file mode 100644 index 0000000..0dc6609 --- /dev/null +++ b/src/ClawdDotNet.Core/Instances/ManagerStarter.cs @@ -0,0 +1,165 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Text.Json; +using ClawdDotNet.Core.Storage; + +namespace ClawdDotNet.Core.Instances; + +/// +/// #764: Finds the running instance manager, or starts it — for the two programs a person starts +/// by hand: ClawdDotNet without a switch and the shell (ClawdDotNet.Shell). +/// +/// Never a second manager. If the manager answers, it is left alone. If it runs as a +/// systemd service on this machine, the service is started — starting the program next to it +/// would put two managers on the same instances. Only otherwise is the manager program started +/// directly. +/// +public static class ManagerStarter +{ + public const int DefaultPort = 7000; + public const string ServiceUnit = "clawddotnet-manager.service"; + private static readonly TimeSpan StartWait = TimeSpan.FromSeconds(30); + + /// Where manager.json lives — and next to it the manager's local socket. + public static string ConfigDirectory => AppPaths.ConfigDirectory; + + /// + /// Makes sure the manager answers. Returns the port, or the reason it does not answer. + /// + /// Folder of the calling program; the manager is looked for there and one level up. + public static async Task<(int Port, string? Problem)> EnsureRunningAsync(string searchFrom) + { + var port = Port(ConfigDirectory); + if (Answers(port)) + return (port, null); + + if (Start(searchFrom) is { } problem) + return (port, problem); + + var until = DateTime.UtcNow + StartWait; + while (!Answers(port) && DateTime.UtcNow < until) + await Task.Delay(500).ConfigureAwait(false); + + return Answers(port) + ? (port, null) + : (port, $"The instance manager did not answer on port {port} within {StartWait.TotalSeconds:0} s. " + + "Its log is under Logs/Manager in the data directory."); + } + + public static bool Answers(int port) + { + try + { + using var client = new TcpClient(); + return client.ConnectAsync("127.0.0.1", port).Wait(TimeSpan.FromSeconds(1)) && client.Connected; + } + catch (Exception) + { + return false; + } + } + + /// + /// The port from manager.json; the default on any doubt. Read as one field, not as the + /// manager's settings type, which the callers do not know. + /// + public static int Port(string configDirectory) + { + try + { + var file = Path.Combine(configDirectory, "manager.json"); + if (!File.Exists(file)) return DefaultPort; + + using var doc = JsonDocument.Parse(File.ReadAllText(file)); + return doc.RootElement.TryGetProperty("webUiPort", out var port) + && port.ValueKind == JsonValueKind.Number + && port.TryGetInt32(out var value) + && value is > 0 and < 65536 + ? value + : DefaultPort; + } + catch (Exception) + { + return DefaultPort; + } + } + + /// Why the manager could not be started, or null. + private static string? Start(string searchFrom) + { + if (OperatingSystem.IsLinux() && Systemctl("cat", ServiceUnit) == 0) + { + return Systemctl("start", ServiceUnit) == 0 + ? null + : $"systemctl --user start {ServiceUnit} failed; see journalctl --user -u {ServiceUnit}."; + } + + if (FindExecutable(searchFrom) is not { } manager) + return "The instance manager (ClawdDotNet.Instanzmanager) was not found next to this program."; + + try + { + // On Windows with its own console window: that is where the manager shows the access + // token on first start. Elsewhere a plain child that outlives the caller. + Process.Start(new ProcessStartInfo(manager) + { + UseShellExecute = OperatingSystem.IsWindows(), + WorkingDirectory = Path.GetDirectoryName(manager)! + }); + return null; + } + catch (Exception ex) + { + return $"The instance manager could not be started: {ex.Message}"; + } + } + + private static int Systemctl(string verb, string unit) + { + try + { + var start = new ProcessStartInfo("systemctl") { RedirectStandardOutput = true, RedirectStandardError = true }; + start.ArgumentList.Add("--user"); + start.ArgumentList.Add(verb); + start.ArgumentList.Add(unit); + using var p = Process.Start(start)!; + p.StandardOutput.ReadToEnd(); + p.StandardError.ReadToEnd(); + p.WaitForExit(30_000); + return p.HasExited ? p.ExitCode : -1; + } + catch (Exception) + { + return -1; // no systemd here + } + } + + /// + /// Next to the caller, one level up (the shell lives in shell/), or in the build folders + /// of a development checkout. + /// + /// Built from segments, not as a string: a path with backslashes is one file name on + /// Linux, and the search never found anything there (moved here from the old + /// App.axaml.cs). + /// + public static string? FindExecutable(string searchFrom) + { + var name = OperatingSystem.IsWindows() ? "ClawdDotNet.Instanzmanager.exe" : "ClawdDotNet.Instanzmanager"; + + string[][] candidates = + [ + [], + [".."], + ["..", "..", "..", "..", "ClawdDotNet.ManagerApp", "bin", "Debug", "net10.0"], + ["..", "..", "..", "..", "..", "src", "ClawdDotNet.ManagerApp", "bin", "Debug", "net10.0"] + ]; + + foreach (var segments in candidates) + { + var path = Path.GetFullPath(Path.Combine([searchFrom, .. segments, name])); + if (File.Exists(path)) return path; + } + + return null; + } +} diff --git a/src/ClawdDotNet.Desktop/LauncherRun.cs b/src/ClawdDotNet.Desktop/LauncherRun.cs index 3b35a4b..853fc38 100644 --- a/src/ClawdDotNet.Desktop/LauncherRun.cs +++ b/src/ClawdDotNet.Desktop/LauncherRun.cs @@ -1,6 +1,5 @@ using System.Diagnostics; -using System.Net.Sockets; -using System.Text.Json; +using ClawdDotNet.Core.Instances; using ClawdDotNet.Core.Storage; namespace ClawdDotNet.Desktop; @@ -11,47 +10,29 @@ namespace ClawdDotNet.Desktop; /// /// Until #764 that opened the Avalonia window. The windows are gone; the web interface is /// the only one. What a person who starts the program wants is therefore: the instance manager -/// running, and its web interface in front of them. Until the shell with its own window exists -/// (#764, stage 2) the system browser shows it. +/// running, and its web interface in front of them — in the shell's window +/// (shell/ClawdDotNet.Shell) where the package brings it, otherwise in the system +/// browser. /// -/// Never a second manager. If the manager answers, it is left alone. If it runs as -/// a systemd service on this machine, the service is started — starting the program next to it -/// would put two managers on the same instances. Only otherwise is the manager program started -/// directly. +/// The shell is a program of its own, in its own folder: it carries Avalonia 11.3 and +/// Chromium, and the instance process — started once per instance by the manager — should load +/// neither. /// internal static class LauncherRun { private const int ExitOk = 0; private const int ExitNoManager = 6; - private const int DefaultPort = 7000; - private const string ServiceUnit = "clawddotnet-manager.service"; - private static readonly TimeSpan StartWait = TimeSpan.FromSeconds(30); - public static async Task RunAsync() { - var port = ManagerPort(); + if (StartShell()) + return ExitOk; - if (!Answers(port)) + var (port, problem) = await ManagerStarter.EnsureRunningAsync(AppContext.BaseDirectory); + if (problem is not null) { - var problem = StartManager(); - if (problem is not null) - { - await Console.Error.WriteLineAsync(problem); - return ExitNoManager; - } - - var until = DateTime.UtcNow + StartWait; - while (!Answers(port) && DateTime.UtcNow < until) - await Task.Delay(500); - - if (!Answers(port)) - { - await Console.Error.WriteLineAsync( - $"The instance manager did not answer on port {port} within {StartWait.TotalSeconds:0} s. " - + "Its log is under Logs/Manager in the data directory."); - return ExitNoManager; - } + await Console.Error.WriteLineAsync(problem); + return ExitNoManager; } var url = $"http://localhost:{port}/"; @@ -60,121 +41,23 @@ internal static class LauncherRun return ExitOk; } - /// Why the manager could not be started, or null. - private static string? StartManager() + /// The shell starts the manager itself and signs its window in. + private static bool StartShell() { - if (OperatingSystem.IsLinux() && Systemctl("cat", ServiceUnit) == 0) - { - return Systemctl("start", ServiceUnit) == 0 - ? null - : $"systemctl --user start {ServiceUnit} failed; see journalctl --user -u {ServiceUnit}."; - } - - if (FindManagerExecutable() is not { } manager) - { - return "The instance manager (ClawdDotNet.Instanzmanager) was not found next to this program."; - } + var name = OperatingSystem.IsWindows() ? "ClawdDotNet.Shell.exe" : "ClawdDotNet.Shell"; + var shell = Path.Combine(AppContext.BaseDirectory, "shell", name); + if (!File.Exists(shell)) + return false; try { - // On Windows with its own console window: that is where the manager shows the access - // token on first start. Elsewhere a plain child that outlives us. - Process.Start(new ProcessStartInfo(manager) - { - UseShellExecute = OperatingSystem.IsWindows(), - WorkingDirectory = Path.GetDirectoryName(manager)! - }); - return null; + Process.Start(new ProcessStartInfo(shell) { UseShellExecute = false, WorkingDirectory = Path.GetDirectoryName(shell)! }); + return true; } catch (Exception ex) { - return $"The instance manager could not be started: {ex.Message}"; - } - } - - private static bool Answers(int port) - { - try - { - using var client = new TcpClient(); - return client.ConnectAsync("127.0.0.1", port).Wait(TimeSpan.FromSeconds(1)) && client.Connected; - } - catch (Exception) - { + Console.Error.WriteLine($"The shell could not be started ({ex.Message}); opening the browser instead."); return false; } } - - private static int Systemctl(string verb, string unit) - { - try - { - var start = new ProcessStartInfo("systemctl") { RedirectStandardOutput = true, RedirectStandardError = true }; - start.ArgumentList.Add("--user"); - start.ArgumentList.Add(verb); - start.ArgumentList.Add(unit); - using var p = Process.Start(start)!; - p.StandardOutput.ReadToEnd(); - p.StandardError.ReadToEnd(); - p.WaitForExit(30_000); - return p.HasExited ? p.ExitCode : -1; - } - catch (Exception) - { - return -1; // no systemd here - } - } - - /// - /// Next to this program in an installation; the build folders in a development checkout. - /// - /// Built from segments, not as a string: a path with backslashes is one file name on - /// Linux, and the search never found anything there (moved here from App.axaml.cs). - /// - private static string? FindManagerExecutable() - { - var name = OperatingSystem.IsWindows() ? "ClawdDotNet.Instanzmanager.exe" : "ClawdDotNet.Instanzmanager"; - - var nextTo = Path.Combine(AppContext.BaseDirectory, name); - if (File.Exists(nextTo)) return nextTo; - - string[][] developmentPaths = - [ - ["..", "..", "..", "..", "ClawdDotNet.ManagerApp", "bin", "Debug", "net10.0"], - ["..", "..", "..", "..", "..", "src", "ClawdDotNet.ManagerApp", "bin", "Debug", "net10.0"] - ]; - - foreach (var segments in developmentPaths) - { - var path = Path.GetFullPath(Path.Combine([AppContext.BaseDirectory, .. segments, name])); - if (File.Exists(path)) return path; - } - - return null; - } - - /// - /// The port from manager.json; the default on any doubt. Read as one field, not as - /// the manager's settings type, which this program does not know. - /// - private static int ManagerPort() - { - try - { - var file = Path.Combine(AppPaths.ConfigDirectory, "manager.json"); - if (!File.Exists(file)) return DefaultPort; - - using var doc = JsonDocument.Parse(File.ReadAllText(file)); - return doc.RootElement.TryGetProperty("webUiPort", out var port) - && port.ValueKind == JsonValueKind.Number - && port.TryGetInt32(out var value) - && value is > 0 and < 65536 - ? value - : DefaultPort; - } - catch (Exception) - { - return DefaultPort; - } - } } diff --git a/src/ClawdDotNet.Manager.Web/AccessToken.cs b/src/ClawdDotNet.Manager.Web/AccessToken.cs index dd7729b..257dd36 100644 --- a/src/ClawdDotNet.Manager.Web/AccessToken.cs +++ b/src/ClawdDotNet.Manager.Web/AccessToken.cs @@ -35,7 +35,13 @@ public sealed class AccessToken(byte[] expected) /// Ob dieser Pfad ein Token braucht: alles unter /api und /events. public static bool NeedsToken(PathString path) - => path.StartsWithSegments("/api") || path.StartsWithSegments("/events"); + => (path.StartsWithSegments("/api") || path.StartsWithSegments("/events")) + // #764: Exchanging a one-time code is the login itself - like the page that asks + // for the token, it cannot require one. The code is the proof (ShellLogin). + && !path.Equals(LoginExchangePath, StringComparison.OrdinalIgnoreCase); + + /// #764: Where the WebUI trades a one-time code from the shell for the access token. + public const string LoginExchangePath = "/api/login/exchange"; /// /// Holt das Token aus der Anfrage. diff --git a/src/ClawdDotNet.Manager.Web/Endpoints.cs b/src/ClawdDotNet.Manager.Web/Endpoints.cs index 7cbdfa3..c3f6f74 100644 --- a/src/ClawdDotNet.Manager.Web/Endpoints.cs +++ b/src/ClawdDotNet.Manager.Web/Endpoints.cs @@ -74,6 +74,18 @@ public sealed record FtpSettingsUpdateDto( bool Enabled, string Host, int Port, string Username, string? Password, string RemoteDirectory, bool RequireTls, int KeepCount); +/// #764: A one-time sign-in code for the shell, and the address that uses it. +public sealed record ShellLoginCodeDto(string Code, string Url); + +/// #764: The code the WebUI found in its address. +public sealed record ShellLoginExchangeDto(string? Code); + +/// #764: The access token, in return for a valid code. +public sealed record ShellLoginTokenDto(string Token); + +/// #764: What the shell shows next to its tray icon. +public sealed record ShellStatusDto(int Instances, int Running, int Troubled, bool LicenseHolds, int WebUiPort); + /// #764: The license as the web interface sees it — never the key, only whether one is set. public sealed record LicenseStatusDto(bool Enforced, bool Holds, bool KeySet, string? HardwareId); @@ -108,6 +120,39 @@ internal static class ManagerEndpoints /// private const long MaxImportBytes = 4L * 1024 * 1024 * 1024; + // ─── The shell (#764) ─── + + /// + /// What the shell asks over the local socket, and the one endpoint over TCP that turns its + /// one-time code into a signed-in browser. The socket-only rule is enforced before routing + /// (ManagerWebHost.CheckToken): /api/shell answers only there, everything else + /// never there. + /// + public static void MapShell(this WebApplication app) + { + app.MapPost("/api/shell/login-code", (ShellLogin login, ManagerCore manager) => + { + var code = login.Issue(); + // In the fragment, not the query: the fragment never reaches a server or its log. + return Results.Ok(new ShellLoginCodeDto(code, $"http://localhost:{manager.Settings.WebUiPort}/#code={code}")); + }); + + app.MapGet("/api/shell/status", (InstanceRegistry registry, ManagerCore manager) => + Results.Ok(new ShellStatusDto( + registry.Instances.Count, + registry.Instances.Count(i => i.Condition == InstanceCondition.Running), + registry.Instances.Count(i => i.Condition is InstanceCondition.Crashed or InstanceCondition.Silent), + manager.LicenseHolds, + manager.Settings.WebUiPort))); + + app.MapPost(AccessToken.LoginExchangePath, (ShellLoginExchangeDto input, ShellLogin login, ManagerCore manager) => + login.Redeem(input.Code) + ? Results.Ok(new ShellLoginTokenDto(manager.Settings.WebUiToken)) + // Same answer for unknown, used and expired: telling them apart helps only a guesser. + : Results.Json(new OutcomeDto(false, "The sign-in link is no longer valid. Open the window again."), + statusCode: StatusCodes.Status401Unauthorized)); + } + // ─── Was den Manager selbst angeht ─── public static void MapVerwaltung(this WebApplication app) diff --git a/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs b/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs index 1910c67..1965ec7 100644 --- a/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs +++ b/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs @@ -85,6 +85,7 @@ public sealed class ManagerWebHost : IAsyncDisposable var app = Build(manager, backups, updates, loggerFactory, url, token, forwarding); app.StartAsync().GetAwaiter().GetResult(); + ClawdDotNet.Core.Instances.ManagerEndpoint.Restrict(Path.GetDirectoryName(manager.SettingsPath)!); var anzeige = loopback ? url : url.Replace("0.0.0.0", Environment.MachineName); @@ -126,7 +127,28 @@ public sealed class ManagerWebHost : IAsyncDisposable builder.Logging.AddFilter("Microsoft", Microsoft.Extensions.Logging.LogLevel.Warning); builder.Logging.AddFilter("System", Microsoft.Extensions.Logging.LogLevel.Warning); - builder.WebHost.UseUrls(url); + // #764: The web port as before, and next to it the local socket for the shell. Both + // through ConfigureKestrel - UseUrls next to explicit listeners would be overridden. + var configDirectory = Path.GetDirectoryName(manager.SettingsPath)!; + var bindAddress = url.Contains("0.0.0.0") ? System.Net.IPAddress.Any : System.Net.IPAddress.Loopback; + + // CurrentUserOnly is the default and is written anyway: on Windows it is the whole + // access restriction of the pipe - the counterpart of 0600 on Linux. + if (OperatingSystem.IsWindows()) + builder.WebHost.UseNamedPipes(pipes => pipes.CurrentUserOnly = true); + + ClawdDotNet.Core.Instances.ManagerEndpoint.ClearStale(configDirectory); + builder.WebHost.ConfigureKestrel(kestrel => + { + kestrel.Listen(bindAddress, manager.Settings.WebUiPort); + + if (OperatingSystem.IsWindows()) + kestrel.ListenNamedPipe(ClawdDotNet.Core.Instances.ManagerEndpoint.PipeName(configDirectory), + listen => listen.Use(ShellTransport.Mark)); + else + kestrel.ListenUnixSocket(ClawdDotNet.Core.Instances.ManagerEndpoint.SocketPath(configDirectory), + listen => listen.Use(ShellTransport.Mark)); + }); builder.Services.AddSingleton(manager); builder.Services.AddSingleton(manager.Instances); @@ -135,6 +157,7 @@ public sealed class ManagerWebHost : IAsyncDisposable builder.Services.AddSingleton(updates ?? new ManagerUpdates(manager, "0.0.0", loggerFactory.CreateLogger("ClawdDotNet.Manager.Update"))); builder.Services.AddSingleton(AccessToken.For(token)); builder.Services.AddSingleton(forwarding); + builder.Services.AddSingleton(new ShellLogin()); var app = builder.Build(); @@ -143,6 +166,7 @@ public sealed class ManagerWebHost : IAsyncDisposable MapStaticFiles(app); + app.MapShell(); app.MapVerwaltung(); app.MapEvents(); app.MapForwarding(); @@ -181,7 +205,17 @@ public sealed class ManagerWebHost : IAsyncDisposable private static async Task CheckToken(HttpContext context, RequestDelegate next) { - if (!AccessToken.NeedsToken(context.Request.Path)) + // #764: The local socket serves the shell and nothing else; the shell endpoints do not + // exist over TCP. 404 in both directions: what does not exist needs no explanation. + var fromShell = ShellTransport.Applies(context); + var shellPath = context.Request.Path.StartsWithSegments(ShellTransport.PathPrefix); + if (fromShell != shellPath) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + if (fromShell || !AccessToken.NeedsToken(context.Request.Path)) { await next(context); return; diff --git a/src/ClawdDotNet.Manager.Web/ShellLogin.cs b/src/ClawdDotNet.Manager.Web/ShellLogin.cs new file mode 100644 index 0000000..6d1bf57 --- /dev/null +++ b/src/ClawdDotNet.Manager.Web/ShellLogin.cs @@ -0,0 +1,88 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Http; + +namespace ClawdDotNet.Manager.Web; + +/// +/// #764: One-time codes that sign the shell's browser window in. +/// +/// Why not hand the shell the access token. The shell would have to put it into +/// the address it opens, and an address ends up in histories, logs and window titles. A code +/// that works once and for at most a minute is worth nothing afterwards. The token itself only +/// travels in the answer to the exchange, the same way it travels when someone types it in. +/// +/// Who gets a code: only a request over the manager's local socket +/// (), which the file system restricts to the user running the +/// manager. Over TCP the endpoint does not exist. +/// +public sealed class ShellLogin(TimeProvider? clock = null) +{ + public static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(60); + + private readonly TimeProvider _clock = clock ?? TimeProvider.System; + + /// Codes by their hash — the code itself is not kept, so a memory dump does not hold usable ones. + private readonly ConcurrentDictionary _open = new(); + + public string Issue() + { + Sweep(); + var code = Base64Url(RandomNumberGenerator.GetBytes(32)); + _open[Hash(code)] = _clock.GetUtcNow() + Lifetime; + return code; + } + + /// + /// True once for a code that was issued and has not expired. The code is gone afterwards + /// either way: a second use, or a use after expiry, fails. + /// + public bool Redeem(string? code) + { + if (string.IsNullOrWhiteSpace(code) || code.Length > 100) + return false; + + return _open.TryRemove(Hash(code.Trim()), out var expires) && _clock.GetUtcNow() <= expires; + } + + private void Sweep() + { + var now = _clock.GetUtcNow(); + foreach (var (key, expires) in _open) + { + if (expires < now) _open.TryRemove(key, out _); + } + } + + private static string Hash(string code) + => Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(code))); + + private static string Base64Url(byte[] bytes) + => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} + +/// +/// #764: Marks connections that came in over the manager's local socket — the same pattern as +/// the instance's LocalTransport: a mark set by the listener, not a guess from a missing +/// IP address. +/// +public sealed class ShellTransport +{ + private static readonly ShellTransport Instance = new(); + + private ShellTransport() { } + + /// The only paths the socket serves. Everything else stays behind the token. + public const string PathPrefix = "/api/shell"; + + public static Func Mark + => next => connection => + { + connection.Features.Set(Instance); + return next(connection); + }; + + public static bool Applies(HttpContext context) + => context.Features.Get() is not null; +} diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.js b/src/ClawdDotNet.Manager.Web/wwwroot/app.js index b351cc8..624b35c 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/app.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.js @@ -5,6 +5,7 @@ import { api, hatToken, + setzeToken, loescheToken, beiUnberechtigt, holeAktiveInstanz, @@ -405,16 +406,41 @@ async function starteHauptAnwendung(instanzen) { initialisiereRouter(); } -// Prüfe bestehende Session -if (hatToken()) { - api("/verwaltung/instanzen") - .then((instanzen) => { - starteHauptAnwendung(instanzen); - }) - .catch(() => { - loescheToken(); - zeigeAnmeldung(); +// #764: The shell opens this page with a one-time code in the fragment (#code=…). It is +// traded for the access token once and removed from the address right away, so it shows up in +// no history entry. A failed exchange falls through to the normal sign-in. +async function redeemShellCode() { + const match = /^#code=([A-Za-z0-9_-]{20,100})$/.exec(window.location.hash); + if (!match) return false; + history.replaceState(null, "", window.location.pathname + window.location.search); + try { + const answer = await fetch("/api/login/exchange", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: match[1] }) }); -} else { - zeigeAnmeldung(); + if (!answer.ok) return false; + const { token } = await answer.json(); + if (!token) return false; + setzeToken(token); + return true; + } catch { + return false; + } } + +// Prüfe bestehende Session +redeemShellCode().then(() => { + if (hatToken()) { + api("/verwaltung/instanzen") + .then((instanzen) => { + starteHauptAnwendung(instanzen); + }) + .catch(() => { + loescheToken(); + zeigeAnmeldung(); + }); + } else { + zeigeAnmeldung(); + } +}); diff --git a/src/ClawdDotNet.Shell/Assets/app.ico b/src/ClawdDotNet.Shell/Assets/app.ico new file mode 100644 index 0000000..0dd1605 Binary files /dev/null and b/src/ClawdDotNet.Shell/Assets/app.ico differ diff --git a/src/ClawdDotNet.Shell/ClawdDotNet.Shell.csproj b/src/ClawdDotNet.Shell/ClawdDotNet.Shell.csproj new file mode 100644 index 0000000..9a54f41 --- /dev/null +++ b/src/ClawdDotNet.Shell/ClawdDotNet.Shell.csproj @@ -0,0 +1,50 @@ + + + + + WinExe + + Exe + net10.0 + enable + enable + ClawdDotNet.Shell + ClawdDotNet.Shell + Assets\app.ico + + app.manifest + + false + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ClawdDotNet.Shell/Program.cs b/src/ClawdDotNet.Shell/Program.cs new file mode 100644 index 0000000..24458e7 --- /dev/null +++ b/src/ClawdDotNet.Shell/Program.cs @@ -0,0 +1,124 @@ +using System.Net.Http.Json; +using Avalonia; +using ClawdDotNet.Core.Instances; +using ClawdDotNet.Core.Storage; +using Xilium.CefGlue; +using Xilium.CefGlue.BrowserProcess; +using Xilium.CefGlue.Common; + +namespace ClawdDotNet.Shell; + +/// +/// #764, stage 2: the shell. Starts the instance manager if it is not running, asks it for a +/// one-time sign-in link over its local socket and shows the web interface in its own window, +/// signed in. The manager keeps running when the window closes — it supervises the instances, +/// the window only shows them. +/// +internal static class Program +{ + private const int ExitOk = 0; + private const int ExitNoManager = 6; + + /// Where the window points; set before Avalonia starts. + internal static string StartUrl { get; private set; } = ""; + + internal static int ManagerPort { get; private set; } + + /// + /// --selftest: load the page, report whether the sign-in worked and the code left the + /// address, then end. For checking a package on a machine where nobody looks at the screen. + /// + internal static bool SelfTest { get; private set; } + + public static int Main(string[] args) + { + // Chromium starts its helper processes from this same executable; they end here. + CefSubProcess.Run(args, true); + + SelfTest = args.Contains("--selftest"); + + // One shell per user is enough: a second one would only open a second window. + using var single = new Mutex(true, "ClawdDotNet.Shell." + Environment.UserName, out var first); + if (!first) + return ExitOk; + + var (port, problem) = ManagerStarter.EnsureRunningAsync(AppContext.BaseDirectory).GetAwaiter().GetResult(); + if (problem is not null) + { + Console.Error.WriteLine(problem); + return ExitNoManager; + } + + ManagerPort = port; + StartUrl = SignInUrl(port); + + // Without a display (a server, an SSH session) there is no window to show — the system + // browser is the next best thing, and on a server usually not there either. Say where to go. + if (OperatingSystem.IsLinux() + && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY")) + && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"))) + { + Console.WriteLine($"No display. The web interface: http://localhost:{port}/"); + return ExitOk; + } + + var cache = Path.Combine(AppPaths.DataDirectory, "shell-cache"); + var resources = ResourcesDirectory(); + + AppDomain.CurrentDomain.ProcessExit += (_, _) => CefRuntime.Shutdown(); + + return AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .AfterSetup(_ => CefRuntimeLoader.Initialize( + new CefSettings + { + RootCachePath = cache, + ResourcesDirPath = resources, + LocalesDirPath = Path.Combine(resources, "locales"), + LogSeverity = CefLogSeverity.Warning, + LogFile = Path.Combine(AppPaths.DataDirectory, "Logs", "shell-cef.log") + }, + // Without no-first-run Chromium waits behind a terms-of-service dialog that + // nobody can see inside this window (found on Linux, 2026-10-02). + [new KeyValuePair("no-first-run", "1"), + new KeyValuePair("no-default-browser-check", "1")])) + .StartWithClassicDesktopLifetime(args); + } + + /// + /// A one-time link from the manager's local socket; without it the plain address, where the + /// sign-in page asks for the token as before. + /// + private static string SignInUrl(int port) + { + try + { + using var client = ManagerEndpoint.CreateClient(ManagerStarter.ConfigDirectory); + client.Timeout = TimeSpan.FromSeconds(5); + var answer = client.PostAsync("/api/shell/login-code", null).GetAwaiter().GetResult(); + if (answer.IsSuccessStatusCode + && answer.Content.ReadFromJsonAsync().GetAwaiter().GetResult() is { Url: { Length: > 0 } url }) + return url; + } + catch (Exception ex) when (ex is HttpRequestException or IOException or TaskCanceledException or TimeoutException) + { + Console.Error.WriteLine($"No one-time sign-in from the manager ({ex.Message}); the page will ask for the token."); + } + + return $"http://localhost:{port}/"; + } + + /// + /// Chromium's resources (icudtl.dat, *.pak, locales). The Linux runtime package puts them into + /// Resources/, Chromium looks next to libcef — the package step moves them; this covers a + /// development build, where they are still in Resources/. + /// + private static string ResourcesDirectory() + { + var nested = Path.Combine(AppContext.BaseDirectory, "Resources"); + return File.Exists(Path.Combine(nested, "icudtl.dat")) ? nested : AppContext.BaseDirectory; + } + + private sealed record LoginCode(string Code, string Url); +} diff --git a/src/ClawdDotNet.Shell/ShellApp.cs b/src/ClawdDotNet.Shell/ShellApp.cs new file mode 100644 index 0000000..e5098bc --- /dev/null +++ b/src/ClawdDotNet.Shell/ShellApp.cs @@ -0,0 +1,135 @@ +using System.Net.Http.Json; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform; +using Avalonia.Themes.Fluent; +using Avalonia.Threading; +using ClawdDotNet.Core.Instances; +using Xilium.CefGlue.Avalonia; + +namespace ClawdDotNet.Shell; + +/// +/// #764: The window and the tray icon. Everything shown inside the window is the web interface; +/// this class only hosts it and says, next to the clock, whether the instances run. +/// +internal sealed class ShellApp : Application +{ + private static readonly TimeSpan StatusInterval = TimeSpan.FromSeconds(15); + + private Window? _window; + private TrayIcon? _tray; + private DispatcherTimer? _timer; + + public override void Initialize() => Styles.Add(new FluentTheme()); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var icon = new WindowIcon(AssetLoader.Open(new Uri("avares://ClawdDotNet.Shell/Assets/app.ico"))); + + _window = new Window + { + Title = "ClawdDotNet", + Icon = icon, + Width = 1360, + Height = 880, + MinWidth = 420, + MinHeight = 480, + WindowStartupLocation = WindowStartupLocation.CenterScreen, + Content = Browser(desktop) + }; + desktop.MainWindow = _window; + + _tray = new TrayIcon + { + Icon = icon, + ToolTipText = "ClawdDotNet", + Menu = new NativeMenu + { + Items = + { + Item(Texts.Open, ShowWindow), + new NativeMenuItemSeparator(), + Item(Texts.Quit, () => desktop.Shutdown()) + } + } + }; + _tray.Clicked += (_, _) => ShowWindow(); + TrayIcon.SetIcons(this, [_tray]); + + _timer = new DispatcherTimer { Interval = StatusInterval }; + _timer.Tick += async (_, _) => await RefreshStatusAsync(); + _timer.Start(); + _ = RefreshStatusAsync(); + } + + base.OnFrameworkInitializationCompleted(); + } + + private static AvaloniaCefBrowser Browser(IClassicDesktopStyleApplicationLifetime desktop) + { + var browser = new AvaloniaCefBrowser { Address = Program.StartUrl }; + if (!Program.SelfTest) + return browser; + + browser.LoadEnd += (_, e) => + { + if (!e.Frame.IsMain) return; + _ = Task.Run(async () => + { + // The exchange runs after load; give it a moment. + await Task.Delay(3000); + var signedIn = await browser.EvaluateJavaScript("return !!sessionStorage.getItem('clawd.token')"); + var hash = await browser.EvaluateJavaScript("return location.hash"); + Console.WriteLine($"SELFTEST status={e.HttpStatusCode} signedIn={signedIn} codeInAddress={hash.Contains("code=")}"); + Dispatcher.UIThread.Post(() => desktop.Shutdown(signedIn && !hash.Contains("code=") ? 0 : 1)); + }); + }; + return browser; + } + + private static NativeMenuItem Item(string header, Action action) + { + var item = new NativeMenuItem(header); + item.Click += (_, _) => action(); + return item; + } + + private void ShowWindow() + { + if (_window is null) return; + _window.Show(); + if (_window.WindowState == WindowState.Minimized) + _window.WindowState = WindowState.Normal; + _window.Activate(); + } + + /// The manager's own view of its instances, over the local socket — no token needed there. + private async Task RefreshStatusAsync() + { + string text; + try + { + using var client = ManagerEndpoint.CreateClient(ManagerStarter.ConfigDirectory); + client.Timeout = TimeSpan.FromSeconds(3); + var status = await client.GetFromJsonAsync("/api/shell/status"); + text = status is null + ? Texts.ManagerUnknown + : !status.LicenseHolds ? Texts.NoLicense + : status.Troubled > 0 ? Texts.Troubled(status.Troubled, status.Instances) + : Texts.Running(status.Running, status.Instances); + } + catch (Exception) + { + text = Texts.ManagerDown; + } + + if (_tray is not null) + _tray.ToolTipText = $"ClawdDotNet — {text}"; + } + + private sealed record Status(int Instances, int Running, int Troubled, bool LicenseHolds, int WebUiPort); +} diff --git a/src/ClawdDotNet.Shell/Texts.cs b/src/ClawdDotNet.Shell/Texts.cs new file mode 100644 index 0000000..1b7790c --- /dev/null +++ b/src/ClawdDotNet.Shell/Texts.cs @@ -0,0 +1,26 @@ +using System.Globalization; + +namespace ClawdDotNet.Shell; + +/// +/// #764 / #661: The few texts of the shell itself — tray menu and tooltip. Everything else is the +/// web interface with its language packs. English first, German as the first additional language, +/// chosen by the system's UI language. +/// +internal static class Texts +{ + private static readonly bool German = + CultureInfo.CurrentUICulture.TwoLetterISOLanguageName.Equals("de", StringComparison.OrdinalIgnoreCase); + + public static string Open => German ? "ClawdDotNet öffnen" : "Open ClawdDotNet"; + public static string Quit => German ? "Fenster beenden (Instanzen laufen weiter)" : "Quit window (instances keep running)"; + public static string ManagerUnknown => German ? "Zustand unbekannt" : "state unknown"; + public static string ManagerDown => German ? "Instanzmanager antwortet nicht" : "instance manager not answering"; + public static string NoLicense => German ? "keine gültige Lizenz" : "no valid license"; + + public static string Running(int running, int total) + => German ? $"{running} von {total} Instanzen laufen" : $"{running} of {total} instances running"; + + public static string Troubled(int troubled, int total) + => German ? $"{troubled} von {total} Instanzen gestört" : $"{troubled} of {total} instances in trouble"; +} diff --git a/src/ClawdDotNet.Shell/app.manifest b/src/ClawdDotNet.Shell/app.manifest new file mode 100644 index 0000000..46acd94 --- /dev/null +++ b/src/ClawdDotNet.Shell/app.manifest @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/tests/ClawdDotNet.Manager.Tests/ClawdDotNet.Manager.Tests.csproj b/tests/ClawdDotNet.Manager.Tests/ClawdDotNet.Manager.Tests.csproj index 6247c45..433e9d0 100644 --- a/tests/ClawdDotNet.Manager.Tests/ClawdDotNet.Manager.Tests.csproj +++ b/tests/ClawdDotNet.Manager.Tests/ClawdDotNet.Manager.Tests.csproj @@ -19,6 +19,8 @@ + + diff --git a/tests/ClawdDotNet.Manager.Tests/ShellLoginTests.cs b/tests/ClawdDotNet.Manager.Tests/ShellLoginTests.cs new file mode 100644 index 0000000..a217ff7 --- /dev/null +++ b/tests/ClawdDotNet.Manager.Tests/ShellLoginTests.cs @@ -0,0 +1,169 @@ +using System.Net; +using System.Net.Http.Json; +using ClawdDotNet.Core.Instances; +using ClawdDotNet.Manager.Web; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Manager.Tests; + +/// +/// #764: The shell's one-time sign-in. A new way in — so the three ways it must fail are tested: +/// used twice, used too late, asked for over TCP instead of the local socket. +/// +public sealed class ShellLoginTests +{ + private sealed class Clock(DateTimeOffset start) : TimeProvider + { + public DateTimeOffset Now { get; set; } = start; + public override DateTimeOffset GetUtcNow() => Now; + } + + [Fact] + public void A_code_works_once() + { + var login = new ShellLogin(); + var code = login.Issue(); + + login.Redeem(code).ShouldBeTrue(); + login.Redeem(code).ShouldBeFalse(); + } + + [Fact] + public void A_code_expires_after_a_minute() + { + var clock = new Clock(DateTimeOffset.UnixEpoch); + var login = new ShellLogin(clock); + var code = login.Issue(); + + clock.Now += ShellLogin.Lifetime + TimeSpan.FromSeconds(1); + + login.Redeem(code).ShouldBeFalse(); + } + + [Fact] + public void A_made_up_code_fails() + { + var login = new ShellLogin(); + login.Issue(); + + login.Redeem("guess").ShouldBeFalse(); + login.Redeem("").ShouldBeFalse(); + login.Redeem(null).ShouldBeFalse(); + } + + [Fact] + public void Codes_are_not_predictable() + { + var login = new ShellLogin(); + + login.Issue().ShouldNotBe(login.Issue()); + login.Issue().Length.ShouldBeGreaterThanOrEqualTo(40); + } +} + +/// #764: The same promises against the running web host, over real TCP and the real socket. +public sealed class ShellLoginHostTests : IAsyncDisposable +{ + private const string Token = "a-sufficiently-long-test-token-for-the-webui"; + + private readonly string _root = Path.Combine( + Path.GetTempPath(), "clawd-tests", "shl-" + Guid.NewGuid().ToString("N")[..8]); + + public async ValueTask DisposeAsync() + { + await Task.CompletedTask; + try { Directory.Delete(_root, recursive: true); } + catch { /* A leftover test folder does not turn a run red. */ } + } + + private static int FreePort() + { + using var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + return port; + } + + private async Task<(ManagerWebHost Web, ManagerCore Manager, HttpClient Tcp, HttpClient Socket)> StartAsync() + { + Directory.CreateDirectory(_root); + var store = new ManagerSettingsStore(_root); + store.Settings.WebUiToken = Token; + store.Settings.WebUiPort = FreePort(); + store.Settings.InstancesDirectory = Path.Combine(_root, "Instances"); + + var registry = new InstanceRegistry( + new InstanceDirectoryManager(store.Settings.InstancesDirectory), new FakeLauncher(), new FakeChannel(), + NullLoggerFactory.Instance, SupervisionOptions.Default); + var manager = new ManagerCore(store, registry, new FakeTokenSource(), NullLoggerFactory.Instance); + await manager.StartAsync(); + var backups = new ManagerBackups(store, registry, null, NullLogger.Instance); + + var web = ManagerWebHost.TryStart(manager, backups, NullLoggerFactory.Instance); + web.ShouldNotBeNull(); + + var tcp = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{store.Settings.WebUiPort}") }; + var socket = ManagerEndpoint.CreateClient(_root); + return (web!, manager, tcp, socket); + } + + [Fact] + public async Task The_code_comes_only_over_the_socket() + { + var (web, manager, tcp, socket) = await StartAsync(); + await using var _ = web; + await using var __ = manager; + + // Over TCP: not there, even with the access token. + tcp.DefaultRequestHeaders.Add("Authorization", $"Bearer {Token}"); + (await tcp.PostAsync("/api/shell/login-code", null)).StatusCode.ShouldBe(HttpStatusCode.NotFound); + + // Over the socket: there, without a token. + var answer = await socket.PostAsync("/api/shell/login-code", null); + answer.StatusCode.ShouldBe(HttpStatusCode.OK); + } + + [Fact] + public async Task The_socket_leads_nowhere_but_the_shell_endpoints() + { + var (web, manager, _, socket) = await StartAsync(); + await using var a = web; + await using var b = manager; + + // No token needed on the socket - so nothing else may be reachable there. + (await socket.GetAsync("/api/verwaltung/instanzen")).StatusCode.ShouldBe(HttpStatusCode.NotFound); + (await socket.GetAsync("/api/verwaltung/license")).StatusCode.ShouldBe(HttpStatusCode.NotFound); + } + + [Fact] + public async Task A_code_signs_in_once() + { + var (web, manager, tcp, socket) = await StartAsync(); + await using var a = web; + await using var b = manager; + + var issued = await (await socket.PostAsync("/api/shell/login-code", null)).Content.ReadFromJsonAsync(); + issued!.Url.ShouldContain("#code="); // in the fragment, never the query + + var first = await tcp.PostAsJsonAsync(AccessToken.LoginExchangePath, new ShellLoginExchangeDto(issued.Code)); + first.StatusCode.ShouldBe(HttpStatusCode.OK); + (await first.Content.ReadFromJsonAsync())!.Token.ShouldBe(Token); + + var second = await tcp.PostAsJsonAsync(AccessToken.LoginExchangePath, new ShellLoginExchangeDto(issued.Code)); + second.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Without_a_code_or_token_nothing_is_open() + { + var (web, manager, tcp, _) = await StartAsync(); + await using var a = web; + await using var b = manager; + + (await tcp.PostAsJsonAsync(AccessToken.LoginExchangePath, new ShellLoginExchangeDto("made-up"))).StatusCode + .ShouldBe(HttpStatusCode.Unauthorized); + (await tcp.GetAsync("/api/verwaltung/instanzen")).StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } +}