using System.Globalization; using System.Security.Cryptography; using System.Diagnostics; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.Options; using Npgsql; using Nexus.Api.Data; using Nexus.Api.Models; using Nexus.Api.Observability; namespace Nexus.Api.Services; /// /// Durable Nexus approval workflow for OpenClaw-owned agents. This service /// deliberately does not retry agents.create: an uncertain dispatch can only /// transition through a read-only reconciliation attempt requested by an /// owner. /// public sealed partial class AgentProposalService( NexusDbContext db, IGatewayConnector connector, IOpenClawAgentConfigurationService agentConfiguration, IOpenClawWriteGate writeGate, IOptions provisioningOptions, AgentProvisioningSignal signal, ILogger logger) : IAgentProposalService { private const int MaxNameLength = 80; private const int MaxRoleLength = 160; private const int MaxDescriptionLength = 4000; private const int MaxModelLength = 240; private const int MaxIdempotencyKeyLength = 200; private static readonly string[] StandardFileNames = [ "AGENTS.md", "SOUL.md", "TOOLS.md", "IDENTITY.md", "USER.md", "HEARTBEAT.md", "BOOTSTRAP.md", "MEMORY.md" ]; private static readonly IReadOnlyDictionary CanonicalFileNames = StandardFileNames.ToDictionary( name => name, name => name, StringComparer.OrdinalIgnoreCase); private static readonly JsonSerializerOptions InternalJsonOptions = new( JsonSerializerDefaults.Web); public async Task GetCreateOptionsAsync( CancellationToken cancellationToken = default) { var gate = await EvaluateGateAsync(cancellationToken); var agentInventory = await TryReadAgentInventoryAsync(cancellationToken); var agents = agentInventory.Items; var models = await TryListModelsAsync(cancellationToken); return new AgentCreateOptionsDto( CanSubmitProposal: true, CanProvision: gate.Ok, State: gate.State, Reason: gate.Message, WorkspaceRoot: NormalizeWorkspaceRoot(provisioningOptions.Value.WorkspaceRoot) ?? "not_configured", ExistingAgentIds: agents .Select(item => item.AgentId) .Order(StringComparer.Ordinal) .ToArray(), Models: models, StandardFiles: StandardFileNames, CheckedAt: DateTimeOffset.UtcNow); } public async Task GetAsync( int limit = 50, string? cursor = null, string? status = null, CancellationToken cancellationToken = default) { var boundedLimit = Math.Clamp(limit, 1, 200); var query = db.AgentProposals.AsNoTracking(); if (!string.IsNullOrWhiteSpace(status)) query = query.Where(item => item.Status == status.Trim().ToLowerInvariant()); if (!string.IsNullOrWhiteSpace(cursor)) { if (!TryDecodeCursor(cursor, out var beforeCreatedAt, out var beforeId)) { throw new AgentProposalValidationException( "cursor", "The proposal cursor is invalid."); } query = beforeId is null ? query.Where(item => item.CreatedAt < beforeCreatedAt) : query.Where(item => item.CreatedAt < beforeCreatedAt || (item.CreatedAt == beforeCreatedAt && item.Id.CompareTo(beforeId.Value) < 0)); } var rows = await query .OrderByDescending(item => item.CreatedAt) .ThenByDescending(item => item.Id) .Take(boundedLimit + 1) .ToListAsync(cancellationToken); var hasMore = rows.Count > boundedLimit; var selected = rows.Take(boundedLimit).ToArray(); return new AgentProposalCollectionDto( selected.Select(item => Map(item, includeFileContent: false)).ToArray(), hasMore && selected.Length > 0 ? EncodeCursor(selected[^1].CreatedAt, selected[^1].Id) : null, DateTimeOffset.UtcNow); } public async Task GetByIdAsync( Guid id, bool includeFileContent = true, CancellationToken cancellationToken = default) { var proposal = await db.AgentProposals .AsNoTracking() .SingleOrDefaultAsync(item => item.Id == id, cancellationToken); return proposal is null ? null : Map(proposal, includeFileContent); } public async Task CreateAsync( CreateAgentProposalRequest request, string source, OpenClawInvocationMetadata invocation, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); ValidateInvocation(invocation); var normalizedSource = NormalizeSource(source); var normalized = NormalizeProposal(request); var idempotencyKey = normalizedSource == "manual" || string.IsNullOrWhiteSpace(request.ClientRequestId) ? invocation.IdempotencyKey : request.ClientRequestId.Trim(); ValidateIdempotencyKey(idempotencyKey); var requestHash = Hash(JsonSerializer.Serialize( new { Source = normalizedSource, normalized.Name, normalized.AgentId, normalized.Role, normalized.Description, normalized.Model, normalized.Emoji, normalized.Avatar, normalized.Workspace, normalized.FilesHash }, InternalJsonOptions)); var keyHash = Hash(idempotencyKey); var replay = await ReplayAsync( "agent-proposal.create", keyHash, requestHash, invocation.CorrelationId, cancellationToken); if (replay is not null) return replay; var now = DateTimeOffset.UtcNow; var proposal = new AgentProposal { Source = normalizedSource, RequestedName = normalized.Name, RequestedAgentId = normalized.AgentId, Role = normalized.Role, Description = normalized.Description, Model = normalized.Model, Emoji = normalized.Emoji, Avatar = normalized.Avatar, Workspace = normalized.Workspace, StandardFilesJson = normalized.FilesJson, StandardFilesHash = normalized.FilesHash, Status = AgentProposalStates.AwaitingApproval, RequestedBy = NormalizeActor(invocation.Actor), CreatedAt = now, UpdatedAt = now }; var claim = Claim( "agent-proposal.create", keyHash, requestHash, proposal.Id, "completed", AgentProposalStates.AwaitingApproval); db.AgentProposals.Add(proposal); db.OperationClaims.Add(claim); db.OutboxEvents.Add(Event( "agent.proposal.created", proposal, new { proposal.Id, proposal.Source, proposal.Status })); try { await db.SaveChangesAsync(cancellationToken); } catch (DbUpdateException) { db.ChangeTracker.Clear(); var concurrentReplay = await ReplayAsync( "agent-proposal.create", keyHash, requestHash, invocation.CorrelationId, cancellationToken); if (concurrentReplay is not null) return concurrentReplay; throw; } return Result( true, AgentProposalStates.AwaitingApproval, "Agent proposal recorded. OpenClaw has not been mutated.", proposal, invocation.CorrelationId); } public Task ApproveAsync( Guid id, AgentProposalActionRequest request, OpenClawInvocationMetadata invocation, CancellationToken cancellationToken = default) => MutateApprovalAsync( id, request, invocation, "agent-proposal.approve", cancellationToken, approve: true); public Task RejectAsync( Guid id, AgentProposalActionRequest request, OpenClawInvocationMetadata invocation, CancellationToken cancellationToken = default) => MutateApprovalAsync( id, request, invocation, "agent-proposal.reject", cancellationToken, approve: false); public async Task RetryAsync( Guid id, AgentProposalActionRequest request, OpenClawInvocationMetadata invocation, CancellationToken cancellationToken = default) { ValidateInvocation(invocation); ValidateReason(request.Reason); var requestHash = Hash($"{id:N}\n{request.ExpectedRevision}\n{request.Reason?.Trim()}"); var keyHash = Hash(invocation.IdempotencyKey); var replay = await ReplayAsync( "agent-proposal.retry", keyHash, requestHash, invocation.CorrelationId, cancellationToken); if (replay is not null) return replay; var proposal = await db.AgentProposals .Include(item => item.ProvisionRequests) .SingleOrDefaultAsync(item => item.Id == id, cancellationToken); if (proposal is null) { return await RecordFailureClaimAsync( "agent-proposal.retry", keyHash, requestHash, id, "not_found", "Agent proposal was not found.", invocation.CorrelationId, cancellationToken); } if (proposal.Revision != request.ExpectedRevision) { return await RecordFailureClaimAsync( "agent-proposal.retry", keyHash, requestHash, id, "concurrency_conflict", "Agent proposal changed since it was loaded.", invocation.CorrelationId, cancellationToken, proposal, "Reload the proposal and retry with its current revision."); } if (proposal.Status is not ( AgentProposalStates.Failed or AgentProposalStates.Partial or AgentProposalStates.InDoubt)) { return await RecordFailureClaimAsync( "agent-proposal.retry", keyHash, requestHash, id, "invalid_state", $"Proposal state '{proposal.Status}' cannot be retried.", invocation.CorrelationId, cancellationToken, proposal); } var gate = await EvaluateGateAsync(cancellationToken); if (!gate.Ok) { return await RecordFailureClaimAsync( "agent-proposal.retry", keyHash, requestHash, id, gate.State, gate.Message ?? "Agent provisioning is blocked by the current OpenClaw policy.", invocation.CorrelationId, cancellationToken, proposal, gate.Recovery); } var stage = proposal.Status == AgentProposalStates.InDoubt ? AgentProvisionStages.ReconcileAgent : !string.IsNullOrWhiteSpace(proposal.OpenClawAgentId) ? AgentProvisionStages.FinalizeFiles : AgentProvisionStages.CreateAgent; var provision = BuildProvisionRequest( proposal, stage, invocation, keyHash); proposal.Status = AgentProposalStates.Provisioning; proposal.LastErrorCode = null; proposal.LastErrorMessage = null; Touch(proposal); db.AgentProvisionRequests.Add(provision); db.OperationClaims.Add(Claim( "agent-proposal.retry", keyHash, requestHash, proposal.Id, "completed", AgentProposalStates.Provisioning)); db.OutboxEvents.Add(Event( "agent.provision.retry_requested", proposal, new { ProposalId = proposal.Id, ProvisionRequestId = provision.Id, provision.Attempt, provision.Stage })); var retryRace = await SaveMutationOrResolveRaceAsync( "agent-proposal.retry", keyHash, requestHash, proposal.Id, invocation.CorrelationId, cancellationToken); if (retryRace is not null) return retryRace; signal.Notify(); return Result( true, AgentProposalStates.Provisioning, stage == AgentProvisionStages.ReconcileAgent ? "A read-only reconciliation was queued. agents.create will not be repeated." : "An explicit provisioning retry was queued.", proposal, invocation.CorrelationId); } public async Task RecoverInterruptedRequestsAsync( CancellationToken cancellationToken = default) { var interrupted = await db.AgentProvisionRequests .Include(item => item.Proposal) .Where(item => item.Status == AgentProvisionRequestStates.Dispatching) .ToListAsync(cancellationToken); if (interrupted.Count == 0) return; foreach (var request in interrupted) { var createdAgentKnown = !string.IsNullOrWhiteSpace(request.OpenClawAgentId) || !string.IsNullOrWhiteSpace(request.Proposal.OpenClawAgentId); request.Stage = createdAgentKnown ? AgentProvisionStages.FinalizeFiles : AgentProvisionStages.ReconcileAgent; request.Status = AgentProvisionRequestStates.Queued; request.LastErrorCode = "process_interrupted"; request.LastErrorMessage = createdAgentKnown ? "Nexus restarted while agent files were being verified; safe file finalization was queued." : "Nexus restarted after the agents.create dispatch boundary; read-only reconciliation was queued."; request.LeaseOwner = null; request.LeaseUntil = null; request.CompletedAt = null; Touch(request); request.Proposal.Status = createdAgentKnown ? AgentProposalStates.Partial : AgentProposalStates.InDoubt; request.Proposal.LastErrorCode = request.LastErrorCode; request.Proposal.LastErrorMessage = request.LastErrorMessage; Touch(request.Proposal); db.OutboxEvents.Add(Event( "agent.provision.interrupted", request.Proposal, new { request.ProposalId, ProvisionRequestId = request.Id, ProvisionRequestStatus = request.Status, ProposalStatus = request.Proposal.Status, request.Stage })); } await db.SaveChangesAsync(cancellationToken); signal.Notify(); } public async Task ProcessNextAsync( CancellationToken cancellationToken = default) { var request = await ClaimNextRequestAsync(cancellationToken); if (request is null) return false; using var activity = NexusTelemetry.ActivitySource.StartActivity( "nexus.agent.provision", ActivityKind.Internal); activity?.SetTag("nexus.provision.stage", request.Stage); var stopwatch = Stopwatch.StartNew(); var outcome = "processed"; try { var gate = await EvaluateGateAsync(cancellationToken); if (!gate.Ok) { outcome = "blocked"; await CompleteFailureAsync( request, AgentProvisionRequestStates.Blocked, AgentProposalStates.Failed, gate.State, gate.Message ?? "Agent provisioning is blocked by the current OpenClaw policy.", cancellationToken); return true; } switch (request.Stage) { case AgentProvisionStages.ReconcileAgent: await ReconcileOnlyAsync(request, cancellationToken); break; case AgentProvisionStages.FinalizeFiles: await FinalizeFilesAsync(request, cancellationToken); break; case AgentProvisionStages.CreateAgent: await CreateAgentAsync(request, cancellationToken); break; default: outcome = "invalid_stage"; await CompleteFailureAsync( request, AgentProvisionRequestStates.Failed, AgentProposalStates.Failed, "invalid_stage", "Provisioning request contains an unsupported stage.", cancellationToken); break; } return true; } catch { outcome = "error"; throw; } finally { stopwatch.Stop(); activity?.SetTag("nexus.provision.outcome", outcome); NexusTelemetry.AgentProvisionDuration.Record( stopwatch.Elapsed.TotalMilliseconds, new KeyValuePair("stage", request.Stage), new KeyValuePair("outcome", outcome)); } } private async Task ClaimNextRequestAsync( CancellationToken cancellationToken) { IDbContextTransaction? transaction = null; try { var now = DateTimeOffset.UtcNow; IQueryable query; if (db.Database.IsRelational()) { transaction = await db.Database.BeginTransactionAsync( cancellationToken); query = db.AgentProvisionRequests.FromSqlInterpolated( $""" SELECT * FROM "AgentProvisionRequests" WHERE "Status" = 'queued' OR ( "Status" = 'dispatching' AND "LeaseUntil" IS NOT NULL AND "LeaseUntil" <= {now} ) ORDER BY "CreatedAt" LIMIT 1 FOR UPDATE SKIP LOCKED """); } else { query = db.AgentProvisionRequests .Where(item => item.Status == AgentProvisionRequestStates.Queued || ( item.Status == AgentProvisionRequestStates.Dispatching && item.LeaseUntil != null && item.LeaseUntil <= now)) .OrderBy(item => item.CreatedAt); } var request = await query .Include(item => item.Proposal) .FirstOrDefaultAsync(cancellationToken); if (request is null) { if (transaction is not null) await transaction.CommitAsync(cancellationToken); return null; } var reclaimed = request.Status == AgentProvisionRequestStates.Dispatching; if (reclaimed) { var createdAgentKnown = !string.IsNullOrWhiteSpace(request.OpenClawAgentId) || !string.IsNullOrWhiteSpace( request.Proposal.OpenClawAgentId); request.Stage = createdAgentKnown ? AgentProvisionStages.FinalizeFiles : AgentProvisionStages.ReconcileAgent; request.LastErrorCode = "lease_expired"; request.LastErrorMessage = createdAgentKnown ? "The previous file-finalization lease expired; safe finalization was reclaimed." : "The previous create dispatch lease expired; read-only reconciliation was reclaimed."; request.Proposal.Status = createdAgentKnown ? AgentProposalStates.Partial : AgentProposalStates.InDoubt; request.Proposal.LastErrorCode = request.LastErrorCode; request.Proposal.LastErrorMessage = request.LastErrorMessage; Touch(request.Proposal); db.OutboxEvents.Add(Event( "agent.provision.lease_reclaimed", request.Proposal, new { request.ProposalId, ProvisionRequestId = request.Id, request.Stage })); } request.Status = AgentProvisionRequestStates.Dispatching; request.DispatchStartedAt = now; request.LeaseOwner = Truncate(Environment.MachineName, 160); request.LeaseUntil = now.AddSeconds(Math.Clamp( provisioningOptions.Value.LeaseSeconds, 15, 300)); Touch(request); await db.SaveChangesAsync(cancellationToken); if (transaction is not null) await transaction.CommitAsync(cancellationToken); return request; } finally { if (transaction is not null) await transaction.DisposeAsync(); } } private async Task MutateApprovalAsync( Guid id, AgentProposalActionRequest request, OpenClawInvocationMetadata invocation, string operation, CancellationToken cancellationToken, bool approve) { ValidateInvocation(invocation); ValidateReason(request.Reason); var requestHash = Hash($"{id:N}\n{request.ExpectedRevision}\n{request.Reason?.Trim()}"); var keyHash = Hash(invocation.IdempotencyKey); var replay = await ReplayAsync( operation, keyHash, requestHash, invocation.CorrelationId, cancellationToken); if (replay is not null) return replay; var proposal = await db.AgentProposals .Include(item => item.ProvisionRequests) .SingleOrDefaultAsync(item => item.Id == id, cancellationToken); if (proposal is null) { return await RecordFailureClaimAsync( operation, keyHash, requestHash, id, "not_found", "Agent proposal was not found.", invocation.CorrelationId, cancellationToken); } if (proposal.Revision != request.ExpectedRevision) { return await RecordFailureClaimAsync( operation, keyHash, requestHash, id, "concurrency_conflict", "Agent proposal changed since it was loaded.", invocation.CorrelationId, cancellationToken, proposal, "Reload the proposal and retry with its current revision."); } if (proposal.Status != AgentProposalStates.AwaitingApproval) { return await RecordFailureClaimAsync( operation, keyHash, requestHash, id, "invalid_state", $"Proposal state '{proposal.Status}' cannot be {(approve ? "approved" : "rejected")}.", invocation.CorrelationId, cancellationToken, proposal); } if (!approve) { proposal.Status = AgentProposalStates.Rejected; proposal.RejectedBy = NormalizeActor(invocation.Actor); proposal.RejectionReason = NormalizeOptional(request.Reason, 1000); proposal.RejectedAt = DateTimeOffset.UtcNow; Touch(proposal); db.OperationClaims.Add(Claim( operation, keyHash, requestHash, proposal.Id, "completed", AgentProposalStates.Rejected)); db.OutboxEvents.Add(Event( "agent.proposal.rejected", proposal, new { proposal.Id, proposal.Status })); var race = await SaveMutationOrResolveRaceAsync( operation, keyHash, requestHash, proposal.Id, invocation.CorrelationId, cancellationToken); if (race is not null) return race; return Result( true, AgentProposalStates.Rejected, "Agent proposal rejected. OpenClaw was not mutated.", proposal, invocation.CorrelationId); } var gate = await EvaluateGateAsync(cancellationToken); if (!gate.Ok) { return await RecordFailureClaimAsync( operation, keyHash, requestHash, id, gate.State, gate.Message ?? "Agent provisioning is blocked by the current OpenClaw policy.", invocation.CorrelationId, cancellationToken, proposal, gate.Recovery); } var inventory = await TryReadAgentInventoryAsync(cancellationToken); if (!inventory.Success) { return await RecordFailureClaimAsync( operation, keyHash, requestHash, id, "gateway_unavailable", "The live OpenClaw agent inventory could not be verified.", invocation.CorrelationId, cancellationToken, proposal, "Restore agents.list access before approving an agent proposal."); } var existingAgents = inventory.Items; if (existingAgents.Any(agent => string.Equals( agent.AgentId, proposal.RequestedAgentId, StringComparison.Ordinal))) { return await RecordFailureClaimAsync( operation, keyHash, requestHash, id, "agent_exists", $"OpenClaw already contains agent '{proposal.RequestedAgentId}'.", invocation.CorrelationId, cancellationToken, proposal, "Choose a different name or refresh the live agent inventory."); } proposal.Status = AgentProposalStates.Provisioning; proposal.ApprovedBy = NormalizeActor(invocation.Actor); proposal.ApprovedAt = DateTimeOffset.UtcNow; proposal.LastErrorCode = null; proposal.LastErrorMessage = null; Touch(proposal); var provision = BuildProvisionRequest( proposal, AgentProvisionStages.CreateAgent, invocation, keyHash); db.AgentProvisionRequests.Add(provision); db.OperationClaims.Add(Claim( operation, keyHash, requestHash, proposal.Id, "completed", AgentProposalStates.Provisioning)); db.OutboxEvents.Add(Event( "agent.provision.requested", proposal, new { ProposalId = proposal.Id, ProvisionRequestId = provision.Id, provision.Attempt, provision.Stage })); var approvalRace = await SaveMutationOrResolveRaceAsync( operation, keyHash, requestHash, proposal.Id, invocation.CorrelationId, cancellationToken); if (approvalRace is not null) return approvalRace; signal.Notify(); return Result( true, AgentProposalStates.Provisioning, "Owner approval recorded and provisioning queued.", proposal, invocation.CorrelationId); } private async Task CreateAgentAsync( AgentProvisionRequest request, CancellationToken cancellationToken) { var proposal = request.Proposal; var before = await TryReadAgentInventoryAsync(cancellationToken); if (!before.Success) { await CompleteFailureAsync( request, AgentProvisionRequestStates.Failed, AgentProposalStates.Failed, "inventory_unavailable", "The live agent inventory could not be verified before agents.create.", cancellationToken); return; } if (before.Items.Any(agent => string.Equals( agent.AgentId, proposal.RequestedAgentId, StringComparison.Ordinal))) { await CompleteFailureAsync( request, AgentProvisionRequestStates.Failed, AgentProposalStates.Failed, "agent_exists", $"OpenClaw already contains agent '{proposal.RequestedAgentId}'.", cancellationToken); return; } string resolvedWorkspace; try { resolvedWorkspace = await ResolveWorkspaceForNewAgentAsync( proposal.RequestedAgentId, cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { await CompleteFailureAsync( request, AgentProvisionRequestStates.Failed, AgentProposalStates.Failed, "workspace_resolution_cancelled", "Provisioning stopped before agents.create while resolving the live OpenClaw workspace configuration.", CancellationToken.None); return; } catch (Exception exception) { logger.LogWarning( exception, "Live OpenClaw workspace resolution failed for proposal {ProposalId}", proposal.Id); await CompleteFailureAsync( request, AgentProvisionRequestStates.Failed, AgentProposalStates.Failed, "workspace_config_unavailable", "The live OpenClaw configuration could not be verified before agents.create.", CancellationToken.None); return; } if (!string.Equals( proposal.Workspace, resolvedWorkspace, StringComparison.Ordinal)) { proposal.Workspace = resolvedWorkspace; Touch(proposal); db.OutboxEvents.Add(Event( "agent.proposal.workspace_resolved", proposal, new { ProposalId = proposal.Id, proposal.RequestedAgentId })); await db.SaveChangesAsync(cancellationToken); } try { var parameters = new JsonObject { ["name"] = proposal.RequestedName, ["workspace"] = proposal.Workspace }; if (!string.IsNullOrWhiteSpace(proposal.Model)) parameters["model"] = proposal.Model; if (!string.IsNullOrWhiteSpace(proposal.Emoji)) parameters["emoji"] = proposal.Emoji; if (!string.IsNullOrWhiteSpace(proposal.Avatar)) parameters["avatar"] = proposal.Avatar; var response = await connector.InvokeAsync( "agents.create", parameters, timeout: TimeSpan.FromSeconds(30), cancellationToken: cancellationToken, invocationContext: OpenClawInvocationContext.Create( actor: request.Actor, idempotencyKey: $"agent-provision:{request.Id:N}", correlationId: request.CorrelationId, traceParent: request.TraceParent, includeIdempotencyParameter: false)); var returnedAgentId = ReadString(response, "agentId"); var returnedWorkspace = ReadString(response, "workspace"); if (ReadBool(response, "ok") == false || string.IsNullOrWhiteSpace(returnedAgentId)) { throw new OpenClawGatewayRpcException( "INVALID_CREATE_RESPONSE", "OpenClaw did not return a confirmed agent identifier."); } var readBack = await TryReadAgentInventoryAsync(cancellationToken); var verified = readBack.Success ? readBack.Items.SingleOrDefault(agent => string.Equals( agent.AgentId, returnedAgentId, StringComparison.Ordinal)) : null; if (verified is null) { await CompleteFailureAsync( request, AgentProvisionRequestStates.InDoubt, AgentProposalStates.InDoubt, "create_not_visible", "agents.create returned success, but the new agent was not visible in agents.list.", cancellationToken); return; } proposal.OpenClawAgentId = verified.AgentId; proposal.OpenClawWorkspace = returnedWorkspace ?? verified.Workspace ?? proposal.Workspace; request.OpenClawAgentId = verified.AgentId; Touch(proposal); Touch(request); await db.SaveChangesAsync(cancellationToken); await FinalizeFilesAsync(request, cancellationToken); } catch (OperationCanceledException) { await CompleteFailureAsync( request, AgentProvisionRequestStates.InDoubt, AgentProposalStates.InDoubt, "create_dispatch_cancelled", "The request crossed the agents.create dispatch boundary before cancellation.", CancellationToken.None); } catch (Exception exception) { logger.LogWarning( exception, "OpenClaw agent create did not complete for proposal {ProposalId}", proposal.Id); var reconciliation = await TryReadAgentInventoryAsync( CancellationToken.None); var reconciled = reconciliation.Success ? reconciliation.Items.SingleOrDefault(item => string.Equals( item.AgentId, proposal.RequestedAgentId, StringComparison.Ordinal)) : null; if (reconciled is not null) { proposal.OpenClawAgentId = reconciled.AgentId; proposal.OpenClawWorkspace = reconciled.Workspace ?? proposal.Workspace; request.OpenClawAgentId = reconciled.AgentId; Touch(proposal); Touch(request); await db.SaveChangesAsync(CancellationToken.None); await FinalizeFilesAsync(request, CancellationToken.None); return; } var definiteFailure = exception is OpenClawGatewayRpcException rpc && !rpc.Retryable; await CompleteFailureAsync( request, definiteFailure ? AgentProvisionRequestStates.Failed : AgentProvisionRequestStates.InDoubt, definiteFailure ? AgentProposalStates.Failed : AgentProposalStates.InDoubt, exception is OpenClawGatewayRpcException gatewayError ? NormalizeErrorCode(gatewayError.Code) : "create_dispatch_uncertain", definiteFailure ? SafeErrorMessage(exception) : "The agents.create outcome is uncertain. Nexus will not repeat it automatically.", CancellationToken.None); } } private async Task ReconcileOnlyAsync( AgentProvisionRequest request, CancellationToken cancellationToken) { var proposal = request.Proposal; var inventory = await TryReadAgentInventoryAsync(cancellationToken); if (!inventory.Success) { await CompleteFailureAsync( request, AgentProvisionRequestStates.InDoubt, AgentProposalStates.InDoubt, "reconciliation_unavailable", "Read-only reconciliation could not read agents.list. agents.create was not repeated.", cancellationToken); return; } var expectedAgentId = proposal.OpenClawAgentId ?? proposal.RequestedAgentId; var found = inventory.Items.SingleOrDefault(item => string.Equals( item.AgentId, expectedAgentId, StringComparison.Ordinal)); if (found is null) { await CompleteFailureAsync( request, AgentProvisionRequestStates.Failed, AgentProposalStates.Failed, "reconciled_absent", "Read-only reconciliation found no matching OpenClaw agent. agents.create was not repeated.", cancellationToken); return; } proposal.OpenClawAgentId = found.AgentId; proposal.OpenClawWorkspace = found.Workspace ?? proposal.Workspace; request.OpenClawAgentId = found.AgentId; Touch(proposal); Touch(request); await db.SaveChangesAsync(cancellationToken); await FinalizeFilesAsync(request, cancellationToken); } private async Task FinalizeFilesAsync( AgentProvisionRequest request, CancellationToken cancellationToken) { var proposal = request.Proposal; var agentId = request.OpenClawAgentId ?? proposal.OpenClawAgentId; if (string.IsNullOrWhiteSpace(agentId)) { await CompleteFailureAsync( request, AgentProvisionRequestStates.Failed, AgentProposalStates.Failed, "missing_agent_id", "Agent files cannot be finalized without a confirmed OpenClaw agent id.", cancellationToken); return; } var files = DeserializeFiles(proposal.StandardFilesJson); try { foreach (var file in files.OrderBy(item => Array.IndexOf( StandardFileNames, item.Key))) { var current = await agentConfiguration.GetAgentFileAsync( agentId, file.Key, cancellationToken); if (!current.Missing && string.Equals( current.ContentHash, Hash(file.Value), StringComparison.Ordinal)) { continue; } await agentConfiguration.SetAgentFileAsync( agentId, file.Key, new UpdateOpenClawAgentFileRequest( file.Value, current.ContentHash), OpenClawInvocationContext.Create( actor: request.Actor, idempotencyKey: $"agent-provision:{proposal.Id:N}:{file.Key}", correlationId: request.CorrelationId, traceParent: request.TraceParent, includeIdempotencyParameter: false), cancellationToken); } proposal.Status = AgentProposalStates.Ready; proposal.StandardFilesJson = "{}"; proposal.LastErrorCode = null; proposal.LastErrorMessage = null; proposal.CompletedAt = DateTimeOffset.UtcNow; request.Status = AgentProvisionRequestStates.Completed; request.LastErrorCode = null; request.LastErrorMessage = null; request.CompletedAt = DateTimeOffset.UtcNow; request.LeaseOwner = null; request.LeaseUntil = null; Touch(proposal); Touch(request); db.OutboxEvents.Add(Event( "agent.provision.completed", proposal, new { ProposalId = proposal.Id, proposal.OpenClawAgentId, proposal.Status, ProvisionRequestId = request.Id })); await db.SaveChangesAsync(cancellationToken); } catch (Exception exception) { logger.LogWarning( exception, "Agent file finalization failed for proposal {ProposalId}", proposal.Id); await CompleteFailureAsync( request, AgentProvisionRequestStates.Partial, AgentProposalStates.Partial, "file_verification_failed", "OpenClaw created the agent, but one or more requested files were not written and verified.", CancellationToken.None); } } private async Task CompleteFailureAsync( AgentProvisionRequest request, string requestState, string proposalState, string errorCode, string errorMessage, CancellationToken cancellationToken) { request.Status = requestState; request.LastErrorCode = NormalizeErrorCode(errorCode); request.LastErrorMessage = Truncate(errorMessage, 2000); request.CompletedAt = DateTimeOffset.UtcNow; request.LeaseOwner = null; request.LeaseUntil = null; request.Proposal.Status = proposalState; request.Proposal.LastErrorCode = request.LastErrorCode; request.Proposal.LastErrorMessage = request.LastErrorMessage; Touch(request); Touch(request.Proposal); db.OutboxEvents.Add(Event( "agent.provision.failed", request.Proposal, new { request.ProposalId, request.Id, RequestStatus = request.Status, ProposalStatus = request.Proposal.Status, ErrorCode = request.LastErrorCode })); await db.SaveChangesAsync(cancellationToken); } private async Task EvaluateGateAsync( CancellationToken cancellationToken) { var root = NormalizeWorkspaceRoot(provisioningOptions.Value.WorkspaceRoot); if (root is null) { return ProvisioningGate.Blocked( "workspace_root_invalid", "The server-managed OpenClaw workspace root is invalid.", "Configure OpenClawAgentProvisioning:WorkspaceRoot with an absolute or ~/ path."); } var requiredMethods = new[] { "agents.list", "agents.create", "agents.files.get", "agents.files.set", "config.get" }; foreach (var method in requiredMethods) { var decision = await writeGate.EvaluateAsync( method, "operator.admin", cancellationToken); if (!decision.Allowed) { return ProvisioningGate.Blocked( decision.State, decision.Message, decision.Recovery); } } return ProvisioningGate.Allowed(); } /// /// Mirrors OpenClaw's workspace rule for a newly created, non-default /// agent. A configured agents.defaults.workspace is treated as the parent /// directory; otherwise Nexus uses the server-only OpenClaw state root and /// OpenClaw's workspace-{agentId} fallback. Browser input is never used. /// private async Task ResolveWorkspaceForNewAgentAsync( string agentId, CancellationToken cancellationToken) { var snapshot = await agentConfiguration.GetConfigAsync(cancellationToken); if (!snapshot.Exists || !snapshot.Valid || snapshot.Config is null) { throw new InvalidOperationException( "OpenClaw config.get did not return a valid live configuration."); } var configuredDefault = ReadNestedString( snapshot.Config, "agents", "defaults", "workspace"); if (!string.IsNullOrWhiteSpace(configuredDefault)) { var defaultRoot = NormalizeWorkspaceRoot(configuredDefault); if (defaultRoot is null) { throw new InvalidOperationException( "OpenClaw agents.defaults.workspace is not a safe absolute or home-relative path."); } return $"{defaultRoot.TrimEnd('/')}/{agentId}"; } var stateRoot = NormalizeWorkspaceRoot( provisioningOptions.Value.WorkspaceRoot) ?? throw new InvalidOperationException( "The server-managed OpenClaw workspace root is invalid."); return $"{stateRoot.TrimEnd('/')}/workspace-{agentId}"; } public static string BuildCapabilityHash(IGatewayConnector gateway) => OpenClawWriteGate.BuildCapabilityHash(gateway); private async Task TryReadAgentInventoryAsync( CancellationToken cancellationToken) { if (connector.ConnectionState != GatewayConnectionState.Connected || !connector.Supports("agents.list") || !connector.GrantedScopes.Contains("operator.read")) { return new AgentInventoryRead(false, []); } try { var response = await connector.InvokeAsync( "agents.list", new JsonObject(), cancellationToken: cancellationToken); var items = ReadArray(response, "agents", "items") .Select(item => new AgentInventoryItem( ReadString(item, "id", "agentId") ?? string.Empty, ReadString(item, "workspace"))) .Where(item => !string.IsNullOrWhiteSpace(item.AgentId)) .ToArray(); return new AgentInventoryRead(true, items); } catch (Exception exception) { logger.LogDebug(exception, "OpenClaw agent inventory read failed"); return new AgentInventoryRead(false, []); } } private async Task> TryListModelsAsync( CancellationToken cancellationToken) { if (connector.ConnectionState != GatewayConnectionState.Connected || !connector.Supports("models.list") || !connector.GrantedScopes.Contains("operator.read")) { return []; } try { var response = await connector.InvokeAsync( "models.list", new JsonObject { ["view"] = "configured" }, cancellationToken: cancellationToken); return ReadArray(response, "models", "items") .Select(item => { var id = ReadString(item, "id") ?? string.Empty; var provider = ReadString(item, "provider") ?? id.Split('/', 2)[0]; return new AgentCreateModelOptionDto( id, ReadString(item, "name") ?? id, provider, ReadBool(item, "available") != false); }) .Where(item => !string.IsNullOrWhiteSpace(item.Id)) .OrderBy(item => item.Name, StringComparer.OrdinalIgnoreCase) .ToArray(); } catch (Exception exception) { logger.LogDebug(exception, "OpenClaw model options read failed"); return []; } } private async Task ReplayAsync( string operation, string keyHash, string requestHash, string correlationId, CancellationToken cancellationToken) { var existing = await db.OperationClaims .AsNoTracking() .SingleOrDefaultAsync( item => item.Operation == operation && item.IdempotencyKeyHash == keyHash, cancellationToken); if (existing is null) return null; if (!string.Equals(existing.RequestHash, requestHash, StringComparison.Ordinal)) { return new AgentProposalOperationDto( false, "idempotency_conflict", "The Idempotency-Key is already bound to another request.", null, "Use a new Idempotency-Key for a different operation.", correlationId, DateTimeOffset.UtcNow); } AgentProposal? proposal = null; if (existing.ResourceId is Guid resourceId) { proposal = await db.AgentProposals .AsNoTracking() .SingleOrDefaultAsync(item => item.Id == resourceId, cancellationToken); } var ok = existing.State == "completed"; return new AgentProposalOperationDto( ok, ok ? "idempotent_replay" : existing.ResultCode ?? existing.State, ok ? "The original operation result was returned without repeating side effects." : "The original rejected operation result was returned.", proposal is null ? null : Map(proposal, includeFileContent: false), null, correlationId, DateTimeOffset.UtcNow); } private async Task SaveMutationOrResolveRaceAsync( string operation, string keyHash, string requestHash, Guid proposalId, string correlationId, CancellationToken cancellationToken) { try { await db.SaveChangesAsync(cancellationToken); return null; } catch (DbUpdateConcurrencyException exception) { logger.LogInformation( exception, "Agent proposal {ProposalId} changed during {Operation}", proposalId, operation); return await ResolveMutationRaceAsync( operation, keyHash, requestHash, proposalId, correlationId, cancellationToken); } catch (DbUpdateException exception) when (exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) { logger.LogInformation( exception, "Agent proposal {ProposalId} encountered a uniqueness race during {Operation}", proposalId, operation); return await ResolveMutationRaceAsync( operation, keyHash, requestHash, proposalId, correlationId, cancellationToken); } } private async Task ResolveMutationRaceAsync( string operation, string keyHash, string requestHash, Guid proposalId, string correlationId, CancellationToken cancellationToken) { db.ChangeTracker.Clear(); var replay = await ReplayAsync( operation, keyHash, requestHash, correlationId, cancellationToken); if (replay is not null) return replay; var current = await db.AgentProposals .AsNoTracking() .SingleOrDefaultAsync( item => item.Id == proposalId, cancellationToken); return await RecordFailureClaimAsync( operation, keyHash, requestHash, proposalId, "concurrency_conflict", "Agent proposal changed while the operation was being persisted.", correlationId, cancellationToken, current, "Reload the proposal and retry with its current revision."); } private async Task RecordFailureClaimAsync( string operation, string keyHash, string requestHash, Guid resourceId, string state, string message, string correlationId, CancellationToken cancellationToken, AgentProposal? proposal = null, string? recovery = null) { db.OperationClaims.Add(Claim( operation, keyHash, requestHash, resourceId, "rejected", state)); await db.SaveChangesAsync(cancellationToken); return new AgentProposalOperationDto( false, state, message, proposal is null ? null : Map(proposal, includeFileContent: false), recovery, correlationId, DateTimeOffset.UtcNow); } private AgentProvisionRequest BuildProvisionRequest( AgentProposal proposal, string stage, OpenClawInvocationMetadata invocation, string idempotencyKeyHash) => new() { ProposalId = proposal.Id, Proposal = proposal, Attempt = proposal.ProvisionRequests.Select(item => item.Attempt).DefaultIfEmpty(0).Max() + 1, Stage = stage, Status = AgentProvisionRequestStates.Queued, IdempotencyKeyHash = idempotencyKeyHash, Actor = NormalizeActor(invocation.Actor), CorrelationId = Truncate(invocation.CorrelationId, 200), TraceParent = NormalizeOptional(invocation.TraceParent, 128), CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }; private static OperationClaim Claim( string operation, string keyHash, string requestHash, Guid? resourceId, string state, string? resultCode) => new() { Operation = operation, IdempotencyKeyHash = keyHash, RequestHash = requestHash, ResourceId = resourceId, State = state, ResultCode = resultCode, CompletedAt = DateTimeOffset.UtcNow, ExpiresAt = DateTimeOffset.UtcNow.AddDays(7) }; private static OutboxEvent Event( string type, AgentProposal proposal, object payload) => new() { Type = type, AggregateType = "agent-proposal", AggregateId = proposal.Id.ToString("N"), AggregateRevision = proposal.Revision, PayloadJson = JsonSerializer.Serialize(payload, InternalJsonOptions), OccurredAt = DateTimeOffset.UtcNow }; private static AgentProposalOperationDto Result( bool ok, string state, string message, AgentProposal proposal, string correlationId, string? recovery = null) => new( ok, state, message, Map(proposal, includeFileContent: false), recovery, correlationId, DateTimeOffset.UtcNow, new OperationResultDto( correlationId, state, proposal.Revision, new EntityRefDto( "agent-proposal", proposal.Id.ToString(), proposal.RequestedName), [], Activity.Current?.Id)); private static AgentProposalDto Map( AgentProposal proposal, bool includeFileContent) { var files = DeserializeFiles(proposal.StandardFilesJson) .OrderBy(item => Array.IndexOf(StandardFileNames, item.Key)) .Select(item => new AgentProposalFileDto( item.Key, Hash(item.Value), Encoding.UTF8.GetByteCount(item.Value), includeFileContent ? item.Value : null)) .ToArray(); var error = string.IsNullOrWhiteSpace(proposal.LastErrorCode) ? null : new AgentProposalErrorDto( proposal.LastErrorCode, proposal.LastErrorMessage ?? "Agent provisioning failed.", RecoveryFor(proposal.LastErrorCode)); return new AgentProposalDto( proposal.Id, proposal.Source, proposal.RequestedName, proposal.RequestedAgentId, proposal.Role, proposal.Description, proposal.Model, proposal.Emoji, proposal.Avatar, proposal.Workspace, files, proposal.Status, proposal.RequestedBy, proposal.ApprovedBy, proposal.RejectedBy, proposal.RejectionReason, proposal.OpenClawAgentId, proposal.OpenClawWorkspace, error, proposal.Revision, proposal.CreatedAt, proposal.UpdatedAt, proposal.ApprovedAt, proposal.RejectedAt, proposal.CompletedAt); } private NormalizedProposal NormalizeProposal(CreateAgentProposalRequest request) { var name = NormalizeRequired(request.Name, nameof(request.Name), MaxNameLength); var agentId = NormalizeAgentId(name); if (agentId == "main") { throw new AgentProposalValidationException( nameof(request.Name), "'main' is reserved by OpenClaw."); } var role = NormalizeOptional(request.Role, MaxRoleLength); var description = NormalizeOptional(request.Description, MaxDescriptionLength); var model = NormalizeOptional(request.Model, MaxModelLength); if (model is not null && !SafeModelPattern().IsMatch(model)) { throw new AgentProposalValidationException( nameof(request.Model), "Model must be a provider/model style identifier."); } var emoji = NormalizeOptional(request.Emoji, 32); var avatar = NormalizeOptional(request.Avatar, 2048); if (avatar?.StartsWith("data:", StringComparison.OrdinalIgnoreCase) == true) { throw new AgentProposalValidationException( nameof(request.Avatar), "Inline data avatars are not accepted in proposals."); } RejectLikelySecret(role, nameof(request.Role)); RejectLikelySecret(description, nameof(request.Description)); var files = new SortedDictionary(StringComparer.Ordinal); foreach (var entry in request.Files ?? new Dictionary()) { if (!CanonicalFileNames.TryGetValue(entry.Key?.Trim() ?? string.Empty, out var nameKey)) { throw new AgentProposalValidationException( nameof(request.Files), $"'{entry.Key}' is not an editable OpenClaw standard file."); } var size = Encoding.UTF8.GetByteCount(entry.Value ?? string.Empty); if (size > Math.Clamp( provisioningOptions.Value.MaxProposalFileBytes, 1024, 1_048_576)) { throw new AgentProposalValidationException( nameof(request.Files), $"{nameKey} exceeds the configured proposal file limit."); } RejectLikelySecret(entry.Value, $"{nameof(request.Files)}.{nameKey}"); files[nameKey] = entry.Value ?? string.Empty; } if (!files.ContainsKey("IDENTITY.md")) { files["IDENTITY.md"] = BuildIdentityFile(name, role, emoji); } if (!files.ContainsKey("AGENTS.md") && (role is not null || description is not null)) { files["AGENTS.md"] = BuildStandingOrdersFile(role, description); } var totalBytes = files.Sum(entry => Encoding.UTF8.GetByteCount(entry.Value)); if (totalBytes > Math.Clamp( provisioningOptions.Value.MaxProposalFilesBytes, 4096, 4_194_304)) { throw new AgentProposalValidationException( nameof(request.Files), "Combined proposal files exceed the configured limit."); } var filesJson = JsonSerializer.Serialize(files, InternalJsonOptions); var workspaceRoot = NormalizeWorkspaceRoot( provisioningOptions.Value.WorkspaceRoot) ?? throw new AgentProposalValidationException( "workspaceRoot", "The server-managed workspace root is invalid."); var workspace = $"{workspaceRoot.TrimEnd('/')}/workspace-{agentId}"; return new NormalizedProposal( name, agentId, role, description, model, emoji, avatar, workspace, filesJson, Hash(filesJson)); } private static string BuildIdentityFile( string name, string? role, string? emoji) { var lines = new List { "# Identity", string.Empty, $"- Name: {name}" }; if (!string.IsNullOrWhiteSpace(role)) lines.Add($"- Role: {role}"); if (!string.IsNullOrWhiteSpace(emoji)) lines.Add($"- Emoji: {emoji}"); return string.Join("\n", lines) + "\n"; } private static string BuildStandingOrdersFile( string? role, string? description) { var mission = description ?? role ?? "Follow the owner's explicit mission."; return $""" # Standing Orders ## Mission {mission} ## Approval boundaries - Ask the owner before destructive, irreversible, credential, deployment, or external communication actions. - Stay within the assigned workspace and granted OpenClaw capabilities. ## Verify and report - Verify material results before reporting completion. - Report blockers and uncertain side effects explicitly. """.Trim() + "\n"; } private static void RejectLikelySecret(string? value, string field) { if (string.IsNullOrWhiteSpace(value)) return; if (PrivateKeyPattern().IsMatch(value) || HighConfidenceTokenPattern().IsMatch(value)) { throw new AgentProposalValidationException( field, "Proposal content must not contain credentials or private keys."); } } private static string NormalizeSource(string source) { var normalized = source?.Trim().ToLowerInvariant(); return normalized switch { "manual" or "iris" or "mcp" => normalized, _ => throw new AgentProposalValidationException( nameof(source), "Proposal source must be manual, iris, or mcp.") }; } private static string NormalizeAgentId(string value) { var trimmed = value.Trim(); if (ValidAgentIdPattern().IsMatch(trimmed)) return trimmed.ToLowerInvariant(); var normalized = InvalidAgentIdCharactersPattern() .Replace(trimmed.ToLowerInvariant(), "-"); normalized = LeadingDashesPattern().Replace(normalized, string.Empty); normalized = TrailingDashesPattern().Replace(normalized, string.Empty); if (normalized.Length > 64) normalized = normalized[..64]; return string.IsNullOrWhiteSpace(normalized) ? "main" : normalized; } private static string? NormalizeWorkspaceRoot(string? root) { var normalized = root?.Trim().Replace('\\', '/').TrimEnd('/'); if (string.IsNullOrWhiteSpace(normalized) || normalized.Length > 1024 || normalized.Contains('\0') || normalized.Split('/', StringSplitOptions.RemoveEmptyEntries) .Any(segment => segment == "..")) { return null; } var rooted = normalized.StartsWith("/", StringComparison.Ordinal) || normalized.StartsWith("~/", StringComparison.Ordinal) || WindowsRootPattern().IsMatch(normalized); return rooted ? normalized : null; } private static string? ReadNestedString( JsonNode root, params string[] path) { JsonNode? current = root; foreach (var segment in path) { current = current is JsonObject obj ? obj[segment] : null; if (current is null) return null; } try { return current.GetValue()?.Trim(); } catch (InvalidOperationException) { return null; } } private static string NormalizeRequired( string? value, string field, int maxLength) { var normalized = value?.Trim(); if (string.IsNullOrWhiteSpace(normalized) || normalized.Length > maxLength || normalized.Contains('\0') || normalized.Contains('\r') || normalized.Contains('\n')) { throw new AgentProposalValidationException( field, $"{field} is required, single-line and limited to {maxLength} characters."); } return normalized; } private static string? NormalizeOptional(string? value, int maxLength) { var normalized = value?.Trim(); if (string.IsNullOrWhiteSpace(normalized)) return null; if (normalized.Length > maxLength || normalized.Contains('\0')) { throw new AgentProposalValidationException( "value", $"Value is limited to {maxLength} characters."); } return normalized; } private static string NormalizeActor(string? actor) => Truncate( string.IsNullOrWhiteSpace(actor) ? "authenticated-user" : actor.Trim(), 240); private static void ValidateInvocation(OpenClawInvocationMetadata invocation) { ArgumentNullException.ThrowIfNull(invocation); ValidateIdempotencyKey(invocation.IdempotencyKey); if (string.IsNullOrWhiteSpace(invocation.CorrelationId) || invocation.CorrelationId.Length > 200) { throw new AgentProposalValidationException( "X-Correlation-ID", "A correlation id with at most 200 characters is required."); } } private static void ValidateIdempotencyKey(string? key) { if (string.IsNullOrWhiteSpace(key) || key.Length > MaxIdempotencyKeyLength) { throw new AgentProposalValidationException( "Idempotency-Key", $"A non-empty idempotency key with at most {MaxIdempotencyKeyLength} characters is required."); } } private static void ValidateReason(string? reason) { if (reason?.Length > 1000 || reason?.Contains('\0') == true) { throw new AgentProposalValidationException( nameof(reason), "Reason must contain at most 1000 characters."); } } private static void Touch(AgentProposal proposal) { proposal.Revision++; proposal.UpdatedAt = DateTimeOffset.UtcNow; } private static void Touch(AgentProvisionRequest request) { request.Revision++; request.UpdatedAt = DateTimeOffset.UtcNow; } private static Dictionary DeserializeFiles(string json) { try { return JsonSerializer.Deserialize>( json, InternalJsonOptions) ?? []; } catch (JsonException) { return []; } } private static IReadOnlyList ReadArray( JsonNode? node, params string[] propertyNames) { if (node is JsonArray direct) return direct.Where(item => item is not null).Select(item => item!).ToArray(); if (node is not JsonObject obj) return []; foreach (var name in propertyNames) { if (obj[name] is JsonArray array) return array.Where(item => item is not null).Select(item => item!).ToArray(); } return []; } private static string? ReadString(JsonNode? node, params string[] propertyNames) { if (node is not JsonObject obj) return null; foreach (var name in propertyNames) { if (obj[name] is not JsonNode value) continue; try { var text = value.GetValue()?.Trim(); if (!string.IsNullOrWhiteSpace(text)) return text; } catch { } } return null; } private static bool? ReadBool(JsonNode? node, params string[] propertyNames) { if (node is not JsonObject obj) return null; foreach (var name in propertyNames) { try { if (obj[name] is JsonNode value) return value.GetValue(); } catch { } } return null; } private static string Hash(string value) => Convert.ToHexString( SHA256.HashData(Encoding.UTF8.GetBytes(value))) .ToLowerInvariant(); private static string SafeErrorMessage(Exception exception) => Truncate( exception is OpenClawGatewayRpcException gateway ? $"{gateway.Code}: {gateway.Message}" : exception.Message, 2000); private static string NormalizeErrorCode(string value) { var normalized = new string(value .Trim() .ToLowerInvariant() .Select(character => char.IsAsciiLetterOrDigit(character) || character == '_' ? character : '_') .Take(120) .ToArray()); return string.IsNullOrWhiteSpace(normalized) ? "unknown_error" : normalized; } private static string Truncate(string value, int length) => value.Length <= length ? value : value[..length]; private static string? RecoveryFor(string errorCode) => errorCode switch { "experimental_blocked" => "Wait for an officially supported external Nexus client identity.", "management_disabled" => "An owner must enable management for the adopted primary profile.", "scope_upgrade_required" => "Approve operator.admin in OpenClaw and re-verify the connection.", "capability_drift" => "Re-verify the OpenClaw profile before issuing another write.", "create_dispatch_uncertain" or "create_dispatch_cancelled" or "process_interrupted" => "Request a read-only reconciliation. Do not call agents.create again yet.", "file_verification_failed" => "Retry file finalization; the OpenClaw agent must not be recreated.", _ => null }; private static string EncodeCursor(DateTimeOffset createdAt, Guid id) => Convert.ToBase64String( Encoding.UTF8.GetBytes( $"{createdAt.UtcTicks.ToString(CultureInfo.InvariantCulture)}|{id:N}")); private static bool TryDecodeCursor( string value, out DateTimeOffset beforeCreatedAt, out Guid? beforeId) { beforeCreatedAt = default; beforeId = null; try { var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(value)); var parts = decoded.Split('|', 2); if (!long.TryParse( parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var ticks)) return false; if (parts.Length == 2) { if (!Guid.TryParseExact(parts[1], "N", out var id)) return false; beforeId = id; } beforeCreatedAt = new DateTimeOffset(ticks, TimeSpan.Zero); return true; } catch { return false; } } [GeneratedRegex("^[a-z0-9][a-z0-9_-]{0,63}$", RegexOptions.IgnoreCase)] private static partial Regex ValidAgentIdPattern(); [GeneratedRegex("[^a-z0-9_-]+")] private static partial Regex InvalidAgentIdCharactersPattern(); [GeneratedRegex("^-+")] private static partial Regex LeadingDashesPattern(); [GeneratedRegex("-+$")] private static partial Regex TrailingDashesPattern(); [GeneratedRegex("^[a-zA-Z0-9][a-zA-Z0-9._:/-]{0,239}$")] private static partial Regex SafeModelPattern(); [GeneratedRegex("^(?:/|~/|[a-zA-Z]:/)")] private static partial Regex WindowsRootPattern(); [GeneratedRegex( "-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----", RegexOptions.IgnoreCase)] private static partial Regex PrivateKeyPattern(); [GeneratedRegex( @"(? Items); private sealed record ProvisioningGate( bool Ok, string State, string? Message, string? Recovery) { public static ProvisioningGate Allowed() => new(true, "ready", null, null); public static ProvisioningGate Blocked( string state, string message, string? recovery) => new(false, state, message, recovery); } }