1253 lines
47 KiB
C#
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 & 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);
|
|
}
|
|
}
|