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.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/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);
+ }
+}