diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index fb18bbeda..21da067dc 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -73,6 +73,7 @@ These are the canonical homes. Do not reintroduce private copies elsewhere.
| Hub navigation tags, page mapping, command catalog/search, and gateway-page classification | `HubPageRegistry` | authoritative |
| Hub notification banner severity and action projection | `AppNotificationInfoBarPresenter` | authoritative |
| Tray-menu semantic composition and connection-toggle state | `TrayMenuPresenter` + `ConnectionTogglePresenter` | authoritative |
+| Dashboard link Tailscale revalidation, browser-credential fallback, and fail-closed URL policy | `GatewayDashboardLinkService` | authoritative |
| App-owned non-tray window creation, reuse, focus, theme, and lifetime | `IWindowManager` + `WindowManager` | authoritative |
| Tray icon, popup coordination, live status, and callback lifetime | `ITrayController` + `TrayController` | authoritative |
| Deep-link/protocol/toast/forwarded activation normalization, current-user IPC, and semantic activation plans | `ActivationRouter` | authoritative |
@@ -169,6 +170,7 @@ leading and trailing pipe. Columns, in order:
| app-window-surface-ownership-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | concrete non-tray window fields, constructors, show/hide/focus/theme/close mechanics, and window event lifetime | IWindowManager + WindowManager | interface forwarding, immutable request construction, route and policy callbacks, and setup restart dialog policy only | App cannot regain a parallel Hub, Chat, status, setup, canvas-request, or runtime-anchor owner | AppSurfaceOwnershipContractTests.App_DelegatesConcreteTrayAndWindowOwnership | source-shape | when App is replaced as the WinUI composition root |
| app-tray-controller | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | tray icon creation, tray popup coordination, click routing, tooltip and live-toggle refresh, theme, callbacks, and disposal | ITrayController + TrayController | App captures immutable snapshots, implements semantic action callbacks, triggers refresh from authoritative state, preserves startup construction order, and constructs shutdown-plan callbacks | one tray icon and root menu are reused; A1 presenters retain semantics; TrayMenuWindow retains native popup mechanics; callbacks detach and resources dispose once | TrayControllerTests.Dispose_UnsubscribesAndDisposesEachResourceOnce | source-shape | when the WinUI tray surface is replaced |
| app-tray-surface-ownership-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | concrete tray icon, root menu, weak live-control state, event subscriptions, popup build coordination, and resource disposal | ITrayController + TrayController | immutable snapshot and action callbacks plus state-change triggers only | App cannot regain tray controls or popup lifetime and TrayController cannot duplicate A1 semantic projection or TrayMenuWindow native mechanics | AppSurfaceOwnershipContractTests.App_DelegatesConcreteTrayAndWindowOwnership | source-shape | when the WinUI tray surface is replaced |
+| dashboard-link-policy | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs + App.CapabilityHandlers.cs + Pages/ConnectionPage.xaml.cs | duplicated Tailscale revalidation, browser-credential fallback, and dashboard URL policy | GatewayDashboardLinkService | callers retain UI launch, error, provenance, and MCP response side effects | trusted Tailscale links omit shared credentials; degraded Tailscale revalidation falls back only to an approved shared browser credential and otherwise fails closed; non-Tailscale QR/bootstrap requests preserve the existing token-free dashboard URL | AppRefactorContractTests.DashboardLinkPolicy_StaysDelegatedToFocusedService | source-shape | when App, ConnectionPage, and local MCP no longer own dashboard-link entrypoints |
| native-tool-projector | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | pure native tool identity, allowlisted display arguments, payload extraction, and flattened-history detection/classification/summary | NativeToolProjector | ChatEventMapper and ChatHistoryLoader call the projector; ChatConversationState supplies scoped correlation plans and ChatMetadataStore owns persistence | unknown identities remain truthful Tool; title aliases are strict; display arguments are allowlisted, redacted, and bounded; live/history projection stays consistent | NativeToolProjectorTests.ExtractToolIdentity_TitleRequiresExactTrustedAlias | behavioral | - |
| provider-native-tool-projection-closed | closed | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | private static copies of native tool identity, display argument, payload, flattened-history projection, and scoped metadata upsert | NativeToolProjector + ChatEventMapper + ChatHistoryLoader + ChatConversationState + ChatMetadataStore | provider forwards typed tool metadata writes while retaining bridge IO, telemetry, and event publication only | provider does not regain native tool JSON projection, identity policy, timeline correlation, or metadata persistence | review-only: pure projection, atomic correlation, and persistence are delegated to focused owners while the provider remains the IO facade | review-only | when OpenClawChatDataProvider is retired |
| chat-conversation-state | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | provider-owned runtime gate and cross-domain state transactions | ChatConversationState | sole lock, timeline and entry metadata, connection/disposal flags, and typed orchestration across lock-free substates; provider supplies bridge context and coordinates IO, telemetry, and events | one authoritative lock atomically commits reset, reconnect, dispose, queue, history, and event transitions without duplicate shared versions | ChatRuntimeOwnershipContractTests.Root_CoordinatesCrossDomainCommitsUnderSoleGate | source-shape | when the chat runtime is replaced by a different atomic transaction boundary |
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