Files
nexus/backend/Services/OpenClawSetupService.cs
T
AzuTear f5552218bc
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s
feat: ship agent-first mission control v0.2.57
2026-07-31 22:39:47 +02:00

1253 lines
47 KiB
C#

using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Configuration;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
namespace Nexus.Api.Services;
/// <summary>
/// Coordinates the safe, single-instance Attach &amp; Adopt flow.
/// This service deliberately performs no subnet scan, Docker socket access,
/// package installation, state migration or automatic repair.
/// </summary>
public sealed class OpenClawSetupService(
IOpenClawConnectionProfileRepository profiles,
IGatewayConnector connector,
IConfiguration configuration,
IOpenClawManagementState? managementState = null,
IOpenClawDeviceIdentityStore? deviceIdentityStore = null)
: IOpenClawSetupService
{
private static readonly string[] BuiltInInternalHosts =
[
"localhost",
"127.0.0.1",
"::1",
"openclaw-gateway",
"host.docker.internal"
];
private static readonly string[] ManagementScopes =
[
"operator.read",
"operator.admin"
];
private static readonly IReadOnlySet<string> AllowedDiscoverySources =
new HashSet<string>(
[
"configured",
"docker",
"loopback",
"host",
"mdns",
"manual"
], StringComparer.Ordinal);
public async Task<OpenClawSetupStatusDto> GetStatusAsync(
CancellationToken cancellationToken = default)
{
var profile = await profiles.GetPrimaryAsync(cancellationToken);
return BuildStatus(profile);
}
public Task<OpenClawDiscoveryDto> DiscoverAsync(
OpenClawDiscoveryRequest request,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var currentEndpoint = GetCurrentConnectorEndpoint();
var candidates = new List<(string Endpoint, string Source, string? Fingerprint)>();
var configuredFingerprint = connector.ActiveTlsFingerprint
?? configuration["GatewayConnector:TlsFingerprint"];
if (currentEndpoint is not null)
candidates.Add((currentEndpoint, "configured", configuredFingerprint));
candidates.Add(("ws://openclaw-gateway:18789/", "docker", null));
candidates.Add(("ws://127.0.0.1:18789/", "loopback", null));
candidates.Add(("ws://localhost:18789/", "loopback", null));
candidates.Add(("ws://host.docker.internal:18789/", "host", null));
var allowedHosts = GetAllowedInternalHosts();
var result = candidates
.Select(candidate =>
{
var validation = ValidateEndpoint(
candidate.Endpoint,
candidate.Fingerprint,
allowedHosts,
requireExternalFingerprint: true);
return new OpenClawDiscoveryCandidateDto(
validation.NormalizedEndpoint ?? candidate.Endpoint,
candidate.Source,
EndpointsEqual(validation.NormalizedEndpoint, currentEndpoint),
validation.RequiresTlsFingerprint,
validation.IsValid,
validation.Error);
})
.GroupBy(
candidate => candidate.Endpoint,
StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.ToList();
var mdnsState = request.IncludeMdns
? "unsupported"
: "not_requested";
var message = request.IncludeMdns
? "mDNS discovery is not available in this build; no network scan was performed."
: "Only configured and well-known local candidates were evaluated.";
return Task.FromResult(new OpenClawDiscoveryDto(
result,
mdnsState,
message,
DateTimeOffset.UtcNow));
}
public async Task<OpenClawSetupOperationDto<OpenClawProbeDto>> ProbeAsync(
ProbeOpenClawRequest request,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var validation = ValidateEndpoint(
request.Endpoint,
request.TlsCertificateFingerprint,
GetAllowedInternalHosts(),
requireExternalFingerprint: true);
if (!validation.IsValid)
{
return Failure<OpenClawProbeDto>(
OpenClawSetupStates.InvalidEndpoint,
validation.Error ?? "The OpenClaw endpoint is invalid.",
"Use wss:// with a confirmed SHA-256 certificate fingerprint for external targets. Plain ws:// is restricted to explicit local candidates.");
}
var endpoint = validation.NormalizedEndpoint!;
var currentEndpoint = GetCurrentConnectorEndpoint();
var identitySupported = ExternalClientIdentitySupported();
if (identitySupported && !EndpointsEqual(endpoint, currentEndpoint))
{
await connector.ConfigureEndpointAsync(
endpoint,
request.TlsCertificateFingerprint,
bootstrapToken: null,
cancellationToken);
await WaitForConnectorTransitionAsync(cancellationToken);
currentEndpoint = GetCurrentConnectorEndpoint();
}
var isCurrent = EndpointsEqual(endpoint, currentEndpoint);
var hash = BuildCapabilityHash();
var leastPrivilege = HasReadOnlyScope();
var connected = connector.ConnectionState == GatewayConnectionState.Connected;
var versionMatches = VersionMatches();
var probe = new OpenClawProbeDto(
endpoint,
"manual",
isCurrent,
connected,
connector.GatewayVersion,
connector.RequiredVersion,
versionMatches,
connector.ProtocolVersion,
connector.DeviceId,
connector.PairingRequired,
connector.PairingRequestId,
Sorted(connector.GrantedScopes),
Sorted(connector.AdvertisedMethods),
hash,
identitySupported
&& isCurrent
&& connected
&& versionMatches
&& connector.ProtocolVersion == 4
&& leastPrivilege,
leastPrivilege,
DateTimeOffset.UtcNow);
if (!isCurrent)
{
return Failure(
identitySupported
? OpenClawSetupStates.GatewayUnavailable
: OpenClawSetupStates.ExperimentalBlocked,
identitySupported
? "The validated endpoint could not become the running Gateway connector target."
: "OpenClaw does not yet advertise a supported external Nexus operator client identity.",
identitySupported
? "Check the Gateway endpoint and retry the explicit probe."
: "Keep the connector experimental until the pinned OpenClaw release supports an official Nexus or generic external-operator client id.",
probe);
}
if (!identitySupported)
{
return Failure(
OpenClawSetupStates.ExperimentalBlocked,
"OpenClaw does not yet advertise a supported external Nexus operator client identity.",
"Keep the connector experimental until the pinned OpenClaw release supports an official Nexus or generic external-operator client id.",
probe);
}
if (connector.PairingRequired)
{
return Failure(
OpenClawSetupStates.PairingRequired,
"OpenClaw is waiting for an operator to approve the Nexus device.",
"Approve the displayed pairing request in OpenClaw, then verify the connection.",
probe);
}
if (!connected)
{
return Failure(
OpenClawSetupStates.GatewayUnavailable,
"The configured OpenClaw Gateway is not connected.",
"Check the Gateway endpoint and server-side credential, then retry.",
probe);
}
if (!versionMatches || connector.ProtocolVersion != 4)
{
return Failure(
OpenClawSetupStates.GatewayUnavailable,
"The connected OpenClaw version or protocol does not match the verified Nexus contract.",
"Use the pinned OpenClaw release and protocol v4 before adoption.",
probe);
}
if (!leastPrivilege)
{
return Failure(
OpenClawSetupStates.ExcessiveScope,
"The initial connection is not read-only.",
"Pair the Nexus device with operator.read only. Request management scopes after adoption.",
probe);
}
return Success(
OpenClawSetupStates.Probed,
"The OpenClaw endpoint is reachable through the configured read-only connector.",
probe);
}
public async Task<OpenClawSetupOperationDto<OpenClawSetupStatusDto>> AttachAsync(
AttachOpenClawRequest request,
CancellationToken cancellationToken = default)
{
var requestError = ValidateAttachRequest(request);
if (requestError is not null)
{
return Failure<OpenClawSetupStatusDto>(
OpenClawSetupStates.InvalidRequest,
requestError,
"Correct the request and retry. Bootstrap credentials are never persisted.");
}
var current = await profiles.GetPrimaryAsync(cancellationToken);
if (current is not null)
{
return Failure(
OpenClawSetupStates.ConcurrencyConflict,
"A primary OpenClaw connection profile already exists.",
"Verify or delete the existing profile instead of attaching a second instance.",
BuildStatus(current));
}
var endpointValidation = ValidateEndpoint(
request.Endpoint,
request.TlsCertificateFingerprint,
GetAllowedInternalHosts(),
requireExternalFingerprint: true);
if (!endpointValidation.IsValid)
{
return Failure<OpenClawSetupStatusDto>(
OpenClawSetupStates.InvalidEndpoint,
endpointValidation.Error ?? "The OpenClaw endpoint is invalid.",
"Correct the endpoint and TLS trust data before attaching.");
}
if (!ExternalClientIdentitySupported())
{
return Failure<OpenClawSetupStatusDto>(
OpenClawSetupStates.ExperimentalBlocked,
"Attach remains blocked until OpenClaw supports the external Nexus operator identity.",
"Use only a pinned OpenClaw release with an officially supported Nexus or generic external-operator client id.");
}
var (bootstrapSecret, bootstrapError) = ResolveBootstrapSecret(request);
if (bootstrapError is not null)
{
return Failure<OpenClawSetupStatusDto>(
OpenClawSetupStates.InvalidRequest,
bootstrapError,
"Provide a masked one-time token, env:VARIABLE, or a configured server-side SecretRef.");
}
await connector.ConfigureEndpointAsync(
endpointValidation.NormalizedEndpoint!,
request.TlsCertificateFingerprint,
bootstrapSecret,
cancellationToken);
await WaitForConnectorTransitionAsync(cancellationToken);
var probeResult = await ProbeAsync(
new ProbeOpenClawRequest(
request.Endpoint,
request.TlsCertificateFingerprint),
cancellationToken);
if (!probeResult.Ok || probeResult.Data is not { CanAttach: true } probe)
{
return Failure<OpenClawSetupStatusDto>(
probeResult.State,
probeResult.Message,
probeResult.Recovery);
}
var fingerprint = NormalizeFingerprint(request.TlsCertificateFingerprint);
var now = DateTimeOffset.UtcNow;
var profile = new OpenClawConnectionProfile
{
ProfileId = OpenClawConnectionProfile.PrimaryProfileId,
Endpoint = probe.Endpoint,
DiscoverySource = NormalizeDiscoverySource(request.DiscoverySource),
RequiredVersion = connector.RequiredVersion,
TlsCertificateFingerprint = fingerprint,
AdoptionState = OpenClawAdoptionStates.Attached,
ManagementEnabled = false,
CapabilityHash = probe.CapabilityHash,
DeviceId = connector.DeviceId,
CreatedAt = now,
UpdatedAt = now,
LastProbedAt = now
};
try
{
var saved = await profiles.SavePrimaryAsync(
profile,
expectedRevision: null,
cancellationToken);
return Success(
OpenClawSetupStates.Attached,
"The read-only OpenClaw connection was attached. No OpenClaw resources were copied.",
BuildStatus(saved));
}
catch (OpenClawConnectionProfileConcurrencyException exception)
{
return Failure<OpenClawSetupStatusDto>(
OpenClawSetupStates.ConcurrencyConflict,
exception.Message,
"Reload setup status before retrying.");
}
}
public async Task<OpenClawSetupOperationDto<OpenClawSetupStatusDto>> VerifyAsync(
VerifyOpenClawRequest request,
CancellationToken cancellationToken = default)
{
var profile = await profiles.GetPrimaryAsync(cancellationToken);
var precondition = CheckProfilePreconditions(profile, request.ExpectedRevision);
if (precondition is not null)
return precondition;
var probe = await ProbeAsync(
new ProbeOpenClawRequest(
profile!.Endpoint,
profile.TlsCertificateFingerprint),
cancellationToken);
var postAdoptionVerification =
profile!.AdoptionState == OpenClawAdoptionStates.Adopted &&
probe.State == OpenClawSetupStates.ExcessiveScope;
if (probe.Data is not { } data
|| (!probe.Ok && !postAdoptionVerification))
{
return Failure<OpenClawSetupStatusDto>(
probe.State,
probe.Message,
probe.Recovery);
}
if (profile.AdoptionState != OpenClawAdoptionStates.Adopted)
profile.AdoptionState = OpenClawAdoptionStates.Verified;
profile.CapabilityHash = data.CapabilityHash;
profile.DeviceId = connector.DeviceId;
profile.LastProbedAt = data.CheckedAt;
profile.LastVerifiedAt = DateTimeOffset.UtcNow;
return await SaveStatusAsync(
profile,
request.ExpectedRevision,
OpenClawSetupStates.Verified,
"The read-only OpenClaw connection was verified.",
cancellationToken);
}
public async Task<OpenClawSetupOperationDto<OpenClawAdoptionInventoryDto>> AdoptAsync(
AdoptOpenClawRequest request,
CancellationToken cancellationToken = default)
{
var profile = await profiles.GetPrimaryAsync(cancellationToken);
var profileError = CheckProfile<OpenClawAdoptionInventoryDto>(
profile,
request.ExpectedRevision);
if (profileError is not null)
return profileError;
if (profile!.AdoptionState is not (
OpenClawAdoptionStates.Verified or OpenClawAdoptionStates.Adopted))
{
return Failure<OpenClawAdoptionInventoryDto>(
OpenClawSetupStates.InvalidRequest,
"The OpenClaw connection must be verified before adoption.",
"Run the verify step with the current profile revision.");
}
var liveError = CheckLiveReadConnection(profile);
if (liveError is not null)
return Failure<OpenClawAdoptionInventoryDto>(
liveError.Value.State,
liveError.Value.Message,
liveError.Value.Recovery);
var inventory = await CaptureInventoryAsync(cancellationToken);
if (inventory.AgentCount is null || inventory.CronJobCount is null)
{
return Failure(
OpenClawSetupStates.GatewayUnavailable,
"OpenClaw did not provide the core agent and cron inventories.",
"Verify that agents.list and cron.list are advertised with operator.read, then retry.",
inventory);
}
profile.AdoptionState = OpenClawAdoptionStates.Adopted;
profile.ManagementEnabled = false;
profile.CapabilityHash = BuildCapabilityHash();
profile.LastVerifiedAt = DateTimeOffset.UtcNow;
profile.AdoptedAt ??= DateTimeOffset.UtcNow;
try
{
await profiles.SavePrimaryAsync(
profile,
request.ExpectedRevision,
cancellationToken);
return Success(
OpenClawSetupStates.Adopted,
"The live OpenClaw inventory was adopted without copying resource configuration into Nexus.",
inventory);
}
catch (OpenClawConnectionProfileConcurrencyException exception)
{
return Failure<OpenClawAdoptionInventoryDto>(
OpenClawSetupStates.ConcurrencyConflict,
exception.Message,
"Reload setup status and inventory before retrying.");
}
}
public async Task<OpenClawSetupOperationDto<OpenClawSetupStatusDto>> SetManagementAsync(
SetOpenClawManagementRequest request,
CancellationToken cancellationToken = default)
{
var profile = await profiles.GetPrimaryAsync(cancellationToken);
var precondition = CheckProfilePreconditions(profile, request.ExpectedRevision);
if (precondition is not null)
return precondition;
if (!request.Enabled)
{
profile!.ManagementEnabled = false;
return await SaveStatusAsync(
profile,
request.ExpectedRevision,
OpenClawSetupStates.Adopted,
"OpenClaw management was disabled locally.",
cancellationToken);
}
if (!request.Confirmed)
{
return Failure(
OpenClawSetupStates.InvalidRequest,
"Enabling OpenClaw management requires explicit confirmation.",
"Confirm the elevated management boundary and retry.",
BuildStatus(profile));
}
if (profile!.AdoptionState != OpenClawAdoptionStates.Adopted)
{
return Failure(
OpenClawSetupStates.InvalidRequest,
"OpenClaw must be adopted before management can be enabled.",
"Complete read-only verification and adoption first.",
BuildStatus(profile));
}
if (!ExternalClientIdentitySupported())
{
return Failure(
OpenClawSetupStates.ExperimentalBlocked,
"Management remains blocked until OpenClaw supports the external Nexus operator identity.",
"Upgrade only to a pinned OpenClaw release that explicitly supports the Nexus client.",
BuildStatus(profile));
}
if (connector.ConnectionState != GatewayConnectionState.Connected
|| !EndpointsEqual(profile.Endpoint, GetCurrentConnectorEndpoint()))
{
return Failure(
OpenClawSetupStates.GatewayUnavailable,
"The adopted OpenClaw profile is not the currently connected Gateway.",
"Restore and verify the adopted connection before requesting management.",
BuildStatus(profile));
}
if (!ManagementScopes.All(scope => connector.GrantedScopes.Contains(scope)))
{
await connector.RequestOperatorScopesAsync(
ManagementScopes,
cancellationToken);
return Failure(
OpenClawSetupStates.ScopeUpgradeRequired,
"Nexus requested an explicit operator.admin scope upgrade from OpenClaw.",
"Approve the new pairing or scope request in OpenClaw, wait for Nexus to reconnect, then retry.",
BuildStatus(profile));
}
profile.ManagementEnabled = true;
profile.CapabilityHash = BuildCapabilityHash();
profile.LastVerifiedAt = DateTimeOffset.UtcNow;
return await SaveStatusAsync(
profile,
request.ExpectedRevision,
OpenClawSetupStates.Adopted,
"OpenClaw management was enabled for the adopted connection.",
cancellationToken);
}
public async Task<OpenClawSetupOperationDto<OpenClawSetupStatusDto>> DeleteAsync(
DeleteOpenClawConnectionRequest request,
CancellationToken cancellationToken = default)
{
var profile = await profiles.GetPrimaryAsync(cancellationToken);
var precondition = CheckProfilePreconditions(profile, request.ExpectedRevision);
if (precondition is not null)
return precondition;
var confirmation = ValidateEndpoint(
request.Endpoint,
profile!.TlsCertificateFingerprint,
GetAllowedInternalHosts(),
requireExternalFingerprint: false);
if (!confirmation.IsValid
|| !EndpointsEqual(confirmation.NormalizedEndpoint, profile.Endpoint))
{
return Failure(
OpenClawSetupStates.InvalidRequest,
"The endpoint confirmation does not match the active profile.",
"Enter the exact endpoint shown in setup status.",
BuildStatus(profile));
}
if (!string.Equals(
request.DeviceId?.Trim(),
profile.DeviceId,
StringComparison.Ordinal))
{
return Failure(
OpenClawSetupStates.InvalidRequest,
"The device id confirmation does not match the active profile.",
"Enter the exact device id shown in setup status.",
BuildStatus(profile));
}
try
{
var deleted = await profiles.DeletePrimaryAsync(
request.ExpectedRevision,
cancellationToken);
if (!deleted)
{
return Failure<OpenClawSetupStatusDto>(
OpenClawSetupStates.NotFound,
"No OpenClaw connection profile exists.",
"Refresh setup status.");
}
managementState?.SetEnabled(false);
if (deviceIdentityStore is not null
&& !string.IsNullOrWhiteSpace(profile.DeviceId)
&& Uri.TryCreate(profile.Endpoint, UriKind.Absolute, out var profileEndpoint))
{
var binding = GatewayConnector.BuildGatewayBinding(
profileEndpoint,
profile.TlsCertificateFingerprint);
await deviceIdentityStore.RemoveTokenAsync(
profile.DeviceId,
"operator",
binding,
cancellationToken);
}
await connector.DisconnectAsync(cancellationToken);
return Success(
OpenClawSetupStates.Removed,
"The Nexus connection profile and its bound device token were removed. OpenClaw resources were not changed.",
BuildStatus(null),
"The server-side Nexus device key is retained for future pairing; any separately configured bootstrap credential must be removed from server secret storage independently.");
}
catch (OpenClawConnectionProfileConcurrencyException exception)
{
return Failure<OpenClawSetupStatusDto>(
OpenClawSetupStates.ConcurrencyConflict,
exception.Message,
"Reload setup status before retrying.");
}
}
private async Task<OpenClawAdoptionInventoryDto> CaptureInventoryAsync(
CancellationToken cancellationToken)
{
var diagnostics = new List<string>();
var agents = await InvokeItemsAsync(
"agents.list",
new { },
["agents", "items"],
"agents",
diagnostics,
cancellationToken);
int? agentFileCount = null;
if (agents is not null && connector.Supports("agents.files.list"))
{
agentFileCount = 0;
foreach (var agent in agents)
{
var agentId = ReadString(agent, "id", "agentId");
if (string.IsNullOrWhiteSpace(agentId))
{
diagnostics.Add("agent-files: skipped one agent without an id");
continue;
}
var files = await InvokeItemsAsync(
"agents.files.list",
new { agentId },
["files", "items"],
"agent-files",
diagnostics,
cancellationToken);
if (files is null)
{
agentFileCount = null;
break;
}
agentFileCount += files.Count;
}
}
else if (!connector.Supports("agents.files.list"))
{
diagnostics.Add("agent-files: method not advertised");
}
var cronJobs = await InvokeItemsAsync(
"cron.list",
new { includeDisabled = true },
["jobs", "items"],
"cron",
diagnostics,
cancellationToken);
var models = await InvokeItemsAsync(
"models.list",
new { view = "configured" },
["models", "items"],
"models",
diagnostics,
cancellationToken);
var channels = await InvokeItemsAsync(
"channels.status",
new { },
["channels", "items", "accounts"],
"channels",
diagnostics,
cancellationToken,
allowObjectCollection: true);
var nodes = await InvokeItemsAsync(
"nodes.list",
new { },
["nodes", "items"],
"nodes",
diagnostics,
cancellationToken);
return new OpenClawAdoptionInventoryDto(
agents?.Count,
agentFileCount,
cronJobs?.Count,
models?.Count,
channels?.Count,
nodes?.Count,
diagnostics,
DateTimeOffset.UtcNow);
}
private async Task<IReadOnlyList<JsonNode>?> InvokeItemsAsync(
string method,
object parameters,
IReadOnlyList<string> collectionKeys,
string diagnosticName,
ICollection<string> diagnostics,
CancellationToken cancellationToken,
bool allowObjectCollection = false)
{
if (!connector.Supports(method))
{
diagnostics.Add($"{diagnosticName}: method not advertised");
return null;
}
try
{
var payload = await connector.InvokeAsync(
method,
parameters,
cancellationToken: cancellationToken);
var items = ReadItems(payload, collectionKeys, allowObjectCollection);
if (items is null)
diagnostics.Add($"{diagnosticName}: response did not contain a collection");
return items;
}
catch (OpenClawGatewayRpcException exception)
{
diagnostics.Add($"{diagnosticName}: gateway error {exception.Code}");
return null;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
diagnostics.Add($"{diagnosticName}: gateway response could not be read");
return null;
}
}
private static IReadOnlyList<JsonNode>? ReadItems(
JsonNode? payload,
IReadOnlyList<string> collectionKeys,
bool allowObjectCollection)
{
if (payload is JsonArray rootArray)
return rootArray.Where(item => item is not null).Cast<JsonNode>().ToList();
if (payload is not JsonObject root)
return null;
foreach (var key in collectionKeys)
{
if (root[key] is JsonArray array)
return array.Where(item => item is not null).Cast<JsonNode>().ToList();
if (allowObjectCollection && root[key] is JsonObject objectCollection)
return objectCollection.Select(item => item.Value).Where(item => item is not null).Cast<JsonNode>().ToList();
}
return null;
}
private OpenClawSetupStatusDto BuildStatus(OpenClawConnectionProfile? profile)
{
var experimentalBlocked = !ExternalClientIdentitySupported();
var state = experimentalBlocked
? OpenClawSetupStates.ExperimentalBlocked
: profile is null
? OpenClawSetupStates.NotConfigured
: connector.ConnectionState != GatewayConnectionState.Connected
? OpenClawSetupStates.Disconnected
: profile.AdoptionState;
var message = experimentalBlocked
? "The Attach & Adopt connector is experimental until OpenClaw supports an external Nexus operator identity."
: profile is null
? "No OpenClaw connection has been adopted."
: connector.StatusMessage;
var recovery = experimentalBlocked
? "Do not enable production management by impersonating OpenClaw's CLI or Control UI client identities."
: connector.ConnectionState == GatewayConnectionState.Connected
? null
: "Restore the configured Gateway connection and verify the profile.";
return new OpenClawSetupStatusDto(
OpenClawConnectionProfile.PrimaryProfileId,
state,
experimentalBlocked,
profile is not null,
profile?.Endpoint,
profile?.DiscoverySource,
profile?.AdoptionState ?? OpenClawAdoptionStates.None,
profile?.ManagementEnabled ?? false,
profile?.RequiredVersion ?? connector.RequiredVersion,
connector.GatewayVersion,
connector.ProtocolVersion,
profile?.DeviceId ?? connector.DeviceId,
connector.DeviceTokenConfigured,
connector.PairingRequired,
connector.PairingRequestId,
Sorted(connector.GrantedScopes),
Sorted(connector.AdvertisedMethods),
profile?.CapabilityHash,
profile?.Revision,
profile?.LastProbedAt,
profile?.LastVerifiedAt,
profile?.AdoptedAt,
profile?.UpdatedAt,
message,
recovery,
DateTimeOffset.UtcNow);
}
private async Task<OpenClawSetupOperationDto<OpenClawSetupStatusDto>> SaveStatusAsync(
OpenClawConnectionProfile profile,
int expectedRevision,
string state,
string message,
CancellationToken cancellationToken)
{
try
{
var saved = await profiles.SavePrimaryAsync(
profile,
expectedRevision,
cancellationToken);
managementState?.SetEnabled(saved.ManagementEnabled);
return Success(state, message, BuildStatus(saved));
}
catch (OpenClawConnectionProfileConcurrencyException exception)
{
return Failure<OpenClawSetupStatusDto>(
OpenClawSetupStates.ConcurrencyConflict,
exception.Message,
"Reload setup status before retrying.");
}
}
private OpenClawSetupOperationDto<OpenClawSetupStatusDto>? CheckProfilePreconditions(
OpenClawConnectionProfile? profile,
int expectedRevision)
=> CheckProfile<OpenClawSetupStatusDto>(profile, expectedRevision);
private OpenClawSetupOperationDto<T>? CheckProfile<T>(
OpenClawConnectionProfile? profile,
int expectedRevision)
{
if (profile is null)
{
return Failure<T>(
OpenClawSetupStates.NotFound,
"No OpenClaw connection profile exists.",
"Discover and attach an existing OpenClaw instance first.");
}
if (expectedRevision <= 0 || profile.Revision != expectedRevision)
{
return Failure<T>(
OpenClawSetupStates.ConcurrencyConflict,
"The OpenClaw connection profile changed since it was loaded.",
"Reload setup status and retry with the current revision.");
}
return null;
}
private (string State, string Message, string Recovery)? CheckLiveReadConnection(
OpenClawConnectionProfile profile)
{
if (!ExternalClientIdentitySupported())
{
return (
OpenClawSetupStates.ExperimentalBlocked,
"Adoption remains blocked until OpenClaw supports the external Nexus operator identity.",
"Use only a pinned OpenClaw release with an officially supported client id.");
}
if (!EndpointsEqual(profile.Endpoint, GetCurrentConnectorEndpoint()))
{
return (
OpenClawSetupStates.DynamicEndpointUnsupported,
"The adopted profile is not the endpoint of the running connector.",
"Update server configuration and restart Nexus before verification.");
}
if (connector.ConnectionState != GatewayConnectionState.Connected)
{
return (
OpenClawSetupStates.GatewayUnavailable,
"The OpenClaw Gateway is not connected.",
"Restore the Gateway connection before adoption.");
}
if (!VersionMatches() || connector.ProtocolVersion != 4)
{
return (
OpenClawSetupStates.GatewayUnavailable,
"The connected OpenClaw version or protocol no longer matches the verified contract.",
"Restore the pinned OpenClaw version and protocol v4 before adoption.");
}
if (!connector.GrantedScopes.Contains("operator.read"))
{
return (
OpenClawSetupStates.ScopeUpgradeRequired,
"OpenClaw has not granted operator.read.",
"Approve the read scope before adoption.");
}
if (profile.AdoptionState != OpenClawAdoptionStates.Adopted
&& !HasReadOnlyScope())
{
return (
OpenClawSetupStates.ExcessiveScope,
"The initial adoption connection must contain only operator.read.",
"Reconnect with operator.read only and complete adoption before requesting management.");
}
return null;
}
private string? GetCurrentConnectorEndpoint()
{
if (!string.IsNullOrWhiteSpace(connector.ActiveEndpoint))
return connector.ActiveEndpoint;
var configured = configuration["Integrations:OpenClaw:BaseUrl"];
if (string.IsNullOrWhiteSpace(configured)
|| !Uri.TryCreate(configured.Trim(), UriKind.Absolute, out var uri))
{
return null;
}
var scheme = uri.Scheme.ToLowerInvariant() switch
{
"http" => "ws",
"https" => "wss",
"ws" => "ws",
"wss" => "wss",
_ => null
};
if (scheme is null)
return null;
var path = configuration["GatewayConnector:WebSocketPath"];
var builder = new UriBuilder(uri)
{
Scheme = scheme,
Path = string.IsNullOrWhiteSpace(path) ? "/" : NormalizePath(path),
Query = string.Empty,
Fragment = string.Empty
};
var validation = ValidateEndpoint(
builder.Uri.AbsoluteUri,
configuration["GatewayConnector:TlsFingerprint"],
GetAllowedInternalHosts(),
requireExternalFingerprint: false);
return validation.NormalizedEndpoint;
}
private (string? Secret, string? Error) ResolveBootstrapSecret(
AttachOpenClawRequest request)
{
if (!string.IsNullOrWhiteSpace(request.BootstrapToken))
return (request.BootstrapToken, null);
var reference = request.BootstrapSecretReference?.Trim();
if (string.IsNullOrWhiteSpace(reference))
return (null, null);
string? secret;
if (reference.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
{
var variable = reference[4..];
if (string.IsNullOrWhiteSpace(variable)
|| variable.Length > 128
|| variable.Any(character =>
!(char.IsAsciiLetterOrDigit(character) || character == '_')))
{
return (null, "The environment SecretRef is invalid.");
}
secret = Environment.GetEnvironmentVariable(variable);
}
else
{
if (reference.Length > 128
|| reference.Any(character =>
!(char.IsAsciiLetterOrDigit(character)
|| character is '_' or '-' or '.')))
{
return (null, "The server SecretRef is invalid.");
}
secret = configuration[$"OpenClawSetup:BootstrapSecretReferences:{reference}"];
}
return string.IsNullOrWhiteSpace(secret)
? (null, "The server-side bootstrap SecretRef could not be resolved.")
: (secret, null);
}
private async Task WaitForConnectorTransitionAsync(
CancellationToken cancellationToken)
{
for (var attempt = 0; attempt < 50; attempt++)
{
if (connector.ConnectionState is GatewayConnectionState.Connected
or GatewayConnectionState.Failed
|| connector.PairingRequired)
{
return;
}
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
}
}
private EndpointValidation ValidateEndpoint(
string? endpoint,
string? tlsCertificateFingerprint,
IReadOnlySet<string> allowedInternalHosts,
bool requireExternalFingerprint)
{
if (string.IsNullOrWhiteSpace(endpoint)
|| endpoint.Length > 2048
|| !Uri.TryCreate(endpoint.Trim(), UriKind.Absolute, out var uri))
{
return EndpointValidation.Invalid("A valid absolute OpenClaw WebSocket endpoint is required.");
}
if (uri.Scheme is not ("ws" or "wss"))
return EndpointValidation.Invalid("The endpoint scheme must be ws:// or wss://.");
if (!string.IsNullOrEmpty(uri.UserInfo))
return EndpointValidation.Invalid("Credentials are not allowed in an OpenClaw endpoint URL.");
if (!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment))
return EndpointValidation.Invalid("Query strings and fragments are not allowed in an OpenClaw endpoint URL.");
if (string.IsNullOrWhiteSpace(uri.Host)
|| uri.Host is "0.0.0.0" or "::")
{
return EndpointValidation.Invalid("The endpoint must identify one concrete host.");
}
var host = uri.Host.TrimEnd('.').ToLowerInvariant();
var isInternal = uri.IsLoopback || allowedInternalHosts.Contains(host);
if (uri.Scheme == "ws" && !isInternal)
{
return EndpointValidation.Invalid(
"Plain ws:// is restricted to loopback or explicitly allowed internal hosts.",
requiresTlsFingerprint: true);
}
var normalizedFingerprint = NormalizeFingerprint(tlsCertificateFingerprint);
if (!string.IsNullOrWhiteSpace(tlsCertificateFingerprint)
&& normalizedFingerprint is null)
{
return EndpointValidation.Invalid(
"The TLS certificate fingerprint must be a 64-character SHA-256 hexadecimal value.",
requiresTlsFingerprint: !isInternal);
}
if (!isInternal
&& requireExternalFingerprint
&& normalizedFingerprint is null)
{
return EndpointValidation.Invalid(
"External OpenClaw endpoints require a confirmed SHA-256 TLS certificate fingerprint.",
requiresTlsFingerprint: true);
}
var builder = new UriBuilder(uri)
{
Scheme = uri.Scheme.ToLowerInvariant(),
Host = host,
Path = NormalizePath(uri.AbsolutePath),
Query = string.Empty,
Fragment = string.Empty
};
return EndpointValidation.Valid(
builder.Uri.AbsoluteUri,
requiresTlsFingerprint: !isInternal);
}
private IReadOnlySet<string> GetAllowedInternalHosts()
{
var hosts = new HashSet<string>(
BuiltInInternalHosts,
StringComparer.OrdinalIgnoreCase);
foreach (var child in configuration
.GetSection("OpenClawSetup:AllowedInternalHosts")
.GetChildren())
{
var value = child.Value?.Trim().TrimEnd('.');
if (!string.IsNullOrWhiteSpace(value)
&& value.Length <= 253
&& !value.Contains('/')
&& !value.Contains(':'))
{
hosts.Add(value.ToLowerInvariant());
}
}
return hosts;
}
private bool ExternalClientIdentitySupported()
=> configuration.GetValue<bool>(
"OpenClawSetup:ExternalClientIdentitySupported",
false);
private bool HasReadOnlyScope()
=> connector.GrantedScopes.Contains("operator.read")
&& connector.GrantedScopes.All(scope =>
string.Equals(scope, "operator.read", StringComparison.Ordinal));
private bool VersionMatches()
=> string.IsNullOrWhiteSpace(connector.RequiredVersion)
|| string.Equals(
connector.RequiredVersion,
connector.GatewayVersion,
StringComparison.Ordinal);
private string BuildCapabilityHash()
{
var contract = string.Join(
"\n",
new[]
{
connector.GatewayVersion ?? string.Empty,
connector.RequiredVersion ?? string.Empty,
connector.ProtocolVersion?.ToString() ?? string.Empty,
string.Join(",", Sorted(connector.GrantedScopes)),
string.Join(",", Sorted(connector.AdvertisedMethods)),
string.Join(",", Sorted(connector.AdvertisedEvents))
});
return Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(contract)))
.ToLowerInvariant();
}
private static string? ValidateAttachRequest(AttachOpenClawRequest request)
{
if (string.IsNullOrWhiteSpace(request.DiscoverySource)
|| request.DiscoverySource.Length > 80)
{
return "Discovery source is required and must contain at most 80 characters.";
}
if (!AllowedDiscoverySources.Contains(
request.DiscoverySource.Trim().ToLowerInvariant()))
{
return "Discovery source must be configured, docker, loopback, host, mdns, or manual.";
}
if (!string.IsNullOrWhiteSpace(request.BootstrapToken)
&& !string.IsNullOrWhiteSpace(request.BootstrapSecretReference))
{
return "Provide either a one-time bootstrap token or a server secret reference, not both.";
}
if (request.BootstrapToken?.Length > 4096)
return "Bootstrap token exceeds the accepted request size.";
if (request.BootstrapSecretReference?.Length > 240)
return "Bootstrap secret reference must contain at most 240 characters.";
return null;
}
private static string NormalizeDiscoverySource(string source)
{
var normalized = source.Trim().ToLowerInvariant();
return normalized.Length <= 80 ? normalized : normalized[..80];
}
private static string NormalizePath(string? path)
{
var normalized = string.IsNullOrWhiteSpace(path) ? "/" : path.Trim();
if (!normalized.StartsWith('/'))
normalized = "/" + normalized;
return normalized;
}
private static string? NormalizeFingerprint(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
var normalized = value.Trim();
if (normalized.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
|| normalized.StartsWith("sha256/", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[7..];
}
normalized = normalized.Replace(":", string.Empty, StringComparison.Ordinal)
.Replace("-", string.Empty, StringComparison.Ordinal)
.ToLowerInvariant();
return normalized.Length == 64
&& normalized.All(character =>
character is >= '0' and <= '9'
or >= 'a' and <= 'f')
? normalized
: null;
}
private static bool EndpointsEqual(string? left, string? right)
=> !string.IsNullOrWhiteSpace(left)
&& !string.IsNullOrWhiteSpace(right)
&& string.Equals(left, right, StringComparison.OrdinalIgnoreCase);
private static IReadOnlyList<string> Sorted(IEnumerable<string> values)
=> values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.OrderBy(value => value, StringComparer.Ordinal)
.ToList();
private static string? ReadString(JsonNode node, params string[] keys)
{
if (node is not JsonObject item)
return null;
foreach (var key in keys)
{
try
{
var value = item[key]?.GetValue<string>();
if (!string.IsNullOrWhiteSpace(value))
return value.Trim();
}
catch
{
// Treat shape drift as a missing field and keep the inventory safe.
}
}
return null;
}
private static OpenClawSetupOperationDto<T> Success<T>(
string state,
string message,
T data,
string? recovery = null)
=> new(
true,
state,
message,
data,
recovery,
DateTimeOffset.UtcNow);
private static OpenClawSetupOperationDto<T> Failure<T>(
string state,
string message,
string? recovery,
T? data = default)
=> new(
false,
state,
message,
data,
recovery,
DateTimeOffset.UtcNow);
private sealed record EndpointValidation(
bool IsValid,
string? NormalizedEndpoint,
bool RequiresTlsFingerprint,
string? Error)
{
public static EndpointValidation Valid(
string normalizedEndpoint,
bool requiresTlsFingerprint)
=> new(true, normalizedEndpoint, requiresTlsFingerprint, null);
public static EndpointValidation Invalid(
string error,
bool requiresTlsFingerprint = false)
=> new(false, null, requiresTlsFingerprint, error);
}
}