Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion src/OpenClaw.Connection/GatewayConnectionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ public async Task SwitchGatewayAsync(string gatewayId)
}
}

public async Task<SetupCodeResult> ApplySetupCodeAsync(string setupCode)
public async Task<SetupCodeResult> ApplySetupCodeAsync(string setupCode, SshTunnelConfig? sshTunnel = null)
{
ThrowIfDisposed();

Expand Down Expand Up @@ -554,6 +554,7 @@ public async Task<SetupCodeResult> ApplySetupCodeAsync(string setupCode)
Url = gatewayUrl,
SharedGatewayToken = existing?.SharedGatewayToken, // preserve existing shared token if any
BootstrapToken = decoded.Token ?? existing?.BootstrapToken,
SshTunnel = sshTunnel ?? existing?.SshTunnel,
};
_registry.AddOrUpdate(record);
_registry.SetActive(recordId);
Expand Down
2 changes: 1 addition & 1 deletion src/OpenClaw.Connection/IGatewayConnectionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public interface IGatewayConnectionManager : IDisposable, IAsyncDisposable
Task EnsureNodeConnectedAsync(CancellationToken cancellationToken = default);

// ─── Setup ───
Task<SetupCodeResult> ApplySetupCodeAsync(string setupCode);
Task<SetupCodeResult> ApplySetupCodeAsync(string setupCode, SshTunnelConfig? sshTunnel = null);
Task<SetupCodeResult> ConnectWithSharedTokenAsync(string gatewayUrl, string token, SshTunnelConfig? sshTunnel = null);

// ─── Operator Client Access ───
Expand Down
134 changes: 134 additions & 0 deletions src/OpenClaw.Shared/GatewayErrorClassifier.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
using System;

namespace OpenClaw.Shared;

/// <summary>
/// Actionable classification of a gateway connection error. Lets the UI route a
/// raw error string to a specific recovery path instead of a generic failure —
/// distinguishing unauthorized, scope mismatch, token drift, pairing, TLS,
/// tunnel, and server problems.
/// </summary>
public enum GatewayErrorKind
{
/// <summary>No error text, or nothing recognizable.</summary>
Unknown,

/// <summary>Connection refused / unreachable / timed out.</summary>
Network,

/// <summary>Generic unauthorized / invalid-token rejection.</summary>
Auth,

/// <summary>
/// The stored device token is no longer recognized by the gateway (rotated,
/// revoked, or replaced) — the fix is to re-pair, not to retry.
/// </summary>
TokenDrift,

/// <summary>
/// Authenticated but missing a required operator/node scope (e.g. cannot
/// approve pairing or read config) — the fix is to re-pair for higher scopes.
/// </summary>
ScopeMismatch,

/// <summary>Device/node pairing approval is pending on the gateway host.</summary>
PairingRequired,

/// <summary>Pairing was explicitly rejected on the gateway host.</summary>
PairingRejected,

/// <summary>TLS/certificate/cleartext transport problem.</summary>
Tls,

/// <summary>SSH tunnel could not be established or dropped.</summary>
Tunnel,

/// <summary>Gateway returned a 5xx / internal error.</summary>
Server,

/// <summary>Rate limited by the gateway.</summary>
RateLimited,
}

/// <summary>
/// Pure heuristic classifier for gateway error strings. Order is significant:
/// the more specific kinds (scope, token drift) are matched before the generic
/// auth bucket so a "re-pair" path wins over a plain "retry" path.
/// </summary>
public static class GatewayErrorClassifier
{
public static GatewayErrorKind Classify(string? error)
{
if (string.IsNullOrWhiteSpace(error))
return GatewayErrorKind.Unknown;

var e = error.ToLowerInvariant();

if ((Contains(e, "rate") && Contains(e, "limit")) ||
Contains(e, "429") || Contains(e, "too many request"))
return GatewayErrorKind.RateLimited;

// SSH/tunnel first: SSH failures often read "Permission denied
// (publickey)" which would otherwise be mistaken for a scope problem.
if (Contains(e, "ssh") || Contains(e, "tunnel"))
return GatewayErrorKind.Tunnel;

// Transport security before pairing/auth: e.g. "certificate not
// approved by CA" must not be read as a pairing approval.
if (Contains(e, "tls") || Contains(e, "ssl") || Contains(e, "certificate") ||
Contains(e, "cert ") || Contains(e, "handshake") ||
Contains(e, "cleartext") || Contains(e, "insecure"))
return GatewayErrorKind.Tls;

// Scope/permission problems — authenticated but under-privileged.
if (Contains(e, "scope") ||
Contains(e, "insufficient priv") ||
Contains(e, "not permitted") ||
Contains(e, "permission denied") ||
(Contains(e, "forbidden") && Contains(e, "scope")))
return GatewayErrorKind.ScopeMismatch;

// Token drift — the device token specifically is stale/unknown.
if (Contains(e, "re-pair") || Contains(e, "repair token") ||
Contains(e, "token rotat") || Contains(e, "token revoked") ||
Contains(e, "token mismatch") || Contains(e, "token drift") ||
(Contains(e, "device token") &&
(Contains(e, "unknown") || Contains(e, "invalid") ||
Contains(e, "expired") || Contains(e, "not recognized") ||
Contains(e, "no longer"))))
return GatewayErrorKind.TokenDrift;

// Pairing lifecycle. Use specific tokens ("pairing"/"approval") so we
// don't match "repair" (contains "pair") or "approved by CA".
if (Contains(e, "pairing") || Contains(e, "approval"))
{
if (Contains(e, "reject") || Contains(e, "denied") || Contains(e, "declin"))
return GatewayErrorKind.PairingRejected;
return GatewayErrorKind.PairingRequired;
}

// Server (5xx) before the broad auth bucket: a transient
// "500 internal error: token validation failed" must not route the
// user to a re-pair flow.
if (Contains(e, "500") || Contains(e, "502") || Contains(e, "503") ||
Contains(e, "internal error") || Contains(e, "server error"))
return GatewayErrorKind.Server;

// Generic auth — after the more specific auth-adjacent kinds above.
if (Contains(e, "401") || Contains(e, "unauthor") || Contains(e, "forbid") ||
Contains(e, "auth") || Contains(e, "token") || Contains(e, "credential"))
return GatewayErrorKind.Auth;

// Network.
if (Contains(e, "refused") || Contains(e, "unreachable") ||
Contains(e, "timeout") || Contains(e, "timed out") ||
Contains(e, "network") || Contains(e, "no route") ||
Contains(e, "could not connect") || Contains(e, "connection closed"))
return GatewayErrorKind.Network;

return GatewayErrorKind.Unknown;
}

private static bool Contains(string haystack, string needle) =>
haystack.Contains(needle, StringComparison.Ordinal);
}
130 changes: 130 additions & 0 deletions src/OpenClaw.Shared/RemoteGatewayClassifier.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
using System;

