Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4efc213
fix(connection): open the dashboard on the tunnel, not the saved URL
SebTardif Sep 23, 2026
acb5744
fix(connection): bind dashboard tokens to the owned SSH tunnel
SebTardif Sep 23, 2026
51c2586
fix(connection): keep dashboard credential precedence on the pinned r…
SebTardif Sep 23, 2026
accb329
Merge main into fix/f020-dashboard-tunnel
shanselman Sep 23, 2026
99ea745
fix(connection): use HTTP credentials for pinned SSH dashboards
shanselman Sep 23, 2026
2b3c7cd
Merge remote-tracking branch 'origin/main' into fix/f020-dashboard-tu…
SebTardif Oct 7, 2026
73c7723
fix(connection): authorize pinned dashboard HTTP credentials through …
SebTardif Oct 7, 2026
edb569f
chore: retrigger CI after the tray UI host hang
SebTardif Oct 7, 2026
1d9bbac
fix(connection): keep the saved dashboard route on the SSH tunnel
SebTardif Oct 7, 2026
d94a9f7
fix(connection): recheck SSH dashboard ownership before opening it
SebTardif Oct 8, 2026
e52d75a
fix(connection): keep the dashboard token off the issued SSH link
SebTardif Oct 8, 2026
60b1cc0
fix(connection): bind the dashboard handoff to the gateway that issue…
SebTardif Oct 8, 2026
e136209
fix(connection): keep the dashboard token off the tunnel port
SebTardif Oct 8, 2026
e8f4d2c
fix(connection): keep one dashboard origin and recheck the tunnel
SebTardif Oct 8, 2026
a6f5e2e
ci: retrigger UI after a testhost crash
SebTardif Oct 8, 2026
18bebb6
ci: retrigger Core after a credential-file lock
SebTardif Oct 8, 2026
9cb62aa
fix(connection): keep each dashboard tab on the gateway that opened it
SebTardif Oct 8, 2026
5e28f41
fix(connection): route dashboard assets to the issuing session
SebTardif Oct 8, 2026
26f0757
fix(connection): separate dashboard origins and forward the request
SebTardif Oct 9, 2026
10f8f2d
fix(connection): keep each dashboard origin and decode chunked bodies
SebTardif Oct 9, 2026
77fe88d
chore(ci): retrigger UI host crash on dashboard origin fix
SebTardif Oct 9, 2026
c50f406
fix(connection): keep a dashboard origin with its gateway
SebTardif Oct 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions src/OpenClaw.Connection/DashboardCredentialGate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
namespace OpenClaw.Connection;

/// <summary>
/// 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.
/// </summary>
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);
}
75 changes: 75 additions & 0 deletions src/OpenClaw.Connection/GatewayClientEndpointResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,79 @@ public static string Resolve(GatewayRecord record)

return $"ws://localhost:{tunnel.LocalPort}";
}

/// <summary>
/// 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.
/// </summary>
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);
}
117 changes: 80 additions & 37 deletions src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
{
Expand Down Expand Up @@ -151,6 +124,76 @@ public static bool TryResolve(
legacyCredential.Source);
return true;
}

/// <summary>
/// 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.
/// </summary>
public static bool TryResolveRecord(
GatewayRecord record,
string identityDirectory,
IDeviceIdentityReader identityReader,
Func<GatewayRecord, GatewayCredential, bool>? 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(
Expand Down
5 changes: 4 additions & 1 deletion src/OpenClaw.Connection/SshTunnelService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -85,7 +86,8 @@ public SshTunnelSnapshot CreateSnapshot()
CurrentBrowserProxyLocalPort,
StartedAtUtc,
LastError,
Status);
Status,
CurrentSshPort);
}
}

Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/OpenClaw.Connection/SshTunnelSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ public sealed record SshTunnelSnapshot(
int CurrentBrowserProxyLocalPort,
DateTime? StartedAtUtc,
string? LastError,
TunnelStatus Status);
TunnelStatus Status,
int CurrentSshPort = 22);
Loading
Loading