diff --git a/docs/CONNECTION_ARCHITECTURE.md b/docs/CONNECTION_ARCHITECTURE.md index 63629b340..68da2c9a7 100644 --- a/docs/CONNECTION_ARCHITECTURE.md +++ b/docs/CONNECTION_ARCHITECTURE.md @@ -135,6 +135,8 @@ Idle → Connecting → Connected Each `GatewayRecord` contains: `Id`, `Url`, `FriendlyName`, `SharedGatewayToken`, `BootstrapToken`, `LastConnected`, `SshTunnel` config, `IsLocal`, `RequiresV2Signature`, `SetupManagedDistroName`, and `BrowserControlPort`. The `IdentityDirName` property is computed from `Id`. +Many gateway records may be saved, but only `ActiveId` in `gateways.json` is the effective gateway. Active gateway changes must be made through `GatewayRegistry.SetActive(...)` and saved immediately by connection flows that switch or apply credentials. `SetActive(...)` raises `GatewayRegistry.Changed`, so UI and diagnostics can observe a gateway switch even before the new connection finishes. Each active gateway resolves identity from `%APPDATA%\OpenClawTray\gateways\\`; old gateway events are ignored by `GatewayConnectionManager` generation + gateway-id guards after a switch. + `SettingsManager` still owns general tray settings (node mode, MCP mode, SSH tunnel toggles, notifications, UI preferences). It may read legacy `Token` / `BootstrapToken` JSON fields into memory for migration, but save must not write those legacy credential fields back. ## Credential precedence @@ -148,7 +150,9 @@ Credential resolution order is intentionally strict: The invariant is that a paired device token always wins. Do not downgrade a paired operator or node to a shared/bootstrap token, because that can reduce scopes or trigger unnecessary re-pairing. -**`CredentialResolver`** implements the precedence for WebSocket connections (operator and node roles). +**`CredentialResolver`** implements the precedence for WebSocket connections (operator and node roles). It also returns a detailed `GatewayCredentialResolution` so the active snapshot and diagnostics can distinguish `Resolved`, `Missing`, `Unreadable`, `Corrupt`, `FallbackUsed`, and `BootstrapRequired`. Shared-token-only gateways are a clean resolved state when no paired device token exists. If a stored per-gateway device token is unreadable or corrupt and the resolver falls back to a shared/bootstrap token, `GatewayConnectionSnapshot` preserves that fallback status instead of reporting only the token source. + +Unreadable/corrupt identity fallback is an explicit same-gateway recovery path, not a silent cross-gateway downgrade. A readable stored device token still always wins. When the per-gateway identity file cannot be read or parsed, fallback may use only credentials already stored on the same active `GatewayRecord`; the snapshot and diagnostics report `FallbackUsed` with `PrimaryStatus=Unreadable` or `Corrupt` so UI/diagnostics can prompt repair or re-pair. Credential reads never fall back to another gateway's identity directory. Node credential precedence follows the same invariant with a distinct stored token: @@ -157,7 +161,7 @@ Node credential precedence follows the same invariant with a distinct stored tok 3. **`GatewayRecord.BootstrapToken`** — one-time setup, limited scopes. 4. **No credential** — caller logs and skips node client init. -**`InteractiveGatewayCredentialResolver`** resolves credentials for HTTP surfaces (chat URL `?token=` auth). It **prefers SharedGatewayToken** over DeviceToken because HTTP endpoints expect the shared token, not the per-device WebSocket token. +**`InteractiveGatewayCredentialResolver`** resolves credentials for HTTP surfaces (chat URL `?token=` auth). It **prefers SharedGatewayToken** over DeviceToken because HTTP endpoints expect the shared token, not the per-device WebSocket token. Browser proxy diagnostics should treat the missing shared token as a browser-control caveat, not as proof that the operator or node gateway connection is disconnected. ## Client instance lifecycle diff --git a/src/OpenClaw.Connection/ConnectionDiagnostics.cs b/src/OpenClaw.Connection/ConnectionDiagnostics.cs index e727e8a16..4f4d7d1d2 100644 --- a/src/OpenClaw.Connection/ConnectionDiagnostics.cs +++ b/src/OpenClaw.Connection/ConnectionDiagnostics.cs @@ -54,6 +54,22 @@ public void RecordCredentialResolution(GatewayCredential? credential) Record("credential", $"Resolved: {credential.Source}", $"IsBootstrap={credential.IsBootstrapToken}"); } + public void RecordCredentialResolutionResult(GatewayCredentialResolution resolution) + { + ArgumentNullException.ThrowIfNull(resolution); + + if (resolution.Credential == null) + { + Record("credential", $"No credential resolved: {resolution.Status}", resolution.Detail); + return; + } + + var detail = $"IsBootstrap={resolution.Credential.IsBootstrapToken}; Status={resolution.Status}; FallbackUsed={resolution.FallbackUsed}"; + if (!string.IsNullOrWhiteSpace(resolution.Detail)) + detail += $"; {resolution.Detail}"; + Record("credential", $"Resolved: {resolution.Credential.Source}", detail); + } + public void RecordWebSocketEvent(string eventName, string? detail = null) { Record("websocket", eventName, detail); diff --git a/src/OpenClaw.Connection/ConnectionStateMachine.cs b/src/OpenClaw.Connection/ConnectionStateMachine.cs index a4b709b70..ff9f53fbb 100644 --- a/src/OpenClaw.Connection/ConnectionStateMachine.cs +++ b/src/OpenClaw.Connection/ConnectionStateMachine.cs @@ -15,6 +15,14 @@ internal sealed class ConnectionStateMachine private string? _nodeError; private string? _operatorCredentialSource; private string? _nodeCredentialSource; + private GatewayCredentialResolutionStatus? _operatorCredentialStatus; + private GatewayCredentialResolutionStatus? _nodeCredentialStatus; + private bool _operatorCredentialFallbackUsed; + private bool _nodeCredentialFallbackUsed; + private bool _operatorCredentialBootstrapRequired; + private bool _nodeCredentialBootstrapRequired; + private string? _operatorCredentialDetail; + private string? _nodeCredentialDetail; private bool _nodeEnabled; /// @@ -127,6 +135,10 @@ public void SetNodeEnabled(bool enabled) _nodeState = RoleConnectionState.Disabled; _nodeError = null; _nodeCredentialSource = null; + _nodeCredentialStatus = null; + _nodeCredentialFallbackUsed = false; + _nodeCredentialBootstrapRequired = false; + _nodeCredentialDetail = null; } else if (_nodeState == RoleConnectionState.Disabled) { @@ -145,6 +157,14 @@ public void Reset() _nodeError = null; _operatorCredentialSource = null; _nodeCredentialSource = null; + _operatorCredentialStatus = null; + _nodeCredentialStatus = null; + _operatorCredentialFallbackUsed = false; + _nodeCredentialFallbackUsed = false; + _operatorCredentialBootstrapRequired = false; + _nodeCredentialBootstrapRequired = false; + _operatorCredentialDetail = null; + _nodeCredentialDetail = null; RebuildSnapshot(); } @@ -168,6 +188,22 @@ internal void SetOperatorDeviceId(string? deviceId) internal void SetOperatorCredentialSource(string? source) { _operatorCredentialSource = source; + _operatorCredentialStatus = string.IsNullOrEmpty(source) + ? null + : GatewayCredentialResolutionStatus.Resolved; + _operatorCredentialFallbackUsed = false; + _operatorCredentialBootstrapRequired = false; + _operatorCredentialDetail = null; + RebuildSnapshot(); + } + + internal void SetOperatorCredentialResolution(GatewayCredentialResolution resolution) + { + _operatorCredentialSource = resolution.Credential?.Source; + _operatorCredentialStatus = resolution.Status; + _operatorCredentialFallbackUsed = resolution.FallbackUsed; + _operatorCredentialBootstrapRequired = resolution.BootstrapRequired; + _operatorCredentialDetail = resolution.Detail; RebuildSnapshot(); } @@ -203,15 +239,43 @@ internal void SetNodeInfo( internal void SetNodeCredentialSource(string? source) { _nodeCredentialSource = source; + _nodeCredentialStatus = string.IsNullOrEmpty(source) + ? null + : GatewayCredentialResolutionStatus.Resolved; + _nodeCredentialFallbackUsed = false; + _nodeCredentialBootstrapRequired = false; + _nodeCredentialDetail = null; + RebuildSnapshot(); + } + + internal void SetNodeCredentialResolution(GatewayCredentialResolution resolution) + { + _nodeCredentialSource = resolution.Credential?.Source; + _nodeCredentialStatus = resolution.Status; + _nodeCredentialFallbackUsed = resolution.FallbackUsed; + _nodeCredentialBootstrapRequired = resolution.BootstrapRequired; + _nodeCredentialDetail = resolution.Detail; RebuildSnapshot(); } - internal void BlockNodeStart(string detail) + internal void BlockNodeStart(string detail, bool preserveCredentialResolution = false) { _nodeEnabled = true; _nodeState = RoleConnectionState.Error; _nodeError = detail; _nodeCredentialSource = null; + if (!preserveCredentialResolution) + { + _nodeCredentialStatus = null; + _nodeCredentialFallbackUsed = false; + _nodeCredentialBootstrapRequired = false; + _nodeCredentialDetail = null; + } + else + { + _nodeCredentialStatus ??= GatewayCredentialResolutionStatus.Missing; + _nodeCredentialDetail ??= detail; + } RebuildSnapshot(); } @@ -291,6 +355,14 @@ private void ApplyTransition(ConnectionTrigger trigger, string? detail) _nodeError = null; _operatorCredentialSource = null; _nodeCredentialSource = null; + _operatorCredentialStatus = null; + _nodeCredentialStatus = null; + _operatorCredentialFallbackUsed = false; + _nodeCredentialFallbackUsed = false; + _operatorCredentialBootstrapRequired = false; + _nodeCredentialBootstrapRequired = false; + _operatorCredentialDetail = null; + _nodeCredentialDetail = null; break; case ConnectionTrigger.ReconnectScheduled: @@ -353,6 +425,10 @@ private void RebuildSnapshot() OperatorState = _operatorState, OperatorError = _operatorError, OperatorCredentialSource = _operatorCredentialSource, + OperatorCredentialStatus = _operatorCredentialStatus, + OperatorCredentialFallbackUsed = _operatorCredentialFallbackUsed, + OperatorCredentialBootstrapRequired = _operatorCredentialBootstrapRequired, + OperatorCredentialDetail = _operatorCredentialDetail, OperatorPairingRequired = _operatorState == RoleConnectionState.PairingRequired, // Clear requestId when no longer in PairingRequired to prevent stale reads OperatorPairingRequestId = _operatorState == RoleConnectionState.PairingRequired @@ -361,6 +437,10 @@ private void RebuildSnapshot() NodeState = _nodeState, NodeError = _nodeError, NodeCredentialSource = _nodeCredentialSource, + NodeCredentialStatus = _nodeCredentialStatus, + NodeCredentialFallbackUsed = _nodeCredentialFallbackUsed, + NodeCredentialBootstrapRequired = _nodeCredentialBootstrapRequired, + NodeCredentialDetail = _nodeCredentialDetail, // Clear requestId when no longer in PairingRequired to prevent stale reads NodePairingRequestId = _nodeState == RoleConnectionState.PairingRequired ? Current.NodePairingRequestId : null, diff --git a/src/OpenClaw.Connection/CredentialResolver.cs b/src/OpenClaw.Connection/CredentialResolver.cs index 17da24646..83585f458 100644 --- a/src/OpenClaw.Connection/CredentialResolver.cs +++ b/src/OpenClaw.Connection/CredentialResolver.cs @@ -28,43 +28,133 @@ public CredentialResolver(IDeviceIdentityReader identityReader) _identityReader = identityReader ?? throw new ArgumentNullException(nameof(identityReader)); } - public GatewayCredential? ResolveOperator(GatewayRecord record, string identityPath) + public GatewayCredential? ResolveOperator(GatewayRecord record, string identityPath) => + ResolveOperatorDetailed(record, identityPath).Credential; + + public GatewayCredentialResolution ResolveOperatorDetailed(GatewayRecord record, string identityPath) { ArgumentNullException.ThrowIfNull(record); - // 1. Paired device token — highest priority, never downgrade - var storedToken = _identityReader.TryReadStoredDeviceToken(identityPath); - if (!string.IsNullOrWhiteSpace(storedToken)) - return new GatewayCredential(storedToken!, false, SourceDeviceToken); + return ResolveRole( + _identityReader.ReadStoredDeviceToken(identityPath), + SourceDeviceToken, + record.SharedGatewayToken, + record.BootstrapToken); + } - // 2. Shared gateway token — works for any device, full scopes - if (!string.IsNullOrWhiteSpace(record.SharedGatewayToken)) - return new GatewayCredential(record.SharedGatewayToken!, false, SourceSharedGatewayToken); + public GatewayCredential? ResolveNode(GatewayRecord record, string identityPath) => + ResolveNodeDetailed(record, identityPath).Credential; - // 3. Bootstrap token — one-time setup, limited scopes - if (!string.IsNullOrWhiteSpace(record.BootstrapToken)) - return new GatewayCredential(record.BootstrapToken!, true, SourceBootstrapToken); + public GatewayCredentialResolution ResolveNodeDetailed(GatewayRecord record, string identityPath) + { + ArgumentNullException.ThrowIfNull(record); - return null; + return ResolveRole( + _identityReader.ReadStoredNodeDeviceToken(identityPath), + SourceNodeDeviceToken, + record.SharedGatewayToken, + record.BootstrapToken); } - public GatewayCredential? ResolveNode(GatewayRecord record, string identityPath) + private static GatewayCredentialResolution ResolveRole( + DeviceTokenReadResult storedToken, + string deviceTokenSource, + string? sharedGatewayToken, + string? bootstrapToken) { - ArgumentNullException.ThrowIfNull(record); + if (storedToken.Status == DeviceTokenReadStatus.Resolved && + !string.IsNullOrWhiteSpace(storedToken.Token)) + { + var credential = new GatewayCredential(storedToken.Token!, false, deviceTokenSource); + return new GatewayCredentialResolution(credential, GatewayCredentialResolutionStatus.Resolved); + } + + var primaryStatus = MapPrimaryStatus(storedToken.Status); + if (!string.IsNullOrWhiteSpace(sharedGatewayToken)) + { + var fallbackUsed = primaryStatus is GatewayCredentialResolutionStatus.Unreadable + or GatewayCredentialResolutionStatus.Corrupt; + var detail = fallbackUsed + ? BuildFallbackDetail(deviceTokenSource, primaryStatus, SourceSharedGatewayToken, storedToken.Detail) + : null; + var credential = new GatewayCredential(sharedGatewayToken!, false, SourceSharedGatewayToken) + { + ResolutionStatus = fallbackUsed + ? GatewayCredentialResolutionStatus.FallbackUsed + : GatewayCredentialResolutionStatus.Resolved, + FallbackUsed = fallbackUsed, + ResolutionDetail = detail + }; + return new GatewayCredentialResolution( + credential, + fallbackUsed + ? GatewayCredentialResolutionStatus.FallbackUsed + : GatewayCredentialResolutionStatus.Resolved, + FallbackUsed: fallbackUsed, + BootstrapRequired: false, + Detail: detail, + PrimaryStatus: primaryStatus); + } - // 1. Paired node token — highest priority - var storedToken = _identityReader.TryReadStoredNodeDeviceToken(identityPath); - if (!string.IsNullOrWhiteSpace(storedToken)) - return new GatewayCredential(storedToken!, false, SourceNodeDeviceToken); + if (!string.IsNullOrWhiteSpace(bootstrapToken)) + { + var fallbackUsed = storedToken.Status is DeviceTokenReadStatus.Unreadable or DeviceTokenReadStatus.Corrupt; + var detail = fallbackUsed + ? BuildFallbackDetail(deviceTokenSource, primaryStatus, SourceBootstrapToken, storedToken.Detail) + : "Using bootstrap token; pairing is required before a durable device token is available."; + var credential = new GatewayCredential(bootstrapToken!, true, SourceBootstrapToken) + { + ResolutionStatus = GatewayCredentialResolutionStatus.BootstrapRequired, + FallbackUsed = fallbackUsed, + ResolutionDetail = detail + }; + return new GatewayCredentialResolution( + credential, + GatewayCredentialResolutionStatus.BootstrapRequired, + FallbackUsed: fallbackUsed, + BootstrapRequired: true, + Detail: detail, + PrimaryStatus: primaryStatus); + } - // 2. Shared gateway token - if (!string.IsNullOrWhiteSpace(record.SharedGatewayToken)) - return new GatewayCredential(record.SharedGatewayToken!, false, SourceSharedGatewayToken); + var missingDetail = storedToken.Status switch + { + DeviceTokenReadStatus.Unreadable => $"Stored {deviceTokenSource} is unreadable and no shared/bootstrap fallback is available. {storedToken.Detail}", + DeviceTokenReadStatus.Corrupt => $"Stored {deviceTokenSource} is corrupt and no shared/bootstrap fallback is available. {storedToken.Detail}", + _ => "No device, shared, or bootstrap credential is available." + }; + return new GatewayCredentialResolution( + null, + primaryStatus == GatewayCredentialResolutionStatus.Resolved + ? GatewayCredentialResolutionStatus.Missing + : primaryStatus, + Detail: missingDetail, + PrimaryStatus: primaryStatus); + } - // 3. Bootstrap token - if (!string.IsNullOrWhiteSpace(record.BootstrapToken)) - return new GatewayCredential(record.BootstrapToken!, true, SourceBootstrapToken); + private static GatewayCredentialResolutionStatus MapPrimaryStatus(DeviceTokenReadStatus status) => + status switch + { + DeviceTokenReadStatus.Resolved => GatewayCredentialResolutionStatus.Resolved, + DeviceTokenReadStatus.Unreadable => GatewayCredentialResolutionStatus.Unreadable, + DeviceTokenReadStatus.Corrupt => GatewayCredentialResolutionStatus.Corrupt, + _ => GatewayCredentialResolutionStatus.Missing + }; - return null; + private static string BuildFallbackDetail( + string primarySource, + GatewayCredentialResolutionStatus primaryStatus, + string fallbackSource, + string? readDetail) + { + var reason = primaryStatus switch + { + GatewayCredentialResolutionStatus.Unreadable => $"{primarySource} is unreadable", + GatewayCredentialResolutionStatus.Corrupt => $"{primarySource} is corrupt", + _ => $"{primarySource} is missing" + }; + return string.IsNullOrWhiteSpace(readDetail) + ? $"{reason}; using {fallbackSource}." + : $"{reason}; using {fallbackSource}. {readDetail}"; } } diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs index 98e415d6b..ea4d5d419 100644 --- a/src/OpenClaw.Connection/GatewayConnectionManager.cs +++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs @@ -195,19 +195,29 @@ private async Task ConnectCoreAsync(string? gatewayId = null) if (!Directory.Exists(perGatewayIdentityDir)) Directory.CreateDirectory(perGatewayIdentityDir); - var credential = _credentialResolver.ResolveOperator(record, perGatewayIdentityDir); + var credentialResolution = _credentialResolver.ResolveOperatorDetailed(record, perGatewayIdentityDir); + var credential = credentialResolution.Credential; if (_forceBootstrapForGatewayRecordId == record.Id && !string.IsNullOrWhiteSpace(record.BootstrapToken)) { credential = new GatewayCredential( record.BootstrapToken!, IsBootstrapToken: true, - CredentialResolver.SourceBootstrapToken); + CredentialResolver.SourceBootstrapToken) + { + ResolutionStatus = GatewayCredentialResolutionStatus.BootstrapRequired, + ResolutionDetail = "Using setup-code bootstrap token for this connection." + }; + credentialResolution = new GatewayCredentialResolution( + credential, + GatewayCredentialResolutionStatus.BootstrapRequired, + BootstrapRequired: true, + Detail: credential.ResolutionDetail); _forceBootstrapForGatewayRecordId = null; } _activeConnectUsedBootstrapToken = credential?.IsBootstrapToken == true; _postBootstrapOperatorReconnectScheduled = false; - _diagnostics.RecordCredentialResolution(credential); + _diagnostics.RecordCredentialResolutionResult(credentialResolution); _activeIdentityPath = perGatewayIdentityDir; _activeGatewayRecordId = record.Id; _activeSshTunnel = record.SshTunnel; @@ -219,8 +229,10 @@ private async Task ConnectCoreAsync(string? gatewayId = null) _logger.Warn("[ConnMgr] No credential available for gateway"); // Must go through Connecting → Error since AuthenticationFailed requires Connecting state _stateMachine.TryTransition(ConnectionTrigger.ConnectRequested); - _stateMachine.SetOperatorCredentialSource(null); - _stateMachine.TryTransition(ConnectionTrigger.AuthenticationFailed, "No credential available"); + _stateMachine.SetOperatorCredentialResolution(credentialResolution); + _stateMachine.TryTransition( + ConnectionTrigger.AuthenticationFailed, + BuildCredentialFailureMessage("operator", credentialResolution)); EmitStateChanged(); return; } @@ -228,7 +240,7 @@ private async Task ConnectCoreAsync(string? gatewayId = null) // Transition to Connecting var prevState = _stateMachine.Current.OverallState; _stateMachine.TryTransition(ConnectionTrigger.ConnectRequested); - _stateMachine.SetOperatorCredentialSource(credential.Source); + _stateMachine.SetOperatorCredentialResolution(credentialResolution); _diagnostics.RecordStateChange(prevState, _stateMachine.Current.OverallState); EmitStateChanged(); @@ -277,41 +289,40 @@ tunnel.SshPort is < 1 or > 65535 || NewClient = lifecycle.DataClient }); - // Subscribe to client events with generation guard + // Subscribe to client events with generation and gateway guards. + var subscribedGatewayId = record.Id; lifecycle.StatusChanged += (s, status) => { - if (Interlocked.Read(ref _generation) != gen) return; + if (!IsCurrentGatewayAttempt(gen, subscribedGatewayId)) return; _ = HandleOperatorStatusChangedAsync(status, gen); }; lifecycle.AuthenticationFailed += (s, msg) => { - if (Interlocked.Read(ref _generation) != gen) return; + if (!IsCurrentGatewayAttempt(gen, subscribedGatewayId)) return; _ = HandleAuthenticationFailedAsync(msg, gen); }; lifecycle.DataClient.HandshakeSucceeded += (s, e) => { - if (Interlocked.Read(ref _generation) != gen) return; + if (!IsCurrentGatewayAttempt(gen, subscribedGatewayId)) return; _ = HandleHandshakeSucceededAsync(gen); }; lifecycle.DataClient.DeviceTokenReceived += (s, e) => { - if (Interlocked.Read(ref _generation) != gen) return; - HandleDeviceTokenReceived(e); + _ = HandleDeviceTokenReceivedAsync(e, gen, subscribedGatewayId, perGatewayIdentityDir); }; lifecycle.DataClient.PairingRequired += (s, requestId) => { - if (Interlocked.Read(ref _generation) != gen) return; + if (!IsCurrentGatewayAttempt(gen, subscribedGatewayId)) return; _ = HandlePairingRequiredAsync(requestId, gen); }; lifecycle.DataClient.NodePairListUpdated += (s, list) => { - if (Interlocked.Read(ref _generation) != gen) return; + if (!IsCurrentGatewayAttempt(gen, subscribedGatewayId)) return; _ = HandleNodePairListUpdatedAsync(list, gen); }; - lifecycle.DataClient.V2SignatureFallback += (s, _) => + lifecycle.DataClient.V2SignatureFallback += (s, e) => { - if (Interlocked.Read(ref _generation) != gen) return; - RememberGatewayNeedsV2Signature(record.Id); + _ = HandleV2SignatureFallbackAsync(gen, subscribedGatewayId); }; // Local gateways only support v2 signatures — skip the v3 attempt entirely @@ -372,9 +383,6 @@ tunnel.SshPort is < 1 or > 65535 || string.Equals(_activeGatewayRecordId, record.Id, StringComparison.Ordinal) && string.Equals(_stateMachine.Current.GatewayUrl, record.Url, StringComparison.Ordinal) && Equals(_activeSshTunnel, record.SshTunnel); - var operatorCredentialSource = preservesOperatorConnection - ? _stateMachine.Current.OperatorCredentialSource - : null; var gen = Interlocked.Read(ref _generation); if (!preservesOperatorConnection) { @@ -400,24 +408,30 @@ tunnel.SshPort is < 1 or > 65535 || _stateMachine.StartNodeConnecting(); _stateMachine.SetNodeCredentialSource(null); - var nodeCredential = _credentialResolver.ResolveNode(record, perGatewayIdentityDir); + var nodeCredentialResolution = _credentialResolver.ResolveNodeDetailed(record, perGatewayIdentityDir); + var nodeCredential = nodeCredentialResolution.Credential; if (nodeCredential == null) { _logger.Warn("[ConnMgr] No node credential available for node-only connect"); - _diagnostics.Record("node", "No node credential available for node-only connect"); - _stateMachine.BlockNodeStart(MissingNodeCredentialMessage); + _diagnostics.RecordCredentialResolutionResult(nodeCredentialResolution); + _stateMachine.SetNodeCredentialResolution(nodeCredentialResolution); + _stateMachine.BlockNodeStart( + BuildCredentialFailureMessage("node", nodeCredentialResolution), + preserveCredentialResolution: true); EmitStateChanged(); return null; } - _diagnostics.RecordCredentialResolution(nodeCredential); - _stateMachine.SetOperatorCredentialSource(operatorCredentialSource); + _diagnostics.RecordCredentialResolutionResult(nodeCredentialResolution); + if (!preservesOperatorConnection) + _stateMachine.SetOperatorCredentialSource(null); _diagnostics.Record("node", $"Starting node-only connection to {record.Url}", $"Credential source: {nodeCredential.Source}"); if (!preservesOperatorConnection && !await TryStartTunnelForNodeOnlyAsync(record)) { - _stateMachine.BlockNodeStart(NodeTunnelStartFailedMessage); + _stateMachine.SetNodeCredentialResolution(nodeCredentialResolution); + _stateMachine.BlockNodeStart(NodeTunnelStartFailedMessage, preserveCredentialResolution: true); EmitStateChanged(); return null; } @@ -512,6 +526,28 @@ public async Task SwitchGatewayAsync(string gatewayId) await _transitionSemaphore.WaitAsync(); try { + if (_registry.GetById(gatewayId) == null) + { + _logger.Warn($"[ConnMgr] Cannot switch gateway — record {gatewayId} not found"); + _diagnostics.Record("state", "Switch gateway failed", $"Gateway record not found: {gatewayId}"); + return; + } + + var previousActiveId = _registry.ActiveGatewayId; + _diagnostics.Record("state", $"Switching active gateway to {gatewayId}"); + _registry.SetActive(gatewayId); + try + { + _registry.Save(); + } + catch (Exception ex) + { + _registry.SetActive(previousActiveId); + _logger.Warn($"[ConnMgr] Failed to persist active gateway switch: {ex.Message}"); + _diagnostics.Record("state", "Switch gateway failed", $"Could not persist active gateway: {ex.Message}"); + return; + } + await DisconnectCoreAsync(); // Stop tunnel when switching gateways — the new one may not need it. // Use a bounded timeout to avoid blocking all connection transitions. @@ -525,8 +561,6 @@ public async Task SwitchGatewayAsync(string gatewayId) } catch (Exception ex) { _logger.Warn($"[ConnMgr] Tunnel stop error on gateway switch: {ex.Message}"); } } - _gatewayNeedsV2Signature = false; // new gateway might support v3 - _registry.SetActive(gatewayId); await ConnectCoreAsync(gatewayId); } finally @@ -550,47 +584,65 @@ public async Task ApplySetupCodeAsync(string setupCode, SshTunn if (!GatewayUrlHelper.IsValidGatewayUrl(gatewayUrl)) return new SetupCodeResult(SetupCodeOutcome.InvalidUrl, "Invalid gateway URL"); - // 3. Disconnect current gateway if any - await DisconnectAsync(); - - var existing = _registry.FindByUrl(gatewayUrl); + await _transitionSemaphore.WaitAsync(); + try + { + var existing = _registry.FindByUrl(gatewayUrl); - // New gateway URL → reset v2 signature flag (new gateway might support v3) - var isNewGateway = existing == null; - if (isNewGateway) - _gatewayNeedsV2Signature = false; + // 4. Create or update gateway record + var recordId = existing?.Id ?? Guid.NewGuid().ToString(); - // 4. Create or update gateway record - var recordId = existing?.Id ?? Guid.NewGuid().ToString(); + // Setup codes from `openclaw qr` always provide bootstrap tokens. + // Store as BootstrapToken so the credential resolver passes IsBootstrapToken=true, + // causing the client to send auth.bootstrapToken (not auth.token). + var record = (existing ?? new GatewayRecord { Id = recordId }) with + { + Url = gatewayUrl, + SharedGatewayToken = existing?.SharedGatewayToken, // preserve existing shared token if any + BootstrapToken = decoded.Token ?? existing?.BootstrapToken, + SshTunnel = sshTunnel ?? existing?.SshTunnel, + }; + var previousRecord = existing; + var previousActiveId = _registry.ActiveGatewayId; + _registry.AddOrUpdate(record); + _registry.SetActive(recordId); + try + { + _registry.Save(); + } + catch (Exception ex) + { + if (previousRecord == null) + _registry.Remove(recordId); + else + _registry.AddOrUpdate(previousRecord); + _registry.SetActive(previousActiveId); + _logger.Warn($"[ConnMgr] Failed to persist setup-code gateway update: {ex.Message}"); + return new SetupCodeResult(SetupCodeOutcome.ConnectionFailed, ex.Message); + } - // Setup codes from `openclaw qr` always provide bootstrap tokens. - // Store as BootstrapToken so the credential resolver passes IsBootstrapToken=true, - // causing the client to send auth.bootstrapToken (not auth.token). - var record = (existing ?? new GatewayRecord { Id = recordId }) with - { - 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); - _registry.Save(); + // 3. Disconnect current gateway only after the new active gateway is persisted. + await DisconnectCoreAsync(); - // Ensure identity directory - var identityDir = _registry.GetIdentityDirectory(recordId); - if (!Directory.Exists(identityDir)) - Directory.CreateDirectory(identityDir); + // Ensure identity directory + var identityDir = _registry.GetIdentityDirectory(recordId); + if (!Directory.Exists(identityDir)) + Directory.CreateDirectory(identityDir); - // Clear stored device tokens so we start fresh with the bootstrap token. - // The keypair (device ID) stays — only the tokens are wiped. - DeviceIdentityStore.ClearStoredTokens(identityDir, _logger); - _diagnostics.Record("setup", $"Setup code applied for {GatewayUrlHelper.SanitizeForDisplay(gatewayUrl)}"); + // Clear stored device tokens so we start fresh with the bootstrap token. + // The keypair (device ID) stays — only the tokens are wiped. + DeviceIdentityStore.ClearStoredTokens(identityDir, _logger); + _diagnostics.Record("setup", $"Setup code applied for {GatewayUrlHelper.SanitizeForDisplay(gatewayUrl)}"); - // 5. Connect to new gateway - if (!string.IsNullOrWhiteSpace(decoded.Token)) - _forceBootstrapForGatewayRecordId = recordId; - await ConnectAsync(recordId); + // 5. Connect to new gateway + if (!string.IsNullOrWhiteSpace(decoded.Token)) + _forceBootstrapForGatewayRecordId = recordId; + await ConnectCoreAsync(recordId); + } + finally + { + _transitionSemaphore.Release(); + } return new SetupCodeResult(SetupCodeOutcome.Success, GatewayUrl: gatewayUrl); } @@ -603,49 +655,70 @@ public async Task ConnectWithSharedTokenAsync( if (!GatewayUrlHelper.IsValidGatewayUrl(gatewayUrl)) return new SetupCodeResult(SetupCodeOutcome.InvalidUrl, "Invalid gateway URL"); - // Find or create gateway record (dedup by URL) - var existing = _registry.FindByUrl(gatewayUrl); - var recordId = existing?.Id ?? Guid.NewGuid().ToString(); - var identityDir = _registry.GetIdentityDirectory(recordId); - var hasDurableTokens = - DeviceIdentity.HasStoredDeviceTokenForRole(identityDir, "operator", _logger) || - DeviceIdentity.HasStoredDeviceTokenForRole(identityDir, "node", _logger); - - if (existing != null && hasDurableTokens) + try { - var validation = await ValidateSharedTokenBeforeReplacementAsync( - gatewayUrl, - token, - identityDir, - existing); - if (validation.Outcome != SetupCodeOutcome.Success) - return validation; - } - - // Disconnect current gateway only after replacement credentials have been validated. - await DisconnectAsync(); + await _transitionSemaphore.WaitAsync(); + try + { + var existing = _registry.FindByUrl(gatewayUrl); + var recordId = existing?.Id ?? Guid.NewGuid().ToString(); + var identityDir = _registry.GetIdentityDirectory(recordId); + var hasDurableTokens = + DeviceIdentity.HasStoredDeviceTokenForRole(identityDir, "operator", _logger) || + DeviceIdentity.HasStoredDeviceTokenForRole(identityDir, "node", _logger); + + if (existing != null && hasDurableTokens) + { + var validation = await ValidateSharedTokenBeforeReplacementAsync( + gatewayUrl, + token, + identityDir, + existing); + if (validation.Outcome != SetupCodeOutcome.Success) + return validation; + } - var record = (existing ?? new GatewayRecord { Id = recordId }) with - { - Url = gatewayUrl, - SharedGatewayToken = token, - BootstrapToken = null, - SshTunnel = sshTunnel, - }; - _registry.AddOrUpdate(record); + var record = (existing ?? new GatewayRecord { Id = recordId }) with + { + Url = gatewayUrl, + SharedGatewayToken = token, + BootstrapToken = null, + SshTunnel = sshTunnel, + }; + var previousRecord = existing; + var previousActiveId = _registry.ActiveGatewayId; + _registry.AddOrUpdate(record); + _registry.SetActive(recordId); + try + { + _registry.Save(); + } + catch (Exception ex) + { + if (previousRecord == null) + _registry.Remove(recordId); + else + _registry.AddOrUpdate(previousRecord); + _registry.SetActive(previousActiveId); + _logger.Warn($"[ConnMgr] Failed to persist shared-token gateway update: {ex.Message}"); + return new SetupCodeResult(SetupCodeOutcome.ConnectionFailed, ex.Message); + } - // Clear stored device tokens so the shared token is used - if (!Directory.Exists(identityDir)) - Directory.CreateDirectory(identityDir); - DeviceIdentityStore.ClearStoredTokens(identityDir, _logger); + // Disconnect current gateway only after replacement credentials have been validated and persisted. + await DisconnectCoreAsync(); - _registry.SetActive(recordId); - _registry.Save(); + // Clear stored device tokens so the shared token is used. + if (!Directory.Exists(identityDir)) + Directory.CreateDirectory(identityDir); + DeviceIdentityStore.ClearStoredTokens(identityDir, _logger); - // Connect to the gateway - try - { - await ConnectAsync(recordId); + // Connect to the gateway + await ConnectCoreAsync(recordId); + } + finally + { + _transitionSemaphore.Release(); + } return new SetupCodeResult(SetupCodeOutcome.Success, GatewayUrl: gatewayUrl); } catch (Exception ex) @@ -897,39 +970,61 @@ private async Task HandleHandshakeSucceededAsync(long gen) } } - private void HandleDeviceTokenReceived(DeviceTokenReceivedEventArgs e) + private async Task HandleDeviceTokenReceivedAsync( + DeviceTokenReceivedEventArgs e, + long gen, + string gatewayRecordId, + string identityPath) { - _diagnostics.Record("credential", $"Device token received for {e.Role}", - $"Scopes={string.Join(",", e.Scopes ?? [])}"); - - if (_identityStore != null && _activeIdentityPath != null) + await _transitionSemaphore.WaitAsync(); + try { - try - { - _identityStore.StoreToken(_activeIdentityPath, e.Token, e.Scopes, e.Role); - _logger.Info($"[ConnMgr] Persisted {e.Role} device token via identity store"); - } - catch (Exception ex) + if (!IsCurrentGatewayAttempt(gen, gatewayRecordId)) + return; + + _diagnostics.Record("credential", $"Device token received for {e.Role}", + $"Scopes={string.Join(",", e.Scopes ?? [])}"); + + if (_identityStore != null) { - _logger.Warn($"[ConnMgr] Failed to persist {e.Role} device token: {ex.Message}"); + try + { + _identityStore.StoreToken(identityPath, e.Token, e.Scopes, e.Role); + _logger.Info($"[ConnMgr] Persisted {e.Role} device token via identity store"); + } + catch (Exception ex) + { + _logger.Warn($"[ConnMgr] Failed to persist {e.Role} device token: {ex.Message}"); + } } - } - TryClearBootstrapTokenAfterDurablePairing(); - TrySchedulePostBootstrapOperatorReconnect(e); + TryClearBootstrapTokenAfterDurablePairing(gatewayRecordId, identityPath); + TrySchedulePostBootstrapOperatorReconnect(e, gen, gatewayRecordId, identityPath); + } + finally + { + _transitionSemaphore.Release(); + } } private void TryClearBootstrapTokenAfterDurablePairing() { - if (_activeGatewayRecordId == null || _activeIdentityPath == null) + var activeGatewayRecordId = _activeGatewayRecordId; + var activeIdentityPath = _activeIdentityPath; + if (activeGatewayRecordId == null || activeIdentityPath == null) return; - var record = _registry.GetById(_activeGatewayRecordId); + TryClearBootstrapTokenAfterDurablePairing(activeGatewayRecordId, activeIdentityPath); + } + + private void TryClearBootstrapTokenAfterDurablePairing(string gatewayRecordId, string identityPath) + { + var record = _registry.GetById(gatewayRecordId); if (record?.BootstrapToken == null) return; - var hasOperatorToken = DeviceIdentity.HasStoredDeviceTokenForRole(_activeIdentityPath, "operator", _logger); - var hasNodeToken = DeviceIdentity.HasStoredDeviceTokenForRole(_activeIdentityPath, "node", _logger); + var hasOperatorToken = DeviceIdentity.HasStoredDeviceTokenForRole(identityPath, "operator", _logger); + var hasNodeToken = DeviceIdentity.HasStoredDeviceTokenForRole(identityPath, "node", _logger); if (!hasOperatorToken || !hasNodeToken) { _diagnostics.Record( @@ -939,24 +1034,38 @@ private void TryClearBootstrapTokenAfterDurablePairing() return; } - _registry.AddOrUpdate(record with { BootstrapToken = null }); - _registry.Save(); - _diagnostics.Record("credential", "Cleared bootstrap token — operator and node tokens are durable"); + var updated = _registry.Update(gatewayRecordId, r => r with { BootstrapToken = null }); + if (updated == null) + return; + + try + { + _registry.Save(); + _diagnostics.Record("credential", "Cleared bootstrap token — operator and node tokens are durable"); + } + catch (Exception ex) + { + _logger.Warn($"[ConnMgr] Failed to persist cleared bootstrap token: {ex.Message}"); + _diagnostics.Record("credential", "Failed to persist cleared bootstrap token", ex.Message); + } } - private void TrySchedulePostBootstrapOperatorReconnect(DeviceTokenReceivedEventArgs e) + private void TrySchedulePostBootstrapOperatorReconnect( + DeviceTokenReceivedEventArgs e, + long gen, + string gatewayRecordId, + string identityPath) { - if (!_activeConnectUsedBootstrapToken || - _postBootstrapOperatorReconnectScheduled || - _activeIdentityPath == null || - _activeGatewayRecordId == null) + if (!IsCurrentGatewayAttempt(gen, gatewayRecordId) || + !_activeConnectUsedBootstrapToken || + _postBootstrapOperatorReconnectScheduled) { return; } var hasOperatorToken = !string.IsNullOrWhiteSpace( - DeviceIdentity.TryReadStoredDeviceTokenForRole(_activeIdentityPath, "operator", _logger)); - var record = _registry.GetById(_activeGatewayRecordId); + DeviceIdentity.TryReadStoredDeviceTokenForRole(identityPath, "operator", _logger)); + var record = _registry.GetById(gatewayRecordId); var canReconnectWithSharedToken = !string.IsNullOrWhiteSpace(record?.SharedGatewayToken); if (!hasOperatorToken && !canReconnectWithSharedToken) @@ -966,15 +1075,14 @@ private void TrySchedulePostBootstrapOperatorReconnect(DeviceTokenReceivedEventA return; _postBootstrapOperatorReconnectScheduled = true; - var reconnectGeneration = Interlocked.Read(ref _generation); var detail = hasOperatorToken ? "using persisted operator device token" : "using preserved shared gateway token"; - RememberGatewayNeedsV2Signature(_activeGatewayRecordId); + RememberGatewayNeedsV2Signature(gatewayRecordId, markActiveAttempt: true); _diagnostics.Record("credential", "Bootstrap handoff complete — reconnecting operator role", detail); ScheduleDelayedReconnect( - reconnectGeneration, + gen, "[ConnMgr] Post-bootstrap operator reconnect failed", ex => _diagnostics.Record("credential", "Post-bootstrap operator reconnect failed", ex.Message)); } @@ -1004,9 +1112,25 @@ private void ScheduleDelayedReconnect( }); } - private void RememberGatewayNeedsV2Signature(string? gatewayRecordId) + private async Task HandleV2SignatureFallbackAsync(long gen, string gatewayRecordId) + { + await _transitionSemaphore.WaitAsync(); + try + { + RememberGatewayNeedsV2Signature( + gatewayRecordId, + markActiveAttempt: IsCurrentGatewayAttempt(gen, gatewayRecordId)); + } + finally + { + _transitionSemaphore.Release(); + } + } + + private void RememberGatewayNeedsV2Signature(string? gatewayRecordId, bool markActiveAttempt = true) { - _gatewayNeedsV2Signature = true; + if (markActiveAttempt) + _gatewayNeedsV2Signature = true; if (string.IsNullOrWhiteSpace(gatewayRecordId)) return; @@ -1281,6 +1405,33 @@ private bool IsExpectedNodeStartCurrent( (!expectedNodeGeneration.HasValue || Interlocked.Read(ref _nodeConnectionGeneration) == expectedNodeGeneration.Value); + private bool IsCurrentGatewayAttempt(long expectedGeneration, string expectedGatewayId) => + !_disposed && + Interlocked.Read(ref _generation) == expectedGeneration && + string.Equals(_activeGatewayRecordId, expectedGatewayId, StringComparison.Ordinal); + + private static string BuildCredentialFailureMessage(string role, GatewayCredentialResolution resolution) + { + var prefix = role.Equals("node", StringComparison.OrdinalIgnoreCase) + ? "No node credential available" + : "No operator credential available"; + return resolution.Status switch + { + GatewayCredentialResolutionStatus.Corrupt => + $"{prefix}: stored device token is corrupt. Re-pair this PC or add a shared/bootstrap gateway token.", + GatewayCredentialResolutionStatus.Unreadable => + $"{prefix}: stored device token is unreadable. Check file permissions, re-pair this PC, or add a shared/bootstrap gateway token.", + GatewayCredentialResolutionStatus.Missing => role.Equals("node", StringComparison.OrdinalIgnoreCase) + ? MissingNodeCredentialMessage + : $"{prefix}. Add a shared/bootstrap gateway token or re-pair this PC.", + _ when !string.IsNullOrWhiteSpace(resolution.Detail) => + $"{prefix}. {resolution.Detail}", + _ => role.Equals("node", StringComparison.OrdinalIgnoreCase) + ? MissingNodeCredentialMessage + : $"{prefix}. Add a shared/bootstrap gateway token or re-pair this PC." + }; + } + private async Task BlockNodeStartAsync( string detail, CancellationToken cancellationToken, @@ -1340,13 +1491,18 @@ private async Task StartNodeConnectionCoreAsync( return false; } - if (_activeGatewayRecordId == null || _activeIdentityPath == null) + if (!IsExpectedNodeStartCurrent(expectedLifecycleGeneration, nodeGeneration)) + return false; + + var activeGatewayRecordId = _activeGatewayRecordId; + var activeIdentityPath = _activeIdentityPath; + if (activeGatewayRecordId == null || activeIdentityPath == null) { await BlockNodeStartAsync(MissingActiveGatewayForNodeMessage, cancellationToken, expectedLifecycleGeneration, nodeGeneration); return false; } - var record = _registry.GetById(_activeGatewayRecordId); + var record = _registry.GetById(activeGatewayRecordId); if (record == null) { _logger.Warn("[ConnMgr] Cannot start node — gateway record not found"); @@ -1375,13 +1531,28 @@ private async Task StartNodeConnectionCoreAsync( _transitionSemaphore.Release(); } - // Use root identity path — clients always read/write from root, not per-gateway - var nodeCredential = _credentialResolver.ResolveNode(record, _activeIdentityPath!); + var nodeCredentialResolution = _credentialResolver.ResolveNodeDetailed(record, activeIdentityPath); + var nodeCredential = nodeCredentialResolution.Credential; if (nodeCredential == null) { _logger.Warn("[ConnMgr] No node credential available — skipping node connection"); - _diagnostics.Record("node", "No node credential available"); - await BlockNodeStartAsync(MissingNodeCredentialMessage, cancellationToken, expectedLifecycleGeneration, nodeGeneration); + _diagnostics.RecordCredentialResolutionResult(nodeCredentialResolution); + await _transitionSemaphore.WaitAsync(cancellationToken); + try + { + if (!IsExpectedNodeStartCurrent(expectedLifecycleGeneration, nodeGeneration)) + return false; + + _stateMachine.SetNodeCredentialResolution(nodeCredentialResolution); + _stateMachine.BlockNodeStart( + BuildCredentialFailureMessage("node", nodeCredentialResolution), + preserveCredentialResolution: true); + EmitStateChanged(); + } + finally + { + _transitionSemaphore.Release(); + } return false; } @@ -1392,6 +1563,7 @@ private async Task StartNodeConnectionCoreAsync( return false; _stateMachine.SetNodeCredentialSource(nodeCredential.Source); + _stateMachine.SetNodeCredentialResolution(nodeCredentialResolution); } finally { @@ -1413,7 +1585,7 @@ private async Task StartNodeConnectionCoreAsync( try { - await _nodeConnector.ConnectAsync(nodeConnectUrl, nodeCredential, _activeIdentityPath, + await _nodeConnector.ConnectAsync(nodeConnectUrl, nodeCredential, activeIdentityPath, useV2Signature: _gatewayNeedsV2Signature, cancellationToken: cancellationToken); } diff --git a/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs b/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs index b06cec57d..f793bd618 100644 --- a/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs +++ b/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs @@ -15,6 +15,10 @@ public sealed record GatewayConnectionSnapshot public bool OperatorPairingRequired { get; init; } public string? OperatorDeviceId { get; init; } public string? OperatorCredentialSource { get; init; } + public GatewayCredentialResolutionStatus? OperatorCredentialStatus { get; init; } + public bool OperatorCredentialFallbackUsed { get; init; } + public bool OperatorCredentialBootstrapRequired { get; init; } + public string? OperatorCredentialDetail { get; init; } /// /// The requestId returned by the gateway when operator pairing is required. /// Used by setup flows to approve the specific pairing request via CLI. @@ -28,6 +32,10 @@ public sealed record GatewayConnectionSnapshot public OpenClaw.Shared.PairingStatus NodePairingStatus { get; init; } public string? NodeDeviceId { get; init; } public string? NodeCredentialSource { get; init; } + public GatewayCredentialResolutionStatus? NodeCredentialStatus { get; init; } + public bool NodeCredentialFallbackUsed { get; init; } + public bool NodeCredentialBootstrapRequired { get; init; } + public string? NodeCredentialDetail { get; init; } /// /// The requestId returned by the gateway when node pairing is required. /// Used by the connection page to show the correct approval command. diff --git a/src/OpenClaw.Connection/GatewayCredentialResolution.cs b/src/OpenClaw.Connection/GatewayCredentialResolution.cs new file mode 100644 index 000000000..76967cc4d --- /dev/null +++ b/src/OpenClaw.Connection/GatewayCredentialResolution.cs @@ -0,0 +1,31 @@ +namespace OpenClaw.Connection; + +public enum GatewayCredentialResolutionStatus +{ + Resolved, + Missing, + Unreadable, + Corrupt, + FallbackUsed, + BootstrapRequired +} + +public sealed record GatewayCredentialResolution( + GatewayCredential? Credential, + GatewayCredentialResolutionStatus Status, + bool FallbackUsed = false, + bool BootstrapRequired = false, + string? Detail = null, + GatewayCredentialResolutionStatus? PrimaryStatus = null) +{ + public static GatewayCredentialResolution FromLegacy(GatewayCredential? credential) => + credential is null + ? new(null, GatewayCredentialResolutionStatus.Missing) + : new( + credential, + credential.IsBootstrapToken + ? GatewayCredentialResolutionStatus.BootstrapRequired + : GatewayCredentialResolutionStatus.Resolved, + FallbackUsed: false, + BootstrapRequired: credential.IsBootstrapToken); +} diff --git a/src/OpenClaw.Connection/GatewayRegistry.cs b/src/OpenClaw.Connection/GatewayRegistry.cs index 8e42eaa4b..38e99caed 100644 --- a/src/OpenClaw.Connection/GatewayRegistry.cs +++ b/src/OpenClaw.Connection/GatewayRegistry.cs @@ -74,6 +74,7 @@ public string GetIdentityDirectory(string gatewayId) public GatewayRecord AddOrUpdate(GatewayRecord record) { List snapshot; + string? activeId; lock (_lock) { var idx = _records.FindIndex(r => r.Id == record.Id); @@ -82,26 +83,35 @@ public GatewayRecord AddOrUpdate(GatewayRecord record) else _records.Add(record); snapshot = _records.ToList(); + activeId = _activeId; } - Changed?.Invoke(this, new GatewayRegistryChangedEventArgs(snapshot)); + Changed?.Invoke(this, new GatewayRegistryChangedEventArgs(snapshot, activeId)); return record; } public void Remove(string id) { List snapshot; + string? activeId; lock (_lock) { _records.RemoveAll(r => r.Id == id); if (_activeId == id) _activeId = null; snapshot = _records.ToList(); + activeId = _activeId; } - Changed?.Invoke(this, new GatewayRegistryChangedEventArgs(snapshot)); + Changed?.Invoke(this, new GatewayRegistryChangedEventArgs(snapshot, activeId)); } - public void SetActive(string gatewayId) + public void SetActive(string? gatewayId) { - lock (_lock) _activeId = gatewayId; + List snapshot; + lock (_lock) + { + _activeId = gatewayId; + snapshot = _records.ToList(); + } + Changed?.Invoke(this, new GatewayRegistryChangedEventArgs(snapshot, gatewayId)); } /// @@ -114,6 +124,7 @@ public void SetActive(string gatewayId) { GatewayRecord? updated; List snapshot; + string? activeId; lock (_lock) { var idx = _records.FindIndex(r => r.Id == id); @@ -122,8 +133,9 @@ public void SetActive(string gatewayId) ArgumentNullException.ThrowIfNull(updated, nameof(updater)); _records[idx] = updated; snapshot = _records.ToList(); + activeId = _activeId; } - Changed?.Invoke(this, new GatewayRegistryChangedEventArgs(snapshot)); + Changed?.Invoke(this, new GatewayRegistryChangedEventArgs(snapshot, activeId)); return updated; } @@ -131,22 +143,42 @@ public void SetActive(string gatewayId) public void Save() { - RegistryData data; lock (_lock) { - data = new RegistryData { Gateways = _records.ToList(), ActiveId = _activeId }; - } - var json = JsonSerializer.Serialize(data, s_jsonOptions); + var data = new RegistryData { Gateways = _records.ToList(), ActiveId = _activeId }; + var json = JsonSerializer.Serialize(data, s_jsonOptions); + + var dir = Path.GetDirectoryName(_filePath); + if (dir != null && !_fs.DirectoryExists(dir)) + _fs.CreateDirectory(dir); - var dir = Path.GetDirectoryName(_filePath); - if (dir != null && !_fs.DirectoryExists(dir)) - _fs.CreateDirectory(dir); + // Atomic write: unique temp file then rename. Keep the registry lock + // through the move so concurrent saves cannot publish stale snapshots. + var tempPath = $"{_filePath}.{Guid.NewGuid():N}.tmp"; + try + { + _fs.WriteAllText(tempPath, json); + File.Move(tempPath, _filePath, overwrite: true); + } + catch + { + TryDeleteTempFile(tempPath); + throw; + } + } + } - // Atomic write: temp file then rename - var tempPath = _filePath + ".tmp"; - _fs.WriteAllText(tempPath, json); - // On Windows, File.Move with overwrite works as atomic rename - File.Move(tempPath, _filePath, overwrite: true); + private void TryDeleteTempFile(string tempPath) + { + try + { + if (_fs.FileExists(tempPath)) + _fs.DeleteFile(tempPath); + } + catch (Exception ex) + { + _logger.Warn($"Failed to delete temporary gateway registry file '{tempPath}': {ex.Message}"); + } } public void Load() @@ -378,8 +410,10 @@ private sealed class RegistryData public sealed class GatewayRegistryChangedEventArgs : EventArgs { public IReadOnlyList Records { get; } - public GatewayRegistryChangedEventArgs(IReadOnlyList records) + public string? ActiveGatewayId { get; } + public GatewayRegistryChangedEventArgs(IReadOnlyList records, string? activeGatewayId = null) { Records = records; + ActiveGatewayId = activeGatewayId; } } diff --git a/src/OpenClaw.Connection/ICredentialResolver.cs b/src/OpenClaw.Connection/ICredentialResolver.cs index c8c56b320..a2c045792 100644 --- a/src/OpenClaw.Connection/ICredentialResolver.cs +++ b/src/OpenClaw.Connection/ICredentialResolver.cs @@ -5,7 +5,13 @@ namespace OpenClaw.Connection; /// /// Resolved gateway credential plus metadata about which source was used. /// -public sealed record GatewayCredential(string Token, bool IsBootstrapToken, string Source); +public sealed record GatewayCredential(string Token, bool IsBootstrapToken, string Source) +{ + public GatewayCredentialResolutionStatus ResolutionStatus { get; init; } = + GatewayCredentialResolutionStatus.Resolved; + public bool FallbackUsed { get; init; } + public string? ResolutionDetail { get; init; } +} /// /// Resolves the best activation credential for connecting to a gateway. @@ -19,9 +25,21 @@ public interface ICredentialResolver /// GatewayCredential? ResolveOperator(GatewayRecord record, string identityPath); + /// + /// Resolve the operator credential and preserve missing/corrupt/fallback status. + /// + GatewayCredentialResolution ResolveOperatorDetailed(GatewayRecord record, string identityPath) => + GatewayCredentialResolution.FromLegacy(ResolveOperator(record, identityPath)); + /// /// Resolve the best node activation credential for the given gateway record. /// Returns null if no credential is available. /// GatewayCredential? ResolveNode(GatewayRecord record, string identityPath); + + /// + /// Resolve the node credential and preserve missing/corrupt/fallback status. + /// + GatewayCredentialResolution ResolveNodeDetailed(GatewayRecord record, string identityPath) => + GatewayCredentialResolution.FromLegacy(ResolveNode(record, identityPath)); } diff --git a/src/OpenClaw.Shared/DeviceIdentity.cs b/src/OpenClaw.Shared/DeviceIdentity.cs index bcb24cd7a..cb5d51fe3 100644 --- a/src/OpenClaw.Shared/DeviceIdentity.cs +++ b/src/OpenClaw.Shared/DeviceIdentity.cs @@ -33,20 +33,32 @@ public class DeviceIdentity public IReadOnlyList? NodeDeviceTokenScopes => _nodeDeviceTokenScopes; public static string? TryReadStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null) => - TryReadStoredDeviceTokenForRole(dataPath, "operator", logger); + ReadStoredDeviceToken(dataPath, logger).Token; - public static string? TryReadStoredDeviceTokenForRole(string dataPath, string role, IOpenClawLogger? logger = null) + public static DeviceTokenReadResult ReadStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null) => + ReadStoredDeviceTokenForRole(dataPath, "operator", logger); + + public static string? TryReadStoredDeviceTokenForRole(string dataPath, string role, IOpenClawLogger? logger = null) => + ReadStoredDeviceTokenForRole(dataPath, role, logger).Token; + + public static DeviceTokenReadResult ReadStoredDeviceTokenForRole(string dataPath, string role, IOpenClawLogger? logger = null) { var tokenRole = ParseDeviceTokenRole(role); var keyPath = Path.Combine(dataPath, "device-key-ed25519.json"); if (!File.Exists(keyPath)) { - return null; + return DeviceTokenReadResult.Missing("Identity file is missing."); } try { using var doc = JsonDocument.Parse(File.ReadAllText(keyPath)); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + logger?.Warn("Failed to read stored device token: device-key-ed25519.json root is not a JSON object."); + return DeviceTokenReadResult.Corrupt("Identity file root is not a JSON object."); + } + var tokenPropertyName = tokenRole == DeviceTokenRole.Node ? nameof(DeviceKeyData.NodeDeviceToken) : nameof(DeviceKeyData.DeviceToken); @@ -55,23 +67,33 @@ public class DeviceIdentity deviceToken.ValueKind == JsonValueKind.String) { var value = deviceToken.GetString(); - return string.IsNullOrWhiteSpace(value) ? null : value; + return string.IsNullOrWhiteSpace(value) + ? DeviceTokenReadResult.Missing($"No stored {role} device token.") + : DeviceTokenReadResult.Resolved(value!); } + + return DeviceTokenReadResult.Missing($"No stored {role} device token."); } catch (IOException ex) { logger?.Warn($"Failed to read stored device token: {ex.Message}"); + return DeviceTokenReadResult.Unreadable(ex.Message); } catch (UnauthorizedAccessException ex) { logger?.Warn($"Failed to read stored device token: {ex.Message}"); + return DeviceTokenReadResult.Unreadable(ex.Message); } catch (JsonException ex) { logger?.Warn($"Failed to read stored device token: {ex.Message}"); + return DeviceTokenReadResult.Corrupt(ex.Message); + } + catch (InvalidOperationException ex) + { + logger?.Warn($"Failed to read stored device token: {ex.Message}"); + return DeviceTokenReadResult.Corrupt(ex.Message); } - - return null; } public static bool HasStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null) => diff --git a/src/OpenClaw.Shared/DeviceIdentityFileReader.cs b/src/OpenClaw.Shared/DeviceIdentityFileReader.cs index 4856d37c8..035e0f571 100644 --- a/src/OpenClaw.Shared/DeviceIdentityFileReader.cs +++ b/src/OpenClaw.Shared/DeviceIdentityFileReader.cs @@ -11,6 +11,12 @@ public sealed class DeviceIdentityFileReader : IDeviceIdentityReader public string? TryReadStoredDeviceToken(string dataPath) => DeviceIdentity.TryReadStoredDeviceToken(dataPath); + public DeviceTokenReadResult ReadStoredDeviceToken(string dataPath) => + DeviceIdentity.ReadStoredDeviceToken(dataPath); + public string? TryReadStoredNodeDeviceToken(string dataPath) => DeviceIdentity.TryReadStoredDeviceTokenForRole(dataPath, "node"); + + public DeviceTokenReadResult ReadStoredNodeDeviceToken(string dataPath) => + DeviceIdentity.ReadStoredDeviceTokenForRole(dataPath, "node"); } diff --git a/src/OpenClaw.Shared/DeviceTokenReadResult.cs b/src/OpenClaw.Shared/DeviceTokenReadResult.cs new file mode 100644 index 000000000..f7dbe11f6 --- /dev/null +++ b/src/OpenClaw.Shared/DeviceTokenReadResult.cs @@ -0,0 +1,27 @@ +namespace OpenClaw.Shared; + +public enum DeviceTokenReadStatus +{ + Resolved, + Missing, + Unreadable, + Corrupt +} + +public sealed record DeviceTokenReadResult( + string? Token, + DeviceTokenReadStatus Status, + string? Detail = null) +{ + public static DeviceTokenReadResult Resolved(string token) => + new(token, DeviceTokenReadStatus.Resolved); + + public static DeviceTokenReadResult Missing(string? detail = null) => + new(null, DeviceTokenReadStatus.Missing, detail); + + public static DeviceTokenReadResult Unreadable(string detail) => + new(null, DeviceTokenReadStatus.Unreadable, detail); + + public static DeviceTokenReadResult Corrupt(string detail) => + new(null, DeviceTokenReadStatus.Corrupt, detail); +} diff --git a/src/OpenClaw.Shared/IDeviceIdentityReader.cs b/src/OpenClaw.Shared/IDeviceIdentityReader.cs index 3dfe1b6fc..801e04c69 100644 --- a/src/OpenClaw.Shared/IDeviceIdentityReader.cs +++ b/src/OpenClaw.Shared/IDeviceIdentityReader.cs @@ -12,9 +12,31 @@ public interface IDeviceIdentityReader /// string? TryReadStoredDeviceToken(string dataPath); + /// + /// Read the stored operator device token and preserve why it was unavailable. + /// + DeviceTokenReadResult ReadStoredDeviceToken(string dataPath) + { + var token = TryReadStoredDeviceToken(dataPath); + return string.IsNullOrWhiteSpace(token) + ? DeviceTokenReadResult.Missing() + : DeviceTokenReadResult.Resolved(token!); + } + /// /// Try to read the stored node device token from the identity file at the given path. /// Returns null if no token is stored or the file doesn't exist. /// string? TryReadStoredNodeDeviceToken(string dataPath); + + /// + /// Read the stored node device token and preserve why it was unavailable. + /// + DeviceTokenReadResult ReadStoredNodeDeviceToken(string dataPath) + { + var token = TryReadStoredNodeDeviceToken(dataPath); + return string.IsNullOrWhiteSpace(token) + ? DeviceTokenReadResult.Missing() + : DeviceTokenReadResult.Resolved(token!); + } } diff --git a/src/OpenClaw.Shared/IFileSystem.cs b/src/OpenClaw.Shared/IFileSystem.cs index 286b86589..589fee23e 100644 --- a/src/OpenClaw.Shared/IFileSystem.cs +++ b/src/OpenClaw.Shared/IFileSystem.cs @@ -11,6 +11,7 @@ public interface IFileSystem void CreateDirectory(string path); bool DirectoryExists(string path); void CopyFile(string source, string destination, bool overwrite); + void DeleteFile(string path); } /// @@ -28,4 +29,5 @@ public sealed class RealFileSystem : IFileSystem public bool DirectoryExists(string path) => Directory.Exists(path); public void CopyFile(string source, string destination, bool overwrite) => File.Copy(source, destination, overwrite); + public void DeleteFile(string path) => File.Delete(path); } diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index abbc39cda..2ef92bb36 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -3684,6 +3684,7 @@ private AppStateSnapshot CaptureSnapshot() EffectiveGatewayUrl = activeGateway?.Url ?? _settings?.GatewayUrl, EffectiveBrowserControlPort = activeGateway?.BrowserControlPort, HasActiveGatewayRecord = activeGateway != null, + ActiveGatewayHasSharedToken = !string.IsNullOrWhiteSpace(activeGateway?.SharedGatewayToken), ActiveGatewaySshTunnel = activeGateway?.SshTunnel }; } diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index 132e2ae9a..9ed9c67b9 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -1502,8 +1502,7 @@ private static string BuildAuthModeLabel( { if (isActive) { - var credentialLabel = ConnectionPagePlan.FormatCredentialSource( - snapshot.OperatorCredentialSource ?? snapshot.NodeCredentialSource); + var credentialLabel = ConnectionPagePlan.FormatCredentialSummary(snapshot); if (!string.IsNullOrEmpty(credentialLabel)) return credentialLabel; diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs index 905a77537..74aa42b57 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs @@ -784,7 +784,7 @@ private static string BuildConnectedDetailLine(GatewayRecord? rec, GatewaySelfIn var url = ConnectionCardPlanSanitizer.SanitizeGatewayUrl(rec?.Url); if (!string.IsNullOrEmpty(url)) bits.Add(url); if (rec?.SshTunnel != null) bits.Add("via SSH tunnel"); - var credential = FormatCredentialSource(snap.OperatorCredentialSource ?? snap.NodeCredentialSource); + var credential = FormatCredentialSummary(snap); if (!string.IsNullOrEmpty(credential)) bits.Add(credential); if (!string.IsNullOrWhiteSpace(self?.ServerVersion)) bits.Add($"v{self!.ServerVersion}"); if (self?.UptimeMs is long uptime && uptime > 0) @@ -804,6 +804,25 @@ internal static string FormatCredentialSource(string? source) }; } + internal static string FormatCredentialSummary(GatewayConnectionSnapshot snap) + { + var useOperator = !string.IsNullOrEmpty(snap.OperatorCredentialSource); + var source = useOperator ? snap.OperatorCredentialSource : snap.NodeCredentialSource; + var label = FormatCredentialSource(source); + if (string.IsNullOrEmpty(label)) + return ""; + + var status = useOperator ? snap.OperatorCredentialStatus : snap.NodeCredentialStatus; + var fallbackUsed = useOperator ? snap.OperatorCredentialFallbackUsed : snap.NodeCredentialFallbackUsed; + if (fallbackUsed || status == GatewayCredentialResolutionStatus.FallbackUsed) + return $"{label} (fallback)"; + if (status == GatewayCredentialResolutionStatus.BootstrapRequired || + (useOperator ? snap.OperatorCredentialBootstrapRequired : snap.NodeCredentialBootstrapRequired)) + return $"{label} (pairing required)"; + + return label; + } + private static int CountEnabledCapabilities(SettingsManager s) { int n = 0; diff --git a/src/OpenClaw.Tray.WinUI/Services/AppStateSnapshot.cs b/src/OpenClaw.Tray.WinUI/Services/AppStateSnapshot.cs index 58c9efec5..1efacf4ee 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AppStateSnapshot.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AppStateSnapshot.cs @@ -37,6 +37,7 @@ internal sealed record AppStateSnapshot /// True when a GatewayRecord is currently active. Distinguishes "active gateway /// has no tunnel" from "no active gateway (fall back to global settings)". public bool HasActiveGatewayRecord { get; init; } + public bool ActiveGatewayHasSharedToken { get; init; } /// SSH tunnel config from the active GatewayRecord. Null means this gateway is /// direct (no tunnel), NOT "unknown". Only meaningful when HasActiveGatewayRecord is true. diff --git a/src/OpenClaw.Tray.WinUI/Services/CommandCenterBrowserProxyAuthWarningPolicy.cs b/src/OpenClaw.Tray.WinUI/Services/CommandCenterBrowserProxyAuthWarningPolicy.cs new file mode 100644 index 000000000..e3b409613 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/CommandCenterBrowserProxyAuthWarningPolicy.cs @@ -0,0 +1,23 @@ +using OpenClaw.Shared; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace OpenClawTray.Services; + +internal static class CommandCenterBrowserProxyAuthWarningPolicy +{ + internal static bool ShouldShow( + bool nodeBrowserProxyEnabled, + bool activeGatewayHasSharedToken, + IReadOnlyList nodes) + { + if (!nodeBrowserProxyEnabled || activeGatewayHasSharedToken) + return false; + + return nodes.Any(node => + node.BrowserApprovedCommands.Contains("browser.proxy", StringComparer.OrdinalIgnoreCase) || + node.UnverifiedDeclaredCommands.Contains("browser.proxy", StringComparer.OrdinalIgnoreCase) || + node.LocalDeclaredCommands.Contains("browser.proxy", StringComparer.OrdinalIgnoreCase)); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/CommandCenterStateBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/CommandCenterStateBuilder.cs index 4ad1aeedd..d2b4df8a2 100644 --- a/src/OpenClaw.Tray.WinUI/Services/CommandCenterStateBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Services/CommandCenterStateBuilder.cs @@ -272,11 +272,10 @@ node.ApprovalState is GatewayNodeApprovalState.PendingApproval or private IEnumerable BuildBrowserProxyAuthWarnings(IReadOnlyList nodes) { - if (_snapshot.Settings?.NodeBrowserProxyEnabled == false || - !nodes.Any(node => - node.BrowserApprovedCommands.Contains("browser.proxy", StringComparer.OrdinalIgnoreCase) || - node.UnverifiedDeclaredCommands.Contains("browser.proxy", StringComparer.OrdinalIgnoreCase) || - node.LocalDeclaredCommands.Contains("browser.proxy", StringComparer.OrdinalIgnoreCase))) + if (!CommandCenterBrowserProxyAuthWarningPolicy.ShouldShow( + _snapshot.Settings?.NodeBrowserProxyEnabled != false, + _snapshot.ActiveGatewayHasSharedToken, + nodes)) { yield break; } diff --git a/tests/OpenClaw.Connection.Tests/CredentialResolverTests.cs b/tests/OpenClaw.Connection.Tests/CredentialResolverTests.cs index 6973fea69..bfd708341 100644 --- a/tests/OpenClaw.Connection.Tests/CredentialResolverTests.cs +++ b/tests/OpenClaw.Connection.Tests/CredentialResolverTests.cs @@ -55,6 +55,28 @@ public void ResolveOperator_FallsToSharedToken_WhenNoDeviceToken() Assert.Equal(CredentialResolver.SourceSharedGatewayToken, result.Source); } + [Fact] + public void ResolveOperatorDetailed_SharedTokenOnly_IsResolvedNotFallback() + { + var record = new GatewayRecord + { + Id = "gw-1", + Url = "wss://test", + SharedGatewayToken = "shared", + BootstrapToken = "boot" + }; + _mockReader.OperatorToken = null; + + var result = _resolver.ResolveOperatorDetailed(record, "/id"); + + Assert.NotNull(result.Credential); + Assert.Equal("shared", result.Credential!.Token); + Assert.Equal(CredentialResolver.SourceSharedGatewayToken, result.Credential.Source); + Assert.Equal(GatewayCredentialResolutionStatus.Resolved, result.Status); + Assert.False(result.FallbackUsed); + Assert.False(result.Credential.FallbackUsed); + } + [Fact] public void ResolveOperator_FallsToBootstrap_WhenNoDeviceOrShared() { @@ -85,6 +107,157 @@ public void ResolveOperator_ReturnsNull_WhenNoCredentials() Assert.Null(result); } + [Fact] + public void ResolveOperatorDetailed_ReturnsMissing_WhenNoCredentials() + { + var record = new GatewayRecord { Id = "gw-1", Url = "wss://test" }; + _mockReader.OperatorToken = null; + + var result = _resolver.ResolveOperatorDetailed(record, "/id"); + + Assert.Null(result.Credential); + Assert.Equal(GatewayCredentialResolutionStatus.Missing, result.Status); + } + + [Fact] + public void ResolveOperatorDetailed_ReturnsCorrupt_WhenStoredTokenFileIsInvalidAndNoFallbackExists() + { + var tempDir = Path.Combine(Path.GetTempPath(), "openclaw-corrupt-token-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "device-key-ed25519.json"), "{ broken json"); + var resolver = new CredentialResolver(DeviceIdentityFileReader.Instance); + var record = new GatewayRecord { Id = "gw-1", Url = "wss://test" }; + + var result = resolver.ResolveOperatorDetailed(record, tempDir); + + Assert.Null(result.Credential); + Assert.Equal(GatewayCredentialResolutionStatus.Corrupt, result.Status); + Assert.Contains("corrupt", result.Detail, StringComparison.OrdinalIgnoreCase); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Theory] + [InlineData("[]")] + [InlineData("null")] + public void ResolveOperatorDetailed_ReturnsCorrupt_WhenStoredTokenFileHasWrongJsonShape(string json) + { + var tempDir = Path.Combine(Path.GetTempPath(), "openclaw-wrong-shape-token-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "device-key-ed25519.json"), json); + var resolver = new CredentialResolver(DeviceIdentityFileReader.Instance); + var record = new GatewayRecord { Id = "gw-1", Url = "wss://test" }; + + var result = resolver.ResolveOperatorDetailed(record, tempDir); + + Assert.Null(result.Credential); + Assert.Equal(GatewayCredentialResolutionStatus.Corrupt, result.Status); + Assert.Contains("not a JSON object", result.Detail, StringComparison.OrdinalIgnoreCase); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void ResolveOperatorDetailed_FallsBack_WhenStoredTokenFileHasWrongJsonShapeAndSharedTokenExists() + { + var tempDir = Path.Combine(Path.GetTempPath(), "openclaw-wrong-shape-fallback-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "device-key-ed25519.json"), "[]"); + var resolver = new CredentialResolver(DeviceIdentityFileReader.Instance); + var record = new GatewayRecord + { + Id = "gw-1", + Url = "wss://test", + SharedGatewayToken = "shared" + }; + + var result = resolver.ResolveOperatorDetailed(record, tempDir); + + Assert.NotNull(result.Credential); + Assert.Equal("shared", result.Credential!.Token); + Assert.Equal(GatewayCredentialResolutionStatus.FallbackUsed, result.Status); + Assert.Equal(GatewayCredentialResolutionStatus.Corrupt, result.PrimaryStatus); + Assert.True(result.FallbackUsed); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void ResolveOperatorDetailed_ReportsFallbackUsed_WhenStoredTokenIsCorruptAndSharedTokenExists() + { + var tempDir = Path.Combine(Path.GetTempPath(), "openclaw-fallback-token-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "device-key-ed25519.json"), "{ broken json"); + var resolver = new CredentialResolver(DeviceIdentityFileReader.Instance); + var record = new GatewayRecord + { + Id = "gw-1", + Url = "wss://test", + SharedGatewayToken = "shared" + }; + + var result = resolver.ResolveOperatorDetailed(record, tempDir); + + Assert.NotNull(result.Credential); + Assert.Equal("shared", result.Credential!.Token); + Assert.Equal(CredentialResolver.SourceSharedGatewayToken, result.Credential.Source); + Assert.Equal(GatewayCredentialResolutionStatus.FallbackUsed, result.Status); + Assert.Equal(GatewayCredentialResolutionStatus.Corrupt, result.PrimaryStatus); + Assert.True(result.FallbackUsed); + Assert.True(result.Credential.FallbackUsed); + Assert.Contains("corrupt", result.Detail, StringComparison.OrdinalIgnoreCase); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void ResolveOperatorDetailed_ReportsFallbackUsed_WhenStoredTokenIsUnreadableAndSharedTokenExists() + { + var reader = new MockDeviceIdentityReader + { + OperatorReadResult = DeviceTokenReadResult.Unreadable("access denied") + }; + var resolver = new CredentialResolver(reader); + var record = new GatewayRecord + { + Id = "gw-1", + Url = "wss://test", + SharedGatewayToken = "shared" + }; + + var result = resolver.ResolveOperatorDetailed(record, "/id"); + + Assert.NotNull(result.Credential); + Assert.Equal("shared", result.Credential!.Token); + Assert.Equal(CredentialResolver.SourceSharedGatewayToken, result.Credential.Source); + Assert.Equal(GatewayCredentialResolutionStatus.FallbackUsed, result.Status); + Assert.Equal(GatewayCredentialResolutionStatus.Unreadable, result.PrimaryStatus); + Assert.True(result.FallbackUsed); + Assert.True(result.Credential.FallbackUsed); + Assert.Contains("unreadable", result.Detail, StringComparison.OrdinalIgnoreCase); + Assert.Contains("access denied", result.Detail, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void ResolveOperator_NeverDowngradesPairedDevice() { @@ -162,6 +335,26 @@ public void ResolveNode_FallsToBootstrap_WhenNoNodeOrShared() Assert.True(result.IsBootstrapToken); } + [Fact] + public void ResolveNodeDetailed_ReportsBootstrapRequired_WhenBootstrapTokenIsUsed() + { + var record = new GatewayRecord + { + Id = "gw-1", + Url = "wss://test", + BootstrapToken = "boot" + }; + _mockReader.NodeToken = null; + + var result = _resolver.ResolveNodeDetailed(record, "/id"); + + Assert.NotNull(result.Credential); + Assert.Equal("boot", result.Credential!.Token); + Assert.True(result.Credential.IsBootstrapToken); + Assert.Equal(GatewayCredentialResolutionStatus.BootstrapRequired, result.Status); + Assert.True(result.BootstrapRequired); + } + [Fact] public void ResolveNode_ReturnsNull_WhenNoCredentials() { @@ -228,8 +421,22 @@ private sealed class MockDeviceIdentityReader : IDeviceIdentityReader { public string? OperatorToken { get; set; } public string? NodeToken { get; set; } + public DeviceTokenReadResult? OperatorReadResult { get; set; } + public DeviceTokenReadResult? NodeReadResult { get; set; } public string? TryReadStoredDeviceToken(string dataPath) => OperatorToken; public string? TryReadStoredNodeDeviceToken(string dataPath) => NodeToken; + public DeviceTokenReadResult ReadStoredDeviceToken(string dataPath) => + OperatorReadResult ?? IDeviceIdentityReaderExtensions.FromToken(OperatorToken); + public DeviceTokenReadResult ReadStoredNodeDeviceToken(string dataPath) => + NodeReadResult ?? IDeviceIdentityReaderExtensions.FromToken(NodeToken); + } + + private static class IDeviceIdentityReaderExtensions + { + public static DeviceTokenReadResult FromToken(string? token) => + string.IsNullOrWhiteSpace(token) + ? DeviceTokenReadResult.Missing() + : DeviceTokenReadResult.Resolved(token!); } } diff --git a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs index d1e36d08d..e442d88ab 100644 --- a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs +++ b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs @@ -168,6 +168,252 @@ public async Task SwitchGatewayAsync_UsesTargetGatewayIdentityAndCredential() Assert.Equal("gw-2", _registry.ActiveGatewayId); } + [Fact] + public async Task SwitchGatewayAsync_PersistsActiveGatewayIdAfterReload() + { + SetupGateway("gw-1", "wss://test1"); + SetupGateway("gw-2", "wss://test2"); + _resolver.OperatorCredential = new GatewayCredential("tok", false, "test"); + + await _manager.ConnectAsync("gw-1"); + await _manager.SwitchGatewayAsync("gw-2"); + + var reloaded = new GatewayRegistry(_tempDir); + reloaded.Load(); + + Assert.Equal("gw-2", reloaded.ActiveGatewayId); + Assert.Equal("gw-2", reloaded.GetActive()?.Id); + } + + [Fact] + public async Task SwitchGatewayAsync_UnknownGateway_DoesNotPersistInvalidActiveId() + { + SetupGateway("gw-1", "wss://test1"); + _registry.Save(); + _resolver.OperatorCredential = new GatewayCredential("tok", false, "test"); + + await _manager.SwitchGatewayAsync("missing-gateway"); + + Assert.Equal("gw-1", _registry.ActiveGatewayId); + var reloaded = new GatewayRegistry(_tempDir); + reloaded.Load(); + Assert.Equal("gw-1", reloaded.ActiveGatewayId); + Assert.Empty(_factory.CreatedClients); + } + + [Fact] + public async Task SwitchGatewayAsync_SaveFailureWithNoPreviousActive_ClearsInMemoryActiveId() + { + var fs = new ThrowingWriteFileSystem(); + var registry = new GatewayRegistry(_tempDir, fs); + registry.AddOrUpdate(new GatewayRecord { Id = "gw-1", Url = "wss://test1" }); + var resolver = new MockCredentialResolver + { + OperatorCredential = new GatewayCredential("tok", false, "test") + }; + var factory = new MockClientFactory(); + using var manager = new GatewayConnectionManager( + resolver, + factory, + registry, + NullLogger.Instance); + + await manager.SwitchGatewayAsync("gw-1"); + + Assert.Null(registry.ActiveGatewayId); + Assert.Empty(factory.CreatedClients); + } + + [Fact] + public async Task SwitchGatewayAsync_SaveFailureWithPreviousActive_PreservesActiveGatewayAndLiveClient() + { + var fs = new ThrowingWriteFileSystem(); + var registry = new GatewayRegistry(_tempDir, fs); + registry.AddOrUpdate(new GatewayRecord { Id = "gw-1", Url = "wss://test1" }); + registry.AddOrUpdate(new GatewayRecord { Id = "gw-2", Url = "wss://test2" }); + registry.SetActive("gw-1"); + var resolver = new MockCredentialResolver + { + OperatorCredential = new GatewayCredential("tok", false, "test") + }; + var factory = new MockClientFactory(); + using var manager = new GatewayConnectionManager( + resolver, + factory, + registry, + NullLogger.Instance); + await manager.ConnectAsync("gw-1"); + var activeLifecycle = Assert.Single(factory.CreatedClients); + + await manager.SwitchGatewayAsync("gw-2"); + + Assert.Equal("gw-1", registry.ActiveGatewayId); + Assert.Same(activeLifecycle.DataClient, manager.OperatorClient); + Assert.False(activeLifecycle.IsDisposed); + Assert.Single(factory.CreatedClients); + } + + [Fact] + public async Task ApplySetupCodeAsync_SaveFailure_PreservesActiveGatewayAndLiveClient() + { + var fs = new ThrowingWriteFileSystem(); + var registry = new GatewayRegistry(_tempDir, fs); + registry.AddOrUpdate(new GatewayRecord { Id = "gw-1", Url = "wss://test1" }); + registry.SetActive("gw-1"); + var resolver = new MockCredentialResolver + { + OperatorCredential = new GatewayCredential("tok", false, "test") + }; + var factory = new MockClientFactory(); + using var manager = new GatewayConnectionManager( + resolver, + factory, + registry, + NullLogger.Instance); + await manager.ConnectAsync("gw-1"); + var activeLifecycle = Assert.Single(factory.CreatedClients); + var setupCode = BuildSetupCode("wss://test2", "bootstrap-token"); + + var result = await manager.ApplySetupCodeAsync(setupCode); + + Assert.Equal(SetupCodeOutcome.ConnectionFailed, result.Outcome); + Assert.Equal("gw-1", registry.ActiveGatewayId); + Assert.Same(activeLifecycle.DataClient, manager.OperatorClient); + Assert.False(activeLifecycle.IsDisposed); + Assert.Single(factory.CreatedClients); + Assert.Null(registry.FindByUrl("wss://test2")); + } + + [Fact] + public async Task StaleOldGatewayHandshakeAfterSwitch_DoesNotMutateCurrentSnapshot() + { + SetupGateway("gw-1", "wss://test1"); + SetupGateway("gw-2", "wss://test2"); + _resolver.OperatorCredential = new GatewayCredential("tok", false, "test"); + + await _manager.ConnectAsync("gw-1"); + var oldGatewayLifecycle = _factory.CreatedClients[0]; + await _manager.SwitchGatewayAsync("gw-2"); + + oldGatewayLifecycle.SimulateHandshake(); + // slopwatch-ignore: SW004 Bounded wait gives a wrongly accepted stale async event time to mutate state. + await Task.Delay(50); + + Assert.Equal("gw-2", _manager.CurrentSnapshot.GatewayId); + Assert.Equal("wss://test2", _manager.CurrentSnapshot.GatewayUrl); + Assert.Equal(RoleConnectionState.Connecting, _manager.CurrentSnapshot.OperatorState); + Assert.Null(_registry.GetById("gw-1")?.LastConnected); + } + + [Fact] + public async Task StaleOldGatewayDeviceTokenAfterSwitch_DoesNotPersistToCurrentIdentity() + { + var capturedTokens = new List<(string path, string token, string role)>(); + var store = new CaptureIdentityStore(capturedTokens); + using var manager = new GatewayConnectionManager( + _resolver, _factory, _registry, NullLogger.Instance, + identityStore: store); + SetupGateway("gw-1", "wss://test1"); + SetupGateway("gw-2", "wss://test2"); + _resolver.OperatorCredential = new GatewayCredential("tok", false, "test"); + + await manager.ConnectAsync("gw-1"); + var oldGatewayLifecycle = _factory.CreatedClients[0]; + await manager.SwitchGatewayAsync("gw-2"); + + oldGatewayLifecycle.SimulateDeviceTokenReceived("old-gateway-token", "operator"); + // slopwatch-ignore: SW004 Bounded wait gives a wrongly accepted stale async event time to persist credentials. + await Task.Delay(50); + + Assert.DoesNotContain(capturedTokens, token => token.token == "old-gateway-token"); + Assert.Equal("gw-2", manager.CurrentSnapshot.GatewayId); + } + + [Fact] + public async Task StaleOldGatewayV2FallbackAfterSwitch_DoesNotMarkCurrentGatewayAsV2Required() + { + SetupGateway("gw-1", "wss://test1"); + SetupGateway("gw-2", "wss://test2"); + _resolver.OperatorCredential = new GatewayCredential("tok", false, "test"); + + await _manager.ConnectAsync("gw-1"); + var oldGatewayLifecycle = _factory.CreatedClients[0]; + await _manager.SwitchGatewayAsync("gw-2"); + + oldGatewayLifecycle.SimulateV2SignatureFallback(); + await WaitUntilAsync(() => _registry.GetById("gw-1")?.RequiresV2Signature == true); + + Assert.False(_registry.GetById("gw-2")?.RequiresV2Signature); + Assert.False(_factory.CreatedClients[1].DataClient.UseV2Signature); + } + + [Fact] + public async Task ConnectWithSharedTokenAsync_RevalidatesDurableTokensUnderTransitionSemaphore() + { + _registry.AddOrUpdate(new GatewayRecord + { + Id = "gw-1", + Url = "ws://127.0.0.1:9", + SshTunnel = new SshTunnelConfig("user", "host.example", 18789, 45678) + }); + _registry.SetActive("gw-1"); + _resolver.OperatorCredential = new GatewayCredential("tok", false, "test"); + var tunnel = new BlockingTunnelManager(); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + tunnelManager: tunnel); + var connectTask = manager.ConnectAsync("gw-1"); + await tunnel.Started.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + var replaceTask = manager.ConnectWithSharedTokenAsync("ws://127.0.0.1:9", "bad-shared-token"); + var identityDir = _registry.GetIdentityDirectory("gw-1"); + var identity = new DeviceIdentity(identityDir, NullLogger.Instance); + identity.Initialize(); + identity.StoreDeviceTokenForRole("operator", "operator-token"); + tunnel.AllowStart.SetResult(true); + await connectTask.WaitAsync(TimeSpan.FromSeconds(2)); + + var result = await replaceTask.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Equal(SetupCodeOutcome.ConnectionFailed, result.Outcome); + Assert.Null(_registry.GetById("gw-1")?.SharedGatewayToken); + Assert.Equal("operator-token", DeviceIdentity.TryReadStoredDeviceToken(identityDir)); + } + + [Fact] + public async Task ConnectAsync_CorruptDeviceTokenWithSharedFallback_IsVisibleInSnapshot() + { + _registry.AddOrUpdate(new GatewayRecord + { + Id = "gw-1", + Url = "wss://test", + SharedGatewayToken = "shared-token" + }); + _registry.SetActive("gw-1"); + var identityDir = _registry.GetIdentityDirectory("gw-1"); + Directory.CreateDirectory(identityDir); + File.WriteAllText(Path.Combine(identityDir, "device-key-ed25519.json"), "{ broken json"); + var resolver = new CredentialResolver(DeviceIdentityFileReader.Instance); + var factory = new MockClientFactory(); + using var manager = new GatewayConnectionManager( + resolver, + factory, + _registry, + NullLogger.Instance); + + await manager.ConnectAsync("gw-1"); + + Assert.Single(factory.CreatedCredentials); + Assert.Equal("shared-token", factory.CreatedCredentials[0].Token); + Assert.Equal(CredentialResolver.SourceSharedGatewayToken, manager.CurrentSnapshot.OperatorCredentialSource); + Assert.Equal(GatewayCredentialResolutionStatus.FallbackUsed, manager.CurrentSnapshot.OperatorCredentialStatus); + Assert.True(manager.CurrentSnapshot.OperatorCredentialFallbackUsed); + Assert.Contains("corrupt", manager.CurrentSnapshot.OperatorCredentialDetail, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task StateChanged_Fires_OnConnect() { @@ -794,6 +1040,19 @@ private static async Task WaitUntilAsync(Func condition) } } + private static string BuildSetupCode(string url, string bootstrapToken) + { + var json = System.Text.Json.JsonSerializer.Serialize(new + { + url, + bootstrapToken + }); + return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(json)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + // ─── EnsureNodeConnectedAsync tests ─── [Fact] @@ -937,13 +1196,23 @@ public async Task StartNodeConnectionCoreAsync_MissingActiveGatewayContext_Repor Assert.True(manager.CurrentSnapshot.NodeConnectionIntended); Assert.Equal(OverallConnectionState.Degraded, manager.CurrentSnapshot.OverallState); Assert.Contains("no active gateway context", manager.CurrentSnapshot.NodeError, StringComparison.OrdinalIgnoreCase); + Assert.Null(manager.CurrentSnapshot.NodeCredentialStatus); } [Fact] public async Task ConnectNodeOnlyAsync_PreservesConnectedOperatorForNodeListRefresh() { SetupGateway("gw-1", "wss://test"); - _resolver.OperatorCredential = new GatewayCredential("operator-token", false, CredentialResolver.SourceSharedGatewayToken); + _resolver.OperatorResolution = new GatewayCredentialResolution( + new GatewayCredential("operator-token", false, CredentialResolver.SourceSharedGatewayToken) + { + ResolutionStatus = GatewayCredentialResolutionStatus.FallbackUsed, + FallbackUsed = true, + ResolutionDetail = "operator fallback" + }, + GatewayCredentialResolutionStatus.FallbackUsed, + FallbackUsed: true, + Detail: "operator fallback"); _resolver.NodeCredential = new GatewayCredential("node-token", false, CredentialResolver.SourceNodeDeviceToken); var node = new CountingNodeConnector(); using var manager = new GatewayConnectionManager( @@ -955,6 +1224,8 @@ public async Task ConnectNodeOnlyAsync_PreservesConnectedOperatorForNodeListRefr await manager.ConnectAsync("gw-1"); Assert.Equal(CredentialResolver.SourceSharedGatewayToken, manager.CurrentSnapshot.OperatorCredentialSource); + Assert.Equal(GatewayCredentialResolutionStatus.FallbackUsed, manager.CurrentSnapshot.OperatorCredentialStatus); + Assert.True(manager.CurrentSnapshot.OperatorCredentialFallbackUsed); await InvokeHandshakeSucceededAsync(manager); Assert.Equal(CredentialResolver.SourceSharedGatewayToken, manager.CurrentSnapshot.OperatorCredentialSource); var operatorLifecycle = Assert.Single(_factory.CreatedClients); @@ -967,6 +1238,8 @@ public async Task ConnectNodeOnlyAsync_PreservesConnectedOperatorForNodeListRefr Assert.Single(_factory.CreatedClients); Assert.Equal(1, node.ConnectCount); Assert.Equal(CredentialResolver.SourceSharedGatewayToken, manager.CurrentSnapshot.OperatorCredentialSource); + Assert.Equal(GatewayCredentialResolutionStatus.FallbackUsed, manager.CurrentSnapshot.OperatorCredentialStatus); + Assert.True(manager.CurrentSnapshot.OperatorCredentialFallbackUsed); Assert.Equal(CredentialResolver.SourceNodeDeviceToken, manager.CurrentSnapshot.NodeCredentialSource); } @@ -1114,10 +1387,49 @@ public async Task ConnectNodeOnlyAsync_TunnelStartFailure_ReportsBlockedNode() Assert.True(manager.CurrentSnapshot.NodeConnectionIntended); Assert.Equal(OverallConnectionState.Error, manager.CurrentSnapshot.OverallState); Assert.Contains("SSH tunnel", manager.CurrentSnapshot.NodeError, StringComparison.OrdinalIgnoreCase); + Assert.Null(manager.CurrentSnapshot.NodeCredentialSource); + Assert.Equal(GatewayCredentialResolutionStatus.Resolved, manager.CurrentSnapshot.NodeCredentialStatus); + Assert.False(manager.CurrentSnapshot.NodeCredentialFallbackUsed); + Assert.False(manager.CurrentSnapshot.NodeCredentialBootstrapRequired); Assert.Contains(snapshots, snapshot => snapshot.NodeState == RoleConnectionState.Error); Assert.NotEqual(RoleConnectionState.Connecting, snapshots.Last().NodeState); } + [Fact] + public async Task ConnectNodeOnlyAsync_TunnelStartFailure_PreservesFallbackCredentialFlags() + { + _registry.AddOrUpdate(new GatewayRecord + { + Id = "gw-ssh", + Url = "wss://remote.example", + SharedGatewayToken = "shared-token", + SshTunnel = new SshTunnelConfig("user", "host.example", 18789, 45678) + }); + _registry.SetActive("gw-ssh"); + var identityDir = _registry.GetIdentityDirectory("gw-ssh"); + Directory.CreateDirectory(identityDir); + File.WriteAllText(Path.Combine(identityDir, "device-key-ed25519.json"), "{ broken json"); + var resolver = new CredentialResolver(DeviceIdentityFileReader.Instance); + var node = new CountingNodeConnector(); + var tunnel = new FailingTunnelManager(); + using var manager = new GatewayConnectionManager( + resolver, + _factory, + _registry, + NullLogger.Instance, + nodeConnector: node, + tunnelManager: tunnel); + + await manager.ConnectNodeOnlyAsync("gw-ssh"); + + Assert.Equal(0, node.ConnectCount); + Assert.Equal(RoleConnectionState.Error, manager.CurrentSnapshot.NodeState); + Assert.Null(manager.CurrentSnapshot.NodeCredentialSource); + Assert.Equal(GatewayCredentialResolutionStatus.FallbackUsed, manager.CurrentSnapshot.NodeCredentialStatus); + Assert.True(manager.CurrentSnapshot.NodeCredentialFallbackUsed); + Assert.False(manager.CurrentSnapshot.NodeCredentialBootstrapRequired); + } + [Fact] public async Task ConnectAsync_StartsSshTunnelAndUsesTunnelUrl_WhenGatewayUsesTunnel() { @@ -1272,9 +1584,15 @@ private sealed class MockCredentialResolver : ICredentialResolver { public GatewayCredential? OperatorCredential { get; set; } public GatewayCredential? NodeCredential { get; set; } + public GatewayCredentialResolution? OperatorResolution { get; set; } + public GatewayCredentialResolution? NodeResolution { get; set; } public GatewayCredential? ResolveOperator(GatewayRecord record, string identityPath) => OperatorCredential; public GatewayCredential? ResolveNode(GatewayRecord record, string identityPath) => NodeCredential; + public GatewayCredentialResolution ResolveOperatorDetailed(GatewayRecord record, string identityPath) => + OperatorResolution ?? GatewayCredentialResolution.FromLegacy(OperatorCredential); + public GatewayCredentialResolution ResolveNodeDetailed(GatewayRecord record, string identityPath) => + NodeResolution ?? GatewayCredentialResolution.FromLegacy(NodeCredential); } private sealed class MockClientFactory : IGatewayClientFactory @@ -1295,6 +1613,19 @@ public IGatewayClientLifecycle Create(string gatewayUrl, GatewayCredential crede } } + private sealed class ThrowingWriteFileSystem : IFileSystem + { + public bool FileExists(string path) => File.Exists(path); + public string ReadAllText(string path) => File.ReadAllText(path); + public void WriteAllText(string path, string content) => + throw new IOException("simulated save failure"); + public void CreateDirectory(string path) => Directory.CreateDirectory(path); + public bool DirectoryExists(string path) => Directory.Exists(path); + public void CopyFile(string source, string destination, bool overwrite) => + File.Copy(source, destination, overwrite); + public void DeleteFile(string path) => File.Delete(path); + } + internal sealed class MockLifecycle : IGatewayClientLifecycle { private readonly MockGatewayClient _client; @@ -1795,6 +2126,24 @@ public Task StopAsync() public void Dispose() { } } + private sealed class BlockingTunnelManager : ISshTunnelManager + { + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource AllowStart { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public bool IsActive => false; + public string? LocalTunnelUrl => null; + + public async Task StartAsync(SshTunnelConfig config, CancellationToken ct) + { + Started.SetResult(true); + await AllowStart.Task.WaitAsync(ct); + return $"ws://localhost:{config.LocalPort}"; + } + + public Task StopAsync() => Task.CompletedTask; + public void Dispose() { } + } + private sealed class FailingTunnelManager : ISshTunnelManager { public bool IsActive => false; diff --git a/tests/OpenClaw.Connection.Tests/GatewayRegistryTests.cs b/tests/OpenClaw.Connection.Tests/GatewayRegistryTests.cs index 7bdd27a81..9d8776cf9 100644 --- a/tests/OpenClaw.Connection.Tests/GatewayRegistryTests.cs +++ b/tests/OpenClaw.Connection.Tests/GatewayRegistryTests.cs @@ -78,6 +78,21 @@ public void SetActive_SetsActiveGateway() Assert.Equal("gw-2", _registry.GetActive()!.Id); } + [Fact] + public void SetActive_FiresChangedWithActiveGatewayId() + { + _registry.AddOrUpdate(MakeRecord("gw-1", "wss://test1")); + _registry.AddOrUpdate(MakeRecord("gw-2", "wss://test2")); + GatewayRegistryChangedEventArgs? args = null; + _registry.Changed += (_, e) => args = e; + + _registry.SetActive("gw-2"); + + Assert.NotNull(args); + Assert.Equal("gw-2", args!.ActiveGatewayId); + Assert.Equal(2, args.Records.Count); + } + [Fact] public void SaveAndLoad_RoundTrips() { @@ -97,6 +112,35 @@ public void SaveAndLoad_RoundTrips() Assert.Equal("gw-1", registry2.GetActive()!.Id); } + [Fact] + public void Save_WhenMoveFails_RemovesTempFile() + { + var registryPath = Path.Combine(_tempDir, "gateways.json"); + File.WriteAllText(registryPath, "{}"); + using var lockFile = new FileStream(registryPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + _registry.AddOrUpdate(MakeRecord("gw-1", "wss://test1")); + + var ex = Assert.ThrowsAny(() => _registry.Save()); + + Assert.True( + ex is IOException or UnauthorizedAccessException, + $"Expected an IO/access failure, got {ex.GetType().FullName}: {ex.Message}"); + Assert.Empty(Directory.GetFiles(_tempDir, "gateways.json.*.tmp")); + } + + [Fact] + public void Save_WhenTempWriteFailsAfterCreatingFile_RemovesTempFile() + { + var fs = new FailingTempWriteFileSystem(); + var registry = new GatewayRegistry(_tempDir, fs); + registry.AddOrUpdate(MakeRecord("gw-1", "wss://test1")); + + var ex = Assert.Throws(() => registry.Save()); + + Assert.Equal("simulated partial write failure", ex.Message); + Assert.Empty(Directory.GetFiles(_tempDir, "gateways.json.*.tmp")); + } + [Fact] public void Load_WithNoFile_DoesNotThrow() { @@ -195,13 +239,16 @@ public void FindByUrl_ReturnsNullIfNotFound() [Fact] public void Changed_FiresOnAddOrUpdate() { + _registry.AddOrUpdate(MakeRecord("active", "wss://active")); + _registry.SetActive("active"); GatewayRegistryChangedEventArgs? args = null; _registry.Changed += (s, e) => args = e; _registry.AddOrUpdate(MakeRecord("gw-1", "wss://test1")); Assert.NotNull(args); - Assert.Single(args.Records); + Assert.Equal("active", args!.ActiveGatewayId); + Assert.Equal(2, args.Records.Count); } [Fact] @@ -404,4 +451,20 @@ public void Debug(string message) { } public void Warn(string message) => Warnings.Add(message); public void Error(string message, Exception? ex = null) { } } + + private sealed class FailingTempWriteFileSystem : IFileSystem + { + public bool FileExists(string path) => File.Exists(path); + public string ReadAllText(string path) => File.ReadAllText(path); + public void WriteAllText(string path, string content) + { + File.WriteAllText(path, content[..Math.Min(content.Length, 16)]); + throw new IOException("simulated partial write failure"); + } + public void CreateDirectory(string path) => Directory.CreateDirectory(path); + public bool DirectoryExists(string path) => Directory.Exists(path); + public void CopyFile(string source, string destination, bool overwrite) => + File.Copy(source, destination, overwrite); + public void DeleteFile(string path) => File.Delete(path); + } } diff --git a/tests/OpenClaw.Shared.Tests/DeviceIdentityTests.cs b/tests/OpenClaw.Shared.Tests/DeviceIdentityTests.cs index 1090da36d..39ef55ec7 100644 --- a/tests/OpenClaw.Shared.Tests/DeviceIdentityTests.cs +++ b/tests/OpenClaw.Shared.Tests/DeviceIdentityTests.cs @@ -335,6 +335,25 @@ public void TryReadStoredDeviceTokenForRole_InvalidRole_Throws(string role) finally { Directory.Delete(dir, true); } } + [Theory] + [InlineData("[]")] + [InlineData("null")] + public void ReadStoredDeviceToken_WrongJsonRoot_ReturnsCorrupt(string json) + { + var dir = CreateTempDir(); + try + { + File.WriteAllText(Path.Combine(dir, "device-key-ed25519.json"), json); + + var result = DeviceIdentity.ReadStoredDeviceToken(dir); + + Assert.Null(result.Token); + Assert.Equal(DeviceTokenReadStatus.Corrupt, result.Status); + Assert.Contains("not a JSON object", result.Detail, StringComparison.OrdinalIgnoreCase); + } + finally { Directory.Delete(dir, true); } + } + [IntegrationFact] public void DifferentDirs_ProduceDifferentIdentities() { diff --git a/tests/OpenClaw.Tray.Tests/AssistantBridgeServiceTests.cs b/tests/OpenClaw.Tray.Tests/AssistantBridgeServiceTests.cs index 498cfe49f..ace955607 100644 --- a/tests/OpenClaw.Tray.Tests/AssistantBridgeServiceTests.cs +++ b/tests/OpenClaw.Tray.Tests/AssistantBridgeServiceTests.cs @@ -202,7 +202,7 @@ public async Task StartListenServiceAsync_KillsTimedOutBackendCommand() NullLogger.Instance, "owner", () => launcher, - TimeSpan.FromSeconds(5)); + TimeSpan.FromSeconds(10)); var result = await service.StartListenServiceAsync(); diff --git a/tests/OpenClaw.Tray.Tests/CommandCenterStateBuilderTests.cs b/tests/OpenClaw.Tray.Tests/CommandCenterStateBuilderTests.cs new file mode 100644 index 000000000..a2934bed7 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/CommandCenterStateBuilderTests.cs @@ -0,0 +1,36 @@ +using OpenClaw.Shared; +using OpenClawTray.Services; + +namespace OpenClaw.Tray.Tests; + +public sealed class CommandCenterStateBuilderTests +{ + [Fact] + public void BrowserProxyAuthWarning_IsGatedByActiveGatewaySharedToken() + { + var nodes = new[] + { + new NodeCapabilityHealthInfo + { + BrowserApprovedCommands = ["browser.proxy"] + } + }; + + Assert.True(CommandCenterBrowserProxyAuthWarningPolicy.ShouldShow( + nodeBrowserProxyEnabled: true, + activeGatewayHasSharedToken: false, + nodes)); + Assert.False(CommandCenterBrowserProxyAuthWarningPolicy.ShouldShow( + nodeBrowserProxyEnabled: true, + activeGatewayHasSharedToken: true, + nodes)); + Assert.False(CommandCenterBrowserProxyAuthWarningPolicy.ShouldShow( + nodeBrowserProxyEnabled: false, + activeGatewayHasSharedToken: false, + nodes)); + Assert.False(CommandCenterBrowserProxyAuthWarningPolicy.ShouldShow( + nodeBrowserProxyEnabled: true, + activeGatewayHasSharedToken: false, + [])); + } +} diff --git a/tests/OpenClaw.Tray.Tests/ConnectionPageApproveCommandTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionPageApproveCommandTests.cs index 47cc5d140..7ef660679 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionPageApproveCommandTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionPageApproveCommandTests.cs @@ -128,22 +128,18 @@ public void MissingDevicePairRequestId_EmitsDiscoveryCommand_NotDeviceId( [Fact] public void GatewayCredentialDisplay_PrefersOperatorCredentialOverNodeCredential() { - var planSource = ReadPlanSource(); - var pageSource = ReadConnectionPageSource(); - - Assert.Contains( - "snap.OperatorCredentialSource ?? snap.NodeCredentialSource", - planSource); - Assert.DoesNotContain( - "snap.NodeCredentialSource ?? snap.OperatorCredentialSource", - planSource); - - Assert.Contains( - "snapshot.OperatorCredentialSource ?? snapshot.NodeCredentialSource", - pageSource); - Assert.DoesNotContain( - "snapshot.NodeCredentialSource ?? snapshot.OperatorCredentialSource", - pageSource); + var snapshot = new GatewayConnectionSnapshot + { + OperatorCredentialSource = CredentialResolver.SourceSharedGatewayToken, + OperatorCredentialStatus = GatewayCredentialResolutionStatus.Resolved, + NodeCredentialSource = CredentialResolver.SourceNodeDeviceToken, + NodeCredentialStatus = GatewayCredentialResolutionStatus.FallbackUsed, + NodeCredentialFallbackUsed = true + }; + + var summary = ConnectionPagePlan.FormatCredentialSummary(snapshot); + + Assert.Equal("shared token", summary); } [Fact] diff --git a/tests/OpenClaw.Tray.Tests/ConnectionPageNodeApprovalSourceTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionPageNodeApprovalSourceTests.cs index b452e235f..2979b51e5 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionPageNodeApprovalSourceTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionPageNodeApprovalSourceTests.cs @@ -10,6 +10,11 @@ public void CommandCenterFallback_ProjectsLocalDeclarationsAsUnverified() "OpenClaw.Tray.WinUI", "Services", "CommandCenterStateBuilder.cs"); + var browserProxyPolicySource = ReadSource( + "src", + "OpenClaw.Tray.WinUI", + "Services", + "CommandCenterBrowserProxyAuthWarningPolicy.cs"); var appSource = ReadSource("src", "OpenClaw.Tray.WinUI", "App.xaml.cs"); Assert.Contains("NodeCapabilityHealthInfo.FromLocalDeclarations(localNode)", builderSource); @@ -28,8 +33,8 @@ public void CommandCenterFallback_ProjectsLocalDeclarationsAsUnverified() "PairingApprovalKind.NodePair => CommandCenterDiagnostics.BuildNodeApprovalRepairCommand(_snapshot.NodePairingRequestId)", builderSource); Assert.Contains("_ => CommandCenterDiagnostics.BuildUnknownPairingDiscoveryCommands()", builderSource); - Assert.Contains("node.UnverifiedDeclaredCommands.Contains(\"browser.proxy\"", builderSource); - Assert.Contains("node.LocalDeclaredCommands.Contains(\"browser.proxy\"", builderSource); + Assert.Contains("node.UnverifiedDeclaredCommands.Contains(\"browser.proxy\"", browserProxyPolicySource); + Assert.Contains("node.LocalDeclaredCommands.Contains(\"browser.proxy\"", browserProxyPolicySource); Assert.Contains( "NodePairingApprovalKind = _connectionManager?.CurrentSnapshot.NodePairingApprovalKind", appSource); diff --git a/tests/OpenClaw.Tray.Tests/ConnectionPagePlanApprovalBehaviorTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionPagePlanApprovalBehaviorTests.cs index 0154d1cdb..c6cbfc732 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionPagePlanApprovalBehaviorTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionPagePlanApprovalBehaviorTests.cs @@ -270,6 +270,33 @@ public void MissingNodeCredential_ProjectsAsBlockedNode_NotHealthy() Assert.Equal("No node credential available. Re-pair this PC.", plan.NodeErrorDetail); } + [Fact] + public void ActiveGatewayDetailLine_ShowsCredentialFallback() + { + var settings = new SettingsManager(_settingsDirectory) + { + EnableNodeMode = true + }; + var plan = ConnectionPagePlan.Build( + new GatewayConnectionSnapshot + { + OverallState = OverallConnectionState.Ready, + OperatorState = RoleConnectionState.Connected, + NodeState = RoleConnectionState.Disabled, + GatewayId = "gw-1", + GatewayUrl = "wss://test", + OperatorCredentialSource = CredentialResolver.SourceSharedGatewayToken, + OperatorCredentialStatus = GatewayCredentialResolutionStatus.FallbackUsed, + OperatorCredentialFallbackUsed = true + }, + activeRecord: new GatewayRecord { Id = "gw-1", Url = "wss://test" }, + self: null, + settings: settings, + savedGatewayCount: 1); + + Assert.Contains("shared token (fallback)", plan.ActiveGatewayDetailLine); + } + private ConnectionPagePlan Build( PairingApprovalKind pairingApprovalKind, GatewayNodeInfo? localNode, diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 50527f1ec..653f43f07 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -69,6 +69,7 @@ +