From dc29fbb416a081b99c66f680c565b081591a17b3 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 24 Sep 2026 11:07:29 -0700 Subject: [PATCH 01/27] fix(connection): open the dashboard only after the SSH forward is owned Open Dashboard marked the tunnel up as soon as ssh.exe started, then put the shared token in the browser URL. Wait for the same listener ownership check StartOwnedAsync uses, and do not build that URL when the wait fails. - Add EnsureSettingsOwnedForwardReadyAsync for the settings-owned forward - Stop the forward and show the existing tunnel toast when ownership fails - Leave direct dashboard opens unchanged when SSH is off Signed-off-by: Sebastien Tardif --- src/OpenClaw.Connection/SshTunnelService.cs | 86 +++++++++++++++++-- src/OpenClaw.Tray.WinUI/App.xaml.cs | 80 ++++++++++------- .../SshTunnelServiceTests.cs | 45 ++++++++++ .../AppRefactorContractTests.cs | 29 ++++++- 4 files changed, 199 insertions(+), 41 deletions(-) diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index 14c054c14..9ab063727 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -558,6 +558,85 @@ _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; + try + { + cancellationToken.ThrowIfCancellationRequested(); + EnsureStartedCore( + config, + SshTunnelOwner.Settings, + 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); + } + + return true; + } + catch (Exception ex) + { + if (process is not null) + StopIfCurrent(process, generation); + + lock (_stateLock) + { + LastError = ex.Message; + Status = TunnelStatus.Failed; + } + + _logger.Warn($"SSH dashboard forward is not owned: {ex.Message}"); + return false; + } + } + + private static void RejectOccupiedForwardPorts(SshTunnelConfig tunnel) + { + EnsurePortIsUnoccupied(WindowsTcpListenerSnapshot.Capture(), tunnel.LocalPort); + if (tunnel.IncludeBrowserProxyForward) + EnsurePortIsUnoccupied(WindowsTcpListenerSnapshot.Capture(), tunnel.LocalPort + 2); + } + public async Task StartOwnedAsync( SshTunnelConfig config, CancellationToken ct) @@ -569,12 +648,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 { diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index fdc2b9bf0..7f0bd0012 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -3890,9 +3890,17 @@ private bool TryResolveChatCredentials( #region Actions private void OpenDashboard(string? path = null) + { + AsyncEventHandlerGuard.Run( + () => OpenDashboardAsync(path), + new AppLogger(), + nameof(OpenDashboard)); + } + + private async Task OpenDashboardAsync(string? path) { if (_settings == null) return; - if (!EnsureSshTunnelConfigured()) + if (!await EnsureDashboardSshForwardOwnedAsync()) { _toastService?.ShowToast(new ToastContentBuilder() .AddText("SSH tunnel") @@ -4244,57 +4252,63 @@ private void SetChatSpeakerMuted(bool muted, SettingsWriteOrigin? origin) #endregion - private bool EnsureSshTunnelConfigured() + private async Task EnsureDashboardSshForwardOwnedAsync() { if (_settings == null) { return false; } - 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; + } - try - { - _sshTunnelService ??= new SshTunnelService(new AppLogger()); - var includeBrowserProxy = BrowserProxySshTunnelForwardPolicy.ShouldInclude( - _settings.NodeBrowserProxyEnabled, - _settings.SshTunnelRemotePort, - _settings.SshTunnelLocalPort); - _sshTunnelService.EnsureStarted( + 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; + } + + try + { + _sshTunnelService ??= new SshTunnelService(new AppLogger()); + var includeBrowserProxy = BrowserProxySshTunnelForwardPolicy.ShouldInclude( + _settings.NodeBrowserProxyEnabled, + _settings.SshTunnelRemotePort, + _settings.SshTunnelLocalPort); + var owned = await _sshTunnelService.EnsureSettingsOwnedForwardReadyAsync( + new SshTunnelConfig( _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) + _settings.SshTunnelSshPort), + CancellationToken.None); + 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 + }); + if (!owned) { - Logger.Error($"Failed to start SSH tunnel: {ex.Message}"); UpdateTrayIcon(); return false; } } - else + catch (Exception ex) { - _sshTunnelService?.Stop(); + Logger.Error($"Failed to start SSH tunnel: {ex.Message}"); + UpdateTrayIcon(); + return false; } return true; diff --git a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs index 0125d270e..886a6e807 100644 --- a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs @@ -1,5 +1,6 @@ using OpenClaw.Shared; using System.Net; +using System.Net.Sockets; namespace OpenClaw.Connection.Tests; @@ -337,6 +338,50 @@ 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); + Assert.False(service.IsRunning); + Assert.NotEqual(TunnelStatus.Up, service.Status); + Assert.Contains(port.ToString(), service.LastError); + Assert.Contains("already owned", service.LastError); + } + + [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); + 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() { diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 5f6361a58..f24cec6a5 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -470,11 +470,36 @@ public void LifecycleStatus_IsWrittenFromManagerSnapshotOnly() public void Dashboard_SurfacesSshTunnelConfigurationFailure() { var source = ReadAppSources(); - var method = ExtractMethod(source, "OpenDashboard"); + var method = ExtractMethod(source, "OpenDashboardAsync"); - Assert.Contains("if (!EnsureSshTunnelConfigured())", method); + Assert.Contains("if (!await EnsureDashboardSshForwardOwnedAsync())", method); Assert.Contains("_toastService?.ShowToast", method); Assert.Contains("Check SSH tunnel settings and logs.", method); + AssertInOrder( + method, + "await EnsureDashboardSshForwardOwnedAsync()", + "GatewayDashboardUrlBuilder.Build("); + Assert.DoesNotContain("EnsureStarted(", method); + } + + [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;", gate); + Assert.Contains("EnsureSettingsOwnedForwardReadyAsync(", gate); + Assert.Contains("if (!owned)", gate); + Assert.DoesNotContain("GatewayDashboardUrlBuilder.Build(", gate); + Assert.DoesNotContain("EnsureStarted(", gate); + AssertInOrder( + gate, + "if (!_settings.UseSshTunnel)", + "EnsureSettingsOwnedForwardReadyAsync(", + "if (!owned)"); } [Fact] From 6649c8b8fe339b9a623be56e21ec41443b723e04 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Fri, 25 Sep 2026 21:56:27 +0200 Subject: [PATCH 02/27] fix(connection): bind dashboard launch to SSH ownership lease Resolve the dashboard credential and endpoint from one immutable gateway snapshot, preserve newer tunnel state when an older ownership wait fails, and revalidate the exact tunnel generation at browser handoff. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f6d0b3f-48b9-489e-a9dd-a736c63d485b --- docs/ARCHITECTURE.md | 2 + src/OpenClaw.Connection/SshTunnelService.cs | 130 ++++++++++++- src/OpenClaw.Tray.WinUI/App.xaml.cs | 136 +++++++++---- .../DashboardGatewaySnapshotResolver.cs | 184 ++++++++++++++++++ .../SshTunnelServiceTests.cs | 140 ++++++++++++- .../AppRefactorContractTests.cs | 34 ++-- .../ConnectionRegressionSourceTests.cs | 2 +- .../DashboardGatewaySnapshotResolverTests.cs | 72 +++++++ .../OpenClaw.Tray.Tests.csproj | 1 + 9 files changed, 635 insertions(+), 66 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Services/DashboardGatewaySnapshotResolver.cs create mode 100644 tests/OpenClaw.Tray.Tests/DashboardGatewaySnapshotResolverTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5acb83a9f..82fe88ba6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -246,6 +246,8 @@ leading and trailing pipe. Columns, in order: | exec-carrier-cwd-ambiguity-check | closed | src/OpenClaw.Shared/ExecApprovals/ExecReusableCommandBinder.cs | deciding whether a carrier payload may be durably approved when the working directory could shadow it | CanonicalCmdCarrier.TryBuildPinnedCarrier (payload executable pinning) | - | the approval-time working-directory check is deleted, not merely bypassed: ExecCommandResolver exposes no HasCurrentDirectoryCandidate, a trusted carrier's payload executable is pinned to its resolved absolute path so cmd has nothing to search for, and a post-approval shadow cannot win | ExecReusableCommandBinderTests.PinnedCarrier_IgnoresShadowInsertedAfterApproval | behavioral | - | | exec-legacy-host-quarantine | authoritative | src/OpenClaw.Shared/ExecApprovals/ExecCommandToken.cs | deciding whether a provenance-less path-only allowlist entry authorizes an interpreter or code host | ExecAllowlistMatcher.MatchInternal via ExecCommandToken.IsLegacyQuarantinedHost | argument binding remains the security boundary for every rule this node generates | an allowlist entry with no source and no argPattern is inert when its resolved target is a command host the previous model refused, is never deleted or migrated, and is superseded only by an explicit allow-always sibling carrying source and argPattern | ExecAllowlistArgBindingTests.LegacyPathOnlyEntryForACommandHost_IsInert | behavioral | - | | app-ssh-restart-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs and ConnectionPage.xaml.cs | stopping, starting, reconnecting, and declaring success for a user-requested SSH tunnel restart | GatewayConnectionManager.RestartSshTunnelAsync | App and ConnectionPage invoke the manager and present the result | a restart succeeds only after a fresh generation-bound hello-ok and current registry, config, tunnel generation, and owned listener verification | AppRefactorContractTests.UserSshRestart_StaysDelegatedToConnectionManager | source-shape | when App no longer owns any SSH tunnel UI actions | +| dashboard-gateway-snapshot | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | selecting and comparing the registry or legacy gateway record and resolving its dashboard credential endpoint | DashboardGatewaySnapshotResolver | App owns the browser launch and presents connection or SSH errors | a token-bearing SSH dashboard URL is resolved from one immutable gateway snapshot and uses that snapshot's local forward only after the same tunnel lease is owned | DashboardGatewaySnapshotResolverTests.ActiveSshRecord_ResolvesForwardAndDetectsSecurityRelevantChange | behavioral | - | +| app-dashboard-snapshot-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | private gateway snapshot selection, security-relevant equality, identity-directory selection, and forwarded endpoint resolution for dashboard launch | DashboardGatewaySnapshotResolver | App captures the snapshot, awaits tunnel ownership, invokes the resolver, and performs the browser handoff | App cannot independently mix settings SSH state, a registry credential, and a different dashboard endpoint | AppRefactorContractTests.Dashboard_HoldsSameGatewayAndTunnelOwnershipThroughBrowserLaunch | source-shape | when App no longer owns the dashboard browser action | | hub-page-registry | authoritative | src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs and GatewayNavVisibilityDebouncePolicy | navigation aliases, page mapping, command metadata and search, and gateway-page classification | HubPageRegistry | HubWindow keeps Frame and NavigationView application, back-stack mutation, command cache lifetime, and semantic action execution; GatewayNavVisibilityDebouncePolicy keeps disconnect timing | every current direct, legacy, and agent-scoped tag resolves identically; command order, titles, actions, search caps, and gateway prune set remain stable | HubPageRegistryTests.BuildCommands_PreservesBaseOrderActionsIconsAndResourceKeys | behavioral | - | | hub-page-registry-closed | closed | src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs and GatewayNavVisibilityDebouncePolicy | private tag/page switches, command catalogs or search predicates, and gateway-page tag lists | HubPageRegistry | view-only navigation application and debounce timing listed in hub-page-registry | HubWindow and the debounce policy do not regain catalog or page-classification copies | HubPresentationContractTests.HubPageRegistry_OwnsMappingsCommandsAndGatewayClassification | source-shape | when HubWindow is replaced by a different shell and GatewayNavVisibilityDebouncePolicy is retired | | app-notification-infobar-presentation | authoritative | src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs | banner severity filtering, selected-banner fallback, notification action versus Show more, and action enabled state | AppNotificationInfoBarPresenter | HubWindow keeps notification subscription, bell reconciliation, WinUI control assignment, navigation, and dismissal side effects | Warning and Error banners retain priority and hiding semantics while action projection stays WinUI-free | AppNotificationInfoBarPresenterTests.Present_ActionableNotificationWinsOverShowMore | behavioral | - | diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index 9ab063727..fdf24c589 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -168,7 +168,8 @@ public void EnsureStarted(string user, string host, int remotePort, int localPor private void EnsureStartedCore( SshTunnelConfig tunnel, SshTunnelOwner owner, - Action? beforeStart = null) + Action? beforeStart = null, + Action? generationCaptured = null) { lock (_operationLock) { @@ -190,11 +191,16 @@ private void EnsureStartedCore( { _currentOwner = ResolveOwnerForReuse(_currentOwner, owner); Status = TunnelStatus.Up; + generationCaptured?.Invoke(_lifecycleGeneration); return; } } StopLocked(); + lock (_stateLock) + { + generationCaptured?.Invoke(_lifecycleGeneration); + } beforeStart?.Invoke(tunnel); lock (_stateLock) { @@ -558,7 +564,7 @@ _process is null || public async Task StartAsync(SshTunnelConfig config, CancellationToken ct) => (await StartOwnedAsync(config, ct).ConfigureAwait(false)).Url; - public async Task EnsureSettingsOwnedForwardReadyAsync( + public async Task EnsureSettingsOwnedForwardReadyAsync( SshTunnelConfig config, CancellationToken cancellationToken) { @@ -570,7 +576,8 @@ public async Task EnsureSettingsOwnedForwardReadyAsync( EnsureStartedCore( config, SshTunnelOwner.Settings, - tunnel => RejectOccupiedForwardPorts(tunnel)); + tunnel => RejectOccupiedForwardPorts(tunnel), + capturedGeneration => generation = capturedGeneration); var normalizedConfig = config with { @@ -612,21 +619,130 @@ await WaitForOwnedLocalListenerAsync( cancellationToken).ConfigureAwait(false); } - return true; + return new SshTunnelStartResult( + $"ws://localhost:{config.LocalPort}", + normalizedConfig, + generation); } catch (Exception ex) { + TryFailSettingsForwardAttempt(process, generation, ex.Message); + _logger.Warn($"SSH dashboard forward is not owned: {ex.Message}"); + return null; + } + } + + internal bool TryFailSettingsForwardAttempt( + Process? process, + long generation, + string error) + { + lock (_operationLock) + { + lock (_stateLock) + { + if (process is not null) + { + if (generation != _lifecycleGeneration || + !ReferenceEquals(_process, process)) + { + return false; + } + } + else if (generation != _lifecycleGeneration || + IsRunningLocked()) + { + return false; + } + } + if (process is not null) - StopIfCurrent(process, generation); + StopLocked(); lock (_stateLock) { - LastError = ex.Message; + if (IsRunningLocked()) + return false; + + LastError = error; Status = TunnelStatus.Failed; + return true; } + } + } - _logger.Warn($"SSH dashboard forward is not owned: {ex.Message}"); + public bool TryUseOwnedListener( + SshTunnelStartResult ownership, + int destinationPort, + Func use) + { + ArgumentNullException.ThrowIfNull(ownership); + ArgumentNullException.ThrowIfNull(use); + + var config = ownership.Config; + var isConfiguredForward = + destinationPort == config.LocalPort || + (config.IncludeBrowserProxyForward && destinationPort == config.LocalPort + 2); + if (!isConfiguredForward) return false; + + lock (_operationLock) + { + Process process; + int processId; + DateTime processStartTimeUtc; + lock (_stateLock) + { + if (ownership.OwnershipGeneration != _lifecycleGeneration || + !IsRunningLocked() || + _process is null || + !Equals(_currentConfig, config)) + { + return false; + } + + process = _process; + processId = process.Id; + try + { + processStartTimeUtc = process.StartTime.ToUniversalTime(); + } + catch (Exception ex) + { + _logger.Debug($"SSH dashboard ownership process inspection failed: {ex.Message}"); + return false; + } + } + + try + { + if (!ValidateListenerOwnership( + WindowsTcpListenerSnapshot.Capture(), + destinationPort, + processId, + processStartTimeUtc)) + { + return false; + } + } + catch (Exception ex) + { + _logger.Warn($"SSH dashboard ownership verification failed: {ex.Message}"); + return false; + } + + lock (_stateLock) + { + if (ownership.OwnershipGeneration != _lifecycleGeneration || + !ReferenceEquals(_process, process) || + !IsRunningLocked() || + !Equals(_currentConfig, config)) + { + return false; + } + } + + return use(); } } diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index e05c1f4dd..6e990f93f 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -3915,8 +3915,23 @@ private void OpenDashboard(string? path = null) private async Task OpenDashboardAsync(string? path) { - if (_settings == null) return; - if (!await EnsureDashboardSshForwardOwnedAsync()) + if (_settings == null) + return; + + var snapshotResolver = new DashboardGatewaySnapshotResolver( + _gatewayRegistry, + _settings, + SettingsManager.SettingsDirectoryPath); + if (!snapshotResolver.TryCapture(out var snapshot) || snapshot == null) + { + ShowConnectionSettingsForPairingIssue( + "Dashboard", + "Gateway URL or credential is not configured"); + return; + } + + var ownership = await EnsureDashboardSshForwardOwnedAsync(snapshot.Record.SshTunnel); + if (snapshot.Record.SshTunnel != null && ownership == null) { _toastService?.ShowToast(new ToastContentBuilder() .AddText("SSH tunnel") @@ -3924,7 +3939,19 @@ private async Task OpenDashboardAsync(string? path) return; } - if (!TryResolveChatCredentials(out var gatewayUrl, out var token, out var credentialSource, out var isBootstrapToken)) + if (!snapshotResolver.IsCurrent(snapshot)) + { + ShowTransientConnectionError( + "Dashboard was not opened because the active gateway changed."); + return; + } + + var dashboardAuth = snapshotResolver.ResolveCredentials( + snapshot, + DeviceIdentityFileReader.Instance, + (record, candidate) => + _managedLocalPortProvenance?.IsStrongCredentialAllowed(record, candidate) == true); + if (dashboardAuth == null) { ShowConnectionSettingsForPairingIssue( "Dashboard", @@ -3933,13 +3960,55 @@ private async Task OpenDashboardAsync(string? path) } var url = GatewayDashboardUrlBuilder.Build( - gatewayUrl, + dashboardAuth.GatewayUrl, path, - token, - !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken); + dashboardAuth.Token, + !dashboardAuth.IsBootstrapToken && + dashboardAuth.Source == CredentialResolver.SourceSharedGatewayToken); try { + if (ownership != null) + { + var gatewayChanged = false; + var launched = _sshTunnelService?.TryUseOwnedListener( + ownership, + ownership.Config.LocalPort, + () => + { + if (!snapshotResolver.IsCurrent(snapshot)) + { + gatewayChanged = true; + return false; + } + + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + return true; + }) == true; + if (!launched) + { + if (gatewayChanged) + { + ShowTransientConnectionError( + "Dashboard was not opened because the active gateway changed."); + } + else + { + _toastService?.ShowToast(new ToastContentBuilder() + .AddText("SSH tunnel") + .AddText("Dashboard was not opened because SSH forward ownership changed.")); + } + } + return; + } + + if (!snapshotResolver.IsCurrent(snapshot)) + { + ShowTransientConnectionError( + "Dashboard was not opened because the active gateway changed."); + return; + } + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); } catch (Exception ex) @@ -4268,66 +4337,49 @@ private void SetChatSpeakerMuted(bool muted, SettingsWriteOrigin? origin) #endregion - private async Task EnsureDashboardSshForwardOwnedAsync() + private async Task EnsureDashboardSshForwardOwnedAsync( + SshTunnelConfig? tunnel) { - if (_settings == null) - { - return false; - } - - if (!_settings.UseSshTunnel) - { - _sshTunnelService?.Stop(); - return true; - } + if (tunnel == null) + return null; - if (string.IsNullOrWhiteSpace(_settings.SshTunnelUser) || - string.IsNullOrWhiteSpace(_settings.SshTunnelHost) || - _settings.SshTunnelRemotePort is < 1 or > 65535 || - _settings.SshTunnelLocalPort is < 1 or > 65535) + if (string.IsNullOrWhiteSpace(tunnel.User) || + string.IsNullOrWhiteSpace(tunnel.Host) || + tunnel.RemotePort is < 1 or > 65535 || + tunnel.LocalPort is < 1 or > 65535 || + tunnel.SshPort is < 1 or > 65535) { Logger.Warn("SSH tunnel is enabled but settings are incomplete"); UpdateTrayIcon(); - return false; + return null; } try { _sshTunnelService ??= new SshTunnelService(new AppLogger()); - var includeBrowserProxy = BrowserProxySshTunnelForwardPolicy.ShouldInclude( - _settings.NodeBrowserProxyEnabled, - _settings.SshTunnelRemotePort, - _settings.SshTunnelLocalPort); - var owned = await _sshTunnelService.EnsureSettingsOwnedForwardReadyAsync( - new SshTunnelConfig( - _settings.SshTunnelUser, - _settings.SshTunnelHost, - _settings.SshTunnelRemotePort, - _settings.SshTunnelLocalPort, - includeBrowserProxy, - _settings.SshTunnelSshPort), + var ownership = await _sshTunnelService.EnsureSettingsOwnedForwardReadyAsync( + tunnel, CancellationToken.None); 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 + localEndpoint = $"127.0.0.1:{tunnel.LocalPort}", + remoteHost = tunnel.Host, + remotePort = tunnel.RemotePort }); - if (!owned) + if (ownership == null) { UpdateTrayIcon(); - return false; } + + return ownership; } catch (Exception ex) { Logger.Error($"Failed to start SSH tunnel: {ex.Message}"); UpdateTrayIcon(); - return false; + return null; } - - return true; } private void OnSshTunnelExited(object? sender, SshTunnelExit tunnelExit) => diff --git a/src/OpenClaw.Tray.WinUI/Services/DashboardGatewaySnapshotResolver.cs b/src/OpenClaw.Tray.WinUI/Services/DashboardGatewaySnapshotResolver.cs new file mode 100644 index 000000000..2455f8918 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/DashboardGatewaySnapshotResolver.cs @@ -0,0 +1,184 @@ +using OpenClaw.Connection; +using OpenClaw.Shared; + +namespace OpenClawTray.Services; + +internal sealed record DashboardGatewaySnapshot( + GatewayRecord Record, + bool IsRegistryRecord, + string? LegacyPrimary, + string? LegacyBootstrap); + +internal sealed class DashboardGatewaySnapshotResolver +{ + private readonly GatewayRegistry? _registry; + private readonly SettingsManager _settings; + private readonly string _legacyIdentityDirectory; + + public DashboardGatewaySnapshotResolver( + GatewayRegistry? registry, + SettingsManager settings, + string legacyIdentityDirectory) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentException.ThrowIfNullOrWhiteSpace(legacyIdentityDirectory); + _registry = registry; + _settings = settings; + _legacyIdentityDirectory = legacyIdentityDirectory; + } + + public bool TryCapture(out DashboardGatewaySnapshot? snapshot) + { + if (_registry?.GetActive() is { } active) + { + snapshot = new DashboardGatewaySnapshot( + active, + IsRegistryRecord: true, + LegacyPrimary: null, + LegacyBootstrap: null); + return true; + } + + var gatewayUrl = _settings.GetEffectiveGatewayUrl(); + if (string.IsNullOrWhiteSpace(gatewayUrl)) + { + snapshot = null; + return false; + } + + SshTunnelConfig? tunnel = null; + if (_settings.UseSshTunnel) + { + tunnel = new SshTunnelConfig( + _settings.SshTunnelUser ?? "", + _settings.SshTunnelHost ?? "", + _settings.SshTunnelRemotePort, + _settings.SshTunnelLocalPort, + BrowserProxySshTunnelForwardPolicy.ShouldInclude( + _settings.NodeBrowserProxyEnabled, + _settings.SshTunnelRemotePort, + _settings.SshTunnelLocalPort), + _settings.SshTunnelSshPort); + } + + snapshot = new DashboardGatewaySnapshot( + new GatewayRecord + { + Id = "legacy-settings", + Url = gatewayUrl, + IsLocal = GatewayRecordEditing.IsLoopbackEndpoint(gatewayUrl), + SshTunnel = tunnel, + }, + false, + _settings.LegacyToken, + _settings.LegacyBootstrapToken); + return true; + } + + public bool IsCurrent(DashboardGatewaySnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + if (snapshot.IsRegistryRecord) + { + return _registry?.GetActive() is { } active && + RecordsMatch(snapshot.Record, active); + } + + return _registry?.GetActive() == null && + TryCapture(out var current) && + current is { IsRegistryRecord: false } && + RecordsMatch(snapshot.Record, current.Record) && + string.Equals( + snapshot.LegacyPrimary, + current.LegacyPrimary, + StringComparison.Ordinal) && + string.Equals( + snapshot.LegacyBootstrap, + current.LegacyBootstrap, + StringComparison.Ordinal); + } + + public InteractiveGatewayCredential? ResolveCredentials( + DashboardGatewaySnapshot snapshot, + IDeviceIdentityReader identityReader, + Func? authorizeCredential) + { + ArgumentNullException.ThrowIfNull(snapshot); + ArgumentNullException.ThrowIfNull(identityReader); + if (!snapshot.IsRegistryRecord) + { + return InteractiveGatewayCredentialResolver.TryResolve( + null, + _legacyIdentityDirectory, + identityReader, + snapshot.Record.Url, + snapshot.LegacyPrimary, + snapshot.LegacyBootstrap, + authorizeCredential, + out var legacyResult) + ? legacyResult + : null; + } + + var record = snapshot.Record; + var identityDirectory = snapshot.IsRegistryRecord && _registry != null + ? _registry.GetIdentityDirectory(snapshot.Record.Id) + : _legacyIdentityDirectory; + GatewayCredential? resolved; + if (!string.IsNullOrWhiteSpace(record.SharedGatewayToken)) + { + resolved = new GatewayCredential( + record.SharedGatewayToken!, + IsBootstrapToken: false, + CredentialResolver.SourceSharedGatewayToken); + } + else + { + resolved = new CredentialResolver(identityReader) + .ResolveOperator(record, identityDirectory); + } + + if (resolved == null || + (authorizeCredential is not null && !authorizeCredential(record, resolved))) + { + return null; + } + + string gatewayUrl; + try + { + gatewayUrl = GatewayClientEndpointResolver.Resolve(record); + } + catch (InvalidOperationException) + { + return null; + } + + return new InteractiveGatewayCredential( + gatewayUrl, + resolved.Token, + resolved.IsBootstrapToken, + resolved.Source); + } + + internal static bool RecordsMatch( + GatewayRecord expected, + GatewayRecord current) => + string.Equals(expected.Id, current.Id, StringComparison.Ordinal) && + string.Equals(expected.Url, current.Url, StringComparison.Ordinal) && + string.Equals( + expected.SharedGatewayToken, + current.SharedGatewayToken, + StringComparison.Ordinal) && + string.Equals( + expected.BootstrapToken, + current.BootstrapToken, + StringComparison.Ordinal) && + expected.IsLocal == current.IsLocal && + expected.RequiresV2Signature == current.RequiresV2Signature && + string.Equals( + expected.SetupManagedDistroName, + current.SetupManagedDistroName, + StringComparison.Ordinal) && + Equals(expected.SshTunnel, current.SshTunnel); +} diff --git a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs index 886a6e807..4824aeb98 100644 --- a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs @@ -1,4 +1,5 @@ using OpenClaw.Shared; +using System.Diagnostics; using System.Net; using System.Net.Sockets; @@ -346,11 +347,11 @@ public async Task EnsureSettingsOwnedForwardReadyAsync_RejectsForeignLocalListen var port = ((IPEndPoint)listener.LocalEndpoint).Port; using var service = new SshTunnelService(NullLogger.Instance); - var ready = await service.EnsureSettingsOwnedForwardReadyAsync( + var ownership = await service.EnsureSettingsOwnedForwardReadyAsync( new SshTunnelConfig("user", "host", 18789, port), CancellationToken.None); - Assert.False(ready); + Assert.Null(ownership); Assert.False(service.IsRunning); Assert.NotEqual(TunnelStatus.Up, service.Status); Assert.Contains(port.ToString(), service.LastError); @@ -366,7 +367,7 @@ public async Task EnsureSettingsOwnedForwardReadyAsync_RejectsForeignBrowserProx var localPort = proxyPort - 2; using var service = new SshTunnelService(NullLogger.Instance); - var ready = await service.EnsureSettingsOwnedForwardReadyAsync( + var ownership = await service.EnsureSettingsOwnedForwardReadyAsync( new SshTunnelConfig( "user", "host", @@ -375,13 +376,110 @@ public async Task EnsureSettingsOwnedForwardReadyAsync_RejectsForeignBrowserProx IncludeBrowserProxyForward: true), CancellationToken.None); - Assert.False(ready); + Assert.Null(ownership); Assert.False(service.IsRunning); Assert.NotEqual(TunnelStatus.Up, service.Status); Assert.NotNull(service.LastError); Assert.Contains("already owned", service.LastError); } + [Fact] + public void SettingsForwardFailure_DoesNotOverwriteNewerRunningTunnel() + { + using var service = new SshTunnelService(NullLogger.Instance); + using var currentProcess = Process.GetCurrentProcess(); + using var staleProcess = new Process(); + var config = new SshTunnelConfig("user", "host", 18789, 45678); + InjectRunningTunnel(service, currentProcess, config, generation: 42); + try + { + var published = service.TryFailSettingsForwardAttempt( + staleProcess, + generation: 41, + "older attempt failed"); + + Assert.False(published); + Assert.True(service.IsRunning); + Assert.Equal(TunnelStatus.Up, service.Status); + Assert.Null(service.LastError); + } + finally + { + DetachInjectedProcess(service); + } + } + + [Fact] + public void SettingsForwardFailure_DoesNotOverwriteNewerFailedTunnel() + { + using var service = new SshTunnelService(NullLogger.Instance); + SetPrivateField(service, "_lifecycleGeneration", 42L); + typeof(SshTunnelService) + .GetProperty(nameof(SshTunnelService.Status))! + .SetValue(service, TunnelStatus.Failed); + typeof(SshTunnelService) + .GetProperty(nameof(SshTunnelService.LastError))! + .SetValue(service, "newer tunnel exited"); + + var published = service.TryFailSettingsForwardAttempt( + process: null, + generation: 41, + "older attempt failed"); + + Assert.False(published); + Assert.Equal(TunnelStatus.Failed, service.Status); + Assert.Equal("newer tunnel exited", service.LastError); + } + + [Fact] + public void TryUseOwnedListener_InvokesCallbackOnlyForCurrentOwnership() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + using var service = new SshTunnelService(NullLogger.Instance); + using var currentProcess = Process.GetCurrentProcess(); + var config = new SshTunnelConfig("user", "host", 18789, port); + InjectRunningTunnel(service, currentProcess, config, generation: 42); + try + { + var ownership = new SshTunnelStartResult( + $"ws://localhost:{port}", + config, + OwnershipGeneration: 42); + var invoked = false; + + var used = service.TryUseOwnedListener( + ownership, + port, + () => + { + invoked = true; + return true; + }); + + Assert.True(used); + Assert.True(invoked); + + invoked = false; + used = service.TryUseOwnedListener( + ownership with { OwnershipGeneration = 41 }, + port, + () => + { + invoked = true; + return true; + }); + + Assert.False(used); + Assert.False(invoked); + } + finally + { + DetachInjectedProcess(service); + } + } + [Fact] public void ListenerOwnershipChecks_FailClosedWhenSnapshotIsIncomplete() { @@ -399,4 +497,38 @@ public void ListenerOwnershipChecks_FailClosedWhenSnapshotIsIncomplete() 4321, DateTime.UtcNow)); } + + private static void InjectRunningTunnel( + SshTunnelService service, + Process process, + SshTunnelConfig config, + long generation) + { + SetPrivateField(service, "_process", process); + SetPrivateField(service, "_processStarted", true); + SetPrivateField(service, "_currentConfig", config); + SetPrivateField(service, "_currentOwner", SshTunnelOwner.Settings); + SetPrivateField(service, "_lifecycleGeneration", generation); + typeof(SshTunnelService) + .GetProperty(nameof(SshTunnelService.Status))! + .SetValue(service, TunnelStatus.Up); + } + + private static void DetachInjectedProcess(SshTunnelService service) + { + SetPrivateField(service, "_process", null); + SetPrivateField(service, "_processStarted", false); + SetPrivateField(service, "_currentConfig", null); + } + + private static void SetPrivateField( + SshTunnelService service, + string name, + T value) => + typeof(SshTunnelService) + .GetField( + name, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic)! + .SetValue(service, value); } diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index f24cec6a5..bc24409ee 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -472,34 +472,44 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() var source = ReadAppSources(); var method = ExtractMethod(source, "OpenDashboardAsync"); - Assert.Contains("if (!await EnsureDashboardSshForwardOwnedAsync())", method); + Assert.Contains( + "await EnsureDashboardSshForwardOwnedAsync(snapshot.Record.SshTunnel)", + method); Assert.Contains("_toastService?.ShowToast", method); Assert.Contains("Check SSH tunnel settings and logs.", method); AssertInOrder( method, - "await EnsureDashboardSshForwardOwnedAsync()", + "new DashboardGatewaySnapshotResolver(", + "snapshotResolver.TryCapture(", + "await EnsureDashboardSshForwardOwnedAsync(snapshot.Record.SshTunnel)", + "snapshotResolver.ResolveCredentials(", "GatewayDashboardUrlBuilder.Build("); Assert.DoesNotContain("EnsureStarted(", method); } [Fact] - public void Dashboard_AwaitsSettingsOwnedForwardBeforeTokenUrl() + public void Dashboard_HoldsSameGatewayAndTunnelOwnershipThroughBrowserLaunch() { var source = ReadAppSources(); + var method = ExtractMethod(source, "OpenDashboardAsync"); var gate = ExtractMethod(source, "EnsureDashboardSshForwardOwnedAsync"); - Assert.Contains("if (!_settings.UseSshTunnel)", gate); - Assert.Contains("_sshTunnelService?.Stop()", gate); - Assert.Contains("return true;", gate); + Assert.Contains("snapshotResolver.IsCurrent(snapshot)", method); + Assert.Contains("_sshTunnelService?.TryUseOwnedListener(", method); + Assert.Contains("ownership.Config.LocalPort", method); + Assert.Contains("Process.Start(", method); + AssertInOrder( + method, + "_sshTunnelService?.TryUseOwnedListener(", + "snapshotResolver.IsCurrent(snapshot)", + "Process.Start("); + Assert.Contains("SshTunnelConfig? tunnel", gate); Assert.Contains("EnsureSettingsOwnedForwardReadyAsync(", gate); - Assert.Contains("if (!owned)", gate); + Assert.DoesNotContain("_settings.UseSshTunnel", gate); Assert.DoesNotContain("GatewayDashboardUrlBuilder.Build(", gate); Assert.DoesNotContain("EnsureStarted(", gate); - AssertInOrder( - gate, - "if (!_settings.UseSshTunnel)", - "EnsureSettingsOwnedForwardReadyAsync(", - "if (!owned)"); + Assert.Contains("var gatewayChanged = false;", method); + Assert.Contains("if (gatewayChanged)", method); } [Fact] diff --git a/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs index 6d494f63f..929270322 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs @@ -7,7 +7,7 @@ public void Dashboard_TokenQuery_IsLimitedToSharedGatewayToken() { var appSource = ReadSource("src", "OpenClaw.Tray.WinUI", "App.xaml.cs"); - Assert.Contains("credentialSource == CredentialResolver.SourceSharedGatewayToken", appSource); + Assert.Contains("dashboardAuth.Source == CredentialResolver.SourceSharedGatewayToken", appSource); Assert.DoesNotContain("if (!isBootstrapToken && !string.IsNullOrEmpty(token))", appSource); } diff --git a/tests/OpenClaw.Tray.Tests/DashboardGatewaySnapshotResolverTests.cs b/tests/OpenClaw.Tray.Tests/DashboardGatewaySnapshotResolverTests.cs new file mode 100644 index 000000000..5cb71f893 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/DashboardGatewaySnapshotResolverTests.cs @@ -0,0 +1,72 @@ +using OpenClaw.Connection; +using OpenClaw.Shared; +using OpenClaw.TestSupport; +using OpenClawTray.Services; + +namespace OpenClaw.Tray.Tests; + +public sealed class DashboardGatewaySnapshotResolverTests +{ + [Fact] + public void ActiveSshRecord_ResolvesForwardAndDetectsSecurityRelevantChange() + { + using var temp = new TempDirectory(); + var settings = new SettingsManager(temp.Path); + var registry = new GatewayRegistry(temp.Path); + var record = new GatewayRecord + { + Id = "gateway-1", + Url = "wss://gateway.example", + SshTunnel = new SshTunnelConfig("user", "ssh.example", 18789, 45678), + SharedGatewayToken = "test-auth-token", + }; + registry.AddOrUpdate(record); + registry.SetActive(record.Id); + var resolver = new DashboardGatewaySnapshotResolver( + registry, + settings, + temp.Path); + + Assert.True(resolver.TryCapture(out var snapshot)); + Assert.NotNull(snapshot); + Assert.True(resolver.IsCurrent(snapshot!)); + var result = resolver.ResolveCredentials( + snapshot!, + new EmptyIdentityReader(), + authorizeCredential: (_, _) => true); + Assert.NotNull(result); + Assert.Equal("ws://localhost:45678", result!.GatewayUrl); + Assert.Equal("test-auth-token", result.Token); + + registry.AddOrUpdate(record with { SharedGatewayToken = "secret-token" }); + + Assert.False(resolver.IsCurrent(snapshot)); + } + + [Fact] + public void RecordsMatch_IgnoresPresentationOnlyChanges() + { + var expected = new GatewayRecord + { + Id = "gateway-1", + Url = "wss://gateway.example", + FriendlyName = "Original", + LastConnected = DateTime.UtcNow.AddDays(-1), + SharedGatewayToken = "test-auth-token", + }; + var current = expected with + { + FriendlyName = "Renamed", + LastConnected = DateTime.UtcNow, + }; + + Assert.True(DashboardGatewaySnapshotResolver.RecordsMatch(expected, current)); + } + + private sealed class EmptyIdentityReader : IDeviceIdentityReader + { + public string? TryReadStoredDeviceToken(string dataPath) => null; + + public string? TryReadStoredNodeDeviceToken(string dataPath) => null; + } +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 3812cd5a2..3ef198afe 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -128,6 +128,7 @@ + From af1b4bacf2dec67a5f0e653fd964743f6a3b4192 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Fri, 25 Sep 2026 22:11:43 +0200 Subject: [PATCH 03/27] Revert "fix(connection): bind dashboard launch to SSH ownership lease" Restore PR #1514 to SebTardif's original four-file patch after the maintainer repair exceeded the bounded review scope. This reverts commit 6649c8b8fe339b9a623be56e21ec41443b723e04. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f6d0b3f-48b9-489e-a9dd-a736c63d485b --- docs/ARCHITECTURE.md | 2 - src/OpenClaw.Connection/SshTunnelService.cs | 130 +------------ src/OpenClaw.Tray.WinUI/App.xaml.cs | 136 ++++--------- .../DashboardGatewaySnapshotResolver.cs | 184 ------------------ .../SshTunnelServiceTests.cs | 140 +------------ .../AppRefactorContractTests.cs | 34 ++-- .../ConnectionRegressionSourceTests.cs | 2 +- .../DashboardGatewaySnapshotResolverTests.cs | 72 ------- .../OpenClaw.Tray.Tests.csproj | 1 - 9 files changed, 66 insertions(+), 635 deletions(-) delete mode 100644 src/OpenClaw.Tray.WinUI/Services/DashboardGatewaySnapshotResolver.cs delete mode 100644 tests/OpenClaw.Tray.Tests/DashboardGatewaySnapshotResolverTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 82fe88ba6..5acb83a9f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -246,8 +246,6 @@ leading and trailing pipe. Columns, in order: | exec-carrier-cwd-ambiguity-check | closed | src/OpenClaw.Shared/ExecApprovals/ExecReusableCommandBinder.cs | deciding whether a carrier payload may be durably approved when the working directory could shadow it | CanonicalCmdCarrier.TryBuildPinnedCarrier (payload executable pinning) | - | the approval-time working-directory check is deleted, not merely bypassed: ExecCommandResolver exposes no HasCurrentDirectoryCandidate, a trusted carrier's payload executable is pinned to its resolved absolute path so cmd has nothing to search for, and a post-approval shadow cannot win | ExecReusableCommandBinderTests.PinnedCarrier_IgnoresShadowInsertedAfterApproval | behavioral | - | | exec-legacy-host-quarantine | authoritative | src/OpenClaw.Shared/ExecApprovals/ExecCommandToken.cs | deciding whether a provenance-less path-only allowlist entry authorizes an interpreter or code host | ExecAllowlistMatcher.MatchInternal via ExecCommandToken.IsLegacyQuarantinedHost | argument binding remains the security boundary for every rule this node generates | an allowlist entry with no source and no argPattern is inert when its resolved target is a command host the previous model refused, is never deleted or migrated, and is superseded only by an explicit allow-always sibling carrying source and argPattern | ExecAllowlistArgBindingTests.LegacyPathOnlyEntryForACommandHost_IsInert | behavioral | - | | app-ssh-restart-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs and ConnectionPage.xaml.cs | stopping, starting, reconnecting, and declaring success for a user-requested SSH tunnel restart | GatewayConnectionManager.RestartSshTunnelAsync | App and ConnectionPage invoke the manager and present the result | a restart succeeds only after a fresh generation-bound hello-ok and current registry, config, tunnel generation, and owned listener verification | AppRefactorContractTests.UserSshRestart_StaysDelegatedToConnectionManager | source-shape | when App no longer owns any SSH tunnel UI actions | -| dashboard-gateway-snapshot | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | selecting and comparing the registry or legacy gateway record and resolving its dashboard credential endpoint | DashboardGatewaySnapshotResolver | App owns the browser launch and presents connection or SSH errors | a token-bearing SSH dashboard URL is resolved from one immutable gateway snapshot and uses that snapshot's local forward only after the same tunnel lease is owned | DashboardGatewaySnapshotResolverTests.ActiveSshRecord_ResolvesForwardAndDetectsSecurityRelevantChange | behavioral | - | -| app-dashboard-snapshot-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | private gateway snapshot selection, security-relevant equality, identity-directory selection, and forwarded endpoint resolution for dashboard launch | DashboardGatewaySnapshotResolver | App captures the snapshot, awaits tunnel ownership, invokes the resolver, and performs the browser handoff | App cannot independently mix settings SSH state, a registry credential, and a different dashboard endpoint | AppRefactorContractTests.Dashboard_HoldsSameGatewayAndTunnelOwnershipThroughBrowserLaunch | source-shape | when App no longer owns the dashboard browser action | | hub-page-registry | authoritative | src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs and GatewayNavVisibilityDebouncePolicy | navigation aliases, page mapping, command metadata and search, and gateway-page classification | HubPageRegistry | HubWindow keeps Frame and NavigationView application, back-stack mutation, command cache lifetime, and semantic action execution; GatewayNavVisibilityDebouncePolicy keeps disconnect timing | every current direct, legacy, and agent-scoped tag resolves identically; command order, titles, actions, search caps, and gateway prune set remain stable | HubPageRegistryTests.BuildCommands_PreservesBaseOrderActionsIconsAndResourceKeys | behavioral | - | | hub-page-registry-closed | closed | src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs and GatewayNavVisibilityDebouncePolicy | private tag/page switches, command catalogs or search predicates, and gateway-page tag lists | HubPageRegistry | view-only navigation application and debounce timing listed in hub-page-registry | HubWindow and the debounce policy do not regain catalog or page-classification copies | HubPresentationContractTests.HubPageRegistry_OwnsMappingsCommandsAndGatewayClassification | source-shape | when HubWindow is replaced by a different shell and GatewayNavVisibilityDebouncePolicy is retired | | app-notification-infobar-presentation | authoritative | src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs | banner severity filtering, selected-banner fallback, notification action versus Show more, and action enabled state | AppNotificationInfoBarPresenter | HubWindow keeps notification subscription, bell reconciliation, WinUI control assignment, navigation, and dismissal side effects | Warning and Error banners retain priority and hiding semantics while action projection stays WinUI-free | AppNotificationInfoBarPresenterTests.Present_ActionableNotificationWinsOverShowMore | behavioral | - | diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index fdf24c589..9ab063727 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -168,8 +168,7 @@ public void EnsureStarted(string user, string host, int remotePort, int localPor private void EnsureStartedCore( SshTunnelConfig tunnel, SshTunnelOwner owner, - Action? beforeStart = null, - Action? generationCaptured = null) + Action? beforeStart = null) { lock (_operationLock) { @@ -191,16 +190,11 @@ private void EnsureStartedCore( { _currentOwner = ResolveOwnerForReuse(_currentOwner, owner); Status = TunnelStatus.Up; - generationCaptured?.Invoke(_lifecycleGeneration); return; } } StopLocked(); - lock (_stateLock) - { - generationCaptured?.Invoke(_lifecycleGeneration); - } beforeStart?.Invoke(tunnel); lock (_stateLock) { @@ -564,7 +558,7 @@ _process is null || public async Task StartAsync(SshTunnelConfig config, CancellationToken ct) => (await StartOwnedAsync(config, ct).ConfigureAwait(false)).Url; - public async Task EnsureSettingsOwnedForwardReadyAsync( + public async Task EnsureSettingsOwnedForwardReadyAsync( SshTunnelConfig config, CancellationToken cancellationToken) { @@ -576,8 +570,7 @@ public async Task StartAsync(SshTunnelConfig config, CancellationToken c EnsureStartedCore( config, SshTunnelOwner.Settings, - tunnel => RejectOccupiedForwardPorts(tunnel), - capturedGeneration => generation = capturedGeneration); + tunnel => RejectOccupiedForwardPorts(tunnel)); var normalizedConfig = config with { @@ -619,130 +612,21 @@ await WaitForOwnedLocalListenerAsync( cancellationToken).ConfigureAwait(false); } - return new SshTunnelStartResult( - $"ws://localhost:{config.LocalPort}", - normalizedConfig, - generation); + return true; } catch (Exception ex) { - TryFailSettingsForwardAttempt(process, generation, ex.Message); - _logger.Warn($"SSH dashboard forward is not owned: {ex.Message}"); - return null; - } - } - - internal bool TryFailSettingsForwardAttempt( - Process? process, - long generation, - string error) - { - lock (_operationLock) - { - lock (_stateLock) - { - if (process is not null) - { - if (generation != _lifecycleGeneration || - !ReferenceEquals(_process, process)) - { - return false; - } - } - else if (generation != _lifecycleGeneration || - IsRunningLocked()) - { - return false; - } - } - if (process is not null) - StopLocked(); + StopIfCurrent(process, generation); lock (_stateLock) { - if (IsRunningLocked()) - return false; - - LastError = error; + LastError = ex.Message; Status = TunnelStatus.Failed; - return true; } - } - } - public bool TryUseOwnedListener( - SshTunnelStartResult ownership, - int destinationPort, - Func use) - { - ArgumentNullException.ThrowIfNull(ownership); - ArgumentNullException.ThrowIfNull(use); - - var config = ownership.Config; - var isConfiguredForward = - destinationPort == config.LocalPort || - (config.IncludeBrowserProxyForward && destinationPort == config.LocalPort + 2); - if (!isConfiguredForward) + _logger.Warn($"SSH dashboard forward is not owned: {ex.Message}"); return false; - - lock (_operationLock) - { - Process process; - int processId; - DateTime processStartTimeUtc; - lock (_stateLock) - { - if (ownership.OwnershipGeneration != _lifecycleGeneration || - !IsRunningLocked() || - _process is null || - !Equals(_currentConfig, config)) - { - return false; - } - - process = _process; - processId = process.Id; - try - { - processStartTimeUtc = process.StartTime.ToUniversalTime(); - } - catch (Exception ex) - { - _logger.Debug($"SSH dashboard ownership process inspection failed: {ex.Message}"); - return false; - } - } - - try - { - if (!ValidateListenerOwnership( - WindowsTcpListenerSnapshot.Capture(), - destinationPort, - processId, - processStartTimeUtc)) - { - return false; - } - } - catch (Exception ex) - { - _logger.Warn($"SSH dashboard ownership verification failed: {ex.Message}"); - return false; - } - - lock (_stateLock) - { - if (ownership.OwnershipGeneration != _lifecycleGeneration || - !ReferenceEquals(_process, process) || - !IsRunningLocked() || - !Equals(_currentConfig, config)) - { - return false; - } - } - - return use(); } } diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 6e990f93f..e05c1f4dd 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -3915,23 +3915,8 @@ private void OpenDashboard(string? path = null) private async Task OpenDashboardAsync(string? path) { - if (_settings == null) - return; - - var snapshotResolver = new DashboardGatewaySnapshotResolver( - _gatewayRegistry, - _settings, - SettingsManager.SettingsDirectoryPath); - if (!snapshotResolver.TryCapture(out var snapshot) || snapshot == null) - { - ShowConnectionSettingsForPairingIssue( - "Dashboard", - "Gateway URL or credential is not configured"); - return; - } - - var ownership = await EnsureDashboardSshForwardOwnedAsync(snapshot.Record.SshTunnel); - if (snapshot.Record.SshTunnel != null && ownership == null) + if (_settings == null) return; + if (!await EnsureDashboardSshForwardOwnedAsync()) { _toastService?.ShowToast(new ToastContentBuilder() .AddText("SSH tunnel") @@ -3939,19 +3924,7 @@ private async Task OpenDashboardAsync(string? path) return; } - if (!snapshotResolver.IsCurrent(snapshot)) - { - ShowTransientConnectionError( - "Dashboard was not opened because the active gateway changed."); - return; - } - - var dashboardAuth = snapshotResolver.ResolveCredentials( - snapshot, - DeviceIdentityFileReader.Instance, - (record, candidate) => - _managedLocalPortProvenance?.IsStrongCredentialAllowed(record, candidate) == true); - if (dashboardAuth == null) + if (!TryResolveChatCredentials(out var gatewayUrl, out var token, out var credentialSource, out var isBootstrapToken)) { ShowConnectionSettingsForPairingIssue( "Dashboard", @@ -3960,55 +3933,13 @@ private async Task OpenDashboardAsync(string? path) } var url = GatewayDashboardUrlBuilder.Build( - dashboardAuth.GatewayUrl, + gatewayUrl, path, - dashboardAuth.Token, - !dashboardAuth.IsBootstrapToken && - dashboardAuth.Source == CredentialResolver.SourceSharedGatewayToken); + token, + !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken); try { - if (ownership != null) - { - var gatewayChanged = false; - var launched = _sshTunnelService?.TryUseOwnedListener( - ownership, - ownership.Config.LocalPort, - () => - { - if (!snapshotResolver.IsCurrent(snapshot)) - { - gatewayChanged = true; - return false; - } - - Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); - return true; - }) == true; - if (!launched) - { - if (gatewayChanged) - { - ShowTransientConnectionError( - "Dashboard was not opened because the active gateway changed."); - } - else - { - _toastService?.ShowToast(new ToastContentBuilder() - .AddText("SSH tunnel") - .AddText("Dashboard was not opened because SSH forward ownership changed.")); - } - } - return; - } - - if (!snapshotResolver.IsCurrent(snapshot)) - { - ShowTransientConnectionError( - "Dashboard was not opened because the active gateway changed."); - return; - } - Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); } catch (Exception ex) @@ -4337,49 +4268,66 @@ private void SetChatSpeakerMuted(bool muted, SettingsWriteOrigin? origin) #endregion - private async Task EnsureDashboardSshForwardOwnedAsync( - SshTunnelConfig? tunnel) + private async Task EnsureDashboardSshForwardOwnedAsync() { - if (tunnel == null) - return null; + if (_settings == null) + { + return false; + } + + if (!_settings.UseSshTunnel) + { + _sshTunnelService?.Stop(); + return true; + } - if (string.IsNullOrWhiteSpace(tunnel.User) || - string.IsNullOrWhiteSpace(tunnel.Host) || - tunnel.RemotePort is < 1 or > 65535 || - tunnel.LocalPort is < 1 or > 65535 || - tunnel.SshPort is < 1 or > 65535) + 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 null; + return false; } try { _sshTunnelService ??= new SshTunnelService(new AppLogger()); - var ownership = await _sshTunnelService.EnsureSettingsOwnedForwardReadyAsync( - tunnel, + var includeBrowserProxy = BrowserProxySshTunnelForwardPolicy.ShouldInclude( + _settings.NodeBrowserProxyEnabled, + _settings.SshTunnelRemotePort, + _settings.SshTunnelLocalPort); + var owned = await _sshTunnelService.EnsureSettingsOwnedForwardReadyAsync( + new SshTunnelConfig( + _settings.SshTunnelUser, + _settings.SshTunnelHost, + _settings.SshTunnelRemotePort, + _settings.SshTunnelLocalPort, + includeBrowserProxy, + _settings.SshTunnelSshPort), CancellationToken.None); DiagnosticsJsonlService.Write("tunnel.ensure_started", new { status = _sshTunnelService.Status.ToString(), - localEndpoint = $"127.0.0.1:{tunnel.LocalPort}", - remoteHost = tunnel.Host, - remotePort = tunnel.RemotePort + localEndpoint = $"127.0.0.1:{_settings.SshTunnelLocalPort}", + remoteHost = string.IsNullOrWhiteSpace(_settings.SshTunnelHost) ? null : _settings.SshTunnelHost, + remotePort = _settings.SshTunnelRemotePort }); - if (ownership == null) + if (!owned) { UpdateTrayIcon(); + return false; } - - return ownership; } catch (Exception ex) { Logger.Error($"Failed to start SSH tunnel: {ex.Message}"); UpdateTrayIcon(); - return null; + return false; } + + return true; } private void OnSshTunnelExited(object? sender, SshTunnelExit tunnelExit) => diff --git a/src/OpenClaw.Tray.WinUI/Services/DashboardGatewaySnapshotResolver.cs b/src/OpenClaw.Tray.WinUI/Services/DashboardGatewaySnapshotResolver.cs deleted file mode 100644 index 2455f8918..000000000 --- a/src/OpenClaw.Tray.WinUI/Services/DashboardGatewaySnapshotResolver.cs +++ /dev/null @@ -1,184 +0,0 @@ -using OpenClaw.Connection; -using OpenClaw.Shared; - -namespace OpenClawTray.Services; - -internal sealed record DashboardGatewaySnapshot( - GatewayRecord Record, - bool IsRegistryRecord, - string? LegacyPrimary, - string? LegacyBootstrap); - -internal sealed class DashboardGatewaySnapshotResolver -{ - private readonly GatewayRegistry? _registry; - private readonly SettingsManager _settings; - private readonly string _legacyIdentityDirectory; - - public DashboardGatewaySnapshotResolver( - GatewayRegistry? registry, - SettingsManager settings, - string legacyIdentityDirectory) - { - ArgumentNullException.ThrowIfNull(settings); - ArgumentException.ThrowIfNullOrWhiteSpace(legacyIdentityDirectory); - _registry = registry; - _settings = settings; - _legacyIdentityDirectory = legacyIdentityDirectory; - } - - public bool TryCapture(out DashboardGatewaySnapshot? snapshot) - { - if (_registry?.GetActive() is { } active) - { - snapshot = new DashboardGatewaySnapshot( - active, - IsRegistryRecord: true, - LegacyPrimary: null, - LegacyBootstrap: null); - return true; - } - - var gatewayUrl = _settings.GetEffectiveGatewayUrl(); - if (string.IsNullOrWhiteSpace(gatewayUrl)) - { - snapshot = null; - return false; - } - - SshTunnelConfig? tunnel = null; - if (_settings.UseSshTunnel) - { - tunnel = new SshTunnelConfig( - _settings.SshTunnelUser ?? "", - _settings.SshTunnelHost ?? "", - _settings.SshTunnelRemotePort, - _settings.SshTunnelLocalPort, - BrowserProxySshTunnelForwardPolicy.ShouldInclude( - _settings.NodeBrowserProxyEnabled, - _settings.SshTunnelRemotePort, - _settings.SshTunnelLocalPort), - _settings.SshTunnelSshPort); - } - - snapshot = new DashboardGatewaySnapshot( - new GatewayRecord - { - Id = "legacy-settings", - Url = gatewayUrl, - IsLocal = GatewayRecordEditing.IsLoopbackEndpoint(gatewayUrl), - SshTunnel = tunnel, - }, - false, - _settings.LegacyToken, - _settings.LegacyBootstrapToken); - return true; - } - - public bool IsCurrent(DashboardGatewaySnapshot snapshot) - { - ArgumentNullException.ThrowIfNull(snapshot); - if (snapshot.IsRegistryRecord) - { - return _registry?.GetActive() is { } active && - RecordsMatch(snapshot.Record, active); - } - - return _registry?.GetActive() == null && - TryCapture(out var current) && - current is { IsRegistryRecord: false } && - RecordsMatch(snapshot.Record, current.Record) && - string.Equals( - snapshot.LegacyPrimary, - current.LegacyPrimary, - StringComparison.Ordinal) && - string.Equals( - snapshot.LegacyBootstrap, - current.LegacyBootstrap, - StringComparison.Ordinal); - } - - public InteractiveGatewayCredential? ResolveCredentials( - DashboardGatewaySnapshot snapshot, - IDeviceIdentityReader identityReader, - Func? authorizeCredential) - { - ArgumentNullException.ThrowIfNull(snapshot); - ArgumentNullException.ThrowIfNull(identityReader); - if (!snapshot.IsRegistryRecord) - { - return InteractiveGatewayCredentialResolver.TryResolve( - null, - _legacyIdentityDirectory, - identityReader, - snapshot.Record.Url, - snapshot.LegacyPrimary, - snapshot.LegacyBootstrap, - authorizeCredential, - out var legacyResult) - ? legacyResult - : null; - } - - var record = snapshot.Record; - var identityDirectory = snapshot.IsRegistryRecord && _registry != null - ? _registry.GetIdentityDirectory(snapshot.Record.Id) - : _legacyIdentityDirectory; - GatewayCredential? resolved; - if (!string.IsNullOrWhiteSpace(record.SharedGatewayToken)) - { - resolved = new GatewayCredential( - record.SharedGatewayToken!, - IsBootstrapToken: false, - CredentialResolver.SourceSharedGatewayToken); - } - else - { - resolved = new CredentialResolver(identityReader) - .ResolveOperator(record, identityDirectory); - } - - if (resolved == null || - (authorizeCredential is not null && !authorizeCredential(record, resolved))) - { - return null; - } - - string gatewayUrl; - try - { - gatewayUrl = GatewayClientEndpointResolver.Resolve(record); - } - catch (InvalidOperationException) - { - return null; - } - - return new InteractiveGatewayCredential( - gatewayUrl, - resolved.Token, - resolved.IsBootstrapToken, - resolved.Source); - } - - internal static bool RecordsMatch( - GatewayRecord expected, - GatewayRecord current) => - string.Equals(expected.Id, current.Id, StringComparison.Ordinal) && - string.Equals(expected.Url, current.Url, StringComparison.Ordinal) && - string.Equals( - expected.SharedGatewayToken, - current.SharedGatewayToken, - StringComparison.Ordinal) && - string.Equals( - expected.BootstrapToken, - current.BootstrapToken, - StringComparison.Ordinal) && - expected.IsLocal == current.IsLocal && - expected.RequiresV2Signature == current.RequiresV2Signature && - string.Equals( - expected.SetupManagedDistroName, - current.SetupManagedDistroName, - StringComparison.Ordinal) && - Equals(expected.SshTunnel, current.SshTunnel); -} diff --git a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs index 4824aeb98..886a6e807 100644 --- a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs @@ -1,5 +1,4 @@ using OpenClaw.Shared; -using System.Diagnostics; using System.Net; using System.Net.Sockets; @@ -347,11 +346,11 @@ public async Task EnsureSettingsOwnedForwardReadyAsync_RejectsForeignLocalListen var port = ((IPEndPoint)listener.LocalEndpoint).Port; using var service = new SshTunnelService(NullLogger.Instance); - var ownership = await service.EnsureSettingsOwnedForwardReadyAsync( + var ready = await service.EnsureSettingsOwnedForwardReadyAsync( new SshTunnelConfig("user", "host", 18789, port), CancellationToken.None); - Assert.Null(ownership); + Assert.False(ready); Assert.False(service.IsRunning); Assert.NotEqual(TunnelStatus.Up, service.Status); Assert.Contains(port.ToString(), service.LastError); @@ -367,7 +366,7 @@ public async Task EnsureSettingsOwnedForwardReadyAsync_RejectsForeignBrowserProx var localPort = proxyPort - 2; using var service = new SshTunnelService(NullLogger.Instance); - var ownership = await service.EnsureSettingsOwnedForwardReadyAsync( + var ready = await service.EnsureSettingsOwnedForwardReadyAsync( new SshTunnelConfig( "user", "host", @@ -376,110 +375,13 @@ public async Task EnsureSettingsOwnedForwardReadyAsync_RejectsForeignBrowserProx IncludeBrowserProxyForward: true), CancellationToken.None); - Assert.Null(ownership); + Assert.False(ready); Assert.False(service.IsRunning); Assert.NotEqual(TunnelStatus.Up, service.Status); Assert.NotNull(service.LastError); Assert.Contains("already owned", service.LastError); } - [Fact] - public void SettingsForwardFailure_DoesNotOverwriteNewerRunningTunnel() - { - using var service = new SshTunnelService(NullLogger.Instance); - using var currentProcess = Process.GetCurrentProcess(); - using var staleProcess = new Process(); - var config = new SshTunnelConfig("user", "host", 18789, 45678); - InjectRunningTunnel(service, currentProcess, config, generation: 42); - try - { - var published = service.TryFailSettingsForwardAttempt( - staleProcess, - generation: 41, - "older attempt failed"); - - Assert.False(published); - Assert.True(service.IsRunning); - Assert.Equal(TunnelStatus.Up, service.Status); - Assert.Null(service.LastError); - } - finally - { - DetachInjectedProcess(service); - } - } - - [Fact] - public void SettingsForwardFailure_DoesNotOverwriteNewerFailedTunnel() - { - using var service = new SshTunnelService(NullLogger.Instance); - SetPrivateField(service, "_lifecycleGeneration", 42L); - typeof(SshTunnelService) - .GetProperty(nameof(SshTunnelService.Status))! - .SetValue(service, TunnelStatus.Failed); - typeof(SshTunnelService) - .GetProperty(nameof(SshTunnelService.LastError))! - .SetValue(service, "newer tunnel exited"); - - var published = service.TryFailSettingsForwardAttempt( - process: null, - generation: 41, - "older attempt failed"); - - Assert.False(published); - Assert.Equal(TunnelStatus.Failed, service.Status); - Assert.Equal("newer tunnel exited", service.LastError); - } - - [Fact] - public void TryUseOwnedListener_InvokesCallbackOnlyForCurrentOwnership() - { - using var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - using var service = new SshTunnelService(NullLogger.Instance); - using var currentProcess = Process.GetCurrentProcess(); - var config = new SshTunnelConfig("user", "host", 18789, port); - InjectRunningTunnel(service, currentProcess, config, generation: 42); - try - { - var ownership = new SshTunnelStartResult( - $"ws://localhost:{port}", - config, - OwnershipGeneration: 42); - var invoked = false; - - var used = service.TryUseOwnedListener( - ownership, - port, - () => - { - invoked = true; - return true; - }); - - Assert.True(used); - Assert.True(invoked); - - invoked = false; - used = service.TryUseOwnedListener( - ownership with { OwnershipGeneration = 41 }, - port, - () => - { - invoked = true; - return true; - }); - - Assert.False(used); - Assert.False(invoked); - } - finally - { - DetachInjectedProcess(service); - } - } - [Fact] public void ListenerOwnershipChecks_FailClosedWhenSnapshotIsIncomplete() { @@ -497,38 +399,4 @@ public void ListenerOwnershipChecks_FailClosedWhenSnapshotIsIncomplete() 4321, DateTime.UtcNow)); } - - private static void InjectRunningTunnel( - SshTunnelService service, - Process process, - SshTunnelConfig config, - long generation) - { - SetPrivateField(service, "_process", process); - SetPrivateField(service, "_processStarted", true); - SetPrivateField(service, "_currentConfig", config); - SetPrivateField(service, "_currentOwner", SshTunnelOwner.Settings); - SetPrivateField(service, "_lifecycleGeneration", generation); - typeof(SshTunnelService) - .GetProperty(nameof(SshTunnelService.Status))! - .SetValue(service, TunnelStatus.Up); - } - - private static void DetachInjectedProcess(SshTunnelService service) - { - SetPrivateField(service, "_process", null); - SetPrivateField(service, "_processStarted", false); - SetPrivateField(service, "_currentConfig", null); - } - - private static void SetPrivateField( - SshTunnelService service, - string name, - T value) => - typeof(SshTunnelService) - .GetField( - name, - System.Reflection.BindingFlags.Instance | - System.Reflection.BindingFlags.NonPublic)! - .SetValue(service, value); } diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index bc24409ee..f24cec6a5 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -472,44 +472,34 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() var source = ReadAppSources(); var method = ExtractMethod(source, "OpenDashboardAsync"); - Assert.Contains( - "await EnsureDashboardSshForwardOwnedAsync(snapshot.Record.SshTunnel)", - method); + Assert.Contains("if (!await EnsureDashboardSshForwardOwnedAsync())", method); Assert.Contains("_toastService?.ShowToast", method); Assert.Contains("Check SSH tunnel settings and logs.", method); AssertInOrder( method, - "new DashboardGatewaySnapshotResolver(", - "snapshotResolver.TryCapture(", - "await EnsureDashboardSshForwardOwnedAsync(snapshot.Record.SshTunnel)", - "snapshotResolver.ResolveCredentials(", + "await EnsureDashboardSshForwardOwnedAsync()", "GatewayDashboardUrlBuilder.Build("); Assert.DoesNotContain("EnsureStarted(", method); } [Fact] - public void Dashboard_HoldsSameGatewayAndTunnelOwnershipThroughBrowserLaunch() + public void Dashboard_AwaitsSettingsOwnedForwardBeforeTokenUrl() { var source = ReadAppSources(); - var method = ExtractMethod(source, "OpenDashboardAsync"); var gate = ExtractMethod(source, "EnsureDashboardSshForwardOwnedAsync"); - Assert.Contains("snapshotResolver.IsCurrent(snapshot)", method); - Assert.Contains("_sshTunnelService?.TryUseOwnedListener(", method); - Assert.Contains("ownership.Config.LocalPort", method); - Assert.Contains("Process.Start(", method); - AssertInOrder( - method, - "_sshTunnelService?.TryUseOwnedListener(", - "snapshotResolver.IsCurrent(snapshot)", - "Process.Start("); - Assert.Contains("SshTunnelConfig? tunnel", gate); + Assert.Contains("if (!_settings.UseSshTunnel)", gate); + Assert.Contains("_sshTunnelService?.Stop()", gate); + Assert.Contains("return true;", gate); Assert.Contains("EnsureSettingsOwnedForwardReadyAsync(", gate); - Assert.DoesNotContain("_settings.UseSshTunnel", gate); + Assert.Contains("if (!owned)", gate); Assert.DoesNotContain("GatewayDashboardUrlBuilder.Build(", gate); Assert.DoesNotContain("EnsureStarted(", gate); - Assert.Contains("var gatewayChanged = false;", method); - Assert.Contains("if (gatewayChanged)", method); + AssertInOrder( + gate, + "if (!_settings.UseSshTunnel)", + "EnsureSettingsOwnedForwardReadyAsync(", + "if (!owned)"); } [Fact] diff --git a/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs index 929270322..6d494f63f 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs @@ -7,7 +7,7 @@ public void Dashboard_TokenQuery_IsLimitedToSharedGatewayToken() { var appSource = ReadSource("src", "OpenClaw.Tray.WinUI", "App.xaml.cs"); - Assert.Contains("dashboardAuth.Source == CredentialResolver.SourceSharedGatewayToken", appSource); + Assert.Contains("credentialSource == CredentialResolver.SourceSharedGatewayToken", appSource); Assert.DoesNotContain("if (!isBootstrapToken && !string.IsNullOrEmpty(token))", appSource); } diff --git a/tests/OpenClaw.Tray.Tests/DashboardGatewaySnapshotResolverTests.cs b/tests/OpenClaw.Tray.Tests/DashboardGatewaySnapshotResolverTests.cs deleted file mode 100644 index 5cb71f893..000000000 --- a/tests/OpenClaw.Tray.Tests/DashboardGatewaySnapshotResolverTests.cs +++ /dev/null @@ -1,72 +0,0 @@ -using OpenClaw.Connection; -using OpenClaw.Shared; -using OpenClaw.TestSupport; -using OpenClawTray.Services; - -namespace OpenClaw.Tray.Tests; - -public sealed class DashboardGatewaySnapshotResolverTests -{ - [Fact] - public void ActiveSshRecord_ResolvesForwardAndDetectsSecurityRelevantChange() - { - using var temp = new TempDirectory(); - var settings = new SettingsManager(temp.Path); - var registry = new GatewayRegistry(temp.Path); - var record = new GatewayRecord - { - Id = "gateway-1", - Url = "wss://gateway.example", - SshTunnel = new SshTunnelConfig("user", "ssh.example", 18789, 45678), - SharedGatewayToken = "test-auth-token", - }; - registry.AddOrUpdate(record); - registry.SetActive(record.Id); - var resolver = new DashboardGatewaySnapshotResolver( - registry, - settings, - temp.Path); - - Assert.True(resolver.TryCapture(out var snapshot)); - Assert.NotNull(snapshot); - Assert.True(resolver.IsCurrent(snapshot!)); - var result = resolver.ResolveCredentials( - snapshot!, - new EmptyIdentityReader(), - authorizeCredential: (_, _) => true); - Assert.NotNull(result); - Assert.Equal("ws://localhost:45678", result!.GatewayUrl); - Assert.Equal("test-auth-token", result.Token); - - registry.AddOrUpdate(record with { SharedGatewayToken = "secret-token" }); - - Assert.False(resolver.IsCurrent(snapshot)); - } - - [Fact] - public void RecordsMatch_IgnoresPresentationOnlyChanges() - { - var expected = new GatewayRecord - { - Id = "gateway-1", - Url = "wss://gateway.example", - FriendlyName = "Original", - LastConnected = DateTime.UtcNow.AddDays(-1), - SharedGatewayToken = "test-auth-token", - }; - var current = expected with - { - FriendlyName = "Renamed", - LastConnected = DateTime.UtcNow, - }; - - Assert.True(DashboardGatewaySnapshotResolver.RecordsMatch(expected, current)); - } - - private sealed class EmptyIdentityReader : IDeviceIdentityReader - { - public string? TryReadStoredDeviceToken(string dataPath) => null; - - public string? TryReadStoredNodeDeviceToken(string dataPath) => null; - } -} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 3ef198afe..3812cd5a2 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -128,7 +128,6 @@ - From d135659fdfc452017bb80697bd890ae9243a9c9c Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sun, 27 Sep 2026 13:00:03 -0700 Subject: [PATCH 04/27] fix(connection): recheck SSH ownership before the dashboard opens The listener check returned before the shared-token URL was handed to the browser, and an older wait wrote Failed onto a replacement tunnel. Dashboard launch now keeps the generation from the ready check and refuses to start the browser if that generation is no longer the settings-owned forward. A wait that has already lost the process does not publish failure. Tests: EnsureSettingsOwnedForwardReadyAsync 2 passed. Dashboard contract tests 2 passed. ./build.ps1 exit 0. Shared 4107 passed, 32 skipped. Tray 3116 passed. Signed-off-by: Sebastien Tardif --- src/OpenClaw.Connection/SshTunnelService.cs | 25 +++++++++++++++++ src/OpenClaw.Tray.WinUI/App.xaml.cs | 28 +++++++++++++------ .../SshTunnelServiceTests.cs | 1 + .../AppRefactorContractTests.cs | 11 ++++++-- 4 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index 9ab063727..de67b0181 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -616,6 +616,20 @@ await WaitForOwnedLocalListenerAsync( } 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 false; + } + if (process is not null) StopIfCurrent(process, generation); @@ -630,6 +644,17 @@ await WaitForOwnedLocalListenerAsync( } } + public bool IsSettingsOwnedForwardCurrent(long generation, int localPort) + { + lock (_stateLock) + { + return generation == _lifecycleGeneration && + IsRunningLocked() && + _currentOwner == SshTunnelOwner.Settings && + _currentConfig?.LocalPort == localPort; + } + } + private static void RejectOccupiedForwardPorts(SshTunnelConfig tunnel) { EnsurePortIsUnoccupied(WindowsTcpListenerSnapshot.Capture(), tunnel.LocalPort); diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index e05c1f4dd..72da42f6d 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -3916,7 +3916,8 @@ private void OpenDashboard(string? path = null) private async Task OpenDashboardAsync(string? path) { if (_settings == null) return; - if (!await EnsureDashboardSshForwardOwnedAsync()) + var sshGate = await EnsureDashboardSshForwardOwnedAsync(); + if (!sshGate.Allowed) { _toastService?.ShowToast(new ToastContentBuilder() .AddText("SSH tunnel") @@ -3938,6 +3939,15 @@ private async Task OpenDashboardAsync(string? path) token, !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken); + if (sshGate.Generation is long generation && + _sshTunnelService?.IsSettingsOwnedForwardCurrent(generation, sshGate.LocalPort) != true) + { + _toastService?.ShowToast(new ToastContentBuilder() + .AddText("SSH tunnel") + .AddText("SSH tunnel changed before the dashboard could open.")); + return; + } + try { Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); @@ -4268,17 +4278,17 @@ private void SetChatSpeakerMuted(bool muted, SettingsWriteOrigin? origin) #endregion - private async Task EnsureDashboardSshForwardOwnedAsync() + private async Task<(bool Allowed, long? Generation, int LocalPort)> EnsureDashboardSshForwardOwnedAsync() { if (_settings == null) { - return false; + return (false, null, 0); } if (!_settings.UseSshTunnel) { _sshTunnelService?.Stop(); - return true; + return (true, null, 0); } if (string.IsNullOrWhiteSpace(_settings.SshTunnelUser) || @@ -4288,7 +4298,7 @@ _settings.SshTunnelRemotePort is < 1 or > 65535 || { Logger.Warn("SSH tunnel is enabled but settings are incomplete"); UpdateTrayIcon(); - return false; + return (false, null, 0); } try @@ -4317,17 +4327,17 @@ _settings.SshTunnelRemotePort is < 1 or > 65535 || if (!owned) { UpdateTrayIcon(); - return false; + return (false, null, 0); } + + return (true, _sshTunnelService.OwnershipGeneration, _sshTunnelService.CurrentLocalPort); } catch (Exception ex) { Logger.Error($"Failed to start SSH tunnel: {ex.Message}"); UpdateTrayIcon(); - return false; + return (false, null, 0); } - - return true; } private void OnSshTunnelExited(object? sender, SshTunnelExit tunnelExit) => diff --git a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs index 886a6e807..7827b4fdc 100644 --- a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs @@ -355,6 +355,7 @@ public async Task EnsureSettingsOwnedForwardReadyAsync_RejectsForeignLocalListen 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] diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index f24cec6a5..6984a2ddc 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -472,13 +472,17 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() var source = ReadAppSources(); var method = ExtractMethod(source, "OpenDashboardAsync"); - Assert.Contains("if (!await EnsureDashboardSshForwardOwnedAsync())", method); + Assert.Contains("var sshGate = await EnsureDashboardSshForwardOwnedAsync();", method); + Assert.Contains("if (!sshGate.Allowed)", method); Assert.Contains("_toastService?.ShowToast", method); Assert.Contains("Check SSH tunnel settings and logs.", method); + Assert.Contains("IsSettingsOwnedForwardCurrent(generation, sshGate.LocalPort)", method); AssertInOrder( method, "await EnsureDashboardSshForwardOwnedAsync()", - "GatewayDashboardUrlBuilder.Build("); + "GatewayDashboardUrlBuilder.Build(", + "IsSettingsOwnedForwardCurrent(generation, sshGate.LocalPort)", + "Process.Start("); Assert.DoesNotContain("EnsureStarted(", method); } @@ -490,9 +494,10 @@ public void Dashboard_AwaitsSettingsOwnedForwardBeforeTokenUrl() Assert.Contains("if (!_settings.UseSshTunnel)", gate); Assert.Contains("_sshTunnelService?.Stop()", gate); - Assert.Contains("return true;", gate); + Assert.Contains("return (true, null, 0);", gate); Assert.Contains("EnsureSettingsOwnedForwardReadyAsync(", gate); Assert.Contains("if (!owned)", gate); + Assert.Contains("OwnershipGeneration", gate); Assert.DoesNotContain("GatewayDashboardUrlBuilder.Build(", gate); Assert.DoesNotContain("EnsureStarted(", gate); AssertInOrder( From 79d3c3a33a01ef9341fa92b3ea4c94697169f2b5 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sun, 27 Sep 2026 14:00:31 -0700 Subject: [PATCH 05/27] fix(connection): hold the SSH forward through dashboard launch A manager-owned tunnel was rejected even when it was the verified forward. The failure flag was written after the ownership lock was released, so an older wait could mark a replacement failed. Open Dashboard now accepts a settings or manager owner, requires the dashboard URL to use that local port, and keeps the process alive until the browser launch returns. Tests: EnsureSettingsOwnedForwardReadyAsync 2 passed. Dashboard contract test passed. ./build.ps1 exit 0. Shared 4107 passed, 32 skipped. Tray 3116 passed. Signed-off-by: Sebastien Tardif --- src/OpenClaw.Connection/SshTunnelService.cs | 69 +++++++++++++++---- src/OpenClaw.Tray.WinUI/App.xaml.cs | 32 +++++++-- .../AppRefactorContractTests.cs | 9 ++- 3 files changed, 86 insertions(+), 24 deletions(-) diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index de67b0181..e55d2bac6 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -19,6 +19,8 @@ public sealed class SshTunnelService : ISshTunnelManager private SshTunnelOwner _currentOwner; private string? _lastSpec; private long _lifecycleGeneration; + private int _browserHandoffLeases; + private bool _stopAfterBrowserHandoff; /// Raised when the SSH tunnel exits unexpectedly (not during shutdown). public event EventHandler? TunnelExited; @@ -217,6 +219,12 @@ private void StopLocked() Process? process; lock (_stateLock) { + if (_browserHandoffLeases > 0) + { + _stopAfterBrowserHandoff = true; + return; + } + // Claim and clear the current process before stopping it. Exit callbacks can // then only observe stale ownership and cannot overwrite a replacement. _lifecycleGeneration++; @@ -622,23 +630,19 @@ await WaitForOwnedLocalListenerAsync( 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 false; + } - if (!stillCurrent) - { - _logger.Warn($"SSH dashboard forward wait lost ownership: {ex.Message}"); - return false; + LastError = ex.Message; + Status = TunnelStatus.Failed; } if (process is not null) StopIfCurrent(process, generation); - lock (_stateLock) - { - LastError = ex.Message; - Status = TunnelStatus.Failed; - } - _logger.Warn($"SSH dashboard forward is not owned: {ex.Message}"); return false; } @@ -648,11 +652,46 @@ public bool IsSettingsOwnedForwardCurrent(long generation, int localPort) { lock (_stateLock) { - return generation == _lifecycleGeneration && - IsRunningLocked() && - _currentOwner == SshTunnelOwner.Settings && - _currentConfig?.LocalPort == localPort; + return IsForwardCurrentLocked(generation, localPort); + } + } + + public bool TryEnterBrowserHandoff(long generation, int localPort) + { + lock (_stateLock) + { + if (!IsForwardCurrentLocked(generation, localPort)) + return false; + + _browserHandoffLeases++; + return true; + } + } + + public void ExitBrowserHandoff() + { + var stop = false; + lock (_stateLock) + { + if (_browserHandoffLeases > 0) + _browserHandoffLeases--; + if (_browserHandoffLeases == 0 && _stopAfterBrowserHandoff) + { + _stopAfterBrowserHandoff = false; + stop = true; + } } + + if (stop) + Stop(); + } + + private bool IsForwardCurrentLocked(long generation, int localPort) + { + return generation == _lifecycleGeneration && + IsRunningLocked() && + _currentOwner is SshTunnelOwner.Settings or SshTunnelOwner.GatewayConnectionManager && + _currentConfig?.LocalPort == localPort; } private static void RejectOccupiedForwardPorts(SshTunnelConfig tunnel) diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 72da42f6d..e87810b49 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -3939,13 +3939,19 @@ private async Task OpenDashboardAsync(string? path) token, !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken); - if (sshGate.Generation is long generation && - _sshTunnelService?.IsSettingsOwnedForwardCurrent(generation, sshGate.LocalPort) != true) + var handoff = false; + if (sshGate.Generation is long generation) { - _toastService?.ShowToast(new ToastContentBuilder() - .AddText("SSH tunnel") - .AddText("SSH tunnel changed before the dashboard could open.")); - return; + if (!DashboardUrlUsesOwnedForward(url, sshGate.LocalPort) || + _sshTunnelService?.TryEnterBrowserHandoff(generation, sshGate.LocalPort) != true) + { + _toastService?.ShowToast(new ToastContentBuilder() + .AddText("SSH tunnel") + .AddText("SSH tunnel changed before the dashboard could open.")); + return; + } + + handoff = true; } try @@ -3956,6 +3962,20 @@ private async Task OpenDashboardAsync(string? path) { Logger.Error($"Failed to open dashboard: {ex.Message}"); } + finally + { + if (handoff) + _sshTunnelService?.ExitBrowserHandoff(); + } + } + + private static bool DashboardUrlUsesOwnedForward(string url, int localPort) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + return false; + + var loopback = uri.Host is "127.0.0.1" or "localhost" or "::1"; + return loopback && uri.Port == localPort; } // ── IAppCommands implementation ───────────────────────────────────── diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 6984a2ddc..f1caaa6bb 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -476,13 +476,16 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() Assert.Contains("if (!sshGate.Allowed)", method); Assert.Contains("_toastService?.ShowToast", method); Assert.Contains("Check SSH tunnel settings and logs.", method); - Assert.Contains("IsSettingsOwnedForwardCurrent(generation, sshGate.LocalPort)", method); + Assert.Contains("DashboardUrlUsesOwnedForward(url, sshGate.LocalPort)", method); + Assert.Contains("TryEnterBrowserHandoff(generation, sshGate.LocalPort)", method); + Assert.Contains("ExitBrowserHandoff()", method); AssertInOrder( method, "await EnsureDashboardSshForwardOwnedAsync()", "GatewayDashboardUrlBuilder.Build(", - "IsSettingsOwnedForwardCurrent(generation, sshGate.LocalPort)", - "Process.Start("); + "TryEnterBrowserHandoff(generation, sshGate.LocalPort)", + "Process.Start(", + "ExitBrowserHandoff()"); Assert.DoesNotContain("EnsureStarted(", method); } From d088984b50c1d0ccf31e64619a41e7dc745468b9 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sun, 27 Sep 2026 15:22:37 -0700 Subject: [PATCH 06/27] fix(connection): do not replace an SSH forward during dashboard launch A stop during the browser handoff used to return and let a later start publish a new process over the old listener. A different tunnel spec now fails while that handoff is active, and the old process stays the tracked one. Ending the handoff no longer stops the forward, so the port stays up after the browser is launched. Tests: SshTunnel 67 passed. ./build.ps1 exit 0. Shared 4107 passed, 32 skipped. Signed-off-by: Sebastien Tardif --- src/OpenClaw.Connection/SshTunnelService.cs | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index e55d2bac6..d75b0be79 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -20,7 +20,6 @@ public sealed class SshTunnelService : ISshTunnelManager private string? _lastSpec; private long _lifecycleGeneration; private int _browserHandoffLeases; - private bool _stopAfterBrowserHandoff; /// Raised when the SSH tunnel exits unexpectedly (not during shutdown). public event EventHandler? TunnelExited; @@ -194,6 +193,9 @@ private void EnsureStartedCore( Status = TunnelStatus.Up; return; } + + if (_browserHandoffLeases > 0) + throw new InvalidOperationException("SSH tunnel is held for a dashboard launch."); } StopLocked(); @@ -220,10 +222,7 @@ private void StopLocked() lock (_stateLock) { if (_browserHandoffLeases > 0) - { - _stopAfterBrowserHandoff = true; return; - } // Claim and clear the current process before stopping it. Exit callbacks can // then only observe stale ownership and cannot overwrite a replacement. @@ -670,20 +669,11 @@ public bool TryEnterBrowserHandoff(long generation, int localPort) public void ExitBrowserHandoff() { - var stop = false; lock (_stateLock) { if (_browserHandoffLeases > 0) _browserHandoffLeases--; - if (_browserHandoffLeases == 0 && _stopAfterBrowserHandoff) - { - _stopAfterBrowserHandoff = false; - stop = true; - } } - - if (stop) - Stop(); } private bool IsForwardCurrentLocked(long generation, int localPort) From 0c8c67ce8539771d10cf038785f07a5d1d7f2fb8 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Mon, 28 Sep 2026 08:12:55 -0700 Subject: [PATCH 07/27] fix(connection): keep SSH stop and replacement atomic with the handoff lease A handoff could make StopLocked return while the caller still replaced the tunnel, and StopIfOwnedAsync reported that deferred stop as complete. Signed-off-by: Sebastien Tardif --- src/OpenClaw.Connection/SshTunnelService.cs | 77 ++++++++---- .../SshTunnelServiceTests.cs | 113 ++++++++++++++++++ 2 files changed, 168 insertions(+), 22 deletions(-) diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index d75b0be79..0a35bca18 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -185,6 +185,7 @@ private void EnsureStartedCore( tunnel.IncludeBrowserProxyForward, tunnel.SshPort); + Process? claimedProcess; lock (_stateLock) { if (IsRunningLocked() && string.Equals(_lastSpec, spec, StringComparison.Ordinal)) @@ -196,9 +197,13 @@ private void EnsureStartedCore( 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) { @@ -216,30 +221,50 @@ public void Stop() } } - private void StopLocked() + /// + /// Stops the tracked tunnel. Returns false when a browser handoff lease + /// defers the stop and leaves the process, spec, owner, and status unchanged. + /// + private bool StopLocked() { Process? process; lock (_stateLock) { if (_browserHandoffLeases > 0) - return; + return false; - // 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; + process = ClaimProcessForStopLocked(); } + StopClaimedProcess(process); + return true; + } + + /// + /// 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; @@ -773,8 +798,10 @@ public Task StopIfOwnedAsync( try { ct.ThrowIfCancellationRequested(); + Process? process; lock (_stateLock) { + ct.ThrowIfCancellationRequested(); var normalizedConfig = config with { User = config.User.Trim(), @@ -782,14 +809,16 @@ public Task StopIfOwnedAsync( }; if (_lifecycleGeneration != ownershipGeneration || !Equals(_currentConfig, normalizedConfig) || - _currentOwner != SshTunnelOwner.GatewayConnectionManager) + _currentOwner != SshTunnelOwner.GatewayConnectionManager || + _browserHandoffLeases > 0) { return Task.FromResult(false); } + + process = ClaimProcessForStopLocked(); } - ct.ThrowIfCancellationRequested(); - StopLocked(); + StopClaimedProcess(process); return Task.FromResult(true); } finally @@ -840,16 +869,20 @@ 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); } } diff --git a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs index 7827b4fdc..6b41751e6 100644 --- a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs @@ -1,6 +1,8 @@ using OpenClaw.Shared; +using System.Diagnostics; using System.Net; using System.Net.Sockets; +using System.Reflection; namespace OpenClaw.Connection.Tests; @@ -400,4 +402,115 @@ 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_WhileBrowserHandoffLeaseHeld_ReturnsFalseAndKeepsProcess() + { + 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)); + + try + { + var stopped = await service.StopIfOwnedAsync(config, ownershipGeneration: 4, CancellationToken.None); + + Assert.False(stopped); + 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); + Assert.Equal(SshTunnelOwner.GatewayConnectionManager, CurrentOwner(service)); + } + finally + { + 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; + } } From d6b8ec72c3d2551a9bcb6beeb6dfb2a850ad6e98 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Mon, 28 Sep 2026 09:06:16 -0700 Subject: [PATCH 08/27] fix(connection): finish a deferred SSH stop when the dashboard handoff ends A stop or reset during a browser handoff was dropped, so the forward stayed up after the launch and a reset could report NotConfigured while the process was still running. Signed-off-by: Sebastien Tardif --- src/OpenClaw.Connection/SshTunnelService.cs | 86 +++++++++++++++++-- .../SshTunnelServiceTests.cs | 69 +++++++++++++++ 2 files changed, 149 insertions(+), 6 deletions(-) diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index 0a35bca18..e085f28c3 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -20,6 +20,7 @@ public sealed class SshTunnelService : ISshTunnelManager private string? _lastSpec; private long _lifecycleGeneration; private int _browserHandoffLeases; + private DeferredTunnelStop _deferredStop; /// Raised when the SSH tunnel exits unexpectedly (not during shutdown). public event EventHandler? TunnelExited; @@ -224,6 +225,7 @@ public void Stop() /// /// Stops the tracked tunnel. Returns false when a browser handoff lease /// defers the stop and leaves the process, spec, owner, and status unchanged. + /// The deferred stop completes on the last . /// private bool StopLocked() { @@ -231,9 +233,13 @@ private bool StopLocked() lock (_stateLock) { if (_browserHandoffLeases > 0) + { + RememberDeferredStopLocked(DeferredTunnelStop.Stop); return false; + } process = ClaimProcessForStopLocked(); + _deferredStop = DeferredTunnelStop.None; } StopClaimedProcess(process); @@ -292,12 +298,26 @@ public void ResetNotConfigured() { lock (_operationLock) { - StopLocked(); + Process? process = null; + var deferred = false; lock (_stateLock) { - LastError = null; - Status = TunnelStatus.NotConfigured; + if (_browserHandoffLeases > 0) + { + RememberDeferredStopLocked(DeferredTunnelStop.ResetNotConfigured); + deferred = true; + } + else + { + process = ClaimProcessForStopLocked(); + _deferredStop = DeferredTunnelStop.None; + LastError = null; + Status = TunnelStatus.NotConfigured; + } } + + if (!deferred) + StopClaimedProcess(process); } } @@ -694,11 +714,65 @@ public bool TryEnterBrowserHandoff(long generation, int localPort) public void ExitBrowserHandoff() { - lock (_stateLock) + // 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)) { - if (_browserHandoffLeases > 0) - _browserHandoffLeases--; + Monitor.Enter(_operationLock); + enteredOperationLock = true; } + + try + { + Process? process = null; + var completeStop = false; + lock (_stateLock) + { + 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); + } + } + + /// + /// 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) diff --git a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs index 6b41751e6..78f5e14e7 100644 --- a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs @@ -463,6 +463,75 @@ public async Task StopIfOwnedAsync_WhileBrowserHandoffLeaseHeld_ReturnsFalseAndK } } + [Fact] + public void ResetNotConfigured_DuringBrowserHandoff_DoesNotReportNotConfiguredUntilLeaseEnds() + { + 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(); + + var held = Assert.Throws( + () => service.EnsureStarted("other", "host", 18789, 45679)); + Assert.Equal("SSH tunnel is held for a dashboard launch.", held.Message); + Assert.Same(process, TrackedProcess(service)); + Assert.False(process.HasExited); + Assert.Equal(TunnelStatus.Up, service.Status); + Assert.NotEqual(TunnelStatus.NotConfigured, service.Status); + Assert.Equal("tunnel still up", service.LastError); + Assert.True(service.IsRunning); + + service.ExitBrowserHandoff(); + + Assert.Same(process, TrackedProcess(service)); + Assert.False(process.HasExited); + Assert.Equal(TunnelStatus.Up, service.Status); + Assert.Equal("tunnel still up", service.LastError); + + service.ExitBrowserHandoff(); + + 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_DuringBrowserHandoff_KillsProcessWhenLeaseEnds() + { + 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.Same(process, TrackedProcess(service)); + Assert.False(process.HasExited); + Assert.Equal(TunnelStatus.Up, service.Status); + Assert.True(service.IsRunning); + + service.ExitBrowserHandoff(); + + Assert.Null(TrackedProcess(service)); + Assert.Throws(() => process.HasExited); + Assert.Equal(TunnelStatus.Stopped, service.Status); + Assert.False(service.IsRunning); + } + private static Process PlantRunningTunnel( SshTunnelService service, SshTunnelConfig config, From 8e7492867b0c9f050751d6137a2c71197f639a07 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 29 Sep 2026 10:33:27 -0700 Subject: [PATCH 09/27] fix(connection): keep the checked gateway on its SSH forward until the browser connects Open Dashboard waited for one SSH forward, then read the active gateway again. A different gateway on the same local port could send its token through the forward that had just been checked. The launch now keeps one gateway identity, tunnel destination, and token snapshot through that wait and the browser handoff. A token rotation on that same gateway and destination still opens. Process.Start returns before the browser connects. A stop recorded during launch used to run as soon as the handoff method finished, which freed the port while the browser still held the token URL. A stop that is already waiting skips the launch and completes. A stop that arrives during launch keeps the listener until an established loopback connection uses the forward, or until ssh exits. If that connection does not show up, the listener stays up. Signed-off-by: Sebastien Tardif --- .../DashboardBrowserHandoff.cs | 59 ++++++ src/OpenClaw.Connection/SshTunnelService.cs | 91 ++++++++++ .../WindowsTcpListenerSnapshot.cs | 121 +++++++++++++ src/OpenClaw.Tray.WinUI/App.xaml.cs | 171 +++++++++++++----- .../DashboardBrowserHandoffTests.cs | 82 +++++++++ .../SshTunnelServiceTests.cs | 104 +++++++++++ .../WindowsTcpListenerSnapshotTests.cs | 37 ++++ .../AppRefactorContractTests.cs | 14 +- 8 files changed, 635 insertions(+), 44 deletions(-) create mode 100644 src/OpenClaw.Connection/DashboardBrowserHandoff.cs create mode 100644 tests/OpenClaw.Connection.Tests/DashboardBrowserHandoffTests.cs diff --git a/src/OpenClaw.Connection/DashboardBrowserHandoff.cs b/src/OpenClaw.Connection/DashboardBrowserHandoff.cs new file mode 100644 index 000000000..833f2085a --- /dev/null +++ b/src/OpenClaw.Connection/DashboardBrowserHandoff.cs @@ -0,0 +1,59 @@ +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); + +/// +/// 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 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/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index e085f28c3..f6288365b 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -52,6 +52,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 @@ -758,6 +781,74 @@ public void ExitBrowserHandoff() } } + /// + /// 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 async Task WatchBrowserHandoffConsumptionAsync( + int localPort, + TimeSpan timeout, + Func? consumptionProbe, + CancellationToken cancellationToken = default) + { + var deadline = DateTime.UtcNow + timeout; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + if (BrowserHandoffLeaseCount <= 0) + return true; + + var consumed = consumptionProbe != null + ? consumptionProbe(localPort) + : WindowsTcpListenerSnapshot.HasEstablishedLoopbackConnection(localPort); + if ((consumed || !IsRunning) && + (TryCompleteDeferredBrowserHandoff(localPort) || 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); + } + } + /// /// Caller holds . A reset replaces a plain stop. /// A later stop does not downgrade a reset. diff --git a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs index 76b490afd..c8c90c04b 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,45 @@ 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? GetProcessCommandLine(int processId) { if (processId <= 0) @@ -215,6 +256,85 @@ private static bool CaptureTable( return false; } + 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 +365,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.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index e87810b49..bf4766aa2 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -3915,8 +3915,12 @@ private void OpenDashboard(string? path = null) private async Task OpenDashboardAsync(string? path) { - if (_settings == null) return; - var sshGate = await EnsureDashboardSshForwardOwnedAsync(); + if (_settings == null) + return; + + var capturedTunnel = _settings.UseSshTunnel ? CreateDashboardSshTunnelOrNull() : null; + var hadCredentials = TryCaptureDashboardGatewaySnapshot(capturedTunnel, out var before); + var sshGate = await EnsureDashboardSshForwardOwnedAsync(capturedTunnel); if (!sshGate.Allowed) { _toastService?.ShowToast(new ToastContentBuilder() @@ -3925,7 +3929,16 @@ private async Task OpenDashboardAsync(string? path) return; } - if (!TryResolveChatCredentials(out var gatewayUrl, out var token, out var credentialSource, out var isBootstrapToken)) + if (!hadCredentials || before is null) + { + ShowConnectionSettingsForPairingIssue( + "Dashboard", + "Gateway URL or credential is not configured"); + return; + } + + var tunnelNow = _settings.UseSshTunnel ? CreateDashboardSshTunnelOrNull() : null; + if (!TryCaptureDashboardGatewaySnapshot(tunnelNow, out var after) || after is null) { ShowConnectionSettingsForPairingIssue( "Dashboard", @@ -3933,25 +3946,50 @@ private async Task OpenDashboardAsync(string? path) return; } + if (!DashboardBrowserHandoff.SameBinding(before, after)) + { + _toastService?.ShowToast(new ToastContentBuilder() + .AddText("SSH tunnel") + .AddText("SSH tunnel changed before the dashboard could open.")); + return; + } + var url = GatewayDashboardUrlBuilder.Build( - gatewayUrl, + after.GatewayUrl, path, - token, - !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken); + after.Token, + after.AppendToken); - var handoff = false; - if (sshGate.Generation is long generation) + if (sshGate.Generation is not long generation) { - if (!DashboardUrlUsesOwnedForward(url, sshGate.LocalPort) || - _sshTunnelService?.TryEnterBrowserHandoff(generation, sshGate.LocalPort) != true) + try { - _toastService?.ShowToast(new ToastContentBuilder() - .AddText("SSH tunnel") - .AddText("SSH tunnel changed before the dashboard could open.")); - return; + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); } + catch (Exception ex) + { + Logger.Error($"Failed to open dashboard: {ex.Message}"); + } + + return; + } + + if (!DashboardBrowserHandoff.UrlUsesCapturedForward(url, after) || + _sshTunnelService?.TryEnterBrowserHandoff(generation, sshGate.LocalPort) != true) + { + _toastService?.ShowToast(new ToastContentBuilder() + .AddText("SSH tunnel") + .AddText("SSH tunnel changed before the dashboard could open.")); + return; + } - handoff = true; + if (_sshTunnelService is { HasDeferredStop: true } deferredBeforeLaunch) + { + deferredBeforeLaunch.ExitBrowserHandoff(); + _toastService?.ShowToast(new ToastContentBuilder() + .AddText("SSH tunnel") + .AddText("SSH tunnel changed before the dashboard could open.")); + return; } try @@ -3961,21 +3999,84 @@ private async Task OpenDashboardAsync(string? path) catch (Exception ex) { Logger.Error($"Failed to open dashboard: {ex.Message}"); + _sshTunnelService?.ExitBrowserHandoff(); + return; } - finally + + // Process.Start returns before the browser connects. A stop that arrived + // during launch stays deferred until that connection is established. + if (_sshTunnelService is { HasDeferredStop: true } service) { - if (handoff) - _sshTunnelService?.ExitBrowserHandoff(); + var localPort = sshGate.LocalPort; + _ = Task.Run(async () => + { + try + { + while (!await service.WatchBrowserHandoffConsumptionAsync( + localPort, + TimeSpan.FromSeconds(60), + consumptionProbe: null).ConfigureAwait(false)) + { + } + } + catch (Exception ex) + { + Logger.Warn($"Dashboard browser handoff watch failed: {ex.Message}"); + } + }); + return; } + + _sshTunnelService?.ExitBrowserHandoff(); } - private static bool DashboardUrlUsesOwnedForward(string url, int localPort) + private SshTunnelConfig? CreateDashboardSshTunnelOrNull() { - if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + 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)) + { return false; + } - var loopback = uri.Host is "127.0.0.1" or "localhost" or "::1"; - return loopback && uri.Port == localPort; + var activeId = _gatewayRegistry?.GetActive()?.Id; + var identity = string.IsNullOrWhiteSpace(activeId) ? gatewayUrl : activeId; + snapshot = new DashboardGatewayTunnelSnapshot( + identity, + gatewayUrl, + token, + !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken, + tunnel); + return true; } // ── IAppCommands implementation ───────────────────────────────────── @@ -4298,7 +4399,8 @@ private void SetChatSpeakerMuted(bool muted, SettingsWriteOrigin? origin) #endregion - private async Task<(bool Allowed, long? Generation, int LocalPort)> EnsureDashboardSshForwardOwnedAsync() + private async Task<(bool Allowed, long? Generation, int LocalPort)> EnsureDashboardSshForwardOwnedAsync( + SshTunnelConfig? capturedTunnel) { if (_settings == null) { @@ -4311,10 +4413,7 @@ private void SetChatSpeakerMuted(bool muted, SettingsWriteOrigin? origin) return (true, null, 0); } - if (string.IsNullOrWhiteSpace(_settings.SshTunnelUser) || - string.IsNullOrWhiteSpace(_settings.SshTunnelHost) || - _settings.SshTunnelRemotePort is < 1 or > 65535 || - _settings.SshTunnelLocalPort is < 1 or > 65535) + if (capturedTunnel is null) { Logger.Warn("SSH tunnel is enabled but settings are incomplete"); UpdateTrayIcon(); @@ -4324,25 +4423,15 @@ _settings.SshTunnelRemotePort is < 1 or > 65535 || try { _sshTunnelService ??= new SshTunnelService(new AppLogger()); - var includeBrowserProxy = BrowserProxySshTunnelForwardPolicy.ShouldInclude( - _settings.NodeBrowserProxyEnabled, - _settings.SshTunnelRemotePort, - _settings.SshTunnelLocalPort); var owned = await _sshTunnelService.EnsureSettingsOwnedForwardReadyAsync( - new SshTunnelConfig( - _settings.SshTunnelUser, - _settings.SshTunnelHost, - _settings.SshTunnelRemotePort, - _settings.SshTunnelLocalPort, - includeBrowserProxy, - _settings.SshTunnelSshPort), + capturedTunnel, CancellationToken.None); 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 + localEndpoint = $"127.0.0.1:{capturedTunnel.LocalPort}", + remoteHost = string.IsNullOrWhiteSpace(capturedTunnel.Host) ? null : capturedTunnel.Host, + remotePort = capturedTunnel.RemotePort }); if (!owned) { diff --git a/tests/OpenClaw.Connection.Tests/DashboardBrowserHandoffTests.cs b/tests/OpenClaw.Connection.Tests/DashboardBrowserHandoffTests.cs new file mode 100644 index 000000000..2c23e75c1 --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/DashboardBrowserHandoffTests.cs @@ -0,0 +1,82 @@ +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 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/SshTunnelServiceTests.cs b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs index 78f5e14e7..cb6bd9520 100644 --- a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs @@ -532,6 +532,110 @@ public void Stop_DuringBrowserHandoff_KillsProcessWhenLeaseEnds() Assert.False(service.IsRunning); } + [Fact] + public void StopDuringBrowserHandoff_KeepsListenerUntilConsumptionProbe() + { + 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 + { + service.Stop(); + + Assert.True(service.HasDeferredStop); + Assert.Equal(1, service.BrowserHandoffLeaseCount); + Assert.False(service.TryCompleteDeferredBrowserHandoff(1)); + Assert.Same(process, TrackedProcess(service)); + Assert.False(process.HasExited); + Assert.True(service.IsRunning); + + Assert.True(service.TryCompleteDeferredBrowserHandoff(config.LocalPort)); + Assert.Equal(0, service.BrowserHandoffLeaseCount); + Assert.False(service.HasDeferredStop); + Assert.Null(TrackedProcess(service)); + Assert.Equal(TunnelStatus.Stopped, service.Status); + } + finally + { + if (service.BrowserHandoffLeaseCount > 0) + service.ExitBrowserHandoff(); + } + } + + [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)); + service.Stop(); + + 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.True(service.HasDeferredStop); + Assert.True(service.IsRunning); + Assert.Equal(TunnelStatus.Up, service.Status); + } + finally + { + if (service.BrowserHandoffLeaseCount > 0) + service.ExitBrowserHandoff(); + } + } + + [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, diff --git a/tests/OpenClaw.Connection.Tests/WindowsTcpListenerSnapshotTests.cs b/tests/OpenClaw.Connection.Tests/WindowsTcpListenerSnapshotTests.cs index 5304276ba..3765003f7 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,42 @@ 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 async Task AwaitRedirectedOutput_ReturnsNullWhenStdoutNeverCloses() { diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index f1caaa6bb..cc415fbd3 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -472,21 +472,29 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() var source = ReadAppSources(); var method = ExtractMethod(source, "OpenDashboardAsync"); - Assert.Contains("var sshGate = await EnsureDashboardSshForwardOwnedAsync();", method); + Assert.Contains("var sshGate = await EnsureDashboardSshForwardOwnedAsync(capturedTunnel);", method); Assert.Contains("if (!sshGate.Allowed)", method); Assert.Contains("_toastService?.ShowToast", method); Assert.Contains("Check SSH tunnel settings and logs.", method); - Assert.Contains("DashboardUrlUsesOwnedForward(url, sshGate.LocalPort)", method); + Assert.Contains("DashboardBrowserHandoff.UrlUsesCapturedForward(url, after)", method); Assert.Contains("TryEnterBrowserHandoff(generation, sshGate.LocalPort)", method); + Assert.Contains("HasDeferredStop", method); + Assert.Contains("WatchBrowserHandoffConsumptionAsync(", method); Assert.Contains("ExitBrowserHandoff()", method); AssertInOrder( method, - "await EnsureDashboardSshForwardOwnedAsync()", + "TryCaptureDashboardGatewaySnapshot(", + "await EnsureDashboardSshForwardOwnedAsync(capturedTunnel)", + "DashboardBrowserHandoff.SameBinding(", "GatewayDashboardUrlBuilder.Build(", + "UrlUsesCapturedForward(url, after)", "TryEnterBrowserHandoff(generation, sshGate.LocalPort)", + "HasDeferredStop", "Process.Start(", + "WatchBrowserHandoffConsumptionAsync(", "ExitBrowserHandoff()"); Assert.DoesNotContain("EnsureStarted(", method); + Assert.DoesNotContain("DashboardUrlUsesOwnedForward(", method); } [Fact] From 53e529eab153b8585c72d547010b9dc9c8d92640 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 29 Sep 2026 11:06:49 -0700 Subject: [PATCH 10/27] ci: retrigger after handshake wait flake Core and CLI failed only WaitForOperatorHandshakeAsync_ReturnsTrue_WhenStateEventFires. The wait returned false after 934 ms. That test is not in this change, and it passed locally in 14 ms. The SSH handoff tests in the same job passed. Signed-off-by: Sebastien Tardif From 4aed6a0009783eac23cdaabe9dbd23c9fc877733 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 30 Sep 2026 14:05:39 -0700 Subject: [PATCH 11/27] fix(connection): bind the dashboard token, lease, and browser connection The dashboard snapshot read the active gateway again after resolving the token, so a switch could pair one gateway's token with another's identity. The browser lease and a deferred stop are now decided under one lock. An established loopback connection releases that stop only when it is new for this handoff and, for an accepted socket, owned by the SSH process. Validation: ./build.ps1 exit 0. Shared 4107 passed, 32 skipped. Tray 3116 passed. Connection SSH and TCP snapshot tests passed 40. Signed-off-by: Sebastien Tardif --- .../InteractiveGatewayCredentialResolver.cs | 12 +- src/OpenClaw.Connection/SshTunnelService.cs | 48 +++- .../WindowsTcpListenerSnapshot.cs | 208 ++++++++++++++++++ src/OpenClaw.Tray.WinUI/App.xaml.cs | 35 ++- .../SshTunnelServiceTests.cs | 51 +++++ .../WindowsTcpListenerSnapshotTests.cs | 42 ++++ .../AppRefactorContractTests.cs | 6 +- 7 files changed, 385 insertions(+), 17 deletions(-) diff --git a/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs b/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs index 7f588f421..7fc03f4b8 100644 --- a/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs +++ b/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs @@ -79,7 +79,8 @@ public static bool TryResolve( active.Url, active.SharedGatewayToken!, false, - CredentialResolver.SourceSharedGatewayToken); + CredentialResolver.SourceSharedGatewayToken, + active.Id); return true; } @@ -98,7 +99,8 @@ public static bool TryResolve( active.Url, resolved.Token, resolved.IsBootstrapToken, - resolved.Source); + resolved.Source, + active.Id); return true; } @@ -142,7 +144,8 @@ public static bool TryResolve( gatewayUrl, legacyCredential.Token, legacyCredential.IsBootstrapToken, - legacyCredential.Source); + legacyCredential.Source, + legacyRecord.Id); return true; } } @@ -151,4 +154,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 f6288365b..d7c076a2f 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -1,5 +1,6 @@ using OpenClaw.Shared; using System; +using System.Collections.Generic; using System.Diagnostics; using System.Net; @@ -20,6 +21,8 @@ public sealed class SshTunnelService : ISshTunnelManager private string? _lastSpec; private long _lifecycleGeneration; private int _browserHandoffLeases; + private int? _handoffForwardProcessId; + private HashSet? _handoffSeenConnections; private DeferredTunnelStop _deferredStop; /// Raised when the SSH tunnel exits unexpectedly (not during shutdown). @@ -731,6 +734,33 @@ public bool TryEnterBrowserHandoff(long generation, int localPort) return false; _browserHandoffLeases++; + _handoffForwardProcessId = _process?.Id; + _handoffSeenConnections = WindowsTcpListenerSnapshot.TryCollectEstablishedForwardKeys(localPort, out var seen) + ? seen + : null; + return true; + } + } + + /// + /// Drops one browser-handoff lease when no stop is waiting. + /// A stop that arrived while the lease was held stays deferred and the lease stays. + /// + public bool TryReleaseBrowserHandoffUnlessDeferred(int localPort, out bool watchDeferredStop) + { + lock (_stateLock) + { + watchDeferredStop = false; + if (_browserHandoffLeases <= 0 || _currentConfig?.LocalPort != localPort) + return false; + + if (_deferredStop != DeferredTunnelStop.None) + { + watchDeferredStop = true; + return true; + } + + _browserHandoffLeases--; return true; } } @@ -831,7 +861,7 @@ public async Task WatchBrowserHandoffConsumptionAsync( var consumed = consumptionProbe != null ? consumptionProbe(localPort) - : WindowsTcpListenerSnapshot.HasEstablishedLoopbackConnection(localPort); + : BrowserHandoffHasUnseenForwardUse(localPort); if ((consumed || !IsRunning) && (TryCompleteDeferredBrowserHandoff(localPort) || BrowserHandoffLeaseCount <= 0)) { @@ -866,6 +896,22 @@ private enum DeferredTunnelStop ResetNotConfigured, } + private bool BrowserHandoffHasUnseenForwardUse(int localPort) + { + int? processId; + IReadOnlySet? seen; + lock (_stateLock) + { + processId = _handoffForwardProcessId; + seen = _handoffSeenConnections; + } + + if (seen is null) + return false; + + return WindowsTcpListenerSnapshot.HasUnseenEstablishedForwardUse(localPort, processId, seen); + } + private bool IsForwardCurrentLocked(long generation, int localPort) { return generation == _lifecycleGeneration && diff --git a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs index c8c90c04b..5dfaca5a2 100644 --- a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs +++ b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs @@ -74,6 +74,127 @@ public static bool IsEstablishedLoopbackForwardUse( 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? forwardProcessId, + IReadOnlySet seen) + { + if (!IsEstablishedLoopbackForwardUse( + state, + localAddress, + localPort, + remoteAddress, + remotePort, + forwardPort)) + { + return false; + } + + if (localPort == forwardPort && + forwardProcessId is int owner && + owner > 0 && + processId != owner) + { + return false; + } + + var key = EstablishedForwardKey(localAddress, localPort, remoteAddress, remotePort, processId); + return !seen.Contains(key); + } + + public static bool TryCollectEstablishedForwardKeys(int port, out HashSet keys) + { + var collected = new HashSet(StringComparer.Ordinal); + keys = collected; + if (!OperatingSystem.IsWindows() || port is < 1 or > 65535) + return false; + + var complete = true; + VisitEstablished(AfInet, ipv6: false, row => + { + if (RowMatchesForward(row, port)) + collected.Add(row.Key); + return false; + }, ref complete); + VisitEstablished(AfInet6, ipv6: true, row => + { + if (RowMatchesForward(row, port)) + collected.Add(row.Key); + return false; + }, ref complete); + return complete; + } + + public static bool HasUnseenEstablishedForwardUse( + int port, + int? forwardProcessId, + IReadOnlySet seen) + { + if (!OperatingSystem.IsWindows() || port is < 1 or > 65535 || seen is null) + return false; + + var found = false; + var complete = true; + bool Visit(EstablishedTcpRow row) + { + if (IsUnseenEstablishedForwardUse( + row.State, + row.LocalAddress, + row.LocalPort, + row.RemoteAddress, + row.RemotePort, + row.ProcessId, + port, + forwardProcessId, + seen)) + { + found = true; + return true; + } + + return false; + } + + VisitEstablished(AfInet, ipv6: false, Visit, ref complete); + if (!found) + VisitEstablished(AfInet6, ipv6: true, Visit, ref complete); + return found; + } + + private readonly record struct EstablishedTcpRow( + uint State, + IPAddress LocalAddress, + int LocalPort, + IPAddress RemoteAddress, + int RemotePort, + int ProcessId) + { + public string Key => EstablishedForwardKey(LocalAddress, LocalPort, RemoteAddress, RemotePort, ProcessId); + } + + private static bool RowMatchesForward(EstablishedTcpRow row, int port) => + IsEstablishedLoopbackForwardUse( + row.State, + row.LocalAddress, + row.LocalPort, + row.RemoteAddress, + row.RemotePort, + port); + public static string? GetProcessCommandLine(int processId) { if (processId <= 0) @@ -256,6 +377,93 @@ private static bool CaptureTable( return false; } + 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 diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index bf4766aa2..1c042c0da 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -3871,12 +3871,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; @@ -3900,6 +3914,7 @@ private bool TryResolveChatCredentials( token = credential.Token; credentialSource = credential.Source; isBootstrapToken = credential.IsBootstrapToken; + gatewayId = credential.GatewayId; return true; } @@ -4003,9 +4018,12 @@ private async Task OpenDashboardAsync(string? path) return; } - // Process.Start returns before the browser connects. A stop that arrived - // during launch stays deferred until that connection is established. - if (_sshTunnelService is { HasDeferredStop: true } service) + // Process.Start returns before the browser connects. Decide the lease + // and a deferred stop under one lock so a stop during launch cannot + // complete before the browser connects. + if (_sshTunnelService is { } service && + service.TryReleaseBrowserHandoffUnlessDeferred(sshGate.LocalPort, out var watchDeferredStop) && + watchDeferredStop) { var localPort = sshGate.LocalPort; _ = Task.Run(async () => @@ -4024,10 +4042,7 @@ private async Task OpenDashboardAsync(string? path) Logger.Warn($"Dashboard browser handoff watch failed: {ex.Message}"); } }); - return; } - - _sshTunnelService?.ExitBrowserHandoff(); } private SshTunnelConfig? CreateDashboardSshTunnelOrNull() @@ -4063,13 +4078,13 @@ private bool TryCaptureDashboardGatewaySnapshot( out var gatewayUrl, out var token, out var credentialSource, - out var isBootstrapToken)) + out var isBootstrapToken, + out var gatewayId)) { return false; } - var activeId = _gatewayRegistry?.GetActive()?.Id; - var identity = string.IsNullOrWhiteSpace(activeId) ? gatewayUrl : activeId; + var identity = string.IsNullOrWhiteSpace(gatewayId) ? gatewayUrl : gatewayId; snapshot = new DashboardGatewayTunnelSnapshot( identity, gatewayUrl, diff --git a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs index cb6bd9520..ee2589e00 100644 --- a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs @@ -568,6 +568,57 @@ public void StopDuringBrowserHandoff_KeepsListenerUntilConsumptionProbe() } } + [Fact] + public void ReleaseBrowserHandoff_KeepsLeaseWhenStopIsAlreadyDeferred() + { + 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 + { + Assert.True(service.TryReleaseBrowserHandoffUnlessDeferred(config.LocalPort, out var watch)); + Assert.True(watch); + Assert.Equal(1, service.BrowserHandoffLeaseCount); + Assert.True(service.HasDeferredStop); + Assert.False(process.HasExited); + Assert.True(service.IsRunning); + } + finally + { + if (service.BrowserHandoffLeaseCount > 0) + service.ExitBrowserHandoff(); + } + } + + [Fact] + public void ReleaseBrowserHandoff_DropsLeaseWhenNoStopIsPending() + { + 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)); + + Assert.True(service.TryReleaseBrowserHandoffUnlessDeferred(config.LocalPort, out var watch)); + + Assert.False(watch); + Assert.Equal(0, service.BrowserHandoffLeaseCount); + Assert.False(service.HasDeferredStop); + Assert.False(process.HasExited); + Assert.True(service.IsRunning); + process.Kill(entireProcessTree: true); + } + [Fact] public async Task WatchBrowserHandoff_TimesOutWithoutReleasingOwnedListener() { diff --git a/tests/OpenClaw.Connection.Tests/WindowsTcpListenerSnapshotTests.cs b/tests/OpenClaw.Connection.Tests/WindowsTcpListenerSnapshotTests.cs index 3765003f7..263a0b8d3 100644 --- a/tests/OpenClaw.Connection.Tests/WindowsTcpListenerSnapshotTests.cs +++ b/tests/OpenClaw.Connection.Tests/WindowsTcpListenerSnapshotTests.cs @@ -48,6 +48,48 @@ public void IsEstablishedLoopbackForwardUse_MatchesBrowserOrAcceptedRowOnly() forwardPort: 18789)); } + [Fact] + public void IsUnseenEstablishedForwardUse_IgnoresExistingGatewayTrafficAndForeignOwners() + { + var loopback = IPAddress.Loopback; + var client = IPAddress.Parse("203.0.113.8"); + var seen = new HashSet(StringComparer.Ordinal) + { + WindowsTcpListenerSnapshot.EstablishedForwardKey(client, 40000, loopback, 18789, 10), + }; + + Assert.False(WindowsTcpListenerSnapshot.IsUnseenEstablishedForwardUse( + WindowsTcpListenerSnapshot.TcpStateEstablished, + client, + localPort: 40000, + loopback, + remotePort: 18789, + processId: 10, + forwardPort: 18789, + forwardProcessId: 77, + seen)); + Assert.True(WindowsTcpListenerSnapshot.IsUnseenEstablishedForwardUse( + WindowsTcpListenerSnapshot.TcpStateEstablished, + client, + localPort: 40001, + loopback, + remotePort: 18789, + processId: 88, + forwardPort: 18789, + forwardProcessId: 77, + seen)); + Assert.False(WindowsTcpListenerSnapshot.IsUnseenEstablishedForwardUse( + WindowsTcpListenerSnapshot.TcpStateEstablished, + loopback, + localPort: 18789, + client, + remotePort: 40002, + processId: 10, + forwardPort: 18789, + forwardProcessId: 77, + seen)); + } + [Fact] public async Task AwaitRedirectedOutput_ReturnsNullWhenStdoutNeverCloses() { diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index cc415fbd3..f30ccd827 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -479,8 +479,10 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() Assert.Contains("DashboardBrowserHandoff.UrlUsesCapturedForward(url, after)", method); Assert.Contains("TryEnterBrowserHandoff(generation, sshGate.LocalPort)", method); Assert.Contains("HasDeferredStop", method); + Assert.Contains("TryReleaseBrowserHandoffUnlessDeferred(", method); Assert.Contains("WatchBrowserHandoffConsumptionAsync(", method); Assert.Contains("ExitBrowserHandoff()", method); + Assert.DoesNotContain("_gatewayRegistry?.GetActive()", method); AssertInOrder( method, "TryCaptureDashboardGatewaySnapshot(", @@ -491,8 +493,8 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() "TryEnterBrowserHandoff(generation, sshGate.LocalPort)", "HasDeferredStop", "Process.Start(", - "WatchBrowserHandoffConsumptionAsync(", - "ExitBrowserHandoff()"); + "TryReleaseBrowserHandoffUnlessDeferred(", + "WatchBrowserHandoffConsumptionAsync("); Assert.DoesNotContain("EnsureStarted(", method); Assert.DoesNotContain("DashboardUrlUsesOwnedForward(", method); } From ab3d7485ad05a79df36a488267beb54ee0728dff Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 00:08:54 -0700 Subject: [PATCH 12/27] ci: retrigger after product CI did not start Build and Test never queued on 4aed6a0. Only ClawSweeper Dispatch ran. The tree is unchanged. Signed-off-by: Sebastien Tardif From 7e4afbc665414f541479353afb833c01221cf6b2 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 18:21:57 -0700 Subject: [PATCH 13/27] fix(connection): hold the dashboard SSH lease for the launched browser Return the generation and process id that passed listener ownership, and reject a replacement before the handoff lease. Keep that lease until the launched browser connects, or for one minute when that process is unknown. Unrelated clients and accepted SSH sockets do not end the hold. Open Dashboard goes through GatewayDashboardLauncher again, so a failed open shows the connection dialog, retry keeps the requested path, and a successful open dismisses that failure. Browser launch errors are not written to the log. Validation: .\build.ps1 succeeded. Shared tests 4224 passed, 33 skipped. Tray tests 3807 passed. Focused connection handoff tests 40 passed. Signed-off-by: Sebastien Tardif --- .../DashboardBrowserHandoff.cs | 4 +- src/OpenClaw.Connection/SshTunnelService.cs | 78 ++++++-- .../WindowsTcpListenerSnapshot.cs | 16 +- src/OpenClaw.Tray.WinUI/App.xaml.cs | 176 +++++++++++------- .../Services/DashboardBrowserShell.cs | 56 ++++++ .../Services/GatewayDashboardLauncher.cs | 4 +- .../SshTunnelServiceTests.cs | 55 +++++- .../WindowsTcpListenerSnapshotTests.cs | 34 +++- .../AppRefactorContractTests.cs | 77 +++++--- .../DashboardBrowserShellTests.cs | 13 ++ .../OpenClaw.Tray.Tests.csproj | 1 + .../SetupDashboardHandoffTests.cs | 8 +- 12 files changed, 375 insertions(+), 147 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Services/DashboardBrowserShell.cs create mode 100644 tests/OpenClaw.Tray.Tests/DashboardBrowserShellTests.cs diff --git a/src/OpenClaw.Connection/DashboardBrowserHandoff.cs b/src/OpenClaw.Connection/DashboardBrowserHandoff.cs index 833f2085a..0c053cf09 100644 --- a/src/OpenClaw.Connection/DashboardBrowserHandoff.cs +++ b/src/OpenClaw.Connection/DashboardBrowserHandoff.cs @@ -8,7 +8,9 @@ public sealed record DashboardGatewayTunnelSnapshot( string GatewayUrl, string Token, bool AppendToken, - SshTunnelConfig? Tunnel); + SshTunnelConfig? Tunnel, + bool IsBootstrapToken = false, + string Source = ""); /// /// Binds a dashboard URL to the gateway and SSH destination checked before launch. diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index d7c076a2f..5f668ea01 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -6,6 +6,15 @@ 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. /// @@ -21,7 +30,7 @@ public sealed class SshTunnelService : ISshTunnelManager private string? _lastSpec; private long _lifecycleGeneration; private int _browserHandoffLeases; - private int? _handoffForwardProcessId; + private int? _handoffBrowserProcessId; private HashSet? _handoffSeenConnections; private DeferredTunnelStop _deferredStop; @@ -636,7 +645,7 @@ _process is null || public async Task StartAsync(SshTunnelConfig config, CancellationToken ct) => (await StartOwnedAsync(config, ct).ConfigureAwait(false)).Url; - public async Task EnsureSettingsOwnedForwardReadyAsync( + public async Task EnsureSettingsOwnedForwardReadyAsync( SshTunnelConfig config, CancellationToken cancellationToken) { @@ -690,7 +699,23 @@ await WaitForOwnedLocalListenerAsync( cancellationToken).ConfigureAwait(false); } - return true; + 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) { @@ -703,7 +728,7 @@ await WaitForOwnedLocalListenerAsync( if (!stillCurrent) { _logger.Warn($"SSH dashboard forward wait lost ownership: {ex.Message}"); - return false; + return default; } LastError = ex.Message; @@ -714,7 +739,7 @@ await WaitForOwnedLocalListenerAsync( StopIfCurrent(process, generation); _logger.Warn($"SSH dashboard forward is not owned: {ex.Message}"); - return false; + return default; } } @@ -726,15 +751,17 @@ public bool IsSettingsOwnedForwardCurrent(long generation, int localPort) } } - public bool TryEnterBrowserHandoff(long generation, int localPort) + public bool TryEnterBrowserHandoff(long generation, int localPort, int processId = 0) { lock (_stateLock) { if (!IsForwardCurrentLocked(generation, localPort)) return false; + if (processId > 0 && _process?.Id != processId) + return false; _browserHandoffLeases++; - _handoffForwardProcessId = _process?.Id; + _handoffBrowserProcessId = null; _handoffSeenConnections = WindowsTcpListenerSnapshot.TryCollectEstablishedForwardKeys(localPort, out var seen) ? seen : null; @@ -742,9 +769,21 @@ public bool TryEnterBrowserHandoff(long generation, int localPort) } } + public void NoteBrowserHandoffClient(int processId) + { + if (processId <= 0) + return; + + lock (_stateLock) + { + _handoffBrowserProcessId = processId; + } + } + /// - /// Drops one browser-handoff lease when no stop is waiting. - /// A stop that arrived while the lease was held stays deferred and the lease stays. + /// 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) { @@ -754,13 +793,9 @@ public bool TryReleaseBrowserHandoffUnlessDeferred(int localPort, out bool watch if (_browserHandoffLeases <= 0 || _currentConfig?.LocalPort != localPort) return false; - if (_deferredStop != DeferredTunnelStop.None) - { - watchDeferredStop = true; - return true; - } - - _browserHandoffLeases--; + // Process.Start returns before the browser connects. Keep the lease + // until attributable use, including a stop that arrives after launch. + watchDeferredStop = true; return true; } } @@ -785,6 +820,8 @@ public void ExitBrowserHandoff() { if (_browserHandoffLeases > 0) _browserHandoffLeases--; + if (_browserHandoffLeases == 0) + _handoffBrowserProcessId = null; if (_browserHandoffLeases != 0 || _deferredStop == DeferredTunnelStop.None) return; @@ -898,18 +935,21 @@ private enum DeferredTunnelStop private bool BrowserHandoffHasUnseenForwardUse(int localPort) { - int? processId; + int? browserProcessId; IReadOnlySet? seen; lock (_stateLock) { - processId = _handoffForwardProcessId; seen = _handoffSeenConnections; + browserProcessId = _handoffBrowserProcessId; } if (seen is null) return false; - return WindowsTcpListenerSnapshot.HasUnseenEstablishedForwardUse(localPort, processId, seen); + return WindowsTcpListenerSnapshot.HasUnseenEstablishedForwardUse( + localPort, + browserProcessId, + seen); } private bool IsForwardCurrentLocked(long generation, int localPort) diff --git a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs index 5dfaca5a2..1e2c648cd 100644 --- a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs +++ b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs @@ -90,7 +90,7 @@ public static bool IsUnseenEstablishedForwardUse( int remotePort, int processId, int forwardPort, - int? forwardProcessId, + int? browserProcessId, IReadOnlySet seen) { if (!IsEstablishedLoopbackForwardUse( @@ -104,13 +104,11 @@ public static bool IsUnseenEstablishedForwardUse( return false; } - if (localPort == forwardPort && - forwardProcessId is int owner && - owner > 0 && - processId != owner) - { + // 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); @@ -141,7 +139,7 @@ public static bool TryCollectEstablishedForwardKeys(int port, out HashSet seen) { if (!OperatingSystem.IsWindows() || port is < 1 or > 65535 || seen is null) @@ -159,7 +157,7 @@ bool Visit(EstablishedTcpRow row) row.RemotePort, row.ProcessId, port, - forwardProcessId, + browserProcessId, seen)) { found = true; diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index e38d2a968..30b7b9d0f 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -4019,36 +4019,64 @@ 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( - () => OpenDashboardAsync(path), - new AppLogger(), - nameof(OpenDashboard)); + AsyncEventHandlerGuard.Run(async () => + { + DashboardLaunchPreparation? prepared = null; + var readiness = DashboardLaunchReadiness.FailedReport; + var launcher = new GatewayDashboardLauncher( + 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)); + await launcher.OpenAsync(path); + }, new AppLogger(), nameof(OpenDashboard)); } - private async Task OpenDashboardAsync(string? path) + private async Task<(DashboardLaunchReadiness Readiness, DashboardLaunchPreparation? Prepared)> PrepareDashboardLaunchAsync() { if (_settings == null) - return; + 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) - { - _toastService?.ShowToast(new ToastContentBuilder() - .AddText("SSH tunnel") - .AddText(_sshTunnelService?.LastError ?? "Check SSH tunnel settings and logs.")); - return; - } + return (DashboardLaunchReadiness.FailedReport, null); if (!hadCredentials || before is null) { ShowConnectionSettingsForPairingIssue( "Dashboard", "Gateway URL or credential is not configured"); - return; + return (DashboardLaunchReadiness.FailedVisible, null); } var tunnelNow = _settings.UseSshTunnel ? CreateDashboardSshTunnelOrNull() : null; @@ -4057,7 +4085,7 @@ private async Task OpenDashboardAsync(string? path) ShowConnectionSettingsForPairingIssue( "Dashboard", "Gateway URL or credential is not configured"); - return; + return (DashboardLaunchReadiness.FailedVisible, null); } if (!DashboardBrowserHandoff.SameBinding(before, after)) @@ -4065,83 +4093,93 @@ private async Task OpenDashboardAsync(string? path) _toastService?.ShowToast(new ToastContentBuilder() .AddText("SSH tunnel") .AddText("SSH tunnel changed before the dashboard could open.")); - return; + return (DashboardLaunchReadiness.FailedVisible, null); } - var url = GatewayDashboardUrlBuilder.Build( - after.GatewayUrl, - path, - after.Token, - after.AppendToken); + 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)); + } - if (sshGate.Generation is not long generation) - { - try - { - Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); - } - catch (Exception ex) - { - Logger.Error($"Failed to open dashboard: {ex.Message}"); - } + private Task LaunchDashboardBrowserAsync(string url, DashboardLaunchPreparation? prepared) + { + if (prepared is null) + return Task.FromResult(false); - return; - } + if (prepared.OwnedForward is not { } owned) + return Task.FromResult(DashboardBrowserShell.TryOpen(url, out _)); - if (!DashboardBrowserHandoff.UrlUsesCapturedForward(url, after) || - _sshTunnelService?.TryEnterBrowserHandoff(generation, sshGate.LocalPort) != true) + if (!DashboardBrowserHandoff.UrlUsesCapturedForward(url, prepared.Snapshot) || + _sshTunnelService?.TryEnterBrowserHandoff(owned.Generation, owned.LocalPort, owned.ProcessId) != true) { - _toastService?.ShowToast(new ToastContentBuilder() - .AddText("SSH tunnel") - .AddText("SSH tunnel changed before the dashboard could open.")); - return; + return Task.FromResult(false); } if (_sshTunnelService is { HasDeferredStop: true } deferredBeforeLaunch) { deferredBeforeLaunch.ExitBrowserHandoff(); - _toastService?.ShowToast(new ToastContentBuilder() - .AddText("SSH tunnel") - .AddText("SSH tunnel changed before the dashboard could open.")); - return; + return Task.FromResult(false); } - try - { - Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); - } - catch (Exception ex) + if (!DashboardBrowserShell.TryOpen(url, out var browserProcessId)) { - Logger.Error($"Failed to open dashboard: {ex.Message}"); _sshTunnelService?.ExitBrowserHandoff(); - return; + return Task.FromResult(false); } - // Process.Start returns before the browser connects. Decide the lease - // and a deferred stop under one lock so a stop during launch cannot - // complete before the browser connects. + if (browserProcessId is int browserPid) + _sshTunnelService?.NoteBrowserHandoffClient(browserPid); + if (_sshTunnelService is { } service && - service.TryReleaseBrowserHandoffUnlessDeferred(sshGate.LocalPort, out var watchDeferredStop) && + service.TryReleaseBrowserHandoffUnlessDeferred(owned.LocalPort, out var watchDeferredStop) && watchDeferredStop) { - var localPort = sshGate.LocalPort; + var localPort = owned.LocalPort; _ = Task.Run(async () => { try { - while (!await service.WatchBrowserHandoffConsumptionAsync( + var connected = await service.WatchBrowserHandoffConsumptionAsync( localPort, TimeSpan.FromSeconds(60), - consumptionProbe: null).ConfigureAwait(false)) - { - } + consumptionProbe: null).ConfigureAwait(false); + if (connected) + return; + + service.ExitBrowserHandoff(); + _toastService?.ShowToast(new ToastContentBuilder() + .AddText("SSH tunnel") + .AddText("The dashboard did not confirm a browser connection. The SSH forward hold ended.")); } catch (Exception ex) { - Logger.Warn($"Dashboard browser handoff watch failed: {ex.Message}"); + try + { + service.ExitBrowserHandoff(); + } + catch (Exception releaseEx) + { + Logger.Warn($"Dashboard browser handoff release failed: {releaseEx.GetType().Name}"); + } + + Logger.Warn($"Dashboard browser handoff watch failed: {ex.GetType().Name}"); } }); } + else + { + _sshTunnelService?.ExitBrowserHandoff(); + } + + return Task.FromResult(true); } private SshTunnelConfig? CreateDashboardSshTunnelOrNull() @@ -4189,7 +4227,9 @@ private bool TryCaptureDashboardGatewaySnapshot( gatewayUrl, token, !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken, - tunnel); + tunnel, + isBootstrapToken, + credentialSource); return true; } @@ -4513,25 +4553,25 @@ private void SetChatSpeakerMuted(bool muted, SettingsWriteOrigin? origin) #endregion - private async Task<(bool Allowed, long? Generation, int LocalPort)> EnsureDashboardSshForwardOwnedAsync( + private async Task<(bool Allowed, long? Generation, int LocalPort, int ProcessId)> EnsureDashboardSshForwardOwnedAsync( SshTunnelConfig? capturedTunnel) { if (_settings == null) { - return (false, null, 0); + return (false, null, 0, 0); } if (!_settings.UseSshTunnel) { _sshTunnelService?.Stop(); - return (true, null, 0); + return (true, null, 0, 0); } if (capturedTunnel is null) { Logger.Warn("SSH tunnel is enabled but settings are incomplete"); UpdateTrayIcon(); - return (false, null, 0); + return (false, null, 0, 0); } try @@ -4547,19 +4587,19 @@ private void SetChatSpeakerMuted(bool muted, SettingsWriteOrigin? origin) remoteHost = string.IsNullOrWhiteSpace(capturedTunnel.Host) ? null : capturedTunnel.Host, remotePort = capturedTunnel.RemotePort }); - if (!owned) + if (!owned.Owned) { UpdateTrayIcon(); - return (false, null, 0); + return (false, null, 0, 0); } - return (true, _sshTunnelService.OwnershipGeneration, _sshTunnelService.CurrentLocalPort); + return (true, owned.Generation, owned.LocalPort, owned.ProcessId); } catch (Exception ex) { Logger.Error($"Failed to start SSH tunnel: {ex.Message}"); UpdateTrayIcon(); - return (false, null, 0); + return (false, null, 0, 0); } } diff --git a/src/OpenClaw.Tray.WinUI/Services/DashboardBrowserShell.cs b/src/OpenClaw.Tray.WinUI/Services/DashboardBrowserShell.cs new file mode 100644 index 000000000..f139b830e --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/DashboardBrowserShell.cs @@ -0,0 +1,56 @@ +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 }); + if (process is null) + return false; + + processId = TryReadProcessId(process); + return true; + } + catch (Exception) + { + processId = null; + return false; + } + finally + { + try + { + process?.Dispose(); + } + catch (Exception ex) + { + Trace.WriteLine($"Dashboard browser handle release failed: {ex.GetType().Name}"); + } + } + } + + 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..cfa398c92 100644 --- a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs +++ b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs @@ -7,7 +7,7 @@ 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, @@ -18,7 +18,7 @@ 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."); diff --git a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs index ee2589e00..9364f4946 100644 --- a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs @@ -352,7 +352,7 @@ public async Task EnsureSettingsOwnedForwardReadyAsync_RejectsForeignLocalListen new SshTunnelConfig("user", "host", 18789, port), CancellationToken.None); - Assert.False(ready); + Assert.False(ready.Owned); Assert.False(service.IsRunning); Assert.NotEqual(TunnelStatus.Up, service.Status); Assert.Contains(port.ToString(), service.LastError); @@ -378,7 +378,7 @@ public async Task EnsureSettingsOwnedForwardReadyAsync_RejectsForeignBrowserProx IncludeBrowserProxyForward: true), CancellationToken.None); - Assert.False(ready); + Assert.False(ready.Owned); Assert.False(service.IsRunning); Assert.NotEqual(TunnelStatus.Up, service.Status); Assert.NotNull(service.LastError); @@ -598,7 +598,7 @@ public void ReleaseBrowserHandoff_KeepsLeaseWhenStopIsAlreadyDeferred() } [Fact] - public void ReleaseBrowserHandoff_DropsLeaseWhenNoStopIsPending() + public void ReleaseBrowserHandoff_KeepsLeaseWhenNoStopIsPending() { using var service = new SshTunnelService(NullLogger.Instance); var config = new SshTunnelConfig("user", "host", 18789, 45678); @@ -609,14 +609,51 @@ public void ReleaseBrowserHandoff_DropsLeaseWhenNoStopIsPending() generation: 4); Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort)); - Assert.True(service.TryReleaseBrowserHandoffUnlessDeferred(config.LocalPort, out var watch)); + 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); + } + } - Assert.False(watch); + [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.False(service.HasDeferredStop); - Assert.False(process.HasExited); - Assert.True(service.IsRunning); - process.Kill(entireProcessTree: true); + + Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort, process.Id)); + Assert.Equal(1, service.BrowserHandoffLeaseCount); + service.NoteBrowserHandoffClient(88); + Assert.Equal(88, (int?)GetPrivate(service, "_handoffBrowserProcessId")); + service.NoteBrowserHandoffClient(0); + Assert.Equal(88, (int?)GetPrivate(service, "_handoffBrowserProcessId")); + + service.ExitBrowserHandoff(); + Assert.Null(GetPrivate(service, "_handoffBrowserProcessId")); + if (!process.HasExited) + process.Kill(entireProcessTree: true); } [Fact] diff --git a/tests/OpenClaw.Connection.Tests/WindowsTcpListenerSnapshotTests.cs b/tests/OpenClaw.Connection.Tests/WindowsTcpListenerSnapshotTests.cs index 263a0b8d3..dc4c7883f 100644 --- a/tests/OpenClaw.Connection.Tests/WindowsTcpListenerSnapshotTests.cs +++ b/tests/OpenClaw.Connection.Tests/WindowsTcpListenerSnapshotTests.cs @@ -49,13 +49,13 @@ public void IsEstablishedLoopbackForwardUse_MatchesBrowserOrAcceptedRowOnly() } [Fact] - public void IsUnseenEstablishedForwardUse_IgnoresExistingGatewayTrafficAndForeignOwners() + 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, 10), + WindowsTcpListenerSnapshot.EstablishedForwardKey(client, 40000, loopback, 18789, 88), }; Assert.False(WindowsTcpListenerSnapshot.IsUnseenEstablishedForwardUse( @@ -64,9 +64,9 @@ public void IsUnseenEstablishedForwardUse_IgnoresExistingGatewayTrafficAndForeig localPort: 40000, loopback, remotePort: 18789, - processId: 10, + processId: 88, forwardPort: 18789, - forwardProcessId: 77, + browserProcessId: 88, seen)); Assert.True(WindowsTcpListenerSnapshot.IsUnseenEstablishedForwardUse( WindowsTcpListenerSnapshot.TcpStateEstablished, @@ -76,7 +76,27 @@ public void IsUnseenEstablishedForwardUse_IgnoresExistingGatewayTrafficAndForeig remotePort: 18789, processId: 88, forwardPort: 18789, - forwardProcessId: 77, + 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, @@ -84,9 +104,9 @@ public void IsUnseenEstablishedForwardUse_IgnoresExistingGatewayTrafficAndForeig localPort: 18789, client, remotePort: 40002, - processId: 10, + processId: 88, forwardPort: 18789, - forwardProcessId: 77, + browserProcessId: 88, seen)); } diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 21aedb9d7..af034ddba 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -472,33 +472,51 @@ public void LifecycleStatus_IsWrittenFromManagerSnapshotOnly() public void Dashboard_SurfacesSshTunnelConfigurationFailure() { var source = ReadAppSources(); - var method = ExtractMethod(source, "OpenDashboardAsync"); - - Assert.Contains("var sshGate = await EnsureDashboardSshForwardOwnedAsync(capturedTunnel);", method); - Assert.Contains("if (!sshGate.Allowed)", method); - Assert.Contains("_toastService?.ShowToast", method); - Assert.Contains("Check SSH tunnel settings and logs.", method); - Assert.Contains("DashboardBrowserHandoff.UrlUsesCapturedForward(url, after)", method); - Assert.Contains("TryEnterBrowserHandoff(generation, sshGate.LocalPort)", method); - Assert.Contains("HasDeferredStop", method); - Assert.Contains("TryReleaseBrowserHandoffUnlessDeferred(", method); - Assert.Contains("WatchBrowserHandoffConsumptionAsync(", method); - Assert.Contains("ExitBrowserHandoff()", method); - Assert.DoesNotContain("_gatewayRegistry?.GetActive()", method); + 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("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("GatewayDashboardUrlBuilder.Build", prepare); + Assert.Contains("DashboardBrowserHandoff.UrlUsesCapturedForward(url, prepared.Snapshot)", launch); + Assert.Contains("TryEnterBrowserHandoff(owned.Generation, owned.LocalPort, owned.ProcessId)", launch); + Assert.Contains("HasDeferredStop", launch); + Assert.Contains("NoteBrowserHandoffClient(", launch); + Assert.Contains("TryReleaseBrowserHandoffUnlessDeferred(", launch); + Assert.Contains("WatchBrowserHandoffConsumptionAsync(", launch); + Assert.Contains("ExitBrowserHandoff()", launch); + Assert.Contains("The dashboard did not confirm a browser connection. The SSH forward hold ended.", launch); + Assert.DoesNotContain("ex.Message", launch); + Assert.DoesNotContain("Process.Start", launch); + Assert.DoesNotContain("ex.Message", launcher); AssertInOrder( - method, - "TryCaptureDashboardGatewaySnapshot(", - "await EnsureDashboardSshForwardOwnedAsync(capturedTunnel)", - "DashboardBrowserHandoff.SameBinding(", - "GatewayDashboardUrlBuilder.Build(", - "UrlUsesCapturedForward(url, after)", - "TryEnterBrowserHandoff(generation, sshGate.LocalPort)", + launch, + "UrlUsesCapturedForward(url, prepared.Snapshot)", + "TryEnterBrowserHandoff(owned.Generation, owned.LocalPort, owned.ProcessId)", "HasDeferredStop", - "Process.Start(", + "DashboardBrowserShell.TryOpen", + "NoteBrowserHandoffClient(", "TryReleaseBrowserHandoffUnlessDeferred(", - "WatchBrowserHandoffConsumptionAsync("); - Assert.DoesNotContain("EnsureStarted(", method); - Assert.DoesNotContain("DashboardUrlUsesOwnedForward(", method); + "WatchBrowserHandoffConsumptionAsync(", + "ExitBrowserHandoff()"); + AssertInOrder( + launcher, + "await ensureTunnel()", + "GatewayDashboardUrlBuilder.Build("); } [Fact] @@ -509,17 +527,20 @@ public void Dashboard_AwaitsSettingsOwnedForwardBeforeTokenUrl() Assert.Contains("if (!_settings.UseSshTunnel)", gate); Assert.Contains("_sshTunnelService?.Stop()", gate); - Assert.Contains("return (true, null, 0);", gate); + Assert.Contains("return (true, null, 0, 0);", gate); Assert.Contains("EnsureSettingsOwnedForwardReadyAsync(", gate); - Assert.Contains("if (!owned)", gate); - Assert.Contains("OwnershipGeneration", 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)"); + "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..649d795c6 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/DashboardBrowserShellTests.cs @@ -0,0 +1,13 @@ +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); + } +} 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++); From b90da5784029012bac43a59fa0984a0aaa554300 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 18:40:29 -0700 Subject: [PATCH 14/27] ci: retrigger after the dark sidebar navigation flake Signed-off-by: Sebastien Tardif From 148dae60a0014e6eb80f5c92309023265899c26f Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 19:11:35 -0700 Subject: [PATCH 15/27] fix(connection): keep each dashboard handoff until that browser connects A shell activation that returns no process handle still counts as an open. Each dashboard open keeps its own browser identity and connection baseline. A timeout or a watch error leaves that forward in place. One new connection settles only the handoff it belongs to. Validation: .\build.ps1 succeeded. Shared tests 4224 passed, 33 skipped. Tray tests 3812 passed. Connection handoff tests 44 passed. Signed-off-by: Sebastien Tardif --- .../BrowserHandoffConsumption.cs | 63 +++++ src/OpenClaw.Connection/SshTunnelService.cs | 249 +++++++++++++++--- .../WindowsTcpListenerSnapshot.cs | 38 +++ src/OpenClaw.Tray.WinUI/App.xaml.cs | 74 +++--- .../Services/DashboardBrowserShell.cs | 65 ++++- .../BrowserHandoffConsumptionTests.cs | 61 +++++ .../SshTunnelServiceTests.cs | 42 ++- .../AppRefactorContractTests.cs | 19 +- .../DashboardBrowserShellTests.cs | 17 ++ 9 files changed, 536 insertions(+), 92 deletions(-) create mode 100644 src/OpenClaw.Connection/BrowserHandoffConsumption.cs create mode 100644 tests/OpenClaw.Connection.Tests/BrowserHandoffConsumptionTests.cs diff --git a/src/OpenClaw.Connection/BrowserHandoffConsumption.cs b/src/OpenClaw.Connection/BrowserHandoffConsumption.cs new file mode 100644 index 000000000..5b2efce4d --- /dev/null +++ b/src/OpenClaw.Connection/BrowserHandoffConsumption.cs @@ -0,0 +1,63 @@ +namespace OpenClaw.Connection; + +internal readonly record struct BrowserHandoffView( + long Id, + bool Settled, + int? BrowserProcessId, + string? BrowserProcessName, + IReadOnlySet? Seen); + +internal readonly record struct ForwardClientRow(string Key, int ProcessId, string? ProcessName); + +internal static class BrowserHandoffConsumption +{ + internal static bool TrySelectExclusiveConsumption( + long handoffId, + IReadOnlyList handoffs, + IReadOnlyList rows, + IReadOnlySet claimed, + out string? key) + { + key = null; + BrowserHandoffView? target = null; + foreach (var handoff in handoffs) + { + if (handoff.Id == handoffId && !handoff.Settled) + target = handoff; + } + + if (target is null) + return false; + + foreach (var row in rows) + { + if (claimed.Contains(row.Key) || !Matches(target.Value, row)) + continue; + + var owners = 0; + foreach (var handoff in handoffs) + { + if (!handoff.Settled && Matches(handoff, row)) + owners++; + } + + if (owners != 1) + continue; + + key = row.Key; + return true; + } + + return false; + } + + private static bool Matches(BrowserHandoffView handoff, ForwardClientRow row) + { + if (handoff.Seen is null || handoff.Seen.Contains(row.Key)) + return false; + if (handoff.BrowserProcessId is int processId) + return row.ProcessId == processId; + return !string.IsNullOrEmpty(handoff.BrowserProcessName) && + string.Equals(handoff.BrowserProcessName, row.ProcessName, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index 5f668ea01..49b73318a 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -30,8 +30,9 @@ public sealed class SshTunnelService : ISshTunnelManager private string? _lastSpec; private long _lifecycleGeneration; private int _browserHandoffLeases; - private int? _handoffBrowserProcessId; - private HashSet? _handoffSeenConnections; + private readonly List _browserHandoffs = new(); + private readonly HashSet _claimedBrowserConnections = new(StringComparer.Ordinal); + private long _nextBrowserHandoffId; private DeferredTunnelStop _deferredStop; /// Raised when the SSH tunnel exits unexpectedly (not during shutdown). @@ -751,32 +752,75 @@ public bool IsSettingsOwnedForwardCurrent(long generation, int localPort) } } - public bool TryEnterBrowserHandoff(long generation, int localPort, int processId = 0) + 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++; - _handoffBrowserProcessId = null; - _handoffSeenConnections = WindowsTcpListenerSnapshot.TryCollectEstablishedForwardKeys(localPort, out var seen) - ? seen - : null; + _browserHandoffs.Add(new BrowserHandoffLease + { + Id = handoffId, + LocalPort = localPort, + Seen = WindowsTcpListenerSnapshot.TryCollectEstablishedForwardKeys(localPort, out var seen) + ? seen + : null, + }); return true; } } - public void NoteBrowserHandoffClient(int processId) + public void NoteBrowserHandoffClient(int processId) => + NoteBrowserHandoffClient(NewestOpenHandoffId(), processId); + + public void NoteBrowserHandoffClient(long handoffId, int processId) { - if (processId <= 0) + if (handoffId <= 0 || processId <= 0) + return; + + lock (_stateLock) + { + var handoff = FindOpenHandoffLocked(handoffId); + if (handoff is not null) + handoff.BrowserProcessId = processId; + } + } + + public void NoteBrowserHandoffProcessName(long handoffId, string processName) + { + if (handoffId <= 0 || string.IsNullOrWhiteSpace(processName)) return; lock (_stateLock) { - _handoffBrowserProcessId = processId; + var handoff = FindOpenHandoffLocked(handoffId); + if (handoff is not null) + handoff.BrowserProcessName = processName; + } + } + + internal int? HandoffBrowserProcessId(long handoffId) + { + lock (_stateLock) + { + return FindOpenHandoffLocked(handoffId)?.BrowserProcessId; + } + } + + public bool IsBrowserHandoffOpen(long handoffId) + { + lock (_stateLock) + { + return FindOpenHandoffLocked(handoffId) is not null; } } @@ -801,6 +845,20 @@ public bool TryReleaseBrowserHandoffUnlessDeferred(int localPort, out bool watch } 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 @@ -818,10 +876,16 @@ public void ExitBrowserHandoff() 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) - _handoffBrowserProcessId = null; + _claimedBrowserConnections.Clear(); if (_browserHandoffLeases != 0 || _deferredStop == DeferredTunnelStop.None) return; @@ -883,7 +947,15 @@ public bool TryCompleteDeferredBrowserHandoff(int localPort) /// 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, @@ -893,16 +965,26 @@ public async Task WatchBrowserHandoffConsumptionAsync( while (true) { cancellationToken.ThrowIfCancellationRequested(); - if (BrowserHandoffLeaseCount <= 0) + if (handoffId > 0) + { + if (!IsBrowserHandoffOpen(handoffId)) + return true; + } + else if (BrowserHandoffLeaseCount <= 0) + { return true; + } var consumed = consumptionProbe != null ? consumptionProbe(localPort) - : BrowserHandoffHasUnseenForwardUse(localPort); - if ((consumed || !IsRunning) && - (TryCompleteDeferredBrowserHandoff(localPort) || BrowserHandoffLeaseCount <= 0)) + : handoffId > 0 && TryClaimHandoffConsumption(handoffId, localPort); + if (consumed || !IsRunning) { - return true; + var completed = handoffId > 0 + ? TryFinishHandoff(handoffId, localPort) + : TryCompleteDeferredBrowserHandoff(localPort); + if (completed || (handoffId > 0 ? !IsBrowserHandoffOpen(handoffId) : BrowserHandoffLeaseCount <= 0)) + return true; } if (DateTime.UtcNow >= deadline) @@ -916,6 +998,122 @@ public async Task WatchBrowserHandoffConsumptionAsync( } } + 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 bool TryClaimHandoffConsumption(long handoffId, int localPort) + { + var clients = WindowsTcpListenerSnapshot.ListEstablishedForwardClients(localPort); + List views; + HashSet claimed; + lock (_stateLock) + { + views = _browserHandoffs + .Select(handoff => new BrowserHandoffView( + handoff.Id, + handoff.Settled, + handoff.BrowserProcessId, + handoff.BrowserProcessName, + handoff.Seen)) + .ToList(); + claimed = new HashSet(_claimedBrowserConnections, StringComparer.Ordinal); + } + + var rows = new List(clients.Count); + foreach (var client in clients) + rows.Add(new ForwardClientRow(client.Key, client.ProcessId, ProcessNameFor(client.ProcessId))); + + if (!BrowserHandoffConsumption.TrySelectExclusiveConsumption(handoffId, views, rows, claimed, out var key) || + key is null) + { + return false; + } + + lock (_stateLock) + { + if (_claimedBrowserConnections.Contains(key) || FindOpenHandoffLocked(handoffId) is null) + return false; + + _claimedBrowserConnections.Add(key); + return true; + } + } + + private static string? ProcessNameFor(int processId) + { + if (processId <= 0) + return null; + + try + { + using var process = Process.GetProcessById(processId); + return process.ProcessName; + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception) + { + return null; + } + } + + 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 string? BrowserProcessName { get; set; } + public HashSet? Seen { get; init; } + public bool Settled { get; set; } + } + /// /// Caller holds . A reset replaces a plain stop. /// A later stop does not downgrade a reset. @@ -933,25 +1131,6 @@ private enum DeferredTunnelStop ResetNotConfigured, } - private bool BrowserHandoffHasUnseenForwardUse(int localPort) - { - int? browserProcessId; - IReadOnlySet? seen; - lock (_stateLock) - { - seen = _handoffSeenConnections; - browserProcessId = _handoffBrowserProcessId; - } - - if (seen is null) - return false; - - return WindowsTcpListenerSnapshot.HasUnseenEstablishedForwardUse( - localPort, - browserProcessId, - seen); - } - private bool IsForwardCurrentLocked(long generation, int localPort) { return generation == _lifecycleGeneration && diff --git a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs index 1e2c648cd..4a14ce9f3 100644 --- a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs +++ b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs @@ -173,6 +173,44 @@ bool Visit(EstablishedTcpRow row) return found; } + public readonly record struct EstablishedForwardClient(string Key, int ProcessId); + + public static IReadOnlyList ListEstablishedForwardClients(int port) + { + var collected = new List(); + if (!OperatingSystem.IsWindows() || port is < 1 or > 65535) + return collected; + + var complete = true; + void Visit(EstablishedTcpRow row) + { + if (row.RemotePort == port && + row.LocalPort != port && + IsEstablishedLoopbackForwardUse( + row.State, + row.LocalAddress, + row.LocalPort, + row.RemoteAddress, + row.RemotePort, + port)) + { + collected.Add(new EstablishedForwardClient(row.Key, row.ProcessId)); + } + } + + VisitEstablished(AfInet, ipv6: false, row => + { + Visit(row); + return false; + }, ref complete); + VisitEstablished(AfInet6, ipv6: true, row => + { + Visit(row); + return false; + }, ref complete); + return collected; + } + private readonly record struct EstablishedTcpRow( uint State, IPAddress LocalAddress, diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 30b7b9d0f..3046c6a41 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -4117,67 +4117,67 @@ private Task LaunchDashboardBrowserAsync(string url, DashboardLaunchPrepar if (prepared.OwnedForward is not { } owned) return Task.FromResult(DashboardBrowserShell.TryOpen(url, out _)); - if (!DashboardBrowserHandoff.UrlUsesCapturedForward(url, prepared.Snapshot) || - _sshTunnelService?.TryEnterBrowserHandoff(owned.Generation, owned.LocalPort, owned.ProcessId) != true) + 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 (_sshTunnelService is { HasDeferredStop: true } deferredBeforeLaunch) + if (tunnel.HasDeferredStop) { - deferredBeforeLaunch.ExitBrowserHandoff(); + tunnel.ExitBrowserHandoff(handoffId); return Task.FromResult(false); } if (!DashboardBrowserShell.TryOpen(url, out var browserProcessId)) { - _sshTunnelService?.ExitBrowserHandoff(); + tunnel.ExitBrowserHandoff(handoffId); return Task.FromResult(false); } + var hasBrowserIdentity = false; if (browserProcessId is int browserPid) - _sshTunnelService?.NoteBrowserHandoffClient(browserPid); + { + tunnel.NoteBrowserHandoffClient(handoffId, browserPid); + hasBrowserIdentity = true; + } + else if (DashboardBrowserShell.TryGetDefaultBrowserProcessName() is { Length: > 0 } browserName) + { + tunnel.NoteBrowserHandoffProcessName(handoffId, browserName); + hasBrowserIdentity = true; + } - if (_sshTunnelService is { } service && - service.TryReleaseBrowserHandoffUnlessDeferred(owned.LocalPort, out var watchDeferredStop) && - watchDeferredStop) + if (!hasBrowserIdentity || + !tunnel.TryReleaseBrowserHandoffUnlessDeferred(owned.LocalPort, out var watchDeferredStop) || + !watchDeferredStop) { - var localPort = owned.LocalPort; - _ = Task.Run(async () => + return Task.FromResult(true); + } + + var localPort = owned.LocalPort; + _ = Task.Run(async () => + { + try { - try + while (tunnel.IsBrowserHandoffOpen(handoffId)) { - var connected = await service.WatchBrowserHandoffConsumptionAsync( + var connected = await tunnel.WatchBrowserHandoffConsumptionAsync( + handoffId, localPort, TimeSpan.FromSeconds(60), consumptionProbe: null).ConfigureAwait(false); if (connected) return; - - service.ExitBrowserHandoff(); - _toastService?.ShowToast(new ToastContentBuilder() - .AddText("SSH tunnel") - .AddText("The dashboard did not confirm a browser connection. The SSH forward hold ended.")); } - catch (Exception ex) - { - try - { - service.ExitBrowserHandoff(); - } - catch (Exception releaseEx) - { - Logger.Warn($"Dashboard browser handoff release failed: {releaseEx.GetType().Name}"); - } - - Logger.Warn($"Dashboard browser handoff watch failed: {ex.GetType().Name}"); - } - }); - } - else - { - _sshTunnelService?.ExitBrowserHandoff(); - } + } + catch (Exception ex) + { + Logger.Warn($"Dashboard browser handoff watch failed: {ex.GetType().Name}"); + } + }); return Task.FromResult(true); } diff --git a/src/OpenClaw.Tray.WinUI/Services/DashboardBrowserShell.cs b/src/OpenClaw.Tray.WinUI/Services/DashboardBrowserShell.cs index f139b830e..38ee92622 100644 --- a/src/OpenClaw.Tray.WinUI/Services/DashboardBrowserShell.cs +++ b/src/OpenClaw.Tray.WinUI/Services/DashboardBrowserShell.cs @@ -1,5 +1,7 @@ +using Microsoft.Win32; using System.ComponentModel; using System.Diagnostics; +using System.Runtime.Versioning; namespace OpenClawTray.Services; @@ -16,11 +18,7 @@ internal static bool TryOpen(string url, out int? processId) try { process = Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); - if (process is null) - return false; - - processId = TryReadProcessId(process); - return true; + return InterpretStartedProcess(process, out processId); } catch (Exception) { @@ -40,6 +38,63 @@ internal static bool TryOpen(string url, out int? processId) } } + internal static bool InterpretStartedProcess(Process? process, out int? processId) + { + processId = null; + if (process is null) + return true; + + processId = TryReadProcessId(process); + return true; + } + + internal static string? ProcessNameFromOpenCommand(string? command) + { + if (string.IsNullOrWhiteSpace(command)) + return null; + + var trimmed = command.Trim(); + string path; + if (trimmed.StartsWith('\"')) + { + var end = trimmed.IndexOf('\"', 1); + if (end <= 1) + return null; + path = trimmed[1..end]; + } + else + { + var space = trimmed.IndexOf(' '); + path = space < 0 ? trimmed : trimmed[..space]; + } + + var name = Path.GetFileNameWithoutExtension(path); + return string.IsNullOrWhiteSpace(name) ? null : name; + } + + [SupportedOSPlatform("windows")] + internal static string? TryGetDefaultBrowserProcessName() + { + if (!OperatingSystem.IsWindows()) + return null; + + try + { + using var choice = Registry.CurrentUser.OpenSubKey( + @"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice"); + var progId = choice?.GetValue("ProgId") as string; + if (string.IsNullOrWhiteSpace(progId)) + return null; + + using var commandKey = Registry.ClassesRoot.OpenSubKey(progId + @"\shell\open\command"); + return ProcessNameFromOpenCommand(commandKey?.GetValue(null) as string); + } + catch (Exception) + { + return null; + } + } + private static int? TryReadProcessId(Process process) { try diff --git a/tests/OpenClaw.Connection.Tests/BrowserHandoffConsumptionTests.cs b/tests/OpenClaw.Connection.Tests/BrowserHandoffConsumptionTests.cs new file mode 100644 index 000000000..e6578a110 --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/BrowserHandoffConsumptionTests.cs @@ -0,0 +1,61 @@ +namespace OpenClaw.Connection.Tests; + +public sealed class BrowserHandoffConsumptionTests +{ + [Fact] + public void TrySelectExclusiveConsumption_SettlesOnlyTheMatchingHandoff() + { + var seen = new HashSet(StringComparer.Ordinal) { "old" }; + var handoffs = new[] + { + new BrowserHandoffView(1, false, 10, null, seen), + new BrowserHandoffView(2, false, 20, null, seen), + }; + var rows = new[] + { + new ForwardClientRow("old", 10, "chrome"), + new ForwardClientRow("new-b", 20, "chrome"), + }; + + Assert.False(BrowserHandoffConsumption.TrySelectExclusiveConsumption( + 1, handoffs, rows, new HashSet(), out _)); + Assert.True(BrowserHandoffConsumption.TrySelectExclusiveConsumption( + 2, handoffs, rows, new HashSet(), out var key)); + Assert.Equal("new-b", key); + } + + [Fact] + public void TrySelectExclusiveConsumption_DoesNotShareOneRowAcrossHandoffs() + { + var seen = new HashSet(StringComparer.Ordinal); + var handoffs = new[] + { + new BrowserHandoffView(1, false, null, "chrome", seen), + new BrowserHandoffView(2, false, null, "chrome", seen), + }; + var rows = new[] { new ForwardClientRow("shared", 50, "chrome") }; + + Assert.False(BrowserHandoffConsumption.TrySelectExclusiveConsumption( + 1, handoffs, rows, new HashSet(), out _)); + Assert.False(BrowserHandoffConsumption.TrySelectExclusiveConsumption( + 2, handoffs, rows, new HashSet(), out _)); + } + + [Fact] + public void TrySelectExclusiveConsumption_UsesTheBrowserNameWhenTheProcessIdIsUnknown() + { + var handoffs = new[] + { + new BrowserHandoffView(4, false, null, "msedge", new HashSet()), + }; + var rows = new[] + { + new ForwardClientRow("probe", 8, "powershell"), + new ForwardClientRow("edge", 9, "msedge"), + }; + + Assert.True(BrowserHandoffConsumption.TrySelectExclusiveConsumption( + 4, handoffs, rows, new HashSet(), out var key)); + Assert.Equal("edge", key); + } +} diff --git a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs index 9364f4946..b6b9816eb 100644 --- a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs @@ -643,15 +643,43 @@ public void TryEnterBrowserHandoff_RejectsReplacementProcess() Assert.False(service.TryEnterBrowserHandoff(4, config.LocalPort, otherProcessId)); Assert.Equal(0, service.BrowserHandoffLeaseCount); - Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort, process.Id)); + Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort, process.Id, out var handoffId)); Assert.Equal(1, service.BrowserHandoffLeaseCount); - service.NoteBrowserHandoffClient(88); - Assert.Equal(88, (int?)GetPrivate(service, "_handoffBrowserProcessId")); - service.NoteBrowserHandoffClient(0); - Assert.Equal(88, (int?)GetPrivate(service, "_handoffBrowserProcessId")); + service.NoteBrowserHandoffClient(handoffId, 88); + Assert.Equal(88, service.HandoffBrowserProcessId(handoffId)); + service.NoteBrowserHandoffClient(handoffId, 0); + Assert.Equal(88, service.HandoffBrowserProcessId(handoffId)); - service.ExitBrowserHandoff(); - Assert.Null(GetPrivate(service, "_handoffBrowserProcessId")); + 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); } diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index af034ddba..96cf47015 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -493,26 +493,29 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() Assert.Contains("DashboardBrowserHandoff.SameBinding(", prepare); Assert.DoesNotContain("GatewayDashboardUrlBuilder.Build", prepare); Assert.Contains("DashboardBrowserHandoff.UrlUsesCapturedForward(url, prepared.Snapshot)", launch); - Assert.Contains("TryEnterBrowserHandoff(owned.Generation, owned.LocalPort, owned.ProcessId)", launch); + Assert.Contains("TryEnterBrowserHandoff(owned.Generation, owned.LocalPort, owned.ProcessId, out var handoffId)", launch); Assert.Contains("HasDeferredStop", launch); - Assert.Contains("NoteBrowserHandoffClient(", launch); + Assert.Contains("NoteBrowserHandoffClient(handoffId, browserPid)", launch); + Assert.Contains("NoteBrowserHandoffProcessName(handoffId, browserName)", launch); Assert.Contains("TryReleaseBrowserHandoffUnlessDeferred(", launch); Assert.Contains("WatchBrowserHandoffConsumptionAsync(", launch); - Assert.Contains("ExitBrowserHandoff()", launch); - Assert.Contains("The dashboard did not confirm a browser connection. The SSH forward hold ended.", launch); + Assert.Contains("ExitBrowserHandoff(handoffId)", 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.DoesNotContain("ExitBrowserHandoff", watch); Assert.DoesNotContain("ex.Message", launcher); AssertInOrder( launch, "UrlUsesCapturedForward(url, prepared.Snapshot)", - "TryEnterBrowserHandoff(owned.Generation, owned.LocalPort, owned.ProcessId)", + "TryEnterBrowserHandoff(owned.Generation, owned.LocalPort, owned.ProcessId, out var handoffId)", "HasDeferredStop", + "ExitBrowserHandoff(handoffId)", "DashboardBrowserShell.TryOpen", - "NoteBrowserHandoffClient(", + "NoteBrowserHandoffClient(handoffId, browserPid)", "TryReleaseBrowserHandoffUnlessDeferred(", - "WatchBrowserHandoffConsumptionAsync(", - "ExitBrowserHandoff()"); + "WatchBrowserHandoffConsumptionAsync("); AssertInOrder( launcher, "await ensureTunnel()", diff --git a/tests/OpenClaw.Tray.Tests/DashboardBrowserShellTests.cs b/tests/OpenClaw.Tray.Tests/DashboardBrowserShellTests.cs index 649d795c6..da4ad861e 100644 --- a/tests/OpenClaw.Tray.Tests/DashboardBrowserShellTests.cs +++ b/tests/OpenClaw.Tray.Tests/DashboardBrowserShellTests.cs @@ -10,4 +10,21 @@ 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); + } + + [Theory] + [InlineData("\"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe\" -- \"%1\"", "chrome")] + [InlineData("\"C:\\Program Files\\Mozilla Firefox\\firefox.exe\" -osint -url \"%1\"", "firefox")] + [InlineData("", null)] + [InlineData(null, null)] + public void ProcessNameFromOpenCommand_ReadsTheExecutableName(string? command, string? expected) + { + Assert.Equal(expected, DashboardBrowserShell.ProcessNameFromOpenCommand(command)); + } } From d8601d520dc9671bff651140ef8fea6f7503be39 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 19:38:52 -0700 Subject: [PATCH 16/27] fix(connection): do not treat browser traffic as dashboard consumption Another tab in the same browser can open a new connection to the forward. That is not this dashboard navigation, so the handoff stays while that SSH process is alive. The watcher starts even when Windows returns no browser process, and it releases the lease once that SSH process has exited. Validation: .\build.ps1 succeeded. Shared tests 4224 passed, 33 skipped. Tray tests 3808 passed. Connection handoff tests 42 passed. Signed-off-by: Sebastien Tardif --- .../BrowserHandoffConsumption.cs | 63 ---------- src/OpenClaw.Connection/SshTunnelService.cs | 81 +------------ .../WindowsTcpListenerSnapshot.cs | 111 +----------------- src/OpenClaw.Tray.WinUI/App.xaml.cs | 27 +---- .../Services/DashboardBrowserShell.cs | 49 -------- .../BrowserHandoffConsumptionTests.cs | 61 ---------- .../SshTunnelServiceTests.cs | 38 ++++++ .../AppRefactorContractTests.cs | 15 +-- .../DashboardBrowserShellTests.cs | 10 -- 9 files changed, 54 insertions(+), 401 deletions(-) delete mode 100644 src/OpenClaw.Connection/BrowserHandoffConsumption.cs delete mode 100644 tests/OpenClaw.Connection.Tests/BrowserHandoffConsumptionTests.cs diff --git a/src/OpenClaw.Connection/BrowserHandoffConsumption.cs b/src/OpenClaw.Connection/BrowserHandoffConsumption.cs deleted file mode 100644 index 5b2efce4d..000000000 --- a/src/OpenClaw.Connection/BrowserHandoffConsumption.cs +++ /dev/null @@ -1,63 +0,0 @@ -namespace OpenClaw.Connection; - -internal readonly record struct BrowserHandoffView( - long Id, - bool Settled, - int? BrowserProcessId, - string? BrowserProcessName, - IReadOnlySet? Seen); - -internal readonly record struct ForwardClientRow(string Key, int ProcessId, string? ProcessName); - -internal static class BrowserHandoffConsumption -{ - internal static bool TrySelectExclusiveConsumption( - long handoffId, - IReadOnlyList handoffs, - IReadOnlyList rows, - IReadOnlySet claimed, - out string? key) - { - key = null; - BrowserHandoffView? target = null; - foreach (var handoff in handoffs) - { - if (handoff.Id == handoffId && !handoff.Settled) - target = handoff; - } - - if (target is null) - return false; - - foreach (var row in rows) - { - if (claimed.Contains(row.Key) || !Matches(target.Value, row)) - continue; - - var owners = 0; - foreach (var handoff in handoffs) - { - if (!handoff.Settled && Matches(handoff, row)) - owners++; - } - - if (owners != 1) - continue; - - key = row.Key; - return true; - } - - return false; - } - - private static bool Matches(BrowserHandoffView handoff, ForwardClientRow row) - { - if (handoff.Seen is null || handoff.Seen.Contains(row.Key)) - return false; - if (handoff.BrowserProcessId is int processId) - return row.ProcessId == processId; - return !string.IsNullOrEmpty(handoff.BrowserProcessName) && - string.Equals(handoff.BrowserProcessName, row.ProcessName, StringComparison.OrdinalIgnoreCase); - } -} diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index 49b73318a..b5cf0ea9c 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -31,7 +31,6 @@ public sealed class SshTunnelService : ISshTunnelManager private long _lifecycleGeneration; private int _browserHandoffLeases; private readonly List _browserHandoffs = new(); - private readonly HashSet _claimedBrowserConnections = new(StringComparer.Ordinal); private long _nextBrowserHandoffId; private DeferredTunnelStop _deferredStop; @@ -771,9 +770,6 @@ public bool TryEnterBrowserHandoff(long generation, int localPort, int processId { Id = handoffId, LocalPort = localPort, - Seen = WindowsTcpListenerSnapshot.TryCollectEstablishedForwardKeys(localPort, out var seen) - ? seen - : null, }); return true; } @@ -795,19 +791,6 @@ public void NoteBrowserHandoffClient(long handoffId, int processId) } } - public void NoteBrowserHandoffProcessName(long handoffId, string processName) - { - if (handoffId <= 0 || string.IsNullOrWhiteSpace(processName)) - return; - - lock (_stateLock) - { - var handoff = FindOpenHandoffLocked(handoffId); - if (handoff is not null) - handoff.BrowserProcessName = processName; - } - } - internal int? HandoffBrowserProcessId(long handoffId) { lock (_stateLock) @@ -884,9 +867,6 @@ public void ExitBrowserHandoff(long handoffId) _browserHandoffs.Remove(handoff); if (_browserHandoffLeases > 0) _browserHandoffLeases--; - if (_browserHandoffLeases == 0) - _claimedBrowserConnections.Clear(); - if (_browserHandoffLeases != 0 || _deferredStop == DeferredTunnelStop.None) return; @@ -975,9 +955,8 @@ public async Task WatchBrowserHandoffConsumptionAsync( return true; } - var consumed = consumptionProbe != null - ? consumptionProbe(localPort) - : handoffId > 0 && TryClaimHandoffConsumption(handoffId, localPort); + // Browser process identity does not identify this dashboard navigation. + var consumed = consumptionProbe?.Invoke(localPort) == true; if (consumed || !IsRunning) { var completed = handoffId > 0 @@ -1011,60 +990,6 @@ private bool TryFinishHandoff(long handoffId, int localPort) return true; } - private bool TryClaimHandoffConsumption(long handoffId, int localPort) - { - var clients = WindowsTcpListenerSnapshot.ListEstablishedForwardClients(localPort); - List views; - HashSet claimed; - lock (_stateLock) - { - views = _browserHandoffs - .Select(handoff => new BrowserHandoffView( - handoff.Id, - handoff.Settled, - handoff.BrowserProcessId, - handoff.BrowserProcessName, - handoff.Seen)) - .ToList(); - claimed = new HashSet(_claimedBrowserConnections, StringComparer.Ordinal); - } - - var rows = new List(clients.Count); - foreach (var client in clients) - rows.Add(new ForwardClientRow(client.Key, client.ProcessId, ProcessNameFor(client.ProcessId))); - - if (!BrowserHandoffConsumption.TrySelectExclusiveConsumption(handoffId, views, rows, claimed, out var key) || - key is null) - { - return false; - } - - lock (_stateLock) - { - if (_claimedBrowserConnections.Contains(key) || FindOpenHandoffLocked(handoffId) is null) - return false; - - _claimedBrowserConnections.Add(key); - return true; - } - } - - private static string? ProcessNameFor(int processId) - { - if (processId <= 0) - return null; - - try - { - using var process = Process.GetProcessById(processId); - return process.ProcessName; - } - catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception) - { - return null; - } - } - private long NewestOpenHandoffId() { lock (_stateLock) @@ -1109,8 +1034,6 @@ private sealed class BrowserHandoffLease public long Id { get; init; } public int LocalPort { get; init; } public int? BrowserProcessId { get; set; } - public string? BrowserProcessName { get; set; } - public HashSet? Seen { get; init; } public bool Settled { get; set; } } diff --git a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs index 4a14ce9f3..49ad6be45 100644 --- a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs +++ b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs @@ -114,122 +114,13 @@ public static bool IsUnseenEstablishedForwardUse( return !seen.Contains(key); } - public static bool TryCollectEstablishedForwardKeys(int port, out HashSet keys) - { - var collected = new HashSet(StringComparer.Ordinal); - keys = collected; - if (!OperatingSystem.IsWindows() || port is < 1 or > 65535) - return false; - - var complete = true; - VisitEstablished(AfInet, ipv6: false, row => - { - if (RowMatchesForward(row, port)) - collected.Add(row.Key); - return false; - }, ref complete); - VisitEstablished(AfInet6, ipv6: true, row => - { - if (RowMatchesForward(row, port)) - collected.Add(row.Key); - return false; - }, ref complete); - return complete; - } - - public static bool HasUnseenEstablishedForwardUse( - int port, - int? browserProcessId, - IReadOnlySet seen) - { - if (!OperatingSystem.IsWindows() || port is < 1 or > 65535 || seen is null) - return false; - - var found = false; - var complete = true; - bool Visit(EstablishedTcpRow row) - { - if (IsUnseenEstablishedForwardUse( - row.State, - row.LocalAddress, - row.LocalPort, - row.RemoteAddress, - row.RemotePort, - row.ProcessId, - port, - browserProcessId, - seen)) - { - found = true; - return true; - } - - return false; - } - - VisitEstablished(AfInet, ipv6: false, Visit, ref complete); - if (!found) - VisitEstablished(AfInet6, ipv6: true, Visit, ref complete); - return found; - } - - public readonly record struct EstablishedForwardClient(string Key, int ProcessId); - - public static IReadOnlyList ListEstablishedForwardClients(int port) - { - var collected = new List(); - if (!OperatingSystem.IsWindows() || port is < 1 or > 65535) - return collected; - - var complete = true; - void Visit(EstablishedTcpRow row) - { - if (row.RemotePort == port && - row.LocalPort != port && - IsEstablishedLoopbackForwardUse( - row.State, - row.LocalAddress, - row.LocalPort, - row.RemoteAddress, - row.RemotePort, - port)) - { - collected.Add(new EstablishedForwardClient(row.Key, row.ProcessId)); - } - } - - VisitEstablished(AfInet, ipv6: false, row => - { - Visit(row); - return false; - }, ref complete); - VisitEstablished(AfInet6, ipv6: true, row => - { - Visit(row); - return false; - }, ref complete); - return collected; - } - private readonly record struct EstablishedTcpRow( uint State, IPAddress LocalAddress, int LocalPort, IPAddress RemoteAddress, int RemotePort, - int ProcessId) - { - public string Key => EstablishedForwardKey(LocalAddress, LocalPort, RemoteAddress, RemotePort, ProcessId); - } - - private static bool RowMatchesForward(EstablishedTcpRow row, int port) => - IsEstablishedLoopbackForwardUse( - row.State, - row.LocalAddress, - row.LocalPort, - row.RemoteAddress, - row.RemotePort, - port); + int ProcessId); public static string? GetProcessCommandLine(int processId) { diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 3046c6a41..d7f8495c8 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -4132,31 +4132,12 @@ private Task LaunchDashboardBrowserAsync(string url, DashboardLaunchPrepar return Task.FromResult(false); } - if (!DashboardBrowserShell.TryOpen(url, out var browserProcessId)) + if (!DashboardBrowserShell.TryOpen(url, out _)) { tunnel.ExitBrowserHandoff(handoffId); return Task.FromResult(false); } - var hasBrowserIdentity = false; - if (browserProcessId is int browserPid) - { - tunnel.NoteBrowserHandoffClient(handoffId, browserPid); - hasBrowserIdentity = true; - } - else if (DashboardBrowserShell.TryGetDefaultBrowserProcessName() is { Length: > 0 } browserName) - { - tunnel.NoteBrowserHandoffProcessName(handoffId, browserName); - hasBrowserIdentity = true; - } - - if (!hasBrowserIdentity || - !tunnel.TryReleaseBrowserHandoffUnlessDeferred(owned.LocalPort, out var watchDeferredStop) || - !watchDeferredStop) - { - return Task.FromResult(true); - } - var localPort = owned.LocalPort; _ = Task.Run(async () => { @@ -4164,18 +4145,20 @@ private Task LaunchDashboardBrowserAsync(string url, DashboardLaunchPrepar { while (tunnel.IsBrowserHandoffOpen(handoffId)) { - var connected = await tunnel.WatchBrowserHandoffConsumptionAsync( + var settled = await tunnel.WatchBrowserHandoffConsumptionAsync( handoffId, localPort, TimeSpan.FromSeconds(60), consumptionProbe: null).ConfigureAwait(false); - if (connected) + if (settled) return; } } catch (Exception ex) { Logger.Warn($"Dashboard browser handoff watch failed: {ex.GetType().Name}"); + if (!tunnel.IsRunning) + tunnel.ExitBrowserHandoff(handoffId); } }); diff --git a/src/OpenClaw.Tray.WinUI/Services/DashboardBrowserShell.cs b/src/OpenClaw.Tray.WinUI/Services/DashboardBrowserShell.cs index 38ee92622..3596aa022 100644 --- a/src/OpenClaw.Tray.WinUI/Services/DashboardBrowserShell.cs +++ b/src/OpenClaw.Tray.WinUI/Services/DashboardBrowserShell.cs @@ -1,7 +1,5 @@ -using Microsoft.Win32; using System.ComponentModel; using System.Diagnostics; -using System.Runtime.Versioning; namespace OpenClawTray.Services; @@ -48,53 +46,6 @@ internal static bool InterpretStartedProcess(Process? process, out int? processI return true; } - internal static string? ProcessNameFromOpenCommand(string? command) - { - if (string.IsNullOrWhiteSpace(command)) - return null; - - var trimmed = command.Trim(); - string path; - if (trimmed.StartsWith('\"')) - { - var end = trimmed.IndexOf('\"', 1); - if (end <= 1) - return null; - path = trimmed[1..end]; - } - else - { - var space = trimmed.IndexOf(' '); - path = space < 0 ? trimmed : trimmed[..space]; - } - - var name = Path.GetFileNameWithoutExtension(path); - return string.IsNullOrWhiteSpace(name) ? null : name; - } - - [SupportedOSPlatform("windows")] - internal static string? TryGetDefaultBrowserProcessName() - { - if (!OperatingSystem.IsWindows()) - return null; - - try - { - using var choice = Registry.CurrentUser.OpenSubKey( - @"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice"); - var progId = choice?.GetValue("ProgId") as string; - if (string.IsNullOrWhiteSpace(progId)) - return null; - - using var commandKey = Registry.ClassesRoot.OpenSubKey(progId + @"\shell\open\command"); - return ProcessNameFromOpenCommand(commandKey?.GetValue(null) as string); - } - catch (Exception) - { - return null; - } - } - private static int? TryReadProcessId(Process process) { try diff --git a/tests/OpenClaw.Connection.Tests/BrowserHandoffConsumptionTests.cs b/tests/OpenClaw.Connection.Tests/BrowserHandoffConsumptionTests.cs deleted file mode 100644 index e6578a110..000000000 --- a/tests/OpenClaw.Connection.Tests/BrowserHandoffConsumptionTests.cs +++ /dev/null @@ -1,61 +0,0 @@ -namespace OpenClaw.Connection.Tests; - -public sealed class BrowserHandoffConsumptionTests -{ - [Fact] - public void TrySelectExclusiveConsumption_SettlesOnlyTheMatchingHandoff() - { - var seen = new HashSet(StringComparer.Ordinal) { "old" }; - var handoffs = new[] - { - new BrowserHandoffView(1, false, 10, null, seen), - new BrowserHandoffView(2, false, 20, null, seen), - }; - var rows = new[] - { - new ForwardClientRow("old", 10, "chrome"), - new ForwardClientRow("new-b", 20, "chrome"), - }; - - Assert.False(BrowserHandoffConsumption.TrySelectExclusiveConsumption( - 1, handoffs, rows, new HashSet(), out _)); - Assert.True(BrowserHandoffConsumption.TrySelectExclusiveConsumption( - 2, handoffs, rows, new HashSet(), out var key)); - Assert.Equal("new-b", key); - } - - [Fact] - public void TrySelectExclusiveConsumption_DoesNotShareOneRowAcrossHandoffs() - { - var seen = new HashSet(StringComparer.Ordinal); - var handoffs = new[] - { - new BrowserHandoffView(1, false, null, "chrome", seen), - new BrowserHandoffView(2, false, null, "chrome", seen), - }; - var rows = new[] { new ForwardClientRow("shared", 50, "chrome") }; - - Assert.False(BrowserHandoffConsumption.TrySelectExclusiveConsumption( - 1, handoffs, rows, new HashSet(), out _)); - Assert.False(BrowserHandoffConsumption.TrySelectExclusiveConsumption( - 2, handoffs, rows, new HashSet(), out _)); - } - - [Fact] - public void TrySelectExclusiveConsumption_UsesTheBrowserNameWhenTheProcessIdIsUnknown() - { - var handoffs = new[] - { - new BrowserHandoffView(4, false, null, "msedge", new HashSet()), - }; - var rows = new[] - { - new ForwardClientRow("probe", 8, "powershell"), - new ForwardClientRow("edge", 9, "msedge"), - }; - - Assert.True(BrowserHandoffConsumption.TrySelectExclusiveConsumption( - 4, handoffs, rows, new HashSet(), out var key)); - Assert.Equal("edge", key); - } -} diff --git a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs index b6b9816eb..ffbeba088 100644 --- a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs @@ -718,6 +718,44 @@ public async Task WatchBrowserHandoff_TimesOutWithoutReleasingOwnedListener() } } + [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)); + service.Stop(); + + var waiting = await service.WatchBrowserHandoffConsumptionAsync( + handoffId, + config.LocalPort, + TimeSpan.Zero, + consumptionProbe: null); + Assert.False(waiting); + Assert.Equal(1, service.BrowserHandoffLeaseCount); + Assert.True(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() { diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 96cf47015..f0e7d95ba 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -495,16 +495,17 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() 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("NoteBrowserHandoffClient(handoffId, browserPid)", launch); - Assert.Contains("NoteBrowserHandoffProcessName(handoffId, browserName)", launch); - Assert.Contains("TryReleaseBrowserHandoffUnlessDeferred(", 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.DoesNotContain("ExitBrowserHandoff", watch); + Assert.Contains("if (!tunnel.IsRunning)", watch); + Assert.Contains("ExitBrowserHandoff(handoffId)", watch); Assert.DoesNotContain("ex.Message", launcher); AssertInOrder( launch, @@ -513,9 +514,9 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() "HasDeferredStop", "ExitBrowserHandoff(handoffId)", "DashboardBrowserShell.TryOpen", - "NoteBrowserHandoffClient(handoffId, browserPid)", - "TryReleaseBrowserHandoffUnlessDeferred(", - "WatchBrowserHandoffConsumptionAsync("); + "WatchBrowserHandoffConsumptionAsync(", + "if (!tunnel.IsRunning)", + "ExitBrowserHandoff(handoffId)"); AssertInOrder( launcher, "await ensureTunnel()", diff --git a/tests/OpenClaw.Tray.Tests/DashboardBrowserShellTests.cs b/tests/OpenClaw.Tray.Tests/DashboardBrowserShellTests.cs index da4ad861e..24099d464 100644 --- a/tests/OpenClaw.Tray.Tests/DashboardBrowserShellTests.cs +++ b/tests/OpenClaw.Tray.Tests/DashboardBrowserShellTests.cs @@ -17,14 +17,4 @@ public void InterpretStartedProcess_NullProcess_IsSuccessfulActivationWithoutAPr Assert.True(DashboardBrowserShell.InterpretStartedProcess(null, out var processId)); Assert.Null(processId); } - - [Theory] - [InlineData("\"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe\" -- \"%1\"", "chrome")] - [InlineData("\"C:\\Program Files\\Mozilla Firefox\\firefox.exe\" -osint -url \"%1\"", "firefox")] - [InlineData("", null)] - [InlineData(null, null)] - public void ProcessNameFromOpenCommand_ReadsTheExecutableName(string? command, string? expected) - { - Assert.Equal(expected, DashboardBrowserShell.ProcessNameFromOpenCommand(command)); - } } From 23ff490fc65f0c94878bb058f9c7a56d9335c070 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 20:07:23 -0700 Subject: [PATCH 17/27] fix(connection): let Stop cancel a dashboard hold and open the local forward Disconnect, Restart, and shutdown cancel an open dashboard handoff and then stop the SSH process. Another browser connection still does not release the hold. A saved remote gateway URL is opened through the verified local forward, and the saved profile is left unchanged. Validation: .\build.ps1 succeeded. Shared tests 4224 passed, 33 skipped. Tray tests 3808 passed. Connection handoff tests 36 passed. Snapshot tests 7 passed. Signed-off-by: Sebastien Tardif --- .../DashboardBrowserHandoff.cs | 3 + src/OpenClaw.Connection/SshTunnelService.cs | 46 +++++-- src/OpenClaw.Tray.WinUI/App.xaml.cs | 5 +- .../DashboardBrowserHandoffTests.cs | 19 +++ .../SshTunnelServiceTests.cs | 115 +++++------------- .../AppRefactorContractTests.cs | 1 + 6 files changed, 93 insertions(+), 96 deletions(-) diff --git a/src/OpenClaw.Connection/DashboardBrowserHandoff.cs b/src/OpenClaw.Connection/DashboardBrowserHandoff.cs index 0c053cf09..be7690b1e 100644 --- a/src/OpenClaw.Connection/DashboardBrowserHandoff.cs +++ b/src/OpenClaw.Connection/DashboardBrowserHandoff.cs @@ -40,6 +40,9 @@ public static bool SameTunnelDestination(SshTunnelConfig? left, SshTunnelConfig? left.IncludeBrowserProxyForward == right.IncludeBrowserProxyForward; } + public static string LocalForwardBrowserUrl(int localPort) => + $"http://127.0.0.1:{localPort}/"; + public static bool UrlUsesCapturedForward(string url, DashboardGatewayTunnelSnapshot captured) { if (captured.Tunnel is not SshTunnelConfig tunnel) diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index b5cf0ea9c..510caf5ac 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -258,12 +258,12 @@ public void Stop() } /// - /// Stops the tracked tunnel. Returns false when a browser handoff lease - /// defers the stop and leaves the process, spec, owner, and status unchanged. - /// The deferred stop completes on the last . + /// Stops the tracked tunnel. An open dashboard handoff is cancelled first + /// so Disconnect, Restart, and shutdown are not stuck behind it. /// private bool StopLocked() { + CancelOpenBrowserHandoffs(); Process? process; lock (_stateLock) { @@ -333,6 +333,7 @@ public void ResetNotConfigured() { lock (_operationLock) { + CancelOpenBrowserHandoffs(); Process? process = null; var deferred = false; lock (_stateLock) @@ -827,6 +828,21 @@ public bool TryReleaseBrowserHandoffUnlessDeferred(int localPort, out bool watch } } + public void CancelOpenBrowserHandoffs() + { + long[] handoffIds; + lock (_stateLock) + { + handoffIds = _browserHandoffs + .Where(handoff => !handoff.Settled) + .Select(handoff => handoff.Id) + .ToArray(); + } + + foreach (var handoffId in handoffIds) + ExitBrowserHandoff(handoffId); + } + public void ExitBrowserHandoff() { long handoffId; @@ -1151,19 +1167,29 @@ public Task StopIfOwnedAsync( try { ct.ThrowIfCancellationRequested(); - Process? process; + var normalizedConfig = config with + { + User = config.User.Trim(), + Host = config.Host.Trim(), + }; lock (_stateLock) { ct.ThrowIfCancellationRequested(); - var normalizedConfig = config with + if (_lifecycleGeneration != ownershipGeneration || + !Equals(_currentConfig, normalizedConfig) || + _currentOwner != SshTunnelOwner.GatewayConnectionManager) { - User = config.User.Trim(), - Host = config.Host.Trim(), - }; + return Task.FromResult(false); + } + } + + CancelOpenBrowserHandoffs(); + Process? process; + lock (_stateLock) + { if (_lifecycleGeneration != ownershipGeneration || !Equals(_currentConfig, normalizedConfig) || - _currentOwner != SshTunnelOwner.GatewayConnectionManager || - _browserHandoffLeases > 0) + _currentOwner != SshTunnelOwner.GatewayConnectionManager) { return Task.FromResult(false); } diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index d7f8495c8..5400c5c0c 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -4099,9 +4099,12 @@ private void OpenDashboard(string? path = null) SettingsOwnedForwardBinding? ownedForward = sshGate.Generation is long generation ? new SettingsOwnedForwardBinding(true, generation, sshGate.LocalPort, sshGate.ProcessId) : null; + var browserGatewayUrl = ownedForward is { } forward + ? DashboardBrowserHandoff.LocalForwardBrowserUrl(forward.LocalPort) + : after.GatewayUrl; return (DashboardLaunchReadiness.Ready, new DashboardLaunchPreparation( new InteractiveGatewayCredential( - after.GatewayUrl, + browserGatewayUrl, after.Token, after.IsBootstrapToken, after.Source), diff --git a/tests/OpenClaw.Connection.Tests/DashboardBrowserHandoffTests.cs b/tests/OpenClaw.Connection.Tests/DashboardBrowserHandoffTests.cs index 2c23e75c1..28cccd79d 100644 --- a/tests/OpenClaw.Connection.Tests/DashboardBrowserHandoffTests.cs +++ b/tests/OpenClaw.Connection.Tests/DashboardBrowserHandoffTests.cs @@ -55,6 +55,25 @@ public void SameBinding_RejectsDestinationChangeWhenLocalPortStays() Assert.False(DashboardBrowserHandoff.SameBinding(before, after)); } + [Fact] + public void LocalForwardBrowserUrl_ProjectsTheVerifiedPort() + { + var tunnel = new SshTunnelConfig("user", "gateway.example", 18789, 19001); + var saved = new DashboardGatewayTunnelSnapshot( + "gateway-a", + "wss://gateway.example/", + "token-a", + true, + tunnel); + + var browserUrl = DashboardBrowserHandoff.LocalForwardBrowserUrl(tunnel.LocalPort); + + Assert.Equal("http://127.0.0.1:19001/", browserUrl); + Assert.True(DashboardBrowserHandoff.UrlUsesCapturedForward( + browserUrl + "#token=token-a", + saved)); + } + [Fact] public void UrlUsesCapturedForward_RequiresLoopbackPortAndCapturedToken() { diff --git a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs index ffbeba088..091251080 100644 --- a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs @@ -434,7 +434,7 @@ public void EnsureStarted_WhileBrowserHandoffLeaseHeld_DoesNotReplaceTrackedProc } [Fact] - public async Task StopIfOwnedAsync_WhileBrowserHandoffLeaseHeld_ReturnsFalseAndKeepsProcess() + public async Task StopIfOwnedAsync_DuringBrowserHandoff_CancelsTheHoldAndStops() { using var service = new SshTunnelService(NullLogger.Instance); var config = new SshTunnelConfig("user", "host", 18789, 45678); @@ -445,26 +445,19 @@ public async Task StopIfOwnedAsync_WhileBrowserHandoffLeaseHeld_ReturnsFalseAndK generation: 4); Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort)); - try - { - var stopped = await service.StopIfOwnedAsync(config, ownershipGeneration: 4, CancellationToken.None); + var stopped = await service.StopIfOwnedAsync(config, ownershipGeneration: 4, CancellationToken.None); - Assert.False(stopped); - 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); - Assert.Equal(SshTunnelOwner.GatewayConnectionManager, CurrentOwner(service)); - } - finally - { - service.ExitBrowserHandoff(); - } + 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_DoesNotReportNotConfiguredUntilLeaseEnds() + public void ResetNotConfigured_DuringBrowserHandoff_CancelsTheHoldImmediately() { using var service = new SshTunnelService(NullLogger.Instance); var config = new SshTunnelConfig("user", "host", 18789, 45678); @@ -479,25 +472,7 @@ public void ResetNotConfigured_DuringBrowserHandoff_DoesNotReportNotConfiguredUn service.ResetNotConfigured(); - var held = Assert.Throws( - () => service.EnsureStarted("other", "host", 18789, 45679)); - Assert.Equal("SSH tunnel is held for a dashboard launch.", held.Message); - Assert.Same(process, TrackedProcess(service)); - Assert.False(process.HasExited); - Assert.Equal(TunnelStatus.Up, service.Status); - Assert.NotEqual(TunnelStatus.NotConfigured, service.Status); - Assert.Equal("tunnel still up", service.LastError); - Assert.True(service.IsRunning); - - service.ExitBrowserHandoff(); - - Assert.Same(process, TrackedProcess(service)); - Assert.False(process.HasExited); - Assert.Equal(TunnelStatus.Up, service.Status); - Assert.Equal("tunnel still up", service.LastError); - - service.ExitBrowserHandoff(); - + Assert.Equal(0, service.BrowserHandoffLeaseCount); Assert.Null(TrackedProcess(service)); Assert.Throws(() => process.HasExited); Assert.Equal(TunnelStatus.NotConfigured, service.Status); @@ -506,7 +481,7 @@ public void ResetNotConfigured_DuringBrowserHandoff_DoesNotReportNotConfiguredUn } [Fact] - public void Stop_DuringBrowserHandoff_KillsProcessWhenLeaseEnds() + public void Stop_DuringBrowserHandoff_CancelsTheHoldAndStops() { using var service = new SshTunnelService(NullLogger.Instance); var config = new SshTunnelConfig("user", "host", 18789, 45678); @@ -519,13 +494,8 @@ public void Stop_DuringBrowserHandoff_KillsProcessWhenLeaseEnds() service.Stop(); - Assert.Same(process, TrackedProcess(service)); - Assert.False(process.HasExited); - Assert.Equal(TunnelStatus.Up, service.Status); - Assert.True(service.IsRunning); - - service.ExitBrowserHandoff(); - + Assert.Equal(0, service.BrowserHandoffLeaseCount); + Assert.False(service.HasDeferredStop); Assert.Null(TrackedProcess(service)); Assert.Throws(() => process.HasExited); Assert.Equal(TunnelStatus.Stopped, service.Status); @@ -533,7 +503,7 @@ public void Stop_DuringBrowserHandoff_KillsProcessWhenLeaseEnds() } [Fact] - public void StopDuringBrowserHandoff_KeepsListenerUntilConsumptionProbe() + public void StopDuringBrowserHandoff_DoesNotWaitForBrowserTraffic() { using var service = new SshTunnelService(NullLogger.Instance); var config = new SshTunnelConfig("user", "host", 18789, 45678); @@ -544,32 +514,17 @@ public void StopDuringBrowserHandoff_KeepsListenerUntilConsumptionProbe() generation: 4); Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort)); - try - { - service.Stop(); - - Assert.True(service.HasDeferredStop); - Assert.Equal(1, service.BrowserHandoffLeaseCount); - Assert.False(service.TryCompleteDeferredBrowserHandoff(1)); - Assert.Same(process, TrackedProcess(service)); - Assert.False(process.HasExited); - Assert.True(service.IsRunning); + service.Stop(); - Assert.True(service.TryCompleteDeferredBrowserHandoff(config.LocalPort)); - Assert.Equal(0, service.BrowserHandoffLeaseCount); - Assert.False(service.HasDeferredStop); - Assert.Null(TrackedProcess(service)); - Assert.Equal(TunnelStatus.Stopped, service.Status); - } - finally - { - if (service.BrowserHandoffLeaseCount > 0) - service.ExitBrowserHandoff(); - } + 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_KeepsLeaseWhenStopIsAlreadyDeferred() + public void ReleaseBrowserHandoff_AfterStop_FindsNoLease() { using var service = new SshTunnelService(NullLogger.Instance); var config = new SshTunnelConfig("user", "host", 18789, 45678); @@ -581,20 +536,12 @@ public void ReleaseBrowserHandoff_KeepsLeaseWhenStopIsAlreadyDeferred() Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort)); service.Stop(); - try - { - Assert.True(service.TryReleaseBrowserHandoffUnlessDeferred(config.LocalPort, out var watch)); - Assert.True(watch); - Assert.Equal(1, service.BrowserHandoffLeaseCount); - Assert.True(service.HasDeferredStop); - Assert.False(process.HasExited); - Assert.True(service.IsRunning); - } - finally - { - if (service.BrowserHandoffLeaseCount > 0) - service.ExitBrowserHandoff(); - } + 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] @@ -695,7 +642,6 @@ public async Task WatchBrowserHandoff_TimesOutWithoutReleasingOwnedListener() SshTunnelOwner.Settings, generation: 4); Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort)); - service.Stop(); try { @@ -707,7 +653,7 @@ public async Task WatchBrowserHandoff_TimesOutWithoutReleasingOwnedListener() Assert.False(completed); Assert.False(process.HasExited); Assert.Equal(1, service.BrowserHandoffLeaseCount); - Assert.True(service.HasDeferredStop); + Assert.False(service.HasDeferredStop); Assert.True(service.IsRunning); Assert.Equal(TunnelStatus.Up, service.Status); } @@ -729,7 +675,6 @@ public async Task WatchBrowserHandoff_NullProbe_HoldsUntilTheSshProcessIsGone() SshTunnelOwner.Settings, generation: 4); Assert.True(service.TryEnterBrowserHandoff(4, config.LocalPort, process.Id, out var handoffId)); - service.Stop(); var waiting = await service.WatchBrowserHandoffConsumptionAsync( handoffId, @@ -738,7 +683,7 @@ public async Task WatchBrowserHandoff_NullProbe_HoldsUntilTheSshProcessIsGone() consumptionProbe: null); Assert.False(waiting); Assert.Equal(1, service.BrowserHandoffLeaseCount); - Assert.True(service.HasDeferredStop); + Assert.False(service.HasDeferredStop); Assert.True(service.IsRunning); process.Kill(entireProcessTree: true); diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index f0e7d95ba..5374542d5 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -491,6 +491,7 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() Assert.DoesNotContain("Failed to open dashboard", method); Assert.Contains("if (!sshGate.Allowed)", prepare); Assert.Contains("DashboardBrowserHandoff.SameBinding(", prepare); + Assert.Contains("DashboardBrowserHandoff.LocalForwardBrowserUrl(forward.LocalPort)", 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); From 947f30a503f049f6ece1a2a623bf03b59c02e9d6 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 20:36:33 -0700 Subject: [PATCH 18/27] fix(connection): keep a submitted dashboard URL off a freed SSH port Stop before the browser is opened cancels that launch and then stops SSH. Stop after the URL was handed to the browser leaves the forward in place until a second stop, or until that browser process exits. The local forward URL keeps the saved mount path, query, and fragment. Validation: .\build.ps1 succeeded. Shared tests 4224 passed, 33 skipped. Tray tests 3808 passed. Connection handoff and dashboard URL tests 34 passed. Signed-off-by: Sebastien Tardif --- .../DashboardBrowserHandoff.cs | 17 +++- src/OpenClaw.Connection/SshTunnelService.cs | 88 ++++++++++++++++++- src/OpenClaw.Tray.WinUI/App.xaml.cs | 10 +-- .../DashboardBrowserHandoffTests.cs | 10 +-- .../SshTunnelServiceTests.cs | 31 +++++++ .../AppRefactorContractTests.cs | 6 +- 6 files changed, 145 insertions(+), 17 deletions(-) diff --git a/src/OpenClaw.Connection/DashboardBrowserHandoff.cs b/src/OpenClaw.Connection/DashboardBrowserHandoff.cs index be7690b1e..6ab63b81a 100644 --- a/src/OpenClaw.Connection/DashboardBrowserHandoff.cs +++ b/src/OpenClaw.Connection/DashboardBrowserHandoff.cs @@ -40,8 +40,21 @@ public static bool SameTunnelDestination(SshTunnelConfig? left, SshTunnelConfig? left.IncludeBrowserProxyForward == right.IncludeBrowserProxyForward; } - public static string LocalForwardBrowserUrl(int localPort) => - $"http://127.0.0.1:{localPort}/"; + 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) { diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index 510caf5ac..322d18be0 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -828,13 +828,60 @@ public bool TryReleaseBrowserHandoffUnlessDeferred(int localPort, out bool watch } } + public bool TryBeginDashboardNavigation(long handoffId) + { + lock (_stateLock) + { + var handoff = FindOpenHandoffLocked(handoffId); + if (handoff is null || handoff.CancelRequested || handoff.Submitted) + return false; + + handoff.Opening = true; + 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; + } + else + { + handoff.Submitted = true; + 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) { + var force = _deferredStop != DeferredTunnelStop.None; + foreach (var handoff in _browserHandoffs) + { + if (!handoff.Settled) + handoff.CancelRequested = true; + } + handoffIds = _browserHandoffs - .Where(handoff => !handoff.Settled) + .Where(handoff => !handoff.Settled && (force || (!handoff.Submitted && !handoff.Opening))) .Select(handoff => handoff.Id) .ToArray(); } @@ -973,6 +1020,11 @@ public async Task WatchBrowserHandoffConsumptionAsync( // Browser process identity does not identify this dashboard navigation. var consumed = consumptionProbe?.Invoke(localPort) == true; + if (!consumed && handoffId > 0 && DashboardBrowserProcessExited(handoffId)) + { + if (TryFinishHandoff(handoffId, localPort) || !IsBrowserHandoffOpen(handoffId)) + return true; + } if (consumed || !IsRunning) { var completed = handoffId > 0 @@ -993,6 +1045,31 @@ public async Task WatchBrowserHandoffConsumptionAsync( } } + private bool DashboardBrowserProcessExited(long handoffId) + { + int? processId; + lock (_stateLock) + { + var handoff = FindOpenHandoffLocked(handoffId); + if (handoff is not { Submitted: true }) + return false; + processId = handoff.BrowserProcessId; + } + + if (processId is not int id || id <= 0) + return false; + + try + { + using var process = Process.GetProcessById(id); + return process.HasExited; + } + catch (ArgumentException) + { + return true; + } + } + private bool TryFinishHandoff(long handoffId, int localPort) { lock (_stateLock) @@ -1050,6 +1127,9 @@ 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; } } @@ -1194,6 +1274,12 @@ public Task StopIfOwnedAsync( return Task.FromResult(false); } + if (_browserHandoffLeases > 0) + { + RememberDeferredStopLocked(DeferredTunnelStop.Stop); + return Task.FromResult(false); + } + process = ClaimProcessForStopLocked(); } diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 5400c5c0c..37cfee155 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -4100,7 +4100,7 @@ private void OpenDashboard(string? path = null) ? new SettingsOwnedForwardBinding(true, generation, sshGate.LocalPort, sshGate.ProcessId) : null; var browserGatewayUrl = ownedForward is { } forward - ? DashboardBrowserHandoff.LocalForwardBrowserUrl(forward.LocalPort) + ? DashboardBrowserHandoff.ProjectOntoLocalForward(after.GatewayUrl, forward.LocalPort) : after.GatewayUrl; return (DashboardLaunchReadiness.Ready, new DashboardLaunchPreparation( new InteractiveGatewayCredential( @@ -4129,17 +4129,15 @@ private Task LaunchDashboardBrowserAsync(string url, DashboardLaunchPrepar return Task.FromResult(false); } - if (tunnel.HasDeferredStop) + if (tunnel.HasDeferredStop || !tunnel.TryBeginDashboardNavigation(handoffId)) { tunnel.ExitBrowserHandoff(handoffId); return Task.FromResult(false); } - if (!DashboardBrowserShell.TryOpen(url, out _)) - { - tunnel.ExitBrowserHandoff(handoffId); + 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 () => diff --git a/tests/OpenClaw.Connection.Tests/DashboardBrowserHandoffTests.cs b/tests/OpenClaw.Connection.Tests/DashboardBrowserHandoffTests.cs index 28cccd79d..97d6bc589 100644 --- a/tests/OpenClaw.Connection.Tests/DashboardBrowserHandoffTests.cs +++ b/tests/OpenClaw.Connection.Tests/DashboardBrowserHandoffTests.cs @@ -56,21 +56,21 @@ public void SameBinding_RejectsDestinationChangeWhenLocalPortStays() } [Fact] - public void LocalForwardBrowserUrl_ProjectsTheVerifiedPort() + public void ProjectOntoLocalForward_KeepsTheSavedMountPathAndFragment() { var tunnel = new SshTunnelConfig("user", "gateway.example", 18789, 19001); var saved = new DashboardGatewayTunnelSnapshot( "gateway-a", - "wss://gateway.example/", + "wss://gateway.example/mount/#token=old&view=compact", "token-a", true, tunnel); - var browserUrl = DashboardBrowserHandoff.LocalForwardBrowserUrl(tunnel.LocalPort); + var browserUrl = DashboardBrowserHandoff.ProjectOntoLocalForward(saved.GatewayUrl, tunnel.LocalPort); - Assert.Equal("http://127.0.0.1:19001/", browserUrl); + Assert.Equal("https://127.0.0.1:19001/mount/#token=old&view=compact", browserUrl); Assert.True(DashboardBrowserHandoff.UrlUsesCapturedForward( - browserUrl + "#token=token-a", + "https://127.0.0.1:19001/mount/custodian#token=token-a", saved)); } diff --git a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs index 091251080..bf4bb29f8 100644 --- a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs @@ -480,6 +480,37 @@ public void ResetNotConfigured_DuringBrowserHandoff_CancelsTheHoldImmediately() Assert.False(service.IsRunning); } + [Fact] + public void Stop_AfterNavigationSubmitted_KeepsTheProcessUntilASecondStop() + { + 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)); + Assert.True(service.TryBeginDashboardNavigation(handoffId)); + Assert.True(service.CompleteDashboardNavigation(handoffId, opened: true, processId: null)); + + service.Stop(); + + Assert.True(service.HasDeferredStop); + Assert.Equal(1, service.BrowserHandoffLeaseCount); + Assert.Same(process, TrackedProcess(service)); + Assert.True(service.IsRunning); + Assert.False(process.HasExited); + + service.Stop(); + + Assert.False(service.HasDeferredStop); + Assert.Equal(0, service.BrowserHandoffLeaseCount); + Assert.Null(TrackedProcess(service)); + Assert.False(service.IsRunning); + Assert.Throws(() => process.HasExited); + } + [Fact] public void Stop_DuringBrowserHandoff_CancelsTheHoldAndStops() { diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 5374542d5..72a8e25e1 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -491,7 +491,7 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() Assert.DoesNotContain("Failed to open dashboard", method); Assert.Contains("if (!sshGate.Allowed)", prepare); Assert.Contains("DashboardBrowserHandoff.SameBinding(", prepare); - Assert.Contains("DashboardBrowserHandoff.LocalForwardBrowserUrl(forward.LocalPort)", prepare); + Assert.Contains("DashboardBrowserHandoff.ProjectOntoLocalForward(after.GatewayUrl, forward.LocalPort)", 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); @@ -512,9 +512,9 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() launch, "UrlUsesCapturedForward(url, prepared.Snapshot)", "TryEnterBrowserHandoff(owned.Generation, owned.LocalPort, owned.ProcessId, out var handoffId)", - "HasDeferredStop", - "ExitBrowserHandoff(handoffId)", + "TryBeginDashboardNavigation(handoffId)", "DashboardBrowserShell.TryOpen", + "CompleteDashboardNavigation(handoffId, opened, browserProcessId)", "WatchBrowserHandoffConsumptionAsync(", "if (!tunnel.IsRunning)", "ExitBrowserHandoff(handoffId)"); From 0dc8d36a3230d8df9ac00152a31a572f70f4862d Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 20:54:11 -0700 Subject: [PATCH 19/27] ci: retrigger after the dark sidebar navigation flake Signed-off-by: Sebastien Tardif From a74068c3d215fb190e3745387582d279f1518cdf Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 21:26:09 -0700 Subject: [PATCH 20/27] fix(connection): keep pending dashboard navigation across Stop and shell exit Repeated Stop leaves a submitted dashboard hold in place. A shell process that exits after handing the URL to an existing browser does not release it. Dispose, StopAsync, and Restart stop SSH and keep that local port occupied until the next SSH start binds it. Validation: .\build.ps1 succeeded. Shared tests 4224 passed, 33 skipped. Tray tests 3808 passed. Connection handoff tests passed, including repeated Stop, activation-process exit, and disposal. Signed-off-by: Sebastien Tardif --- .../DashboardForwardPortGuard.cs | 60 ++++++++++++ src/OpenClaw.Connection/SshTunnelService.cs | 97 ++++++++----------- .../SshTunnelServiceTests.cs | 50 +++++++++- 3 files changed, 147 insertions(+), 60 deletions(-) create mode 100644 src/OpenClaw.Connection/DashboardForwardPortGuard.cs diff --git a/src/OpenClaw.Connection/DashboardForwardPortGuard.cs b/src/OpenClaw.Connection/DashboardForwardPortGuard.cs new file mode 100644 index 000000000..e6b15f06c --- /dev/null +++ b/src/OpenClaw.Connection/DashboardForwardPortGuard.cs @@ -0,0 +1,60 @@ +using System.Net; +using System.Net.Sockets; + +namespace OpenClaw.Connection; + +/// +/// Holds a loopback forward port after SSH teardown so a different listener +/// cannot accept a dashboard URL that was already handed to the browser. +/// The next SSH start releases the port immediately before it binds. +/// +internal static class DashboardForwardPortGuard +{ + private static readonly object Gate = new(); + private static readonly Dictionary Listeners = new(); + + internal static void Hold(int port) + { + if (port is < 1 or > 65535) + return; + + lock (Gate) + { + if (Listeners.ContainsKey(port)) + return; + + try + { + var listener = new TcpListener(IPAddress.Loopback, port); + listener.Start(); + Listeners[port] = listener; + } + catch (SocketException) + { + } + } + } + + internal static void Release(int port) + { + lock (Gate) + { + if (!Listeners.Remove(port, out var listener)) + return; + + try + { + listener.Stop(); + } + catch (SocketException) + { + } + } + } + + internal static bool IsHolding(int port) + { + lock (Gate) + return Listeners.ContainsKey(port); + } +} diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index 322d18be0..2221ca3a3 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -457,8 +457,13 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s } var processStarted = false; + var heldDashboardPort = DashboardForwardPortGuard.IsHolding(localPort); + var heldProxyPort = includeBrowserProxyForward && DashboardForwardPortGuard.IsHolding(localPort + 2); try { + DashboardForwardPortGuard.Release(localPort); + if (includeBrowserProxyForward) + DashboardForwardPortGuard.Release(localPort + 2); if (!process.Start()) { throw new InvalidOperationException("Failed to start ssh process"); @@ -520,6 +525,10 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s } } process.Dispose(); + if (heldDashboardPort) + DashboardForwardPortGuard.Hold(localPort); + if (heldProxyPort) + DashboardForwardPortGuard.Hold(localPort + 2); throw new InvalidOperationException("Unable to start SSH tunnel process. Ensure OpenSSH client is installed and available in PATH.", ex); } @@ -566,7 +575,8 @@ private static string BuildSpec(string user, string host, int remotePort, int lo public void Dispose() { - Stop(); + lock (_operationLock) + TeardownLocked(); } public Task IsOwnedListenerReadyAsync( @@ -873,7 +883,6 @@ public void CancelOpenBrowserHandoffs() long[] handoffIds; lock (_stateLock) { - var force = _deferredStop != DeferredTunnelStop.None; foreach (var handoff in _browserHandoffs) { if (!handoff.Settled) @@ -881,7 +890,7 @@ public void CancelOpenBrowserHandoffs() } handoffIds = _browserHandoffs - .Where(handoff => !handoff.Settled && (force || (!handoff.Submitted && !handoff.Opening))) + .Where(handoff => !handoff.Settled && !handoff.Submitted && !handoff.Opening) .Select(handoff => handoff.Id) .ToArray(); } @@ -1018,13 +1027,9 @@ public async Task WatchBrowserHandoffConsumptionAsync( return true; } - // Browser process identity does not identify this dashboard navigation. + // 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 && handoffId > 0 && DashboardBrowserProcessExited(handoffId)) - { - if (TryFinishHandoff(handoffId, localPort) || !IsBrowserHandoffOpen(handoffId)) - return true; - } if (consumed || !IsRunning) { var completed = handoffId > 0 @@ -1045,31 +1050,6 @@ public async Task WatchBrowserHandoffConsumptionAsync( } } - private bool DashboardBrowserProcessExited(long handoffId) - { - int? processId; - lock (_stateLock) - { - var handoff = FindOpenHandoffLocked(handoffId); - if (handoff is not { Submitted: true }) - return false; - processId = handoff.BrowserProcessId; - } - - if (processId is not int id || id <= 0) - return false; - - try - { - using var process = Process.GetProcessById(id); - return process.HasExited; - } - catch (ArgumentException) - { - return true; - } - } - private bool TryFinishHandoff(long handoffId, int localPort) { lock (_stateLock) @@ -1232,10 +1212,35 @@ await WaitForOwnedLocalListenerAsync( public Task StopAsync() { - Stop(); + lock (_operationLock) + TeardownLocked(); return Task.CompletedTask; } + private void TeardownLocked() + { + int[] protectedPorts; + Process? process; + lock (_stateLock) + { + protectedPorts = _browserHandoffs + .Where(handoff => !handoff.Settled && (handoff.Submitted || handoff.Opening)) + .Select(handoff => handoff.LocalPort) + .Distinct() + .ToArray(); + foreach (var handoff in _browserHandoffs) + handoff.CancelRequested = true; + _browserHandoffs.Clear(); + _browserHandoffLeases = 0; + _deferredStop = DeferredTunnelStop.None; + process = ClaimProcessForStopLocked(); + } + + StopClaimedProcess(process); + foreach (var port in protectedPorts) + DashboardForwardPortGuard.Hold(port); + } + public Task StopIfOwnedAsync( SshTunnelConfig config, long ownershipGeneration, @@ -1263,27 +1268,7 @@ public Task StopIfOwnedAsync( } } - CancelOpenBrowserHandoffs(); - Process? process; - lock (_stateLock) - { - if (_lifecycleGeneration != ownershipGeneration || - !Equals(_currentConfig, normalizedConfig) || - _currentOwner != SshTunnelOwner.GatewayConnectionManager) - { - return Task.FromResult(false); - } - - if (_browserHandoffLeases > 0) - { - RememberDeferredStopLocked(DeferredTunnelStop.Stop); - return Task.FromResult(false); - } - - process = ClaimProcessForStopLocked(); - } - - StopClaimedProcess(process); + TeardownLocked(); return Task.FromResult(true); } finally diff --git a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs index bf4bb29f8..386b7e305 100644 --- a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs @@ -481,7 +481,7 @@ public void ResetNotConfigured_DuringBrowserHandoff_CancelsTheHoldImmediately() } [Fact] - public void Stop_AfterNavigationSubmitted_KeepsTheProcessUntilASecondStop() + public void Stop_AfterNavigationSubmitted_KeepsTheProcessUntilTeardown() { using var service = new SshTunnelService(NullLogger.Instance); var config = new SshTunnelConfig("user", "host", 18789, 45678); @@ -504,11 +504,53 @@ public void Stop_AfterNavigationSubmitted_KeepsTheProcessUntilASecondStop() service.Stop(); - Assert.False(service.HasDeferredStop); - Assert.Equal(0, service.BrowserHandoffLeaseCount); - Assert.Null(TrackedProcess(service)); + Assert.True(service.HasDeferredStop); + Assert.Equal(1, service.BrowserHandoffLeaseCount); + Assert.Same(process, TrackedProcess(service)); + Assert.True(service.IsRunning); + Assert.False(process.HasExited); + Assert.False(DashboardForwardPortGuard.IsHolding(config.LocalPort)); + + service.Dispose(); + Assert.False(service.IsRunning); + Assert.True(DashboardForwardPortGuard.IsHolding(config.LocalPort)); Assert.Throws(() => process.HasExited); + DashboardForwardPortGuard.Release(config.LocalPort); + } + + [Fact] + public async Task Watch_DoesNotReleaseWhenTheActivationProcessHasExited() + { + 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); + 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); } [Fact] From a17730be39d6afc0889a20411951a4d23a2ca006 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 21:45:49 -0700 Subject: [PATCH 21/27] ci: retrigger after a concurrent command output flake Signed-off-by: Sebastien Tardif From 890e1583d3ef6b9adeb606f56268363058f05363 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 22:11:33 -0700 Subject: [PATCH 22/27] fix(connection): let restart reuse the dashboard port guard A port held after dashboard teardown is our listener, so startup does not reject it as a foreign process. The guard stays bound. Replacement SSH listens on a backend port, and the guard forwards only to that process. Validation: .\build.ps1 succeeded. Shared tests 4224 passed, 33 skipped. Tray tests 3808 passed. Connection handoff and port-guard tests 38 passed. Signed-off-by: Sebastien Tardif --- .../DashboardForwardPortGuard.cs | 126 +++++++++++++++--- src/OpenClaw.Connection/SshTunnelService.cs | 76 ++++++++--- .../SshTunnelServiceTests.cs | 16 +++ 3 files changed, 182 insertions(+), 36 deletions(-) diff --git a/src/OpenClaw.Connection/DashboardForwardPortGuard.cs b/src/OpenClaw.Connection/DashboardForwardPortGuard.cs index e6b15f06c..83b11c1ce 100644 --- a/src/OpenClaw.Connection/DashboardForwardPortGuard.cs +++ b/src/OpenClaw.Connection/DashboardForwardPortGuard.cs @@ -4,57 +4,151 @@ namespace OpenClaw.Connection; /// -/// Holds a loopback forward port after SSH teardown so a different listener +/// 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 next SSH start releases the port immediately before it binds. +/// 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 required CancellationTokenSource Cancel { get; init; } + public int? BackendPort { get; set; } + public int? BackendProcessId { get; set; } + } + private static readonly object Gate = new(); - private static readonly Dictionary Listeners = new(); + private static readonly Dictionary Listeners = new(); internal static void Hold(int port) { if (port is < 1 or > 65535) - return; + throw new ArgumentOutOfRangeException(nameof(port)); lock (Gate) { if (Listeners.ContainsKey(port)) return; - try - { - var listener = new TcpListener(IPAddress.Loopback, port); - listener.Start(); - Listeners[port] = listener; - } - catch (SocketException) + var listener = new TcpListener(IPAddress.Loopback, port); + listener.Start(); + var cancel = new CancellationTokenSource(); + var slot = new Slot { Listener = listener, Cancel = cancel }; + Listeners[port] = slot; + _ = Task.Run(() => AcceptAsync(port, slot)); + } + } + + internal static void SetBackend(int port, int backendPort, int backendProcessId) + { + lock (Gate) + { + if (Listeners.TryGetValue(port, out var slot)) { + slot.BackendPort = backendPort; + slot.BackendProcessId = backendProcessId; } } } 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) + { + } + } + + 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) + { + try { - if (!Listeners.Remove(port, out var listener)) + while (!slot.Cancel.IsCancellationRequested) + { + var client = await slot.Listener.AcceptTcpClientAsync(slot.Cancel.Token).ConfigureAwait(false); + _ = Task.Run(() => Pump(port, client)); + } + } + catch (OperationCanceledException) + { + } + catch (SocketException) + { + } + catch (ObjectDisposedException) + { + } + } + + private static void 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; + } + + if (backendPort is not int target || backendProcessId is not int processId || processId <= 0) + return; + if (!BackendIsOwnedBy(target, processId)) return; try { - listener.Stop(); + using var backend = new TcpClient(); + backend.Connect(IPAddress.Loopback, target); + var left = client.GetStream(); + var right = backend.GetStream(); + var toRight = left.CopyToAsync(right); + var toLeft = right.CopyToAsync(left); + Task.WaitAll(toRight, toLeft); + } + catch (IOException) + { } catch (SocketException) { } + catch (ObjectDisposedException) + { + } } } - internal static bool IsHolding(int port) + private static bool BackendIsOwnedBy(int backendPort, int processId) { - lock (Gate) - return Listeners.ContainsKey(port); + var snapshot = WindowsTcpListenerSnapshot.Capture(); + return snapshot.Listeners.Any(listener => + listener.Port == backendPort && listener.ProcessId == processId); } } diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index 2221ca3a3..d525aea6a 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Net; +using System.Net.Sockets; namespace OpenClaw.Connection; @@ -457,18 +458,28 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s } var processStarted = false; - var heldDashboardPort = DashboardForwardPortGuard.IsHolding(localPort); - var heldProxyPort = includeBrowserProxyForward && DashboardForwardPortGuard.IsHolding(localPort + 2); + var keepPublicPort = DashboardForwardPortGuard.IsHolding(localPort) && !includeBrowserProxyForward; + var sshLocalPort = keepPublicPort ? AllocateLoopbackPort() : localPort; + if (keepPublicPort) + { + psi.Arguments = SshTunnelCommandLine.BuildArguments( + user, + host, + remotePort, + sshLocalPort, + includeBrowserProxyForward: false, + sshPort); + } + try { - DashboardForwardPortGuard.Release(localPort); - if (includeBrowserProxyForward) - DashboardForwardPortGuard.Release(localPort + 2); if (!process.Start()) { throw new InvalidOperationException("Failed to start ssh process"); } processStarted = true; + if (keepPublicPort) + DashboardForwardPortGuard.SetBackend(localPort, sshLocalPort, process.Id); process.BeginOutputReadLine(); process.BeginErrorReadLine(); @@ -525,10 +536,6 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s } } process.Dispose(); - if (heldDashboardPort) - DashboardForwardPortGuard.Hold(localPort); - if (heldProxyPort) - DashboardForwardPortGuard.Hold(localPort + 2); throw new InvalidOperationException("Unable to start SSH tunnel process. Ensure OpenSSH client is installed and available in PATH.", ex); } @@ -627,11 +634,13 @@ _process is null || try { - if (!ValidateListenerOwnership( + var proofPort = ListenerPortToProve(destinationPort); + if (proofPort < 1 || + !ValidateListenerOwnership( WindowsTcpListenerSnapshot.Capture(), - destinationPort, - processId, - processStartTimeUtc)) + proofPort, + processId, + processStartTimeUtc)) { return Task.FromResult(false); } @@ -1140,9 +1149,34 @@ _currentOwner is SshTunnelOwner.Settings or SshTunnelOwner.GatewayConnectionMana private static void RejectOccupiedForwardPorts(SshTunnelConfig tunnel) { - EnsurePortIsUnoccupied(WindowsTcpListenerSnapshot.Capture(), tunnel.LocalPort); + RejectForeignForwardPort(tunnel.LocalPort); if (tunnel.IncludeBrowserProxyForward) - EnsurePortIsUnoccupied(WindowsTcpListenerSnapshot.Capture(), tunnel.LocalPort + 2); + 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( @@ -1300,11 +1334,13 @@ private async Task WaitForOwnedLocalListenerAsync( } } - if (ValidateListenerOwnership( - WindowsTcpListenerSnapshot.Capture(), - localPort, - processId, - processStartTimeUtc)) + var proofPort = ListenerPortToProve(localPort); + if (proofPort > 0 && + ValidateListenerOwnership( + WindowsTcpListenerSnapshot.Capture(), + proofPort, + processId, + processStartTimeUtc)) { return; } diff --git a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs index 386b7e305..3ac7f6af2 100644 --- a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs @@ -237,6 +237,22 @@ await Assert.ThrowsAnyAsync( } } + [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() { From 36067e9ef448645370596745f21294b3a7a98d82 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 22:28:03 -0700 Subject: [PATCH 23/27] ci: retrigger after a canceled-process wait flake Signed-off-by: Sebastien Tardif From 6f0a4cbbcd7712b24e4c530283efc6724e49cd13 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Fri, 2 Oct 2026 07:35:57 -0700 Subject: [PATCH 24/27] fix(connection): keep the dashboard port through SSH restart This app binds the browser port before SSH starts and keeps it when SSH stops. A replacement SSH process listens on a backend port, and traffic is forwarded only when that backend is still the SSH process for the original gateway. A different host or remote port cannot reuse the hold. Browser-proxy forwarding still uses the saved local port. An unexpected SSH exit keeps the hold instead of dropping the navigation lease. Validation: .\build.ps1 succeeded. Shared tests 4224 passed, 33 skipped. Tray tests 3808 passed. Focused handoff and port-guard tests 40 passed. Signed-off-by: Sebastien Tardif --- .../DashboardForwardPortGuard.cs | 65 ++++++++++++++++++- src/OpenClaw.Connection/SshTunnelService.cs | 53 +++++++++------ src/OpenClaw.Shared/SshTunnelCommandLine.cs | 23 ++++++- .../SshTunnelServiceTests.cs | 47 ++++++++++++++ 4 files changed, 165 insertions(+), 23 deletions(-) diff --git a/src/OpenClaw.Connection/DashboardForwardPortGuard.cs b/src/OpenClaw.Connection/DashboardForwardPortGuard.cs index 83b11c1ce..526cc73c1 100644 --- a/src/OpenClaw.Connection/DashboardForwardPortGuard.cs +++ b/src/OpenClaw.Connection/DashboardForwardPortGuard.cs @@ -16,6 +16,9 @@ private sealed class Slot 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; } } private static readonly object Gate = new(); @@ -40,6 +43,37 @@ internal static void Hold(int port) } } + internal static void ClearBackend(int port) + { + lock (Gate) + { + if (!Listeners.TryGetValue(port, out var slot)) + return; + slot.BackendPort = null; + slot.BackendProcessId = null; + } + } + + internal static bool AllowsDestination(int port, string user, string host, int remotePort) + { + 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.User = user; + slot.Host = host; + slot.RemotePort = remotePort; + return true; + } + + return string.Equals(slot.User, user, StringComparison.Ordinal) && + string.Equals(slot.Host, host, StringComparison.Ordinal) && + slot.RemotePort == remotePort; + } + } + internal static void SetBackend(int port, int backendPort, int backendProcessId) { lock (Gate) @@ -127,11 +161,18 @@ private static void Pump(int port, TcpClient client) { using var backend = new TcpClient(); backend.Connect(IPAddress.Loopback, target); + if (!ConnectedBackendIsOwnedBy(backend, target, processId)) + return; + var left = client.GetStream(); var right = backend.GetStream(); - var toRight = left.CopyToAsync(right); - var toLeft = right.CopyToAsync(left); - Task.WaitAll(toRight, toLeft); + using var stop = new CancellationTokenSource(); + var toRight = left.CopyToAsync(right, stop.Token); + var toLeft = right.CopyToAsync(left, stop.Token); + Task.WhenAny(toRight, toLeft).GetAwaiter().GetResult(); + stop.Cancel(); + client.Close(); + backend.Close(); } catch (IOException) { @@ -145,6 +186,24 @@ private static void Pump(int port, TcpClient client) } } + private static bool ConnectedBackendIsOwnedBy(TcpClient backend, int backendPort, int processId) + { + if (backend.Client.RemoteEndPoint is not IPEndPoint remote || remote.Port != backendPort) + return false; + if (remote.Address is not { } address || !IPAddress.IsLoopback(address)) + return false; + + var snapshot = WindowsTcpListenerSnapshot.Capture(); + var owners = snapshot.Listeners + .Where(listener => listener.Port == backendPort && IsLoopback(listener.Address)) + .Select(listener => listener.ProcessId) + .Distinct() + .ToArray(); + return owners.Length == 1 && owners[0] == processId; + } + + private static bool IsLoopback(IPAddress address) => IPAddress.IsLoopback(address); + private static bool BackendIsOwnedBy(int backendPort, int processId) { var snapshot = WindowsTcpListenerSnapshot.Capture(); diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index d525aea6a..10b450dfc 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -366,6 +366,12 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s var localPort = tunnel.LocalPort; var includeBrowserProxyForward = tunnel.IncludeBrowserProxyForward; var sshPort = tunnel.SshPort; + var alreadyHeld = DashboardForwardPortGuard.IsHolding(localPort); + DashboardForwardPortGuard.Hold(localPort); + if (!DashboardForwardPortGuard.AllowsDestination(localPort, user, host, remotePort)) + throw new InvalidOperationException("SSH destination does not match the dashboard forward."); + + var sshLocalPort = AllocateLoopbackPort(); var psi = new ProcessStartInfo { FileName = "ssh", @@ -373,9 +379,10 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s user, host, remotePort, - localPort, + sshLocalPort, includeBrowserProxyForward, - sshPort), + sshPort, + includeBrowserProxyForward ? localPort + 2 : null), UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, @@ -458,19 +465,6 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s } var processStarted = false; - var keepPublicPort = DashboardForwardPortGuard.IsHolding(localPort) && !includeBrowserProxyForward; - var sshLocalPort = keepPublicPort ? AllocateLoopbackPort() : localPort; - if (keepPublicPort) - { - psi.Arguments = SshTunnelCommandLine.BuildArguments( - user, - host, - remotePort, - sshLocalPort, - includeBrowserProxyForward: false, - sshPort); - } - try { if (!process.Start()) @@ -478,8 +472,7 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s throw new InvalidOperationException("Failed to start ssh process"); } processStarted = true; - if (keepPublicPort) - DashboardForwardPortGuard.SetBackend(localPort, sshLocalPort, process.Id); + DashboardForwardPortGuard.SetBackend(localPort, sshLocalPort, process.Id); process.BeginOutputReadLine(); process.BeginErrorReadLine(); @@ -536,6 +529,8 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s } } process.Dispose(); + if (!alreadyHeld) + DashboardForwardPortGuard.Release(localPort); throw new InvalidOperationException("Unable to start SSH tunnel process. Ensure OpenSSH client is installed and available in PATH.", ex); } @@ -1039,7 +1034,13 @@ public async Task WatchBrowserHandoffConsumptionAsync( // 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) + if (!consumed && !IsRunning && handoffId > 0 && IsSubmittedHandoff(handoffId)) + { + DashboardForwardPortGuard.ClearBackend(localPort); + if (!DashboardForwardPortGuard.IsHolding(localPort)) + DashboardForwardPortGuard.Hold(localPort); + } + else if (consumed || !IsRunning) { var completed = handoffId > 0 ? TryFinishHandoff(handoffId, localPort) @@ -1270,9 +1271,23 @@ private void TeardownLocked() process = ClaimProcessForStopLocked(); } + foreach (var port in protectedPorts) + DashboardForwardPortGuard.ClearBackend(port); StopClaimedProcess(process); foreach (var port in protectedPorts) - DashboardForwardPortGuard.Hold(port); + { + if (!DashboardForwardPortGuard.IsHolding(port)) + DashboardForwardPortGuard.Hold(port); + } + } + + private bool IsSubmittedHandoff(long handoffId) + { + lock (_stateLock) + { + var handoff = FindOpenHandoffLocked(handoffId); + return handoff is { Submitted: true }; + } } public Task StopIfOwnedAsync( diff --git a/src/OpenClaw.Shared/SshTunnelCommandLine.cs b/src/OpenClaw.Shared/SshTunnelCommandLine.cs index 11f28fa4e..fb39962a1 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(); @@ -70,7 +87,11 @@ public static string BuildArguments( sb.Append(BaseOptions); AppendLocalForward(sb, localPort, remotePort); if (includeBrowserProxyForward) - AppendLocalForward(sb, localPort + 2, remotePort + 2); + { + var proxyLocalPort = browserProxyLocalPort ?? localPort + 2; + ValidatePort(proxyLocalPort, nameof(browserProxyLocalPort)); + AppendLocalForward(sb, proxyLocalPort, remotePort + 2); + } if (sshPort != 22) { sb.Append("-p "); diff --git a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs index 3ac7f6af2..358ee8c4d 100644 --- a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs @@ -237,6 +237,24 @@ 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)); + Assert.False(DashboardForwardPortGuard.AllowsDestination(port, "user", "other.example", 18789)); + Assert.False(DashboardForwardPortGuard.AllowsDestination(port, "user", "gateway.example", 18790)); + } + finally + { + DashboardForwardPortGuard.Release(port); + } + } + [Fact] public void RejectForeignForwardPort_AllowsTheDashboardGuard() { @@ -569,6 +587,35 @@ public async Task Watch_DoesNotReleaseWhenTheActivationProcessHasExited() service.ExitBrowserHandoff(handoffId); } + [Fact] + public async Task Watch_SubmittedNavigation_KeepsThePortWhenSshExits() + { + 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)); + 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.False(settled); + Assert.True(service.IsBrowserHandoffOpen(handoffId)); + Assert.True(DashboardForwardPortGuard.IsHolding(config.LocalPort)); + DashboardForwardPortGuard.Release(config.LocalPort); + service.ExitBrowserHandoff(handoffId); + } + [Fact] public void Stop_DuringBrowserHandoff_CancelsTheHoldAndStops() { From 36835f2ac2ea5320bf28939c2a404e86e6c36611 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Fri, 2 Oct 2026 08:19:44 -0700 Subject: [PATCH 25/27] fix(connection): restart dashboard SSH behind the retained port Hold the browser port only while a submitted or opening dashboard navigation needs it. An unexpected SSH exit keeps that port, records the user, host, remote port, and SSH port, and drops the lease so recovery can listen on a backend port. Copy bytes only after the accepted loopback socket belongs to that SSH process and its start time still matches. Validate the browser-proxy port on the saved local port. Project the dashboard URL in GatewayDashboardLauncher. Validation: ./build.ps1 exit 0. Shared 4225 passed, 33 skipped. Tray 3808 passed. Focused handoff tests 46 passed. Signed-off-by: Sebastien Tardif --- .../DashboardForwardPortGuard.cs | 84 +++++++---- src/OpenClaw.Connection/SshTunnelService.cs | 132 ++++++++++++++++-- .../WindowsTcpListenerSnapshot.cs | 34 +++++ src/OpenClaw.Shared/SshTunnelCommandLine.cs | 8 +- src/OpenClaw.Tray.WinUI/App.xaml.cs | 8 +- .../Services/GatewayDashboardLauncher.cs | 8 +- .../SshTunnelServiceTests.cs | 22 ++- tests/OpenClaw.Shared.Tests/ModelsTests.cs | 16 +++ .../AppRefactorContractTests.cs | 3 +- 9 files changed, 254 insertions(+), 61 deletions(-) diff --git a/src/OpenClaw.Connection/DashboardForwardPortGuard.cs b/src/OpenClaw.Connection/DashboardForwardPortGuard.cs index 526cc73c1..ec1749fc4 100644 --- a/src/OpenClaw.Connection/DashboardForwardPortGuard.cs +++ b/src/OpenClaw.Connection/DashboardForwardPortGuard.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Net; using System.Net.Sockets; @@ -19,6 +20,8 @@ private sealed class Slot 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(); @@ -51,30 +54,33 @@ internal static void ClearBackend(int port) return; slot.BackendPort = null; slot.BackendProcessId = null; + slot.BackendStartTimeUtc = null; } } - internal static bool AllowsDestination(int port, string user, string host, int remotePort) + 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) + 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.RemotePort == remotePort && + slot.SshPort == sshPort; } } - internal static void SetBackend(int port, int backendPort, int backendProcessId) + internal static void SetBackend(int port, int backendPort, int backendProcessId, DateTime backendStartTimeUtc) { lock (Gate) { @@ -82,6 +88,7 @@ internal static void SetBackend(int port, int backendPort, int backendProcessId) { slot.BackendPort = backendPort; slot.BackendProcessId = backendProcessId; + slot.BackendStartTimeUtc = backendStartTimeUtc; } } } @@ -124,7 +131,7 @@ private static async Task AcceptAsync(int port, Slot slot) while (!slot.Cancel.IsCancellationRequested) { var client = await slot.Listener.AcceptTcpClientAsync(slot.Cancel.Token).ConfigureAwait(false); - _ = Task.Run(() => Pump(port, client)); + _ = Pump(port, client); } } catch (OperationCanceledException) @@ -138,7 +145,7 @@ private static async Task AcceptAsync(int port, Slot slot) } } - private static void Pump(int port, TcpClient client) + private static async Task Pump(int port, TcpClient client) { using (client) { @@ -152,27 +159,55 @@ private static void Pump(int port, TcpClient client) 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 (!BackendIsOwnedBy(target, processId)) + if (!ProcessStillStartedAt(processId, backendStart)) return; try { using var backend = new TcpClient(); backend.Connect(IPAddress.Loopback, target); - if (!ConnectedBackendIsOwnedBy(backend, target, processId)) + 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); - Task.WhenAny(toRight, toLeft).GetAwaiter().GetResult(); + 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) { @@ -186,28 +221,21 @@ private static void Pump(int port, TcpClient client) } } - private static bool ConnectedBackendIsOwnedBy(TcpClient backend, int backendPort, int processId) + private static bool ProcessStillStartedAt(int processId, DateTime? startTimeUtc) { - if (backend.Client.RemoteEndPoint is not IPEndPoint remote || remote.Port != backendPort) - return false; - if (remote.Address is not { } address || !IPAddress.IsLoopback(address)) + if (startTimeUtc is null) return false; - var snapshot = WindowsTcpListenerSnapshot.Capture(); - var owners = snapshot.Listeners - .Where(listener => listener.Port == backendPort && IsLoopback(listener.Address)) - .Select(listener => listener.ProcessId) - .Distinct() - .ToArray(); - return owners.Length == 1 && owners[0] == processId; + 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; + } } - private static bool IsLoopback(IPAddress address) => IPAddress.IsLoopback(address); - - private static bool BackendIsOwnedBy(int backendPort, int processId) - { - var snapshot = WindowsTcpListenerSnapshot.Capture(); - return snapshot.Listeners.Any(listener => - listener.Port == backendPort && listener.ProcessId == processId); - } } diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index 10b450dfc..3d6f7222b 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -266,6 +266,7 @@ private bool StopLocked() { CancelOpenBrowserHandoffs(); Process? process; + int[] portsToRelease; lock (_stateLock) { if (_browserHandoffLeases > 0) @@ -274,14 +275,39 @@ private bool StopLocked() return false; } + 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) + .Distinct() + .ToArray(); + } + + 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. @@ -337,6 +363,7 @@ public void ResetNotConfigured() CancelOpenBrowserHandoffs(); Process? process = null; var deferred = false; + int[] portsToRelease = []; lock (_stateLock) { if (_browserHandoffLeases > 0) @@ -346,6 +373,7 @@ public void ResetNotConfigured() } else { + portsToRelease = PortsToReleaseLocked(); process = ClaimProcessForStopLocked(); _deferredStop = DeferredTunnelStop.None; LastError = null; @@ -354,7 +382,10 @@ public void ResetNotConfigured() } if (!deferred) + { StopClaimedProcess(process); + ReleaseForwardPorts(portsToRelease); + } } } @@ -366,12 +397,14 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s var localPort = tunnel.LocalPort; var includeBrowserProxyForward = tunnel.IncludeBrowserProxyForward; var sshPort = tunnel.SshPort; - var alreadyHeld = DashboardForwardPortGuard.IsHolding(localPort); - DashboardForwardPortGuard.Hold(localPort); - if (!DashboardForwardPortGuard.AllowsDestination(localPort, user, host, remotePort)) + 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 = AllocateLoopbackPort(); + var sshLocalPort = reuseGuard ? AllocateLoopbackPort() : localPort; var psi = new ProcessStartInfo { FileName = "ssh", @@ -382,7 +415,7 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s sshLocalPort, includeBrowserProxyForward, sshPort, - includeBrowserProxyForward ? localPort + 2 : null), + reuseGuard && includeBrowserProxyForward ? localPort + 2 : null), UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, @@ -448,6 +481,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}"); } @@ -472,7 +506,8 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s throw new InvalidOperationException("Failed to start ssh process"); } processStarted = true; - DashboardForwardPortGuard.SetBackend(localPort, sshLocalPort, process.Id); + if (reuseGuard) + DashboardForwardPortGuard.SetBackend(localPort, sshLocalPort, process.Id, process.StartTime.ToUniversalTime()); process.BeginOutputReadLine(); process.BeginErrorReadLine(); @@ -529,8 +564,6 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s } } process.Dispose(); - if (!alreadyHeld) - DashboardForwardPortGuard.Release(localPort); throw new InvalidOperationException("Unable to start SSH tunnel process. Ensure OpenSSH client is installed and available in PATH.", ex); } @@ -1034,11 +1067,10 @@ public async Task WatchBrowserHandoffConsumptionAsync( // 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 && IsSubmittedHandoff(handoffId)) + if (!consumed && !IsRunning && handoffId > 0 && HandoffNeedsRetainedPort(handoffId)) { - DashboardForwardPortGuard.ClearBackend(localPort); - if (!DashboardForwardPortGuard.IsHolding(localPort)) - DashboardForwardPortGuard.Hold(localPort); + RetainPublicPortAfterUnexpectedExit(localPort); + return true; } else if (consumed || !IsRunning) { @@ -1255,9 +1287,13 @@ public Task StopAsync() 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) @@ -1278,15 +1314,83 @@ private void TeardownLocked() { if (!DashboardForwardPortGuard.IsHolding(port)) DashboardForwardPortGuard.Hold(port); + if (stoppedConfig is not null && stoppedConfig.LocalPort == port) + { + DashboardForwardPortGuard.AllowsDestination( + port, + stoppedConfig.User, + stoppedConfig.Host, + stoppedConfig.RemotePort, + stoppedConfig.SshPort); + } } + + if (protectedPorts.Length == 0 && stoppedPort > 0) + DashboardForwardPortGuard.Release(stoppedPort); } - private bool IsSubmittedHandoff(long handoffId) + private bool HandoffNeedsRetainedPort(long handoffId) { lock (_stateLock) { var handoff = FindOpenHandoffLocked(handoffId); - return handoff is { Submitted: true }; + 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) + 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; } } diff --git a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs index 49ad6be45..6f89bed01 100644 --- a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs +++ b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs @@ -304,6 +304,40 @@ 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, diff --git a/src/OpenClaw.Shared/SshTunnelCommandLine.cs b/src/OpenClaw.Shared/SshTunnelCommandLine.cs index fb39962a1..03fc6ad2f 100644 --- a/src/OpenClaw.Shared/SshTunnelCommandLine.cs +++ b/src/OpenClaw.Shared/SshTunnelCommandLine.cs @@ -77,21 +77,19 @@ 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)); + ValidateBrowserProxyPort(proxyLocalPort, nameof(browserProxyLocalPort)); + ValidatePort(proxyLocalPort, nameof(browserProxyLocalPort)); } var sb = new StringBuilder(); sb.Append(BaseOptions); AppendLocalForward(sb, localPort, remotePort); if (includeBrowserProxyForward) - { - var proxyLocalPort = browserProxyLocalPort ?? localPort + 2; - ValidatePort(proxyLocalPort, nameof(browserProxyLocalPort)); 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 37cfee155..7b8e41838 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -4055,7 +4055,8 @@ private void OpenDashboard(string? path = null) new AppLogger(), "Dashboard launch error"); }, - () => _appNotificationService?.Dismiss(GatewayDashboardLauncher.FailureNotificationId)); + () => _appNotificationService?.Dismiss(GatewayDashboardLauncher.FailureNotificationId), + () => prepared?.OwnedForward?.LocalPort); await launcher.OpenAsync(path); }, new AppLogger(), nameof(OpenDashboard)); } @@ -4099,12 +4100,9 @@ private void OpenDashboard(string? path = null) SettingsOwnedForwardBinding? ownedForward = sshGate.Generation is long generation ? new SettingsOwnedForwardBinding(true, generation, sshGate.LocalPort, sshGate.ProcessId) : null; - var browserGatewayUrl = ownedForward is { } forward - ? DashboardBrowserHandoff.ProjectOntoLocalForward(after.GatewayUrl, forward.LocalPort) - : after.GatewayUrl; return (DashboardLaunchReadiness.Ready, new DashboardLaunchPreparation( new InteractiveGatewayCredential( - browserGatewayUrl, + after.GatewayUrl, after.Token, after.IsBootstrapToken, after.Source), diff --git a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs index cfa398c92..87172324d 100644 --- a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs +++ b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs @@ -11,7 +11,8 @@ internal sealed class GatewayDashboardLauncher( 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) @@ -22,7 +23,10 @@ public async Task OpenAsync(string? path = null) 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/SshTunnelServiceTests.cs b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs index 358ee8c4d..7937994e1 100644 --- a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs @@ -245,9 +245,10 @@ public void AllowsDestination_PinsTheOriginalGateway() DashboardForwardPortGuard.Hold(port); try { - Assert.True(DashboardForwardPortGuard.AllowsDestination(port, "user", "gateway.example", 18789)); - Assert.False(DashboardForwardPortGuard.AllowsDestination(port, "user", "other.example", 18789)); - Assert.False(DashboardForwardPortGuard.AllowsDestination(port, "user", "gateway.example", 18790)); + 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 { @@ -609,10 +610,19 @@ public async Task Watch_SubmittedNavigation_KeepsThePortWhenSshExits() TimeSpan.Zero, consumptionProbe: null); - Assert.False(settled); - Assert.True(service.IsBrowserHandoffOpen(handoffId)); + Assert.True(settled); + Assert.False(service.IsBrowserHandoffOpen(handoffId)); + Assert.Equal(0, service.BrowserHandoffLeaseCount); + Assert.False(service.HasDeferredStop); Assert.True(DashboardForwardPortGuard.IsHolding(config.LocalPort)); - DashboardForwardPortGuard.Release(config.LocalPort); + Assert.False(DashboardForwardPortGuard.AllowsDestination( + config.LocalPort, + config.User, + config.Host, + config.RemotePort, + 2222)); + service.Stop(); + Assert.False(DashboardForwardPortGuard.IsHolding(config.LocalPort)); service.ExitBrowserHandoff(handoffId); } diff --git a/tests/OpenClaw.Shared.Tests/ModelsTests.cs b/tests/OpenClaw.Shared.Tests/ModelsTests.cs index af2c4ef08..4bb6cf068 100644 --- a/tests/OpenClaw.Shared.Tests/ModelsTests.cs +++ b/tests/OpenClaw.Shared.Tests/ModelsTests.cs @@ -225,6 +225,22 @@ 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_RejectsBrowserProxyForwardWhenPortPlusTwoOverflows() { diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 72a8e25e1..cb0f6ce77 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -491,7 +491,7 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() Assert.DoesNotContain("Failed to open dashboard", method); Assert.Contains("if (!sshGate.Allowed)", prepare); Assert.Contains("DashboardBrowserHandoff.SameBinding(", prepare); - Assert.Contains("DashboardBrowserHandoff.ProjectOntoLocalForward(after.GatewayUrl, forward.LocalPort)", 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); @@ -521,6 +521,7 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() AssertInOrder( launcher, "await ensureTunnel()", + "ProjectOntoLocalForward", "GatewayDashboardUrlBuilder.Build("); } From 99f50162af05a486374998518f4bd0036a1b5f1c Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Fri, 2 Oct 2026 08:58:19 -0700 Subject: [PATCH 26/27] fix(connection): keep the dashboard port across SSH stop Claim the public port before a dashboard SSH process listens there. Stop and Reset end that SSH process after the navigation is submitted, and they leave the port bound. Recovery still drops the launch lease so the same destination can restart on a backend port. Validate the browser-proxy local port as a normal port, including 65534 and 65535. Validation: ./build.ps1 exit 0. Shared 4226 passed, 33 skipped. Tray 3808 passed. Focused handoff tests 46 passed. Signed-off-by: Sebastien Tardif --- src/OpenClaw.Connection/SshTunnelService.cs | 163 ++++++++++++++---- src/OpenClaw.Shared/SshTunnelCommandLine.cs | 1 - .../SshTunnelServiceTests.cs | 81 +++++---- tests/OpenClaw.Shared.Tests/ModelsTests.cs | 15 ++ 4 files changed, 189 insertions(+), 71 deletions(-) diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index 3d6f7222b..19f8b6d1f 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -31,6 +31,7 @@ public sealed class SshTunnelService : ISshTunnelManager 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; @@ -259,21 +260,35 @@ public void Stop() } /// - /// Stops the tracked tunnel. An open dashboard handoff is cancelled first - /// so Disconnect, Restart, and shutdown are not stuck behind it. + /// Stops SSH. A port already handed to the dashboard stays bound here. + /// An unsubmitted handoff still defers the stop. /// private bool StopLocked() { CancelOpenBrowserHandoffs(); - Process? process; - int[] portsToRelease; + int[] portsToKeep; + SshTunnelConfig? config; lock (_stateLock) { - if (_browserHandoffLeases > 0) + 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) + { + if (portsToKeep.Length > 0) + { + _browserHandoffs.Clear(); + _browserHandoffLeases = 0; + } portsToRelease = PortsToReleaseLocked(); process = ClaimProcessForStopLocked(); @@ -297,11 +312,29 @@ private int[] PortsToReleaseLocked() return _browserHandoffs .Select(handoff => handoff.LocalPort) .Append(_currentConfig?.LocalPort ?? 0) - .Where(port => port > 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) @@ -361,31 +394,39 @@ public void ResetNotConfigured() lock (_operationLock) { CancelOpenBrowserHandoffs(); - Process? process = null; - var deferred = false; - int[] portsToRelease = []; + int[] portsToKeep; + SshTunnelConfig? config; lock (_stateLock) { - if (_browserHandoffLeases > 0) + portsToKeep = _dashboardProtectedPorts.ToArray(); + config = _currentConfig; + if (_browserHandoffLeases > 0 && portsToKeep.Length == 0) { RememberDeferredStopLocked(DeferredTunnelStop.ResetNotConfigured); - deferred = true; - } - else - { - portsToRelease = PortsToReleaseLocked(); - process = ClaimProcessForStopLocked(); - _deferredStop = DeferredTunnelStop.None; - LastError = null; - Status = TunnelStatus.NotConfigured; + return; } } - if (!deferred) + BindDashboardGuards(portsToKeep, config); + Process? process; + int[] portsToRelease; + lock (_stateLock) { - StopClaimedProcess(process); - ReleaseForwardPorts(portsToRelease); + if (portsToKeep.Length > 0) + { + _browserHandoffs.Clear(); + _browserHandoffLeases = 0; + } + + portsToRelease = PortsToReleaseLocked(); + process = ClaimProcessForStopLocked(); + _deferredStop = DeferredTunnelStop.None; + LastError = null; + Status = TunnelStatus.NotConfigured; } + + StopClaimedProcess(process); + ReleaseForwardPorts(portsToRelease); } } @@ -702,6 +743,7 @@ public async Task EnsureSettingsOwnedForwardReadyAs try { cancellationToken.ThrowIfCancellationRequested(); + ClaimDashboardPublicPort(config); EnsureStartedCore( config, SshTunnelOwner.Settings, @@ -884,6 +926,7 @@ public bool TryBeginDashboardNavigation(long handoffId) return false; handoff.Opening = true; + _dashboardProtectedPorts.Add(handoff.LocalPort); return true; } } @@ -901,10 +944,19 @@ public bool CompleteDashboardNavigation(long handoffId, bool opened, int? proces 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; } @@ -1180,6 +1232,48 @@ _currentOwner is SshTunnelOwner.Settings or SshTunnelOwner.GatewayConnectionMana _currentConfig?.LocalPort == localPort; } + /// + /// Binds the dashboard's public port before SSH listens there. + /// The browser URL is not issued until this returns. + /// + private void 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; + } + + 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); + } + private static void RejectOccupiedForwardPorts(SshTunnelConfig tunnel) { RejectForeignForwardPort(tunnel.LocalPort); @@ -1297,33 +1391,28 @@ private void TeardownLocked() 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); - foreach (var port in protectedPorts) - { - if (!DashboardForwardPortGuard.IsHolding(port)) - DashboardForwardPortGuard.Hold(port); - if (stoppedConfig is not null && stoppedConfig.LocalPort == port) - { - DashboardForwardPortGuard.AllowsDestination( - port, - stoppedConfig.User, - stoppedConfig.Host, - stoppedConfig.RemotePort, - stoppedConfig.SshPort); - } - } if (protectedPorts.Length == 0 && stoppedPort > 0) DashboardForwardPortGuard.Release(stoppedPort); @@ -1355,6 +1444,8 @@ private void RetainPublicPortAfterUnexpectedExit(int localPort) (handoff.Submitted || handoff.Opening)) .Select(handoff => handoff.Id) .ToArray(); + if (retainIds.Length > 0) + _dashboardProtectedPorts.Add(localPort); } if (retainIds.Length == 0) diff --git a/src/OpenClaw.Shared/SshTunnelCommandLine.cs b/src/OpenClaw.Shared/SshTunnelCommandLine.cs index 03fc6ad2f..aeaa48a84 100644 --- a/src/OpenClaw.Shared/SshTunnelCommandLine.cs +++ b/src/OpenClaw.Shared/SshTunnelCommandLine.cs @@ -81,7 +81,6 @@ public static string BuildArguments( if (includeBrowserProxyForward) { ValidateBrowserProxyPort(remotePort, nameof(remotePort)); - ValidateBrowserProxyPort(proxyLocalPort, nameof(browserProxyLocalPort)); ValidatePort(proxyLocalPort, nameof(browserProxyLocalPort)); } diff --git a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs index 7937994e1..3a74e2c69 100644 --- a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs @@ -516,49 +516,46 @@ public void ResetNotConfigured_DuringBrowserHandoff_CancelsTheHoldImmediately() } [Fact] - public void Stop_AfterNavigationSubmitted_KeepsTheProcessUntilTeardown() + public void Stop_AfterNavigationSubmitted_StopsSshAndKeepsThePort() { - 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)); - Assert.True(service.TryBeginDashboardNavigation(handoffId)); - Assert.True(service.CompleteDashboardNavigation(handoffId, opened: true, processId: null)); - - service.Stop(); - - Assert.True(service.HasDeferredStop); - Assert.Equal(1, service.BrowserHandoffLeaseCount); - Assert.Same(process, TrackedProcess(service)); - Assert.True(service.IsRunning); - Assert.False(process.HasExited); + 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(); - service.Stop(); + Assert.False(service.HasDeferredStop); + Assert.Equal(0, service.BrowserHandoffLeaseCount); + Assert.False(service.IsRunning); + Assert.True(DashboardForwardPortGuard.IsHolding(port)); + Assert.Throws(() => process.HasExited); - Assert.True(service.HasDeferredStop); - Assert.Equal(1, service.BrowserHandoffLeaseCount); - Assert.Same(process, TrackedProcess(service)); - Assert.True(service.IsRunning); - Assert.False(process.HasExited); - Assert.False(DashboardForwardPortGuard.IsHolding(config.LocalPort)); + service.Stop(); - service.Dispose(); + Assert.True(DashboardForwardPortGuard.IsHolding(port)); + } - Assert.False(service.IsRunning); - Assert.True(DashboardForwardPortGuard.IsHolding(config.LocalPort)); - Assert.Throws(() => process.HasExited); - DashboardForwardPortGuard.Release(config.LocalPort); + DashboardForwardPortGuard.Release(port); } [Fact] public async Task Watch_DoesNotReleaseWhenTheActivationProcessHasExited() { - using var service = new SshTunnelService(NullLogger.Instance); + 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, @@ -586,13 +583,22 @@ public async Task Watch_DoesNotReleaseWhenTheActivationProcessHasExited() 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() { - using var service = new SshTunnelService(NullLogger.Instance); + 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, @@ -622,8 +628,15 @@ public async Task Watch_SubmittedNavigation_KeepsThePortWhenSshExits() config.RemotePort, 2222)); service.Stop(); - Assert.False(DashboardForwardPortGuard.IsHolding(config.LocalPort)); + Assert.True(DashboardForwardPortGuard.IsHolding(config.LocalPort)); + Assert.Equal(0, service.BrowserHandoffLeaseCount); service.ExitBrowserHandoff(handoffId); + } + finally + { + service.Dispose(); + DashboardForwardPortGuard.Release(config.LocalPort); + } } [Fact] diff --git a/tests/OpenClaw.Shared.Tests/ModelsTests.cs b/tests/OpenClaw.Shared.Tests/ModelsTests.cs index 4bb6cf068..196a2f640 100644 --- a/tests/OpenClaw.Shared.Tests/ModelsTests.cs +++ b/tests/OpenClaw.Shared.Tests/ModelsTests.cs @@ -241,6 +241,21 @@ public void BuildArguments_ValidatesTheExplicitProxyPort() 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() { From 2a20af4a0d672d61fec4ba95d88d49a7c5bc119e Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Fri, 2 Oct 2026 09:34:11 -0700 Subject: [PATCH 27/27] fix(connection): keep manager ownership and both loopback routes Dashboard startup keeps a manager-owned tunnel on that owner when it moves SSH behind the public port. The port guard binds 127.0.0.1 and ::1, and guarded clients use 127.0.0.1. A guard acquired for a start that fails before handoff is released. Validation: ./build.ps1 exit 0. Shared 4226 passed, 33 skipped. Tray 3808 passed. Focused connection tests 54 passed. Signed-off-by: Sebastien Tardif --- .../DashboardForwardPortGuard.cs | 38 +++++++-- .../GatewayClientEndpointResolver.cs | 3 +- src/OpenClaw.Connection/SshTunnelService.cs | 82 +++++++++++++++++-- .../GatewayClientEndpointResolverTests.cs | 28 +++++++ .../SshTunnelServiceTests.cs | 73 +++++++++++++++++ 5 files changed, 212 insertions(+), 12 deletions(-) diff --git a/src/OpenClaw.Connection/DashboardForwardPortGuard.cs b/src/OpenClaw.Connection/DashboardForwardPortGuard.cs index ec1749fc4..28e259438 100644 --- a/src/OpenClaw.Connection/DashboardForwardPortGuard.cs +++ b/src/OpenClaw.Connection/DashboardForwardPortGuard.cs @@ -14,6 +14,7 @@ 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; } @@ -38,11 +39,30 @@ internal static void Hold(int port) return; var listener = new TcpListener(IPAddress.Loopback, port); - listener.Start(); + 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, Cancel = cancel }; + var slot = new Slot { Listener = listener, V6Listener = v6, Cancel = cancel }; Listeners[port] = slot; - _ = Task.Run(() => AcceptAsync(port, slot)); + _ = AcceptAsync(port, slot, listener); + if (v6 is not null) + _ = AcceptAsync(port, slot, v6); } } @@ -110,6 +130,14 @@ internal static void Release(int port) catch (SocketException) { } + + try + { + slot.V6Listener?.Stop(); + } + catch (SocketException) + { + } } internal static bool IsHolding(int port) @@ -124,13 +152,13 @@ internal static bool IsHolding(int port) return Listeners.TryGetValue(port, out var slot) ? slot.BackendPort : null; } - private static async Task AcceptAsync(int port, Slot slot) + private static async Task AcceptAsync(int port, Slot slot, TcpListener listener) { try { while (!slot.Cancel.IsCancellationRequested) { - var client = await slot.Listener.AcceptTcpClientAsync(slot.Cancel.Token).ConfigureAwait(false); + var client = await listener.AcceptTcpClientAsync(slot.Cancel.Token).ConfigureAwait(false); _ = Pump(port, client); } } 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/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index 19f8b6d1f..be16d891a 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -99,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; } @@ -703,6 +703,12 @@ _process is null || try { + if (DashboardForwardPortGuard.IsHolding(destinationPort) && + !TryConfirmRetainedPublicRoute(destinationPort)) + { + return Task.FromResult(false); + } + var proofPort = ListenerPortToProve(destinationPort); if (proofPort < 1 || !ValidateListenerOwnership( @@ -740,13 +746,22 @@ public async Task EnsureSettingsOwnedForwardReadyAs { Process? process = null; long generation = 0; + var acquiredGuard = false; try { cancellationToken.ThrowIfCancellationRequested(); - ClaimDashboardPublicPort(config); + SshTunnelOwner restartOwner; + lock (_stateLock) + { + restartOwner = DashboardRestartOwner( + _currentOwner, + IsRunningLocked() && _currentConfig?.LocalPort == config.LocalPort); + } + + acquiredGuard = ClaimDashboardPublicPort(config); EnsureStartedCore( config, - SshTunnelOwner.Settings, + restartOwner, tunnel => RejectOccupiedForwardPorts(tunnel)); var normalizedConfig = config with @@ -825,6 +840,9 @@ _currentOwner is not (SshTunnelOwner.Settings or SshTunnelOwner.GatewayConnectio Status = TunnelStatus.Failed; } + if (acquiredGuard) + ReleaseUnsubmittedGuard(config.LocalPort); + if (process is not null) StopIfCurrent(process, generation); @@ -1232,11 +1250,23 @@ _currentOwner is SshTunnelOwner.Settings or SshTunnelOwner.GatewayConnectionMana _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 void ClaimDashboardPublicPort(SshTunnelConfig config) + private bool ClaimDashboardPublicPort(SshTunnelConfig config) { var localPort = config.LocalPort; var user = config.User.Trim(); @@ -1249,7 +1279,7 @@ private void ClaimDashboardPublicPort(SshTunnelConfig config) host, config.RemotePort, config.SshPort); - return; + return false; } var ownsPublicPort = false; @@ -1272,6 +1302,16 @@ private void ClaimDashboardPublicPort(SshTunnelConfig config) 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) @@ -1359,7 +1399,7 @@ await WaitForOwnedLocalListenerAsync( ct).ConfigureAwait(false); } return new SshTunnelStartResult( - $"ws://localhost:{config.LocalPort}", + $"ws://{LoopbackWebSocketHost(config.LocalPort)}:{config.LocalPort}", normalizedConfig, generation); } @@ -1544,6 +1584,13 @@ private async Task WaitForOwnedLocalListenerAsync( } } + if (DashboardForwardPortGuard.IsHolding(localPort) && + !TryConfirmRetainedPublicRoute(localPort)) + { + await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken).ConfigureAwait(false); + continue; + } + var proofPort = ListenerPortToProve(localPort); if (proofPort > 0 && ValidateListenerOwnership( @@ -1582,6 +1629,29 @@ private void StopIfCurrent(Process process, long generation) } } + 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/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 3a74e2c69..e73334a3e 100644 --- a/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/SshTunnelServiceTests.cs @@ -256,6 +256,79 @@ public void AllowsDestination_PinsTheOriginalGateway() } } + [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() {