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

2178 lines
76 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
public sealed partial class AgentProposalService(
NexusDbContext db,
IGatewayConnector connector,
IOpenClawAgentConfigurationService agentConfiguration,
IOpenClawWriteGate writeGate,
IOptions<AgentProvisioningOptions> provisioningOptions,
AgentProvisioningSignal signal,
ILogger<AgentProposalService> 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<string, string> CanonicalFileNames =
StandardFileNames.ToDictionary(
name => name,
name => name,
StringComparer.OrdinalIgnoreCase);
private static readonly JsonSerializerOptions InternalJsonOptions = new(
JsonSerializerDefaults.Web);
public async Task<AgentCreateOptionsDto> 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<AgentProposalCollectionDto> 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<AgentProposalDto?> 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<AgentProposalOperationDto> 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<AgentProposalOperationDto> ApproveAsync(
Guid id,
AgentProposalActionRequest request,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default)
=> MutateApprovalAsync(
id,
request,
invocation,
"agent-proposal.approve",
cancellationToken,
approve: true);
public Task<AgentProposalOperationDto> RejectAsync(
Guid id,
AgentProposalActionRequest request,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default)
=> MutateApprovalAsync(
id,
request,
invocation,
"agent-proposal.reject",
cancellationToken,
approve: false);
public async Task<AgentProposalOperationDto> 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<bool> 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<string, object?>("stage", request.Stage),
new KeyValuePair<string, object?>("outcome", outcome));
}
}
private async Task<AgentProvisionRequest?> ClaimNextRequestAsync(
CancellationToken cancellationToken)
{
IDbContextTransaction? transaction = null;
try
{
var now = DateTimeOffset.UtcNow;
IQueryable<AgentProvisionRequest> 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<AgentProposalOperationDto> 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<ProvisioningGate> 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();
}
/// <summary>
/// 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.
/// </summary>
private async Task<string> 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<AgentInventoryRead> 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<IReadOnlyList<AgentCreateModelOptionDto>> 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<AgentProposalOperationDto?> 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<AgentProposalOperationDto?> 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<AgentProposalOperationDto> 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<AgentProposalOperationDto> 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<string, string>(StringComparer.Ordinal);
foreach (var entry in request.Files ?? new Dictionary<string, string>())
{
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<string>
{
"# 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<string>()?.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<string, string> DeserializeFiles(string json)
{
try
{
return JsonSerializer.Deserialize<Dictionary<string, string>>(
json,
InternalJsonOptions)
?? [];
}
catch (JsonException)
{
return [];
}
}
private static IReadOnlyList<JsonNode> 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<string>()?.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<bool>();
}
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(
@"(?<![a-zA-Z0-9])(?:sk-[a-zA-Z0-9_-]{20,}|gh[pousr]_[a-zA-Z0-9]{20,}|xox[baprs]-[a-zA-Z0-9-]{20,})(?![a-zA-Z0-9])",
RegexOptions.IgnoreCase)]
private static partial Regex HighConfidenceTokenPattern();
private sealed record NormalizedProposal(
string Name,
string AgentId,
string? Role,
string? Description,
string? Model,
string? Emoji,
string? Avatar,
string Workspace,
string FilesJson,
string FilesHash);
private sealed record AgentInventoryItem(
string AgentId,
string? Workspace);
private sealed record AgentInventoryRead(
bool Success,
IReadOnlyList<AgentInventoryItem> 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);
}
}