diff --git a/src/OpenClaw.Connection/DashboardBrowserHandoff.cs b/src/OpenClaw.Connection/DashboardBrowserHandoff.cs
new file mode 100644
index 000000000..6ab63b81a
--- /dev/null
+++ b/src/OpenClaw.Connection/DashboardBrowserHandoff.cs
@@ -0,0 +1,77 @@
+namespace OpenClaw.Connection;
+
+///
+/// Gateway and SSH forward captured together for one dashboard launch.
+///
+public sealed record DashboardGatewayTunnelSnapshot(
+ string GatewayIdentity,
+ string GatewayUrl,
+ string Token,
+ bool AppendToken,
+ SshTunnelConfig? Tunnel,
+ bool IsBootstrapToken = false,
+ string Source = "");
+
+///
+/// Binds a dashboard URL to the gateway and SSH destination checked before launch.
+///
+public static class DashboardBrowserHandoff
+{
+ public static bool SameBinding(
+ DashboardGatewayTunnelSnapshot before,
+ DashboardGatewayTunnelSnapshot after)
+ {
+ if (!string.Equals(before.GatewayIdentity, after.GatewayIdentity, StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ return SameTunnelDestination(before.Tunnel, after.Tunnel);
+ }
+
+ public static bool SameTunnelDestination(SshTunnelConfig? left, SshTunnelConfig? right)
+ {
+ if (left is null || right is null)
+ return left is null && right is null;
+
+ return string.Equals(left.User.Trim(), right.User.Trim(), StringComparison.Ordinal) &&
+ string.Equals(left.Host.Trim(), right.Host.Trim(), StringComparison.OrdinalIgnoreCase) &&
+ left.RemotePort == right.RemotePort &&
+ left.LocalPort == right.LocalPort &&
+ left.SshPort == right.SshPort &&
+ left.IncludeBrowserProxyForward == right.IncludeBrowserProxyForward;
+ }
+
+ public static string ProjectOntoLocalForward(string savedGatewayUrl, int localPort)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(savedGatewayUrl);
+ var normalized = savedGatewayUrl
+ .Replace("ws://", "http://", StringComparison.OrdinalIgnoreCase)
+ .Replace("wss://", "https://", StringComparison.OrdinalIgnoreCase);
+ var endpoint = new Uri(normalized, UriKind.Absolute);
+ var builder = new UriBuilder(endpoint)
+ {
+ Scheme = endpoint.Scheme is "https" ? "https" : "http",
+ Host = "127.0.0.1",
+ Port = localPort,
+ };
+ return builder.Uri.AbsoluteUri;
+ }
+
+ public static bool UrlUsesCapturedForward(string url, DashboardGatewayTunnelSnapshot captured)
+ {
+ if (captured.Tunnel is not SshTunnelConfig tunnel)
+ return false;
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
+ return false;
+
+ var loopback = uri.Host is "127.0.0.1" or "localhost" or "::1";
+ if (!loopback || uri.Port != tunnel.LocalPort)
+ return false;
+ if (!captured.AppendToken)
+ return true;
+ if (string.IsNullOrEmpty(captured.Token))
+ return false;
+
+ var marker = "token=" + Uri.EscapeDataString(captured.Token);
+ return uri.Fragment.Contains(marker, StringComparison.Ordinal);
+ }
+}
diff --git a/src/OpenClaw.Connection/DashboardForwardPortGuard.cs b/src/OpenClaw.Connection/DashboardForwardPortGuard.cs
new file mode 100644
index 000000000..28e259438
--- /dev/null
+++ b/src/OpenClaw.Connection/DashboardForwardPortGuard.cs
@@ -0,0 +1,269 @@
+using System.Diagnostics;
+using System.Net;
+using System.Net.Sockets;
+
+namespace OpenClaw.Connection;
+
+///
+/// Owns a loopback forward port after SSH teardown so a different process
+/// cannot accept a dashboard URL that was already handed to the browser.
+/// The listener stays up while a replacement SSH process binds a backend port.
+///
+internal static class DashboardForwardPortGuard
+{
+ private sealed class Slot
+ {
+ public required TcpListener Listener { get; init; }
+ public TcpListener? V6Listener { get; init; }
+ public required CancellationTokenSource Cancel { get; init; }
+ public int? BackendPort { get; set; }
+ public int? BackendProcessId { get; set; }
+ public string? User { get; set; }
+ public string? Host { get; set; }
+ public int? RemotePort { get; set; }
+ public int? SshPort { get; set; }
+ public DateTime? BackendStartTimeUtc { get; set; }
+ }
+
+ private static readonly object Gate = new();
+ private static readonly Dictionary Listeners = new();
+
+ internal static void Hold(int port)
+ {
+ if (port is < 1 or > 65535)
+ throw new ArgumentOutOfRangeException(nameof(port));
+
+ lock (Gate)
+ {
+ if (Listeners.ContainsKey(port))
+ return;
+
+ var listener = new TcpListener(IPAddress.Loopback, port);
+ TcpListener? v6 = null;
+ try
+ {
+ listener.Start();
+ if (Socket.OSSupportsIPv6)
+ {
+ v6 = new TcpListener(IPAddress.IPv6Loopback, port);
+ v6.Server.DualMode = false;
+ v6.Start();
+ }
+ }
+ catch
+ {
+ try { listener.Stop(); } catch (SocketException) { }
+ try { v6?.Stop(); } catch (SocketException) { }
+ throw;
+ }
+
+ var cancel = new CancellationTokenSource();
+ var slot = new Slot { Listener = listener, V6Listener = v6, Cancel = cancel };
+ Listeners[port] = slot;
+ _ = AcceptAsync(port, slot, listener);
+ if (v6 is not null)
+ _ = AcceptAsync(port, slot, v6);
+ }
+ }
+
+ internal static void ClearBackend(int port)
+ {
+ lock (Gate)
+ {
+ if (!Listeners.TryGetValue(port, out var slot))
+ return;
+ slot.BackendPort = null;
+ slot.BackendProcessId = null;
+ slot.BackendStartTimeUtc = null;
+ }
+ }
+
+ internal static bool AllowsDestination(int port, string user, string host, int remotePort, int sshPort)
+ {
+ lock (Gate)
+ {
+ if (!Listeners.TryGetValue(port, out var slot))
+ return true;
+ if (slot.User is null || slot.Host is null || slot.RemotePort is null || slot.SshPort is null)
+ {
+ slot.User = user;
+ slot.Host = host;
+ slot.RemotePort = remotePort;
+ slot.SshPort = sshPort;
+ return true;
+ }
+
+ return string.Equals(slot.User, user, StringComparison.Ordinal) &&
+ string.Equals(slot.Host, host, StringComparison.Ordinal) &&
+ slot.RemotePort == remotePort &&
+ slot.SshPort == sshPort;
+ }
+ }
+
+ internal static void SetBackend(int port, int backendPort, int backendProcessId, DateTime backendStartTimeUtc)
+ {
+ lock (Gate)
+ {
+ if (Listeners.TryGetValue(port, out var slot))
+ {
+ slot.BackendPort = backendPort;
+ slot.BackendProcessId = backendProcessId;
+ slot.BackendStartTimeUtc = backendStartTimeUtc;
+ }
+ }
+ }
+
+ internal static void Release(int port)
+ {
+ Slot? slot;
+ lock (Gate)
+ {
+ if (!Listeners.Remove(port, out slot))
+ return;
+ }
+
+ slot.Cancel.Cancel();
+ try
+ {
+ slot.Listener.Stop();
+ }
+ catch (SocketException)
+ {
+ }
+
+ try
+ {
+ slot.V6Listener?.Stop();
+ }
+ catch (SocketException)
+ {
+ }
+ }
+
+ internal static bool IsHolding(int port)
+ {
+ lock (Gate)
+ return Listeners.ContainsKey(port);
+ }
+
+ internal static int? BackendPort(int port)
+ {
+ lock (Gate)
+ return Listeners.TryGetValue(port, out var slot) ? slot.BackendPort : null;
+ }
+
+ private static async Task AcceptAsync(int port, Slot slot, TcpListener listener)
+ {
+ try
+ {
+ while (!slot.Cancel.IsCancellationRequested)
+ {
+ var client = await listener.AcceptTcpClientAsync(slot.Cancel.Token).ConfigureAwait(false);
+ _ = Pump(port, client);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (SocketException)
+ {
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+ }
+
+ private static async Task Pump(int port, TcpClient client)
+ {
+ using (client)
+ {
+ int? backendPort;
+ int? backendProcessId;
+ lock (Gate)
+ {
+ if (!Listeners.TryGetValue(port, out var slot))
+ return;
+ backendPort = slot.BackendPort;
+ backendProcessId = slot.BackendProcessId;
+ }
+
+ DateTime? backendStart;
+ lock (Gate)
+ {
+ backendStart = Listeners.TryGetValue(port, out var remembered) ? remembered.BackendStartTimeUtc : null;
+ }
+
+ if (backendPort is not int target || backendProcessId is not int processId || processId <= 0)
+ return;
+ if (!ProcessStillStartedAt(processId, backendStart))
+ return;
+
+ try
+ {
+ using var backend = new TcpClient();
+ backend.Connect(IPAddress.Loopback, target);
+ if (backend.Client.LocalEndPoint is not IPEndPoint clientEnd ||
+ backend.Client.RemoteEndPoint is not IPEndPoint serverEnd ||
+ serverEnd.Port != target ||
+ !IPAddress.IsLoopback(serverEnd.Address) ||
+ !WindowsTcpListenerSnapshot.TryConfirmAcceptedLoopbackOwner(
+ serverEnd.Port,
+ clientEnd.Port,
+ processId))
+ {
+ return;
+ }
+
+ var left = client.GetStream();
+ var right = backend.GetStream();
+ using var stop = new CancellationTokenSource();
+ var toRight = left.CopyToAsync(right, stop.Token);
+ var toLeft = right.CopyToAsync(left, stop.Token);
+ await Task.WhenAny(toRight, toLeft).ConfigureAwait(false);
+ stop.Cancel();
+ client.Close();
+ backend.Close();
+ try
+ {
+ await Task.WhenAll(toRight, toLeft).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (IOException)
+ {
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+ }
+ catch (IOException)
+ {
+ }
+ catch (SocketException)
+ {
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+ }
+ }
+
+ private static bool ProcessStillStartedAt(int processId, DateTime? startTimeUtc)
+ {
+ if (startTimeUtc is null)
+ return false;
+
+ try
+ {
+ using var process = Process.GetProcessById(processId);
+ var actual = process.StartTime.ToUniversalTime();
+ return Math.Abs((actual - startTimeUtc.Value).TotalSeconds) < 2;
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception)
+ {
+ return false;
+ }
+ }
+
+}
diff --git a/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs b/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs
index b2239b129..3a78227da 100644
--- a/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs
+++ b/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs
@@ -16,6 +16,7 @@ public static string Resolve(GatewayRecord record)
if (tunnel.LocalPort is < 1 or > 65535)
throw new InvalidOperationException("SSH tunnel local port must be between 1 and 65535.");
- return $"ws://localhost:{tunnel.LocalPort}";
+ var host = DashboardForwardPortGuard.IsHolding(tunnel.LocalPort) ? "127.0.0.1" : "localhost";
+ return $"ws://{host}:{tunnel.LocalPort}";
}
}
diff --git a/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs b/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs
index d69fc8f74..4d92f3b4e 100644
--- a/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs
+++ b/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs
@@ -84,7 +84,8 @@ public static bool TryResolve(
active.Url,
active.SharedGatewayToken!,
false,
- CredentialResolver.SourceSharedGatewayToken);
+ CredentialResolver.SourceSharedGatewayToken,
+ active.Id);
return true;
}
@@ -103,7 +104,8 @@ public static bool TryResolve(
active.Url,
resolved.Token,
resolved.IsBootstrapToken,
- resolved.Source);
+ resolved.Source,
+ active.Id);
return true;
}
@@ -148,7 +150,8 @@ public static bool TryResolve(
gatewayUrl,
legacyCredential.Token,
legacyCredential.IsBootstrapToken,
- legacyCredential.Source);
+ legacyCredential.Source,
+ legacyRecord.Id);
return true;
}
}
@@ -157,4 +160,5 @@ public sealed record InteractiveGatewayCredential(
string GatewayUrl,
string Token,
bool IsBootstrapToken,
- string Source);
+ string Source,
+ string? GatewayId = null);
diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs
index 14c054c14..be16d891a 100644
--- a/src/OpenClaw.Connection/SshTunnelService.cs
+++ b/src/OpenClaw.Connection/SshTunnelService.cs
@@ -1,10 +1,21 @@
using OpenClaw.Shared;
using System;
+using System.Collections.Generic;
using System.Diagnostics;
using System.Net;
+using System.Net.Sockets;
namespace OpenClaw.Connection;
+///
+/// SSH process that passed listener ownership for one dashboard launch.
+///
+public readonly record struct SettingsOwnedForwardBinding(
+ bool Owned,
+ long Generation,
+ int LocalPort,
+ int ProcessId);
+
///
/// Manages an SSH local port-forward process for gateway access.
///
@@ -19,6 +30,11 @@ public sealed class SshTunnelService : ISshTunnelManager
private SshTunnelOwner _currentOwner;
private string? _lastSpec;
private long _lifecycleGeneration;
+ private int _browserHandoffLeases;
+ private readonly HashSet _dashboardProtectedPorts = new();
+ private readonly List _browserHandoffs = new();
+ private long _nextBrowserHandoffId;
+ private DeferredTunnelStop _deferredStop;
/// Raised when the SSH tunnel exits unexpectedly (not during shutdown).
public event EventHandler? TunnelExited;
@@ -50,6 +66,29 @@ public long OwnershipGeneration
}
}
}
+
+ public bool HasDeferredStop
+ {
+ get
+ {
+ lock (_stateLock)
+ {
+ return _deferredStop != DeferredTunnelStop.None;
+ }
+ }
+ }
+
+ public int BrowserHandoffLeaseCount
+ {
+ get
+ {
+ lock (_stateLock)
+ {
+ return _browserHandoffLeases;
+ }
+ }
+ }
+
public SshTunnelConfig? ActiveConfig
{
get
@@ -60,7 +99,7 @@ public SshTunnelConfig? ActiveConfig
}
}
}
- public string? LocalTunnelUrl => IsActive ? $"ws://localhost:{CurrentLocalPort}" : null;
+ public string? LocalTunnelUrl => IsActive ? $"ws://{LoopbackWebSocketHost(CurrentLocalPort)}:{CurrentLocalPort}" : null;
public string? CurrentUser { get; private set; }
public string? CurrentHost { get; private set; }
public int CurrentRemotePort { get; private set; }
@@ -184,6 +223,7 @@ private void EnsureStartedCore(
tunnel.IncludeBrowserProxyForward,
tunnel.SshPort);
+ Process? claimedProcess;
lock (_stateLock)
{
if (IsRunningLocked() && string.Equals(_lastSpec, spec, StringComparison.Ordinal))
@@ -192,9 +232,16 @@ private void EnsureStartedCore(
Status = TunnelStatus.Up;
return;
}
+
+ if (_browserHandoffLeases > 0)
+ throw new InvalidOperationException("SSH tunnel is held for a dashboard launch.");
+
+ // Lease check and claim share this hold. A later handoff cannot
+ // make the stop defer after this caller has decided to replace.
+ claimedProcess = ClaimProcessForStopLocked();
}
- StopLocked();
+ StopClaimedProcess(claimedProcess);
beforeStart?.Invoke(tunnel);
lock (_stateLock)
{
@@ -212,27 +259,113 @@ public void Stop()
}
}
- private void StopLocked()
+ ///
+ /// Stops SSH. A port already handed to the dashboard stays bound here.
+ /// An unsubmitted handoff still defers the stop.
+ ///
+ private bool StopLocked()
{
+ CancelOpenBrowserHandoffs();
+ int[] portsToKeep;
+ SshTunnelConfig? config;
+ lock (_stateLock)
+ {
+ portsToKeep = _dashboardProtectedPorts.ToArray();
+ config = _currentConfig;
+ if (_browserHandoffLeases > 0 && portsToKeep.Length == 0)
+ {
+ RememberDeferredStopLocked(DeferredTunnelStop.Stop);
+ return false;
+ }
+ }
+
+ BindDashboardGuards(portsToKeep, config);
Process? process;
+ int[] portsToRelease;
lock (_stateLock)
{
- // Claim and clear the current process before stopping it. Exit callbacks can
- // then only observe stale ownership and cannot overwrite a replacement.
- _lifecycleGeneration++;
- process = _process;
- _process = null;
- _processStarted = false;
- _currentConfig = null;
- _currentOwner = SshTunnelOwner.Unspecified;
- _lastSpec = null;
- CurrentBrowserProxyLocalPort = 0;
- CurrentBrowserProxyRemotePort = 0;
- StartedAtUtc = null;
- if (Status != TunnelStatus.NotConfigured)
- Status = TunnelStatus.Stopped;
+ if (portsToKeep.Length > 0)
+ {
+ _browserHandoffs.Clear();
+ _browserHandoffLeases = 0;
+ }
+
+ portsToRelease = PortsToReleaseLocked();
+ process = ClaimProcessForStopLocked();
+ _deferredStop = DeferredTunnelStop.None;
}
+ StopClaimedProcess(process);
+ ReleaseForwardPorts(portsToRelease);
+ return true;
+ }
+
+ ///
+ /// Caller holds . Claim clears the current config,
+ /// so the ports have to be captured first.
+ ///
+ private int[] PortsToReleaseLocked()
+ {
+ if (_browserHandoffs.Any(handoff => !handoff.Settled && (handoff.Submitted || handoff.Opening)))
+ return [];
+
+ return _browserHandoffs
+ .Select(handoff => handoff.LocalPort)
+ .Append(_currentConfig?.LocalPort ?? 0)
+ .Where(port => port > 0 && !_dashboardProtectedPorts.Contains(port))
+ .Distinct()
+ .ToArray();
+ }
+
+ private static void BindDashboardGuards(int[] ports, SshTunnelConfig? config)
+ {
+ foreach (var port in ports)
+ {
+ if (!DashboardForwardPortGuard.IsHolding(port))
+ DashboardForwardPortGuard.Hold(port);
+ if (config is not null && config.LocalPort == port)
+ {
+ DashboardForwardPortGuard.AllowsDestination(
+ port,
+ config.User,
+ config.Host,
+ config.RemotePort,
+ config.SshPort);
+ }
+ }
+ }
+
+ private static void ReleaseForwardPorts(int[] ports)
+ {
+ foreach (var port in ports)
+ DashboardForwardPortGuard.Release(port);
+ }
+
+ ///
+ /// Caller holds and has already decided the lease
+ /// does not defer this stop. Does not consult the lease again.
+ ///
+ private Process? ClaimProcessForStopLocked()
+ {
+ // Claim and clear the current process before stopping it. Exit callbacks can
+ // then only observe stale ownership and cannot overwrite a replacement.
+ _lifecycleGeneration++;
+ var process = _process;
+ _process = null;
+ _processStarted = false;
+ _currentConfig = null;
+ _currentOwner = SshTunnelOwner.Unspecified;
+ _lastSpec = null;
+ CurrentBrowserProxyLocalPort = 0;
+ CurrentBrowserProxyRemotePort = 0;
+ StartedAtUtc = null;
+ if (Status != TunnelStatus.NotConfigured)
+ Status = TunnelStatus.Stopped;
+ return process;
+ }
+
+ private void StopClaimedProcess(Process? process)
+ {
if (process == null)
return;
@@ -260,12 +393,40 @@ public void ResetNotConfigured()
{
lock (_operationLock)
{
- StopLocked();
+ CancelOpenBrowserHandoffs();
+ int[] portsToKeep;
+ SshTunnelConfig? config;
+ lock (_stateLock)
+ {
+ portsToKeep = _dashboardProtectedPorts.ToArray();
+ config = _currentConfig;
+ if (_browserHandoffLeases > 0 && portsToKeep.Length == 0)
+ {
+ RememberDeferredStopLocked(DeferredTunnelStop.ResetNotConfigured);
+ return;
+ }
+ }
+
+ BindDashboardGuards(portsToKeep, config);
+ Process? process;
+ int[] portsToRelease;
lock (_stateLock)
{
+ if (portsToKeep.Length > 0)
+ {
+ _browserHandoffs.Clear();
+ _browserHandoffLeases = 0;
+ }
+
+ portsToRelease = PortsToReleaseLocked();
+ process = ClaimProcessForStopLocked();
+ _deferredStop = DeferredTunnelStop.None;
LastError = null;
Status = TunnelStatus.NotConfigured;
}
+
+ StopClaimedProcess(process);
+ ReleaseForwardPorts(portsToRelease);
}
}
@@ -277,6 +438,14 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s
var localPort = tunnel.LocalPort;
var includeBrowserProxyForward = tunnel.IncludeBrowserProxyForward;
var sshPort = tunnel.SshPort;
+ var reuseGuard = DashboardForwardPortGuard.IsHolding(localPort);
+ if (reuseGuard &&
+ !DashboardForwardPortGuard.AllowsDestination(localPort, user, host, remotePort, sshPort))
+ {
+ throw new InvalidOperationException("SSH destination does not match the dashboard forward.");
+ }
+
+ var sshLocalPort = reuseGuard ? AllocateLoopbackPort() : localPort;
var psi = new ProcessStartInfo
{
FileName = "ssh",
@@ -284,9 +453,10 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s
user,
host,
remotePort,
- localPort,
+ sshLocalPort,
includeBrowserProxyForward,
- sshPort),
+ sshPort,
+ reuseGuard && includeBrowserProxyForward ? localPort + 2 : null),
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
@@ -352,6 +522,7 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s
return;
}
+ RetainPublicPortAfterUnexpectedExit(tunnel.LocalPort);
_logger.Warn($"SSH tunnel exited unexpectedly (code {tunnelExit.ExitCode})");
try { process.Dispose(); }
catch (Exception disposeEx) { _logger.Debug($"SshTunnelService: process dispose after unexpected exit failed: {disposeEx.Message}"); }
@@ -376,6 +547,8 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s
throw new InvalidOperationException("Failed to start ssh process");
}
processStarted = true;
+ if (reuseGuard)
+ DashboardForwardPortGuard.SetBackend(localPort, sshLocalPort, process.Id, process.StartTime.ToUniversalTime());
process.BeginOutputReadLine();
process.BeginErrorReadLine();
@@ -478,7 +651,8 @@ private static string BuildSpec(string user, string host, int remotePort, int lo
public void Dispose()
{
- Stop();
+ lock (_operationLock)
+ TeardownLocked();
}
public Task IsOwnedListenerReadyAsync(
@@ -529,11 +703,19 @@ _process is null ||
try
{
- if (!ValidateListenerOwnership(
+ if (DashboardForwardPortGuard.IsHolding(destinationPort) &&
+ !TryConfirmRetainedPublicRoute(destinationPort))
+ {
+ return Task.FromResult(false);
+ }
+
+ var proofPort = ListenerPortToProve(destinationPort);
+ if (proofPort < 1 ||
+ !ValidateListenerOwnership(
WindowsTcpListenerSnapshot.Capture(),
- destinationPort,
- processId,
- processStartTimeUtc))
+ proofPort,
+ processId,
+ processStartTimeUtc))
{
return Task.FromResult(false);
}
@@ -558,6 +740,612 @@ _process is null ||
public async Task StartAsync(SshTunnelConfig config, CancellationToken ct) =>
(await StartOwnedAsync(config, ct).ConfigureAwait(false)).Url;
+ public async Task EnsureSettingsOwnedForwardReadyAsync(
+ SshTunnelConfig config,
+ CancellationToken cancellationToken)
+ {
+ Process? process = null;
+ long generation = 0;
+ var acquiredGuard = false;
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ SshTunnelOwner restartOwner;
+ lock (_stateLock)
+ {
+ restartOwner = DashboardRestartOwner(
+ _currentOwner,
+ IsRunningLocked() && _currentConfig?.LocalPort == config.LocalPort);
+ }
+
+ acquiredGuard = ClaimDashboardPublicPort(config);
+ EnsureStartedCore(
+ config,
+ restartOwner,
+ tunnel => RejectOccupiedForwardPorts(tunnel));
+
+ var normalizedConfig = config with
+ {
+ User = config.User.Trim(),
+ Host = config.Host.Trim(),
+ };
+ DateTime processStartTimeUtc;
+ lock (_stateLock)
+ {
+ if (!IsRunningLocked() ||
+ _process is null ||
+ !Equals(_currentConfig, normalizedConfig))
+ {
+ throw new InvalidOperationException(
+ "SSH tunnel changed before listener ownership could be verified.");
+ }
+
+ process = _process;
+ generation = _lifecycleGeneration;
+ processStartTimeUtc = process.StartTime.ToUniversalTime();
+ }
+
+ var processId = process.Id;
+ await WaitForOwnedLocalListenerAsync(
+ config.LocalPort,
+ process,
+ generation,
+ processId,
+ processStartTimeUtc,
+ cancellationToken).ConfigureAwait(false);
+ if (config.IncludeBrowserProxyForward)
+ {
+ await WaitForOwnedLocalListenerAsync(
+ config.LocalPort + 2,
+ process,
+ generation,
+ processId,
+ processStartTimeUtc,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ lock (_stateLock)
+ {
+ if (generation != _lifecycleGeneration ||
+ !ReferenceEquals(_process, process) ||
+ !IsRunningLocked() ||
+ !Equals(_currentConfig, normalizedConfig) ||
+ _currentOwner is not (SshTunnelOwner.Settings or SshTunnelOwner.GatewayConnectionManager))
+ {
+ return default;
+ }
+
+ return new SettingsOwnedForwardBinding(
+ true,
+ generation,
+ normalizedConfig.LocalPort,
+ processId);
+ }
+ }
+ catch (Exception ex)
+ {
+ var stillCurrent = false;
+ lock (_stateLock)
+ {
+ stillCurrent = process is null
+ ? _process is null
+ : generation == _lifecycleGeneration && ReferenceEquals(_process, process);
+ if (!stillCurrent)
+ {
+ _logger.Warn($"SSH dashboard forward wait lost ownership: {ex.Message}");
+ return default;
+ }
+
+ LastError = ex.Message;
+ Status = TunnelStatus.Failed;
+ }
+
+ if (acquiredGuard)
+ ReleaseUnsubmittedGuard(config.LocalPort);
+
+ if (process is not null)
+ StopIfCurrent(process, generation);
+
+ _logger.Warn($"SSH dashboard forward is not owned: {ex.Message}");
+ return default;
+ }
+ }
+
+ public bool IsSettingsOwnedForwardCurrent(long generation, int localPort)
+ {
+ lock (_stateLock)
+ {
+ return IsForwardCurrentLocked(generation, localPort);
+ }
+ }
+
+ public bool TryEnterBrowserHandoff(long generation, int localPort, int processId = 0) =>
+ TryEnterBrowserHandoff(generation, localPort, processId, out _);
+
+ public bool TryEnterBrowserHandoff(long generation, int localPort, int processId, out long handoffId)
+ {
+ lock (_stateLock)
+ {
+ handoffId = 0;
+ if (!IsForwardCurrentLocked(generation, localPort))
+ return false;
+ if (processId > 0 && _process?.Id != processId)
+ return false;
+
+ handoffId = ++_nextBrowserHandoffId;
+ _browserHandoffLeases++;
+ _browserHandoffs.Add(new BrowserHandoffLease
+ {
+ Id = handoffId,
+ LocalPort = localPort,
+ });
+ return true;
+ }
+ }
+
+ public void NoteBrowserHandoffClient(int processId) =>
+ NoteBrowserHandoffClient(NewestOpenHandoffId(), processId);
+
+ public void NoteBrowserHandoffClient(long handoffId, int processId)
+ {
+ if (handoffId <= 0 || processId <= 0)
+ return;
+
+ lock (_stateLock)
+ {
+ var handoff = FindOpenHandoffLocked(handoffId);
+ if (handoff is not null)
+ handoff.BrowserProcessId = processId;
+ }
+ }
+
+ internal int? HandoffBrowserProcessId(long handoffId)
+ {
+ lock (_stateLock)
+ {
+ return FindOpenHandoffLocked(handoffId)?.BrowserProcessId;
+ }
+ }
+
+ public bool IsBrowserHandoffOpen(long handoffId)
+ {
+ lock (_stateLock)
+ {
+ return FindOpenHandoffLocked(handoffId) is not null;
+ }
+ }
+
+ ///
+ /// Keeps one browser-handoff lease after launch and asks the caller to watch it.
+ /// A stop that arrives before attributable browser use stays deferred.
+ /// Returns false when is not leased.
+ ///
+ public bool TryReleaseBrowserHandoffUnlessDeferred(int localPort, out bool watchDeferredStop)
+ {
+ lock (_stateLock)
+ {
+ watchDeferredStop = false;
+ if (_browserHandoffLeases <= 0 || _currentConfig?.LocalPort != localPort)
+ return false;
+
+ // Process.Start returns before the browser connects. Keep the lease
+ // until attributable use, including a stop that arrives after launch.
+ watchDeferredStop = true;
+ return true;
+ }
+ }
+
+ public bool TryBeginDashboardNavigation(long handoffId)
+ {
+ lock (_stateLock)
+ {
+ var handoff = FindOpenHandoffLocked(handoffId);
+ if (handoff is null || handoff.CancelRequested || handoff.Submitted)
+ return false;
+
+ handoff.Opening = true;
+ _dashboardProtectedPorts.Add(handoff.LocalPort);
+ return true;
+ }
+ }
+
+ public bool CompleteDashboardNavigation(long handoffId, bool opened, int? processId)
+ {
+ var exit = false;
+ lock (_stateLock)
+ {
+ var handoff = FindOpenHandoffLocked(handoffId);
+ if (handoff is null)
+ return false;
+
+ handoff.Opening = false;
+ if (!opened)
+ {
+ exit = true;
+ if (!_browserHandoffs.Any(other =>
+ !other.Settled &&
+ other.Id != handoff.Id &&
+ other.LocalPort == handoff.LocalPort &&
+ (other.Submitted || other.Opening)))
+ {
+ _dashboardProtectedPorts.Remove(handoff.LocalPort);
+ }
+ }
+ else
+ {
+ handoff.Submitted = true;
+ _dashboardProtectedPorts.Add(handoff.LocalPort);
+ if (processId is int id && id > 0)
+ handoff.BrowserProcessId = id;
+ }
+ }
+
+ if (exit)
+ ExitBrowserHandoff(handoffId);
+ return opened && IsBrowserHandoffOpen(handoffId);
+ }
+
+ public void CancelOpenBrowserHandoffs()
+ {
+ long[] handoffIds;
+ lock (_stateLock)
+ {
+ foreach (var handoff in _browserHandoffs)
+ {
+ if (!handoff.Settled)
+ handoff.CancelRequested = true;
+ }
+
+ handoffIds = _browserHandoffs
+ .Where(handoff => !handoff.Settled && !handoff.Submitted && !handoff.Opening)
+ .Select(handoff => handoff.Id)
+ .ToArray();
+ }
+
+ foreach (var handoffId in handoffIds)
+ ExitBrowserHandoff(handoffId);
+ }
+
+ public void ExitBrowserHandoff()
+ {
+ long handoffId;
+ lock (_stateLock)
+ {
+ var oldest = FindOldestOpenHandoffLocked();
+ if (oldest is null)
+ return;
+ handoffId = oldest.Id;
+ }
+
+ ExitBrowserHandoff(handoffId);
+ }
+
+ public void ExitBrowserHandoff(long handoffId)
+ {
+ // Same order as Stop: operation lock, then state lock. Claim under the
+ // state lock and kill only after releasing it. Do not take the operation
+ // lock again when the caller already holds it.
+ var enteredOperationLock = false;
+ if (!Monitor.IsEntered(_operationLock))
+ {
+ Monitor.Enter(_operationLock);
+ enteredOperationLock = true;
+ }
+
+ try
+ {
+ Process? process = null;
+ var completeStop = false;
+ lock (_stateLock)
+ {
+ var handoff = _browserHandoffs.FirstOrDefault(item => item.Id == handoffId);
+ if (handoff is null || handoff.Settled)
+ return;
+
+ handoff.Settled = true;
+ _browserHandoffs.Remove(handoff);
+ if (_browserHandoffLeases > 0)
+ _browserHandoffLeases--;
+ if (_browserHandoffLeases != 0 || _deferredStop == DeferredTunnelStop.None)
+ return;
+
+ var reset = _deferredStop == DeferredTunnelStop.ResetNotConfigured;
+ process = ClaimProcessForStopLocked();
+ _deferredStop = DeferredTunnelStop.None;
+ if (reset)
+ {
+ LastError = null;
+ Status = TunnelStatus.NotConfigured;
+ }
+
+ completeStop = true;
+ }
+
+ if (completeStop)
+ StopClaimedProcess(process);
+ }
+ finally
+ {
+ if (enteredOperationLock)
+ Monitor.Exit(_operationLock);
+ }
+ }
+
+ ///
+ /// Releases one browser-handoff lease for .
+ /// A deferred stop or reset then runs. Returns false when that port is not leased.
+ ///
+ public bool TryCompleteDeferredBrowserHandoff(int localPort)
+ {
+ var enteredOperationLock = false;
+ if (!Monitor.IsEntered(_operationLock))
+ {
+ Monitor.Enter(_operationLock);
+ enteredOperationLock = true;
+ }
+
+ try
+ {
+ lock (_stateLock)
+ {
+ if (_browserHandoffLeases <= 0 || _currentConfig?.LocalPort != localPort)
+ return false;
+ }
+
+ ExitBrowserHandoff();
+ return true;
+ }
+ finally
+ {
+ if (enteredOperationLock)
+ Monitor.Exit(_operationLock);
+ }
+ }
+
+ ///
+ /// Waits until the browser uses , or the SSH process is gone.
+ /// A timeout leaves the lease and listener in place and returns false.
+ ///
+ public Task WatchBrowserHandoffConsumptionAsync(
+ int localPort,
+ TimeSpan timeout,
+ Func? consumptionProbe,
+ CancellationToken cancellationToken = default) =>
+ WatchBrowserHandoffConsumptionAsync(0, localPort, timeout, consumptionProbe, cancellationToken);
+
+ public async Task WatchBrowserHandoffConsumptionAsync(
+ long handoffId,
+ int localPort,
+ TimeSpan timeout,
+ Func? consumptionProbe,
+ CancellationToken cancellationToken = default)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ while (true)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (handoffId > 0)
+ {
+ if (!IsBrowserHandoffOpen(handoffId))
+ return true;
+ }
+ else if (BrowserHandoffLeaseCount <= 0)
+ {
+ return true;
+ }
+
+ // A shell activation process can exit after handing the URL to an
+ // existing browser, so that exit is not navigation completion.
+ var consumed = consumptionProbe?.Invoke(localPort) == true;
+ if (!consumed && !IsRunning && handoffId > 0 && HandoffNeedsRetainedPort(handoffId))
+ {
+ RetainPublicPortAfterUnexpectedExit(localPort);
+ return true;
+ }
+ else if (consumed || !IsRunning)
+ {
+ var completed = handoffId > 0
+ ? TryFinishHandoff(handoffId, localPort)
+ : TryCompleteDeferredBrowserHandoff(localPort);
+ if (completed || (handoffId > 0 ? !IsBrowserHandoffOpen(handoffId) : BrowserHandoffLeaseCount <= 0))
+ return true;
+ }
+
+ if (DateTime.UtcNow >= deadline)
+ {
+ _logger.Warn(
+ "Dashboard SSH forward stayed owned because the browser has not connected; the listener was not stopped.");
+ return false;
+ }
+
+ await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private bool TryFinishHandoff(long handoffId, int localPort)
+ {
+ lock (_stateLock)
+ {
+ var handoff = FindOpenHandoffLocked(handoffId);
+ if (handoff is null || handoff.LocalPort != localPort)
+ return false;
+ }
+
+ ExitBrowserHandoff(handoffId);
+ return true;
+ }
+
+ private long NewestOpenHandoffId()
+ {
+ lock (_stateLock)
+ {
+ return FindNewestOpenHandoffLocked()?.Id ?? 0;
+ }
+ }
+
+ private BrowserHandoffLease? FindOpenHandoffLocked(long handoffId) =>
+ _browserHandoffs.FirstOrDefault(handoff => handoff.Id == handoffId && !handoff.Settled);
+
+ private BrowserHandoffLease? FindOldestOpenHandoffLocked()
+ {
+ BrowserHandoffLease? oldest = null;
+ foreach (var handoff in _browserHandoffs)
+ {
+ if (handoff.Settled)
+ continue;
+ if (oldest is null || handoff.Id < oldest.Id)
+ oldest = handoff;
+ }
+
+ return oldest;
+ }
+
+ private BrowserHandoffLease? FindNewestOpenHandoffLocked()
+ {
+ BrowserHandoffLease? newest = null;
+ foreach (var handoff in _browserHandoffs)
+ {
+ if (handoff.Settled)
+ continue;
+ if (newest is null || handoff.Id > newest.Id)
+ newest = handoff;
+ }
+
+ return newest;
+ }
+
+ private sealed class BrowserHandoffLease
+ {
+ public long Id { get; init; }
+ public int LocalPort { get; init; }
+ public int? BrowserProcessId { get; set; }
+ public bool Opening { get; set; }
+ public bool Submitted { get; set; }
+ public bool CancelRequested { get; set; }
+ public bool Settled { get; set; }
+ }
+
+ ///
+ /// Caller holds . A reset replaces a plain stop.
+ /// A later stop does not downgrade a reset.
+ ///
+ private void RememberDeferredStopLocked(DeferredTunnelStop kind)
+ {
+ if (_deferredStop != DeferredTunnelStop.ResetNotConfigured)
+ _deferredStop = kind;
+ }
+
+ private enum DeferredTunnelStop
+ {
+ None,
+ Stop,
+ ResetNotConfigured,
+ }
+
+ private bool IsForwardCurrentLocked(long generation, int localPort)
+ {
+ return generation == _lifecycleGeneration &&
+ IsRunningLocked() &&
+ _currentOwner is SshTunnelOwner.Settings or SshTunnelOwner.GatewayConnectionManager &&
+ _currentConfig?.LocalPort == localPort;
+ }
+
+ ///
+ /// Keeps a manager-owned tunnel on that owner when Dashboard moves SSH behind the public port.
+ ///
+ internal static SshTunnelOwner DashboardRestartOwner(SshTunnelOwner currentOwner, bool runningOnRequestedPort) =>
+ runningOnRequestedPort && currentOwner == SshTunnelOwner.GatewayConnectionManager
+ ? SshTunnelOwner.GatewayConnectionManager
+ : SshTunnelOwner.Settings;
+
+ internal static string LoopbackWebSocketHost(int port) =>
+ DashboardForwardPortGuard.IsHolding(port) ? "127.0.0.1" : "localhost";
+
+ ///
+ /// Binds the dashboard's public port before SSH listens there.
+ /// Returns true when this call created the guard.
+ /// The browser URL is not issued until this returns.
+ ///
+ private bool ClaimDashboardPublicPort(SshTunnelConfig config)
+ {
+ var localPort = config.LocalPort;
+ var user = config.User.Trim();
+ var host = config.Host.Trim();
+ if (DashboardForwardPortGuard.IsHolding(localPort))
+ {
+ DashboardForwardPortGuard.AllowsDestination(
+ localPort,
+ user,
+ host,
+ config.RemotePort,
+ config.SshPort);
+ return false;
+ }
+
+ var ownsPublicPort = false;
+ lock (_stateLock)
+ {
+ ownsPublicPort = IsRunningLocked() && _currentConfig?.LocalPort == localPort;
+ }
+
+ if (ownsPublicPort)
+ Stop();
+
+ RejectForeignForwardPort(localPort);
+ if (config.IncludeBrowserProxyForward)
+ RejectForeignForwardPort(localPort + 2);
+
+ DashboardForwardPortGuard.Hold(localPort);
+ DashboardForwardPortGuard.AllowsDestination(
+ localPort,
+ user,
+ host,
+ config.RemotePort,
+ config.SshPort);
+ return true;
+ }
+
+ private void ReleaseUnsubmittedGuard(int port)
+ {
+ var protect = false;
+ lock (_stateLock)
+ protect = _dashboardProtectedPorts.Contains(port);
+ if (!protect)
+ DashboardForwardPortGuard.Release(port);
+ }
+
+ private static void RejectOccupiedForwardPorts(SshTunnelConfig tunnel)
+ {
+ RejectForeignForwardPort(tunnel.LocalPort);
+ if (tunnel.IncludeBrowserProxyForward)
+ RejectForeignForwardPort(tunnel.LocalPort + 2);
+ }
+
+ internal static void RejectForeignForwardPort(int port)
+ {
+ if (DashboardForwardPortGuard.IsHolding(port))
+ return;
+
+ EnsurePortIsUnoccupied(WindowsTcpListenerSnapshot.Capture(), port);
+ }
+
+ private static int ListenerPortToProve(int publicPort)
+ {
+ if (!DashboardForwardPortGuard.IsHolding(publicPort))
+ return publicPort;
+
+ return DashboardForwardPortGuard.BackendPort(publicPort) ?? -1;
+ }
+
+ private static int AllocateLoopbackPort()
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+
public async Task StartOwnedAsync(
SshTunnelConfig config,
CancellationToken ct)
@@ -569,12 +1357,7 @@ public async Task StartOwnedAsync(
EnsureStartedCore(
config,
SshTunnelOwner.GatewayConnectionManager,
- tunnel =>
- {
- EnsurePortIsUnoccupied(WindowsTcpListenerSnapshot.Capture(), tunnel.LocalPort);
- if (tunnel.IncludeBrowserProxyForward)
- EnsurePortIsUnoccupied(WindowsTcpListenerSnapshot.Capture(), tunnel.LocalPort + 2);
- });
+ RejectOccupiedForwardPorts);
var normalizedConfig = config with
{
@@ -616,7 +1399,7 @@ await WaitForOwnedLocalListenerAsync(
ct).ConfigureAwait(false);
}
return new SshTunnelStartResult(
- $"ws://localhost:{config.LocalPort}",
+ $"ws://{LoopbackWebSocketHost(config.LocalPort)}:{config.LocalPort}",
normalizedConfig,
generation);
}
@@ -630,10 +1413,118 @@ await WaitForOwnedLocalListenerAsync(
public Task StopAsync()
{
- Stop();
+ lock (_operationLock)
+ TeardownLocked();
return Task.CompletedTask;
}
+ private void TeardownLocked()
+ {
+ int[] protectedPorts;
+ int stoppedPort;
+ Process? process;
+ SshTunnelConfig? stoppedConfig;
+ lock (_stateLock)
+ {
+ stoppedConfig = _currentConfig;
+ stoppedPort = stoppedConfig?.LocalPort ?? 0;
+ protectedPorts = _browserHandoffs
+ .Where(handoff => !handoff.Settled && (handoff.Submitted || handoff.Opening))
+ .Select(handoff => handoff.LocalPort)
+ .Concat(_dashboardProtectedPorts)
+ .Where(port => port > 0)
+ .Distinct()
+ .ToArray();
+ }
+
+ BindDashboardGuards(protectedPorts, stoppedConfig);
+ lock (_stateLock)
+ {
+ foreach (var handoff in _browserHandoffs)
+ handoff.CancelRequested = true;
+ _browserHandoffs.Clear();
+ _browserHandoffLeases = 0;
+ _deferredStop = DeferredTunnelStop.None;
+ foreach (var port in protectedPorts)
+ _dashboardProtectedPorts.Remove(port);
+ process = ClaimProcessForStopLocked();
+ }
+
+ foreach (var port in protectedPorts)
+ DashboardForwardPortGuard.ClearBackend(port);
+ StopClaimedProcess(process);
+
+ if (protectedPorts.Length == 0 && stoppedPort > 0)
+ DashboardForwardPortGuard.Release(stoppedPort);
+ }
+
+ private bool HandoffNeedsRetainedPort(long handoffId)
+ {
+ lock (_stateLock)
+ {
+ var handoff = FindOpenHandoffLocked(handoffId);
+ return handoff is { Submitted: true } or { Opening: true };
+ }
+ }
+
+ ///
+ /// Keeps the public dashboard port after SSH exits and drops the lease.
+ /// Recovery can then start a backend listener. A deferred stop is not run.
+ ///
+ private void RetainPublicPortAfterUnexpectedExit(int localPort)
+ {
+ SshTunnelConfig? config;
+ long[] retainIds;
+ lock (_stateLock)
+ {
+ config = _currentConfig;
+ retainIds = _browserHandoffs
+ .Where(handoff => !handoff.Settled &&
+ handoff.LocalPort == localPort &&
+ (handoff.Submitted || handoff.Opening))
+ .Select(handoff => handoff.Id)
+ .ToArray();
+ if (retainIds.Length > 0)
+ _dashboardProtectedPorts.Add(localPort);
+ }
+
+ if (retainIds.Length == 0)
+ return;
+
+ if (!DashboardForwardPortGuard.IsHolding(localPort))
+ DashboardForwardPortGuard.Hold(localPort);
+ if (config is not null && config.LocalPort == localPort)
+ {
+ DashboardForwardPortGuard.AllowsDestination(
+ localPort,
+ config.User,
+ config.Host,
+ config.RemotePort,
+ config.SshPort);
+ }
+
+ DashboardForwardPortGuard.ClearBackend(localPort);
+ foreach (var handoffId in retainIds)
+ DropHandoffForRecovery(handoffId);
+ }
+
+ private void DropHandoffForRecovery(long handoffId)
+ {
+ lock (_stateLock)
+ {
+ var handoff = _browserHandoffs.FirstOrDefault(item => item.Id == handoffId);
+ if (handoff is null || handoff.Settled)
+ return;
+
+ handoff.Settled = true;
+ _browserHandoffs.Remove(handoff);
+ if (_browserHandoffLeases > 0)
+ _browserHandoffLeases--;
+ if (_browserHandoffLeases == 0)
+ _deferredStop = DeferredTunnelStop.None;
+ }
+ }
+
public Task StopIfOwnedAsync(
SshTunnelConfig config,
long ownershipGeneration,
@@ -645,13 +1536,14 @@ public Task StopIfOwnedAsync(
try
{
ct.ThrowIfCancellationRequested();
+ var normalizedConfig = config with
+ {
+ User = config.User.Trim(),
+ Host = config.Host.Trim(),
+ };
lock (_stateLock)
{
- var normalizedConfig = config with
- {
- User = config.User.Trim(),
- Host = config.Host.Trim(),
- };
+ ct.ThrowIfCancellationRequested();
if (_lifecycleGeneration != ownershipGeneration ||
!Equals(_currentConfig, normalizedConfig) ||
_currentOwner != SshTunnelOwner.GatewayConnectionManager)
@@ -660,8 +1552,7 @@ public Task StopIfOwnedAsync(
}
}
- ct.ThrowIfCancellationRequested();
- StopLocked();
+ TeardownLocked();
return Task.FromResult(true);
}
finally
@@ -693,11 +1584,20 @@ private async Task WaitForOwnedLocalListenerAsync(
}
}
- if (ValidateListenerOwnership(
- WindowsTcpListenerSnapshot.Capture(),
- localPort,
- processId,
- processStartTimeUtc))
+ if (DashboardForwardPortGuard.IsHolding(localPort) &&
+ !TryConfirmRetainedPublicRoute(localPort))
+ {
+ await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken).ConfigureAwait(false);
+ continue;
+ }
+
+ var proofPort = ListenerPortToProve(localPort);
+ if (proofPort > 0 &&
+ ValidateListenerOwnership(
+ WindowsTcpListenerSnapshot.Capture(),
+ proofPort,
+ processId,
+ processStartTimeUtc))
{
return;
}
@@ -712,19 +1612,46 @@ private void StopIfCurrent(Process process, long generation)
{
lock (_operationLock)
{
+ Process? claimed;
lock (_stateLock)
{
if (generation != _lifecycleGeneration ||
- !ReferenceEquals(_process, process))
+ !ReferenceEquals(_process, process) ||
+ _browserHandoffLeases > 0)
{
return;
}
+
+ claimed = ClaimProcessForStopLocked();
}
- StopLocked();
+ StopClaimedProcess(claimed);
}
}
+ internal static bool TryConfirmRetainedPublicRoute(int publicPort)
+ {
+ var snapshot = WindowsTcpListenerSnapshot.Capture();
+ if (!snapshot.Ipv4Complete || !snapshot.Ipv6Complete)
+ return false;
+
+ var listeners = snapshot.Listeners
+ .Where(listener => listener.Port == publicPort && CanServeLoopback(listener.Address))
+ .ToArray();
+ var ownerPid = Environment.ProcessId;
+ if (listeners.Any(listener => listener.ProcessId != ownerPid))
+ {
+ throw new InvalidOperationException(
+ $"Local port {publicPort} is not owned exclusively by the dashboard forward.");
+ }
+
+ if (listeners.Length == 0)
+ return false;
+
+ return !Socket.OSSupportsIPv6 ||
+ listeners.Any(listener => listener.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6);
+ }
+
internal static void EnsurePortIsUnoccupied(
WindowsTcpListenerSnapshotResult snapshot,
int localPort)
diff --git a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs
index 76b490afd..6f89bed01 100644
--- a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs
+++ b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs
@@ -22,6 +22,8 @@ public sealed record WindowsTcpListenerSnapshotResult(
/// Address-specific TCP listener ownership from the Windows IP Helper API.
public static class WindowsTcpListenerSnapshot
{
+ public const uint TcpStateEstablished = 5;
+
public static WindowsTcpListenerSnapshotResult Capture()
{
if (!OperatingSystem.IsWindows())
@@ -33,6 +35,93 @@ public static WindowsTcpListenerSnapshotResult Capture()
return new(result, ipv4Complete, ipv6Complete);
}
+ ///
+ /// True when an established TCP row is using on loopback.
+ /// Listeners are not established, so the owned SSH socket alone does not match.
+ ///
+ public static bool HasEstablishedLoopbackConnection(int port)
+ {
+ if (!OperatingSystem.IsWindows() || port is < 1 or > 65535)
+ return false;
+
+ try
+ {
+ return ScanEstablished(AfInet, port, ipv6: false) ||
+ ScanEstablished(AfInet6, port, ipv6: true);
+ }
+ catch (Exception ex)
+ {
+ Trace.WriteLine(
+ $"Established TCP lookup failed for port {port}: {ex.GetType().Name}: {ex.Message}");
+ return false;
+ }
+ }
+
+ public static bool IsEstablishedLoopbackForwardUse(
+ uint state,
+ IPAddress localAddress,
+ int localPort,
+ IPAddress remoteAddress,
+ int remotePort,
+ int forwardPort)
+ {
+ if (state != TcpStateEstablished || forwardPort is < 1 or > 65535)
+ return false;
+ if (localPort == forwardPort && IPAddress.IsLoopback(localAddress))
+ return true;
+ if (remotePort == forwardPort && IPAddress.IsLoopback(remoteAddress))
+ return true;
+ return false;
+ }
+
+ public static string EstablishedForwardKey(
+ IPAddress localAddress,
+ int localPort,
+ IPAddress remoteAddress,
+ int remotePort,
+ int processId) =>
+ $"{localAddress}|{localPort}|{remoteAddress}|{remotePort}|{processId}";
+
+ public static bool IsUnseenEstablishedForwardUse(
+ uint state,
+ IPAddress localAddress,
+ int localPort,
+ IPAddress remoteAddress,
+ int remotePort,
+ int processId,
+ int forwardPort,
+ int? browserProcessId,
+ IReadOnlySet seen)
+ {
+ if (!IsEstablishedLoopbackForwardUse(
+ state,
+ localAddress,
+ localPort,
+ remoteAddress,
+ remotePort,
+ forwardPort))
+ {
+ return false;
+ }
+
+ // An accepted SSH socket is not the browser that opened the dashboard.
+ if (localPort == forwardPort || remotePort != forwardPort)
+ return false;
+ if (browserProcessId is not int browser || browser <= 0 || processId != browser)
+ return false;
+
+ var key = EstablishedForwardKey(localAddress, localPort, remoteAddress, remotePort, processId);
+ return !seen.Contains(key);
+ }
+
+ private readonly record struct EstablishedTcpRow(
+ uint State,
+ IPAddress LocalAddress,
+ int LocalPort,
+ IPAddress RemoteAddress,
+ int RemotePort,
+ int ProcessId);
+
public static string? GetProcessCommandLine(int processId)
{
if (processId <= 0)
@@ -215,6 +304,206 @@ private static bool CaptureTable(
return false;
}
+ public static bool TryConfirmAcceptedLoopbackOwner(int listenPort, int clientPort, int processId)
+ {
+ if (!OperatingSystem.IsWindows() ||
+ listenPort is < 1 or > 65535 ||
+ clientPort is < 1 or > 65535 ||
+ processId <= 0)
+ {
+ return false;
+ }
+
+ var found = false;
+ var complete = true;
+ bool Visit(EstablishedTcpRow row)
+ {
+ if (row.State == TcpStateEstablished &&
+ row.LocalPort == listenPort &&
+ row.RemotePort == clientPort &&
+ IPAddress.IsLoopback(row.LocalAddress) &&
+ IPAddress.IsLoopback(row.RemoteAddress) &&
+ row.ProcessId == processId)
+ {
+ found = true;
+ return true;
+ }
+
+ return false;
+ }
+
+ VisitEstablished(AfInet, ipv6: false, Visit, ref complete);
+ if (!found)
+ VisitEstablished(AfInet6, ipv6: true, Visit, ref complete);
+ return found && complete;
+ }
+
+ private static void VisitEstablished(
+ int addressFamily,
+ bool ipv6,
+ Func visit,
+ ref bool complete)
+ {
+ var rowSize = ipv6
+ ? Marshal.SizeOf()
+ : Marshal.SizeOf();
+ for (var attempt = 0; attempt < 3; attempt++)
+ {
+ var bufferLength = 0;
+ var status = GetExtendedTcpTable(
+ IntPtr.Zero,
+ ref bufferLength,
+ sort: true,
+ ipVersion: addressFamily,
+ tableClass: TcpTableOwnerPidAll,
+ reserved: 0);
+ if (status != ErrorInsufficientBuffer || bufferLength <= 0)
+ {
+ complete = false;
+ return;
+ }
+
+ var tablePtr = Marshal.AllocHGlobal(bufferLength);
+ try
+ {
+ status = GetExtendedTcpTable(
+ tablePtr,
+ ref bufferLength,
+ sort: true,
+ ipVersion: addressFamily,
+ tableClass: TcpTableOwnerPidAll,
+ reserved: 0);
+ if (status == ErrorInsufficientBuffer)
+ continue;
+ if (status != ErrorSuccess)
+ {
+ complete = false;
+ return;
+ }
+
+ var rowCount = Marshal.ReadInt32(tablePtr);
+ var rowPtr = IntPtr.Add(tablePtr, sizeof(int));
+ for (var index = 0; index < rowCount; index++)
+ {
+ if (visit(ReadEstablishedRow(rowPtr, ipv6)))
+ return;
+ rowPtr = IntPtr.Add(rowPtr, rowSize);
+ }
+
+ return;
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(tablePtr);
+ }
+ }
+
+ complete = false;
+ }
+
+ private static EstablishedTcpRow ReadEstablishedRow(IntPtr rowPtr, bool ipv6)
+ {
+ if (ipv6)
+ {
+ var row = Marshal.PtrToStructure(rowPtr);
+ return new EstablishedTcpRow(
+ row.State,
+ new IPAddress(row.LocalAddress, row.LocalScopeId),
+ ReadPort(row.LocalPort),
+ new IPAddress(row.RemoteAddress, row.RemoteScopeId),
+ ReadPort(row.RemotePort),
+ unchecked((int)row.OwningProcessId));
+ }
+
+ var ipv4 = Marshal.PtrToStructure(rowPtr);
+ return new EstablishedTcpRow(
+ ipv4.State,
+ new IPAddress(BitConverter.GetBytes(ipv4.LocalAddress)),
+ ReadPort(ipv4.LocalPort),
+ new IPAddress(BitConverter.GetBytes(ipv4.RemoteAddress)),
+ ReadPort(ipv4.RemotePort),
+ unchecked((int)ipv4.OwningProcessId));
+ }
+
+ private static bool ScanEstablished(int addressFamily, int port, bool ipv6)
+ {
+ var rowSize = ipv6
+ ? Marshal.SizeOf()
+ : Marshal.SizeOf();
+ for (var attempt = 0; attempt < 3; attempt++)
+ {
+ var bufferLength = 0;
+ var status = GetExtendedTcpTable(
+ IntPtr.Zero,
+ ref bufferLength,
+ sort: true,
+ ipVersion: addressFamily,
+ tableClass: TcpTableOwnerPidAll,
+ reserved: 0);
+ if (status != ErrorInsufficientBuffer || bufferLength <= 0)
+ return false;
+
+ var tablePtr = Marshal.AllocHGlobal(bufferLength);
+ try
+ {
+ status = GetExtendedTcpTable(
+ tablePtr,
+ ref bufferLength,
+ sort: true,
+ ipVersion: addressFamily,
+ tableClass: TcpTableOwnerPidAll,
+ reserved: 0);
+ if (status == ErrorInsufficientBuffer)
+ continue;
+ if (status != ErrorSuccess)
+ return false;
+
+ var rowCount = Marshal.ReadInt32(tablePtr);
+ var rowPtr = IntPtr.Add(tablePtr, sizeof(int));
+ for (var index = 0; index < rowCount; index++)
+ {
+ if (ReadEstablishedLoopback(rowPtr, ipv6, port))
+ return true;
+ rowPtr = IntPtr.Add(rowPtr, rowSize);
+ }
+
+ return false;
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(tablePtr);
+ }
+ }
+
+ return false;
+ }
+
+ private static bool ReadEstablishedLoopback(IntPtr rowPtr, bool ipv6, int port)
+ {
+ if (ipv6)
+ {
+ var row = Marshal.PtrToStructure(rowPtr);
+ var local = new IPAddress(row.LocalAddress, row.LocalScopeId);
+ var remote = new IPAddress(row.RemoteAddress, row.RemoteScopeId);
+ return IsEstablishedLoopbackForwardUse(
+ row.State,
+ local,
+ ReadPort(row.LocalPort),
+ remote,
+ ReadPort(row.RemotePort),
+ port);
+ }
+
+ var ipv4 = Marshal.PtrToStructure(rowPtr);
+ return IsEstablishedLoopbackForwardUse(
+ ipv4.State,
+ new IPAddress(BitConverter.GetBytes(ipv4.LocalAddress)),
+ ReadPort(ipv4.LocalPort),
+ new IPAddress(BitConverter.GetBytes(ipv4.RemoteAddress)),
+ ReadPort(ipv4.RemotePort),
+ port);
+ }
+
private static int ReadPort(byte[] bytes) =>
bytes is { Length: >= 2 } ? (bytes[0] << 8) + bytes[1] : 0;
@@ -245,6 +534,7 @@ private static void ResolveProcess(
private const int AfInet = 2;
private const int AfInet6 = 23;
private const int TcpTableOwnerPidListener = 3;
+ private const int TcpTableOwnerPidAll = 5;
private const uint ErrorSuccess = 0;
private const uint ErrorInsufficientBuffer = 122;
diff --git a/src/OpenClaw.Shared/SshTunnelCommandLine.cs b/src/OpenClaw.Shared/SshTunnelCommandLine.cs
index 11f28fa4e..aeaa48a84 100644
--- a/src/OpenClaw.Shared/SshTunnelCommandLine.cs
+++ b/src/OpenClaw.Shared/SshTunnelCommandLine.cs
@@ -49,6 +49,23 @@ public static string BuildArguments(
int localPort,
bool includeBrowserProxyForward,
int sshPort)
+ => BuildArguments(
+ user,
+ host,
+ remotePort,
+ localPort,
+ includeBrowserProxyForward,
+ sshPort,
+ browserProxyLocalPort: null);
+
+ public static string BuildArguments(
+ string user,
+ string host,
+ int remotePort,
+ int localPort,
+ bool includeBrowserProxyForward,
+ int sshPort,
+ int? browserProxyLocalPort)
{
user = user.Trim();
host = host.Trim();
@@ -60,17 +77,18 @@ public static string BuildArguments(
ValidatePort(remotePort, nameof(remotePort));
ValidatePort(localPort, nameof(localPort));
ValidatePort(sshPort, nameof(sshPort));
+ var proxyLocalPort = browserProxyLocalPort ?? localPort + 2;
if (includeBrowserProxyForward)
{
ValidateBrowserProxyPort(remotePort, nameof(remotePort));
- ValidateBrowserProxyPort(localPort, nameof(localPort));
+ ValidatePort(proxyLocalPort, nameof(browserProxyLocalPort));
}
var sb = new StringBuilder();
sb.Append(BaseOptions);
AppendLocalForward(sb, localPort, remotePort);
if (includeBrowserProxyForward)
- AppendLocalForward(sb, localPort + 2, remotePort + 2);
+ AppendLocalForward(sb, proxyLocalPort, remotePort + 2);
if (sshPort != 22)
{
sb.Append("-p ");
diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs
index 1074a4b27..7b8e41838 100644
--- a/src/OpenClaw.Tray.WinUI/App.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs
@@ -3950,12 +3950,26 @@ private bool TryResolveChatCredentials(
out string gatewayUrl,
out string token,
out string credentialSource,
- out bool isBootstrapToken)
+ out bool isBootstrapToken) =>
+ TryResolveChatCredentials(
+ out gatewayUrl,
+ out token,
+ out credentialSource,
+ out isBootstrapToken,
+ out _);
+
+ private bool TryResolveChatCredentials(
+ out string gatewayUrl,
+ out string token,
+ out string credentialSource,
+ out bool isBootstrapToken,
+ out string? gatewayId)
{
gatewayUrl = string.Empty;
token = string.Empty;
credentialSource = "none";
isBootstrapToken = false;
+ gatewayId = null;
if (_settings == null)
return false;
@@ -3979,6 +3993,7 @@ private bool TryResolveChatCredentials(
token = credential.Token;
credentialSource = credential.Source;
isBootstrapToken = credential.IsBootstrapToken;
+ gatewayId = credential.GatewayId;
return true;
}
@@ -4004,24 +4019,202 @@ private void OpenNativeSetupCompletion(string handle, bool explicitRetry = false
}, new AppLogger(), nameof(OpenNativeSetupCompletion));
}
+ private enum DashboardLaunchReadiness
+ {
+ Ready,
+ FailedVisible,
+ FailedReport,
+ }
+
+ private sealed record DashboardLaunchPreparation(
+ InteractiveGatewayCredential Credential,
+ DashboardGatewayTunnelSnapshot Snapshot,
+ SettingsOwnedForwardBinding? OwnedForward);
+
private void OpenDashboard(string? path = null)
{
AsyncEventHandlerGuard.Run(async () =>
{
+ DashboardLaunchPreparation? prepared = null;
+ var readiness = DashboardLaunchReadiness.FailedReport;
var launcher = new GatewayDashboardLauncher(
- EnsureSshTunnelConfigured,
- () => TryResolveChatCredentials(out var url, out var token, out var source, out var bootstrap)
- ? new InteractiveGatewayCredential(url, token, bootstrap, source) : null,
- async url => await global::Windows.System.Launcher.LaunchUriAsync(new Uri(url)),
- () => AsyncEventHandlerGuard.Run(
- () => _windowManager?.ShowDashboardLaunchFailureAsync(
- () => OpenDashboard(path))
- ?? Task.CompletedTask, new AppLogger(), "Dashboard launch error"),
- () => _appNotificationService?.Dismiss(GatewayDashboardLauncher.FailureNotificationId));
+ async () =>
+ {
+ (readiness, prepared) = await PrepareDashboardLaunchAsync();
+ return readiness == DashboardLaunchReadiness.Ready;
+ },
+ () => prepared?.Credential,
+ url => LaunchDashboardBrowserAsync(url, prepared),
+ () =>
+ {
+ if (readiness == DashboardLaunchReadiness.FailedVisible)
+ return;
+ AsyncEventHandlerGuard.Run(
+ () => _windowManager?.ShowDashboardLaunchFailureAsync(
+ () => OpenDashboard(path)) ?? Task.CompletedTask,
+ new AppLogger(),
+ "Dashboard launch error");
+ },
+ () => _appNotificationService?.Dismiss(GatewayDashboardLauncher.FailureNotificationId),
+ () => prepared?.OwnedForward?.LocalPort);
await launcher.OpenAsync(path);
}, new AppLogger(), nameof(OpenDashboard));
}
+ private async Task<(DashboardLaunchReadiness Readiness, DashboardLaunchPreparation? Prepared)> PrepareDashboardLaunchAsync()
+ {
+ if (_settings == null)
+ return (DashboardLaunchReadiness.FailedVisible, null);
+
+ var capturedTunnel = _settings.UseSshTunnel ? CreateDashboardSshTunnelOrNull() : null;
+ var hadCredentials = TryCaptureDashboardGatewaySnapshot(capturedTunnel, out var before);
+ var sshGate = await EnsureDashboardSshForwardOwnedAsync(capturedTunnel);
+ if (!sshGate.Allowed)
+ return (DashboardLaunchReadiness.FailedReport, null);
+
+ if (!hadCredentials || before is null)
+ {
+ ShowConnectionSettingsForPairingIssue(
+ "Dashboard",
+ "Gateway URL or credential is not configured");
+ return (DashboardLaunchReadiness.FailedVisible, null);
+ }
+
+ var tunnelNow = _settings.UseSshTunnel ? CreateDashboardSshTunnelOrNull() : null;
+ if (!TryCaptureDashboardGatewaySnapshot(tunnelNow, out var after) || after is null)
+ {
+ ShowConnectionSettingsForPairingIssue(
+ "Dashboard",
+ "Gateway URL or credential is not configured");
+ return (DashboardLaunchReadiness.FailedVisible, null);
+ }
+
+ if (!DashboardBrowserHandoff.SameBinding(before, after))
+ {
+ _toastService?.ShowToast(new ToastContentBuilder()
+ .AddText("SSH tunnel")
+ .AddText("SSH tunnel changed before the dashboard could open."));
+ return (DashboardLaunchReadiness.FailedVisible, null);
+ }
+
+ SettingsOwnedForwardBinding? ownedForward = sshGate.Generation is long generation
+ ? new SettingsOwnedForwardBinding(true, generation, sshGate.LocalPort, sshGate.ProcessId)
+ : null;
+ return (DashboardLaunchReadiness.Ready, new DashboardLaunchPreparation(
+ new InteractiveGatewayCredential(
+ after.GatewayUrl,
+ after.Token,
+ after.IsBootstrapToken,
+ after.Source),
+ after,
+ ownedForward));
+ }
+
+ private Task LaunchDashboardBrowserAsync(string url, DashboardLaunchPreparation? prepared)
+ {
+ if (prepared is null)
+ return Task.FromResult(false);
+
+ if (prepared.OwnedForward is not { } owned)
+ return Task.FromResult(DashboardBrowserShell.TryOpen(url, out _));
+
+ if (!DashboardBrowserHandoff.UrlUsesCapturedForward(url, prepared.Snapshot))
+ return Task.FromResult(false);
+
+ if (_sshTunnelService is not SshTunnelService tunnel ||
+ !tunnel.TryEnterBrowserHandoff(owned.Generation, owned.LocalPort, owned.ProcessId, out var handoffId))
+ {
+ return Task.FromResult(false);
+ }
+
+ if (tunnel.HasDeferredStop || !tunnel.TryBeginDashboardNavigation(handoffId))
+ {
+ tunnel.ExitBrowserHandoff(handoffId);
+ return Task.FromResult(false);
+ }
+
+ var opened = DashboardBrowserShell.TryOpen(url, out var browserProcessId);
+ if (!tunnel.CompleteDashboardNavigation(handoffId, opened, browserProcessId))
+ return Task.FromResult(false);
+
+ var localPort = owned.LocalPort;
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ while (tunnel.IsBrowserHandoffOpen(handoffId))
+ {
+ var settled = await tunnel.WatchBrowserHandoffConsumptionAsync(
+ handoffId,
+ localPort,
+ TimeSpan.FromSeconds(60),
+ consumptionProbe: null).ConfigureAwait(false);
+ if (settled)
+ return;
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"Dashboard browser handoff watch failed: {ex.GetType().Name}");
+ if (!tunnel.IsRunning)
+ tunnel.ExitBrowserHandoff(handoffId);
+ }
+ });
+
+ return Task.FromResult(true);
+ }
+
+ private SshTunnelConfig? CreateDashboardSshTunnelOrNull()
+ {
+ if (_settings == null ||
+ string.IsNullOrWhiteSpace(_settings.SshTunnelUser) ||
+ string.IsNullOrWhiteSpace(_settings.SshTunnelHost) ||
+ _settings.SshTunnelRemotePort is < 1 or > 65535 ||
+ _settings.SshTunnelLocalPort is < 1 or > 65535)
+ {
+ return null;
+ }
+
+ var includeBrowserProxy = BrowserProxySshTunnelForwardPolicy.ShouldInclude(
+ _settings.NodeBrowserProxyEnabled,
+ _settings.SshTunnelRemotePort,
+ _settings.SshTunnelLocalPort);
+ return new SshTunnelConfig(
+ _settings.SshTunnelUser,
+ _settings.SshTunnelHost,
+ _settings.SshTunnelRemotePort,
+ _settings.SshTunnelLocalPort,
+ includeBrowserProxy,
+ _settings.SshTunnelSshPort);
+ }
+
+ private bool TryCaptureDashboardGatewaySnapshot(
+ SshTunnelConfig? tunnel,
+ out DashboardGatewayTunnelSnapshot? snapshot)
+ {
+ snapshot = null;
+ if (!TryResolveChatCredentials(
+ out var gatewayUrl,
+ out var token,
+ out var credentialSource,
+ out var isBootstrapToken,
+ out var gatewayId))
+ {
+ return false;
+ }
+
+ var identity = string.IsNullOrWhiteSpace(gatewayId) ? gatewayUrl : gatewayId;
+ snapshot = new DashboardGatewayTunnelSnapshot(
+ identity,
+ gatewayUrl,
+ token,
+ !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken,
+ tunnel,
+ isBootstrapToken,
+ credentialSource);
+ return true;
+ }
+
// ── IAppCommands implementation ─────────────────────────────────────
void IAppCommands.OpenDashboard(string? path) => OpenDashboard(path);
@@ -4342,60 +4535,54 @@ private void SetChatSpeakerMuted(bool muted, SettingsWriteOrigin? origin)
#endregion
- private bool EnsureSshTunnelConfigured()
+ private async Task<(bool Allowed, long? Generation, int LocalPort, int ProcessId)> EnsureDashboardSshForwardOwnedAsync(
+ SshTunnelConfig? capturedTunnel)
{
if (_settings == null)
{
- return false;
+ return (false, null, 0, 0);
}
- if (_settings.UseSshTunnel)
+ if (!_settings.UseSshTunnel)
{
- if (string.IsNullOrWhiteSpace(_settings.SshTunnelUser) ||
- string.IsNullOrWhiteSpace(_settings.SshTunnelHost) ||
- _settings.SshTunnelRemotePort is < 1 or > 65535 ||
- _settings.SshTunnelLocalPort is < 1 or > 65535)
- {
- Logger.Warn("SSH tunnel is enabled but settings are incomplete");
- UpdateTrayIcon();
- return false;
- }
+ _sshTunnelService?.Stop();
+ return (true, null, 0, 0);
+ }
- try
+ if (capturedTunnel is null)
+ {
+ Logger.Warn("SSH tunnel is enabled but settings are incomplete");
+ UpdateTrayIcon();
+ return (false, null, 0, 0);
+ }
+
+ try
+ {
+ _sshTunnelService ??= new SshTunnelService(new AppLogger());
+ var owned = await _sshTunnelService.EnsureSettingsOwnedForwardReadyAsync(
+ capturedTunnel,
+ CancellationToken.None);
+ DiagnosticsJsonlService.Write("tunnel.ensure_started", new
{
- _sshTunnelService ??= new SshTunnelService(new AppLogger());
- var includeBrowserProxy = BrowserProxySshTunnelForwardPolicy.ShouldInclude(
- _settings.NodeBrowserProxyEnabled,
- _settings.SshTunnelRemotePort,
- _settings.SshTunnelLocalPort);
- _sshTunnelService.EnsureStarted(
- _settings.SshTunnelUser,
- _settings.SshTunnelHost,
- _settings.SshTunnelRemotePort,
- _settings.SshTunnelLocalPort,
- includeBrowserProxy,
- _settings.SshTunnelSshPort);
- DiagnosticsJsonlService.Write("tunnel.ensure_started", new
- {
- status = _sshTunnelService.Status.ToString(),
- localEndpoint = $"127.0.0.1:{_settings.SshTunnelLocalPort}",
- remoteHost = string.IsNullOrWhiteSpace(_settings.SshTunnelHost) ? null : _settings.SshTunnelHost,
- remotePort = _settings.SshTunnelRemotePort
- });
- }
- catch (Exception ex)
+ status = _sshTunnelService.Status.ToString(),
+ localEndpoint = $"127.0.0.1:{capturedTunnel.LocalPort}",
+ remoteHost = string.IsNullOrWhiteSpace(capturedTunnel.Host) ? null : capturedTunnel.Host,
+ remotePort = capturedTunnel.RemotePort
+ });
+ if (!owned.Owned)
{
- Logger.Error($"Failed to start SSH tunnel: {ex.Message}");
UpdateTrayIcon();
- return false;
+ return (false, null, 0, 0);
}
+
+ return (true, owned.Generation, owned.LocalPort, owned.ProcessId);
}
- else
+ catch (Exception ex)
{
- _sshTunnelService?.Stop();
+ Logger.Error($"Failed to start SSH tunnel: {ex.Message}");
+ UpdateTrayIcon();
+ return (false, null, 0, 0);
}
-
- return true;
}
private void OnSshTunnelExited(object? sender, SshTunnelExit tunnelExit) =>
diff --git a/src/OpenClaw.Tray.WinUI/Services/DashboardBrowserShell.cs b/src/OpenClaw.Tray.WinUI/Services/DashboardBrowserShell.cs
new file mode 100644
index 000000000..3596aa022
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Services/DashboardBrowserShell.cs
@@ -0,0 +1,62 @@
+using System.ComponentModel;
+using System.Diagnostics;
+
+namespace OpenClawTray.Services;
+
+///
+/// Opens a dashboard URL with the shell and returns a browser process id when Windows provides one.
+/// Launch failures are not logged: the exception text can contain the credential URL.
+///
+internal static class DashboardBrowserShell
+{
+ internal static bool TryOpen(string url, out int? processId)
+ {
+ processId = null;
+ Process? process = null;
+ try
+ {
+ process = Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
+ return InterpretStartedProcess(process, out processId);
+ }
+ catch (Exception)
+ {
+ processId = null;
+ return false;
+ }
+ finally
+ {
+ try
+ {
+ process?.Dispose();
+ }
+ catch (Exception ex)
+ {
+ Trace.WriteLine($"Dashboard browser handle release failed: {ex.GetType().Name}");
+ }
+ }
+ }
+
+ internal static bool InterpretStartedProcess(Process? process, out int? processId)
+ {
+ processId = null;
+ if (process is null)
+ return true;
+
+ processId = TryReadProcessId(process);
+ return true;
+ }
+
+ private static int? TryReadProcessId(Process process)
+ {
+ try
+ {
+ if (process.HasExited)
+ return null;
+ return process.Id > 0 ? process.Id : null;
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or Win32Exception)
+ {
+ return null;
+ }
+ }
+}
diff --git a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs
index e7cfae2d3..87172324d 100644
--- a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs
@@ -7,22 +7,26 @@ namespace OpenClawTray.Services;
/// Uses the normal Dashboard credential path without setup completion authority.
internal sealed class GatewayDashboardLauncher(
- Func ensureTunnel,
+ Func> ensureTunnel,
Func resolveCredential,
Func> launchBrowser,
Action reportFailure,
- Action? reportOpened = null)
+ Action? reportOpened = null,
+ Func? ownedLocalForwardPort = null)
{
internal const string FailureNotificationId = "setup-dashboard-launch";
public async Task OpenAsync(string? path = null)
{
try
{
- if (!ensureTunnel())
+ if (!await ensureTunnel())
throw new InvalidOperationException("The Gateway tunnel is unavailable.");
var credential = resolveCredential()
?? throw new InvalidOperationException("The Gateway credential is unavailable.");
- var url = GatewayDashboardUrlBuilder.Build(credential.GatewayUrl, path, credential.Token,
+ var gatewayUrl = ownedLocalForwardPort?.Invoke() is int localPort
+ ? DashboardBrowserHandoff.ProjectOntoLocalForward(credential.GatewayUrl, localPort)
+ : credential.GatewayUrl;
+ var url = GatewayDashboardUrlBuilder.Build(gatewayUrl, path, credential.Token,
!credential.IsBootstrapToken && credential.Source == CredentialResolver.SourceSharedGatewayToken);
if (!await launchBrowser(url))
throw new InvalidOperationException("Windows did not open the Dashboard.");
diff --git a/tests/OpenClaw.Connection.Tests/DashboardBrowserHandoffTests.cs b/tests/OpenClaw.Connection.Tests/DashboardBrowserHandoffTests.cs
new file mode 100644
index 000000000..97d6bc589
--- /dev/null
+++ b/tests/OpenClaw.Connection.Tests/DashboardBrowserHandoffTests.cs
@@ -0,0 +1,101 @@
+namespace OpenClaw.Connection.Tests;
+
+public sealed class DashboardBrowserHandoffTests
+{
+ private static SshTunnelConfig Tunnel(int localPort = 18789) =>
+ new("user", "gateway.example", 22, localPort, false, 22);
+
+ [Fact]
+ public void SameBinding_AllowsTokenRotationOnTheSameGatewayAndDestination()
+ {
+ var tunnel = Tunnel();
+ var before = new DashboardGatewayTunnelSnapshot(
+ "gw-a",
+ "ws://127.0.0.1:18789",
+ "old-token",
+ true,
+ tunnel);
+ var after = before with { Token = "new-token" };
+
+ Assert.True(DashboardBrowserHandoff.SameBinding(before, after));
+ }
+
+ [Fact]
+ public void SameBinding_RejectsGatewaySwitchOntoTheSameLocalPort()
+ {
+ var tunnel = Tunnel();
+ var before = new DashboardGatewayTunnelSnapshot(
+ "gw-a",
+ "ws://127.0.0.1:18789",
+ "token-a",
+ true,
+ tunnel);
+ var after = new DashboardGatewayTunnelSnapshot(
+ "gw-b",
+ "ws://127.0.0.1:18789",
+ "token-b",
+ true,
+ tunnel);
+
+ Assert.False(DashboardBrowserHandoff.SameBinding(before, after));
+ Assert.True(DashboardBrowserHandoff.SameTunnelDestination(before.Tunnel, after.Tunnel));
+ }
+
+ [Fact]
+ public void SameBinding_RejectsDestinationChangeWhenLocalPortStays()
+ {
+ var before = new DashboardGatewayTunnelSnapshot(
+ "gw-a",
+ "ws://127.0.0.1:18789",
+ "token-a",
+ true,
+ Tunnel());
+ var after = before with { Tunnel = Tunnel() with { Host = "other.example" } };
+
+ Assert.False(DashboardBrowserHandoff.SameBinding(before, after));
+ }
+
+ [Fact]
+ public void ProjectOntoLocalForward_KeepsTheSavedMountPathAndFragment()
+ {
+ var tunnel = new SshTunnelConfig("user", "gateway.example", 18789, 19001);
+ var saved = new DashboardGatewayTunnelSnapshot(
+ "gateway-a",
+ "wss://gateway.example/mount/#token=old&view=compact",
+ "token-a",
+ true,
+ tunnel);
+
+ var browserUrl = DashboardBrowserHandoff.ProjectOntoLocalForward(saved.GatewayUrl, tunnel.LocalPort);
+
+ Assert.Equal("https://127.0.0.1:19001/mount/#token=old&view=compact", browserUrl);
+ Assert.True(DashboardBrowserHandoff.UrlUsesCapturedForward(
+ "https://127.0.0.1:19001/mount/custodian#token=token-a",
+ saved));
+ }
+
+ [Fact]
+ public void UrlUsesCapturedForward_RequiresLoopbackPortAndCapturedToken()
+ {
+ var captured = new DashboardGatewayTunnelSnapshot(
+ "gw-a",
+ "ws://127.0.0.1:18789",
+ "token a",
+ true,
+ Tunnel());
+ var token = Uri.EscapeDataString(captured.Token);
+
+ Assert.True(DashboardBrowserHandoff.UrlUsesCapturedForward(
+ $"http://127.0.0.1:18789/#token={token}",
+ captured));
+ Assert.False(DashboardBrowserHandoff.UrlUsesCapturedForward(
+ "http://127.0.0.1:18789/#token=other",
+ captured));
+ Assert.False(DashboardBrowserHandoff.UrlUsesCapturedForward(
+ $"http://127.0.0.1:9/#token={token}",
+ captured));
+ Assert.False(DashboardBrowserHandoff.UrlUsesCapturedForward(
+ $"https://gateway.example/#token={token}",
+ captured));
+ }
+}
diff --git a/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs b/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs
index 5511d22c9..aeb6a8f6a 100644
--- a/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs
+++ b/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs
@@ -1,3 +1,6 @@
+using System.Net;
+using System.Net.Sockets;
+
namespace OpenClaw.Connection.Tests;
public sealed class GatewayClientEndpointResolverTests
@@ -32,6 +35,31 @@ public void Resolve_UsesLocalForwardForTunnelBackedRecord()
Assert.Equal("ws://localhost:45678", GatewayClientEndpointResolver.Resolve(record));
}
+ [Fact]
+ public void Resolve_PinsHeldDashboardPortToIpv4Loopback()
+ {
+ using var probe = new TcpListener(IPAddress.Loopback, 0);
+ probe.Start();
+ var port = ((IPEndPoint)probe.LocalEndpoint).Port;
+ probe.Stop();
+ DashboardForwardPortGuard.Hold(port);
+ try
+ {
+ var record = new GatewayRecord
+ {
+ Id = "held-forward",
+ Url = "ws://remote.internal:18789",
+ SshTunnel = new SshTunnelConfig("user", "remote.internal", 18789, port),
+ };
+
+ Assert.Equal($"ws://127.0.0.1:{port}", GatewayClientEndpointResolver.Resolve(record));
+ }
+ finally
+ {
+ DashboardForwardPortGuard.Release(port);
+ }
+ }
+
[Theory]
[InlineData(0)]
[InlineData(65536)]
diff --git a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs
index 0125d270e..e73334a3e 100644
--- a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs
+++ b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs
@@ -1,5 +1,8 @@
using OpenClaw.Shared;
+using System.Diagnostics;
using System.Net;
+using System.Net.Sockets;
+using System.Reflection;
namespace OpenClaw.Connection.Tests;
@@ -234,6 +237,114 @@ await Assert.ThrowsAnyAsync(
}
}
+ [Fact]
+ public void AllowsDestination_PinsTheOriginalGateway()
+ {
+ const int port = 45681;
+ DashboardForwardPortGuard.Release(port);
+ DashboardForwardPortGuard.Hold(port);
+ try
+ {
+ Assert.True(DashboardForwardPortGuard.AllowsDestination(port, "user", "gateway.example", 18789, 22));
+ Assert.False(DashboardForwardPortGuard.AllowsDestination(port, "user", "other.example", 18789, 22));
+ Assert.False(DashboardForwardPortGuard.AllowsDestination(port, "user", "gateway.example", 18790, 22));
+ Assert.False(DashboardForwardPortGuard.AllowsDestination(port, "user", "gateway.example", 18789, 2222));
+ }
+ finally
+ {
+ DashboardForwardPortGuard.Release(port);
+ }
+ }
+
+ [Fact]
+ public void DashboardRestartOwner_PreservesManagerOwnership()
+ {
+ Assert.Equal(
+ SshTunnelOwner.GatewayConnectionManager,
+ SshTunnelService.DashboardRestartOwner(SshTunnelOwner.GatewayConnectionManager, runningOnRequestedPort: true));
+ Assert.Equal(
+ SshTunnelOwner.Settings,
+ SshTunnelService.DashboardRestartOwner(SshTunnelOwner.GatewayConnectionManager, runningOnRequestedPort: false));
+ Assert.Equal(
+ SshTunnelOwner.Settings,
+ SshTunnelService.DashboardRestartOwner(SshTunnelOwner.Settings, runningOnRequestedPort: true));
+ }
+
+ [Fact]
+ public void Hold_ReservesBothLoopbackRoutes()
+ {
+ using var probe = new TcpListener(IPAddress.Loopback, 0);
+ probe.Start();
+ var port = ((IPEndPoint)probe.LocalEndpoint).Port;
+ probe.Stop();
+ DashboardForwardPortGuard.Hold(port);
+ try
+ {
+ using var ipv4 = new TcpListener(IPAddress.Loopback, port);
+ Assert.ThrowsAny(() => ipv4.Start());
+ if (Socket.OSSupportsIPv6)
+ {
+ var ipv6 = new TcpListener(IPAddress.IPv6Loopback, port);
+ ipv6.Server.DualMode = false;
+ Assert.ThrowsAny(() => ipv6.Start());
+ }
+
+ Assert.Equal("127.0.0.1", SshTunnelService.LoopbackWebSocketHost(port));
+ var confirmed = false;
+ for (var attempt = 0; attempt < 20 && !confirmed; attempt++)
+ {
+ confirmed = SshTunnelService.TryConfirmRetainedPublicRoute(port);
+ if (!confirmed)
+ Thread.Sleep(50);
+ }
+
+ Assert.True(confirmed);
+ }
+ finally
+ {
+ DashboardForwardPortGuard.Release(port);
+ }
+ }
+
+ [Fact]
+ public async Task EnsureSettingsOwnedForwardReadyAsync_ReleasesGuardWhenStartupFails()
+ {
+ using var probe = new TcpListener(IPAddress.Loopback, 0);
+ probe.Start();
+ var port = ((IPEndPoint)probe.LocalEndpoint).Port;
+ probe.Stop();
+ using var service = new SshTunnelService(NullLogger.Instance);
+ try
+ {
+ var ready = await service.EnsureSettingsOwnedForwardReadyAsync(
+ new SshTunnelConfig("user", "host", 18789, port, false, 0),
+ CancellationToken.None);
+
+ Assert.False(ready.Owned);
+ Assert.False(DashboardForwardPortGuard.IsHolding(port));
+ }
+ finally
+ {
+ DashboardForwardPortGuard.Release(port);
+ }
+ }
+
+ [Fact]
+ public void RejectForeignForwardPort_AllowsTheDashboardGuard()
+ {
+ const int port = 45679;
+ DashboardForwardPortGuard.Release(port);
+ DashboardForwardPortGuard.Hold(port);
+ try
+ {
+ SshTunnelService.RejectForeignForwardPort(port);
+ }
+ finally
+ {
+ DashboardForwardPortGuard.Release(port);
+ }
+ }
+
[Fact]
public void EnsurePortIsUnoccupied_RejectsExistingListener()
{
@@ -337,6 +448,51 @@ public void ValidateListenerOwnership_AcceptsOnlyExactLaunchedProcess()
() => SshTunnelService.ValidateListenerOwnership(unrelated, 45678, 4321, startedAt));
}
+ [Fact]
+ public async Task EnsureSettingsOwnedForwardReadyAsync_RejectsForeignLocalListener()
+ {
+ using var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ using var service = new SshTunnelService(NullLogger.Instance);
+
+ var ready = await service.EnsureSettingsOwnedForwardReadyAsync(
+ new SshTunnelConfig("user", "host", 18789, port),
+ CancellationToken.None);
+
+ Assert.False(ready.Owned);
+ Assert.False(service.IsRunning);
+ Assert.NotEqual(TunnelStatus.Up, service.Status);
+ Assert.Contains(port.ToString(), service.LastError);
+ Assert.Contains("already owned", service.LastError);
+ Assert.False(service.IsSettingsOwnedForwardCurrent(service.OwnershipGeneration, port));
+ }
+
+ [Fact]
+ public async Task EnsureSettingsOwnedForwardReadyAsync_RejectsForeignBrowserProxyListener()
+ {
+ using var proxy = new TcpListener(IPAddress.Loopback, 0);
+ proxy.Start();
+ var proxyPort = ((IPEndPoint)proxy.LocalEndpoint).Port;
+ var localPort = proxyPort - 2;
+ using var service = new SshTunnelService(NullLogger.Instance);
+
+ var ready = await service.EnsureSettingsOwnedForwardReadyAsync(
+ new SshTunnelConfig(
+ "user",
+ "host",
+ 18789,
+ localPort,
+ IncludeBrowserProxyForward: true),
+ CancellationToken.None);
+
+ Assert.False(ready.Owned);
+ Assert.False(service.IsRunning);
+ Assert.NotEqual(TunnelStatus.Up, service.Status);
+ Assert.NotNull(service.LastError);
+ Assert.Contains("already owned", service.LastError);
+ }
+
[Fact]
public void ListenerOwnershipChecks_FailClosedWhenSnapshotIsIncomplete()
{
@@ -354,4 +510,511 @@ public void ListenerOwnershipChecks_FailClosedWhenSnapshotIsIncomplete()
4321,
DateTime.UtcNow));
}
+
+ [Fact]
+ public void EnsureStarted_WhileBrowserHandoffLeaseHeld_DoesNotReplaceTrackedProcess()
+ {
+ using var service = new SshTunnelService(NullLogger.Instance);
+ var config = new SshTunnelConfig("user", "host", 18789, 45678);
+ using var process = PlantRunningTunnel(
+ service,
+ config,
+ SshTunnelOwner.Settings,
+ generation: 4);
+ Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort));
+
+ try
+ {
+ var ex = Assert.Throws(
+ () => service.EnsureStarted("user", "host", 18789, 45679));
+
+ Assert.Equal("SSH tunnel is held for a dashboard launch.", ex.Message);
+ Assert.Same(process, TrackedProcess(service));
+ Assert.Equal(4, service.OwnershipGeneration);
+ Assert.True(service.IsRunning);
+ Assert.Equal(TunnelStatus.Up, service.Status);
+ Assert.Equal(config, service.ActiveConfig);
+ }
+ finally
+ {
+ service.ExitBrowserHandoff();
+ }
+ }
+
+ [Fact]
+ public async Task StopIfOwnedAsync_DuringBrowserHandoff_CancelsTheHoldAndStops()
+ {
+ using var service = new SshTunnelService(NullLogger.Instance);
+ var config = new SshTunnelConfig("user", "host", 18789, 45678);
+ using var process = PlantRunningTunnel(
+ service,
+ config,
+ SshTunnelOwner.GatewayConnectionManager,
+ generation: 4);
+ Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort));
+
+ var stopped = await service.StopIfOwnedAsync(config, ownershipGeneration: 4, CancellationToken.None);
+
+ Assert.True(stopped);
+ Assert.Equal(0, service.BrowserHandoffLeaseCount);
+ Assert.False(service.IsBrowserHandoffOpen(1));
+ Assert.Null(TrackedProcess(service));
+ Assert.False(service.IsRunning);
+ Assert.Equal(TunnelStatus.Stopped, service.Status);
+ Assert.Throws(() => process.HasExited);
+ }
+
+ [Fact]
+ public void ResetNotConfigured_DuringBrowserHandoff_CancelsTheHoldImmediately()
+ {
+ using var service = new SshTunnelService(NullLogger.Instance);
+ var config = new SshTunnelConfig("user", "host", 18789, 45678);
+ using var process = PlantRunningTunnel(
+ service,
+ config,
+ SshTunnelOwner.Settings,
+ generation: 4);
+ SetPrivate(service, "k__BackingField", "tunnel still up");
+ Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort));
+ Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort));
+
+ service.ResetNotConfigured();
+
+ Assert.Equal(0, service.BrowserHandoffLeaseCount);
+ Assert.Null(TrackedProcess(service));
+ Assert.Throws(() => process.HasExited);
+ Assert.Equal(TunnelStatus.NotConfigured, service.Status);
+ Assert.Null(service.LastError);
+ Assert.False(service.IsRunning);
+ }
+
+ [Fact]
+ public void Stop_AfterNavigationSubmitted_StopsSshAndKeepsThePort()
+ {
+ var port = 45678;
+ DashboardForwardPortGuard.Release(port);
+ using (var service = new SshTunnelService(NullLogger.Instance))
+ {
+ var config = new SshTunnelConfig("user", "host", 18789, port);
+ using var process = PlantRunningTunnel(
+ service,
+ config,
+ SshTunnelOwner.Settings,
+ generation: 4);
+ Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort, process.Id, out var handoffId));
+ Assert.True(service.TryBeginDashboardNavigation(handoffId));
+ Assert.True(service.CompleteDashboardNavigation(handoffId, opened: true, processId: null));
+
+ service.Stop();
+
+ Assert.False(service.HasDeferredStop);
+ Assert.Equal(0, service.BrowserHandoffLeaseCount);
+ Assert.False(service.IsRunning);
+ Assert.True(DashboardForwardPortGuard.IsHolding(port));
+ Assert.Throws(() => process.HasExited);
+
+ service.Stop();
+
+ Assert.True(DashboardForwardPortGuard.IsHolding(port));
+ }
+
+ DashboardForwardPortGuard.Release(port);
+ }
+
+ [Fact]
+ public async Task Watch_DoesNotReleaseWhenTheActivationProcessHasExited()
+ {
+ var service = new SshTunnelService(NullLogger.Instance);
+ var config = new SshTunnelConfig("user", "host", 18789, 45678);
+ DashboardForwardPortGuard.Release(config.LocalPort);
+ try
+ {
+ using var process = PlantRunningTunnel(
+ service,
+ config,
+ SshTunnelOwner.Settings,
+ generation: 4);
+ using var shell = Process.Start(new ProcessStartInfo("cmd.exe", "/c exit")
+ {
+ CreateNoWindow = true,
+ UseShellExecute = false,
+ });
+ Assert.NotNull(shell);
+ Assert.True(shell.WaitForExit(3_000));
+ Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort, process.Id, out var handoffId));
+ Assert.True(service.TryBeginDashboardNavigation(handoffId));
+ Assert.True(service.CompleteDashboardNavigation(handoffId, opened: true, processId: shell.Id));
+
+ var settled = await service.WatchBrowserHandoffConsumptionAsync(
+ handoffId,
+ config.LocalPort,
+ TimeSpan.Zero,
+ consumptionProbe: null);
+
+ Assert.False(settled);
+ Assert.Equal(1, service.BrowserHandoffLeaseCount);
+ Assert.True(service.IsRunning);
+ Assert.False(process.HasExited);
+ service.ExitBrowserHandoff(handoffId);
+ }
+ finally
+ {
+ service.Dispose();
+ DashboardForwardPortGuard.Release(config.LocalPort);
+ }
+ }
+
+ [Fact]
+ public async Task Watch_SubmittedNavigation_KeepsThePortWhenSshExits()
+ {
+ var service = new SshTunnelService(NullLogger.Instance);
+ var config = new SshTunnelConfig("user", "host", 18789, 45678);
+ DashboardForwardPortGuard.Release(config.LocalPort);
+ try
+ {
+ using var process = PlantRunningTunnel(
+ service,
+ config,
+ SshTunnelOwner.Settings,
+ generation: 4);
+ Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort, process.Id, out var handoffId));
+ Assert.True(service.TryBeginDashboardNavigation(handoffId));
+ Assert.True(service.CompleteDashboardNavigation(handoffId, opened: true, processId: null));
+ process.Kill(entireProcessTree: true);
+ Assert.True(process.WaitForExit(3_000));
+
+ var settled = await service.WatchBrowserHandoffConsumptionAsync(
+ handoffId,
+ config.LocalPort,
+ TimeSpan.Zero,
+ consumptionProbe: null);
+
+ Assert.True(settled);
+ Assert.False(service.IsBrowserHandoffOpen(handoffId));
+ Assert.Equal(0, service.BrowserHandoffLeaseCount);
+ Assert.False(service.HasDeferredStop);
+ Assert.True(DashboardForwardPortGuard.IsHolding(config.LocalPort));
+ Assert.False(DashboardForwardPortGuard.AllowsDestination(
+ config.LocalPort,
+ config.User,
+ config.Host,
+ config.RemotePort,
+ 2222));
+ service.Stop();
+ Assert.True(DashboardForwardPortGuard.IsHolding(config.LocalPort));
+ Assert.Equal(0, service.BrowserHandoffLeaseCount);
+ service.ExitBrowserHandoff(handoffId);
+ }
+ finally
+ {
+ service.Dispose();
+ DashboardForwardPortGuard.Release(config.LocalPort);
+ }
+ }
+
+ [Fact]
+ public void Stop_DuringBrowserHandoff_CancelsTheHoldAndStops()
+ {
+ using var service = new SshTunnelService(NullLogger.Instance);
+ var config = new SshTunnelConfig("user", "host", 18789, 45678);
+ using var process = PlantRunningTunnel(
+ service,
+ config,
+ SshTunnelOwner.Settings,
+ generation: 4);
+ Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort));
+
+ service.Stop();
+
+ Assert.Equal(0, service.BrowserHandoffLeaseCount);
+ Assert.False(service.HasDeferredStop);
+ Assert.Null(TrackedProcess(service));
+ Assert.Throws(() => process.HasExited);
+ Assert.Equal(TunnelStatus.Stopped, service.Status);
+ Assert.False(service.IsRunning);
+ }
+
+ [Fact]
+ public void StopDuringBrowserHandoff_DoesNotWaitForBrowserTraffic()
+ {
+ using var service = new SshTunnelService(NullLogger.Instance);
+ var config = new SshTunnelConfig("user", "host", 18789, 45678);
+ using var process = PlantRunningTunnel(
+ service,
+ config,
+ SshTunnelOwner.Settings,
+ generation: 4);
+ Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort));
+
+ service.Stop();
+
+ Assert.False(service.HasDeferredStop);
+ Assert.Equal(0, service.BrowserHandoffLeaseCount);
+ Assert.Null(TrackedProcess(service));
+ Assert.Throws(() => process.HasExited);
+ Assert.Equal(TunnelStatus.Stopped, service.Status);
+ }
+
+ [Fact]
+ public void ReleaseBrowserHandoff_AfterStop_FindsNoLease()
+ {
+ using var service = new SshTunnelService(NullLogger.Instance);
+ var config = new SshTunnelConfig("user", "host", 18789, 45678);
+ using var process = PlantRunningTunnel(
+ service,
+ config,
+ SshTunnelOwner.Settings,
+ generation: 4);
+ Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort));
+ service.Stop();
+
+ Assert.False(service.TryReleaseBrowserHandoffUnlessDeferred(config.LocalPort, out var watch));
+ Assert.False(watch);
+ Assert.Equal(0, service.BrowserHandoffLeaseCount);
+ Assert.False(service.HasDeferredStop);
+ Assert.Throws(() => process.HasExited);
+ Assert.False(service.IsRunning);
+ }
+
+ [Fact]
+ public void ReleaseBrowserHandoff_KeepsLeaseWhenNoStopIsPending()
+ {
+ using var service = new SshTunnelService(NullLogger.Instance);
+ var config = new SshTunnelConfig("user", "host", 18789, 45678);
+ using var process = PlantRunningTunnel(
+ service,
+ config,
+ SshTunnelOwner.Settings,
+ generation: 4);
+ Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort));
+
+ try
+ {
+ Assert.True(service.TryReleaseBrowserHandoffUnlessDeferred(config.LocalPort, out var watch));
+
+ Assert.True(watch);
+ Assert.Equal(1, service.BrowserHandoffLeaseCount);
+ Assert.False(service.HasDeferredStop);
+ Assert.False(process.HasExited);
+ Assert.True(service.IsRunning);
+ }
+ finally
+ {
+ if (service.BrowserHandoffLeaseCount > 0)
+ service.ExitBrowserHandoff();
+ if (!process.HasExited)
+ process.Kill(entireProcessTree: true);
+ }
+ }
+
+ [Fact]
+ public void TryEnterBrowserHandoff_RejectsReplacementProcess()
+ {
+ using var service = new SshTunnelService(NullLogger.Instance);
+ var config = new SshTunnelConfig("user", "host", 18789, 45678);
+ using var process = PlantRunningTunnel(
+ service,
+ config,
+ SshTunnelOwner.Settings,
+ generation: 4);
+ var otherProcessId = process.Id == 1 ? 2 : 1;
+
+ Assert.False(service.TryEnterBrowserHandoff(4, config.LocalPort, otherProcessId));
+ Assert.Equal(0, service.BrowserHandoffLeaseCount);
+
+ Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort, process.Id, out var handoffId));
+ Assert.Equal(1, service.BrowserHandoffLeaseCount);
+ service.NoteBrowserHandoffClient(handoffId, 88);
+ Assert.Equal(88, service.HandoffBrowserProcessId(handoffId));
+ service.NoteBrowserHandoffClient(handoffId, 0);
+ Assert.Equal(88, service.HandoffBrowserProcessId(handoffId));
+
+ service.ExitBrowserHandoff(handoffId);
+ Assert.Null(service.HandoffBrowserProcessId(handoffId));
+ if (!process.HasExited)
+ process.Kill(entireProcessTree: true);
+ }
+
+ [Fact]
+ public void BrowserHandoff_SecondOpenKeepsTheFirstBrowserIdentity()
+ {
+ using var service = new SshTunnelService(NullLogger.Instance);
+ var config = new SshTunnelConfig("user", "host", 18789, 45678);
+ using var process = PlantRunningTunnel(
+ service,
+ config,
+ SshTunnelOwner.Settings,
+ generation: 4);
+ Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort, process.Id, out var first));
+ Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort, process.Id, out var second));
+
+ service.NoteBrowserHandoffClient(first, 11);
+ service.NoteBrowserHandoffClient(second, 22);
+
+ Assert.Equal(11, service.HandoffBrowserProcessId(first));
+ Assert.Equal(22, service.HandoffBrowserProcessId(second));
+ Assert.Equal(2, service.BrowserHandoffLeaseCount);
+
+ service.ExitBrowserHandoff(second);
+ Assert.Equal(11, service.HandoffBrowserProcessId(first));
+ Assert.Equal(1, service.BrowserHandoffLeaseCount);
+ service.ExitBrowserHandoff(first);
+ if (!process.HasExited)
+ process.Kill(entireProcessTree: true);
+ }
+
+ [Fact]
+ public async Task WatchBrowserHandoff_TimesOutWithoutReleasingOwnedListener()
+ {
+ using var service = new SshTunnelService(NullLogger.Instance);
+ var config = new SshTunnelConfig("user", "host", 18789, 45678);
+ using var process = PlantRunningTunnel(
+ service,
+ config,
+ SshTunnelOwner.Settings,
+ generation: 4);
+ Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort));
+
+ try
+ {
+ var completed = await service.WatchBrowserHandoffConsumptionAsync(
+ config.LocalPort,
+ TimeSpan.Zero,
+ static _ => false);
+
+ Assert.False(completed);
+ Assert.False(process.HasExited);
+ Assert.Equal(1, service.BrowserHandoffLeaseCount);
+ Assert.False(service.HasDeferredStop);
+ Assert.True(service.IsRunning);
+ Assert.Equal(TunnelStatus.Up, service.Status);
+ }
+ finally
+ {
+ if (service.BrowserHandoffLeaseCount > 0)
+ service.ExitBrowserHandoff();
+ }
+ }
+
+ [Fact]
+ public async Task WatchBrowserHandoff_NullProbe_HoldsUntilTheSshProcessIsGone()
+ {
+ using var service = new SshTunnelService(NullLogger.Instance);
+ var config = new SshTunnelConfig("user", "host", 18789, 45678);
+ using var process = PlantRunningTunnel(
+ service,
+ config,
+ SshTunnelOwner.Settings,
+ generation: 4);
+ Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort, process.Id, out var handoffId));
+
+ var waiting = await service.WatchBrowserHandoffConsumptionAsync(
+ handoffId,
+ config.LocalPort,
+ TimeSpan.Zero,
+ consumptionProbe: null);
+ Assert.False(waiting);
+ Assert.Equal(1, service.BrowserHandoffLeaseCount);
+ Assert.False(service.HasDeferredStop);
+ Assert.True(service.IsRunning);
+
+ process.Kill(entireProcessTree: true);
+ Assert.True(process.WaitForExit(3_000));
+
+ var settled = await service.WatchBrowserHandoffConsumptionAsync(
+ handoffId,
+ config.LocalPort,
+ TimeSpan.FromSeconds(2),
+ consumptionProbe: null);
+ Assert.True(settled);
+ Assert.False(service.IsBrowserHandoffOpen(handoffId));
+ Assert.Equal(0, service.BrowserHandoffLeaseCount);
+ Assert.False(service.HasDeferredStop);
+ Assert.False(service.IsRunning);
+ }
+
+ [Fact]
+ public async Task WatchBrowserHandoff_ProbeCompletesDeferredStop()
+ {
+ using var service = new SshTunnelService(NullLogger.Instance);
+ var config = new SshTunnelConfig("user", "host", 18789, 45678);
+ using var process = PlantRunningTunnel(
+ service,
+ config,
+ SshTunnelOwner.Settings,
+ generation: 4);
+ Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort));
+ service.Stop();
+
+ try
+ {
+ var completed = await service.WatchBrowserHandoffConsumptionAsync(
+ config.LocalPort,
+ TimeSpan.FromSeconds(5),
+ static _ => true);
+
+ Assert.True(completed);
+ Assert.Equal(0, service.BrowserHandoffLeaseCount);
+ Assert.False(service.HasDeferredStop);
+ Assert.Null(TrackedProcess(service));
+ Assert.Throws(() => process.HasExited);
+ Assert.Equal(TunnelStatus.Stopped, service.Status);
+ }
+ finally
+ {
+ if (service.BrowserHandoffLeaseCount > 0)
+ service.ExitBrowserHandoff();
+ }
+ }
+
+ private static Process PlantRunningTunnel(
+ SshTunnelService service,
+ SshTunnelConfig config,
+ SshTunnelOwner owner,
+ long generation)
+ {
+ var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = "cmd.exe",
+ Arguments = "/d /c ping -n 30 127.0.0.1",
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ },
+ };
+ Assert.True(process.Start());
+ SetPrivate(service, "_process", process);
+ SetPrivate(service, "_processStarted", true);
+ SetPrivate(service, "_currentConfig", config);
+ SetPrivate(service, "_currentOwner", owner);
+ SetPrivate(
+ service,
+ "_lastSpec",
+ $"{config.User}@{config.Host}:{config.SshPort}:{config.LocalPort}:{config.RemotePort}:browserProxy={config.IncludeBrowserProxyForward}");
+ SetPrivate(service, "_lifecycleGeneration", generation);
+ SetPrivate(service, "k__BackingField", TunnelStatus.Up);
+ return process;
+ }
+
+ private static Process? TrackedProcess(SshTunnelService service) =>
+ (Process?)GetPrivate(service, "_process");
+
+ private static SshTunnelOwner CurrentOwner(SshTunnelService service) =>
+ (SshTunnelOwner)GetPrivate(service, "_currentOwner")!;
+
+ private static void SetPrivate(object target, string name, object? value) =>
+ PrivateField(name).SetValue(target, value);
+
+ private static object? GetPrivate(object target, string name) =>
+ PrivateField(name).GetValue(target);
+
+ private static FieldInfo PrivateField(string name)
+ {
+ var field = typeof(SshTunnelService).GetField(
+ name,
+ BindingFlags.Instance | BindingFlags.NonPublic);
+ Assert.NotNull(field);
+ return field;
+ }
}
diff --git a/tests/OpenClaw.Connection.Tests/WindowsTcpListenerSnapshotTests.cs b/tests/OpenClaw.Connection.Tests/WindowsTcpListenerSnapshotTests.cs
index 5304276ba..dc4c7883f 100644
--- a/tests/OpenClaw.Connection.Tests/WindowsTcpListenerSnapshotTests.cs
+++ b/tests/OpenClaw.Connection.Tests/WindowsTcpListenerSnapshotTests.cs
@@ -1,4 +1,5 @@
using System.Diagnostics;
+using System.Net;
namespace OpenClaw.Connection.Tests;
@@ -11,6 +12,104 @@ public void GetProcessCommandLine_InvalidPid_ReturnsNull()
Assert.Null(WindowsTcpListenerSnapshot.GetProcessCommandLine(-1));
}
+ [Fact]
+ public void IsEstablishedLoopbackForwardUse_MatchesBrowserOrAcceptedRowOnly()
+ {
+ var loopback = IPAddress.Loopback;
+ var remote = IPAddress.Parse("203.0.113.5");
+
+ Assert.False(WindowsTcpListenerSnapshot.IsEstablishedLoopbackForwardUse(
+ state: 2,
+ loopback,
+ localPort: 18789,
+ remote,
+ remotePort: 40000,
+ forwardPort: 18789));
+ Assert.True(WindowsTcpListenerSnapshot.IsEstablishedLoopbackForwardUse(
+ WindowsTcpListenerSnapshot.TcpStateEstablished,
+ loopback,
+ localPort: 18789,
+ remote,
+ remotePort: 40000,
+ forwardPort: 18789));
+ Assert.True(WindowsTcpListenerSnapshot.IsEstablishedLoopbackForwardUse(
+ WindowsTcpListenerSnapshot.TcpStateEstablished,
+ remote,
+ localPort: 40000,
+ loopback,
+ remotePort: 18789,
+ forwardPort: 18789));
+ Assert.False(WindowsTcpListenerSnapshot.IsEstablishedLoopbackForwardUse(
+ WindowsTcpListenerSnapshot.TcpStateEstablished,
+ remote,
+ localPort: 18789,
+ remote,
+ remotePort: 40000,
+ forwardPort: 18789));
+ }
+
+ [Fact]
+ public void IsUnseenEstablishedForwardUse_RequiresTheLaunchedBrowserClient()
+ {
+ var loopback = IPAddress.Loopback;
+ var client = IPAddress.Parse("203.0.113.8");
+ var seen = new HashSet(StringComparer.Ordinal)
+ {
+ WindowsTcpListenerSnapshot.EstablishedForwardKey(client, 40000, loopback, 18789, 88),
+ };
+
+ Assert.False(WindowsTcpListenerSnapshot.IsUnseenEstablishedForwardUse(
+ WindowsTcpListenerSnapshot.TcpStateEstablished,
+ client,
+ localPort: 40000,
+ loopback,
+ remotePort: 18789,
+ processId: 88,
+ forwardPort: 18789,
+ browserProcessId: 88,
+ seen));
+ Assert.True(WindowsTcpListenerSnapshot.IsUnseenEstablishedForwardUse(
+ WindowsTcpListenerSnapshot.TcpStateEstablished,
+ client,
+ localPort: 40001,
+ loopback,
+ remotePort: 18789,
+ processId: 88,
+ forwardPort: 18789,
+ browserProcessId: 88,
+ seen));
+ Assert.False(WindowsTcpListenerSnapshot.IsUnseenEstablishedForwardUse(
+ WindowsTcpListenerSnapshot.TcpStateEstablished,
+ client,
+ localPort: 40001,
+ loopback,
+ remotePort: 18789,
+ processId: 88,
+ forwardPort: 18789,
+ browserProcessId: 77,
+ seen));
+ Assert.False(WindowsTcpListenerSnapshot.IsUnseenEstablishedForwardUse(
+ WindowsTcpListenerSnapshot.TcpStateEstablished,
+ client,
+ localPort: 40001,
+ loopback,
+ remotePort: 18789,
+ processId: 88,
+ forwardPort: 18789,
+ browserProcessId: null,
+ seen));
+ Assert.False(WindowsTcpListenerSnapshot.IsUnseenEstablishedForwardUse(
+ WindowsTcpListenerSnapshot.TcpStateEstablished,
+ loopback,
+ localPort: 18789,
+ client,
+ remotePort: 40002,
+ processId: 88,
+ forwardPort: 18789,
+ browserProcessId: 88,
+ seen));
+ }
+
[Fact]
public async Task AwaitRedirectedOutput_ReturnsNullWhenStdoutNeverCloses()
{
diff --git a/tests/OpenClaw.Shared.Tests/ModelsTests.cs b/tests/OpenClaw.Shared.Tests/ModelsTests.cs
index af2c4ef08..196a2f640 100644
--- a/tests/OpenClaw.Shared.Tests/ModelsTests.cs
+++ b/tests/OpenClaw.Shared.Tests/ModelsTests.cs
@@ -225,6 +225,37 @@ public void BuildArguments_RejectsInvalidSshPort()
sshPort: 0));
}
+ [Fact]
+ public void BuildArguments_ValidatesTheExplicitProxyPort()
+ {
+ var args = SshTunnelCommandLine.BuildArguments(
+ "scott",
+ "mac-mini.local",
+ 18789,
+ 65534,
+ includeBrowserProxyForward: true,
+ sshPort: 22,
+ browserProxyLocalPort: 19002);
+
+ Assert.Contains("-L 65534:127.0.0.1:18789", args);
+ Assert.Contains("-L 19002:127.0.0.1:18791", args);
+ }
+
+ [Fact]
+ public void BuildArguments_AcceptsProxyPorts65534And65535()
+ {
+ var args = SshTunnelCommandLine.BuildArguments(
+ "scott",
+ "mac-mini.local",
+ 18789,
+ 65533,
+ includeBrowserProxyForward: true,
+ sshPort: 22);
+
+ Assert.Contains("-L 65533:127.0.0.1:18789", args);
+ Assert.Contains("-L 65535:127.0.0.1:18791", args);
+ }
+
[Fact]
public void BuildArguments_RejectsBrowserProxyForwardWhenPortPlusTwoOverflows()
{
diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs
index 65aac5279..cb0f6ce77 100644
--- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs
+++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs
@@ -473,12 +473,80 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure()
{
var source = ReadAppSources();
var method = ExtractMethod(source, "OpenDashboard");
+ var prepare = ExtractMethod(source, "PrepareDashboardLaunchAsync");
+ var launch = ExtractMethod(source, "LaunchDashboardBrowserAsync");
+ var launcher = File.ReadAllText(Path.Combine(
+ TestRepositoryPaths.GetRepositoryRoot(),
+ "src",
+ "OpenClaw.Tray.WinUI",
+ "Services",
+ "GatewayDashboardLauncher.cs"));
Assert.Contains("new GatewayDashboardLauncher(", method);
- Assert.Contains("EnsureSshTunnelConfigured,", method);
Assert.Contains("ShowDashboardLaunchFailureAsync", method);
+ Assert.Contains("Dismiss(GatewayDashboardLauncher.FailureNotificationId)", method);
+ Assert.Contains("await launcher.OpenAsync(path)", method);
Assert.DoesNotContain("GatewayDashboardUrlBuilder.Build", method);
Assert.DoesNotContain("Process.Start", method);
+ Assert.DoesNotContain("Failed to open dashboard", method);
+ Assert.Contains("if (!sshGate.Allowed)", prepare);
+ Assert.Contains("DashboardBrowserHandoff.SameBinding(", prepare);
+ Assert.DoesNotContain("ProjectOntoLocalForward", prepare);
+ Assert.DoesNotContain("GatewayDashboardUrlBuilder.Build", prepare);
+ Assert.Contains("DashboardBrowserHandoff.UrlUsesCapturedForward(url, prepared.Snapshot)", launch);
+ Assert.Contains("TryEnterBrowserHandoff(owned.Generation, owned.LocalPort, owned.ProcessId, out var handoffId)", launch);
+ Assert.Contains("HasDeferredStop", launch);
+ Assert.Contains("WatchBrowserHandoffConsumptionAsync(", launch);
+ Assert.Contains("ExitBrowserHandoff(handoffId)", launch);
+ Assert.DoesNotContain("NoteBrowserHandoffProcessName", launch);
+ Assert.DoesNotContain("TryGetDefaultBrowserProcessName", launch);
+ Assert.DoesNotContain("hasBrowserIdentity", launch);
+ Assert.DoesNotContain("The dashboard did not confirm a browser connection. The SSH forward hold ended.", launch);
+ Assert.DoesNotContain("ex.Message", launch);
+ Assert.DoesNotContain("Process.Start", launch);
+ var watch = launch[launch.IndexOf("WatchBrowserHandoffConsumptionAsync(", StringComparison.Ordinal)..];
+ Assert.Contains("if (!tunnel.IsRunning)", watch);
+ Assert.Contains("ExitBrowserHandoff(handoffId)", watch);
+ Assert.DoesNotContain("ex.Message", launcher);
+ AssertInOrder(
+ launch,
+ "UrlUsesCapturedForward(url, prepared.Snapshot)",
+ "TryEnterBrowserHandoff(owned.Generation, owned.LocalPort, owned.ProcessId, out var handoffId)",
+ "TryBeginDashboardNavigation(handoffId)",
+ "DashboardBrowserShell.TryOpen",
+ "CompleteDashboardNavigation(handoffId, opened, browserProcessId)",
+ "WatchBrowserHandoffConsumptionAsync(",
+ "if (!tunnel.IsRunning)",
+ "ExitBrowserHandoff(handoffId)");
+ AssertInOrder(
+ launcher,
+ "await ensureTunnel()",
+ "ProjectOntoLocalForward",
+ "GatewayDashboardUrlBuilder.Build(");
+ }
+
+ [Fact]
+ public void Dashboard_AwaitsSettingsOwnedForwardBeforeTokenUrl()
+ {
+ var source = ReadAppSources();
+ var gate = ExtractMethod(source, "EnsureDashboardSshForwardOwnedAsync");
+
+ Assert.Contains("if (!_settings.UseSshTunnel)", gate);
+ Assert.Contains("_sshTunnelService?.Stop()", gate);
+ Assert.Contains("return (true, null, 0, 0);", gate);
+ Assert.Contains("EnsureSettingsOwnedForwardReadyAsync(", gate);
+ Assert.Contains("if (!owned.Owned)", gate);
+ Assert.Contains("return (true, owned.Generation, owned.LocalPort, owned.ProcessId);", gate);
+ Assert.DoesNotContain("OwnershipGeneration", gate);
+ Assert.DoesNotContain("CurrentLocalPort", gate);
+ Assert.DoesNotContain("GatewayDashboardUrlBuilder.Build(", gate);
+ Assert.DoesNotContain("EnsureStarted(", gate);
+ AssertInOrder(
+ gate,
+ "if (!_settings.UseSshTunnel)",
+ "EnsureSettingsOwnedForwardReadyAsync(",
+ "if (!owned.Owned)",
+ "owned.Generation");
}
[Fact]
diff --git a/tests/OpenClaw.Tray.Tests/DashboardBrowserShellTests.cs b/tests/OpenClaw.Tray.Tests/DashboardBrowserShellTests.cs
new file mode 100644
index 000000000..24099d464
--- /dev/null
+++ b/tests/OpenClaw.Tray.Tests/DashboardBrowserShellTests.cs
@@ -0,0 +1,20 @@
+using OpenClawTray.Services;
+
+namespace OpenClaw.Tray.Tests;
+
+public sealed class DashboardBrowserShellTests
+{
+ [Fact]
+ public void TryOpen_InvalidTarget_ReturnsFalseWithoutAProcessId()
+ {
+ Assert.False(DashboardBrowserShell.TryOpen("", out var processId));
+ Assert.Null(processId);
+ }
+
+ [Fact]
+ public void InterpretStartedProcess_NullProcess_IsSuccessfulActivationWithoutAProcessId()
+ {
+ Assert.True(DashboardBrowserShell.InterpretStartedProcess(null, out var processId));
+ Assert.Null(processId);
+ }
+}
diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
index 7be9c15ee..3cc7ff14a 100644
--- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
+++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
@@ -100,6 +100,7 @@
+
diff --git a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs
index 9d725a8f0..03252b818 100644
--- a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs
+++ b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs
@@ -120,7 +120,7 @@ public void RetiredOrInvalidHandoff_IsVisibleFailureRoute_NotGlobalDashboardFall
public async Task OrdinaryDashboard_UsesRequestedPathAndNormalCredentials(string? path, string suffix)
{
string? launched = null;
- var launcher = new GatewayDashboardLauncher(() => true,
+ var launcher = new GatewayDashboardLauncher(() => Task.FromResult(true),
() => new(Gateway.Url, "synthetic shared", false, CredentialResolver.SourceSharedGatewayToken),
url => { launched = url; return Task.FromResult(true); },
() => throw new InvalidOperationException("Unexpected failure"));
@@ -135,7 +135,7 @@ public async Task OrdinaryDashboard_UsesRequestedPathAndNormalCredentials(string
public async Task DeviceAndBootstrapTokens_NeverEnterBrowserUrl(string source, bool bootstrap)
{
string? launched = null;
- var launcher = new GatewayDashboardLauncher(() => true,
+ var launcher = new GatewayDashboardLauncher(() => Task.FromResult(true),
() => new(Gateway.Url, "do-not-export", bootstrap, source),
url => { launched = url; return Task.FromResult(true); },
() => throw new InvalidOperationException("Unexpected failure"));
@@ -152,7 +152,7 @@ public async Task Failure_IsReportedOnceWithoutAutomaticRetry(string failure)
{
var launches = 0;
var failures = 0;
- var launcher = new GatewayDashboardLauncher(() => failure != "tunnel",
+ var launcher = new GatewayDashboardLauncher(() => Task.FromResult(failure != "tunnel"),
() => failure == "credential" ? null : new(Gateway.Url, "shared", false, CredentialResolver.SourceSharedGatewayToken),
_ =>
{
@@ -181,7 +181,7 @@ public async Task ExplicitDashboardRetry_KeepsRequestedPathAndClearsFailureOnlyA
var urls = new List();
var failures = 0;
var opened = 0;
- var launcher = new GatewayDashboardLauncher(() => true,
+ var launcher = new GatewayDashboardLauncher(() => Task.FromResult(true),
() => new(Gateway.Url, "synthetic", false, CredentialResolver.SourceSharedGatewayToken),
url => { urls.Add(url); return Task.FromResult(urls.Count == 2); },
() => failures++, () => opened++);