Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/CONNECTION_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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\<id>\`; 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
Expand All @@ -148,7 +150,7 @@ 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.

Node credential precedence follows the same invariant with a distinct stored token:

Expand All @@ -157,7 +159,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

Expand Down
16 changes: 16 additions & 0 deletions src/OpenClaw.Connection/ConnectionDiagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
82 changes: 81 additions & 1 deletion src/OpenClaw.Connection/ConnectionStateMachine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
Expand Down Expand Up @@ -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)
{
Expand All @@ -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();
}

Expand All @@ -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();
}

Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
140 changes: 115 additions & 25 deletions src/OpenClaw.Connection/CredentialResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}";
}
}
Loading