2178 lines
76 KiB
C#
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);
|
|
}
|
|
}
|