From bd8ab21e0a31af98b8fcd3e45dc38bb4d65ef4e7 Mon Sep 17 00:00:00 2001
From: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com>
Date: Tue, 18 Aug 2026 04:23:19 +0100
Subject: [PATCH 1/6] fix(companion): require live Tailscale readiness for
dashboard auth
---
.../GatewayConnectionManager.cs | 59 ++
src/OpenClaw.Connection/GatewayRecord.cs | 8 +
.../GatewayTailscaleAuthUpgrade.cs | 539 ++++++++++++
.../IGatewayConnectionManager.cs | 9 +
src/OpenClaw.SetupEngine/PairOperatorStep.cs | 12 +
.../TailscaleSetupSteps.cs | 18 +-
.../Capabilities/AppCapability.cs | 11 +-
src/OpenClaw.Shared/ChannelsSnapshot.cs | 7 +
src/OpenClaw.Shared/IOperatorGatewayClient.cs | 2 +
src/OpenClaw.Shared/OpenClawGatewayClient.cs | 16 +-
.../TailscaleServeStatusPolicy.cs | 209 +++++
.../App.CapabilityHandlers.cs | 31 +-
src/OpenClaw.Tray.WinUI/App.xaml.cs | 70 +-
.../Helpers/GatewayDashboardUrlBuilder.cs | 13 +-
.../Pages/ConnectionPage.xaml.cs | 81 +-
.../GatewayTailscaleAuthLiveVerifierTests.cs | 431 ++++++++++
.../GatewayTailscaleAuthUpgradeTests.cs | 780 ++++++++++++++++++
.../Setup/SetupAndConnectTests.cs | 11 +-
.../SetupStepsTests.cs | 69 ++
.../AppCapabilityTests.cs | 39 +
.../OpenClawGatewayClientTests.cs | 86 +-
.../AppRefactorContractTests.cs | 27 +
.../ConnectionPageTailscaleRecoveryTests.cs | 47 ++
.../GatewayDashboardUrlBuilderTests.cs | 17 +
24 files changed, 2563 insertions(+), 29 deletions(-)
create mode 100644 src/OpenClaw.Connection/GatewayTailscaleAuthUpgrade.cs
create mode 100644 src/OpenClaw.Shared/TailscaleServeStatusPolicy.cs
create mode 100644 tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthLiveVerifierTests.cs
create mode 100644 tests/OpenClaw.Connection.Tests/GatewayTailscaleAuthUpgradeTests.cs
diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs
index f2c57556a..8f49e95fd 100644
--- a/src/OpenClaw.Connection/GatewayConnectionManager.cs
+++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs
@@ -56,6 +56,7 @@ public sealed class GatewayConnectionManager :
private readonly ICredentialResolver _credentialResolver;
private readonly IGatewayClientFactory _clientFactory;
private readonly GatewayRegistry _registry;
+ private readonly IGatewayTailscaleAuthLiveVerifier _tailscaleAuthLiveVerifier;
private readonly IOpenClawLogger _logger;
private readonly IDeviceIdentityStore? _identityStore;
private readonly INodeConnector? _nodeConnector;
@@ -132,6 +133,8 @@ public GatewayConnectionManager(
_clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory));
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _tailscaleAuthLiveVerifier = new GatewayTailscaleAuthLiveVerifier(
+ new WslExeCommandRunner(_logger));
_identityStore = identityStore;
_nodeConnector = nodeConnector;
_tunnelManager = tunnelManager;
@@ -203,6 +206,62 @@ public GatewayConnectionManager(
public IOperatorGatewayClient? OperatorClient => _activeLifecycle?.DataClient;
/// Internal access to the concrete client for auto-approve and other manager-internal operations.
internal OpenClawGatewayClient? ConcreteOperatorClient => _activeLifecycle?.DataClient;
+
+ public async Task EnableTailscaleDashboardAuthAsync(
+ string gatewayId,
+ CancellationToken cancellationToken = default)
+ {
+ await _transitionSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (!string.Equals(_activeGatewayRecordId, gatewayId, StringComparison.Ordinal))
+ return new(GatewayTailscaleAuthUpgradeOutcome.NotActive);
+
+ var client = OperatorClient;
+ if (client is null)
+ return new(GatewayTailscaleAuthUpgradeOutcome.NotConnected);
+
+ var service = new GatewayTailscaleAuthUpgradeService(_registry);
+ return await service.EnableAsync(
+ gatewayId,
+ new GatewayTailscaleAuthConfigClientAdapter(client),
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+ finally
+ {
+ _transitionSemaphore.Release();
+ }
+ }
+
+ public async Task RevalidateTailscaleDashboardAuthAsync(
+ string gatewayId,
+ CancellationToken cancellationToken = default)
+ {
+ await _transitionSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (!string.Equals(_activeGatewayRecordId, gatewayId, StringComparison.Ordinal))
+ return false;
+
+ var client = OperatorClient;
+ if (client is null)
+ return false;
+
+ var service = new GatewayTailscaleAuthUpgradeService(
+ _registry,
+ _tailscaleAuthLiveVerifier);
+ return await service.RevalidateAsync(
+ gatewayId,
+ new GatewayTailscaleAuthConfigClientAdapter(client),
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+ finally
+ {
+ _transitionSemaphore.Release();
+ }
+ }
public ConnectionDiagnostics Diagnostics => _diagnostics;
// ─── Lifecycle ───
diff --git a/src/OpenClaw.Connection/GatewayRecord.cs b/src/OpenClaw.Connection/GatewayRecord.cs
index 379bd3424..5be315505 100644
--- a/src/OpenClaw.Connection/GatewayRecord.cs
+++ b/src/OpenClaw.Connection/GatewayRecord.cs
@@ -33,6 +33,12 @@ public sealed record GatewayRecord
/// WSL distro name for gateway records provisioned by SetupEngine.
public string? SetupManagedDistroName { get; init; }
+ ///
+ /// True when setup or an explicit upgrade granted this managed gateway's
+ /// Dashboard access to verified Tailscale identity authentication.
+ ///
+ public bool TrustTailscaleAuth { get; init; }
+
/// Per-gateway SSH tunnel configuration. Null if no tunnel needed.
public SshTunnelConfig? SshTunnel { get; init; }
@@ -95,6 +101,7 @@ rebuilt.SshTunnel is null &&
// Migrate legacy "Local ()" ownership to the explicit durable marker.
SetupManagedDistroName = managedDistroName,
RequiresV2Signature = rebuilt.RequiresV2Signature || existing.RequiresV2Signature,
+ TrustTailscaleAuth = rebuilt.TrustTailscaleAuth || existing.TrustTailscaleAuth,
};
}
else if (existingManagedDistroName is not null)
@@ -104,6 +111,7 @@ rebuilt.SshTunnel is null &&
IsLocal = OpenClaw.Shared.LocalGatewayUrlClassifier.IsLocalGatewayUrl(rebuilt.Url),
SetupManagedDistroName = null,
RequiresV2Signature = false,
+ TrustTailscaleAuth = false,
FriendlyName = ParseLegacyManagedDistroName(result.FriendlyName) is not null
? null
: result.FriendlyName,
diff --git a/src/OpenClaw.Connection/GatewayTailscaleAuthUpgrade.cs b/src/OpenClaw.Connection/GatewayTailscaleAuthUpgrade.cs
new file mode 100644
index 000000000..283af7b49
--- /dev/null
+++ b/src/OpenClaw.Connection/GatewayTailscaleAuthUpgrade.cs
@@ -0,0 +1,539 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using OpenClaw.Shared;
+
+namespace OpenClaw.Connection;
+
+public enum GatewayTailscaleAuthUpgradeOutcome
+{
+ Succeeded,
+ AlreadyEnabled,
+ Ineligible,
+ NotActive,
+ NotConnected,
+ MissingConfigScope,
+ ConfigUnavailable,
+ PatchRejected,
+ PersistenceFailed,
+}
+
+public sealed record GatewayTailscaleAuthUpgradeResult(
+ GatewayTailscaleAuthUpgradeOutcome Outcome,
+ string? Error = null)
+{
+ public bool IsSuccess => Outcome is
+ GatewayTailscaleAuthUpgradeOutcome.Succeeded or
+ GatewayTailscaleAuthUpgradeOutcome.AlreadyEnabled;
+}
+
+public static class GatewayTailscaleAuthUpgradePolicy
+{
+ public static bool IsEligible(GatewayRecord? record)
+ {
+ if (record is null ||
+ !record.IsLocal ||
+ record.SshTunnel is not null ||
+ GatewayRecordEditing.ResolveManagedDistroName(record) is null)
+ {
+ return false;
+ }
+
+ // Topology only limits where the opt-in is offered; confirmation and a successful Core patch grant trust.
+ return GatewayTopologyClassifier.Classify(record.Url, useSshTunnel: false).DetectedKind ==
+ GatewayKind.Tailscale;
+ }
+
+ public static bool CanOffer(GatewayRecord? record) =>
+ record?.TrustTailscaleAuth != true && IsEligible(record);
+}
+
+internal interface IGatewayTailscaleAuthConfigClient
+{
+ IReadOnlyList GrantedOperatorScopes { get; }
+ bool IsConnectedToGateway { get; }
+ Task RequestConfigDetailedAsync(int timeoutMs = 15000);
+ Task PatchConfigDetailedAsync(
+ JsonElement fullConfig,
+ string? baseHash,
+ int timeoutMs = 15000);
+}
+
+internal sealed class GatewayTailscaleAuthConfigClientAdapter(IOperatorGatewayClient client)
+ : IGatewayTailscaleAuthConfigClient
+{
+ public IReadOnlyList GrantedOperatorScopes => client.GrantedOperatorScopes;
+ public bool IsConnectedToGateway => client.IsConnectedToGateway;
+ public Task RequestConfigDetailedAsync(int timeoutMs = 15000) =>
+ client.RequestConfigDetailedAsync(timeoutMs);
+ public Task PatchConfigDetailedAsync(
+ JsonElement fullConfig,
+ string? baseHash,
+ int timeoutMs = 15000) =>
+ client.PatchConfigDetailedAsync(fullConfig, baseHash, timeoutMs);
+}
+
+internal enum GatewayTailscaleAuthLiveState
+{
+ Ready,
+ NotReady,
+ Unavailable,
+}
+
+internal interface IGatewayTailscaleAuthLiveVerifier
+{
+ Task VerifyAsync(
+ GatewayRecord record,
+ int gatewayPort,
+ CancellationToken cancellationToken);
+}
+
+internal sealed class GatewayTailscaleAuthLiveVerifier : IGatewayTailscaleAuthLiveVerifier
+{
+ private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5);
+ private static readonly IReadOnlyList StatusCommand =
+ ["/usr/bin/tailscale", "status", "--json"];
+ private static readonly IReadOnlyList ServeStatusCommand =
+ ["/usr/bin/tailscale", "serve", "status", "--json"];
+
+ private readonly IWslCommandRunner _commandRunner;
+ private readonly TimeSpan _timeout;
+
+ public GatewayTailscaleAuthLiveVerifier(
+ IWslCommandRunner commandRunner,
+ TimeSpan? timeout = null)
+ {
+ _commandRunner = commandRunner ?? throw new ArgumentNullException(nameof(commandRunner));
+ _timeout = timeout ?? DefaultTimeout;
+ }
+
+ public async Task VerifyAsync(
+ GatewayRecord record,
+ int gatewayPort,
+ CancellationToken cancellationToken)
+ {
+ if (GatewayRecordEditing.ResolveManagedDistroName(record) is not { } distroName ||
+ !Uri.TryCreate(record.Url, UriKind.Absolute, out var gatewayUri) ||
+ gatewayPort is <= 0 or > 65535)
+ {
+ return GatewayTailscaleAuthLiveState.Unavailable;
+ }
+
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(_timeout);
+
+ WslCommandResult statusResult;
+ try
+ {
+ statusResult = await _commandRunner.RunAsync(
+ BuildRootProbeArguments(distroName, StatusCommand),
+ timeout.Token)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch
+ {
+ return GatewayTailscaleAuthLiveState.Unavailable;
+ }
+
+ if (!statusResult.Success)
+ return GatewayTailscaleAuthLiveState.Unavailable;
+
+ string dnsName;
+ try
+ {
+ using var status = JsonDocument.Parse(statusResult.StandardOutput);
+ var root = status.RootElement;
+ var backendRunning =
+ root.ValueKind == JsonValueKind.Object &&
+ root.TryGetProperty("BackendState", out var backendState) &&
+ backendState.ValueKind == JsonValueKind.String &&
+ string.Equals(backendState.GetString(), "Running", StringComparison.Ordinal);
+ dnsName =
+ root.ValueKind == JsonValueKind.Object &&
+ root.TryGetProperty("Self", out var self) &&
+ self.ValueKind == JsonValueKind.Object &&
+ self.TryGetProperty("DNSName", out var dnsNameElement) &&
+ dnsNameElement.ValueKind == JsonValueKind.String &&
+ !string.IsNullOrWhiteSpace(dnsNameElement.GetString())
+ ? dnsNameElement.GetString()!.Trim().TrimEnd('.')
+ : string.Empty;
+
+ if (!backendRunning ||
+ !string.Equals(dnsName, gatewayUri.Host.TrimEnd('.'), StringComparison.OrdinalIgnoreCase))
+ {
+ return GatewayTailscaleAuthLiveState.NotReady;
+ }
+ }
+ catch (JsonException)
+ {
+ return GatewayTailscaleAuthLiveState.Unavailable;
+ }
+
+ WslCommandResult serveResult;
+ try
+ {
+ serveResult = await _commandRunner.RunAsync(
+ BuildRootProbeArguments(distroName, ServeStatusCommand),
+ timeout.Token)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch
+ {
+ return GatewayTailscaleAuthLiveState.Unavailable;
+ }
+
+ if (!serveResult.Success ||
+ !TailscaleServeStatusPolicy.TryParse(
+ serveResult.StandardOutput,
+ gatewayPort,
+ gatewayUri,
+ out var serveStatus))
+ {
+ return GatewayTailscaleAuthLiveState.Unavailable;
+ }
+
+ return serveStatus.RoutesToGateway && !serveStatus.FunnelEnabled
+ ? GatewayTailscaleAuthLiveState.Ready
+ : GatewayTailscaleAuthLiveState.NotReady;
+ }
+
+ private static IReadOnlyList BuildRootProbeArguments(
+ string distroName,
+ IReadOnlyList command) =>
+ ["-d", distroName, "--user", "root", "--", .. command];
+}
+
+internal sealed class GatewayTailscaleAuthUpgradeService
+{
+ private static readonly TimeSpan ConfigTimeout = TimeSpan.FromSeconds(15);
+ private readonly GatewayRegistry _registry;
+ private readonly IGatewayTailscaleAuthLiveVerifier? _liveVerifier;
+
+ public GatewayTailscaleAuthUpgradeService(GatewayRegistry registry)
+ : this(registry, liveVerifier: null)
+ {
+ }
+
+ public GatewayTailscaleAuthUpgradeService(
+ GatewayRegistry registry,
+ IGatewayTailscaleAuthLiveVerifier? liveVerifier)
+ {
+ _registry = registry ?? throw new ArgumentNullException(nameof(registry));
+ _liveVerifier = liveVerifier;
+ }
+
+ public async Task EnableAsync(
+ string gatewayId,
+ IGatewayTailscaleAuthConfigClient client,
+ CancellationToken cancellationToken)
+ {
+ var record = _registry.GetById(gatewayId);
+ if (!GatewayTailscaleAuthUpgradePolicy.IsEligible(record))
+ return new(GatewayTailscaleAuthUpgradeOutcome.Ineligible);
+
+ if (!string.Equals(_registry.ActiveGatewayId, gatewayId, StringComparison.Ordinal))
+ return new(GatewayTailscaleAuthUpgradeOutcome.NotActive);
+
+ if (!client.IsConnectedToGateway)
+ return new(GatewayTailscaleAuthUpgradeOutcome.NotConnected);
+
+ if (!OperatorScopeHelper.CanReadConfig(client.GrantedOperatorScopes) ||
+ !OperatorScopeHelper.CanWriteConfig(client.GrantedOperatorScopes))
+ {
+ return new(GatewayTailscaleAuthUpgradeOutcome.MissingConfigScope);
+ }
+
+ ConfigSnapshot snapshot;
+ try
+ {
+ snapshot = await ReadConfigAsync(client, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ return new(GatewayTailscaleAuthUpgradeOutcome.ConfigUnavailable, ex.Message);
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (record!.TrustTailscaleAuth && AllowsTailscaleAuth(snapshot.Root))
+ return new(GatewayTailscaleAuthUpgradeOutcome.AlreadyEnabled);
+
+ if (string.IsNullOrWhiteSpace(snapshot.BaseHash))
+ {
+ return new(GatewayTailscaleAuthUpgradeOutcome.ConfigUnavailable, "Config base hash is unavailable.");
+ }
+
+ var previousValue = false;
+ var changed = false;
+ if (!record.TrustTailscaleAuth)
+ {
+ _registry.Update(gatewayId, current =>
+ {
+ if (!GatewayTailscaleAuthUpgradePolicy.CanOffer(current))
+ return current;
+
+ previousValue = current.TrustTailscaleAuth;
+ changed = true;
+ return current with { TrustTailscaleAuth = true };
+ });
+
+ if (!changed)
+ return new(GatewayTailscaleAuthUpgradeOutcome.NotActive);
+
+ try
+ {
+ _registry.Save();
+ }
+ catch (Exception ex)
+ {
+ _registry.Update(gatewayId, current => current with { TrustTailscaleAuth = previousValue });
+ return new(GatewayTailscaleAuthUpgradeOutcome.PersistenceFailed, ex.Message);
+ }
+ }
+
+ if (cancellationToken.IsCancellationRequested)
+ {
+ var rollback = RollBackMarkerAfterRejectedPatch(
+ gatewayId,
+ changed,
+ previousValue,
+ patchError: null);
+ if (rollback.Outcome == GatewayTailscaleAuthUpgradeOutcome.PersistenceFailed)
+ return rollback;
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+
+ // Core beta.7 and current main define config.patch as JSON Merge Patch.
+ // Send only the intended leaf: replaying the full snapshot would turn
+ // unrelated null values into deletion markers.
+ var configPatch = CreateAllowTailscalePatch();
+ ConfigPatchResult patch;
+ Task? patchTask = null;
+ try
+ {
+ patchTask = client.PatchConfigDetailedAsync(configPatch, snapshot.BaseHash);
+ patch = await patchTask.WaitAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // Dispatch cannot be canceled at the client boundary. Observe its bounded
+ // result before returning so callers never proceed beside an in-flight
+ // mutation. Roll back only a definitive gateway rejection; otherwise the
+ // marker remains for the next trust-aware live revalidation.
+ try
+ {
+ var completedPatch = await patchTask!.ConfigureAwait(false);
+ if (!completedPatch.Ok && completedPatch.IsGatewayRejection)
+ {
+ var rollback = RollBackMarkerAfterRejectedPatch(
+ gatewayId,
+ changed,
+ previousValue,
+ completedPatch.Error);
+ if (rollback.Outcome == GatewayTailscaleAuthUpgradeOutcome.PersistenceFailed)
+ return rollback;
+ }
+ }
+ catch
+ {
+ // A transport failure is ambiguous: Core may have committed.
+ }
+ throw;
+ }
+ catch (Exception ex)
+ {
+ // The response can be lost after Core commits the patch. Keep the marker so
+ // the next trust-aware launch revalidates the authoritative Core state.
+ return new(GatewayTailscaleAuthUpgradeOutcome.PatchRejected, ex.Message);
+ }
+
+ if (!patch.Ok && patch.IsGatewayRejection)
+ return RollBackMarkerAfterRejectedPatch(gatewayId, changed, previousValue, patch.Error);
+
+ if (!patch.Ok)
+ {
+ // The request may have committed before its response was lost. Preserve
+ // the marker so the next trust-aware launch revalidates Core state.
+ return new(GatewayTailscaleAuthUpgradeOutcome.PatchRejected, patch.Error);
+ }
+
+ return new(GatewayTailscaleAuthUpgradeOutcome.Succeeded);
+ }
+
+ public async Task RevalidateAsync(
+ string gatewayId,
+ IGatewayTailscaleAuthConfigClient client,
+ CancellationToken cancellationToken)
+ {
+ var record = _registry.GetById(gatewayId);
+ if (record?.TrustTailscaleAuth != true ||
+ !GatewayTailscaleAuthUpgradePolicy.IsEligible(record) ||
+ !string.Equals(_registry.ActiveGatewayId, gatewayId, StringComparison.Ordinal) ||
+ !client.IsConnectedToGateway ||
+ !OperatorScopeHelper.CanReadConfig(client.GrantedOperatorScopes))
+ {
+ return false;
+ }
+
+ ConfigSnapshot snapshot;
+ try
+ {
+ snapshot = await ReadConfigAsync(client, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch
+ {
+ return false;
+ }
+
+ if (AllowsTailscaleAuth(snapshot.Root))
+ {
+ if (_liveVerifier is null || !TryGetGatewayPort(snapshot.Root, out var gatewayPort))
+ return false;
+
+ try
+ {
+ return await _liveVerifier.VerifyAsync(record, gatewayPort, cancellationToken).ConfigureAwait(false) ==
+ GatewayTailscaleAuthLiveState.Ready;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ _registry.Update(gatewayId, current => current with { TrustTailscaleAuth = false });
+ try
+ {
+ _registry.Save();
+ }
+ catch
+ {
+ _registry.Update(gatewayId, current => current with { TrustTailscaleAuth = true });
+ }
+ return false;
+ }
+
+ private static async Task ReadConfigAsync(
+ IGatewayTailscaleAuthConfigClient client,
+ CancellationToken cancellationToken)
+ {
+ var timeoutMs = checked((int)ConfigTimeout.TotalMilliseconds);
+ var response = await client.RequestConfigDetailedAsync(timeoutMs)
+ .WaitAsync(cancellationToken)
+ .ConfigureAwait(false);
+ return CaptureSnapshot(response);
+ }
+
+ private static ConfigSnapshot CaptureSnapshot(JsonElement response)
+ {
+ var root = response.TryGetProperty("parsed", out var parsed)
+ ? parsed
+ : response.TryGetProperty("config", out var config)
+ ? config
+ : response;
+ var baseHash = response.TryGetProperty("baseHash", out var baseHashElement) &&
+ baseHashElement.ValueKind == JsonValueKind.String
+ ? baseHashElement.GetString()
+ : response.TryGetProperty("hash", out var hashElement) &&
+ hashElement.ValueKind == JsonValueKind.String
+ ? hashElement.GetString()
+ : null;
+ if (baseHash is null &&
+ response.TryGetProperty("raw", out var rawElement) &&
+ rawElement.ValueKind == JsonValueKind.String &&
+ rawElement.GetString() is { } raw)
+ {
+ baseHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(raw)));
+ }
+
+ return new(root.Clone(), baseHash);
+ }
+
+ private static JsonElement CreateAllowTailscalePatch()
+ {
+ using var document = JsonDocument.Parse("""
+ { "gateway": { "auth": { "allowTailscale": true } } }
+ """);
+ return document.RootElement.Clone();
+ }
+
+ private GatewayTailscaleAuthUpgradeResult RollBackMarkerAfterRejectedPatch(
+ string gatewayId,
+ bool changed,
+ bool previousValue,
+ string? patchError)
+ {
+ if (!changed)
+ return new(GatewayTailscaleAuthUpgradeOutcome.PatchRejected, patchError);
+
+ _registry.Update(gatewayId, current => current with { TrustTailscaleAuth = previousValue });
+ try
+ {
+ _registry.Save();
+ return new(GatewayTailscaleAuthUpgradeOutcome.PatchRejected, patchError);
+ }
+ catch (Exception ex)
+ {
+ return new(
+ GatewayTailscaleAuthUpgradeOutcome.PersistenceFailed,
+ $"Local Tailscale trust-marker rollback failed: {ex.Message}");
+ }
+ }
+
+ // A true marker is issued only after this service or setup explicitly writes
+ // allowTailscale=true. Omission therefore means that grant drifted or was revoked;
+ // do not duplicate Core's environment-sensitive implicit auth resolver here.
+ private static bool AllowsTailscaleAuth(JsonElement config) =>
+ config.ValueKind == JsonValueKind.Object &&
+ config.TryGetProperty("gateway", out var gateway) &&
+ gateway.ValueKind == JsonValueKind.Object &&
+ gateway.TryGetProperty("auth", out var auth) &&
+ auth.ValueKind == JsonValueKind.Object &&
+ auth.TryGetProperty("allowTailscale", out var allowTailscale) &&
+ allowTailscale.ValueKind is JsonValueKind.True;
+
+ private static bool TryGetGatewayPort(JsonElement config, out int port)
+ {
+ const int defaultGatewayPort = 18789;
+ port = 0;
+ if (config.ValueKind != JsonValueKind.Object ||
+ !config.TryGetProperty("gateway", out var gateway) ||
+ gateway.ValueKind != JsonValueKind.Object)
+ {
+ return false;
+ }
+
+ if (!gateway.TryGetProperty("port", out var gatewayPort))
+ {
+ port = defaultGatewayPort;
+ return true;
+ }
+
+ return gatewayPort.ValueKind == JsonValueKind.Number &&
+ gatewayPort.TryGetInt32(out port) &&
+ port is > 0 and <= 65535;
+ }
+
+ private sealed record ConfigSnapshot(JsonElement Root, string? BaseHash);
+}
diff --git a/src/OpenClaw.Connection/IGatewayConnectionManager.cs b/src/OpenClaw.Connection/IGatewayConnectionManager.cs
index 0dc94a65f..eab1c1632 100644
--- a/src/OpenClaw.Connection/IGatewayConnectionManager.cs
+++ b/src/OpenClaw.Connection/IGatewayConnectionManager.cs
@@ -29,6 +29,15 @@ public interface IGatewayConnectionManager : IDisposable, IAsyncDisposable
Task RestartSshTunnelAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(false);
Task SwitchGatewayAsync(string gatewayId);
+ Task EnableTailscaleDashboardAuthAsync(
+ string gatewayId,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(new GatewayTailscaleAuthUpgradeResult(
+ GatewayTailscaleAuthUpgradeOutcome.NotConnected));
+ Task RevalidateTailscaleDashboardAuthAsync(
+ string gatewayId,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(false);
void SetGatewayConnectionIntent(string gatewayId, bool shouldBeConnected);
bool IsAutomaticReconnectAllowed(string gatewayId);
diff --git a/src/OpenClaw.SetupEngine/PairOperatorStep.cs b/src/OpenClaw.SetupEngine/PairOperatorStep.cs
index 9a30951df..f2bf76840 100644
--- a/src/OpenClaw.SetupEngine/PairOperatorStep.cs
+++ b/src/OpenClaw.SetupEngine/PairOperatorStep.cs
@@ -12,6 +12,8 @@ namespace OpenClaw.SetupEngine;
public sealed class PairOperatorStep : SetupStep
{
+ private const string AllowTailscaleConfigKey = "gateway.auth.allowTailscale";
+
public override string Id => "pair-operator";
public override string DisplayName => "Pair operator connection";
public override RetryPolicy Retry => new(MaxAttempts: 3, InitialDelay: TimeSpan.FromSeconds(3));
@@ -50,6 +52,7 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati
BootstrapToken = ctx.BootstrapToken,
IsLocal = true,
SetupManagedDistroName = ctx.DistroName,
+ TrustTailscaleAuth = ResolveEffectiveTailscaleAuthTrust(ctx.Config),
LastConnected = DateTime.UtcNow
};
@@ -166,6 +169,15 @@ record = registry.AddOrUpdate(record);
}
}
+ private static bool ResolveEffectiveTailscaleAuthTrust(SetupConfig config)
+ {
+ if (!config.Tailscale.Enabled || !config.Tailscale.TrustTailscaleAuth)
+ return false;
+
+ return config.Gateway.ExtraConfig?.TryGetValue(AllowTailscaleConfigKey, out var value) != true ||
+ bool.TryParse(value, out var enabled) && enabled;
+ }
+
internal static async Task EnsurePairingEndpointTrustedAsync(
SetupContext ctx,
CancellationToken cancellationToken,
diff --git a/src/OpenClaw.SetupEngine/TailscaleSetupSteps.cs b/src/OpenClaw.SetupEngine/TailscaleSetupSteps.cs
index cf8c6eb99..9817f7e18 100644
--- a/src/OpenClaw.SetupEngine/TailscaleSetupSteps.cs
+++ b/src/OpenClaw.SetupEngine/TailscaleSetupSteps.cs
@@ -139,15 +139,21 @@ private static bool HasGatewayWebProxy(JsonElement root, int port)
foreach (var webEndpoint in web.EnumerateObject())
{
- if (!webEndpoint.Value.TryGetProperty("Handlers", out var handlers) || handlers.ValueKind != JsonValueKind.Object)
+ if (webEndpoint.Value.ValueKind != JsonValueKind.Object ||
+ !webEndpoint.Value.TryGetProperty("Handlers", out var handlers) ||
+ handlers.ValueKind != JsonValueKind.Object)
+ {
continue;
+ }
foreach (var handler in handlers.EnumerateObject())
{
if (handler.Value.ValueKind != JsonValueKind.Object ||
!handler.Value.TryGetProperty("Proxy", out var proxy) ||
proxy.ValueKind != JsonValueKind.String)
+ {
continue;
+ }
if (IsLoopbackGatewayProxy(proxy.GetString(), port))
return true;
@@ -157,9 +163,6 @@ private static bool HasGatewayWebProxy(JsonElement root, int port)
return false;
}
- // Serve status represents Funnel as AllowFunnel on current Tailscale
- // versions. Accept the legacy Funnel spelling too so a version change
- // cannot silently turn a public endpoint into an accepted setup state.
private static bool HasEnabledFunnel(JsonElement root)
{
foreach (var property in root.EnumerateObject())
@@ -176,11 +179,10 @@ private static bool HasEnabledFunnel(JsonElement root)
JsonValueKind.True => true,
JsonValueKind.False or JsonValueKind.Null or JsonValueKind.Undefined => false,
JsonValueKind.Array => value.EnumerateArray().Any(ContainsEnabledFunnelValue),
- JsonValueKind.Object => value.EnumerateObject().Any(property => ContainsEnabledFunnelValue(property.Value)),
- // A non-empty string is a configured public endpoint in older status
- // documents. Be conservative: setup must never accept it as private.
+ JsonValueKind.Object => value.EnumerateObject().Any(property =>
+ ContainsEnabledFunnelValue(property.Value)),
JsonValueKind.String => !string.IsNullOrWhiteSpace(value.GetString()),
- _ => false
+ _ => false,
};
private static bool IsLoopbackGatewayProxy(string? proxy, int port) =>
diff --git a/src/OpenClaw.Shared/Capabilities/AppCapability.cs b/src/OpenClaw.Shared/Capabilities/AppCapability.cs
index 5faac7ccc..cbc4fe948 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