diff --git a/src/OpenClaw.Connection/DashboardCredentialGate.cs b/src/OpenClaw.Connection/DashboardCredentialGate.cs
new file mode 100644
index 000000000..af4d81fcd
--- /dev/null
+++ b/src/OpenClaw.Connection/DashboardCredentialGate.cs
@@ -0,0 +1,71 @@
+namespace OpenClaw.Connection;
+
+///
+/// Decides whether a dashboard URL may carry a token for one pinned gateway record.
+/// Device and bootstrap credentials are not written into the URL. A shared token is
+/// appended only when that is the credential resolved for the same pin.
+///
+public static class DashboardCredentialGate
+{
+ public const string PinMismatchMessage = "Dashboard blocked because of a pin mismatch.";
+
+ public static DashboardCredentialDecision Decide(
+ bool pinMatches,
+ bool samePinnedRecord,
+ bool tunnelAllowsSharedToken,
+ string? credentialSource,
+ bool isBootstrapToken,
+ string? resolvedToken,
+ string? pinnedSharedToken)
+ {
+ if (!pinMatches || !samePinnedRecord)
+ return DashboardCredentialDecision.Mismatch;
+
+ var source = string.IsNullOrWhiteSpace(credentialSource) ? "none" : credentialSource;
+ var sharedChosen = !isBootstrapToken
+ && string.Equals(source, CredentialResolver.SourceSharedGatewayToken, StringComparison.Ordinal);
+
+ if (!sharedChosen)
+ {
+ return new DashboardCredentialDecision(
+ AppendToken: false,
+ CredentialSource: source,
+ Token: null,
+ PinMismatch: false);
+ }
+
+ if (!tunnelAllowsSharedToken)
+ {
+ return new DashboardCredentialDecision(
+ AppendToken: false,
+ CredentialSource: source,
+ Token: null,
+ PinMismatch: false);
+ }
+
+ if (string.IsNullOrEmpty(resolvedToken)
+ || !string.Equals(resolvedToken, pinnedSharedToken, StringComparison.Ordinal))
+ {
+ return DashboardCredentialDecision.Mismatch;
+ }
+
+ return new DashboardCredentialDecision(
+ AppendToken: true,
+ CredentialSource: source,
+ Token: resolvedToken,
+ PinMismatch: false);
+ }
+}
+
+public readonly record struct DashboardCredentialDecision(
+ bool AppendToken,
+ string CredentialSource,
+ string? Token,
+ bool PinMismatch)
+{
+ public static DashboardCredentialDecision Mismatch { get; } = new(
+ AppendToken: false,
+ CredentialSource: "none",
+ Token: null,
+ PinMismatch: true);
+}
diff --git a/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs b/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs
index b2239b129..dcdbde62f 100644
--- a/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs
+++ b/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs
@@ -18,4 +18,79 @@ public static string Resolve(GatewayRecord record)
return $"ws://localhost:{tunnel.LocalPort}";
}
+
+ ///
+ /// Picks the WebSocket endpoint a dashboard may open.
+ /// SSH records open only the local forward, and only while that forward is up.
+ /// The shared token must not be attached to the saved, untunneled address.
+ ///
+ public static bool TryResolveDashboardEndpoint(
+ GatewayRecord record,
+ SshTunnelSnapshot? tunnel,
+ out string endpoint,
+ out bool appendSharedToken,
+ bool listenerOwned = false)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+
+ if (record.SshTunnel is not { } config)
+ {
+ endpoint = record.Url;
+ appendSharedToken = !string.IsNullOrWhiteSpace(record.SharedGatewayToken);
+ return !string.IsNullOrWhiteSpace(endpoint);
+ }
+
+ if (!listenerOwned || !IsDashboardTunnelUp(config, tunnel))
+ {
+ endpoint = "";
+ appendSharedToken = false;
+ return false;
+ }
+
+ if (!TrySubstituteLocalDashboardAddress(record.Url, config.LocalPort, out endpoint))
+ {
+ endpoint = "";
+ appendSharedToken = false;
+ return false;
+ }
+
+ appendSharedToken = !string.IsNullOrWhiteSpace(record.SharedGatewayToken);
+ return true;
+ }
+
+ // The socket client uses Resolve. The browser keeps the saved route
+ // and only the host and port move onto the local forward.
+ private static bool TrySubstituteLocalDashboardAddress(string savedUrl, int localPort, out string endpoint)
+ {
+ endpoint = "";
+ if (!Uri.TryCreate(savedUrl, UriKind.Absolute, out var saved))
+ return false;
+
+ var builder = new UriBuilder(saved)
+ {
+ Host = "localhost",
+ Port = localPort,
+ };
+ builder.UserName = "";
+ builder.Password = "";
+ var text = builder.Uri.AbsoluteUri;
+ if (saved.AbsolutePath == "/" &&
+ string.IsNullOrEmpty(saved.Query) &&
+ string.IsNullOrEmpty(saved.Fragment) &&
+ !savedUrl.TrimEnd().EndsWith('/'))
+ {
+ text = text.TrimEnd('/');
+ }
+
+ endpoint = text;
+ return true;
+ }
+
+ private static bool IsDashboardTunnelUp(SshTunnelConfig config, SshTunnelSnapshot? tunnel) =>
+ tunnel is { IsRunning: true, Status: OpenClaw.Shared.TunnelStatus.Up } &&
+ tunnel.CurrentLocalPort == config.LocalPort &&
+ tunnel.CurrentRemotePort == config.RemotePort &&
+ tunnel.CurrentSshPort == config.SshPort &&
+ string.Equals(tunnel.CurrentUser, config.User, StringComparison.Ordinal) &&
+ string.Equals(tunnel.CurrentHost, config.Host, StringComparison.OrdinalIgnoreCase);
}
diff --git a/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs b/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs
index d69fc8f74..29b195037 100644
--- a/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs
+++ b/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs
@@ -65,48 +65,21 @@ public static bool TryResolve(
credential = null;
return false;
}
- // For HTTP surfaces (chat), prefer SharedGatewayToken over DeviceToken.
- // DeviceToken is for WebSocket auth (auth.deviceToken); SharedGatewayToken
- // is for HTTP ?token= auth which the chat/dashboard endpoints expect.
- if (!string.IsNullOrWhiteSpace(active.SharedGatewayToken))
- {
- var sharedCredential = new GatewayCredential(
- active.SharedGatewayToken!,
- IsBootstrapToken: false,
- CredentialResolver.SourceSharedGatewayToken);
- if (authorizeCredential is not null &&
- !authorizeCredential(active, sharedCredential))
- {
- credential = null;
- return false;
- }
- credential = new InteractiveGatewayCredential(
- active.Url,
- active.SharedGatewayToken!,
- false,
- CredentialResolver.SourceSharedGatewayToken);
- return true;
- }
- // Fall back to standard credential resolution (DeviceToken → Bootstrap)
- var resolver = new CredentialResolver(identityReader);
- var resolved = resolver.ResolveOperator(active, registry!.GetIdentityDirectory(active.Id));
- if (resolved != null)
+ if (TryResolveRecord(
+ active,
+ registry!.GetIdentityDirectory(active.Id),
+ identityReader,
+ authorizeCredential,
+ out credential,
+ out var rejected))
{
- if (authorizeCredential is not null &&
- !authorizeCredential(active, resolved))
- {
- credential = null;
- return false;
- }
- credential = new InteractiveGatewayCredential(
- active.Url,
- resolved.Token,
- resolved.IsBootstrapToken,
- resolved.Source);
return true;
}
+ if (rejected)
+ return false;
+
if (active.NativePackageFamilyName is not null ||
!string.Equals(active.Url, effectiveGatewayUrl, StringComparison.OrdinalIgnoreCase))
{
@@ -151,6 +124,76 @@ public static bool TryResolve(
legacyCredential.Source);
return true;
}
+
+ ///
+ /// Resolves HTTP credentials for one gateway record.
+ /// Shared token wins for dashboard/chat URLs when it is present and allowed.
+ /// Otherwise the operator resolver order applies: device token, then bootstrap.
+ ///
+ public static bool TryResolveRecord(
+ GatewayRecord record,
+ string identityDirectory,
+ IDeviceIdentityReader identityReader,
+ Func? authorizeCredential,
+ out InteractiveGatewayCredential? credential,
+ out bool rejected)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+ ArgumentException.ThrowIfNullOrWhiteSpace(identityDirectory);
+ ArgumentNullException.ThrowIfNull(identityReader);
+
+ rejected = false;
+ if (string.IsNullOrWhiteSpace(record.Url))
+ {
+ credential = null;
+ return false;
+ }
+
+ // HTTP surfaces prefer the shared token. DeviceToken is WebSocket auth.
+ if (!string.IsNullOrWhiteSpace(record.SharedGatewayToken))
+ {
+ var sharedCredential = new GatewayCredential(
+ record.SharedGatewayToken!,
+ IsBootstrapToken: false,
+ CredentialResolver.SourceSharedGatewayToken);
+ if (authorizeCredential is not null &&
+ !authorizeCredential(record, sharedCredential))
+ {
+ credential = null;
+ rejected = true;
+ return false;
+ }
+
+ credential = new InteractiveGatewayCredential(
+ record.Url,
+ record.SharedGatewayToken!,
+ false,
+ CredentialResolver.SourceSharedGatewayToken);
+ return true;
+ }
+
+ var resolved = new CredentialResolver(identityReader).ResolveOperator(record, identityDirectory);
+ if (resolved == null)
+ {
+ credential = null;
+ return false;
+ }
+
+ if (authorizeCredential is not null &&
+ !authorizeCredential(record, resolved))
+ {
+ credential = null;
+ rejected = true;
+ return false;
+ }
+
+ credential = new InteractiveGatewayCredential(
+ record.Url,
+ resolved.Token,
+ resolved.IsBootstrapToken,
+ resolved.Source);
+ return true;
+ }
}
public sealed record InteractiveGatewayCredential(
diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs
index 14c054c14..48dd55258 100644
--- a/src/OpenClaw.Connection/SshTunnelService.cs
+++ b/src/OpenClaw.Connection/SshTunnelService.cs
@@ -62,6 +62,7 @@ public SshTunnelConfig? ActiveConfig
}
public string? LocalTunnelUrl => IsActive ? $"ws://localhost:{CurrentLocalPort}" : null;
public string? CurrentUser { get; private set; }
+ public int CurrentSshPort { get; private set; }
public string? CurrentHost { get; private set; }
public int CurrentRemotePort { get; private set; }
public int CurrentLocalPort { get; private set; }
@@ -85,7 +86,8 @@ public SshTunnelSnapshot CreateSnapshot()
CurrentBrowserProxyLocalPort,
StartedAtUtc,
LastError,
- Status);
+ Status,
+ CurrentSshPort);
}
}
@@ -389,6 +391,7 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s
_processStarted = true;
CurrentUser = user;
+ CurrentSshPort = sshPort;
CurrentHost = host;
CurrentRemotePort = remotePort;
CurrentLocalPort = localPort;
diff --git a/src/OpenClaw.Connection/SshTunnelSnapshot.cs b/src/OpenClaw.Connection/SshTunnelSnapshot.cs
index 060f7196a..7c37c72ae 100644
--- a/src/OpenClaw.Connection/SshTunnelSnapshot.cs
+++ b/src/OpenClaw.Connection/SshTunnelSnapshot.cs
@@ -15,4 +15,5 @@ public sealed record SshTunnelSnapshot(
int CurrentBrowserProxyLocalPort,
DateTime? StartedAtUtc,
string? LastError,
- TunnelStatus Status);
+ TunnelStatus Status,
+ int CurrentSshPort = 22);
diff --git a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs
index 76b490afd..55ca5f6c0 100644
--- a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs
+++ b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Net;
+using System.Net.Sockets;
using System.Runtime.InteropServices;
namespace OpenClaw.Connection;
@@ -33,6 +34,158 @@ public static WindowsTcpListenerSnapshotResult Capture()
return new(result, ipv4Complete, ipv6Complete);
}
+ public readonly record struct AcceptedTcpEndpoint(
+ IPAddress LocalAddress,
+ int LocalPort,
+ IPAddress RemoteAddress,
+ int RemotePort,
+ int ProcessId);
+
+ public static int? MatchAcceptedProcess(
+ IEnumerable rows,
+ IPEndPoint server,
+ IPEndPoint client)
+ {
+ foreach (var row in rows)
+ {
+ if (row.LocalPort == server.Port &&
+ row.RemotePort == client.Port &&
+ AddressesMatch(row.LocalAddress, server.Address) &&
+ AddressesMatch(row.RemoteAddress, client.Address))
+ return row.ProcessId;
+ }
+
+ return null;
+ }
+
+ public static WindowsTcpListenerInfo? MatchListener(
+ IEnumerable listeners,
+ IPEndPoint connected)
+ {
+ WindowsTcpListenerInfo? wildcard = null;
+ foreach (var item in listeners)
+ {
+ if (item.Port != connected.Port)
+ continue;
+ if (AddressesMatch(item.Address, connected.Address))
+ return item;
+ if (wildcard is null && IsSameFamilyWildcard(item.Address, connected.Address))
+ wildcard = item;
+ }
+
+ return wildcard;
+ }
+
+ public static int? AcceptedProcessId(IPEndPoint server, IPEndPoint client)
+ {
+ if (!OperatingSystem.IsWindows())
+ return null;
+
+ var rows = new List();
+ if (!CaptureConnections(rows))
+ return null;
+ return MatchAcceptedProcess(rows, server, client);
+ }
+
+ internal static bool AddressesMatch(IPAddress left, IPAddress right)
+ {
+ if (left.Equals(right))
+ return true;
+ if (left.IsIPv4MappedToIPv6 && left.MapToIPv4().Equals(right))
+ return true;
+ if (right.IsIPv4MappedToIPv6 && right.MapToIPv4().Equals(left))
+ return true;
+ return false;
+ }
+
+ private static bool IsSameFamilyWildcard(IPAddress listener, IPAddress connected)
+ {
+ if (connected.AddressFamily == AddressFamily.InterNetwork)
+ return listener.Equals(IPAddress.Any);
+ if (connected.AddressFamily == AddressFamily.InterNetworkV6)
+ return listener.Equals(IPAddress.IPv6Any);
+ return false;
+ }
+
+ private static bool CaptureConnections(List rows)
+ {
+ var ipv4 = CaptureConnectionFamily(AfInet, rows);
+ var ipv6 = CaptureConnectionFamily(AfInet6, rows);
+ return ipv4 || ipv6;
+ }
+
+ private static bool CaptureConnectionFamily(int addressFamily, List rows)
+ {
+ for (var attempt = 0; attempt < 3; attempt++)
+ {
+ var bufferLength = 0;
+ var status = GetExtendedTcpTable(
+ IntPtr.Zero,
+ ref bufferLength,
+ sort: true,
+ ipVersion: addressFamily,
+ tableClass: TcpTableOwnerPidConnections,
+ 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: TcpTableOwnerPidConnections,
+ reserved: 0);
+ if (status == ErrorInsufficientBuffer)
+ continue;
+ if (status != ErrorSuccess)
+ return false;
+
+ var rowCount = Marshal.ReadInt32(tablePtr);
+ var rowPtr = IntPtr.Add(tablePtr, sizeof(int));
+ var rowSize = addressFamily == AfInet6
+ ? Marshal.SizeOf()
+ : Marshal.SizeOf();
+ for (var i = 0; i < rowCount; i++)
+ {
+ if (addressFamily == AfInet6)
+ {
+ var row = Marshal.PtrToStructure(rowPtr);
+ rows.Add(new AcceptedTcpEndpoint(
+ new IPAddress(row.LocalAddress, row.LocalScopeId),
+ ReadPort(row.LocalPort),
+ new IPAddress(row.RemoteAddress, row.RemoteScopeId),
+ ReadPort(row.RemotePort),
+ unchecked((int)row.OwningProcessId)));
+ }
+ else
+ {
+ var row = Marshal.PtrToStructure(rowPtr);
+ rows.Add(new AcceptedTcpEndpoint(
+ new IPAddress(BitConverter.GetBytes(row.LocalAddress)),
+ ReadPort(row.LocalPort),
+ new IPAddress(BitConverter.GetBytes(row.RemoteAddress)),
+ ReadPort(row.RemotePort),
+ unchecked((int)row.OwningProcessId)));
+ }
+
+ rowPtr = IntPtr.Add(rowPtr, rowSize);
+ }
+
+ return true;
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(tablePtr);
+ }
+ }
+
+ return false;
+ }
+
public static string? GetProcessCommandLine(int processId)
{
if (processId <= 0)
@@ -245,6 +398,7 @@ private static void ResolveProcess(
private const int AfInet = 2;
private const int AfInet6 = 23;
private const int TcpTableOwnerPidListener = 3;
+ private const int TcpTableOwnerPidConnections = 4;
private const uint ErrorSuccess = 0;
private const uint ErrorInsufficientBuffer = 122;
diff --git a/src/OpenClaw.Shared/Capabilities/AppCapability.cs b/src/OpenClaw.Shared/Capabilities/AppCapability.cs
index 51020e931..9c993efe1 100644
--- a/src/OpenClaw.Shared/Capabilities/AppCapability.cs
+++ b/src/OpenClaw.Shared/Capabilities/AppCapability.cs
@@ -46,7 +46,7 @@ public class AppCapability : NodeCapabilityBase
public Func? SettingsSetHandler;
public Func