namespace OpenClaw.Shared;

/// <summary>
/// How the tray reaches a configured gateway. Drives remote-setup guidance and
/// the cleartext-token warning in Connection settings.
/// </summary>
public enum GatewayConnectionTopology
{
/// <summary>URL was empty or could not be parsed.</summary>
Unknown,

/// <summary>localhost / 127.0.0.1 / ::1 — reached directly on this machine.</summary>
Local,

/// <summary>
/// A loopback URL that actually fronts a remote gateway through a managed
/// SSH tunnel (the WebSocket talks to <c>ws://localhost:&lt;localPort&gt;</c>
/// but the bytes are encrypted by SSH end-to-end).
/// </summary>
SshTunnel,

/// <summary>Remote host over TLS (<c>wss://</c> or <c>https://</c>).</summary>
DirectSecure,

/// <summary>
/// Remote host over cleartext (<c>ws://</c> or <c>http://</c>) — the token
/// travels unencrypted across the network.
/// </summary>
DirectInsecure,
}

/// <summary>Whether the credential is protected in transit.</summary>
public enum GatewayTransportSecurity
{
/// <summary>Loopback only — no network exposure.</summary>
LocalLoopback,

/// <summary>Encrypted (TLS) or tunnelled (SSH) — token protected on the wire.</summary>
Encrypted,

/// <summary>Cleartext to a non-local host — token exposed on the wire.</summary>
Cleartext,
}

/// <summary>Immutable classification of a gateway endpoint for setup/repair UX.</summary>
public sealed record RemoteGatewayProfile(
GatewayConnectionTopology Topology,
GatewayTransportSecurity Security,
string Host,
bool IsTls)
{
public bool IsLocal => Topology == GatewayConnectionTopology.Local;

public bool IsRemote =>
Topology is GatewayConnectionTopology.DirectSecure
or GatewayConnectionTopology.DirectInsecure
or GatewayConnectionTopology.SshTunnel;

/// <summary>
/// True when a token would travel in cleartext over a network. The UI should
/// steer the user to TLS (<c>wss://</c>), an SSH tunnel, or a trusted proxy
/// (e.g. Tailscale) before saving such a gateway.
/// </summary>
public bool RecommendsTransportHardening =>
Security == GatewayTransportSecurity.Cleartext;
}

/// <summary>
/// Pure classifier that maps a gateway URL (plus whether a managed SSH tunnel is
/// configured) to a <see cref="RemoteGatewayProfile"/>. No I/O, no UI types.
/// </summary>
public static class RemoteGatewayClassifier
{
public static RemoteGatewayProfile Classify(string? url, bool hasSshTunnel = false)
{
var trimmed = url?.Trim();
if (string.IsNullOrEmpty(trimmed) ||
!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) ||
string.IsNullOrEmpty(uri.Host))
{
// Unparseable input is left to GatewayUrlHelper validation elsewhere;
// we surface no transport warning so half-typed URLs don't flicker a
// scary banner on every keystroke.
return new RemoteGatewayProfile(
GatewayConnectionTopology.Unknown,
GatewayTransportSecurity.LocalLoopback,
Host: string.Empty,
IsTls: false);
}

var host = uri.Host;
var scheme = uri.Scheme;
var isTls = scheme.Equals("wss", StringComparison.OrdinalIgnoreCase) ||
scheme.Equals("https", StringComparison.OrdinalIgnoreCase);

// A managed SSH tunnel encrypts the hop even though the WebSocket URL is
// loopback. Treat it as a remote-but-encrypted endpoint.
if (hasSshTunnel)
{
return new RemoteGatewayProfile(
GatewayConnectionTopology.SshTunnel,
GatewayTransportSecurity.Encrypted,
host,
isTls);
}

if (LocalGatewayUrlClassifier.IsLocalGatewayUrl(trimmed))
{
return new RemoteGatewayProfile(
GatewayConnectionTopology.Local,
GatewayTransportSecurity.LocalLoopback,
host,
isTls);
}

return isTls
? new RemoteGatewayProfile(
GatewayConnectionTopology.DirectSecure,
GatewayTransportSecurity.Encrypted,
host,
IsTls: true)
: new RemoteGatewayProfile(
GatewayConnectionTopology.DirectInsecure,
GatewayTransportSecurity.Cleartext,
host,
IsTls: false);
}
}
Loading
Loading